Commit a4dd6fa6 by luoqi

feat(web): 矩阵改用 claude design 的 1b「连续色带」+ 浮层真动效

按产品在 claude design 出的稿子(分配矩阵.dc.html 的 1b 方案)重做:

· 三列共用**一条** amber → emerald → sky 的横向渐变,格子透明浮在这张面上 ——
  列与列之间没有缝,整片读起来是一条"由烫到冷"的轴,只用色相区分窗口。
· 用 shadcn Card(CardHeader/CardContent)承载,浮层只负责定位与动效,宽度交给 Card 自己定
  —— 两处都写宽度必然对不齐,而且改一处会静默留下另一处的白边。
· 表头补列汇总(黄金期 271 / 窗口内 280 / 窗口外 3,254),现算不新增字段。
· hover 文案**保持原样**:只给天数区间,不给解释。

️ 配色是**恢复**但不是回到老样子:老那套是"温度比喻"的冷暖渐变(已撤),
这套是与列头一一对应的三个色相 —— 色相与文字是同一信息的两种编码,
刻意的冗余(色盲靠文字、扫视靠颜色)。「数量不参与配色」这条自始至终没变。

 顺带修掉两处**写了不生效、且完全不报错**的样式:

① popover.tsx 一直挂着 shadcn 官方的 `animate-in fade-in-0 …`,而那套来自
   tailwindcss-animate 插件、**本仓根本没装** → 浮层一帧动画都没有。
   改用原生 keyframes,并**经 @theme 注册成 --animate-***:
   Tailwind 的 variant 只作用在它自己生成的 utility 上,手写同名 class 拿不到
   `data-[state=open]:` —— 实测 animation-name: none(class 在、样式在,就是没被选中)。
   缩放锚点接 --radix-popover-content-transform-origin,浮层从触发按钮那个角长出来。

② 格子的 hover 描边先写成 `shadow-[inset_0_0_0_2px_var(--color-slate-900)]`,
   Tailwind v4 把带 var() 的任意值解析成"只有颜色的阴影",偏移和扩散全丢,
   实测是 `oklab(0 0 0 / 0) 0 0 0 0 inset` —— 完全透明。改用原生 outline;
   ️ 还必须带 `outline-solid`,否则基态的 `outline-none` 把 outline-style 钉成 none,
   只加 outline-2 就是"宽度 2px、样式 none",一条线都不画。

浏览器逐项实测:渐变面(oklab 插值)、hover 内描边(solid 2px, offset -2px)、
出现/消失动画(overlayIn 0.14s / overlayOut 0.1s,Radix 会等动画结束再卸载)、
tooltip 区间不变;next build 通过,971 tests passing,控制台零报错。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent c9d70688
......@@ -229,3 +229,25 @@ body {
::-webkit-scrollbar-corner {
background: transparent;
}
/* ── Radix 浮层的出现/消失 ────────────────────────────────────────
⚠️ shadcn 官方那套 `animate-in fade-in-0 zoom-in-95` 来自 tailwindcss-animate 插件,
**本仓没装**(内网拉包本身是风险)—— 那些 class 写了也不生效,
popover.tsx 里原先就挂着一串,实际一帧动画都没有。这里用原生 keyframes 补上。
⚠️⚠️ 必须经 `@theme` 注册成 `--animate-*`,**不能只写一条 `.animate-overlayIn` 类**:
浮层要靠 `data-[state=open]:animate-overlayIn` 触发,而 Tailwind 的 variant
只作用在**它自己生成的 utility** 上 —— 手写的同名 class 拿不到 variant,
实测算出来 animation-name: none(class 在、样式在,就是没被 variant 选中)。 */
@keyframes overlayIn {
from { opacity: 0; transform: scale(0.96) translateY(-2px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
@keyframes overlayOut {
from { opacity: 1; transform: scale(1) translateY(0); }
to { opacity: 0; transform: scale(0.96) translateY(-2px); }
}
@theme {
--animate-overlayIn: overlayIn 0.14s cubic-bezier(0.22, 1, 0.36, 1);
--animate-overlayOut: overlayOut 0.1s ease-in;
}
......@@ -267,7 +267,9 @@ export function PatientPickerRail({
分配
</button>
</PopoverTrigger>
<PopoverContent align="end" sideOffset={6} className="w-[420px] p-0">
{/* 宽度交给 PoolMatrix 里的 Card 自己定(w-auto),浮层只负责定位与动效;
两处都写宽度必然对不齐,而且改一处会静默留下另一处的白边。 */}
<PopoverContent align="end" sideOffset={6} className="w-auto p-0">
<PoolMatrix selected={cell} onPick={pickCell} />
</PopoverContent>
</Popover>
......
......@@ -10,32 +10,51 @@ import {
type TemperatureValue,
} from '@pac/types';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { plansApi, type PoolMatrix, type PoolMatrixRow } from './plans-api';
/**
* PoolMatrix — 初选矩阵(8 潜在治疗 × 3 窗口温度)
* PoolMatrix — 初选矩阵(8 潜在治疗 × 3 召回窗口)。排版按 claude design 的 **1b「连续色带」**
*
* 生产线的**第一环**:主管在这里点一格,就完成了「初选」,人群随即交给助手出确认单。
* 矩阵是召回池的一个**视图模式**(tab 行「分配」浮层展开),⛔ 不新开路由 ——
* 主管本质也是客服、也要执行,割裂成两个页面会把他劈成两个身份(T16)。
*
* ═══ 为什么这里**没有**配色(2026-08 产品定,是撤回不是遗漏)═══════════
* 列头从「热 / 温 / 冷」改成**临床说法**「黄金期 / 窗口内 / 窗口外」之后,
* 温度渐变那套颜色就成了**第二套更弱的编码** —— 名字已经把语义说准了,
* 再刷一层冷暖色只是重复,还得额外维护对比度、色标位置、深色模式。一并撤掉。
* ═══ 1b 的核心:矩阵区是**一张连续的渐变面**,不是 24 个色块 ═══════════
* 三列共用一条 `amber → emerald → sky` 的横向渐变,**只用色相区分窗口**;
* 格子本身透明,浮在这张面上。列与列之间没有缝,整片读起来是一条"由烫到冷"的轴。
*
* ⛔ **别再把冷暖色加回来**(尤其别按数量深浅上色):
* 「这格橙是因为在黄金期、还是因为人多」分不清 —— 数量已经用数字表达了,
* 档位已经用列头表达了,两者都不需要颜色再说一遍。
* ⛔ 唯一允许的颜色是**交互态**(hover / 选中):那表达的是"你正指着谁",不是数据。
* ⛔ **数量绝不参与配色**(这条纪律一直没变,1b 也遵守):
* 渐变只由**列的位置**决定 —— 否则「这格暖是因为在黄金期、还是因为人多」分不清。
* 人数已经用数字表达了,不需要颜色再说一遍。
* ⚠️ 色相承载的是**窗口档位**,与列头文字是同一个信息的两种编码 —— 这是刻意的冗余
* (色盲用户靠文字,扫视靠颜色),不是重复。
*
* ⚠️ 用 `/nn` 透明度而不是设计稿里的 `color-mix(in oklch, …, white)`:
* 两者在白底卡片上视觉等价(Tailwind v4 的透明度修饰符本身就编译成 color-mix),
* 而透明度写法**不写死任何十六进制**,符合 globals.css 那条「别在组件里写死色值」。
*/
/// 列序走共享的 TEMPERATURE_ORDER,中文走共享的 TEMPERATURE_META —— ⛔ 别在这里内联中文表
const COLUMNS = TEMPERATURE_ORDER;
type TempKey = TemperatureValue;
/// 行标签列宽。列头与每一行都靠它对齐,别在某一处手改
const LABEL_W = 'w-[52px]';
/**
* 每档的**色相**。⚠️ 只用于「这一列是哪个窗口」的标识(列头圆点 + 汇总数字),
* 格子的底色来自下方那张整片渐变,不是从这里取的 —— 两处若各上各的色就会对不齐。
*/
const HUE: Record<TempKey, { dot: string; text: string }> = {
hot: { dot: 'bg-amber-600', text: 'text-amber-700' },
warm: { dot: 'bg-emerald-600', text: 'text-emerald-700' },
cold: { dot: 'bg-sky-600', text: 'text-sky-700' },
};
/// 整片矩阵那条渐变 —— 一处定义,列头刻度与格子面共用;写两遍必然漂
const PLANE = 'bg-linear-to-r from-amber-600/55 via-emerald-600/45 to-sky-600/40';
/// 行标签列宽 + 行高:列头、标签列、格子面三者靠它们对齐,别在某一处手改
const LABEL_W = 'w-[54px]';
const ROW_H = 'h-[30px]';
export function PoolMatrix({
clinicId,
......@@ -64,11 +83,12 @@ export function PoolMatrix({
}, [clinicId]);
if (error) {
return <div className="p-3 text-[12px] text-rose-600">矩阵加载失败:{error}</div>;
return <div className="p-4 text-xs text-rose-600">矩阵加载失败:{error}</div>;
}
if (!data) {
// 骨架与真身**同高**,浮层不会先窄后宽跳一下(浮层的抖动比列表更刺眼)
return (
<div className="flex items-center justify-center gap-2 p-6 text-[12px] text-slate-400">
<div className="flex h-[318px] items-center justify-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
正在数人…
</div>
......@@ -76,55 +96,98 @@ export function PoolMatrix({
}
const showUnknown = data.unknownTotal > 0;
/// 列汇总 = 该列 8 行之和。⚠️ 现算不请求:后端给的是格子,汇总是纯展示派生,不该多一个字段去漂
const colTotal = (c: TempKey) => data.rows.reduce((a, r) => a + r[c], 0);
return (
<div className="p-2">
{/* 列头 —— 三档用**临床说法**,不是「热/温/冷」那套比喻 */}
<div className="flex items-center border-b border-slate-100 pb-1">
<div className={cn(LABEL_W, 'shrink-0 text-[11px] text-slate-400')}>潜在治疗</div>
<Card className="w-[380px] border-0 shadow-none">
<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="flex items-baseline gap-2.5 whitespace-nowrap text-[11px] text-muted-foreground nums">
{COLUMNS.map((c) => (
<span key={c}>
{TEMPERATURE_META[c].zh}{' '}
<span className={cn('font-semibold', HUE[c].text)}>{colTotal(c).toLocaleString()}</span>
</span>
))}
</div>
</CardHeader>
<CardContent className="p-3 pt-0">
{/* 列头:圆点 + 档位名,颜色与下方渐变面的对应段同族 */}
<div className="flex items-center pb-1.5">
<div className={cn(LABEL_W, 'shrink-0')} />
<div className="flex flex-1">
{COLUMNS.map((c) => (
<div
key={c}
title={TEMPERATURE_META[c].hint}
className="flex-1 text-center text-[12px] font-medium text-slate-700"
className={cn(
'flex flex-1 items-center justify-center gap-1.5 text-[11px] font-semibold',
HUE[c].text,
)}
>
<span className={cn('h-1.5 w-1.5 rounded-sm', HUE[c].dot)} />
{TEMPERATURE_META[c].zh}
</div>
))}
</div>
{showUnknown && <div className="w-11 shrink-0 text-center text-[11px] text-slate-400">待重算</div>}
{showUnknown && (
<div className="w-10 shrink-0 text-center text-[11px] text-muted-foreground">待重算</div>
)}
</div>
<div className="mt-0.5">
<div className="flex items-start">
{/* 行标签独立成列 —— 它不该压在渐变面上(压上去白底文字会跟着色相变脏) */}
<div className={cn(LABEL_W, 'shrink-0')}>
{data.rows.map((row) => (
<MatrixRow
<div key={row.key} className={cn(ROW_H, 'flex items-center text-xs font-medium')}>
{potentialTreatmentItemName(row.key)}
</div>
))}
</div>
{/* ⭐ 1b 的主体:**一整张**渐变面,8×3 个格子透明浮在上面 */}
<div className={cn('flex-1 overflow-hidden rounded', PLANE)}>
{data.rows.map((row) => (
<MatrixRow key={row.key} row={row} selected={selected} onPick={onPick} />
))}
</div>
{showUnknown && (
<div className="w-10 shrink-0">
{data.rows.map((row) => (
<div
key={row.key}
row={row}
showUnknown={showUnknown}
selected={selected}
onPick={onPick}
/>
title="温度待重算 —— 画像还没算出窗口边界"
className={cn(
ROW_H,
'flex items-center justify-center text-[11px] text-muted-foreground nums',
)}
>
{row.unknown || '—'}
</div>
))}
</div>
)}
</div>
{data.note && (
<p className="mt-1.5 rounded bg-amber-50 px-2 py-1.5 text-[10.5px] leading-relaxed text-amber-800">
<p className="mt-2 rounded bg-amber-50 px-2 py-1.5 text-[10.5px] leading-relaxed text-amber-800">
{data.note}
</p>
)}
</div>
</CardContent>
</Card>
);
}
function MatrixRow({
row,
showUnknown,
selected,
onPick,
}: {
row: PoolMatrixRow;
showUnknown: boolean;
selected?: { treatment: string; temperature: TempKey } | null;
onPick: (cell: { treatment: string; treatmentZh: string; temperature: TempKey; count: number }) => void;
}) {
......@@ -133,11 +196,7 @@ function MatrixRow({
// 8×3 的表里每行都拖着「治疗」两个字纯属噪声,还挤掉列宽。
const zh = potentialTreatmentItemName(row.key);
return (
<div className="flex items-stretch">
<div className={cn(LABEL_W, 'shrink-0 self-center pr-1.5 text-right text-[11.5px] text-slate-600')}>
{zh}
</div>
<div className="flex flex-1">
<div className="flex">
{COLUMNS.map((c) => {
const n = row[c];
const isSelected = selected?.treatment === row.key && selected.temperature === c;
......@@ -147,31 +206,31 @@ function MatrixRow({
type="button"
disabled={n === 0}
onClick={() => onPick({ treatment: row.key, treatmentZh: zh, temperature: c, count: n })}
// ⭐ hover 只给**天数区间**,不给解释:格子里已经有数字、列头已经写了档位名,
// 再叠一段说明就是噪声。区间是主管唯一看不出来的那个信息。
// ⭐ hover 文案保持原样:只给**天数区间**,不给解释。
// 格子里已经有数字、列头已经写了档位名,再叠一段说明就是噪声;
// 区间是主管唯一看不出来的那个信息。
title={temperatureWindowHint(row.key, c)}
className={cn(
'flex-1 rounded py-1.5 text-[12px] tabular-nums transition-colors',
// ⛔ 颜色只表达**交互态**,不表达数据(见文件头)
ROW_H,
'flex-1 text-[13px] text-slate-900 nums',
// ⭐ hover/选中用**内描边**而不是换底色 —— 换底色会在渐变面上戳一个洞,
// 整片"连续"的观感当场破掉。描边只加一圈框,底下的渐变原样透出来。
// ⚠️ 用 outline 而不是 shadow-[inset_…_var(--color-…)]:后者 Tailwind v4
// 会把带 var() 的任意值解析成"只有颜色的阴影",偏移和扩散全丢,
// 实测算出来是 `oklab(0 0 0 / 0) 0 0 0 0 inset` —— 完全透明,**看不出坏了**。
// 负 offset 让描边画在格子内侧,与相邻格子不互相挤压(设计稿也是这么写的)。
n === 0
? 'cursor-default text-slate-300'
: 'cursor-pointer font-medium text-slate-700 hover:bg-slate-100',
isSelected && 'bg-slate-100 ring-1 ring-inset ring-slate-700',
? 'cursor-default text-slate-900/35'
// ⚠️ 必须带 `outline-solid`:基态的 `outline-none` 把 outline-style 钉成 none,
// 只加 `outline-2` 只改宽度 —— 实测算出来是 width:2px 但 style:none,**一条线都不画**。
: 'cursor-pointer font-medium outline-none hover:outline-solid hover:outline-2 hover:-outline-offset-2 hover:outline-slate-900 focus-visible:outline-solid focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-slate-900',
isSelected && 'outline-solid outline-2 -outline-offset-2 outline-slate-900',
)}
>
{n || '—'}
{n ? n.toLocaleString() : '—'}
</button>
);
})}
</div>
{showUnknown && (
<div
className="flex w-11 shrink-0 items-center justify-center bg-slate-50 text-[11.5px] tabular-nums text-slate-400"
title="温度待重算 —— 画像还没算出窗口边界"
>
{row.unknown || '—'}
</div>
)}
</div>
);
}
......@@ -22,7 +22,12 @@ const PopoverContent = React.forwardRef<
sideOffset={sideOffset}
className={cn(
'z-50 w-auto rounded-md border border-slate-100 bg-white p-2 shadow-md outline-none',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
// ⚠️ 原先挂的是 shadcn 官方那串 `animate-in fade-in-0 …` —— 它们来自
// tailwindcss-animate 插件,**本仓没装**,所以一帧动画都没有(class 写了不报错,
// 只是不生效,最难发现的那种)。改用 globals.css 里的原生 keyframes。
// 缩放锚点跟随 Radix 算出来的位置 → 浮层从触发按钮那个角"长出来",而不是自身中心缩放
'origin-(--radix-popover-content-transform-origin)',
'data-[state=open]:animate-overlayIn data-[state=closed]:animate-overlayOut',
className,
)}
{...props}
......
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