Commit 73cf9501 by luoqi

feat(主管工作台): 矩阵摊开常驻,历史批次退成灰调

「分一批新的」不再是按钮 + 浮层:
· 删掉「样式2 里世界」整版(传送门 / 锤子光标 / 敲击动效)与那个样式开关,
  连同 globals.css 的 .pac-portal-* 一起清掉 —— 只留矩阵那版。
· 矩阵直接摊在「我分的批次」上方:左栏是上下结构(挑格子 → 看这些格子
  分出去之后跑成什么样),整栏再与「团队现在什么状态」左右并排。
· 矩阵在助手确认/撤销后跟着重拉 —— 刚分走的人就是从这些格子里出去的。

「我分的批次」区分在跑与历史:
· 在跑的 = 主色竖轨 + 白底 + 满饱和语义色;历史 = 灰底 + 同色相低饱和。
· 语义色**保留**(还看得出哪列是退回哪列是约上), 不做成纯灰。

🔴 顺带记下一个全站地雷:globals.css 的 `* { border-color: … }` 不在任何
@layer 里,压过 Tailwind 所有 border 颜色工具类 —— `border-l-brand-400`
写了不报错也不生效(竖轨最初就是这么哑掉的)。彩色边框只能走 inline style。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 0ffe757b
......@@ -112,7 +112,15 @@
Arial, "Microsoft YaHei", sans-serif;
}
/* 全局 border 颜色 — 这样写不用每处都标 border-border */
/* 全局 border 颜色 — 这样写不用每处都标 border-border
*
* 🔴 **这条规则不在任何 @layer 里,所以它压过 Tailwind 的 `border-*` 颜色工具类**
* (无层级的样式整体胜过 `@layer utilities`,与优先级无关)。
* ⇒ 组件里写 `border-l-brand-400` / `border-rose-300` 之类**不会报错、也不会生效**,
* 算出来永远是这里这个灰(2026-08-11 在主管工作台那条竖轨上实测踩到)。
* 要给某处上**彩色边框**,走 inline style 或 `@utility`,⛔ 别指望工具类。
* ⚠️ 想把这条改成分层的(让工具类重新生效)要整站回归:现在全站的 `border-xxx-100`
* 之类**都**落在这个灰上,一改就全变。 */
* {
border-color: hsl(var(--border));
}
......@@ -283,63 +291,14 @@ body {
.pac-stream-dot { display: none; }
}
/* ── 「分一批新的 · 里世界」传送门 ────────────────────────────────
幕布 = 一个**小圆**(200×200)从按钮位置 `transform: scale()` 放大到盖满,
内容在幕布快盖满时 `opacity` 淡入。
/* ── 「分一批新的 · 里世界」传送门 —— **2026-08-11 删** ──────────────
这里原来是那版全屏「打地鼠」的动效:一个 200×200 的小圆从按钮位置
`transform: scale()` 放大盖满全屏(`.pac-portal-disc`),内容随后 `opacity` 淡入
(`.pac-portal-body`),外加敲击形变 `whack` 与冲击环 `ringOut`。
产品定下只留矩阵那一版、并且常驻在页面上,整套演出连同 `ArcadeMatrix` 一起删。
🔴 **别再改回 `clip-path: circle()`**(2026-08-07 连着两轮翻车):
① 定位:`at <x> <y>` 是按元素的 reference box 算的,不是视口。
`fixed` 元素的包含块只要被祖先的 transform / filter / backdrop-filter /
contain 劫走,圆心就整体偏掉,而且**不报错**——
在开发面板里量得分毫不差,产品那边却"不是从鼠标处扩散的"。
现在用 `absolute` + `left/top`,量的和用的是**同一个盒子**,偏不了。
② 性能:clip-path 不上合成层,动画那半秒**每帧重画整屏**(含 48 个格子)。
现在只动 `transform` / `opacity`,全程合成层、零重绘。
⛔ 幕布别做成全屏大小再 scale:纹理按未缩放尺寸申请(2700² ≈ 28MB)。
小圆放大才是便宜的那条。
⚠️ 200×200 这个尺寸与组件里的 `PORTAL_DISC_R = 100` 是一对,改一处要改两处。
⚠️ 幕布是**纯色**、内容层才带渐变:淡入时两者交叠,
纯色取的是渐变的中段色,所以看不出接缝。⛔ 别给幕布也上渐变 ——
渐变会跟着 scale 一起放大,糊成一团。 */
.pac-portal-disc,
.pac-portal-disc-out {
position: absolute;
width: 200px;
height: 200px;
margin: -100px 0 0 -100px;
border-radius: 9999px;
background: #04225c;
will-change: transform;
}
/* ⚠️ 缓动别用 `ease-out` —— 起手就是满速,圆在头几十毫秒里就铺开半屏,人眼抓不住起点 */
.pac-portal-disc { animation: portalDisc .5s cubic-bezier(.4, 0, .2, 1) forwards; }
.pac-portal-disc-out { animation: portalDisc .34s cubic-bezier(.4, 0, 1, 1) reverse forwards; }
@keyframes portalDisc {
from { transform: scale(0); }
to { transform: scale(var(--s, 1)); }
}
/* 内容:等幕布铺到九成再进场 —— ⛔ 延迟别小于 .3s,否则内容会在屏幕外的黑边上先亮起来 */
.pac-portal-body { animation: portalBody .26s ease-out .34s both; }
.pac-portal-body-out { animation: portalBody .14s ease-in reverse both; }
@keyframes portalBody {
from { opacity: 0; transform: scale(.985); }
to { opacity: 1; transform: none; }
}
/* 敲下去:先陷进去再弹没 */
@keyframes whack {
0% { transform: translateY(0) scale(1); }
35% { transform: translateY(6px) scale(0.9); }
100% { opacity: 0; transform: translateY(16px) scale(0.7); }
}
/* 冲击环 */
@keyframes ringOut {
from { opacity: 0.9; transform: scale(0.4); }
to { opacity: 0; transform: scale(2.4); }
}
@media (prefers-reduced-motion: reduce) {
/* 无障碍:传送门直接到位、敲击不做形变 —— ⚠️ 移交本身照常发生,只是没有这段演出 */
.pac-portal-disc,
.pac-portal-disc-out { animation: none !important; transform: scale(var(--s, 1)); }
.pac-portal-body,
.pac-portal-body-out { animation: none !important; }
}
⚠️ 万一哪天要复活,去 git 里翻(`new-batch.tsx` 的 `ArcadeMatrix`),
⛔ 别凭印象重写 —— 那里踩过两条不重写一遍就还会再踩的坑:
① 幕布**别用 `clip-path: circle()`**:`at <x> <y>` 按元素的 reference box 算,
`fixed` 的包含块被祖先 transform/filter/contain 劫走后圆心整体偏,且不报错。
② 幕布**别做成全屏再 scale**:纹理按未缩放尺寸申请(2700² ≈ 28MB)。 */
......@@ -125,9 +125,11 @@ export function PoolMatrix({
—— 主管一对数就觉得系统在骗他(实测 2,526 vs 池子 2,129)。
他真正要的是「点哪一格拿多少人」,那是格子本身的数,不是列合计。
*/}
{/* ⚠️ 这里原来右边还有一句「点一格 = 把这批人交给助手」,2026-08-11 挪走了:
矩阵现在常驻在「分一批新的」面板里,那句话已经写在面板标题旁边,
两行挨着说同一件事是噪音。⛔ 别加回来 —— 要改措辞去改面板那一处。 */}
<CardHeader className="flex-row items-baseline justify-between space-y-0 p-3 pb-2.5">
<div className="text-[13px] font-semibold">潜在治疗</div>
<div className="text-[11px] text-muted-foreground">点一格 = 把这批人交给助手</div>
</CardHeader>
<CardContent className="p-3 pt-0">
......
......@@ -161,20 +161,23 @@ export function BatchTracking({ clinicId }: { clinicId: string | null }) {
* ⚠️ 分组只在「全部」视图里做 —— 「还在跑的」本身就是一组,再按月切没有意义。
*/
const groups = useMemo(() => {
const out: Array<{ key: string; note: string; rows: AssignmentBrief[] }> = [];
const out: BatchGroupData[] = [];
if (scope === 'running') {
return [{ key: '还在跑的', note: '时效未到、或还有没人动过的单', rows: shown }];
return [
{ key: '还在跑的', note: '时效未到、或还有没人动过的单', past: false, rows: shown },
];
}
const runIds = new Set(running.map((b) => b.id));
const run = shown.filter((b) => runIds.has(b.id));
if (run.length) out.push({ key: '还在跑的', note: '时效未到、或还有没人动过的单', rows: run });
if (run.length)
out.push({ key: '还在跑的', note: '时效未到、或还有没人动过的单', past: false, rows: run });
let lastKey = '';
for (const b of shown) {
if (runIds.has(b.id)) continue;
const d = new Date(b.createdAt);
const key = `${d.getMonth() + 1} 月`;
if (key !== lastKey) {
out.push({ key, note: '已经结束,只作回顾', rows: [] });
out.push({ key, note: '已经结束,只作回顾', past: true, rows: [] });
lastKey = key;
}
out[out.length - 1]!.rows.push(b);
......@@ -274,22 +277,79 @@ export function BatchTracking({ clinicId }: { clinicId: string | null }) {
);
}
type BatchGroupData = { key: string; note: string; past: boolean; rows: AssignmentBrief[] };
/**
* 「还在跑的」与「历史」两套配色(2026-08-11 产品:两者要在**颜色上**分得开)。
*
* ⭐ 分法是「**主色 + 满饱和** vs 灰底 + 褪色」:
* 在跑的那几批带一条主色竖轨、白底、数字保持原来的语义色(绿=已处置、红=退回、
* 琥珀=没动/到期)—— 它们是主管**还来得及动手**的;
* 历史批次退成灰底 + 同色相的低饱和,语义色仍在(还看得出哪列是什么),但整块往后退。
* ⛔ 别把历史行做成纯灰(把语义色一起抹掉):那样「退回 12」和「约上 12」看起来一样,
* 而回顾恰恰就是在比这几列。
* ⛔ 也别只靠那条竖轨 —— 一条 2px 的线滚起来看不见,底色才是扫视时抓得到的。
*
* 🔴 竖轨的颜色**必须走 inline style**(`rail` 是个 CSS 颜色值不是类名):
* globals.css 里那条 `* { border-color: hsl(var(--border)) }` **不在任何 @layer 里**,
* 压过 Tailwind 的 `border-l-brand-400` —— 写类名不报错、也不生效,
* 算出来是那个灰(2026-08-11 实测:整栏竖轨全是灰的,肉眼以为"没做")。
*/
const TONE = {
live: {
rail: 'var(--color-brand-400)',
row: 'bg-white hover:bg-slate-50',
when: 'text-slate-500',
cohort: 'text-slate-900',
meta: 'text-slate-400',
planned: 'text-slate-700',
handled: 'text-emerald-700',
idle: 'text-slate-500',
idleHeavy: 'font-semibold text-amber-700',
released: 'text-rose-600',
expired: 'text-amber-700',
booked: 'text-slate-900',
// 组标题也带上主色(浅底 + 深字)—— 竖轨太细,标题这一条才是扫视时先看到的
head: 'bg-brand-50',
headKey: 'text-brand-800',
},
past: {
rail: 'transparent',
row: 'bg-slate-50/80 hover:bg-slate-100',
when: 'text-slate-400',
cohort: 'text-slate-500',
meta: 'text-slate-400/80',
planned: 'text-slate-400',
handled: 'text-emerald-800/55',
idle: 'text-slate-400',
idleHeavy: 'font-medium text-amber-800/60',
released: 'text-rose-800/55',
expired: 'text-amber-800/55',
booked: 'text-slate-500',
head: 'bg-slate-100/70',
headKey: 'text-slate-500',
},
} as const;
function BatchGroup({
group,
now,
openId,
onOpen,
}: {
group: { key: string; note: string; rows: AssignmentBrief[] };
group: BatchGroupData;
now: number;
openId: string | null;
onOpen: (id: string) => void;
}) {
const t = group.past ? TONE.past : TONE.live;
/// 竖轨 —— ⛔ 只能 inline(见 TONE 上面那段:全局 `*` 规则把 border 颜色类吃掉了)
const rail = { borderLeft: `2px solid ${t.rail}` };
return (
<>
<tr>
<td colSpan={7} className="border-t border-slate-100 bg-white px-3.5 py-1.5">
<span className="text-[11px] font-semibold text-slate-600">{group.key}</span>
<td colSpan={7} style={rail} className={cn('border-t px-3.5 py-1.5', t.head)}>
<span className={cn('text-[11px] font-semibold', t.headKey)}>{group.key}</span>
<span className="ml-1.5 text-[10.5px] text-slate-400">{group.note}</span>
</td>
</tr>
......@@ -302,15 +362,18 @@ function BatchGroup({
key={b.id}
onClick={() => onOpen(b.id)}
className={cn(
'cursor-pointer border-t border-slate-50 hover:bg-slate-50',
'cursor-pointer border-t border-slate-50',
t.row,
// ⚠️ 选中态压在两套配色**之上** —— 抽屉开着的那一行必须一眼看出来,
// ⛔ 不能被历史行的灰底吃掉
openId === b.id && 'bg-brand-50 hover:bg-brand-50',
)}
>
<td className="px-3.5 py-2">
<td style={rail} className="px-3.5 py-2">
<div className="flex items-baseline gap-2 whitespace-nowrap">
<span className="nums text-[11.5px] text-slate-500">{when}</span>
<span className="font-medium text-slate-900">{cohort}</span>
<span className="nums text-[11px] text-slate-400">
<span className={cn('nums text-[11.5px]', t.when)}>{when}</span>
<span className={cn('font-medium', t.cohort)}>{cohort}</span>
<span className={cn('nums text-[11px]', t.meta)}>
跑了 {ageDays(b, now)} 天 · {b.agents} 位客服
</span>
{b.status === 'revoked' && (
......@@ -318,19 +381,14 @@ function BatchGroup({
)}
</div>
</td>
<td className="nums px-2.5 py-2 text-right">{b.planned}</td>
<td className="nums px-2.5 py-2 text-right text-emerald-700">{b.handled}</td>
<td
className={cn(
'nums px-2.5 py-2 text-right',
heavy ? 'font-semibold text-amber-700' : 'text-slate-500',
)}
>
<td className={cn('nums px-2.5 py-2 text-right', t.planned)}>{b.planned}</td>
<td className={cn('nums px-2.5 py-2 text-right', t.handled)}>{b.handled}</td>
<td className={cn('nums px-2.5 py-2 text-right', heavy ? t.idleHeavy : t.idle)}>
{b.inHand}
</td>
<td className="nums px-2.5 py-2 text-right text-rose-600">{b.released}</td>
<td className="nums px-2.5 py-2 text-right text-amber-700">{b.expired}</td>
<td className="nums px-2.5 py-2 text-right text-slate-900">{b.booked}</td>
<td className={cn('nums px-2.5 py-2 text-right', t.released)}>{b.released}</td>
<td className={cn('nums px-2.5 py-2 text-right', t.expired)}>{b.expired}</td>
<td className={cn('nums px-2.5 py-2 text-right', t.booked)}>{b.booked}</td>
</tr>
);
})}
......
'use client';
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { X } from 'lucide-react';
import { TEMPERATURE_META, TEMPERATURE_ORDER, type TemperatureValue } from '@pac/types';
import { useCallback, useEffect, useState } from 'react';
import { TEMPERATURE_META, type TemperatureValue } from '@pac/types';
import { plansApi, type PoolMatrix as PoolMatrixData } from '@/components/plans/plans-api';
import { PoolMatrix } from '@/components/plans/pool-matrix';
import { useAssignmentSyncStore } from '@/stores/assignment-sync-store';
import { useAssistantStore } from '@/stores/assistant-store';
import { cn } from '@/lib/utils';
/**
* 「分一批新的」—— **两版**(设计稿 `newBatchMode` 那两个选项,产品要求都实现)
* 「分一批新的」—— **常驻面板**,摆在「我分的批次」正上方
*
* · `window` 矩阵窗口:居中 modal 套现有 `PoolMatrix`。改动最小,就是把左栏那个浮层搬过来。
* · `arcade` 里世界打地鼠:全屏传送门 + 深色场 + 敲一格 + 数据粒子飞向助手。
* 🔴 2026-08-11 产品定:**不再由按钮触发、也不再有第二版**。
* 在此之前有两版(设计稿 `newBatchMode`):`window` 居中 modal、`arcade` 里世界打地鼠,
* 由头部一个「样式1 / 样式2」开关切,点「+ 分一批新的」才展开。
* ⇒ 现在只保留 `window` 那份的内容(矩阵本身),并且**直接摊开**:
* 矩阵是主管进这一屏的**第一个动作**,藏在按钮后面等于每次都要多点一下才看得到池子。
* ⛔ 别再把它收回浮层里 —— 上下结构(挑格子 → 看这些格子分出去之后怎么样)
* 本身就是这一栏要讲的因果,收起来这条线就断了。
* ⛔ 里世界那版(传送门 / 锤子光标 / 数据粒子)连同 globals.css 的 `.pac-portal-*`
* 一起删了 —— 需要考古去翻 git,⛔ 别凭印象重写一份。
*
* ⛔ **两版挑完格子走的是同一条路**:`useAssistantStore.handoff()` ——
* ⛔ 挑完格子走的仍是**同一条**移交路:`useAssistantStore.handoff()` ——
* 发数据流 → 粒子飞到助手 → 落地后自动开大窗并说那句话。
* ⚠️ 这条链路是现成的(左栏那个入口一直在用),⛔ 别在这儿另写一套移交:
* 移交的时序(动画多长、什么时候开窗)是舞台层的事,业务侧复制一份必然漂。
* ⚠️ 这条链路是现成的,⛔ 别在这儿另写一套移交:移交的时序(动画多长、
* 什么时候开窗)是舞台层的事,业务侧复制一份必然漂。
*/
export type NewBatchMode = 'window' | 'arcade';
type Picked = {
treatment: string;
......@@ -29,7 +34,6 @@ type Picked = {
rect: { x: number; y: number; w: number; h: number };
};
/** 两版共用的移交 —— ⛔ 别在各自分支里各写一遍 */
function handoff(c: Picked) {
const tempZh = TEMPERATURE_META[c.temperature].zh;
useAssistantStore.getState().handoff({
......@@ -41,21 +45,24 @@ function handoff(c: Picked) {
});
}
export function NewBatchOverlay({
mode,
clinicId,
onClose,
/** 「里世界」入场用的传送门圆心 = **触发按钮中心**的视口坐标;不给则从右上角进 */
origin,
}: {
mode: NewBatchMode;
clinicId: string | null;
onClose: () => void;
origin?: { x: number; y: number } | null;
}) {
export function NewBatchPanel({ clinicId }: { clinicId: string | null }) {
const [matrix, setMatrix] = useState<PoolMatrixData | null>(null);
const [error, setError] = useState<string | null>(null);
/**
* 换诊所先清空 —— ⛔ 不能让上一家的人数在新诊所的标题下面多留一帧。
* ⚠️ 只挂 `clinicId`:下面那次「分完重拉」不走这里,否则每分一批矩阵都要闪一下骨架。
*/
useEffect(() => {
setMatrix(null);
setError(null);
}, [clinicId]);
/**
* 取矩阵。**助手那边确认/撤销之后也要重拉**(`syncSeq`):
* 刚分走的人就是从这些格子里出去的,不重拉的话主管会对着**已经分掉的数**再点一次。
*/
const syncSeq = useAssignmentSyncStore((s) => s.seq);
useEffect(() => {
if (!clinicId) return;
let alive = true;
......@@ -66,375 +73,37 @@ export function NewBatchOverlay({
return () => {
alive = false;
};
}, [clinicId]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [onClose]);
const pick = useCallback(
(c: Picked) => {
onClose();
handoff(c);
},
[onClose],
);
}, [clinicId, syncSeq]);
if (mode === 'arcade') {
return (
<ArcadeMatrix
matrix={matrix}
error={error}
origin={origin ?? null}
onPick={pick}
onClose={onClose}
/>
);
}
const pick = useCallback((c: Picked) => handoff(c), []);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/30 p-4"
onClick={onClose}
>
<div
className="w-full max-w-[62rem] overflow-hidden rounded-xl bg-white shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-baseline gap-2 border-b border-slate-50 px-4 py-3">
<span className="text-[13.5px] font-semibold text-slate-900">分一批新的</span>
<span className="text-[11.5px] text-slate-400">
点一格 = 把这批人交给助手,助手出确认单
</span>
<button
type="button"
onClick={onClose}
aria-label="关闭"
className="ml-auto rounded-md p-1 text-slate-500 hover:bg-slate-100"
>
<X className="h-4 w-4" />
</button>
</div>
{/* ⚠️ 小屏兜底:矩阵有六列,窄了就横向滚,⛔ 别让它压缩到看不清数字 */}
<div className="overflow-x-auto p-4">
{error ? (
<p className="py-10 text-center text-[12px] text-rose-600">{error}</p>
) : matrix ? (
/* ⚠️ `w-full` 覆盖组件默认的 560px(那是给左栏 popover 的)——
不覆盖的话 modal 有 992px 宽而矩阵只占 560,右边一大片空白(实测)。
`min-w-[34rem]` 保证再窄也不把数字挤糊,由外层横向滚兜住。 */
<PoolMatrix data={matrix} selected={null} onPick={pick} className="w-full min-w-[34rem]" />
) : (
<div className="space-y-2 py-6">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-7 animate-pulse rounded bg-slate-100" />
))}
</div>
)}
</div>
<div className="flex w-full flex-col overflow-hidden rounded-lg border border-slate-100 bg-white">
<div className="flex flex-none flex-wrap items-baseline gap-x-2 gap-y-1 border-b border-slate-50 px-3.5 py-2.5">
<span className="text-[13.5px] font-semibold text-slate-900">分一批新的</span>
<span className="text-[11px] text-slate-400">
点一格 = 把这批人交给助手,助手出确认单
</span>
</div>
</div>
);
}
/**
* 「里世界」打地鼠版。
*
* ⚠️ 三个技术点是**实测会静默失效**的,别省:
* ① `offset-path` 粒子飞行 Safari 16 才支持 —— 低版本不崩但完全没动效,
* 所以粒子**只是锦上添花**,移交本身不依赖它(`handoff` 立即发出)。
* ② 自定义光标的 data URI 在 Windows 上**上限 32×32**,超了整个 cursor 被忽略 →
* 锤子必须画在 32×32 里(设计稿给的是 34,会被丢掉)。
* ③ `prefers-reduced-motion` 下把飞行与入场动画全关掉。
*
* ⭐ 地洞的色相**只由列位置取样**,与表世界那条 amber→emerald→sky 同一条轴 ——
* ⛔ 数量不参与配色(与 pool-matrix 同一条纪律)。
*/
function ArcadeMatrix({
matrix,
error,
origin,
onPick,
onClose,
}: {
matrix: PoolMatrixData | null;
error: string | null;
origin: { x: number; y: number } | null;
onPick: (c: Picked) => void;
onClose: () => void;
}) {
const rootRef = useRef<HTMLDivElement>(null);
const [hit, setHit] = useState<string | null>(null);
const [leaving, setLeaving] = useState(false);
/**
* 传送门的几何:圆心(相对**幕布自己**的左上角)+ 要放大多少倍。
*
* 🔴 换掉了原来的 `clip-path: circle()` 方案,两个原因,都踩过:
* ① **定位不可靠**:clip-path 的 `at <x> <y>` 是按元素的 **reference box** 算的,
* 不是视口。`fixed` 元素的包含块只要被祖先的 transform / filter /
* backdrop-filter / contain 之类劫走,这个盒子就不再是视口 ——
* 于是圆心整体偏掉,而且**不报任何错**。产品连着两轮反馈"不是从鼠标处扩散的",
* 在开发面板里却量得分毫不差,就是这个差别。
* 现在改成 `absolute` + `left/top`,量的是**同一个盒子**(`rootRef` 的 rect),
* 偏不了 —— 见下面的 `useLayoutEffect`。
* ② **性能**:clip-path 不上合成层,那 0.5s 里每一帧都要重画整屏(含 48 个格子)。
* 现在幕布是一个 200×200 的小圆靠 `transform: scale()` 放大,
* 内容只做 `opacity` —— 全程合成层,**零重绘**。
* ⛔ 别图省事把幕布直接做成全屏大小再 scale:那会申请一张按未缩放尺寸算的纹理
* (2700² ≈ 28MB),小圆放大才是便宜的那条路。
*
* ⚠️ 必须在 `useLayoutEffect` 里量(不是 render 里读 `window`):
* 要的是幕布**实际**占的那个盒子,不是 `innerWidth/innerHeight` 这个假设。
* ⚠️ 布局副作用里 setState 会在**绘制前**同步补一次 render,所以不会闪一帧。
*/
const [portal, setPortal] = useState<{ x: number; y: number; s: number } | null>(null);
useLayoutEffect(() => {
const el = rootRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
// 没给圆心时从右上角进(那也是按钮所在的角)
const p = origin ?? { x: r.left + r.width * 0.9, y: r.top + r.height * 0.06 };
const x = p.x - r.left;
const y = p.y - r.top;
// 盖满 = 圆心到最远那个角的距离
const far = Math.hypot(Math.max(x, r.width - x), Math.max(y, r.height - y));
setPortal({ x, y, s: far / PORTAL_DISC_R });
}, [origin]);
/**
* ⚠️ 幕布还没盖住之前,内容是 `opacity:0` 但**照样能点**(透明不等于穿透)——
* 那 0.36s 里手快点中一格就会盲发一批人。所以入场期间先关掉命中。
* ⛔ 别改用 `onAnimationEnd` 判断:格子自己也有动画、事件会冒泡上来,
* 而且 reduced-motion 下根本不触发。
*/
const [ready, setReady] = useState(false);
useEffect(() => {
const t = window.setTimeout(() => setReady(true), 360);
return () => window.clearTimeout(t);
}, []);
const leave = useCallback(() => {
setLeaving(true);
window.setTimeout(onClose, 380);
}, [onClose]);
const whack = (key: string, e: React.MouseEvent, c: Picked) => {
setHit(key);
// ⭐ 移交**立即发**,⛔ 不等动画:动画失败(Safari / reduced-motion)时也不能卡住业务
onPick({ ...c, rect: rectOf(e.currentTarget as HTMLElement) });
};
return (
/* ⚠️ 根节点**不能有背景** —— 传送门没张开的地方要露出下面的工作台,
"从按钮里钻进去"的因果关系全靠这个。 */
<div ref={rootRef} className="fixed inset-0 z-50 overflow-hidden">
{portal && (
<div
aria-hidden
className={leaving ? 'pac-portal-disc-out' : 'pac-portal-disc'}
style={
{ left: portal.x, top: portal.y, '--s': portal.s } as React.CSSProperties
}
/>
)}
<div
className={cn(
'absolute inset-0 flex flex-col items-center justify-center gap-4 px-6 py-8',
leaving ? 'pac-portal-body-out' : 'pac-portal-body',
!ready && 'pointer-events-none',
)}
style={{
background:
'radial-gradient(120% 90% at 50% 12%, #0B2E7A 0%, #031C53 45%, #010F32 100%)',
}}
>
<div className="flex w-full max-w-[74rem] items-baseline gap-3">
<span className="text-[17px] font-semibold tracking-wide text-slate-50">
挑一格,把这批人交给助手
</span>
<span className="text-[12px] text-brand-200/60">
行是潜在治疗,列是召回窗口 · 敲下去就出确认单
</span>
<button
type="button"
onClick={leave}
className="ml-auto rounded-lg border border-brand-200/25 bg-white/5 px-2.5 py-1 text-[11.5px] text-brand-100 hover:bg-white/15 hover:text-white"
>
退回
</button>
</div>
{error ? (
<p className="text-[12.5px] text-rose-300">{error}</p>
) : !matrix ? (
<p className="text-[12.5px] text-brand-200/60">正在点亮地洞…</p>
{/* ⚠️ 小屏兜底:矩阵有六列,窄了就横向滚,⛔ 别让它压缩到看不清数字 */}
<div className="overflow-x-auto">
{!clinicId ? (
<p className="py-8 text-center text-[12px] text-slate-400">先选一家诊所</p>
) : error ? (
<p className="py-8 text-center text-[12px] text-rose-600">{error}</p>
) : matrix ? (
/* ⚠️ `w-full` 覆盖组件默认的 560px(那是当年给左栏 popover 的)——
不覆盖的话这一栏有一千多像素宽而矩阵只占 560,右边一大片空白。
`min-w-[34rem]` 保证再窄也不把数字挤糊,由外层横向滚兜住。 */
<PoolMatrix data={matrix} selected={null} onPick={pick} className="w-full min-w-[34rem]" />
) : (
<div className="w-full max-w-[74rem]">
{/* 列头 —— 六个档位的光点,色相与下面的地洞同源 */}
<div className="flex items-center pb-2.5">
<div className="w-[4.75rem] flex-none" />
<div className="flex flex-1 gap-2.5">
{TEMPERATURE_ORDER.map((t, i) => (
<div
key={t}
className="flex flex-1 items-center justify-center gap-1.5 whitespace-nowrap text-[12px] font-semibold"
style={{ color: bandTint((i + 0.5) / TEMPERATURE_ORDER.length, 0.95) }}
>
<span
className="h-1.5 w-1.5 rounded-sm"
style={{
background: bandTint((i + 0.5) / TEMPERATURE_ORDER.length, 1),
boxShadow: `0 0 8px ${bandTint((i + 0.5) / TEMPERATURE_ORDER.length, 1)}`,
}}
/>
{TEMPERATURE_META[t].zh}
</div>
))}
</div>
</div>
<div className="flex items-start">
<div className="flex w-[4.75rem] flex-none flex-col gap-2.5">
{matrix.rows.map((r) => (
<div
key={r.key}
className="flex h-14 items-center text-[13px] font-medium text-slate-100/85"
>
{r.zh}
</div>
))}
</div>
<div
className="flex-1 rounded-2xl p-2.5"
style={{
background:
'linear-gradient(to right, rgba(252,211,77,.42), rgba(110,231,183,.34) 45%, rgba(125,211,252,.40))',
boxShadow: 'inset 0 0 0 1px rgba(255,255,255,.06)',
}}
>
<div className="flex flex-col gap-2.5">
{matrix.rows.map((row) => (
<div key={row.key} className="flex gap-2.5">
{TEMPERATURE_ORDER.map((t, ci) => {
const n = row.counts[t] ?? 0;
const key = `${row.key}-${t}`;
const tint = bandTint((ci + 0.5) / TEMPERATURE_ORDER.length, 0.34);
if (n === 0) {
return (
<span
key={t}
title="这一格没有人"
className="relative h-14 flex-1 rounded-xl"
style={{
background:
'radial-gradient(closest-side, rgba(2,10,32,.55), rgba(2,10,32,.18))',
boxShadow: 'inset 0 2px 6px rgba(0,0,0,.45)',
}}
/>
);
}
return (
<button
key={t}
type="button"
title={`${row.zh} · ${TEMPERATURE_META[t].zh} · ${n} 人`}
onClick={(e) =>
whack(key, e, {
treatment: row.key,
treatmentZh: row.zh,
temperature: t,
count: n,
rect: { x: 0, y: 0, w: 0, h: 0 },
})
}
className={cn(
'relative h-14 flex-1 rounded-xl border-0 text-[17px] font-semibold text-slate-50 transition-transform',
'hover:-translate-y-[3px] focus-visible:outline focus-visible:outline-2 focus-visible:outline-white',
hit === key && 'animate-[whack_.42s_ease-in_forwards]',
)}
style={{
cursor: HAMMER_CURSOR,
backgroundColor: tint,
backgroundImage:
'linear-gradient(180deg, rgba(255,255,255,.14), rgba(2,10,32,.20))',
boxShadow:
'inset 0 0 0 1px rgba(255,255,255,.14), 0 6px 14px -6px rgba(1,15,50,.8)',
textShadow: '0 1px 8px rgba(1,15,50,.6)',
}}
>
{n.toLocaleString()}
{hit === key && (
<span className="pointer-events-none absolute -inset-1.5 rounded-2xl border-2 border-white/90 animate-[ringOut_.5s_ease-out_forwards]" />
)}
</button>
);
})}
</div>
))}
</div>
</div>
</div>
<p className="mt-3 text-[11.5px] leading-relaxed text-brand-200/50">
前两档按该治疗自己的周期算,后四档按诊断距今多久算。一个人有几个潜在治疗就出现在几行
—— 所以各行相加会大于池子总人数。
</p>
<div className="space-y-2 p-3.5">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-6 animate-pulse rounded bg-slate-100" />
))}
</div>
)}
</div>
</div>
);
}
/**
* 传送门幕布的**未缩放**半径(px),与 globals.css 的 `.pac-portal-disc` 尺寸一一对应。
* ⚠️ 改这里就要同步改那边的 `width/height/margin`,否则放大倍数算错、盖不满或过冲。
*/
const PORTAL_DISC_R = 100;
/** 元素在视口里的矩形 —— 移交动画的起点 */
function rectOf(el: HTMLElement) {
const r = el.getBoundingClientRect();
return { x: r.left, y: r.top, w: r.width, h: r.height };
}
/**
* 地洞色相 —— 沿 amber→emerald→sky 这条轴按**列中心**取样。
* ⛔ 数量不参与(与 pool-matrix 同一条纪律):否则"这格亮是因为久没来、还是因为人多"分不清。
*/
const STOPS: Array<[number, [number, number, number]]> = [
[0, [252, 211, 77]],
[0.45, [110, 231, 183]],
[1, [125, 211, 252]],
];
function bandTint(f: number, alpha: number): string {
let a = STOPS[0]!;
let b = STOPS[STOPS.length - 1]!;
for (let i = 0; i < STOPS.length - 1; i++) {
if (f >= STOPS[i]![0] && f <= STOPS[i + 1]![0]) {
a = STOPS[i]!;
b = STOPS[i + 1]!;
break;
}
}
const t = (f - a[0]) / (b[0] - a[0] || 1);
const c = a[1].map((v, i) => Math.round(v + (b[1][i]! - v) * t));
return `rgba(${c.join(',')},${alpha})`;
}
/**
* 锤子光标。
* 🔴 **必须画在 32×32 之内** —— Windows 上自定义光标超过 32×32 会被**整个忽略**
* (设计稿给的是 34×34,那份在 Windows 上等于没有光标效果)。
*/
const HAMMER_SVG =
'<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">' +
'<g transform="rotate(-28 16 16)">' +
'<rect x="14" y="10" width="4" height="18" rx="2" fill="#DCE7FE" stroke="#01102F" stroke-width="1.6"/>' +
'<rect x="6" y="4" width="19" height="8.5" rx="2.5" fill="#F8FAFC" stroke="#01102F" stroke-width="1.6"/>' +
'<rect x="9" y="6" width="5" height="4.5" rx="1" fill="#85A9FA" opacity=".8"/>' +
'</g></svg>';
const HAMMER_CURSOR = `url("data:image/svg+xml,${encodeURIComponent(HAMMER_SVG)}") 12 8, pointer`;
'use client';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useState } from 'react';
import { clinicScopeReady, useAuthStore, visibleClinics } from '@/stores/auth-store';
import { AssistantWidget } from '@/components/assistant/assistant-widget';
import { SupervisorHeader } from './supervisor-header';
import { BatchTracking } from './batch-tracking';
import { TeamStatus } from './team-status';
import { NewBatchOverlay, type NewBatchMode } from './new-batch';
import { cn } from '@/lib/utils';
import { NewBatchPanel } from './new-batch';
/**
* 主管工作台 —— 左「我分的批次」/ 右「团队现在什么状态」。
* 主管工作台 —— 左「分一批新的 + 我分的批次」/ 右「团队现在什么状态」。
*
* ⚠️ 设计稿是 1440×1000 的固定框,这里做**自适应**(2026-08-07 产品定,与现有页面一致):
* 右栏固定 34rem、左栏吃剩下的;<1280px 时右栏落到下面(⛔ 别横向滚 ——
......@@ -48,83 +47,27 @@ export function SupervisorWorkbench() {
setClinicId(clinics[0]!.id);
}, [scopeReady, clinics, clinicId]);
/**
* 「分一批新的」两版并存(设计稿 `newBatchMode`,产品要求都实现)。
* ⚠️ 记在 localStorage:这是**个人偏好**不是业务配置 —— 主管挑顺手的那个用,
* ⛔ 别做成后台开关(那意味着全院统一,而这两版本来就是给人选的)。
*/
const [mode, setMode] = useState<NewBatchMode>('window');
useEffect(() => {
const v = localStorage.getItem('pac.newBatchMode');
if (v === 'arcade' || v === 'window') setMode(v);
}, []);
const pickMode = (m: NewBatchMode) => {
setMode(m);
localStorage.setItem('pac.newBatchMode', m);
};
const [overlay, setOverlay] = useState(false);
/// 传送门的圆心 = 按钮中心的视口坐标;⛔ 必须在点击那一刻取,浮层开了就量不到了
const originRef = useRef<{ x: number; y: number } | null>(null);
return (
<div className="flex h-screen flex-col overflow-hidden bg-slate-50">
<SupervisorHeader clinicId={clinicId} onClinicChange={setClinicId} />
{/* ⚠️ 工具行**靠右** —— 左边那两块面板才是主管进来要看的东西;
「分一批新的」是他看完之后的动作,摆在左上角会跟「我分的批次」标题抢第一眼。 */}
<div className="flex flex-none items-center justify-end gap-2 px-3 pt-3 xl:px-4">
{/* 两版切换 —— 挨着入口放,主管点之前就知道会进哪一版 */}
<span className="inline-flex rounded-lg bg-slate-100 p-0.5">
{(
[
{ m: 'window', label: '样式1' },
{ m: 'arcade', label: '样式2' },
] as const
).map((o) => (
<button
key={o.m}
type="button"
onClick={() => pickMode(o.m)}
className={cn(
'rounded-md px-2.5 py-1 text-[11.5px] transition-colors',
mode === o.m
? 'bg-white text-slate-900 shadow-sm'
: 'text-slate-500 hover:text-slate-700',
)}
>
{o.label}
</button>
))}
</span>
<button
type="button"
disabled={!clinicId}
onClick={(e) => {
/**
* 圆心 = **按钮中心**(2026-08-07 产品定:「改成从按钮扩散也行」)。
*
* ⚠️ 之前试过用鼠标落点 `clientX/Y`,产品那边连着两轮都说"不在点击处"。
* 真正的病根不在这儿(是幕布的定位方式,见 new-batch.tsx 的长注释),
* 但按钮中心**本来就够用**:按钮才 100 出头,误差顶天 50px,
* 而它是确定的、跟键盘触发也是同一个值 —— 少一条会静默走偏的分支。
* ⛔ 别再改回 `clientX/Y`:键盘触发时那两个值是 0,
* 一旦哪天漏了兜底,传送门就从屏幕左上角炸开。
*/
const r = e.currentTarget.getBoundingClientRect();
originRef.current = { x: r.left + r.width / 2, y: r.top + r.height / 2 };
setOverlay(true);
}}
className="rounded-lg bg-brand-600 px-3 py-1.5 text-[12.5px] font-medium text-white hover:bg-brand-700 disabled:opacity-40"
>
+ 分一批新的
</button>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-auto p-3 xl:flex-row xl:overflow-hidden xl:p-4">
<section className="flex min-h-[26rem] min-w-0 flex-1 xl:min-h-0">
<BatchTracking clinicId={clinicId} />
</section>
{/**
* 左栏是**上下结构**(2026-08-11 产品定):上面挑格子分人,下面看这些格子
* 分出去之后跑成什么样 —— 那是同一件事的**前后两段**,摞在一起才读得出因果。
* 右边的团队状态是**另一个维度**(按人看而不是按批看),所以跟这一整栏并排。
* ⛔ 别把矩阵挪到右栏或收回浮层:一收,上下这条因果链就断了。
*/}
<div className="flex min-w-0 flex-1 flex-col gap-3 xl:min-h-0">
{/* ⚠️ `flex-none`:矩阵是固定 8 行的东西,高度由内容定;
剩下的竖直空间全给批次列表(它才是要滚的那个)。 */}
<section className="flex-none">
<NewBatchPanel clinicId={clinicId} />
</section>
<section className="flex min-h-[26rem] min-w-0 flex-1 xl:min-h-0">
<BatchTracking clinicId={clinicId} />
</section>
</div>
{/* ⚠️ 470px(设计稿宽度)装不下「退回率」那一列:
百分比下面还有「退回 3 / 已处置 3 = 100.0%,另有 65 条没动」——
两个分母都得给(那是定死的口径),整行就被切掉右半截(实测)。
......@@ -134,15 +77,6 @@ export function SupervisorWorkbench() {
</section>
</div>
{overlay && (
<NewBatchOverlay
mode={mode}
clinicId={clinicId}
origin={originRef.current}
onClose={() => setOverlay(false)}
/>
)}
{/* 🔴 助手必须挂在这一页上 —— 挑完格子走 `handoff()`,落点就是它。
⚠️ 它原本只挂在 `plans/layout`;工作台是**另一条路由**(T16 已改写),
⛔ 不挂的话点完格子什么都不会发生,而且不报任何错。 */}
......
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