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 { 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