Commit 2a58939b by luoqi

feat(web): P4 前端 —— T16 改造 + 助手外部控制口 + 移交入口与动效

## T16:客服只看得到「我的」

️ **藏 tab 不等于做到 T16** —— view 还有两条会自己滑到 pool 的路,不堵住的话
tab 看不见但列表里躺着的还是整池,而且**不报任何错**:

1. 🔴 rail 的自动回落 effect:客服的「我的」为空时,原逻辑 `setView('pool')`
   把他**直接扔进召回池**。这是 T16 最容易漏的一处。
2. /plans 落地页的兜底:mine 空 → 自动跳召回池第一个患者的详情页,
   而那个人根本不是分给他的。

两条都按 canDispatch 门控。 判据用 PLAN_DISPATCH,不用 PLAN_VIEW_ALL / PLAN_ASSIGN
—— 后者连 staff 都有(自助认领语义),两个都区分不出主管。

认领入口整段删除(后端 POST :id/assign 保留 —— T12/T16:将来放开客服主动性
是把入口加回来的事,不用改模型)。行内动作按钮从「认领/返池」二选一降为只剩「退回」,
️ grid 叠层机制保留 —— 它防的是 hover 时整行位移,与按钮有几个无关。

顺带清了三处指向不存在按钮的文案(这类文案比不给提示更糟,主管会去找):
· 状态药丸「待认领」→「待分配」(语义也变了:active 现在是"在池里等主管派")
· 详情页顶栏「未认领 · 仅查看」→「未分配 · 仅查看」
· 认领闸提示「在左侧列表点该患者的『认领』接手」→「请联系主管分配」

## 客服姓名:详情页不再显示 uuid

`FollowupPlan` 加 `assigneeName`,服务端用回访表解析后下发(取最近一次)。
前端拿不到就退回 `#` + id 前 8 位, 不显示完整 uuid(400px 卡片会撑爆)。

## assistant-store:此前「移交助手」根本无处落地

不是难写,是**没有接口** —— `AssistantWidget` 的 `open` 是组件内 useState,
外面打不开;`useAssistantChat()` 在 `AssistantChat` 内实例化,`send` 不外露。
新 store 30 行:`open` / `ask(text)` / `consume(seq)`。
️ 用 seq 自增而不是"消费完置空"(与 plan-sync-store 同款):置空要消费方回写 store,
那是双向数据流,StrictMode 双执行下会变成发一次跑两次、或一次都不跑。
️ widget 收起仍是 **CSS 隐藏不卸载**,别顺手改条件渲染 —— 改了每收一次就丢一次对话。
️ 只在 widget 变体消费 pending:/assistant 整页可能同时开着,两处都消费会发两遍。

## 移交入口 + absorb 动效

业务侧**只有两行**:发一个语义事件 + 说一句话。
```
emitPetEvent({ type: 'cohort_handoff', payload: { count, treatment } })
assistantStore.ask('帮我给…这批患者出一份分配方案')
```
「怎么演」归 pet-brain(三层单向架构的大脑层),换演法不动分配功能一行代码。
受限动作词表加 `absorb`(数字流被吸进嘴里),keyframes 加在 `PET_CSS` 模板串里
—— ️ 宠物的 29 个动画都注入在 pet-body 的 `<style>` 里,**不在 globals.css**。
/pet-lab 已加进预览列表。

 移交时**不传 planId 列表** —— 传了等于把收敛规则搬到前端,而那是会漂的。
后端 propose_assignment 自己按排序键圈。

## 浏览器实测暴露的两个真问题(已修)

1. **助手编了一个不存在的按钮**:它说「请在卡片上点击『确认并下发此批次』」——
   原生确认单(P4.4)还没做。这是 T14 的反面:没有证据的东西不许写进结论,
   **界面元素也算证据**。systemExtra 加第 7 条硬约束。
2. **默认分满 = 850 人**:target 不传时取"团队剩余容量之和"(17 人 × 50),
   而 T5 说的是「宁可 100 人做透」。️ 「团队还能吃多少」≠「这批该分多少」。
    批次规模上限属于产品决策(教条七·待确认),工程不替他定数 ——
   改为在 selectionNote 里把这个数的来历直说:「850 = 在岗 17 位客服的剩余容量之和
   (**分满**),不是建议规模」,并提示可以直接说个数字缩小。

## 浏览器端到端(本地真实数据 2,695 条召回池)

主管:两个 tab + 移交按钮 + 「待分配」药丸 
客服:**只有「我的」;为空时停在空态,没有跳进召回池** 
      空态文案「暂无分配给你的任务 / 新任务由主管统一派发」,不提召回池 
点移交 → 助手自动开窗 → 自动发问 → 调 propose_assignment → 渲染按客服分配表 
助手原话:「**确认单已呈现,请过目。**…容量上限 50 条/人为**默认值**,
          暂无历史数据支撑,积累后将按实际完成率反推替换。」
          —— 没说"已经分配好了",且照抄了 capacityNote 

877 单测通过。️ **P4.4 原生确认单尚未实现**,是 S1 剩下的最后一块。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 83f62942
...@@ -23,10 +23,11 @@ const DISPATCHER_EXTRA = ` ...@@ -23,10 +23,11 @@ const DISPATCHER_EXTRA = `
### 批次分配的生产线(不要跳步,也不要替主管跨步) ### 批次分配的生产线(不要跳步,也不要替主管跨步)
1. 主管已在召回池选定人群(潜在治疗 × 温度),你拿到的是**已初选**的人群,不必再问他要筛什么 1. 主管已在召回池选定人群(潜在治疗 × 温度),你拿到的是**已初选**的人群,不必再问他要筛什么
2. 你出「全景确认单」——**直出,不追问** 2. 你出「全景确认单」——**直出,不追问**
3. 主管在卡片上确认(可微调:指定客服、时效);**确认这一下才会真的写库** 3. 主管确认(可微调:指定客服、时效);**确认这一下才会真的写库**
⚠️ 原生确认单卡片尚未上线 —— 现在是文字/表格呈现,见下方第 7 条
4. 分配完成后可以跟踪:list_assignment_batches / get_assignment_detail 4. 分配完成后可以跟踪:list_assignment_batches / get_assignment_detail
### 条硬约束(违反任何一条都会造成真实损失) ### 条硬约束(违反任何一条都会造成真实损失)
1. **你全程只读。** 唯一改变数据的动作是主管在确认单上点确认,那由界面完成。 1. **你全程只读。** 唯一改变数据的动作是主管在确认单上点确认,那由界面完成。
⛔ **绝不要说「已经分配好了」/「已经派下去了」** —— 正确说法是 ⛔ **绝不要说「已经分配好了」/「已经派下去了」** —— 正确说法是
...@@ -54,12 +55,21 @@ const DISPATCHER_EXTRA = ` ...@@ -54,12 +55,21 @@ const DISPATCHER_EXTRA = `
原样转述 rosterNote,⛔ 不要说成「系统确认在职」,也不要替主管挡人 —— 原样转述 rosterNote,⛔ 不要说成「系统确认在职」,也不要替主管挡人 ——
名册外的客服他照样可以指定(有人只做召回不做回访,名册里查不到)。 名册外的客服他照样可以指定(有人只做召回不做回访,名册里查不到)。
7. ⛔ **不要提任何界面上不存在的按钮。**
原生确认单卡片**尚未上线**,现在你能做的就是把方案用文字/表格讲清楚。
⛔ 别说「请在卡片上点击『确认并下发』」—— 那个按钮不存在,主管会去找,找不到。
正确说法:「方案如上,确认无误请回复『确认分配』,我会告诉你下一步。」
⚠️ 这条是 T14 的直接推论:没有证据的东西不许写进结论,**界面元素也算证据**。
### 拟分方案怎么给(主管会把关,你负责有理有据) ### 拟分方案怎么给(主管会把关,你负责有理有据)
- **专属优先**:该患者有专属客服且在名册内 → 分给他 - **专属优先**:该患者有专属客服且在名册内 → 分给他
- **溢出铺平**:专属客服已达容量 / 无专属 / 专属已离岗 → 铺给其他在岗客服, - **溢出铺平**:专属客服已达容量 / 无专属 / 专属已离岗 → 铺给其他在岗客服,
按当前在手量从少到多补,**均分**;在手已达上限的人本批跳过但仍列出来标「已满」 按当前在手量从少到多补,**均分**;在手已达上限的人本批跳过但仍列出来标「已满」
- 容量默认 ${AGENT_CAPACITY_DEFAULT}(**默认值**,无历史数据支撑,要标注) - 容量默认 ${AGENT_CAPACITY_DEFAULT}(**默认值**,无历史数据支撑,要标注)
- 分不下去就**明说分不下去**,建议缩小批次;⛔ 不要硬塞给已经满的人 - 分不下去就**明说分不下去**,建议缩小批次;⛔ 不要硬塞给已经满的人
- ⚠️ 不传 targetCount 时服务端会**按团队剩余容量分满**(可能好几百人)——
那是技术上限不是建议规模。selectionNote 里会带提示,**照抄它**,
并主动问主管这批想做多少人。T5:一批做透好过多批做浅。
- 每一条都要说得出「为什么是他」:专属 / 手上最空 / 主管指定,三选一 - 每一条都要说得出「为什么是他」:专属 / 手上最空 / 主管指定,三选一
`.trim(); `.trim();
......
...@@ -85,10 +85,15 @@ export class AssignmentProposalService { ...@@ -85,10 +85,15 @@ export class AssignmentProposalService {
const agents = roster.agents; const agents = roster.agents;
// ── 拟分人数 ──────────────────────────────────────────── // ── 拟分人数 ────────────────────────────────────────────
// 默认 = 在岗客服的**剩余容量总和**(容量默认值 - 在手),不是"人数 × 容量"。 // ⚠️⚠️ **「团队还能吃多少」不等于「这批该分多少」** —— 这两件事必须分开。
// ⚠️ 容量是**默认值**,所以这个数也是默认值,确认单上必须标出来。 // totalRoom 是技术上限(17 人 × 每人 50 = 850);而 T5 说的是
// 「宁可只做 100 人做透,不做 1000 人做浅」。默认分满余量正是它反对的那种做法。
// ⛔ 但**批次规模上限属于产品决策**(教条七·待确认),工程不该替他定一个数。
// 折中:默认仍取余量(不凭空造一个没依据的上限),但在 selectionNote 里
// **把这个数的来历和风险直说**,让主管一眼看出 850 是"分满"而不是"建议值"。
const totalRoom = agents.reduce((a, g) => a + Math.max(0, AGENT_CAPACITY_DEFAULT - g.inHand), 0); const totalRoom = agents.reduce((a, g) => a + Math.max(0, AGENT_CAPACITY_DEFAULT - g.inHand), 0);
const target = Math.max(0, input.targetCount ?? totalRoom); const target = Math.max(0, input.targetCount ?? totalRoom);
const isFullCapacity = input.targetCount == null;
if (target === 0 || agents.length === 0) { if (target === 0 || agents.length === 0) {
return emptyProposal(clinicId, potentialTreatment, agents, roster.rosterNote, target); return emptyProposal(clinicId, potentialTreatment, agents, roster.rosterNote, target);
} }
...@@ -162,6 +167,10 @@ export class AssignmentProposalService { ...@@ -162,6 +167,10 @@ export class AssignmentProposalService {
`暂无历史数据反推各人真实吞吐,积累后按实际完成率拐点替换。`, `暂无历史数据反推各人真实吞吐,积累后按实际完成率拐点替换。`,
selectionNote: selectionNote:
`按「未进过批次优先 → 优先级高优先 → 患者号」排序取前 ${target} 人;` + `按「未进过批次优先 → 优先级高优先 → 患者号」排序取前 ${target} 人;` +
(isFullCapacity
? `⚠️ ${target} 人 = 在岗 ${agents.length} 位客服的剩余容量之和(**分满**),不是建议规模。` +
`一批做透通常比做浅有效,如需缩小规模直接说个数字(如"这批只要 100 人")。`
: '') +
(exploreN > 0 (exploreN > 0
? `其中 ${exploreN} 人来自探索配额(排名之外抽取,用于日后验证排序是否选得准)。` ? `其中 ${exploreN} 人来自探索配额(排名之外抽取,用于日后验证排序是否选得准)。`
: '') + : '') +
......
...@@ -553,6 +553,10 @@ export class PlanService { ...@@ -553,6 +553,10 @@ export class PlanService {
throw new NotFoundException(`Plan ${planId} not found`); throw new NotFoundException(`Plan ${planId} not found`);
} }
const assigneeName = plan.assigneeUserId
? await this.resolveAgentName(scope, plan.assigneeUserId)
: null;
const [patient, persona, scriptRow, summariesRows, executions] = await Promise.all([ const [patient, persona, scriptRow, summariesRows, executions] = await Promise.all([
this.prisma.patient.findUnique({ this.prisma.patient.findUnique({
where: { id: plan.patientId }, where: { id: plan.patientId },
...@@ -574,7 +578,9 @@ export class PlanService { ...@@ -574,7 +578,9 @@ export class PlanService {
if (!patient) throw new NotFoundException(`Patient ${plan.patientId} not found`); if (!patient) throw new NotFoundException(`Patient ${plan.patientId} not found`);
return { return {
plan: serializePlan(plan), // ⭐ 承接人姓名服务端解析后下发 —— 前端翻不了(PAC 无 users 表,dictionary.users 只有登录人),
// 不给的话详情页「承接人」显示的就是一串 uuid。
plan: serializePlan(plan, assigneeName),
patient: serializePatient(patient), patient: serializePatient(patient),
persona: persona ? serializePersona(persona) : null, persona: persona ? serializePersona(persona) : null,
// 落库正文的自报家门是占位符,读出来按当前登录人回填(见 agent-identity.ts) // 落库正文的自报家门是占位符,读出来按当前登录人回填(见 agent-identity.ts)
...@@ -587,6 +593,23 @@ export class PlanService { ...@@ -587,6 +593,23 @@ export class PlanService {
}; };
} }
/**
* userId → 姓名(单个)。源是回访表 `task_director_name`,取**最近一次** ——
* id→姓名是时间性的(实测同一个 id 在不同时期是不同的人),取旧名比不显示更糟。
* 查不到返回 null,前端按 null 显示 id 前缀,⛔ 不要编一个名字出来。
*/
private async resolveAgentName(
scope: TenantScopeContext,
userId: string,
): Promise<string | null> {
const row = await this.prisma.patientReturnVisit.findFirst({
where: { hostId: scope.hostId, tenantId: scope.tenantId, taskDirectorId: userId },
orderBy: { sourceCreatedAt: 'desc' },
select: { taskDirectorName: true },
});
return row?.taskDirectorName ?? null;
}
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
// assign // assign
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
...@@ -853,7 +876,7 @@ function toPlanReasonBrief( ...@@ -853,7 +876,7 @@ function toPlanReasonBrief(
}; };
} }
function serializePlan(p: PlanRow): import('@pac/types').FollowupPlan { function serializePlan(p: PlanRow, assigneeName?: string | null): import('@pac/types').FollowupPlan {
// FollowupPlanSchema 需要顶层 scenario / inclusionReason / evidence // FollowupPlanSchema 需要顶层 scenario / inclusionReason / evidence
// DB 这些在 PlanReason 子表;取 primary reason(MAX priorityScore)填充顶层 // DB 这些在 PlanReason 子表;取 primary reason(MAX priorityScore)填充顶层
const primary = p.reasons[0]; const primary = p.reasons[0];
...@@ -886,6 +909,8 @@ function serializePlan(p: PlanRow): import('@pac/types').FollowupPlan { ...@@ -886,6 +909,8 @@ function serializePlan(p: PlanRow): import('@pac/types').FollowupPlan {
recycleAt: p.recycleAt?.toISOString() ?? null, recycleAt: p.recycleAt?.toISOString() ?? null,
snoozedUntil: p.snoozedUntil?.toISOString() ?? null, snoozedUntil: p.snoozedUntil?.toISOString() ?? null,
assigneeUserId: p.assigneeUserId, assigneeUserId: p.assigneeUserId,
/// 由调用方(detail)解析后注入 —— 序列化函数本身不发查询
assigneeName: assigneeName ?? null,
assignedAt: p.assignedAt?.toISOString() ?? null, assignedAt: p.assignedAt?.toISOString() ?? null,
createdAt: p.createdAt.toISOString(), createdAt: p.createdAt.toISOString(),
updatedAt: p.updatedAt.toISOString(), updatedAt: p.updatedAt.toISOString(),
......
...@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'; ...@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
import { Inbox, RefreshCw } from 'lucide-react'; import { Inbox, RefreshCw } from 'lucide-react';
import { Permission } from '@pac/types'; import { Permission } from '@pac/types';
import { Can } from '@/components/can'; import { Can } from '@/components/can';
import { useHasPermission } from '@/hooks/use-permission';
import { plansApi } from '@/components/plans/plans-api'; import { plansApi } from '@/components/plans/plans-api';
import { PatientPickerRail } from '@/components/plans/patient-picker-rail'; import { PatientPickerRail } from '@/components/plans/patient-picker-rail';
import { IdentityCluster } from '@/components/identity-cluster'; import { IdentityCluster } from '@/components/identity-cluster';
...@@ -29,6 +30,8 @@ export default function PlansEntryPage() { ...@@ -29,6 +30,8 @@ export default function PlansEntryPage() {
function EntryResolver() { function EntryResolver() {
const router = useRouter(); const router = useRouter();
/// 主管判据(T16):只有他有召回池,客服的落地兜底不能滑进池子
const canDispatch = useHasPermission(Permission.PLAN_DISPATCH);
const [phase, setPhase] = useState<'resolving' | 'empty'>('resolving'); const [phase, setPhase] = useState<'resolving' | 'empty'>('resolving');
useEffect(() => { useEffect(() => {
...@@ -42,12 +45,17 @@ function EntryResolver() { ...@@ -42,12 +45,17 @@ function EntryResolver() {
router.replace(`/plans/${mine.items[0].id}`); router.replace(`/plans/${mine.items[0].id}`);
return; return;
} }
// ② 召回池第一个 // ② 召回池第一个 —— ⭐ **仅主管**。
const pool = await plansApi.list({ view: 'pool', sort: 'priority_desc', page: 1, pageSize: 1 }); // 客服没有召回池入口(T16),这里若不门控就是一条隐式的认领通道:
if (cancelled) return; // 他的「我的」一空,系统就自动把他送进池子里某个患者的详情页,
if (pool.items[0]) { // 而那个人根本不是分给他的。tab 藏了没用,路径也得堵。
router.replace(`/plans/${pool.items[0].id}`); if (canDispatch) {
return; const pool = await plansApi.list({ view: 'pool', sort: 'priority_desc', page: 1, pageSize: 1 });
if (cancelled) return;
if (pool.items[0]) {
router.replace(`/plans/${pool.items[0].id}`);
return;
}
} }
setPhase('empty'); setPhase('empty');
} catch { } catch {
...@@ -57,7 +65,7 @@ function EntryResolver() { ...@@ -57,7 +65,7 @@ function EntryResolver() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [router]); }, [router, canDispatch]);
if (phase === 'resolving') { if (phase === 'resolving') {
return ( return (
...@@ -89,10 +97,15 @@ function EntryResolver() { ...@@ -89,10 +97,15 @@ function EntryResolver() {
<span className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-slate-100 text-slate-400"> <span className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
<Inbox className="h-7 w-7" /> <Inbox className="h-7 w-7" />
</span> </span>
<div className="text-[15px] font-medium text-slate-700">当前没有待跟进的召回任务</div> <div className="text-[15px] font-medium text-slate-700">
{canDispatch ? '当前没有待跟进的召回任务' : '暂无分配给你的任务'}
</div>
{/* ⭐ 客服看到的空态不能提"召回池"——他没有那个入口,提了只会让他去找一个不存在的地方。
主管的空态照旧(他确实两边都能看)。 */}
<p className="max-w-sm text-[12.5px] leading-relaxed text-slate-500"> <p className="max-w-sm text-[12.5px] leading-relaxed text-slate-500">
我的任务与召回池都是空的。可以在左侧调整筛选(视图 / 诊所 / 筛选标签)自行查找, {canDispatch
或等召回引擎下次重算后刷新。 ? '我的任务与召回池都是空的。可以在左侧调整筛选(视图 / 诊所 / 筛选标签)自行查找,或等召回引擎下次重算后刷新。'
: '手头的任务都处理完了。新任务由主管统一派发,稍后刷新看看。'}
</p> </p>
<button <button
type="button" type="button"
......
...@@ -6,7 +6,7 @@ import { usePlanSyncStore } from '@/stores/plan-sync-store'; ...@@ -6,7 +6,7 @@ import { usePlanSyncStore } from '@/stores/plan-sync-store';
import { PetBody, type PetBodyPose } from '@/components/pet/pet-body'; import { PetBody, type PetBodyPose } from '@/components/pet/pet-body';
import { PetFab } from '@/components/pet/pet-widget'; import { PetFab } from '@/components/pet/pet-widget';
const POSES: PetBodyPose[] = ['idle', 'think', 'celebrate', 'sleep', 'walk', 'fall', 'sit', 'happy', 'brush', 'rinse', 'floss', 'shine', 'chomp', 'fly', 'vet_exam', 'vet_scale', 'vet_fill', 'vet_polish', 'vet_fluoride', 'climb', 'magnify']; const POSES: PetBodyPose[] = ['idle', 'think', 'celebrate', 'sleep', 'walk', 'fall', 'sit', 'happy', 'brush', 'rinse', 'floss', 'shine', 'chomp', 'fly', 'vet_exam', 'vet_scale', 'vet_fill', 'vet_polish', 'vet_fluoride', 'absorb', 'climb', 'magnify'];
/** /**
* 宠物实验室(dev 调试页,无需登录)— 上:各姿态静态陈列(直接喂 PetBody); * 宠物实验室(dev 调试页,无需登录)— 上:各姿态静态陈列(直接喂 PetBody);
......
...@@ -28,6 +28,7 @@ import { ...@@ -28,6 +28,7 @@ import {
downsampleTo16k, downsampleTo16k,
floatToPcm16, floatToPcm16,
} from '@/components/realtime-coach/audio-utils'; } from '@/components/realtime-coach/audio-utils';
import { useAssistantStore } from '@/stores/assistant-store';
import { import {
useAssistantChat, useAssistantChat,
type Artifact, type Artifact,
...@@ -463,6 +464,21 @@ export function AssistantChat({ ...@@ -463,6 +464,21 @@ export function AssistantChat({
void send(text); void send(text);
}; };
// ⭐ 外部投递的提问(「移交助手」等业务入口)—— 见 assistant-store。
// ⚠️ 只在 widget 变体里消费:widget 常驻挂载,而 /assistant 整页可能同时开着,
// 两处都消费会把同一句话发两遍。
// ⚠️ 依赖只写 pending?.seq:写整个 pending 对象会因引用变化在 StrictMode 下重复触发。
const pending = useAssistantStore((st) => st.pending);
const consumePending = useAssistantStore((st) => st.consume);
const pendingSeq = pending?.seq;
useEffect(() => {
if (variant !== 'widget' || !pending) return;
if (status === 'streaming') return; // 上一轮还在跑,等它完了再发,别打断
fire(pending.text);
consumePending(pending.seq);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pendingSeq, status, variant]);
const submit = () => { const submit = () => {
if (!input.trim() || status === 'streaming') return; if (!input.trim() || status === 'streaming') return;
fire(input); fire(input);
......
...@@ -5,6 +5,7 @@ import { Permission } from '@pac/types'; ...@@ -5,6 +5,7 @@ import { Permission } from '@pac/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useHasPermission } from '@/hooks/use-permission'; import { useHasPermission } from '@/hooks/use-permission';
import { usePlanSyncStore } from '@/stores/plan-sync-store'; import { usePlanSyncStore } from '@/stores/plan-sync-store';
import { useAssistantStore } from '@/stores/assistant-store';
import { AssistantFab } from './assistant-fab'; import { AssistantFab } from './assistant-fab';
import { AssistantChat } from './assistant-chat'; import { AssistantChat } from './assistant-chat';
...@@ -18,7 +19,12 @@ import { AssistantChat } from './assistant-chat'; ...@@ -18,7 +19,12 @@ import { AssistantChat } from './assistant-chat';
*/ */
export function AssistantWidget() { export function AssistantWidget() {
const allowed = useHasPermission(Permission.AGENT_INVOKE); const allowed = useHasPermission(Permission.AGENT_INVOKE);
const [open, setOpen] = useState(false); // ⭐ open 提到 store:业务代码(「移交助手」)要能从外面把它打开。
// 原来是组件内 useState,外部完全够不着 —— 那正是移交入口此前无处落地的原因。
// ⚠️ 收起仍然是 CSS 隐藏**不卸载**(见文件头注释),别顺手改成条件渲染:
// 改了主管每收一次助手就丢一次对话,而且看不出任何异常。
const open = useAssistantStore((s) => s.open);
const setOpen = useAssistantStore((s) => s.setOpen);
const [maximized, setMaximized] = useState(false); // 最大化:居中放大窗 const [maximized, setMaximized] = useState(false); // 最大化:居中放大窗
// 窗口落点 + 尺寸:打开瞬间按"钮的当前位置"计算(拖钮的意义所在),并夹紧在视窗内 // 窗口落点 + 尺寸:打开瞬间按"钮的当前位置"计算(拖钮的意义所在),并夹紧在视窗内
const [winRect, setWinRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null); const [winRect, setWinRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
......
...@@ -133,6 +133,7 @@ export function PetBody({ ...@@ -133,6 +133,7 @@ export function PetBody({
const flossing = pose === 'floss'; // 牙线自理:头顶拉线锯冠缝 const flossing = pose === 'floss'; // 牙线自理:头顶拉线锯冠缝
const shining = pose === 'shine'; // 闪亮微笑:咧嘴 + 牙面 ding const shining = pose === 'shine'; // 闪亮微笑:咧嘴 + 牙面 ding
const chomping = pose === 'chomp'; // 咬合操:嘴快速张合 const chomping = pose === 'chomp'; // 咬合操:嘴快速张合
const absorbing = pose === 'absorb'; // 吸入:一批患者被"吸"进来(移交人群时演)
const flying = pose === 'fly'; // 超人:红披风直线飞 const flying = pose === 'fly'; // 超人:红披风直线飞
const morphing = pose === 'morph'; // 超人变身:原地蓄力 + 披风展开 + 光环 const morphing = pose === 'morph'; // 超人变身:原地蓄力 + 披风展开 + 光环
// 牙医组合:统一戴额镜,按步骤换手里的器械 // 牙医组合:统一戴额镜,按步骤换手里的器械
...@@ -158,10 +159,20 @@ export function PetBody({ ...@@ -158,10 +159,20 @@ export function PetBody({
falling ? 'pac-pet-falling' : '', falling ? 'pac-pet-falling' : '',
happy ? 'pac-pet-wiggle' : '', happy ? 'pac-pet-wiggle' : '',
flying ? 'pac-pet-flylean' : '', flying ? 'pac-pet-flylean' : '',
absorbing ? 'pac-pet-absorb' : '',
].join(' ')} ].join(' ')}
aria-hidden aria-hidden
> >
<style>{PET_CSS}</style> <style>{PET_CSS}</style>
{/* 吸入:三个数字流从右侧被吸进嘴里(移交人群)。⚠️ keyframes 在 PET_CSS 里,
不在 globals.css —— 宠物的 29 个动画都注入在这个 <style> 里 */}
{absorbing && (
<g className="pac-pet-absorb-flow" fill="#0d9488" fontSize="7" fontWeight="700">
<text x="52" y="30">1</text>
<text x="56" y="36">0</text>
<text x="52" y="42">1</text>
</g>
)}
{/* 超人红披风:画在身体之后(置于身后),朝后(facing 由外层翻转)飘动;变身时从领口展开 */} {/* 超人红披风:画在身体之后(置于身后),朝后(facing 由外层翻转)飘动;变身时从领口展开 */}
{(flying || morphing) && ( {(flying || morphing) && (
<g className={morphing ? 'pac-pet-cape-unfurl' : 'pac-pet-cape'}> <g className={morphing ? 'pac-pet-cape-unfurl' : 'pac-pet-cape'}>
...@@ -275,6 +286,9 @@ export function PetBody({ ...@@ -275,6 +286,9 @@ export function PetBody({
<path d="M28 31 Q32 35.5 36 31" stroke="#334155" strokeWidth="1.8" strokeLinecap="round" fill="none" /> <path d="M28 31 Q32 35.5 36 31" stroke="#334155" strokeWidth="1.8" strokeLinecap="round" fill="none" />
) : rinsing ? ( ) : rinsing ? (
<path d="M30.5 32.5 L33.5 32.5" stroke="#334155" strokeWidth="1.6" strokeLinecap="round" fill="none" /> <path d="M30.5 32.5 L33.5 32.5" stroke="#334155" strokeWidth="1.6" strokeLinecap="round" fill="none" />
) : absorbing ? (
// 吸入时张成小圆嘴(像在吸)
<ellipse cx="32" cy="32.6" rx="2.6" ry="3.2" fill="#334155" />
) : chomping ? ( ) : chomping ? (
<g> <g>
<ellipse className="pac-pet-chomp-open" cx="32" cy="32.5" rx="4" ry="3.2" fill="#334155" /> <ellipse className="pac-pet-chomp-open" cx="32" cy="32.5" rx="4" ry="3.2" fill="#334155" />
...@@ -465,6 +479,10 @@ const PET_CSS = ` ...@@ -465,6 +479,10 @@ const PET_CSS = `
.pac-pet-bubble { animation: pacPetBubble 1.1s ease-in-out infinite; } .pac-pet-bubble { animation: pacPetBubble 1.1s ease-in-out infinite; }
.pac-pet-rinse-b { animation: pacPetRinse 1.6s ease-out infinite; } .pac-pet-rinse-b { animation: pacPetRinse 1.6s ease-out infinite; }
.pac-pet-floss { animation: pacPetFloss 0.5s ease-in-out infinite; } .pac-pet-floss { animation: pacPetFloss 0.5s ease-in-out infinite; }
.pac-pet-absorb { animation: pacPetAbsorb 0.8s ease-in-out infinite; }
.pac-pet-absorb-flow text { animation: pacPetAbsorbFlow 0.9s ease-in infinite; }
.pac-pet-absorb-flow text:nth-child(2) { animation-delay: 0.3s; }
.pac-pet-absorb-flow text:nth-child(3) { animation-delay: 0.6s; }
.pac-pet-chomp-open { animation: pacPetChomp 0.5s steps(1) infinite; } .pac-pet-chomp-open { animation: pacPetChomp 0.5s steps(1) infinite; }
.pac-pet-chomp-closed { animation: pacPetChomp 0.5s steps(1) infinite; animation-delay: -0.25s; } .pac-pet-chomp-closed { animation: pacPetChomp 0.5s steps(1) infinite; animation-delay: -0.25s; }
.pac-pet-shieldfx { animation: pacPetShield 2.2s ease-in-out infinite; transform-origin: 32px 34px; } .pac-pet-shieldfx { animation: pacPetShield 2.2s ease-in-out infinite; transform-origin: 32px 34px; }
...@@ -500,6 +518,13 @@ const PET_CSS = ` ...@@ -500,6 +518,13 @@ const PET_CSS = `
@keyframes pacPetRinse { 0% { opacity: 0; transform: translateY(3px) scale(0.5); } 30% { opacity: 1; } 100% { opacity: 0; transform: translateY(-12px) scale(1.15); } } @keyframes pacPetRinse { 0% { opacity: 0; transform: translateY(3px) scale(0.5); } 30% { opacity: 1; } 100% { opacity: 0; transform: translateY(-12px) scale(1.15); } }
@keyframes pacPetFloss { 0%,100% { transform: translateY(0); } 50% { transform: translateY(3px); } } @keyframes pacPetFloss { 0%,100% { transform: translateY(0); } 50% { transform: translateY(3px); } }
@keyframes pacPetChomp { 0%,49.9% { opacity: 1; } 50%,100% { opacity: 0; } } @keyframes pacPetChomp { 0%,49.9% { opacity: 1; } 50%,100% { opacity: 0; } }
/* 吸入:身体轻微前倾 + 数字流被吸向嘴部 */
@keyframes pacPetAbsorb { 0%,100% { transform: scale(1); } 45% { transform: scale(1.04) translateX(1px); } }
@keyframes pacPetAbsorbFlow {
0% { opacity: 0; transform: translateX(10px) scale(1); }
25% { opacity: 1; }
100% { opacity: 0; transform: translateX(-20px) scale(0.4); }
}
@keyframes pacPetShield { 0%,100% { opacity: 0.55; transform: scale(0.985); } 50% { opacity: 0.95; transform: scale(1.015); } } @keyframes pacPetShield { 0%,100% { opacity: 0.55; transform: scale(0.985); } 50% { opacity: 0.95; transform: scale(1.015); } }
@keyframes pacPetBlink { 0%,91%,97%,100% { transform: scaleY(0); } 93.5% { transform: scaleY(1); } } @keyframes pacPetBlink { 0%,91%,97%,100% { transform: scaleY(0); } 93.5% { transform: scaleY(1); } }
@keyframes pacPetJump { 0%,100% { transform: translateY(0); } 35% { transform: translateY(-7px); } 55% { transform: translateY(0); } 70% { transform: translateY(-3px); } } @keyframes pacPetJump { 0%,100% { transform: translateY(0); } 35% { transform: translateY(-7px); } 55% { transform: translateY(0); } 70% { transform: translateY(-3px); } }
......
...@@ -17,7 +17,8 @@ export type PetPose = ...@@ -17,7 +17,8 @@ export type PetPose =
| 'vet_scale' | 'vet_scale'
| 'vet_fill' | 'vet_fill'
| 'vet_polish' | 'vet_polish'
| 'vet_fluoride'; | 'vet_fluoride'
| 'absorb';
/** gesture → 一次性姿态映射(greet/think 只有台词/基线,没有专属一次性姿态)。 */ /** gesture → 一次性姿态映射(greet/think 只有台词/基线,没有专属一次性姿态)。 */
const ONE_SHOT_POSE: Partial<Record<string, PetPose>> = { const ONE_SHOT_POSE: Partial<Record<string, PetPose>> = {
...@@ -31,6 +32,7 @@ const ONE_SHOT_POSE: Partial<Record<string, PetPose>> = { ...@@ -31,6 +32,7 @@ const ONE_SHOT_POSE: Partial<Record<string, PetPose>> = {
vet_fill: 'vet_fill', vet_fill: 'vet_fill',
vet_polish: 'vet_polish', vet_polish: 'vet_polish',
vet_fluoride: 'vet_fluoride', vet_fluoride: 'vet_fluoride',
absorb: 'absorb',
}; };
const SLEEP_AFTER_MS = 300_000; // 无操作 5 分钟 → 打盹 const SLEEP_AFTER_MS = 300_000; // 无操作 5 分钟 → 打盹
...@@ -128,6 +130,17 @@ export function usePetBrain(opts: { calm?: boolean } = {}) { ...@@ -128,6 +130,17 @@ export function usePetBrain(opts: { calm?: boolean } = {}) {
setPose(basePose()); setPose(basePose());
return; return;
} }
if (e.type === 'cohort_handoff') {
// ⭐ **这里是唯一决定「怎么演」的地方**(三层架构的大脑层)。
// 业务侧只发语义事件,换演法只改这一处。
const { count, treatment } = e.payload;
play({
gesture: 'absorb',
bubble: treatment ? `正在挑 ${count}${treatment}…` : `正在挑 ${count} 位…`,
ttlMs: 2400,
});
return;
}
if (e.type === 'ai_thinking_start') thinking.current = true; if (e.type === 'ai_thinking_start') thinking.current = true;
if (e.type === 'ai_thinking_end') thinking.current = false; if (e.type === 'ai_thinking_end') thinking.current = false;
settle(); settle();
......
...@@ -164,7 +164,12 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) { ...@@ -164,7 +164,12 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
goal: real.plan?.goal ?? null, goal: real.plan?.goal ?? null,
assignee: { assignee: {
id: real.plan?.assigneeUserId ?? 'u_unknown', id: real.plan?.assigneeUserId ?? 'u_unknown',
name: real.plan?.assigneeUserId ?? '(未分配)', // ⭐ 姓名由服务端解析后下发(assigneeName)。前端翻不了 —— PAC 没有 users 表,
// dictionary.users 只覆盖当前登录人,所以这里长期显示的是一串 uuid。
// 服务端也查不到时退回 id 前 8 位(⛔ 不显示完整 uuid:400px 卡片会被撑爆)。
name:
real.plan?.assigneeName ??
(real.plan?.assigneeUserId ? `#${real.plan.assigneeUserId.slice(0, 8)}` : '(未分配)'),
role: (real.plan?.recommendedRole as UserRole) ?? UserRole.STAFF, role: (real.plan?.recommendedRole as UserRole) ?? UserRole.STAFF,
}, },
/// 认领人**原始值**(null = 无人认领)—— 认领闸判定必须用这个,不能用上面的 /// 认领人**原始值**(null = 无人认领)—— 认领闸判定必须用这个,不能用上面的
......
...@@ -202,7 +202,7 @@ export function PlanDetailApp({ ...@@ -202,7 +202,7 @@ export function PlanDetailApp({
// ⚠️ 真正的约束在**服务端**(plan/claim-guard.ts,拒绝码 20504/20505)—— 前端这层 // ⚠️ 真正的约束在**服务端**(plan/claim-guard.ts,拒绝码 20504/20505)—— 前端这层
// 只是把拒绝**前置**:点下去立刻给解释,而不是发一个注定失败的请求再报错。 // 只是把拒绝**前置**:点下去立刻给解释,而不是发一个注定失败的请求再报错。
// 所以这里绝不能用 disabled 了事:按钮保持可点,点了才讲原因,否则客服不知道为什么点不动。 // 所以这里绝不能用 disabled 了事:按钮保持可点,点了才讲原因,否则客服不知道为什么点不动。
// 两种拒绝态文案必须分开 —— "没人认领"要引导去认领(顶栏有按钮), // 两种拒绝态文案必须分开 —— "没人认领"要说清"等主管派"(T16 后已无认领入口),
// "别人认领了"要说清占用人,否则组员会以为是自己没点对。 // "别人认领了"要说清占用人,否则组员会以为是自己没点对。
const meId = useAuthStore((s) => s.user?.sub ?? null); const meId = useAuthStore((s) => s.user?.sub ?? null);
const assigneeUserId = effectivePlan.assigneeUserId ?? null; const assigneeUserId = effectivePlan.assigneeUserId ?? null;
...@@ -220,9 +220,10 @@ export function PlanDetailApp({ ...@@ -220,9 +220,10 @@ export function PlanDetailApp({
const gateCheck = (): boolean => { const gateCheck = (): boolean => {
if (canOperate) return true; if (canOperate) return true;
if (claimState === 'unclaimed') { if (claimState === 'unclaimed') {
showToast('amber', '请先认领后再操作', '这条召回还没人认领,在左侧列表点该患者的「认领」接手后即可操作'); // ⭐ T16:认领入口已撤,原文案指向一个不存在的按钮 —— 那比不给提示更糟
showToast('amber', '这条任务还没有分配', '该召回尚未派给任何人,请联系主管分配后再操作');
} else { } else {
showToast('amber', '无法操作', `该召回已被其他同事认领(${assigneeUserId}),如需接手请让其返池`); showToast('amber', '无法操作', `该召回已分配给其他同事(${data.plan.assignee.name}),如需接手请联系主管改派`);
} }
return false; return false;
}; };
...@@ -1144,7 +1145,7 @@ function TopBar({ ...@@ -1144,7 +1145,7 @@ function TopBar({
<span <span
title={ title={
claimGate.claimState === 'unclaimed' claimGate.claimState === 'unclaimed'
? '未认领 —— 只能查看;在左侧列表点该患者的「认领」接手后才能操作' ? '尚未分配 —— 只能查看;这条召回还没派给任何人,请联系主管分配'
: '该召回已被其他同事认领,你只能查看' : '该召回已被其他同事认领,你只能查看'
} }
className={cn( className={cn(
...@@ -1154,7 +1155,7 @@ function TopBar({ ...@@ -1154,7 +1155,7 @@ function TopBar({
: 'bg-slate-100 text-slate-500', : 'bg-slate-100 text-slate-500',
)} )}
> >
{claimGate.claimState === 'unclaimed' ? '未认领 · 仅查看' : '仅查看'} {claimGate.claimState === 'unclaimed' ? '未分配 · 仅查看' : '仅查看'}
</span> </span>
)} )}
</div> </div>
......
...@@ -81,6 +81,9 @@ export type PlanDetailData = { ...@@ -81,6 +81,9 @@ export type PlanDetailData = {
recommendedRole: string | null; recommendedRole: string | null;
recommendedChannel: string | null; recommendedChannel: string | null;
assigneeUserId: string | null; assigneeUserId: string | null;
/// 承接人姓名 —— 服务端解析后下发(前端翻不了:PAC 无 users 表,
/// dictionary.users 只覆盖当前登录人)。查不到为 null,由 adapt-data 退回 id 前缀。
assigneeName?: string | null;
assignedAt: string | null; assignedAt: string | null;
recycleAt: string | null; recycleAt: string | null;
/// 召回冷静期 / 终态抑制窗到期时间(execution 回写按 outcome 算)。 /// 召回冷静期 / 终态抑制窗到期时间(execution 回写按 outcome 算)。
......
...@@ -4,7 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; ...@@ -4,7 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { plansApi } from './plans-api'; import { plansApi } from './plans-api';
import { ChevronDown, Filter, Loader2, PhoneCall, Search, X } from 'lucide-react'; import { ChevronDown, Filter, Loader2, PhoneCall, Search, Sparkles, X } from 'lucide-react';
import { import {
EXECUTION_OUTCOME_GROUP_META, EXECUTION_OUTCOME_GROUP_META,
EXECUTION_OUTCOME_META, EXECUTION_OUTCOME_META,
...@@ -23,6 +23,8 @@ import { useAuthStore, visibleClinics } from '@/stores/auth-store'; ...@@ -23,6 +23,8 @@ import { useAuthStore, visibleClinics } from '@/stores/auth-store';
import { PriorityHover, type PriorityBreakdown } from '@/components/priority-hover'; import { PriorityHover, type PriorityBreakdown } from '@/components/priority-hover';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { usePlanSyncStore } from '@/stores/plan-sync-store'; import { usePlanSyncStore } from '@/stores/plan-sync-store';
import { useAssistantStore } from '@/stores/assistant-store';
import { emitPetEvent } from '@/lib/pet-events';
import { usePatientPicker, type PickerFilters } from './use-patient-picker'; import { usePatientPicker, type PickerFilters } from './use-patient-picker';
/** /**
...@@ -35,9 +37,17 @@ import { usePatientPicker, type PickerFilters } from './use-patient-picker'; ...@@ -35,9 +37,17 @@ import { usePatientPicker, type PickerFilters } from './use-patient-picker';
*/ */
type View = PickerFilters['view']; type View = PickerFilters['view'];
// '全部' tab 已下线(信息过载;召回池 + 我的 足够日常)。view='all' 逻辑保留备用。 /**
const VIEW_TABS: Array<{ v: View; label: string }> = [ * '全部' tab 已下线(信息过载;召回池 + 我的 足够日常)。view='all' 逻辑保留备用。
{ v: 'pool', label: '召回池' }, *
* ⭐ 2026-08(T16):**召回池只对主管可见**。客服看到的只有「我的」——
* 任务由主管派发,他不需要、也不应该自助去池子里捞人。
* ⚠️ 藏 tab **不等于**做到了 T16:view 还有两条会自己滑到 pool 的路
* (下方的自动回落 effect、以及 /plans 落地页的兜底),两条都必须一起门控,
* 否则 tab 看不见但列表里躺着的还是整池 —— 而且不会有任何报错。
*/
const VIEW_TABS: Array<{ v: View; label: string; requires?: Permission }> = [
{ v: 'pool', label: '召回池', requires: Permission.PLAN_DISPATCH },
{ v: 'mine', label: '我的' }, { v: 'mine', label: '我的' },
]; ];
...@@ -50,8 +60,10 @@ export function PatientPickerRail({ ...@@ -50,8 +60,10 @@ export function PatientPickerRail({
onNavigated?: () => void; onNavigated?: () => void;
}) { }) {
const router = useRouter(); const router = useRouter();
const canViewAll = useHasPermission(Permission.PLAN_VIEW_ALL); /// ⭐ 主管判据。⛔ 别用 PLAN_VIEW_ALL / PLAN_ASSIGN —— 前者是"看全部经办人"、
const canRecycle = useHasPermission(Permission.PLAN_RECYCLE); // 「返池」按钮门控(staff 也有,只能退自己的) /// 后者连 staff 都有(那是自助认领语义),两个都区分不出主管。
const canDispatch = useHasPermission(Permission.PLAN_DISPATCH);
const canRecycle = useHasPermission(Permission.PLAN_RECYCLE); // 「退回」按钮门控(staff 也有,只能退自己的)
// 默认进「我的」;加载后若「我的」为空,自动回落「召回池」(见下方 effect)。 // 默认进「我的」;加载后若「我的」为空,自动回落「召回池」(见下方 effect)。
// 与落地页选默认患者同策略(mine 优先,空则 pool)。 // 与落地页选默认患者同策略(mine 优先,空则 pool)。
...@@ -116,11 +128,15 @@ export function PatientPickerRail({ ...@@ -116,11 +128,15 @@ export function PatientPickerRail({
const justFinished = prevLoadingRef.current && !loading; // 刚从加载中变为完成 const justFinished = prevLoadingRef.current && !loading; // 刚从加载中变为完成
prevLoadingRef.current = loading; prevLoadingRef.current = loading;
if (autoFellBackRef.current) return; if (autoFellBackRef.current) return;
// 🔴 T16 最容易漏的一处:客服的「我的」为空时,原逻辑会把他**直接扔进召回池** ——
// tab 已经藏了,但 view 变成了 pool,列表里躺着整池患者,而且不报任何错。
// 藏入口和堵路径是两件事,这里是后者。
if (!canDispatch) return;
if (view === 'mine' && justFinished && total === 0) { if (view === 'mine' && justFinished && total === 0) {
autoFellBackRef.current = true; autoFellBackRef.current = true;
setView('pool'); setView('pool');
} }
}, [view, loading, total]); }, [view, loading, total, canDispatch]);
// 右侧详情动作(提交归档等)→ 同步左栏对应行状态(不重拉)。 // 右侧详情动作(提交归档等)→ 同步左栏对应行状态(不重拉)。
// 终态(completed/abandoned)行已不属于 pool/mine 视图(服务端过滤口径)→ 原地剔除, // 终态(completed/abandoned)行已不属于 pool/mine 视图(服务端过滤口径)→ 原地剔除,
...@@ -140,24 +156,31 @@ export function PatientPickerRail({ ...@@ -140,24 +156,31 @@ export function PatientPickerRail({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [sync.seq]); }, [sync.seq]);
// 认领:assign 给自己。认领后该 plan 归「我的」、离开「召回池」: // ⭐ T16:认领入口已从前端全部撤掉(后端 POST :id/assign 保留,见 plans-api.recycle 旁注)。
// 在召回池 tab → 原地移除该行(它已不属于本 tab);其它 tab(all)→ 仅改状态。 // 客服的单由主管派发;将来若放开客服主动性,是把入口加回来的事,不用改模型。
// 不重拉,保滚动/筛选。
const claim = async (planId: string, name: string) => {
const sub = user?.sub;
if (!sub) return;
try {
await plansApi.assign(planId, sub);
if (view === 'pool') removeItem(planId);
else patchItem(planId, { status: 'assigned', assigneeUserId: sub });
// 反向通知详情页:认领入口只在本列表,详情页的操作闸要靠这条即时解锁
usePlanSyncStore.getState().notifyClaim(planId, sub);
toast.success(`已认领 ${name}`);
} catch (err) {
toast.error(err instanceof Error ? err.message : '认领失败');
}
};
/**
* 移交助手 —— 把当前筛出来的人群交给助手出确认单。
*
* ⚠️ 业务侧只做两件事:发一个**语义**事件、说一句话。
* 「宠物怎么演」归 pet-brain(三层单向架构的大脑层),换演法不用动这里;
* 「助手怎么圈人」归后端 propose_assignment,这里不传 planId 列表 ——
* 传了就等于把收敛规则搬到前端,而那是会漂的。
*/
const handoffToAssistant = () => {
// 从当前筛选标签里认出「潜在治疗」那一维 —— 只为了让气泡和提问带上人话
// (「潜在种植」而不是 "potential_treatment:implant")。认不出就不带,不猜。
const tKey = [...tags].find((t) => t.startsWith('potential_treatment:'));
const treatmentLabel = tKey
? potentialTreatmentCardLabel(tKey.slice('potential_treatment:'.length))
: undefined;
emitPetEvent({ type: 'cohort_handoff', payload: { count: total, treatment: treatmentLabel } });
useAssistantStore.getState().ask(
treatmentLabel
? `帮我给「${treatmentLabel}」这批患者出一份分配方案`
: '帮我给当前召回池筛出来的这批患者出一份分配方案',
);
};
// 返池:回收已认领的 plan → 回到召回池可被再次认领(plan 仍 active)。需 PLAN_RECYCLE 权限。 // 返池:回收已认领的 plan → 回到召回池可被再次认领(plan 仍 active)。需 PLAN_RECYCLE 权限。
// 返池后该 plan 离开「我的」:在我的 tab → 原地移除该行;其它 tab → 仅改状态。 // 返池后该 plan 离开「我的」:在我的 tab → 原地移除该行;其它 tab → 仅改状态。
// 不重拉,保滚动/筛选。 // 不重拉,保滚动/筛选。
...@@ -194,7 +217,7 @@ export function PatientPickerRail({ ...@@ -194,7 +217,7 @@ export function PatientPickerRail({
<aside className="flex h-full w-[320px] flex-none flex-col border-r border-slate-100 bg-white"> <aside className="flex h-full w-[320px] flex-none flex-col border-r border-slate-100 bg-white">
{/* view tabs */} {/* view tabs */}
<div className="flex flex-none gap-1 border-b border-slate-100 px-2 pt-2"> <div className="flex flex-none gap-1 border-b border-slate-100 px-2 pt-2">
{VIEW_TABS.filter((t) => t.v !== 'all' || canViewAll).map((t) => ( {VIEW_TABS.filter((t) => !t.requires || canDispatch).map((t) => (
<button <button
key={t.v} key={t.v}
type="button" type="button"
...@@ -214,6 +237,19 @@ export function PatientPickerRail({ ...@@ -214,6 +237,19 @@ export function PatientPickerRail({
</span> </span>
</div> </div>
{/* ⭐ 移交助手 —— 主管在召回池里筛好人群后,把这批交给助手出确认单。
MVS 阶段挂在这里;矩阵(第二刀)来了只换调用处,三层动效架构不用动。 */}
{canDispatch && view === 'pool' && total > 0 && (
<button
type="button"
onClick={handoffToAssistant}
className="flex flex-none items-center justify-center gap-1.5 border-b border-slate-100 bg-brand-50/60 px-2 py-2 text-[12px] font-medium text-brand-700 transition-colors hover:bg-brand-50"
>
<Sparkles className="h-3.5 w-3.5" />
把这 {total} 人移交助手,出分配方案
</button>
)}
{/* 搜索 + 工具行 */} {/* 搜索 + 工具行 */}
<div className="flex-none space-y-1.5 border-b border-slate-100 p-2"> <div className="flex-none space-y-1.5 border-b border-slate-100 p-2">
<div className="relative"> <div className="relative">
...@@ -309,11 +345,6 @@ export function PatientPickerRail({ ...@@ -309,11 +345,6 @@ export function PatientPickerRail({
router.push(`/plans/${p.id}`); router.push(`/plans/${p.id}`);
onNavigated?.(); onNavigated?.();
}} }}
onClaim={
p.status === 'active' && !p.assigneeUserId
? () => void claim(p.id, p.patient.name ?? '')
: undefined
}
onRecycle={ onRecycle={
// 被认领(有 assignee)且非终态 → 可返池。不再只认 status='assigned': // 被认领(有 assignee)且非终态 → 可返池。不再只认 status='assigned':
// 无自动回收后 plan 可能停在 active 但仍挂人名下,也要能退回池。 // 无自动回收后 plan 可能停在 active 但仍挂人名下,也要能退回池。
...@@ -530,7 +561,9 @@ function RailClinicFilter({ ...@@ -530,7 +561,9 @@ function RailClinicFilter({
// ── 状态 pill(与列表页 STATUS_META 同款)───────────────────── // ── 状态 pill(与列表页 STATUS_META 同款)─────────────────────
const STATUS_META: Record<string, { label: string; tone: string }> = { const STATUS_META: Record<string, { label: string; tone: string }> = {
active: { label: '待认领', tone: 'bg-sky-100 text-sky-700' }, // ⭐ T16 后不再叫「待认领」:客服已经没有认领入口了,这个词会让他去找一个不存在的按钮。
// 语义也变了 —— active 现在的含义是"在池子里等主管派",不是"等谁来领"。
active: { label: '待分配', tone: 'bg-sky-100 text-sky-700' },
assigned: { label: '进行中', tone: 'bg-amber-100 text-amber-700' }, assigned: { label: '进行中', tone: 'bg-amber-100 text-amber-700' },
completed: { label: '已完成', tone: 'bg-emerald-100 text-emerald-700' }, completed: { label: '已完成', tone: 'bg-emerald-100 text-emerald-700' },
abandoned: { label: '已放弃', tone: 'bg-rose-100 text-rose-700' }, abandoned: { label: '已放弃', tone: 'bg-rose-100 text-rose-700' },
...@@ -688,7 +721,6 @@ function PatientRow({ ...@@ -688,7 +721,6 @@ function PatientRow({
showClinic, showClinic,
hidePhone, hidePhone,
onClick, onClick,
onClaim,
onRecycle, onRecycle,
}: { }: {
plan: PlanListItem; plan: PlanListItem;
...@@ -699,7 +731,6 @@ function PatientRow({ ...@@ -699,7 +731,6 @@ function PatientRow({
/** 宿主配了原始档案(VIEW_PATIENT)时隐藏手机号 —— 真号在宿主档案页,PAC 侧为造数假号。与详情页同条件。 */ /** 宿主配了原始档案(VIEW_PATIENT)时隐藏手机号 —— 真号在宿主档案页,PAC 侧为造数假号。与详情页同条件。 */
hidePhone: boolean; hidePhone: boolean;
onClick: () => void; onClick: () => void;
onClaim?: () => void;
/** 已认领 plan 的「返池」回收动作(有 PLAN_RECYCLE 且非终态时传入;staff 只能退自己的,服务端兜底) */ /** 已认领 plan 的「返池」回收动作(有 PLAN_RECYCLE 且非终态时传入;staff 只能退自己的,服务端兜底) */
onRecycle?: () => void; onRecycle?: () => void;
}) { }) {
...@@ -777,13 +808,13 @@ function PatientRow({ ...@@ -777,13 +808,13 @@ function PatientRow({
)} )}
<span className="ml-auto flex-none" /> <span className="ml-auto flex-none" />
{(() => { {(() => {
// 动作按钮:待认领→「认领」(品牌色),已认领→「返池」(amber)。二者与状态药丸叠同 grid 格, // 动作按钮:已认领→「退回」(amber)。与状态药丸叠同 grid 格,槽位宽/高恒为较大值,
// 槽位宽/高恒为较大值,hover 只切 visibility(非 display)→ 悬停整行不重排、不位移。 // hover 只切 visibility(非 display)→ 悬停整行不重排、不位移。
const act = onClaim // ⭐ T16:原本这里是「认领 / 返池」二选一,认领入口已撤,只剩退回。
? { label: '认领', tone: 'bg-brand-600 hover:bg-brand-700', fn: onClaim } // ⚠️ grid 叠层机制保留 —— 它防的是 hover 时整行位移,与按钮有几个无关。
: onRecycle const act = onRecycle
? { label: '返池', tone: 'bg-amber-500 hover:bg-amber-600', fn: onRecycle } ? { label: '退回', tone: 'bg-amber-500 hover:bg-amber-600', fn: onRecycle }
: null; : null;
if (!act) return <StatusPill status={p.status} />; if (!act) return <StatusPill status={p.status} />;
return ( return (
<span className="grid flex-none items-center justify-items-end"> <span className="grid flex-none items-center justify-items-end">
......
...@@ -26,7 +26,8 @@ export type PetGesture = ...@@ -26,7 +26,8 @@ export type PetGesture =
| 'vet_scale' // 洗牙(超声洁治 + 水珠) | 'vet_scale' // 洗牙(超声洁治 + 水珠)
| 'vet_fill' // 补牙(电钻 + 火星) | 'vet_fill' // 补牙(电钻 + 火星)
| 'vet_polish' // 抛光(抛光杯 + ✨) | 'vet_polish' // 抛光(抛光杯 + ✨)
| 'vet_fluoride'; // 涂氟(applicator,随后获护盾) | 'vet_fluoride' // 涂氟(applicator,随后获护盾)
| 'absorb'; // 吸入:一批患者被"吸"进助手(移交人群时演)
/** 语义事件 — 感知层发,大脑订阅。 */ /** 语义事件 — 感知层发,大脑订阅。 */
export type PetEvent = export type PetEvent =
...@@ -37,7 +38,13 @@ export type PetEvent = ...@@ -37,7 +38,13 @@ export type PetEvent =
/** 宠物开口说话的三个阶段:think=已发起 LLM 还没吐第一个词 / talk=流式中(嘴动) / end=说完 */ /** 宠物开口说话的三个阶段:think=已发起 LLM 还没吐第一个词 / talk=流式中(嘴动) / end=说完 */
| { type: 'pet_voice'; phase: 'think' | 'talk' | 'end' } | { type: 'pet_voice'; phase: 'think' | 'talk' | 'end' }
/** 大脑 idle 轮盘抽中"牙医组合"→ 通知 widget 跑诊疗派生链 */ /** 大脑 idle 轮盘抽中"牙医组合"→ 通知 widget 跑诊疗派生链 */
| { type: 'vet_combo' }; | { type: 'vet_combo' }
/**
* 把一批患者移交给助手(主管在召回池点「移交助手」)。
* ⚠️ 业务代码只发这个**语义**事件,不描述"怎么演" —— 演法归 pet-brain。
* 将来换演法(比如改成传送带动画)不用动分配功能的任何一行。
*/
| { type: 'cohort_handoff'; payload: { count: number; treatment?: string } };
/** 演出脚本 — 现在规则导演用,将来 LLM 导演同入口。 */ /** 演出脚本 — 现在规则导演用,将来 LLM 导演同入口。 */
export interface DirectorScript { export interface DirectorScript {
......
'use client';
import { create } from 'zustand';
/**
* 助手窗的外部控制口 —— 让**业务代码**能打开助手并塞一句话进去。
*
* ── 为什么必须有这个 store ──────────────────────────────────
* 在此之前助手窗是完全自闭的:
* · `AssistantWidget` 的 `open` 是组件内 `useState`,外面打不开;
* · `useAssistantChat()` 在 `AssistantChat` 内部实例化,`send` 不外露。
* 于是「在召回池点一下,把这批人移交给助手」这件事**根本无处落地** ——
* 不是难写,是没有接口。这 30 行就是那个接口。
*
* ── 为什么用 seq 而不是"消费完置空" ─────────────────────────
* 与 `plan-sync-store` 同款:自增序号触发订阅,消费方按 seq 变化执行一次。
* 用"置空"表达已消费需要消费方回写 store,那是双向数据流,
* StrictMode 双执行下会变成"发一次跑两次 / 或一次都不跑"。
*
* ⚠️ `AssistantWidget` 收起时是 **CSS 隐藏而非卸载**(见那里的注释:对话/工具步骤/
* 听写状态全靠这个保留)。store 化之后**必须保持这个特性** ——
* 若顺手改成条件渲染,主管每收一次助手就丢一次对话,而这在代码上看不出任何异常。
*/
interface AssistantState {
open: boolean;
setOpen: (open: boolean) => void;
/**
* 待发送的一句话。`seq` 自增即为"有新请求";`text` 是要替主管说的话。
* null = 没有待处理请求。
*/
pending: { text: string; seq: number } | null;
/** 打开助手并发一句话(移交入口用) */
ask: (text: string) => void;
/** 消费方(AssistantChat)发完后调,避免重复发送 */
consume: (seq: number) => void;
}
export const useAssistantStore = create<AssistantState>((set, get) => ({
open: false,
setOpen: (open) => set({ open }),
pending: null,
ask: (text) => {
const seq = (get().pending?.seq ?? 0) + 1;
// 先开窗再挂待办:窗没开就发消息,主管会看到助手"自己动了"却不知道在哪
set({ open: true, pending: { text, seq } });
},
consume: (seq) => {
// 只清掉自己那一条 —— 期间若又来了新请求(seq 更大),不能连它一起清
if (get().pending?.seq === seq) set({ pending: null });
},
}));
...@@ -49,8 +49,16 @@ export const FollowupPlanSchema = z.object({ ...@@ -49,8 +49,16 @@ export const FollowupPlanSchema = z.object({
/// 召回冷静期 / 终态抑制窗 deadline(execution 回写按 outcome 计算); /// 召回冷静期 / 终态抑制窗 deadline(execution 回写按 outcome 计算);
/// 终态+未到期 → 不重新生成;active+未到期 → 不进召回池(到点浮现)。null=无抑制 /// 终态+未到期 → 不重新生成;active+未到期 → 不进召回池(到点浮现)。null=无抑制
snoozedUntil: z.string().nullable(), snoozedUntil: z.string().nullable(),
/// 实际指派给谁(宿主侧 user id);展示用 name 通过 token 字典查 /// 实际指派给谁(宿主侧 user id)
assigneeUserId: z.string().nullable(), assigneeUserId: z.string().nullable(),
/**
* ⭐ 承接人姓名 —— **服务端解析后下发**,不要指望前端自己翻。
* PAC 没有 users 表,前端的 `dictionary.users` 只覆盖当前登录人,
* 于是详情页「承接人」长期显示的是一串 uuid。
* 姓名源是回访表 `task_director_name`(取最近一次;id→姓名是时间性的)。
* null = 未分配,或该 id 在回访表里查无记录(只做召回不做回访的人)。
*/
assigneeName: z.string().nullable().optional(),
assignedAt: z.string().nullable(), assignedAt: z.string().nullable(),
createdAt: z.string(), createdAt: z.string(),
updatedAt: z.string(), updatedAt: z.string(),
......
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