Commit 58266293 by luoqi

feat(web): 卡片加「撤销这批」+ 去掉批次号 + 助手撤销时卡片同步

主管问"有撤销吗、前端实现了吗" —— 后端(限时 30 分钟 + view 判据)和助手工具
revoke_assignment 都在,但**前端零实现**:assignmentsApi 连 revoke 方法都没有,
批次列表页也不存在。先做 A(卡片上的入口),批次列表页单独排。

· 确认后的卡片终态加「撤销这批」,30 分钟窗口内可见(按**确认那一刻**算,
   不是提案生成时间 —— 主管可能盯着单子改了二十分钟才点确认,那不该算进补救窗口),
  到点按钮自己消失, 不让他点了才被服务端拒。
· 弹窗**先说「客服已经打开过的收不回来」**:撤销几乎总是部分成功,
  主管以为"点了就全回来了"就会停止补救,而那几条还挂在别人手上。
  结果如实报三个数(revoked/skippedTouched/alreadyReleased), 不只弹「已撤销」。
  note 原话进对话(既给主管看,也补模型上下文)。
·  去掉「· 批次 #9a9fc222」:uuid 前 8 位对主管没意义,只占位置 + 制造记忆负担。
  模型照旧走 list_assignment_batches 拿完整 id,与界面无关。
·  **助手撤销时卡片跟着切态**:revoke_assignment 是 MCP 工具推不了侧信道,
  但前端本来就收得到它的 tool_result —— 拿它当同步信号,按 assignmentId 精确匹配。
  不同步的话卡片说「已分配」、助手说「已撤回」,两句互相打架。

🔴 顺带修一个**我自己上一版引入的回归**:`key={i}` + 「把确认单钉到消息末尾」——
助手每追加一句文本,sheet 下标就变 → React 卸载重建 → **卡片本地状态全丢**
(主管删的条、拖的改派、逐条时效、确认时刻)。症状是"撤销按钮压根不出现",
但丢的远不止那一个按钮。改成按 requestId 做 key。

本地实测:分 17 条 → 卡片出现「撤销这批」→ 弹窗 → 确认 →
「已分配 已撤销 17 条」;库里 plan_assignments.status=revoked、
plan_event_logs 落 17 条 auto_release/revoked。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 19bd658f
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Check, ChevronRight, GripVertical, Loader2, Users, X } from 'lucide-react';
import { Check, ChevronRight, GripVertical, Loader2, Undo2, Users, X } from 'lucide-react';
import {
ASSIGNMENT_EXPIRES_DAYS_PRESETS,
AssignStrategy,
REVOKE_WINDOW_MINUTES,
Permission,
type AgentOverride,
type AssignmentProposal,
type ProposedItem,
type RevokeAssignmentResponse,
type SheetEditOp,
} from '@pac/types';
import { Can } from '@/components/can';
......@@ -178,6 +180,49 @@ export function AssignmentConfirmSheet({
*/
const [benefit, setBenefit] = useState<string>('');
/**
* ── 撤销整批(限时 30 分钟)────────────────────────────────
*
* ⚠️ 语义是「**手滑 / 分错人**的补救」,不是"改主意重新调度" ——
* 改主意应该走「客服退回」,那条路有完整的原因记录(T7)。所以限时。
* ⚠️ 它是 **UX 摩擦不是安全边界**:主管本来就有 PLAN_RECYCLE + PLAN_VIEW_ALL,
* 30 分钟后照样能逐条收回来。⛔ 别基于"过了窗口就锁死了"去设计别的东西。
* ⚠️ 窗口按**确认那一刻**算(confirmedAt),⛔ 不能用提案生成时间:
* 主管可能盯着确认单改了二十分钟才点确认,那二十分钟不该算进他的补救窗口。
*/
const [confirmedAt, setConfirmedAt] = useState<number | null>(null);
const [revoked, setRevoked] = useState<RevokeAssignmentResponse | null>(null);
const [revoking, setRevoking] = useState(false);
const [confirmRevoke, setConfirmRevoke] = useState(false);
/// 每 30 秒重算一次窗口(到点按钮自己消失,⛔ 别让主管点了才被服务端拒)
const [, setRevokeTick] = useState(0);
useEffect(() => {
if (!confirmedAt || revoked) return;
const i = setInterval(() => setRevokeTick((t) => t + 1), 30_000);
return () => clearInterval(i);
}, [confirmedAt, revoked]);
const canRevoke =
assignmentId != null &&
confirmedAt != null &&
Date.now() - confirmedAt < REVOKE_WINDOW_MINUTES * 60_000;
const doRevoke = async () => {
if (!assignmentId) return;
setRevoking(true);
try {
const res = await assignmentsApi.revoke(assignmentId);
setRevoked(res);
setConfirmRevoke(false);
// ⭐ 结果**原话**进对话:note 是服务端拼的成品句子(含"已看过的没收回"那半句),
// 既给主管看,也补进模型上下文 —— 否则它下一轮会说"整批都撤回了"
onEditApplied?.(res.note);
} catch (e) {
setError(e instanceof Error ? e.message : '撤销失败');
setConfirmRevoke(false);
} finally {
setRevoking(false);
}
};
/**
* 🔴 拖拽时的**自动滚动**。
*
* 助手窗里一屏只放得下三四个客服,而 HTML5 DnD **不会**自己滚容器 ——
......@@ -209,7 +254,9 @@ export function AssignmentConfirmSheet({
};
const done = state === 'confirmed';
const readOnly = done || submitting;
/// 助手撤的整批(state 由 tool_result 切过来);本地撤的走 revoked
const cancelled = state === 'cancelled';
const readOnly = done || cancelled || submitting;
/**
* 生效条目 = 提案条目 − 删掉的 + 拖拽改派 + 逐条时效。
......@@ -301,6 +348,7 @@ export function AssignmentConfirmSheet({
`${res.assigned} 条 · ${groups.length} 位客服 · ${expiresInDays} 天有效` +
(dropped.size ? ` · 主管移除 ${dropped.size} 条` : '') +
(res.skipped.length ? ` · ${res.skipped.length} 条未落(确认期间被他人认领)` : '');
setConfirmedAt(Date.now());
onConfirmed(res.assignmentId, summary);
} catch (e) {
setError(e instanceof Error ? e.message : '确认失败');
......@@ -654,10 +702,41 @@ export function AssignmentConfirmSheet({
{/* ④ 确认 */}
<div className="border-t border-slate-100 px-3 py-2">
{error && <p className="mb-1.5 text-[11px] text-rose-600">{error}</p>}
{done ? (
<div className="flex items-center gap-1.5 text-[11.5px] font-medium text-emerald-700">
{cancelled && !revoked ? (
// 助手撤的:本地没有三个数,只说状态 —— 具体数字助手在消息里已经原话转述过
<span className="text-[11.5px] font-medium text-slate-500">这批已撤销</span>
) : done ? (
<div className="flex items-center gap-2">
<span className="flex items-center gap-1.5 text-[11.5px] font-medium text-emerald-700">
<Check className="h-3.5 w-3.5" />
已分配 · 批次 #{assignmentId?.slice(0, 8)}
已分配
{/* ⛔ 不显示批次号:`#9a9fc222` 是 uuid 前 8 位,对主管没有任何意义
(他不会拿它去查什么),只是占位置 + 制造"我是不是该记下来"的负担。
⚠️ **模型那边照旧拿得到完整 id** —— 它走 list_assignment_batches,
与界面显不显示无关。 */}
</span>
{revoked ? (
// 撤销结果**如实三个数**,⛔ 不能只说"已撤销":客服打开过的单收不回来,
// 只报一个数会让主管以为整批都回来了
<span className="text-[11px] text-slate-500">
已撤销 {revoked.revoked} 条
{revoked.skippedTouched > 0 && ` · ${revoked.skippedTouched} 条客服已看过,未收回`}
{revoked.alreadyReleased > 0 && ` · ${revoked.alreadyReleased} 条本就不在手上`}
</span>
) : (
canRevoke && (
<Button
variant="ghost"
size="sm"
disabled={revoking}
onClick={() => setConfirmRevoke(true)}
className="h-6 gap-1 px-2 text-[11px] text-slate-500 hover:text-rose-600"
>
{revoking ? <Loader2 className="h-3 w-3 animate-spin" /> : <Undo2 className="h-3 w-3" />}
撤销这批
</Button>
)
)}
</div>
) : (
<Can
......@@ -676,6 +755,33 @@ export function AssignmentConfirmSheet({
)}
</div>
{/* 撤销整批前的二次确认。
⚠️ 措辞必须先说「**客服已经打开过的收不回来**」—— 撤销几乎总是部分成功,
主管以为"点了就全回来了"的话,他会停止补救(比如不再逐条去打招呼),
而那几条其实还挂在别人手上。 */}
<AlertDialog open={confirmRevoke} onOpenChange={(o) => !o && setConfirmRevoke(false)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>撤销这批分配?</AlertDialogTitle>
<AlertDialogDescription>
这批 {items.length} 条会退回召回池。
<span className="font-medium text-slate-700">
客服已经打开过的单不会被收回
</span>
—— 他可能已经联系了患者,收回等于把那通电话抹掉。撤销窗口是确认后{' '}
{REVOKE_WINDOW_MINUTES} 分钟;过了就走「客服退回」逐条处理。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={revoking}>不撤了</AlertDialogCancel>
<Button disabled={revoking} onClick={() => void doRevoke()} className="gap-1.5">
{revoking && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
确认撤销
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 移除客服前的二次确认 —— ⚠️ 必须说清"他名下的条目会一起移出",
否则主管以为只是把人从名单上划掉,回头发现少了 20 个患者 */}
<AlertDialog open={pendingRemove != null} onOpenChange={(o) => !o && setPendingRemove(null)}>
......
......@@ -414,7 +414,13 @@ function MessageView({
<div className="min-w-0 space-y-2.5">
{ordered.map((b, i) => (
<BlockView
key={i}
// 🔴 确认单**必须用稳定 key**(requestId),⛔ 不能用下标。
// 下标 key + "把 sheet 钉到消息末尾"的排序 = 助手每追加一句文本,
// sheet 的下标就变 → React 卸载重建组件 → **本地状态全丢**:
// 主管删的条、拖的改派、逐条时效、以及"能不能撤销"的确认时刻,
// 都会在助手说下一句话时被悄悄清空(实测:确认后撤销按钮直接不出现)。
// ⚠️ 这是排序改动引入的回归 —— 排序本身没错,错在 key 跟着位置走。
key={b.kind === 'assignment_sheet' ? `sheet:${b.requestId}` : `b:${i}`}
block={b}
onSheetConfirmed={onSheetConfirmed}
onSheetEdited={onSheetEdited}
......
......@@ -57,6 +57,25 @@ export type Block =
edits?: { seq: number; ops: SheetEditOp[] }[];
};
/**
* 从 revoke_assignment 的返回里抠出 assignmentId。
* ⚠️ MCP 工具的返回是 `{content:[{type:'text',text:'<json>'}]}` 这种包装,
* 形状随实现变 —— 所以**宽容解析**:能拿到就用,拿不到就不同步(⛔ 别抛错,
* 撤销本身已经成功了,前端同步失败不该让用户看到红字)。
*/
function extractRevokedId(result: unknown): string | null {
try {
const raw =
typeof result === 'string'
? result
: JSON.stringify(result ?? '');
const m = raw.match(/"assignmentId"\s*:\s*"([0-9a-fA-F-]{36})"/);
return m?.[1] ?? null;
} catch {
return null;
}
}
/** render_artifact 是本地"渲染"工具,不当普通工具步骤展示,而是渲成卡片。 */
const ARTIFACT_TOOL = 'render_artifact';
......@@ -270,6 +289,31 @@ export function useAssistantChat() {
]);
break;
case 'tool_result':
/**
* ⭐ 助手撤销整批时,**把卡片也切到撤销态**。
*
* 🔴 不同步的话主管会看到两句互相矛盾的话:卡片写着「已分配」,
* 助手说「已撤回 12 条」—— 这正是整个设计一直在防的"两处不一致听谁的"。
* ⚠️ revoke_assignment 是 **MCP 工具**,推不了侧信道 ——
* 但前端本来就收得到它的 tool_result,拿这个当同步信号,不必改架构。
* ⚠️ 按 assignmentId 精确匹配,⛔ 别改"最后一张":主管可能连着分了两批,
* 撤的是前一批。
*/
if (evt.tool === 'revoke_assignment') {
const rid = extractRevokedId(evt.result);
if (rid) {
setMessages((prev) =>
prev.map((m) => ({
...m,
blocks: m.blocks.map((b) =>
b.kind === 'assignment_sheet' && b.assignmentId === rid
? { ...b, state: 'cancelled' as const }
: b,
),
})),
);
}
}
patch((blocks) => {
const realIdx = findToolIdx(blocks, evt.id ? String(evt.id) : undefined);
if (realIdx === -1) return blocks;
......
......@@ -6,6 +6,7 @@ import type {
ListAgentsResponse,
ListAssignmentsResponse,
AssignmentDetailResponse,
RevokeAssignmentResponse,
} from '@pac/types';
import { api } from '@/lib/api-client';
......@@ -32,6 +33,21 @@ export const assignmentsApi = {
api.get<AssignmentDetailResponse>(`/pac/v1/plans/assignments/${encodeURIComponent(id)}`),
/** 在岗客服名册 + 在手负载(一个端点,不拆两个 —— 拆开必然口径漂移) */
/**
* 撤销整批(限时 30 分钟)。
*
* ⚠️ 语义是「**手滑 / 分错人**的补救」,不是"改主意重新调度" ——
* 改主意应该走「客服退回」,那条路有完整的原因记录。
* ⚠️ **几乎总是部分成功**:客服已经打开过的单不会被收回(他可能已经联系了患者),
* 所以返回 revoked / skippedTouched / alreadyReleased 三个数 —— 界面必须如实显示,
* ⛔ 不能只弹一句「已撤销」。
*/
revoke: (assignmentId: string) =>
api.post<RevokeAssignmentResponse>(
`/pac/v1/plans/assignments/${encodeURIComponent(assignmentId)}/revoke`,
{},
),
agents: (clinicId: string, include?: string[]) =>
api.get<ListAgentsResponse>(
`/pac/v1/plans/assignments/agents?clinicId=${encodeURIComponent(clinicId)}` +
......
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