Commit 08a4b565 by luoqi

feat(web): 人群移交的数据流动画 —— 格子 → 助手 → 开大窗

点中矩阵某一格后:一串粒子沿弧线飞向助手 → 助手进「吸收」姿态 → 落地后自动
开会话窗(最大化)并说出那句话,接上原有流程。

沿用本仓既有取舍(不引动画库):CSS Motion Path(offset-path)+ 原生 keyframes,
曲线一次算好塞进 --pac-stream-path,粒子只改 offset-distance —— 位置由合成器算,
不占主线程,与宠物那 31 个 keyframes 同一套路。

 **两个端点都在播放那一刻现取**,这是"位置要准"的关键:
· 起点 = 事件带来的格子矩形 —— 浮层紧接着就关,不当场取就没了;
· 终点 = 助手 FAB 的**实时** rect(挂 data-assistant-fab 现查)——
  那颗钮可被主管拖到任意角落并记进 localStorage,写死右下角的话,
  拖过钮的人看到的就是粒子飞向空气。
弧线的控制点取中点沿法线抬起,抬的方向跟着走向翻转,否则从左上飞和从右下飞
会一个上凸一个下凹,看着像两套动画。

分层没有破:业务只发语义事件(cohort_handoff + from),**不描述怎么飞**;
飞法/粒子数/时长全在舞台层 cohort-stream。换成传送带只改那一个文件。
️ 时长的真理源在舞台层,store 引它来排"飞完再开窗" ——
业务侧自己 setTimeout 的话,每个调用点都要知道动画多长,改一次要满仓找。
反过来也不让舞台层去开助手:那会让"怎么演"决定"接下来做什么"。

顺带把 maximized 从 AssistantWidget 的组件内 useState 提到 store ——
与 open 当初同一个毛病:业务侧要"移交后直接开大窗"就够不着。

无障碍:prefers-reduced-motion: reduce 时粒子整段不出现,移交本身照常发生。

浏览器实测:一次点击产生 10 颗粒子,offset-path 起点 = 格心(312,180)、
终点 = FAB 中心(1236,676) 精确吻合,0.62s/颗、45ms 间隔;
落地后助手自动最大化并发出「帮我给「种植·黄金期」这批患者出一份分配方案」,
确认单渲染出「确认分配 43 条」与该格数字一致。
next build 通过,971 tests passing,控制台零报错。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 89ecf16b
......@@ -251,3 +251,34 @@ body {
--animate-overlayIn: overlayIn 0.14s cubic-bezier(0.22, 1, 0.36, 1);
--animate-overlayOut: overlayOut 0.1s ease-in;
}
/* ── 人群移交:数据流粒子 ──────────────────────────────────────────
点中矩阵某一格 → 一串小方块沿一条弧线飞向助手,像数据被"抽走"。
走 CSS Motion Path(offset-path):曲线一次算好塞进 --pac-stream-path,
每颗粒子只改 offset-distance —— 位置由合成器算,不占主线程,
与本仓「不引动画库」的既有取舍一致(宠物那 31 个 keyframes 也是这么来的)。
⚠️ 粒子是 position:fixed + left/top:0,所以 path 里的坐标**就是视口坐标**,
调用方直接把 getBoundingClientRect() 的值塞进去即可,不用换算。 */
@keyframes pacStreamFly {
0% { offset-distance: 0%; opacity: 0; scale: 0.5; }
15% { opacity: 1; scale: 1; }
80% { opacity: 1; scale: 1; }
100% { offset-distance: 100%; opacity: 0; scale: 0.4; }
}
.pac-stream-dot {
position: fixed;
left: 0;
top: 0;
width: 7px;
height: 7px;
border-radius: 2px;
pointer-events: none;
z-index: 62; /* 压过助手窗(60)与 FAB(61):粒子要能飞到脸上 */
offset-path: var(--pac-stream-path);
offset-rotate: 0deg; /* 保持正方向,不让方块跟着切线转 —— 转起来像碎屑不像数据 */
animation: pacStreamFly var(--pac-stream-dur, 620ms) cubic-bezier(0.4, 0, 0.35, 1) forwards;
}
@media (prefers-reduced-motion: reduce) {
/* 无障碍:不飞,直接不出现(移交本身仍照常发生,只是没有这段演出) */
.pac-stream-dot { display: none; }
}
......@@ -102,6 +102,9 @@ export function AssistantFab({
<button
ref={btnRef}
type="button"
// ⭐ 数据流粒子的终点靠这个属性现查(cohort-stream)。钮可拖动 + 位置记 localStorage,
// 所以终点必须**播放时取实时 rect**,不能写死右下角。
data-assistant-fab
title={open ? '收起助手' : '打开助手(可拖动)'}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
......
......@@ -7,6 +7,7 @@ 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 { CohortStream } from './cohort-stream';
import { AssistantChat } from './assistant-chat';
/**
......@@ -25,7 +26,10 @@ export function AssistantWidget() {
// 改了主管每收一次助手就丢一次对话,而且看不出任何异常。
const open = useAssistantStore((s) => s.open);
const setOpen = useAssistantStore((s) => s.setOpen);
const [maximized, setMaximized] = useState(false); // 最大化:居中放大窗
// ⭐ 最大化也提到 store(与 open 同理):移交完成后要能从外部直接开大窗。
// ⚠️ 仍然只是显隐/尺寸,**不卸载**对话(见文件头)。
const maximized = useAssistantStore((s) => s.maximized);
const setMaximized = useAssistantStore((s) => s.setMaximized);
// 窗口落点 + 尺寸:打开瞬间按"钮的当前位置"计算(拖钮的意义所在),并夹紧在视窗内
const [winRect, setWinRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
......@@ -75,7 +79,7 @@ export function AssistantWidget() {
setOpen(false);
setMaximized(false); // 收起时还原尺寸,下次打开仍是小窗
}}
onToggleMaximize={() => setMaximized((m) => !m)}
onToggleMaximize={() => setMaximized(!maximized)}
maximized={maximized}
examples={examples}
/>
......@@ -83,6 +87,8 @@ export function AssistantWidget() {
{/* 助手入口(静态版):固定右下角,点击开/关助手窗;只在 AI 思考/说话、成约、切患者时原地反馈。
完整桌宠(漫游/物理/狩猎)保留在 /pet-lab 演示页,不进工作区(老板反馈"做得太过")。 */}
<AssistantFab open={open} onToggle={(r) => (open ? setOpen(false) : openAt(r))} />
{/* 数据流舞台:挂在这里是因为它要的两个端点(格子 / FAB)都在同一屏,且与助手同生命周期 */}
<CohortStream />
</>
);
}
'use client';
import { useEffect, useState } from 'react';
import { onPetEvent } from '@/lib/pet-events';
/**
* CohortStream — 「人群移交」的**舞台层**:一串数据流粒子从矩阵格子飞进助手。
*
* 三层架构里它属于**身体/舞台**那一层(同 pet-body):
* 业务侧只发一个语义事件 `cohort_handoff{ count, from }`,**不描述怎么飞**;
* 飞法、粒子数、时长全在这里。换成传送带、换成粒子聚合,业务代码一行不动。
*
* ⭐ 两个端点都**在播放的那一刻现取**,不预存:
* 起点 = 事件带来的格子矩形(浮层随后就关了,不现取就没了);
* 终点 = 助手 FAB 的实时位置 —— 那颗钮**可以被主管拖到任意角落**并记进 localStorage,
* 写死右下角的话,拖过钮的人看到的就是粒子飞向空气。
*/
/** 单颗粒子的飞行时长。⚠️ 与 assistant-store 的 HANDOFF_FLIGHT_MS 是同一件事,那边引这里 */
export const STREAM_FLIGHT_MS = 620;
/** 粒子数与出发间隔 —— 末颗落点 ≈ (N-1)*STAGGER + FLIGHT */
const DOTS = 10;
const STAGGER_MS = 45;
/** 整段演出总时长(末颗落地) */
export const STREAM_TOTAL_MS = (DOTS - 1) * STAGGER_MS + STREAM_FLIGHT_MS;
interface Flight {
id: number;
path: string;
}
export function CohortStream() {
const [flights, setFlights] = useState<Flight[]>([]);
useEffect(() => {
let seq = 0;
return onPetEvent((e) => {
if (e.type !== 'cohort_handoff' || !e.payload.from) return;
const from = e.payload.from;
// 终点:助手 FAB 的**当前**位置(可拖动,所以必须现查)
const fab = document.querySelector('[data-assistant-fab]')?.getBoundingClientRect();
if (!fab) return; // 没挂助手(无 AGENT_INVOKE 权限)→ 不演,静默跳过
const x0 = from.x + from.w / 2;
const y0 = from.y + from.h / 2;
const x1 = fab.left + fab.width / 2;
const y1 = fab.top + fab.height / 2;
// 控制点:取中点再往"垂直于连线"的方向抬一点 → 一条自然的弧,而不是直线飞
// ⚠️ 抬的方向跟着走向翻转,否则从左上飞和从右下飞会一个上凸一个下凹,看着像两套动画
const mx = (x0 + x1) / 2;
const my = (y0 + y1) / 2;
const dist = Math.hypot(x1 - x0, y1 - y0);
const lift = Math.min(120, dist * 0.22);
const cx = mx + ((y1 - y0) / (dist || 1)) * lift;
const cy = my - ((x1 - x0) / (dist || 1)) * lift;
const id = ++seq;
setFlights((f) => [...f, { id, path: `path("M ${x0} ${y0} Q ${cx} ${cy} ${x1} ${y1}")` }]);
// 自清理:动画是 forwards,不删节点会一直堆在 DOM 里
window.setTimeout(() => setFlights((f) => f.filter((x) => x.id !== id)), STREAM_TOTAL_MS + 120);
});
}, []);
if (flights.length === 0) return null;
return (
<>
{flights.map((f) =>
Array.from({ length: DOTS }, (_, i) => (
<span
key={`${f.id}-${i}`}
aria-hidden
className="pac-stream-dot bg-brand-600"
style={
{
'--pac-stream-path': f.path,
'--pac-stream-dur': `${STREAM_FLIGHT_MS}ms`,
animationDelay: `${i * STAGGER_MS}ms`,
// 首尾两颗淡一点、小一点 → 队列有头有尾,不是一坨方块平移
opacity: i === 0 || i === DOTS - 1 ? 0.55 : 1,
} as React.CSSProperties
}
/>
)),
)}
</>
);
}
......@@ -25,7 +25,6 @@ import { PriorityHover, type PriorityBreakdown } from '@/components/priority-hov
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
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 { PoolMatrix } from './pool-matrix';
......@@ -189,19 +188,22 @@ export function PatientPickerRail({
treatmentZh: string;
temperature: 'hot' | 'warm' | 'cold';
count: number;
rect: { x: number; y: number; w: number; h: number };
}) => {
// ⛔ 别内联中文表:档位显示名的单一真理源是 TEMPERATURE_META(改措辞只改那一处)
const tempZh = TEMPERATURE_META[c.temperature].zh;
setCell({ treatment: c.treatment, treatmentZh: c.treatmentZh, temperature: c.temperature });
setMatrixMode(false);
emitPetEvent({
type: 'cohort_handoff',
payload: { count: c.count, treatment: c.treatmentZh, temperature: tempZh },
// ⭐ 一次调用做完整段移交:发数据流 → 粒子飞到助手 → 落地后自动开大窗并说那句话。
// 这里**不自己 setTimeout**:动画多长是舞台层的事,业务侧知道了就得跟着改(见 store 注释)。
useAssistantStore.getState().handoff({
text: `帮我给「${c.treatmentZh} · ${tempZh}」这批患者出一份分配方案`,
from: c.rect,
count: c.count,
treatment: c.treatmentZh,
temperature: tempZh,
});
useAssistantStore
.getState()
.ask(`帮我给「${c.treatmentZh} · ${tempZh}」这批患者出一份分配方案`);
};
// 返池:回收已认领的 plan → 回到召回池可被再次认领(plan 仍 active)。需 PLAN_RECYCLE 权限。
// 返池后该 plan 离开「我的」:在我的 tab → 原地移除该行;其它 tab → 仅改状态。
// 不重拉,保滚动/筛选。
......
......@@ -64,8 +64,18 @@ export function PoolMatrix({
clinicId?: string;
/** 当前选中的格子(回显用) */
selected?: { treatment: string; temperature: TempKey } | null;
/** 点格子 → 初选完成,把这一格的人群交出去 */
onPick: (cell: { treatment: string; treatmentZh: string; temperature: TempKey; count: number }) => void;
/**
* 点格子 → 初选完成,把这一格的人群交出去。
* ⚠️ 带 `rect`(该格子的视口矩形):数据流动画的**起点**。
* 必须在点击那一刻取 —— 浮层紧接着就关了,回头再查只能拿到 null。
*/
onPick: (cell: {
treatment: string;
treatmentZh: string;
temperature: TempKey;
count: number;
rect: { x: number; y: number; w: number; h: number };
}) => void;
}) {
const [data, setData] = useState<PoolMatrix | null>(null);
const [error, setError] = useState<string | null>(null);
......@@ -189,7 +199,13 @@ function MatrixRow({
}: {
row: PoolMatrixRow;
selected?: { treatment: string; temperature: TempKey } | null;
onPick: (cell: { treatment: string; treatmentZh: string; temperature: TempKey; count: number }) => void;
onPick: (cell: {
treatment: string;
treatmentZh: string;
temperature: TempKey;
count: number;
rect: { x: number; y: number; w: number; h: number };
}) => void;
}) {
// ⛔ 中文一律查 labels.ts,别在组件里另写一张表 —— 改措辞要即时生效、且全站一处。
// 用**项目名**(种植 / 早矫 / 拔牙)而不是卡片措辞(种植治疗):
......@@ -205,7 +221,16 @@ function MatrixRow({
key={c}
type="button"
disabled={n === 0}
onClick={() => onPick({ treatment: row.key, treatmentZh: zh, temperature: c, count: n })}
onClick={(e) => {
const r = e.currentTarget.getBoundingClientRect();
onPick({
treatment: row.key,
treatmentZh: zh,
temperature: c,
count: n,
rect: { x: r.left, y: r.top, w: r.width, h: r.height },
});
}}
// ⭐ hover 文案保持原样:只给**天数区间**,不给解释。
// 格子里已经有数字、列头已经写了档位名,再叠一段说明就是噪声;
// 区间是主管唯一看不出来的那个信息。
......
......@@ -46,8 +46,17 @@ export type PetEvent =
*/
| {
type: 'cohort_handoff';
/** temperature 是**中文档位**('热'/'温'/'冷'),不是 code —— 它只进气泡文案,不进逻辑 */
payload: { count: number; treatment?: string; temperature?: string };
/**
* temperature 是**中文档位**,不是 code —— 它只进气泡文案,不进逻辑。
* `from` 是**起点矩形**(点中的那个矩阵格子,视口坐标):数据流动画要从它飞向助手。
* ⚠️ 业务侧给的是"从哪儿来",**不描述怎么飞** —— 飞法归舞台层(cohort-stream)。
*/
payload: {
count: number;
treatment?: string;
temperature?: string;
from?: { x: number; y: number; w: number; h: number };
};
};
/** 演出脚本 — 现在规则导演用,将来 LLM 导演同入口。 */
......
'use client';
import { create } from 'zustand';
import { emitPetEvent } from '@/lib/pet-events';
import { STREAM_TOTAL_MS } from '@/components/assistant/cohort-stream';
/**
* 助手窗的外部控制口 —— 让**业务代码**能打开助手并塞一句话进去。
......@@ -25,12 +27,34 @@ interface AssistantState {
open: boolean;
setOpen: (open: boolean) => void;
/**
* 最大化。⚠️ 原先是 `AssistantWidget` 的组件内 `useState` —— 与 `open` 当初同一个毛病:
* 业务侧要"移交后直接开大窗"就够不着。提到 store 后两者一起由外部驱动。
*/
maximized: boolean;
setMaximized: (maximized: boolean) => void;
/**
* 待发送的一句话。`seq` 自增即为"有新请求";`text` 是要替主管说的话。
* null = 没有待处理请求。
*/
pending: { text: string; seq: number } | null;
/** 打开助手并发一句话(移交入口用) */
ask: (text: string) => void;
/** 打开助手并发一句话(移交入口用);maximize=true 时同时开大窗 */
ask: (text: string, opts?: { maximize?: boolean }) => void;
/**
* 人群移交 —— 发数据流动画,**等它飞完**再开助手。
*
* ⚠️ 为什么不在业务侧自己 setTimeout:那样每个调用点都要知道动画多长,
* 改一次时长要满仓找。时长的真理源在 cohort-stream(舞台层),这里引它。
* ⚠️ 为什么不让舞台层自己开助手:那会让"怎么演"反过来决定"接下来做什么" ——
* 业务语义(要说哪句话、开不开大窗)必须留在业务这边。
* 舞台只负责演,时长是它对外的唯一契约。
*/
handoff: (input: {
text: string;
from: { x: number; y: number; w: number; h: number };
count: number;
treatment?: string;
temperature?: string;
}) => void;
/** 消费方(AssistantChat)发完后调,避免重复发送 */
consume: (seq: number) => void;
}
......@@ -38,11 +62,19 @@ interface AssistantState {
export const useAssistantStore = create<AssistantState>((set, get) => ({
open: false,
setOpen: (open) => set({ open }),
maximized: false,
setMaximized: (maximized) => set({ maximized }),
pending: null,
ask: (text) => {
ask: (text, opts) => {
const seq = (get().pending?.seq ?? 0) + 1;
// 先开窗再挂待办:窗没开就发消息,主管会看到助手"自己动了"却不知道在哪
set({ open: true, pending: { text, seq } });
set({ open: true, pending: { text, seq }, ...(opts?.maximize ? { maximized: true } : {}) });
},
handoff: ({ text, from, count, treatment, temperature }) => {
// ① 先发语义事件:舞台层据此放数据流,大脑层据此进"吸收"姿态
emitPetEvent({ type: 'cohort_handoff', payload: { count, treatment, temperature, from } });
// ② 粒子飞完再开窗 —— 先开窗会把飞行路径整个盖住,演了等于没演
window.setTimeout(() => get().ask(text, { maximize: true }), STREAM_TOTAL_MS);
},
consume: (seq) => {
// 只清掉自己那一条 —— 期间若又来了新请求(seq 更大),不能连它一起清
......
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