Commit 77bf6bd1 by luoqi

feat(web): 确认单 —— 待分配组、姓名可复制、重排按钮、同批指令不再互相覆盖

- 「待分配」单列一组(amber + 顶部粗边),可拖可删可收拢、默认展开,
  带医生列与已分配一致; 不处理就是不分,文案必须说清
- 顶栏「共 N 人 · 拟分 X(Y 位客服) · 待分配 Z 人」—— 先给总数再拆,
  光写「拟分 21 · 待分配 39」读不出 21 含不含 39
- 患者/客服姓名点击复制(draggable=false + stopPropagation,否则吃掉整行拖拽)
- 重排后卡片自己交代换了什么 —— 重排走 HTTP 只换卡片,助手不会重新说一遍,
  不交代就是"上面写着 34 人要您定、卡片显示 0 待分配"两份打架的口径
- 🔴 同批指令后一条读不到前一条:「李汝明给李闻,剩下的都给高瑞珍」——
  第二条读的还是旧 state,李汝明被连同其余 31 人一起铺给了高瑞珍,
  而两条都回报"已执行"。改成 moveDraft/dropDraft 草稿,最后一次性提交
- findPatients 要搜到「待分配」、findAgent 要搜到「本批未分到」——
  卡片上看得见的人,指令就必须能指到
- 拖给「未分到」的客服后组标题不再退化成 #678 · 在手 0
- 铺平算水位时排除本次要改派的这批(否则算了两遍)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent a07b3691
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Check, ChevronRight, GripVertical, Loader2, Undo2, Users, X } from 'lucide-react';
import { Check, ChevronDown, ChevronRight, GripVertical, Loader2, Undo2, Users, X } from 'lucide-react';
import {
ASSIGNMENT_EXPIRES_DAYS_PRESETS,
potentialTreatmentItemName,
AssignStrategy,
REVOKE_WINDOW_MINUTES,
Permission,
......@@ -77,6 +78,51 @@ interface EffectiveItem extends ProposedItem {
* ⚠️ 名额上限必须原样带走:少带一次,主管上次设的「李莉这批最多 5 条」
* 就在这一次确认里被悄悄清掉了,而界面上完全看不出来。
*/
/**
* 助手编辑动作的中文名 —— 只用在**拒绝**的那句话里(「已经确认了,『改时效』改不了」)。
* ⚠️ 拒绝必须说清拒的是**哪一件事**:一句笼统的"改不了"会让模型下一轮换个说法再试一遍。
*/
const OP_ZH: Record<SheetEditOp['action'], string> = {
assign: '改派',
remove: '移出本批',
set_expiry: '改时效',
set_benefit: '改福利',
};
/**
* 患者姓名 —— **点一下复制**(2026-08-06 产品加)。
*
* ⚠️ 主管审这批人时常要把名字贴到别处(问医生、查宿主档案、发消息),
* 在一个 400px 的列表里手动选中一个中文名很难选准。
* ⚠️ `stopPropagation` 是必须的:这一行同时是**折叠开关的兄弟**且整行可拖,
* 不拦住会连带触发别的行为。
* ⚠️ `draggable={false}`:不加的话按住名字拖会被浏览器当成拖文本,把整行的拖拽吃掉。
* ⛔ 复制的是**姓名本身**,不含病历号/性别年龄 —— 那些是给眼睛看的上下文,
* 粘出去多带一串反而要手动删。
*/
function CopyableName({ name }: { name: string }) {
const [copied, setCopied] = useState(false);
return (
<button
type="button"
draggable={false}
title="点击复制姓名"
onClick={(e) => {
e.stopPropagation();
void navigator.clipboard?.writeText(name);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
}}
className={cn(
'rounded px-0.5 transition-colors',
copied ? 'bg-emerald-50 text-emerald-700' : 'hover:bg-brand-50 hover:text-brand-700',
)}
>
{copied ? '已复制' : name}
</button>
);
}
function carryOverrides(fromSheet: Record<string, AgentOverride>): Record<string, AgentOverride> {
const out: Record<string, AgentOverride> = {};
for (const [userId, o] of Object.entries(fromSheet)) {
......@@ -91,6 +137,19 @@ function carryOverrides(fromSheet: Record<string, AgentOverride>): Record<string
* 出现几百次,样式必须跟卡片其余部分一致。
* ⚠️ 值域来自 `ASSIGNMENT_EXPIRES_DAYS_PRESETS`(共享常量),⛔ 别在这里内联 [1..7]。
*/
/**
* 时效下拉 —— **点了才挂真正的 Radix Select**。
*
* 🔴 由来(2026-08-05 实测):原来每一行直接渲染一个 `<Select>`。展开一个 36 条的客服组
* = 一次性挂 36 个 Radix Select,每个都带 portal / 焦点管理 / 键盘导航,
* 展开那一下明显卡顿。而这些下拉**绝大多数根本不会被点** —— 主管一批 200 人,
* 真正逐条改时效的是个位数。
*
* ✅ 平时只是一个 `<button>`(样式与 SelectTrigger 一致),点击才换成真 Select 并自动展开。
* ⚠️ `defaultOpen` 是关键:换过去之后要立刻弹开,否则用户得点两下。
* ⚠️ 收起后**不还原成按钮** —— 还原会让"刚改完又想再改"变成再等一次挂载,
* 而且已经挂上的那一个不再是成本问题(卡的是 36 个同时挂)。
*/
function DaySelect({
value,
disabled,
......@@ -103,18 +162,36 @@ function DaySelect({
/** 条目行用紧凑版(只显示数字,省掉"天"字省宽度) */
compact?: boolean;
}) {
const [live, setLive] = useState(false);
const cls = cn('h-6 gap-1 px-1.5 text-[11px]', compact ? 'w-[54px]' : 'w-[76px]');
if (!live) {
return (
<button
type="button"
disabled={disabled}
onClick={() => setLive(true)}
// 样式与 SelectTrigger 对齐 —— 换过去时不许有跳动
className={cn(
cls,
'inline-flex items-center justify-between rounded-md border border-input bg-transparent',
'shadow-xs transition-colors hover:bg-accent/50 disabled:cursor-not-allowed disabled:opacity-50',
)}
>
<span>{compact ? value : `${value} 天`}</span>
<ChevronDown className="h-3 w-3 opacity-50" />
</button>
);
}
return (
<Select
value={String(value)}
disabled={disabled}
defaultOpen
onValueChange={(v) => onChange(Number(v))}
>
<SelectTrigger
className={cn(
'h-6 gap-1 px-1.5 text-[11px]',
compact ? 'w-[54px]' : 'w-[76px]',
)}
>
<SelectTrigger className={cls}>
<SelectValue />
</SelectTrigger>
{/* z 层级已在 ui/select.tsx 统一抬到 70(助手窗是 z-60,见那里的层级约定) */}
......@@ -137,6 +214,7 @@ export function AssignmentConfirmSheet({
onConfirmed,
edits,
onEditApplied,
onSheetReplaced,
}: {
requestId: string;
sheet: AssignmentProposal;
......@@ -152,6 +230,11 @@ export function AssignmentConfirmSheet({
edits?: { seq: number; ops: SheetEditOp[] }[];
/** 执行结果回一句进对话(既给主管看,也补进模型上下文 —— 否则它不知道到底成没成) */
onEditApplied?: (summary: string) => void;
/**
* 「重新排一版」返回的新提案 —— 由外层把消息流里那个 block 换掉。
* ⚠️ 换的是**整张卡**:人群没变、人换了,主管在旧卡上做的删/改派对新名单没有意义。
*/
onSheetReplaced?: (next: AssignmentProposal) => void;
}) {
/**
* 批次默认时效 —— 初值来自**提案**(沿用主管上一次的值),⛔ 不是写死的 3。
......@@ -190,6 +273,10 @@ export function AssignmentConfirmSheet({
* ⚠️ 窗口按**确认那一刻**算(confirmedAt),⛔ 不能用提案生成时间:
* 主管可能盯着确认单改了二十分钟才点确认,那二十分钟不该算进他的补救窗口。
*/
/// 「待分配」是否展开。⭐ **默认展开** —— 它的存在就是为了引起注意,默认藏起来等于白做
const [pendingOpen, setPendingOpen] = useState(true);
/// 「重新排一版」进行中(按钮转圈,防连点)
const [refilling, setRefilling] = useState(false);
const [confirmedAt, setConfirmedAt] = useState<number | null>(null);
const [revoked, setRevoked] = useState<RevokeAssignmentResponse | null>(null);
const [revoking, setRevoking] = useState(false);
......@@ -265,8 +352,8 @@ export function AssignmentConfirmSheet({
* ⛔ 别再直接用 `sheet.byAgent` —— 它是**提案时**的分组,拖过一条就对不上了。
*/
const items = useMemo<EffectiveItem[]>(
() =>
sheet.items
() => [
...sheet.items
.filter((i) => !dropped.has(i.planId))
.map((i) => ({
...i,
......@@ -274,7 +361,43 @@ export function AssignmentConfirmSheet({
days: dayByPlan[i.planId] ?? expiresInDays,
moved: moveByPlan[i.planId] != null && moveByPlan[i.planId] !== i.assigneeUserId,
})),
[sheet.items, dropped, moveByPlan, dayByPlan, expiresInDays],
/**
* ⭐ 「待分配」里**被主管拖给某人**的那几条 —— 从这一刻起它们就是正常条目。
*
* ⚠️ 没拖过的**不进来**:待分配的语义就是"没有经办人",不处理就不落库。
* ⛔ 别在这里给个默认经办人兜底 —— 那等于把刚拿掉的自动改派偷偷加回来。
* ⚠️ 一律标 `MANUAL`:这是**主管的决定**,不是算法的。事后反推「专属 vs 铺平」
* 效果差异时,把人的决定混进算法策略里会直接污染结论(T20)。
*/
...(sheet.pending ?? [])
.filter((p) => !dropped.has(p.planId) && moveByPlan[p.planId] != null)
.map((p) => ({
...p,
assigneeUserId: moveByPlan[p.planId]!,
assignStrategy: AssignStrategy.MANUAL,
assignee: moveByPlan[p.planId]!,
days: dayByPlan[p.planId] ?? expiresInDays,
moved: true,
})),
],
[
sheet.items,
sheet.pending,
dropped,
moveByPlan,
dayByPlan,
expiresInDays,
],
);
/**
* 还留在「待分配」里的 —— 主管**还没处置**的那几条(既没拖给谁,也没移出)。
* ⚠️ 这一组的存在本身就是要**引起注意**:确认时它们不会落库,不看就等于放弃这几个人。
*/
const pendingList = useMemo(
() =>
(sheet.pending ?? []).filter((p) => !dropped.has(p.planId) && moveByPlan[p.planId] == null),
[sheet.pending, dropped, moveByPlan],
);
/** 按客服分组(顺序沿用提案里的客服顺序;拖过去的新客服排在后面) */
......@@ -289,15 +412,41 @@ export function AssignmentConfirmSheet({
...sheet.byAgent.map((a) => a.userId).filter((u) => byUser.has(u)),
...[...byUser.keys()].filter((u) => !sheet.byAgent.some((a) => a.userId === u)),
];
const nameOf = new Map(sheet.byAgent.map((a) => [a.userId, a.name]));
const inHandOf = new Map(sheet.byAgent.map((a) => [a.userId, a.inHandBefore]));
/**
* 🔴 姓名 / 在手量必须**同时查 byAgent 和 skippedAgents**(2026-08-06 实测)。
*
* byAgent 只含**提案时就分到条目**的人。主管把一个人拖给(或让助手分给)
* 「本批未分到」里的客服后,那位客服在 byAgent 里查不到 →
* 姓名回落成 id 前缀、在手回落成 0,界面显示成「**#678 1 条(在手 0 → 1)**」。
* 而王锐实际在手 56 —— 一个真人被显示成一串 id,在手量还是错的(T14:证据要看得懂)。
* ⛔ 别只补姓名不补在手量:那会变成「王锐 1 条(在手 0 → 1)」,更像数据坏了。
*/
const nameOf = new Map<string, string | null>([
...sheet.byAgent.map((a) => [a.userId, a.name] as const),
...sheet.skippedAgents.map((a) => [a.userId, a.name] as const),
]);
const inHandOf = new Map<string, number>([
...sheet.byAgent.map((a) => [a.userId, a.inHandBefore] as const),
...sheet.skippedAgents.map((a) => [a.userId, a.inHand] as const),
]);
return order.map((userId) => ({
userId,
name: nameOf.get(userId) ?? null,
inHandBefore: inHandOf.get(userId) ?? 0,
list: byUser.get(userId)!,
}));
}, [items, sheet.byAgent]);
}, [items, sheet.byAgent, sheet.skippedAgents]);
/**
* 本批一条都没分到的客服 —— 拖过去之后他就有条目了,要从这行剔掉,
* 否则同一个人既在上面成组、又在"未分到"里。
* ⚠️ 用 memo 算一次:原来在 JSX 里写了两遍(`&&` 判空一次、渲染一次),
* 每次重渲染跑两遍 O(a²);数量小不至于卡,但"同一个表达式写两遍"迟早会漂成两个口径。
*/
const idleAgents = useMemo(
() => sheet.skippedAgents.filter((a) => !groups.some((g) => g.userId === a.userId)),
[sheet.skippedAgents, groups],
);
const confirm = async () => {
setSubmitting(true);
......@@ -349,7 +498,10 @@ export function AssignmentConfirmSheet({
});
const summary =
'已确认分配:' +
(sheet.potentialTreatment ? `${sheet.potentialTreatment} · ` : '') +
// ⚠️ 用中文项目名,⛔ 别把 `endo` / `filling` 这种码摆到主管眼前(说人话,见 assistant-prompts 第 0 条)
(sheet.potentialTreatment
? `${potentialTreatmentItemName(sheet.potentialTreatment)} · `
: '') +
`${res.assigned} 条 · ${groups.length} 位客服 · ${expiresInDays} 天有效` +
(dropped.size ? ` · 主管移除 ${dropped.size} 条` : '') +
(res.skipped.length ? ` · ${res.skipped.length} 条未落(确认期间被他人认领)` : '');
......@@ -359,10 +511,36 @@ export function AssignmentConfirmSheet({
// 一调 revoke_assignment 就报「批次不存在」,而卡片上的撤销按钮握着完整 id 一直是好的,
// 两条路测一条就漏了(实测 2026-08-03)。
// ⚠️ 界面这份**不显示 id**:uuid 对主管没有意义,短号则会诱导他把短号念给助手。
/**
* ⭐ 没配福利 → 就地提醒一句"还能补挂"(2026-08-06 产品加的)。
*
* 为什么是这一刻:福利只影响**此后生成**的话术,而客服正是从现在开始陆续打开这批单
* —— 再往后每过一会儿,能用上福利的人就少一个。确认这一下是最后一个自然时机。
*
* 🔴 **必须写进界面那份(可见文案),⛔ 不能只写进模型那份。**
* `appendAssistantNote` 只是往消息流里塞一段文字,**不会触发模型新一轮发言** ——
* 只给模型看,等于主管永远看不到这句提醒(实测:确认完对话里一个字都没多)。
* 同时也给模型那份留一句状态,他回头问「这批带福利没」时才答得上来。
*
* ⛔⛔ **不许写"带福利成功率更高 / 转化率提升"** —— 本系统**不统计成功**
* (助手硬约束第 4 条),那个结论编不出来。只讲事实:福利会进话术、当开口的由头。
* ⚠️ 措辞要短:主管刚点完确认,这里是顺带一提,不是推销。
*/
const benefitNudge = benefit.trim()
? ''
// ⚠️ 必须是**两个换行**:这段走 ReactMarkdown 渲染,单个 \n 会被折叠掉,
// 结果是「…3 天有效 这批还没带福利 ——」挤成一行,读起来像上一句的尾巴(实测)。
: '\n\n这批还没带福利 —— 现在说一句「这批带上『老客户复查免挂号费』」我就写进去,' +
'客服生成话术时会拿它当开口的由头;不需要就不用管。';
onConfirmed(
res.assignmentId,
summary,
`${summary}(批次 id ${res.assignmentId} —— 主管要撤销这批时,把这个完整 id 传给 revoke_assignment)`,
summary + benefitNudge,
`${summary}(批次 id ${res.assignmentId} —— 主管要撤销这批时,把这个完整 id 传给 revoke_assignment)` +
(benefitNudge
? benefitNudge +
'\n⚠️ 上面这句提醒**界面已经对主管说过了**,⛔ 你不要再重复一遍;' +
'他说「带上…」时你调 set_benefit,说不用就别再提。'
: ''),
);
} catch (e) {
setError(e instanceof Error ? e.message : '确认失败');
......@@ -390,6 +568,14 @@ export function AssignmentConfirmSheet({
* ⚠️ 匹配不到 / 匹配到多个都要**如实回一句**,⛔ 不许静默跳过:
* 模型看不到卡片,静默失败会让它跟主管说"已经移除了",而那个人还在。
* ⚠️ `appliedRef` 记已执行到第几批 —— 组件每次重渲染 edits 都在,不挡就会重复执行。
*
* 🔴🔴 **确认之后这些 state 已经没人读了**(2026-08-06 实测的事故)。
* 确认前:改 state → `confirm()` 把 state 打包发给服务端,一切正常。
* 确认后:批次早就落库了,`confirm()` 再也不会跑 —— 改 state 只是改了个界面,
* 而卡片照样亮角标、照样回一句「已更新」,主管完全看不出什么也没发生。
* 实测就是这么丢的:确认完再说「这批带上八折」,角标亮了,话术里没有福利。
* ⇒ 确认后**必须分两条路**:福利走真实接口补写(见下),其余一律**如实拒绝**。
* ⛔ 别再往这个 effect 里加"只改 state"的分支而不看 `assignmentId`。
*/
const appliedRef = useRef(0);
useEffect(() => {
......@@ -399,91 +585,320 @@ export function AssignmentConfirmSheet({
appliedRef.current = Math.max(...edits.map((e) => e.seq));
const notes: string[] = [];
// ⚠️ 用 items(生效条目)匹配,不是 sheet.items —— 已经删掉的人不该再被"找到"
// ⭐ 已确认 = 卡片进入只读态。此时唯一还能改的是**福利**(它是前向的,只影响之后生成的话术),
// 而且必须走接口真写库;人员 / 时效那些动的是已经发到客服手上的单,只能走撤销重分。
if (assignmentId) {
for (const { ops } of fresh) {
for (const op of ops) {
if (op.action === 'set_benefit') {
void assignmentsApi
// ⚠️ schema 的 superRefine 保证 set_benefit 必有 text;`?? ''` 只是类型兜底
.setBenefit(assignmentId, op.text ?? '')
.then((r) => {
setBenefit(r.benefitText ?? '');
// ⭐ 回报**服务端那句**,不是前端自己编的 —— 里面有"作废了几条话术缓存"
// 和"几条客服已经打开过",这两个数只有服务端知道(T14)。
onEditApplied?.(`确认单已更新:${r.note}`);
})
.catch((e) =>
onEditApplied?.(
`福利没改成:${e instanceof Error ? e.message : '接口报错'} —— 请如实告诉主管,⛔ 不要说已经改好了。`,
),
);
continue;
}
notes.push(
`这批**已经确认分配**了,「${OP_ZH[op.action] ?? op.action}」改不了 —— ` +
`单子已经在客服手上。要调整请撤销这批重新分( 30 分钟),或让客服自己退回`,
);
}
}
if (notes.length) onEditApplied?.(`确认单没有改动:${notes.join(';')}`);
return;
}
/**
* 🔴🔴 **同一批指令里,后一条必须看得见前一条的结果**(2026-08-06 实测)。
*
* `setMoveByPlan` / `setDropped` 是 React state,**本次 effect 里读不到刚写的值**。
* 实测事故:主管一句话里给了两条指令 ——
* 「李汝明分配给李闻,剩下的都分配给高瑞珍」
* 「改派李汝明→李闻」先执行,但紧接着那条「待分配整组铺平」读的还是**旧的**
* `moveByPlan`,于是李汝明仍被当成"待分配",连同其余 31 人一起铺给了高瑞珍 ——
* **前一条指令被后一条静默覆盖**,而两条都回报了"已执行"。
*
* ⇒ 本次 effect 内一律读写下面这两个**草稿**,最后一次性提交。
* ⛔ 别在循环里直接读 `moveByPlan` / `dropped`,那是事故的原样复现。
*/
const moveDraft: Record<string, string> = { ...moveByPlan };
const dropDraft = new Set(dropped);
/// 还留在「待分配」里的(按草稿算)—— 每次用前重算,别缓存
const pendingRest = () =>
(sheet.pending ?? []).filter((p) => !dropDraft.has(p.planId) && moveDraft[p.planId] == null);
/**
* 按姓名 / 病历号找人。
*
* ⚠️ 用 items(生效条目)不是 sheet.items —— 已经删掉的人不该再被"找到"。
* 🔴 **必须把「待分配」也算进来**(2026-08-06 实测):主管说「把常瀚越分配给刘艳阳」,
* 而常瀚越正躺在待分配里 —— 只搜 items 会回一句「确认单里没有『常瀚越』,跳过」,
* 主管明明看着他的名字就在卡片上。⇒ **卡片上看得见的人,指令就必须能指到。**
* ⚠️ 待分配的条目没有 assignee:`action:'assign'` 命中它 = 主管拍板把人给谁(标「指定」),
* `action:'remove'` 命中它 = 移出本批,两者都走同一套 moveByPlan / dropped,天然生效。
*/
const findPatients = (q: string) => {
const k = q.trim();
return items.filter((i) => i.patientName === k || i.medicalRecordNumber === k);
const hit = (i: { patientName: string | null; medicalRecordNumber: string | null }) =>
i.patientName === k || i.medicalRecordNumber === k;
// ⚠️ 待分配那半边按**草稿**过滤:同批次前一条指令刚处置过的人不该再被找到
return [...items.filter((i) => !dropDraft.has(i.planId)).filter(hit), ...pendingRest().filter(hit)];
};
/**
* 按姓名 / userId 找客服。
*
* 🔴 **必须把「本批未分到」的人也算进来**(2026-08-06 实测):主管说
* 「把陈浡龙分配给刘艳阳」,而刘艳阳这轮一条都没分到 —— 只搜 `groups`(有条目的)
* 会回「没找到客服『刘艳阳』」,而他的名字**就印在卡片下面那行「本批未分到」里**。
* ⇒ 与 findPatients 同一条规矩:**卡片上看得见的,指令就必须能指到。**
* ⚠️ 而且这些人恰恰是**手上最空**的,是主管最可能想分给他的对象。
* ⚠️ 返回 `list` 供 `select.group='agent'` 用;未分到的人 list 为空(他本来就没条目)。
*/
const findAgent = (q: string) => {
const k = q.trim();
return groups.find((g) => g.name === k || g.userId === k) ?? null;
const inSheet = groups.find((g) => g.name === k || g.userId === k);
if (inSheet) return inSheet;
const idle = sheet.skippedAgents.find((a) => a.name === k || a.userId === k);
return idle
? { userId: idle.userId, name: idle.name, inHandBefore: idle.inHand, list: [] }
: null;
};
for (const { ops } of fresh) {
for (const op of ops) {
if (op.action === 'remove_agent' || (op.action === 'set_expiry' && op.agent)) {
const name = op.action === 'remove_agent' ? op.agent : op.agent!;
const g = findAgent(name);
if (!g) {
notes.push(`没找到客服「${name}」,跳过`);
continue;
/**
* ── ① 选谁 ───────────────────────────────────────────────
* 返回 null = 已经回过话(没找到 / 有歧义),这条指令到此为止。
* ⚠️ 每条指令**现算**,⛔ 别提到循环外缓存:前一条刚移出/改派过的人不该再被选中。
*/
const resolve = (): { planIds: string[]; label: string } | null => {
if (op.select.group === 'patients') {
const planIds: string[] = [];
const names: string[] = [];
for (const q of op.select.patients) {
const hit = findPatients(q);
if (hit.length === 0) {
notes.push(`确认单里没有「${q}」,跳过`);
continue;
}
if (hit.length > 1) {
// ⛔ 同名时不猜:猜错等于把不该动的人动了,而主管看不出来
notes.push(`有 ${hit.length} 位叫「${q}」,没有执行 —— 请用病历号指定`);
continue;
}
planIds.push(hit[0]!.planId);
names.push(hit[0]!.patientName ?? q);
}
return planIds.length ? { planIds, label: `「${names.join('、')}」` } : null;
}
// 🔴 同名歧义:实测「张悦」既是本批客服、又是本批患者,模型选了 remove_agent,
// 于是"把张悦移出确认单"变成了把**客服**张悦连同他名下 20 条一起移除。
// ⛔ 这种时候不许猜 —— 猜错动的是 20 条,而主管以为只动了 1 条。
if (findPatients(name).length > 0) {
notes.push(
`「${name}」既是本批客服、又是本批患者,没有执行 —— 请说清是「客服 ${name}」还是患者`,
);
continue;
if (op.select.group === 'agent') {
const name = op.select.agent;
const g = findAgent(name);
if (!g) {
notes.push(`没找到客服「${name}」,跳过`);
return null;
}
/**
* 🔴 同名歧义:实测「张悦」既是本批客服、又是本批患者,模型选了"移除客服",
* 于是"把张悦移出确认单"变成了把**客服**张悦连同他名下 20 条一起移除。
* ⛔ 这种时候不许猜 —— 猜错动的是 20 条,而主管以为只动了 1 条。
*/
if (findPatients(name).length > 0) {
notes.push(
`「${name}」既是本批客服、又是本批患者,没有执行 —— 请说清是「客服 ${name}」还是患者`,
);
return null;
}
const live = g.list.filter((it) => !dropDraft.has(it.planId));
if (live.length === 0) {
// ⛔ 别报"已处理 0 条"(听起来像做了什么)
notes.push(`「${g.name ?? name}」本批一条都没分到,没什么可动的`);
return null;
}
return {
planIds: live.map((it) => it.planId),
label: `客服「${g.name ?? name}」名下 ${live.length} `,
};
}
if (op.action === 'remove_agent') {
setDropped((sset) => {
const n = new Set(sset);
for (const it of g.list) n.add(it.planId);
return n;
});
notes.push(`已移除客服「${g.name ?? name}」及其 ${g.list.length} `);
} else {
setDayByPlan((m) => {
const n = { ...m };
for (const it of g.list) n[it.planId] = op.days;
return n;
});
notes.push(`${g.name ?? name}」名下 ${g.list.length} 条时效改为 ${op.days} `);
if (op.select.group === 'pending') {
const rest = pendingRest();
if (rest.length === 0) {
notes.push('「待分配」已经没有待处理的人了');
return null;
}
return { planIds: rest.map((p) => p.planId), label: `「待分配」的 ${rest.length} ` };
}
continue;
}
// batch:整批 —— 只有 set_expiry / set_benefit 走得到这里(schema 已拦住其余)
return { planIds: [], label: '整批' };
};
// ── ② 干什么 ───────────────────────────────────────────────
if (op.action === 'set_benefit') {
setBenefit(op.text);
notes.push(op.text.trim() ? `本批福利设为「${op.text}` : '已撤掉本批福利');
setBenefit(op.text ?? '');
notes.push((op.text ?? '').trim() ? `本批福利设为「${op.text}` : '已撤掉本批福利');
continue;
}
if (op.action === 'set_expiry' && !op.patient) {
setExpiresInDays(op.days);
if (op.action === 'set_expiry' && op.select.group === 'batch') {
setExpiresInDays(op.days!);
notes.push(`整批时效改为 ${op.days} `);
continue;
}
// 以下都是"针对某个患者"
const who = op.action === 'set_expiry' ? op.patient! : op.patient;
const hit = findPatients(who);
if (hit.length === 0) {
notes.push(`确认单里没有「${who},跳过`);
const sel = resolve();
if (!sel) continue;
if (op.action === 'remove') {
for (const id of sel.planIds) dropDraft.add(id);
notes.push(
op.select.group === 'pending'
? `已把${sel.label}全部移出本批(他们回池子,下次还能再分)`
: `已从本批移除${sel.label}`,
);
continue;
}
if (hit.length > 1) {
// ⛔ 同名时不猜:猜错等于把不该动的人动了,而主管看不出来
notes.push(` ${hit.length} 位叫「${who},没有执行 —— 请用病历号指定`);
if (op.action === 'set_expiry') {
const days = op.days!;
setDayByPlan((m) => {
const n = { ...m };
for (const id of sel.planIds) n[id] = days;
return n;
});
// ⚠️ 待分配的人还没有经办人:时效先记下,等他被分给某位客服才随之落库。
// ⛔ 别默不作声 —— 主管会以为这条已经进批次了。
const stillPending = pendingRest().filter((p) => sel.planIds.includes(p.planId)).length;
notes.push(
`${sel.label}时效改为 ${days} ` +
(stillPending > 0
? `(其中 ${stillPending} 人还在「待分配」里,分给客服后这个时效才生效)`
: ''),
);
continue;
}
const it = hit[0]!;
if (op.action === 'remove_patient') {
setDropped((sset) => new Set(sset).add(it.planId));
notes.push(`已从本批移除「${it.patientName ?? who}`);
} else if (op.action === 'move_patient') {
const to = findAgent(op.toAgent);
if (!to) {
notes.push(`没找到客服「${op.toAgent},${who}」未改派`);
continue;
// ── ③ 给谁(action === 'assign')──────────────────────────
/**
* ⭐ 边界:**决定是主管做的,执行是助手做的**。取消自动改派后待分配动辄几十人,
* 只给逐条改派等于把 55 次拖拽推回给他 —— 那不是保护,是甩锅。
* ⚠️ 一律标「指定」(MANUAL)—— 这是人的决定,⛔ 别混进算法策略里(T20 要反推的正是算法)。
*/
const to = op.to!;
if (to.mode === 'owner') {
/**
* 🔴 **各自还给自己的专属客服** —— 与 balance **语义正好相反**。
*
* 由来(2026-08-06 实测):主管说「把待分配的患者各自分给到各自的专属客服」,
* 当时只有铺平一种铺法,于是 18 个人被散给了 17 位**别人**。
* ⭐ 这条反而最安全:收的人就是患者自己那位专属客服,⛔ 一条关系都不动。
* ⚠️ 专属客服已离岗 / 不在本批名册的**分不下去**,如实报数,
* ⛔ 别顺手改派给别人(那就又犯了一遍那个错)。
*/
const roster = new Set([
...sheet.byAgent.map((a) => a.userId),
...sheet.skippedAgents.map((a) => a.userId),
]);
/// 专属关系只有「待分配」那份数据带着(pending[].ownerUserId);
/// 已落到人头上的条目本来就在他自己的专属客服手上,不需要还
const ownerOf = new Map(
(sheet.pending ?? []).map((p) => [p.planId, { id: p.ownerUserId, name: p.ownerName }]),
);
const done = new Map<string, number>();
let orphan = 0;
for (const id of sel.planIds) {
const owner = ownerOf.get(id);
if (!owner?.id || !roster.has(owner.id)) {
orphan++;
continue;
}
moveDraft[id] = owner.id;
const key = owner.name ?? owner.id;
done.set(key, (done.get(key) ?? 0) + 1);
}
setMoveByPlan((m) => ({ ...m, [it.planId]: to.userId }));
notes.push(`${it.patientName ?? who}」改派给「${to.name ?? op.toAgent}`);
} else {
setDayByPlan((m) => ({ ...m, [it.planId]: op.days }));
notes.push(`${it.patientName ?? who}」时效改为 ${op.days} `);
const detail = [...done].map(([n, c]) => `${n} ${c} `).join('、');
notes.push(
done.size === 0
? `一个也没分下去:${sel.label}查不到在本批名册里的专属客服(可能已离岗)`
: `已把${sel.label}中的 ${sel.planIds.length - orphan} **各自还给自己的专属客服**(${detail})` +
(orphan > 0 ? `;另有 ${orphan} 人查不到在岗的专属客服,没动他们` : ''),
);
continue;
}
/**
* 铺平 —— **谁手上少先给谁**,与服务端 placeAgents 的 `lowest()` 同一条规则。
* ⛔ 不是真随机:主管嘴上说"随机",要的是齐平;真随机会让同一张单每次算出不同结果。
*/
// 候选客服:主管点名的 → 按姓名解析;没点名 → 本批所有在岗(含一条都没分到的,
// ⚠️ 他们恰恰是最空的那批,漏了就等于把人又堆给已经满的人)
const roster = [
...sheet.byAgent.map((a) => ({ userId: a.userId, name: a.name, load: a.inHandBefore })),
...sheet.skippedAgents.map((a) => ({ userId: a.userId, name: a.name, load: a.inHand })),
];
const missed: string[] = [];
const targets = to.agents?.length
? to.agents
.map((q) => {
const hit = roster.find((r) => r.name === q.trim() || r.userId === q.trim());
if (!hit) missed.push(q);
return hit;
})
.filter((x): x is (typeof roster)[number] => !!x)
: roster;
if (targets.length === 0) {
notes.push(
missed.length
? `没找到客服「${missed.join('、')},一个人也没分`
: '本批没有可分配的客服,一个人也没分',
);
continue;
}
// 水位 = 分配前在手 + 本轮已给。先把卡片上**当前**的分配算进去,否则会把人堆给已经满的
const load = new Map(targets.map((t) => [t.userId, t.load]));
// ⚠️ 已在卡片上的条目要按**草稿**算经办人:同批次前一条刚改派过的人,
// 算在旧客服头上会让水位偏一格。
// ⚠️ 本次要改派的这批人先排除掉 —— 他们马上要重新落位,算进去等于算了两遍
const moving = new Set(sel.planIds);
for (const it of items) {
if (dropDraft.has(it.planId) || moving.has(it.planId)) continue;
const who = moveDraft[it.planId] ?? it.assignee;
if (load.has(who)) load.set(who, (load.get(who) ?? 0) + 1);
}
const next: Record<string, string> = {};
for (const id of sel.planIds) {
// 谁手上最少给谁;同数按 userId 升序打破平局(确定性 —— 同样输入同样结果)
let who = targets[0]!.userId;
for (const t of targets) {
const l = load.get(t.userId) ?? 0;
const best = load.get(who) ?? 0;
if (l < best || (l === best && t.userId < who)) who = t.userId;
}
next[id] = who;
moveDraft[id] = who;
load.set(who, (load.get(who) ?? 0) + 1);
}
const nameOf = new Map(targets.map((t) => [t.userId, t.name ?? t.userId]));
const spread = [...new Set(Object.values(next))]
.map((u) => `${nameOf.get(u)} ${Object.values(next).filter((x) => x === u).length} `)
.join('、');
notes.push(
`已把${sel.label}**谁手上少先给谁**铺给 ${targets.length} 位客服(${spread})` +
(missed.length ? `;没找到「${missed.join('、')}` : ''),
);
}
}
// ⭐ **一次性提交草稿** —— 循环里只改草稿,这里才落到 state。
// ⛔ 别改回"每条指令各自 setState":那会让同批次的后一条读不到前一条(见上面的事故)。
// ⚠️ 用函数式合并而不是整个替换:草稿只增不删,合并等价且不会覆盖掉
// effect 排期期间主管手动拖出来的改动。
setMoveByPlan((m) => ({ ...m, ...moveDraft }));
setDropped((sset) => new Set([...sset, ...dropDraft]));
if (notes.length) onEditApplied?.(`确认单已更新:${notes.join(';')}`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [edits]);
......@@ -511,8 +926,30 @@ export function AssignmentConfirmSheet({
{/* ① 汇总 */}
<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" />
{/*
⚠️ 有待分配时必须**先给总数**,再拆两半。
光写「拟分 21 人 · 待分配 39 人」读不出 21 是不是含 39 —— 主管第一反应会是
"那到底分了几个"。先摆一个 60,后面两个数就是它的拆分,不用猜(T14:数字要能对上)。
⛔ 别把待分配算进「拟分」:那 39 条**不会落库**,算进去就是虚报。
*/}
<span className="font-semibold text-slate-800">
拟分 {items.length} 人 · {groups.length} 位客服
{pendingList.length > 0 ? (
// ⚠️ 每段各自 nowrap:400px 的面板里必然换行,不锁的话会断成「待分配 3 / 9」,
// 数字被劈成两行 —— 断要断在「·」上,不能断在数字中间。
<>
<span className="whitespace-nowrap">共 {items.length + pendingList.length} 人</span>
<span className="whitespace-nowrap">
{' '}· 拟分 {items.length}({groups.length} 位客服)
</span>
<span className="whitespace-nowrap text-amber-800">
{' '}· 待分配 {pendingList.length} 人
</span>
</>
) : (
<>
拟分 {items.length} 人 · {groups.length} 位客服
</>
)}
</span>
{dropped.size > 0 && (
<Badge variant="secondary" className="text-[10px] font-normal">
......@@ -544,6 +981,29 @@ export function AssignmentConfirmSheet({
</div>
</div>
{/*
①.5 **「重新排一版」做了什么** —— 紧贴汇总行,⛔ 不能放到卡片底部。
🔴 由来(2026-08-06 实测):点了重排,卡片从「拟分 16 · 待分配 34」变成「拟分 50」,
而**上面那段话还是旧的**(仍写着"34 人要您定"、"19 人无主")—— 重排走 HTTP
只换卡片,助手不会重新说一遍。主管看到的是两份打架的口径,合理的第一反应
就是「19 个人怎么顶掉了 34 个」。⇒ 换了什么必须由**卡片自己**交代。
⚠️ 尤其是**代价**:为凑满 N,专属大户会超出建议水位多接自己的人(69 条 vs 别人 58 条)。
不说这一句,那个 69 就成了无法解释的数。
*/}
{sheet.refillNote && (
<div className="whitespace-pre-line border-b border-sky-100 bg-sky-50/60 px-3 py-2 text-[11.5px] leading-relaxed text-sky-900">
{sheet.refillNote.split('**').map((seg, i) =>
i % 2 === 1 ? (
<strong key={i} className="font-semibold">
{seg}
</strong>
) : (
<span key={i}>{seg}</span>
),
)}
</div>
)}
{/* ② 按客服(产能视图)—— 这一层是主管真正在看的 */}
<CardContent className="p-0">
<div className="divide-y divide-slate-50">
......@@ -579,18 +1039,38 @@ export function AssignmentConfirmSheet({
return n;
})
}
className="flex min-w-0 flex-1 items-center gap-2 text-left"
className="flex-none"
aria-label={isOpen ? '收起' : '展开'}
>
<ChevronRight
className={cn(
'h-3 w-3 flex-none text-slate-400 transition-transform',
'h-3 w-3 text-slate-400 transition-transform',
isOpen && 'rotate-90',
)}
/>
{/* 姓名解析不出来时退回 id 前缀 —— ⛔ 不显示完整 uuid,卡片会撑爆 */}
<span className="truncate font-medium text-slate-700">
{g.name ?? `#${g.userId.slice(0, 8)}`}
</span>
</button>
{/*
⚠️ 客服姓名要**可点击复制**,所以它是一个 <button> ——
⛔ 不能再套在展开那个 <button> 里(button 嵌 button 是非法 HTML,
浏览器会把内层拎出来,点击行为随之错乱)。
⇒ 展开的点击区拆成两块:左边的箭头 + 右边的条数,姓名夹在中间只管复制。
姓名解析不出来时退回 id 前缀 —— ⛔ 不显示完整 uuid,卡片会撑爆。
*/}
<span className="min-w-0 truncate font-medium text-slate-700">
<CopyableName name={g.name ?? `#${g.userId.slice(0, 8)}`} />
</span>
<button
type="button"
onClick={() =>
setOpen((sset) => {
const n = new Set(sset);
if (n.has(g.userId)) n.delete(g.userId);
else n.add(g.userId);
return n;
})
}
className="flex min-w-0 flex-1 items-center text-left"
>
<span className="flex-none tabular-nums text-slate-500">
{g.list.length} 条
<span className="ml-1 text-[10.5px] text-slate-400">
......@@ -631,7 +1111,7 @@ export function AssignmentConfirmSheet({
)}
{/* ⭐ 姓名优先;宿主没给姓名才退回 id 前缀 */}
<span className="min-w-0 flex-1 truncate text-[11.5px] text-slate-700">
{i.patientName ?? `#${i.patientId.slice(0, 8)}`}
<CopyableName name={i.patientName ?? `#${i.patientId.slice(0, 8)}`} />
{i.medicalRecordNumber && (
<span className="ml-1 font-mono text-[10px] text-slate-400">
{i.medicalRecordNumber}
......@@ -644,6 +1124,22 @@ export function AssignmentConfirmSheet({
</span>
)}
</span>
{/*
医生 —— 主管审这批人时要看"这人是谁跟的"。
⚠️ 两列都读 patient_profiles 现成的字段(与列表页同源),⛔ 别在前端另算。
⭐ 文案统一成「主治 X」打头:两者相同时只此一句,不同才补「· 最近 Y」。
⛔ 不用「近/主」缩写(要猜),⛔ 不缀"医生"(整列都是医生,是噪声)。
*/}
{i.attendingDoctor || i.lastVisitDoctor ? (
<span className="flex-none text-[10px] text-slate-400">
主治 {i.attendingDoctor ?? i.lastVisitDoctor}
{i.attendingDoctor &&
i.lastVisitDoctor &&
i.lastVisitDoctor !== i.attendingDoctor && (
<> · 最近 {i.lastVisitDoctor}</>
)}
</span>
) : null}
{/* ⛔ 只标「专属」,不标「铺平」—— 后者是算法词,主管看不懂。
没标的就是"不是他的老客户",反而更好读。 */}
{i.assignStrategy === AssignStrategy.DEDICATED && !i.moved && (
......@@ -698,12 +1194,147 @@ export function AssignmentConfirmSheet({
</div>
</CardContent>
{/*
🔴 **待分配** —— 有专属客服、但那位客服这轮已排满的患者(2026-08-06 产品定)。
原来助手会把这些人自动改派给手上最空的客服(铺平)。产品判定:
**把患者从他的专属客服手里挪走是关系层面的决定,助手没资格替主管做。**
⇒ 助手不再决定,而是**把问题摊开**,单列一组交给主管。
⚠️ 视觉上必须**跳出来**(amber + 顶部粗边):它混在客服组里就等于没提醒 ——
主管一路往下点确认,这几个人就悄悄没了。
⚠️ 操作与客服组**完全一致**:拖到任意客服 = 分给他(标「指定」);× = 移出本批。
⛔ 不处理就是不分,这几条**不会落库**(见 confirm 的 items) —— 文案必须说清这一点。
*/}
{pendingList.length > 0 && (
<div className="border-t-2 border-amber-300 bg-amber-50/40">
<div className="px-3 py-2">
{/*
⚠️ 可收拢,但**默认展开** —— 这一组的存在本身就是要引起注意,
默认折叠等于把它藏起来,那就回到"悄悄没了"的老问题。
⭐ 收拢同时是**性能**手段:待分配一次能有几十上百人,而这块原来**无条件全量渲染**
(客服组是折叠的、展开才渲染明细,这里一直没有对等机制)。
*/}
<button
type="button"
onClick={() => setPendingOpen((v) => !v)}
className="flex w-full items-center gap-1.5 text-left text-[11.5px] font-medium text-amber-900"
>
<ChevronRight
className={cn('h-3 w-3 flex-none transition-transform', pendingOpen && 'rotate-90')}
/>
<Users className="h-3.5 w-3.5 flex-none" />
待分配 {pendingList.length} 人 · 他们的专属客服排满了
</button>
{/* ⚠️ 这段说明**不跟着收拢** —— 收起来之后仍要看得见"不处理就是不分" */}
{/*
🔴 **只留一个按钮**(2026-08-06 走查):这块原来还铺了两行说明 + 一行分布,
而**助手在上面那段话里已经说过一模一样的内容** —— 卡片是用来操作的,
不是第二遍复述。⛔ 别再往这里加解释文字。
⚠️ 「重新排一版」= 服务端重出一版:遇到专属排满的**跳过**,从池子里往后取,
尽量凑满 N。⛔ 它**不动任何人的专属关系** —— 这是底线,不是可选项。
⚠️ ⛔ 不写名次/优先级:产品判定「排名不重要,重要的是这批人共享同一组特征」。
*/}
{!readOnly && sheet.canRefill && (
<div className="mt-1 pl-[18px]">
<Button
size="sm"
variant="outline"
disabled={refilling}
onClick={async () => {
setRefilling(true);
try {
// ⚠️ 条件原样带回 —— 少带一个,重排就退回"沿用上一次",人数悄悄变了
const next = await assignmentsApi.refill({
clinicId: sheet.clinicId,
potentialTreatment: sheet.potentialTreatment,
temperature: sheet.temperature,
targetCount: sheet.batchSize,
expiresInDays,
});
onSheetReplaced?.(next);
} catch (e) {
setError(e instanceof Error ? e.message : '重新排版失败');
} finally {
setRefilling(false);
}
}}
className="h-6 border-amber-300 bg-white px-2 text-[11px] text-amber-800 hover:bg-amber-50"
>
{refilling ? '正在重排…' : '重新排一版,尽量排满'}
</Button>
</div>
)}
</div>
{pendingOpen && (
<ul className="space-y-px px-2 pb-2">
{pendingList.map((p) => (
<li
key={p.planId}
draggable={!readOnly}
onDragStart={(e) => {
e.dataTransfer.setData('text/plan-id', p.planId);
e.dataTransfer.effectAllowed = 'move';
}}
className={cn(
'flex items-center gap-1.5 rounded px-1.5 py-1 hover:bg-white',
!readOnly && 'cursor-grab active:cursor-grabbing',
)}
>
{!readOnly && <GripVertical className="h-3 w-3 flex-none text-amber-400" />}
<span className="min-w-0 flex-1 truncate text-[11.5px] text-slate-700">
<CopyableName name={p.patientName ?? `#${p.patientId.slice(0, 8)}`} />
{p.medicalRecordNumber && (
<span className="ml-1 font-mono text-[10px] text-slate-400">
{p.medicalRecordNumber}
</span>
)}
{(p.gender || p.age != null) && (
<span className="ml-1 text-[10px] tabular-nums text-slate-400">
{formatGender(p.gender)}·{p.age ?? '?'} 岁
</span>
)}
</span>
{/* ⚠️ 医生列**必须和已分配的行一致**(2026-08-06 走查):主管审的是同一批人,
两组显示的字段不一样会让他以为待分配是"另一种东西"。写法与上面那段同源。 */}
{p.attendingDoctor || p.lastVisitDoctor ? (
<span className="flex-none text-[10px] text-slate-400">
主治 {p.attendingDoctor ?? p.lastVisitDoctor}
{p.attendingDoctor &&
p.lastVisitDoctor &&
p.lastVisitDoctor !== p.attendingDoctor && <> · 最近 {p.lastVisitDoctor}</>}
</span>
) : null}
{/* ⭐ 「为什么卡住」的证据:是谁的专属 + 他现在手上多少。
⛔ 别只写「专属已满」—— 主管要判断的正是"这个人该不该从他手里挪走"。 */}
<span className="flex-none text-[10px] text-amber-700">
专属 {p.ownerName ?? p.ownerUserId.slice(0, 6)}(在手 {p.ownerInHand})
</span>
{!readOnly && (
<Button
variant="ghost"
size="icon"
title="移出本批"
onClick={() => setDropped((sset) => new Set(sset).add(p.planId))}
className="h-6 w-6 flex-none text-slate-400 hover:text-rose-600"
>
<X className="h-3.5 w-3.5" />
</Button>
)}
</li>
))}
</ul>
)}
</div>
)}
{/* 本批没分到的人 —— 仍然列出来:主管要看见"他不是被漏了"。
⚠️ 措辞不能写「已满」:没有容量上限这回事了,他没分到是因为**手上本来就最多**
(水位法没轮到)或被精调成 0 名额。 */}
{sheet.skippedAgents.length > 0 && (
{/* ⚠️ 拖过去之后他就有条目了 —— 从这行剔掉,否则同一个人既在上面成组、又在"未分到"里 */}
{idleAgents.length > 0 && (
<div className="border-t border-slate-100 px-3 py-1.5 text-[10.5px] text-slate-400">
本批未分到:{sheet.skippedAgents.map((a) => `${a.name ?? a.userId}(在手 ${a.inHand})`).join('、')}
本批未分到:{idleAgents.map((a) => `${a.name ?? a.userId}(在手 ${a.inHand})`).join('、')}
</div>
)}
......@@ -713,6 +1344,22 @@ export function AssignmentConfirmSheet({
· 本批多少人 → 顶部「拟分 N 人」和确认按钮上都写着;
· 怎么改 → 助手在消息里说,而且现在可以直接让助手改(edit_assignment_sheet)。 */}
{/*
「怎么调」的四条引导 —— **常驻在卡片上**(2026-08-06 走查从对话里搬来)。
原来要求助手每出一版都在消息里念一遍。第一次有用,而主管一天要看好几版,
第二次起全是噪音 —— 那段话比确认单本身还长。
⭐ 放在这里:不占对话流、永远在需要时看得见,而且**紧挨着确认按钮**
(他犹豫要不要点的那一刻,正好看到"原来还能这么调")。
⚠️ 只在**还能改**的时候显示:已确认 / 已撤销时它是误导。
*/}
{!readOnly && (
<div className="border-t border-slate-100 bg-slate-50/60 px-3 py-1.5 text-[10.5px] leading-relaxed text-slate-400">
可以直接跟我说:「把某某移出这批」「某某的单给 5 天」「这批 200 人」·
「只要商保直付的」「排掉最近来过的」· 「这批带上『老客户复查免挂号费』」
</div>
)}
{/* ④ 确认 */}
<div className="border-t border-slate-100 px-3 py-2">
{error && <p className="mb-1.5 text-[11px] text-rose-600">{error}</p>}
......
......@@ -28,7 +28,7 @@ import {
downsampleTo16k,
floatToPcm16,
} from '@/components/realtime-coach/audio-utils';
import { Permission } from '@pac/types';
import { Permission, type AssignmentProposal } from '@pac/types';
import { useHasPermission } from '@/hooks/use-permission';
import { useAssistantStore } from '@/stores/assistant-store';
import { AssignmentConfirmSheet } from './assignment-confirm-sheet';
......@@ -156,11 +156,14 @@ function BlockView({
block,
onSheetConfirmed,
onSheetEdited,
onSheetReplaced,
}: {
block: Block;
onSheetConfirmed?: (requestId: string, assignmentId: string, summary: string, modelSummary?: string) => void;
/** 助手改确认单后,界面回报的那一句(注入消息流) */
onSheetEdited?: (summary: string) => void;
/** 「重新排一版」回来的新提案 —— 整块换掉那张卡 */
onSheetReplaced?: (requestId: string, next: AssignmentProposal) => void;
}) {
// 工具调用不再单独显示卡片 —— 其"正在做什么"通过下方 loading 指示体现(见 MessageView)。
if (block.kind === 'tool') return null;
......@@ -176,6 +179,7 @@ function BlockView({
// 执行结果回一句进对话:既给主管看,也补进模型上下文 ——
// 否则它不知道到底成没成(可能有人没找到),下一句就会替系统撒谎
onEditApplied={onSheetEdited}
onSheetReplaced={(next) => onSheetReplaced?.(block.requestId, next)}
onConfirmed={(id, summary, modelSummary) =>
onSheetConfirmed?.(block.requestId, id, summary, modelSummary)
}
......@@ -431,12 +435,15 @@ function MessageView({
streaming,
onSheetConfirmed,
onSheetEdited,
onSheetReplaced,
}: {
message: ChatMessage;
streaming?: boolean;
onSheetConfirmed?: (requestId: string, assignmentId: string, summary: string, modelSummary?: string) => void;
/** 助手改确认单后,界面回报的那一句(注入消息流) */
onSheetEdited?: (summary: string) => void;
/** 「重新排一版」回来的新提案 —— 整块换掉那张卡 */
onSheetReplaced?: (requestId: string, next: AssignmentProposal) => void;
}) {
if (message.role === 'user') {
return (
......@@ -491,6 +498,7 @@ function MessageView({
block={b}
onSheetConfirmed={onSheetConfirmed}
onSheetEdited={onSheetEdited}
onSheetReplaced={onSheetReplaced}
/>
))}
{showHint && <GeneratingHint label={hintLabel} />}
......@@ -571,8 +579,17 @@ export function AssistantChat({
/** 空态开场建议(规则生成,调用方按场景定制);不传用页面版默认 */
examples?: string[];
} = {}) {
const { messages, status, model, setModel, send, stop, appendAssistantNote, settleSheet } =
useAssistantChat();
const {
messages,
status,
model,
setModel,
send,
stop,
appendAssistantNote,
settleSheet,
replaceSheet,
} = useAssistantChat();
/**
* 占位符按**角色**分。
......@@ -608,7 +625,9 @@ export function AssistantChat({
) => {
settleSheet(requestId, assignmentId);
// ⭐ 第二个参数带完整批次 id —— 模型撤销时唯一的 id 来源(见 assignment-confirm-sheet)
appendAssistantNote(summary, modelSummary);
// ⚠️ `asNewMessage`:主管点确认发生在模型发言之外,贴到上一条尾巴上会被当成上一段的续写,
// 他根本不觉得助手说了话(实测反馈:"agent 没有输出引导")。自成一条才读得出来。
appendAssistantNote(summary, modelSummary, { asNewMessage: true });
};
/**
* 助手改完确认单,界面把**实际结果**回一句进消息流。
......@@ -617,6 +636,9 @@ export function AssistantChat({
* 全靠这一句。不注入的话它下一轮就会自信地说"已经移除了",而那个人可能还在。
*/
const onSheetEdited = (summary: string) => appendAssistantNote(summary);
/** 「重新排一版」回来 → 整块换掉那张卡(人群没变、人换了) */
const onSheetReplaced = (requestId: string, next: AssignmentProposal) =>
replaceSheet(requestId, next);
const [input, setInput] = useState('');
const scrollRef = useRef<HTMLDivElement>(null);
const taRef = useRef<HTMLTextAreaElement>(null);
......@@ -850,6 +872,7 @@ export function AssistantChat({
streaming={status === 'streaming' && i === messages.length - 1 && m.role === 'assistant'}
onSheetConfirmed={onSheetConfirmed}
onSheetEdited={onSheetEdited}
onSheetReplaced={onSheetReplaced}
/>
))
)}
......
......@@ -471,15 +471,49 @@ export function useAssistantChat() {
* 不注入的话,下一轮主管说「刚才那批改成 5 天」,模型手里完全没有"那批"的痕迹,
* 会当成新需求重新提议一次。改动很小但极易漏,漏了会被当成模型能力问题。
*/
const appendAssistantNote = useCallback((text: string, modelText?: string) => {
setMessages((prev) => {
if (prev.length === 0) return prev;
const last = prev[prev.length - 1]!;
return [
...prev.slice(0, -1),
{ ...last, blocks: [...last.blocks, { kind: 'text', text, ...(modelText ? { modelText } : {}) }] },
];
});
const appendAssistantNote = useCallback(
(text: string, modelText?: string, opts?: { asNewMessage?: boolean }) => {
const block: Block = { kind: 'text', text, ...(modelText ? { modelText } : {}) };
setMessages((prev) => {
if (prev.length === 0) return prev;
/**
* 🔴 **确认单落库这一句必须自成一条消息**(2026-08-06 实测)。
*
* 默认行为是"贴到最后一条消息的末尾" —— 对「界面回报改单结果」是对的
* (那是模型那句话的续写)。但**主管点确认**发生在模型发言之外:
* 贴上去会和上一条的最后一行挤成一段,渲染出来是
* 「…确认无误就点卡片上的『确认分配』。已确认分配:修复 · 21 条 …这批还没带福利 ——」,
* 主管扫一眼只当是上一段的尾巴,根本不觉得助手"说了话"(实测反馈:"agent 没有输出引导")。
* ⇒ 点确认这一路走 `asNewMessage`,让它像助手新说的一句,自己一个气泡。
*/
if (opts?.asNewMessage) {
return [...prev, { id: nextId(), role: 'assistant', blocks: [block] }];
}
const last = prev[prev.length - 1]!;
return [...prev.slice(0, -1), { ...last, blocks: [...last.blocks, block] }];
});
},
[],
);
/**
* 「重新排一版」回来的新提案 —— **整块替换**那张确认单。
*
* ⚠️ 换的是整张卡:人群没变、人换了。主管在旧卡上做的删/改派对新名单没有意义,
* 所以卡片自身的 state 也会随 key 变化重置(见下面按 requestId 换 key)。
* ⛔ 别只替换 items:byAgent / skippedAgents / 几段话术都得跟着换,漏一个就对不上数。
*/
const replaceSheet = useCallback((requestId: string, next: unknown) => {
setMessages((prev) =>
prev.map((m) => ({
...m,
blocks: m.blocks.map((b) =>
b.kind === 'assignment_sheet' && b.requestId === requestId
? { ...b, sheet: next as typeof b.sheet, edits: [] }
: b,
),
})),
);
}, []);
/** 确认单落库后把卡片切到终态(按钮禁用,显示批次号) */
......@@ -496,5 +530,5 @@ export function useAssistantChat() {
);
}, []);
return { messages, status, model, setModel, send, stop, appendAssistantNote, settleSheet };
return { messages, status, model, setModel, send, stop, appendAssistantNote, settleSheet, replaceSheet };
}
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