Commit 7cb0048d by luoqi

feat(web): 患者带性别年龄 + 拖拽自动滚动 + 输入框加回边框/回到底部 + 去掉三句依据

产品走查四条:

① **输入框加回边框**(上一版"无边框"太散,找不到落点):外层仍与会话同底、无分隔线,
   输入块自己有边框。同时补一个**「回到底部」**圆钮 —— 只在没贴底时出现,浮在输入框
   正上方居中(对齐 Claude Code)。️ 判据复用 onScroll 里那个 80px 阈值, 不另写一个:
   两个阈值不一致会出现"按钮还在、其实已经到底"这种解释不清的状态。
   为此把 stickRef 加了个可渲染副本 atBottom(ref 改了不触发重渲染)。

② 患者条目补**性别·年龄**,与患者详情卡片同口径(formatGender + calcAge)。
   ️ 年龄**读时算**(存的是 birthDate),两处各写一遍必然在生日当天差一岁。

③ 🔴 **拖拽自动滚动**:HTML5 DnD 不会自己滚容器,助手窗一屏只放得下三四个客服,
   拖到边缘就卡住 —— 想丢给下面的人基本不可能。现在拖动中指针进入上/下 56px 感应带
   就按 rAF 持续滚。️ 滚的是**离卡片最近的可滚动祖先**(运行时找),不是 window:
   写死 window 在最大化/非最大化两种形态下会滚错东西。

④ 卡片底部那三句(selectionNote/basisNote/rosterNote)撤掉展示 ——
   "规则拼成的,用户不需要知道"。️ 三句话没删:助手在消息正文里已经原话转述过一遍,
   卡片再贴一次是同一段出现两遍。 服务端那三个字段别删,助手要靠它们照抄。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 1d7041e6
...@@ -9,6 +9,7 @@ import { ...@@ -9,6 +9,7 @@ import {
type ProposedItem, type ProposedItem,
type TemperatureValue, type TemperatureValue,
} from '@pac/types'; } from '@pac/types';
import { calcAge } from '@pac/utils';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator'; import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import { AgentRosterService } from './agent-roster.service'; import { AgentRosterService } from './agent-roster.service';
...@@ -221,11 +222,16 @@ export class AssignmentProposalService { ...@@ -221,11 +222,16 @@ export class AssignmentProposalService {
// 让它认识"患者叫什么"只会让它更难测,而对分法没有任何影响。 // 让它认识"患者叫什么"只会让它更难测,而对分法没有任何影响。
const items = { const items = {
unplaced: raw.unplaced, unplaced: raw.unplaced,
placed: raw.placed.map((it) => ({ placed: raw.placed.map((it) => {
const p = info.get(it.patientId);
return {
...it, ...it,
patientName: info.get(it.patientId)?.name ?? null, patientName: p?.name ?? null,
medicalRecordNumber: info.get(it.patientId)?.mrn ?? null, medicalRecordNumber: p?.mrn ?? null,
})), gender: p?.gender ?? null,
age: p?.age ?? null,
};
}),
}; };
const byAgent = new Map<string, ProposedItem[]>(); const byAgent = new Map<string, ProposedItem[]>();
...@@ -423,20 +429,34 @@ export class AssignmentProposalService { ...@@ -423,20 +429,34 @@ export class AssignmentProposalService {
*/ */
private async patientsOf(patientIds: string[]): Promise<{ private async patientsOf(patientIds: string[]): Promise<{
dedicated: Map<string, string>; dedicated: Map<string, string>;
info: Map<string, { name: string | null; mrn: string | null }>; info: Map<string, PatientBrief>;
}> { }> {
const ids = [...new Set(patientIds)]; const ids = [...new Set(patientIds)];
if (ids.length === 0) return { dedicated: new Map(), info: new Map() }; if (ids.length === 0) return { dedicated: new Map(), info: new Map() };
const rows = await this.prisma.patient.findMany({ const rows = await this.prisma.patient.findMany({
where: { id: { in: ids } }, where: { id: { in: ids } },
select: { id: true, preferences: true, name: true, medicalRecordNumber: true }, select: {
id: true,
preferences: true,
name: true,
medicalRecordNumber: true,
gender: true,
birthDate: true,
},
}); });
const dedicated = new Map<string, string>(); const dedicated = new Map<string, string>();
const info = new Map<string, { name: string | null; mrn: string | null }>(); const info = new Map<string, PatientBrief>();
for (const r of rows) { for (const r of rows) {
const id = (r.preferences as { dedicatedCs?: { id?: string } } | null)?.dedicatedCs?.id; const id = (r.preferences as { dedicatedCs?: { id?: string } } | null)?.dedicatedCs?.id;
if (typeof id === 'string' && id) dedicated.set(r.id, id); if (typeof id === 'string' && id) dedicated.set(r.id, id);
info.set(r.id, { name: r.name ?? null, mrn: r.medicalRecordNumber ?? null }); info.set(r.id, {
name: r.name ?? null,
mrn: r.medicalRecordNumber ?? null,
gender: r.gender ?? null,
// ⚠️ 存的是生日不是年龄 —— 年龄必须**读时算**(与列表页 plan.service 同一个 calcAge),
// 两处各写一遍必然在生日当天差一岁
age: r.birthDate ? calcAge(r.birthDate) : null,
});
} }
return { dedicated, info }; return { dedicated, info };
} }
...@@ -446,7 +466,15 @@ export class AssignmentProposalService { ...@@ -446,7 +466,15 @@ export class AssignmentProposalService {
* 落人算法的产出 —— ⛔ **不含患者姓名**:那是身份信息,与"分给谁"这件事无关, * 落人算法的产出 —— ⛔ **不含患者姓名**:那是身份信息,与"分给谁"这件事无关,
* 由 propose() 在算完之后贴上(见那里的注释)。 * 由 propose() 在算完之后贴上(见那里的注释)。
*/ */
type PlacedItem = Omit<ProposedItem, 'patientName' | 'medicalRecordNumber'>; type PlacedItem = Omit<ProposedItem, 'patientName' | 'medicalRecordNumber' | 'gender' | 'age'>;
/** 确认单要展示的患者身份(与患者详情卡片同口径) */
interface PatientBrief {
name: string | null;
mrn: string | null;
gender: string | null;
age: number | null;
}
/** /**
* ② 落人:**专属优先 → 无主补空 → 有主改派**,三趟共用同一本水位账。 * ② 落人:**专属优先 → 无主补空 → 有主改派**,三趟共用同一本水位账。
......
'use client'; 'use client';
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Check, ChevronRight, GripVertical, Loader2, Users, X } from 'lucide-react'; import { Check, ChevronRight, GripVertical, Loader2, Users, X } from 'lucide-react';
import { import {
ASSIGNMENT_EXPIRES_DAYS_PRESETS, ASSIGNMENT_EXPIRES_DAYS_PRESETS,
...@@ -32,7 +32,7 @@ import { ...@@ -32,7 +32,7 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { cn } from '@/lib/utils'; import { cn, formatGender } from '@/lib/utils';
/** /**
* 全景确认单 —— 主管**一次确认**就落库的那张卡片。 * 全景确认单 —— 主管**一次确认**就落库的那张卡片。
...@@ -159,6 +159,36 @@ export function AssignmentConfirmSheet({ ...@@ -159,6 +159,36 @@ export function AssignmentConfirmSheet({
const [pendingRemove, setPendingRemove] = useState<string | null>(null); const [pendingRemove, setPendingRemove] = useState<string | null>(null);
/// 拖拽悬停中的目标客服(高亮用) /// 拖拽悬停中的目标客服(高亮用)
const [dragOver, setDragOver] = useState<string | null>(null); const [dragOver, setDragOver] = useState<string | null>(null);
/**
* 🔴 拖拽时的**自动滚动**。
*
* 助手窗里一屏只放得下三四个客服,而 HTML5 DnD **不会**自己滚容器 ——
* 主管拖着一条想丢给下面的人,拖到边缘就卡住了,只能先滚一段再拖,基本等于不可用。
* 这里在拖动过程中监听指针的 Y 位置:进入上/下 `EDGE` 像素带就按 rAF 持续滚。
* ⚠️ 滚的是**离条目最近的可滚动祖先**(助手窗的消息区),不是 window ——
* 写死 window 在最大化/非最大化两种形态下会滚错东西。
*/
const scrollerRef = useRef<HTMLElement | null>(null);
const autoScroll = useRef<{ dir: number; raf: number } | null>(null);
const stopAutoScroll = () => {
if (autoScroll.current) cancelAnimationFrame(autoScroll.current.raf);
autoScroll.current = null;
};
const onDragOverAnywhere = (e: React.DragEvent) => {
const sc = scrollerRef.current;
if (!sc) return;
const r = sc.getBoundingClientRect();
const EDGE = 56; // 边缘感应带
const dir = e.clientY < r.top + EDGE ? -1 : e.clientY > r.bottom - EDGE ? 1 : 0;
if (dir === 0) return stopAutoScroll();
if (autoScroll.current?.dir === dir) return; // 同方向already在滚
stopAutoScroll();
const step = () => {
sc.scrollTop += dir * 12;
autoScroll.current = { dir, raf: requestAnimationFrame(step) };
};
autoScroll.current = { dir, raf: requestAnimationFrame(step) };
};
const done = state === 'confirmed'; const done = state === 'confirmed';
const readOnly = done || submitting; const readOnly = done || submitting;
...@@ -271,8 +301,26 @@ export function AssignmentConfirmSheet({ ...@@ -271,8 +301,26 @@ export function AssignmentConfirmSheet({
const removingGroup = groups.find((g) => g.userId === pendingRemove); const removingGroup = groups.find((g) => g.userId === pendingRemove);
/// 找到离卡片最近的可滚动祖先(助手窗的消息区),拖拽自动滚动用
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let el = rootRef.current?.parentElement ?? null;
while (el) {
const oy = getComputedStyle(el).overflowY;
if ((oy === 'auto' || oy === 'scroll') && el.scrollHeight > el.clientHeight) break;
el = el.parentElement;
}
scrollerRef.current = el;
}, []);
return ( return (
<Card className="overflow-hidden border-brand-100 text-[12px] shadow-sm"> <Card
ref={rootRef}
onDragOver={onDragOverAnywhere}
onDragEnd={stopAutoScroll}
onDrop={stopAutoScroll}
className="overflow-hidden border-brand-100 text-[12px] shadow-sm"
>
{/* ① 汇总 */} {/* ① 汇总 */}
<div className="flex items-center gap-2 border-b border-slate-100 bg-brand-50/50 px-3 py-2"> <div className="flex items-center gap-2 border-b border-slate-100 bg-brand-50/50 px-3 py-2">
<Users className="h-3.5 w-3.5 flex-none text-brand-600" /> <Users className="h-3.5 w-3.5 flex-none text-brand-600" />
...@@ -392,6 +440,12 @@ export function AssignmentConfirmSheet({ ...@@ -392,6 +440,12 @@ export function AssignmentConfirmSheet({
{i.medicalRecordNumber} {i.medicalRecordNumber}
</span> </span>
)} )}
{/* 性别·年龄 —— 与患者详情卡片同一种写法(formatGender 归一宿主的各种取值) */}
{(i.gender || i.age != null) && (
<span className="ml-1 text-[10px] tabular-nums text-slate-400">
{formatGender(i.gender)}·{i.age ?? '?'}
</span>
)}
</span> </span>
{/* ⛔ 只标「专属」,不标「铺平」—— 后者是算法词,主管看不懂。 {/* ⛔ 只标「专属」,不标「铺平」—— 后者是算法词,主管看不懂。
没标的就是"不是他的老客户",反而更好读。 */} 没标的就是"不是他的老客户",反而更好读。 */}
...@@ -484,12 +538,11 @@ export function AssignmentConfirmSheet({ ...@@ -484,12 +538,11 @@ export function AssignmentConfirmSheet({
</p> </p>
</div> </div>
{/* 依据 —— 成品句子原样展示,不在前端改写 */} {/* ⛔ 这里原来把 selectionNote / basisNote / rosterNote 三句原样贴了一遍 —— 撤掉
<div className="space-y-0.5 border-t border-slate-100 bg-slate-50/50 px-3 py-2 text-[10.5px] leading-relaxed text-slate-500"> (2026-08-03 走查:"规则拼成的,用户不需要知道")。
<p>{sheet.selectionNote}</p> ⚠️ 三句话**没有删**,只是不在卡片上重复:助手在消息正文里已经原话转述了一遍
<p>{sheet.basisNote}</p> (T14 双保险的"提示词"那一半),卡片再贴一次是同一段话出现两遍。
<p>{sheet.rosterNote}</p> ⛔ 别把服务端那三个字段也删了 —— 助手要靠它们照抄。 */}
</div>
{/* ④ 确认 */} {/* ④ 确认 */}
<div className="border-t border-slate-100 px-3 py-2"> <div className="border-t border-slate-100 px-3 py-2">
......
...@@ -486,6 +486,9 @@ export function AssistantChat({ ...@@ -486,6 +486,9 @@ export function AssistantChat({
const taRef = useRef<HTMLTextAreaElement>(null); const taRef = useRef<HTMLTextAreaElement>(null);
// 是否"贴底":用户在底部附近时跟随自动滚;一旦上滚阅读则停,直到滚回底部。 // 是否"贴底":用户在底部附近时跟随自动滚;一旦上滚阅读则停,直到滚回底部。
const stickRef = useRef(true); const stickRef = useRef(true);
/// ⭐ 同一件事的**可渲染**副本 —— ref 改了不触发重渲染,而"回到底部"按钮要跟着显隐。
/// ⚠️ 两者必须在同一处更新(见 onScroll),分开写必然漂。
const [atBottom, setAtBottom] = useState(true);
// 流式/新消息时,仅在贴底状态才滚到底(否则尊重用户当前阅读位置)。 // 流式/新消息时,仅在贴底状态才滚到底(否则尊重用户当前阅读位置)。
useEffect(() => { useEffect(() => {
...@@ -497,7 +500,16 @@ export function AssistantChat({ ...@@ -497,7 +500,16 @@ export function AssistantChat({
const onScroll = () => { const onScroll = () => {
const el = scrollRef.current; const el = scrollRef.current;
if (!el) return; if (!el) return;
stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80; const near = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
stickRef.current = near;
setAtBottom(near);
};
const toBottom = () => {
const el = scrollRef.current;
if (!el) return;
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
stickRef.current = true;
setAtBottom(true);
}; };
// 发起新提问 → 重新贴底(让用户看到自己的消息 + 回答)。 // 发起新提问 → 重新贴底(让用户看到自己的消息 + 回答)。
...@@ -710,9 +722,23 @@ export function AssistantChat({ ...@@ -710,9 +722,23 @@ export function AssistantChat({
{/* Composer —— 与会话区**同底色、无边框、无分隔线**(2026-08-03 走查,对齐 Claude Code 那种)。 {/* Composer —— 与会话区**同底色、无边框、无分隔线**(2026-08-03 走查,对齐 Claude Code 那种)。
⚠️ 底色写死 `bg-slate-50` 就会跟根容器漂;这里直接 `bg-inherit`, ⚠️ 底色写死 `bg-slate-50` 就会跟根容器漂;这里直接 `bg-inherit`,
根容器换底色时它自动跟上。 */} 根容器换底色时它自动跟上。 */}
<div className="flex-none bg-inherit"> <div className="relative flex-none bg-inherit">
{/* ⭐ 「回到底部」—— 只在**没贴底**时出现,浮在输入框正上方居中(对齐 Claude Code)。
⚠️ 判据复用 onScroll 里那个 80px 阈值,⛔ 别再写第二个 —— 两个阈值不一致时,
会出现"按钮还在、但其实已经到底了"这种解释不清的状态。 */}
{!atBottom && messages.length > 0 && (
<button
type="button"
onClick={toBottom}
title="回到最新"
className="absolute -top-4 left-1/2 z-10 inline-flex h-7 w-7 -translate-x-1/2 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-500 shadow-md transition-colors hover:bg-slate-50 hover:text-slate-700"
>
<ChevronDown className="h-4 w-4" />
</button>
)}
<div className="mx-auto max-w-3xl px-4 pb-3 pt-1"> <div className="mx-auto max-w-3xl px-4 pb-3 pt-1">
<div className="flex items-end gap-2 px-1"> {/* 输入框**有边框**(与会话同底,但自己成一块) —— 无边框那版太散,找不到落点 */}
<div className="flex items-end gap-2 rounded-xl border border-slate-200 bg-white px-3 py-2 transition-colors focus-within:border-slate-300">
<textarea <textarea
ref={taRef} ref={taRef}
rows={1} rows={1}
......
...@@ -279,6 +279,10 @@ export const ProposedItemSchema = z.object({ ...@@ -279,6 +279,10 @@ export const ProposedItemSchema = z.object({
patientName: z.string().nullable(), patientName: z.string().nullable(),
/// 病历号(宿主不一定给:jvs-dw 91% / friday 63%),缺就不占位 /// 病历号(宿主不一定给:jvs-dw 91% / friday 63%),缺就不占位
medicalRecordNumber: z.string().nullable(), medicalRecordNumber: z.string().nullable(),
/// ⭐ 性别 / 年龄 —— 与患者详情卡片**同口径**(gender 是原始值,前端用 formatGender 归一)。
/// 主管扫这一列时看的是"这批人大概是谁",光有姓名不够(同名、判断人群构成都要它)
gender: z.string().nullable(),
age: z.number().int().nullable(),
assigneeUserId: z.string(), assigneeUserId: z.string(),
assignStrategy: AssignStrategySchema, assignStrategy: AssignStrategySchema,
selectionMode: z.enum(['rank', 'explore']), selectionMode: z.enum(['rank', 'explore']),
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment