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 = `
### 批次分配的生产线(不要跳步,也不要替主管跨步)
1. 主管已在召回池选定人群(潜在治疗 × 温度),你拿到的是**已初选**的人群,不必再问他要筛什么
2. 你出「全景确认单」——**直出,不追问**
3. 主管在卡片上确认(可微调:指定客服、时效);**确认这一下才会真的写库**
3. 主管确认(可微调:指定客服、时效);**确认这一下才会真的写库**
⚠️ 原生确认单卡片尚未上线 —— 现在是文字/表格呈现,见下方第 7 条
4. 分配完成后可以跟踪:list_assignment_batches / get_assignment_detail
### 条硬约束(违反任何一条都会造成真实损失)
### 条硬约束(违反任何一条都会造成真实损失)
1. **你全程只读。** 唯一改变数据的动作是主管在确认单上点确认,那由界面完成。
⛔ **绝不要说「已经分配好了」/「已经派下去了」** —— 正确说法是
......@@ -54,12 +55,21 @@ const DISPATCHER_EXTRA = `
原样转述 rosterNote,⛔ 不要说成「系统确认在职」,也不要替主管挡人 ——
名册外的客服他照样可以指定(有人只做召回不做回访,名册里查不到)。
7. ⛔ **不要提任何界面上不存在的按钮。**
原生确认单卡片**尚未上线**,现在你能做的就是把方案用文字/表格讲清楚。
⛔ 别说「请在卡片上点击『确认并下发』」—— 那个按钮不存在,主管会去找,找不到。
正确说法:「方案如上,确认无误请回复『确认分配』,我会告诉你下一步。」
⚠️ 这条是 T14 的直接推论:没有证据的东西不许写进结论,**界面元素也算证据**。
### 拟分方案怎么给(主管会把关,你负责有理有据)
- **专属优先**:该患者有专属客服且在名册内 → 分给他
- **溢出铺平**:专属客服已达容量 / 无专属 / 专属已离岗 → 铺给其他在岗客服,
按当前在手量从少到多补,**均分**;在手已达上限的人本批跳过但仍列出来标「已满」
- 容量默认 ${AGENT_CAPACITY_DEFAULT}(**默认值**,无历史数据支撑,要标注)
- 分不下去就**明说分不下去**,建议缩小批次;⛔ 不要硬塞给已经满的人
- ⚠️ 不传 targetCount 时服务端会**按团队剩余容量分满**(可能好几百人)——
那是技术上限不是建议规模。selectionNote 里会带提示,**照抄它**,
并主动问主管这批想做多少人。T5:一批做透好过多批做浅。
- 每一条都要说得出「为什么是他」:专属 / 手上最空 / 主管指定,三选一
`.trim();
......
......@@ -85,10 +85,15 @@ export class AssignmentProposalService {
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 target = Math.max(0, input.targetCount ?? totalRoom);
const isFullCapacity = input.targetCount == null;
if (target === 0 || agents.length === 0) {
return emptyProposal(clinicId, potentialTreatment, agents, roster.rosterNote, target);
}
......@@ -162,6 +167,10 @@ export class AssignmentProposalService {
`暂无历史数据反推各人真实吞吐,积累后按实际完成率拐点替换。`,
selectionNote:
`按「未进过批次优先 → 优先级高优先 → 患者号」排序取前 ${target} 人;` +
(isFullCapacity
? `⚠️ ${target} 人 = 在岗 ${agents.length} 位客服的剩余容量之和(**分满**),不是建议规模。` +
`一批做透通常比做浅有效,如需缩小规模直接说个数字(如"这批只要 100 人")。`
: '') +
(exploreN > 0
? `其中 ${exploreN} 人来自探索配额(排名之外抽取,用于日后验证排序是否选得准)。`
: '') +
......
......@@ -553,6 +553,10 @@ export class PlanService {
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([
this.prisma.patient.findUnique({
where: { id: plan.patientId },
......@@ -574,7 +578,9 @@ export class PlanService {
if (!patient) throw new NotFoundException(`Patient ${plan.patientId} not found`);
return {
plan: serializePlan(plan),
// ⭐ 承接人姓名服务端解析后下发 —— 前端翻不了(PAC 无 users 表,dictionary.users 只有登录人),
// 不给的话详情页「承接人」显示的就是一串 uuid。
plan: serializePlan(plan, assigneeName),
patient: serializePatient(patient),
persona: persona ? serializePersona(persona) : null,
// 落库正文的自报家门是占位符,读出来按当前登录人回填(见 agent-identity.ts)
......@@ -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
// ─────────────────────────────────────────────
......@@ -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
// DB 这些在 PlanReason 子表;取 primary reason(MAX priorityScore)填充顶层
const primary = p.reasons[0];
......@@ -886,6 +909,8 @@ function serializePlan(p: PlanRow): import('@pac/types').FollowupPlan {
recycleAt: p.recycleAt?.toISOString() ?? null,
snoozedUntil: p.snoozedUntil?.toISOString() ?? null,
assigneeUserId: p.assigneeUserId,
/// 由调用方(detail)解析后注入 —— 序列化函数本身不发查询
assigneeName: assigneeName ?? null,
assignedAt: p.assignedAt?.toISOString() ?? null,
createdAt: p.createdAt.toISOString(),
updatedAt: p.updatedAt.toISOString(),
......
......@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
import { Inbox, RefreshCw } from 'lucide-react';
import { Permission } from '@pac/types';
import { Can } from '@/components/can';
import { useHasPermission } from '@/hooks/use-permission';
import { plansApi } from '@/components/plans/plans-api';
import { PatientPickerRail } from '@/components/plans/patient-picker-rail';
import { IdentityCluster } from '@/components/identity-cluster';
......@@ -29,6 +30,8 @@ export default function PlansEntryPage() {
function EntryResolver() {
const router = useRouter();
/// 主管判据(T16):只有他有召回池,客服的落地兜底不能滑进池子
const canDispatch = useHasPermission(Permission.PLAN_DISPATCH);
const [phase, setPhase] = useState<'resolving' | 'empty'>('resolving');
useEffect(() => {
......@@ -42,13 +45,18 @@ function EntryResolver() {
router.replace(`/plans/${mine.items[0].id}`);
return;
}
// ② 召回池第一个
// ② 召回池第一个 —— ⭐ **仅主管**。
// 客服没有召回池入口(T16),这里若不门控就是一条隐式的认领通道:
// 他的「我的」一空,系统就自动把他送进池子里某个患者的详情页,
// 而那个人根本不是分给他的。tab 藏了没用,路径也得堵。
if (canDispatch) {
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');
} catch {
if (!cancelled) setPhase('empty');
......@@ -57,7 +65,7 @@ function EntryResolver() {
return () => {
cancelled = true;
};
}, [router]);
}, [router, canDispatch]);
if (phase === 'resolving') {
return (
......@@ -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">
<Inbox className="h-7 w-7" />
</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">
我的任务与召回池都是空的。可以在左侧调整筛选(视图 / 诊所 / 筛选标签)自行查找,
或等召回引擎下次重算后刷新。
{canDispatch
? '我的任务与召回池都是空的。可以在左侧调整筛选(视图 / 诊所 / 筛选标签)自行查找,或等召回引擎下次重算后刷新。'
: '手头的任务都处理完了。新任务由主管统一派发,稍后刷新看看。'}
</p>
<button
type="button"
......
......@@ -6,7 +6,7 @@ import { usePlanSyncStore } from '@/stores/plan-sync-store';
import { PetBody, type PetBodyPose } from '@/components/pet/pet-body';
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);
......
......@@ -28,6 +28,7 @@ import {
downsampleTo16k,
floatToPcm16,
} from '@/components/realtime-coach/audio-utils';
import { useAssistantStore } from '@/stores/assistant-store';
import {
useAssistantChat,
type Artifact,
......@@ -463,6 +464,21 @@ export function AssistantChat({
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 = () => {
if (!input.trim() || status === 'streaming') return;
fire(input);
......
......@@ -5,6 +5,7 @@ import { Permission } from '@pac/types';
import { cn } from '@/lib/utils';
import { useHasPermission } from '@/hooks/use-permission';
import { usePlanSyncStore } from '@/stores/plan-sync-store';
import { useAssistantStore } from '@/stores/assistant-store';
import { AssistantFab } from './assistant-fab';
import { AssistantChat } from './assistant-chat';
......@@ -18,7 +19,12 @@ import { AssistantChat } from './assistant-chat';
*/
export function AssistantWidget() {
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 [winRect, setWinRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
......
......@@ -133,6 +133,7 @@ export function PetBody({
const flossing = pose === 'floss'; // 牙线自理:头顶拉线锯冠缝
const shining = pose === 'shine'; // 闪亮微笑:咧嘴 + 牙面 ding
const chomping = pose === 'chomp'; // 咬合操:嘴快速张合
const absorbing = pose === 'absorb'; // 吸入:一批患者被"吸"进来(移交人群时演)
const flying = pose === 'fly'; // 超人:红披风直线飞
const morphing = pose === 'morph'; // 超人变身:原地蓄力 + 披风展开 + 光环
// 牙医组合:统一戴额镜,按步骤换手里的器械
......@@ -158,10 +159,20 @@ export function PetBody({
falling ? 'pac-pet-falling' : '',
happy ? 'pac-pet-wiggle' : '',
flying ? 'pac-pet-flylean' : '',
absorbing ? 'pac-pet-absorb' : '',
].join(' ')}
aria-hidden
>
<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 由外层翻转)飘动;变身时从领口展开 */}
{(flying || morphing) && (
<g className={morphing ? 'pac-pet-cape-unfurl' : 'pac-pet-cape'}>
......@@ -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" />
) : rinsing ? (
<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 ? (
<g>
<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 = `
.pac-pet-bubble { animation: pacPetBubble 1.1s ease-in-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-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-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; }
......@@ -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 pacPetFloss { 0%,100% { transform: translateY(0); } 50% { transform: translateY(3px); } }
@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 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); } }
......
......@@ -17,7 +17,8 @@ export type PetPose =
| 'vet_scale'
| 'vet_fill'
| 'vet_polish'
| 'vet_fluoride';
| 'vet_fluoride'
| 'absorb';
/** gesture → 一次性姿态映射(greet/think 只有台词/基线,没有专属一次性姿态)。 */
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_polish: 'vet_polish',
vet_fluoride: 'vet_fluoride',
absorb: 'absorb',
};
const SLEEP_AFTER_MS = 300_000; // 无操作 5 分钟 → 打盹
......@@ -128,6 +130,17 @@ export function usePetBrain(opts: { calm?: boolean } = {}) {
setPose(basePose());
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_end') thinking.current = false;
settle();
......
......@@ -164,7 +164,12 @@ export function adaptData(real: PlanDetailData, dict?: TokenDictionary) {
goal: real.plan?.goal ?? null,
assignee: {
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,
},
/// 认领人**原始值**(null = 无人认领)—— 认领闸判定必须用这个,不能用上面的
......
......@@ -202,7 +202,7 @@ export function PlanDetailApp({
// ⚠️ 真正的约束在**服务端**(plan/claim-guard.ts,拒绝码 20504/20505)—— 前端这层
// 只是把拒绝**前置**:点下去立刻给解释,而不是发一个注定失败的请求再报错。
// 所以这里绝不能用 disabled 了事:按钮保持可点,点了才讲原因,否则客服不知道为什么点不动。
// 两种拒绝态文案必须分开 —— "没人认领"要引导去认领(顶栏有按钮),
// 两种拒绝态文案必须分开 —— "没人认领"要说清"等主管派"(T16 后已无认领入口),
// "别人认领了"要说清占用人,否则组员会以为是自己没点对。
const meId = useAuthStore((s) => s.user?.sub ?? null);
const assigneeUserId = effectivePlan.assigneeUserId ?? null;
......@@ -220,9 +220,10 @@ export function PlanDetailApp({
const gateCheck = (): boolean => {
if (canOperate) return true;
if (claimState === 'unclaimed') {
showToast('amber', '请先认领后再操作', '这条召回还没人认领,在左侧列表点该患者的「认领」接手后即可操作');
// ⭐ T16:认领入口已撤,原文案指向一个不存在的按钮 —— 那比不给提示更糟
showToast('amber', '这条任务还没有分配', '该召回尚未派给任何人,请联系主管分配后再操作');
} else {
showToast('amber', '无法操作', `该召回已被其他同事认领(${assigneeUserId}),如需接手请让其返池`);
showToast('amber', '无法操作', `该召回已分配给其他同事(${data.plan.assignee.name}),如需接手请联系主管改派`);
}
return false;
};
......@@ -1144,7 +1145,7 @@ function TopBar({
<span
title={
claimGate.claimState === 'unclaimed'
? '未认领 —— 只能查看;在左侧列表点该患者的「认领」接手后才能操作'
? '尚未分配 —— 只能查看;这条召回还没派给任何人,请联系主管分配'
: '该召回已被其他同事认领,你只能查看'
}
className={cn(
......@@ -1154,7 +1155,7 @@ function TopBar({
: 'bg-slate-100 text-slate-500',
)}
>
{claimGate.claimState === 'unclaimed' ? '未认领 · 仅查看' : '仅查看'}
{claimGate.claimState === 'unclaimed' ? '未分配 · 仅查看' : '仅查看'}
</span>
)}
</div>
......
......@@ -81,6 +81,9 @@ export type PlanDetailData = {
recommendedRole: string | null;
recommendedChannel: string | null;
assigneeUserId: string | null;
/// 承接人姓名 —— 服务端解析后下发(前端翻不了:PAC 无 users 表,
/// dictionary.users 只覆盖当前登录人)。查不到为 null,由 adapt-data 退回 id 前缀。
assigneeName?: string | null;
assignedAt: string | null;
recycleAt: string | null;
/// 召回冷静期 / 终态抑制窗到期时间(execution 回写按 outcome 算)。
......
......@@ -26,7 +26,8 @@ export type PetGesture =
| 'vet_scale' // 洗牙(超声洁治 + 水珠)
| 'vet_fill' // 补牙(电钻 + 火星)
| 'vet_polish' // 抛光(抛光杯 + ✨)
| 'vet_fluoride'; // 涂氟(applicator,随后获护盾)
| 'vet_fluoride' // 涂氟(applicator,随后获护盾)
| 'absorb'; // 吸入:一批患者被"吸"进助手(移交人群时演)
/** 语义事件 — 感知层发,大脑订阅。 */
export type PetEvent =
......@@ -37,7 +38,13 @@ export type PetEvent =
/** 宠物开口说话的三个阶段:think=已发起 LLM 还没吐第一个词 / talk=流式中(嘴动) / end=说完 */
| { type: 'pet_voice'; phase: 'think' | 'talk' | 'end' }
/** 大脑 idle 轮盘抽中"牙医组合"→ 通知 widget 跑诊疗派生链 */
| { type: 'vet_combo' };
| { type: 'vet_combo' }
/**
* 把一批患者移交给助手(主管在召回池点「移交助手」)。
* ⚠️ 业务代码只发这个**语义**事件,不描述"怎么演" —— 演法归 pet-brain。
* 将来换演法(比如改成传送带动画)不用动分配功能的任何一行。
*/
| { type: 'cohort_handoff'; payload: { count: number; treatment?: string } };
/** 演出脚本 — 现在规则导演用,将来 LLM 导演同入口。 */
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({
/// 召回冷静期 / 终态抑制窗 deadline(execution 回写按 outcome 计算);
/// 终态+未到期 → 不重新生成;active+未到期 → 不进召回池(到点浮现)。null=无抑制
snoozedUntil: z.string().nullable(),
/// 实际指派给谁(宿主侧 user id);展示用 name 通过 token 字典查
/// 实际指派给谁(宿主侧 user id)
assigneeUserId: z.string().nullable(),
/**
* ⭐ 承接人姓名 —— **服务端解析后下发**,不要指望前端自己翻。
* PAC 没有 users 表,前端的 `dictionary.users` 只覆盖当前登录人,
* 于是详情页「承接人」长期显示的是一串 uuid。
* 姓名源是回访表 `task_director_name`(取最近一次;id→姓名是时间性的)。
* null = 未分配,或该 id 在回访表里查无记录(只做召回不做回访的人)。
*/
assigneeName: z.string().nullable().optional(),
assignedAt: z.string().nullable(),
createdAt: 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