Commit ce100876 by luoqi

refactor(web): 矩阵二轮走查 —— 整片连成一条渐变 + hover 只给区间

① 去掉底部文字(那条「或者:按左栏当前筛选的 N 人移交」)。
    连带把「按筛选人群移交」这条路整个撤了,入口只留矩阵一个 ——
   生产线 ① 明写「主管在矩阵上点一格」,两个入口做同一件事,主管得先想用哪个。
   要复活是加回一个按钮 + 一次 emitPetEvent/ask 的事,模型和后端都不用动。
   顺手删掉随之变成死代码的 handoffToAssistant。

② hover 只给**天数区间**:「≤ 120 天」/「120–180 天」/「> 180 天」。
   格子里已有数字、列头已写热/温/冷,再叠一段说明就是噪声;
   区间是主管唯一看不出来的那个信息。
   ️ 多码标签给各码区间的**并集**(拔牙 = K01 ∪ K03 → 热 ≤90 / 温 60–180 / 冷 >90):
   挑其中一个码的数字会骗人,而并集是对该档人群天数范围的如实描述。
   加了一条回归锁「三档必须覆盖整条数轴、不留缝也不写反」。

③ **整片矩阵共用一条横向渐变**,格子不再各有底色。
   温度是**连续量**,给每格各刷一块色 = 把它退化成三个并列的分类,冷热轴的意思就没了。
   实现上渐变挂在行容器、格子透明浮在上面 → 列与列、行与行连成一整片;
   列头那条刻度用的是**同一个** SCALE 常量,写两遍必然漂。
   ️ 色标位置(20%/50%/80%)是对着三列**列心**(16.7/50/83.3%)定的,
   正好落在纯橙/纯琥珀/纯蓝上,数字对比度才够 —— 改列宽必须同步改色标。
    「数量不参与配色」的老纪律没变:渐变只由列的位置决定。

浏览器实测:整片渐变无缝、8 行标签、hover 区间(单码/多码)均正确,无横向溢出;
next build 通过,971 tests passing。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 35c10335
......@@ -173,17 +173,30 @@ describe('标签 ← 诊断码的投影必须与 classifyGapToLabel 一致', ()
});
});
describe('窗口期 hover 文案', () => {
test('⭐ 单码标签给确切天数(种植黄金期 120 天)', () => {
expect(temperatureWindowHint('implant', Temperature.HOT)).toBe('黄金期:诊断后 120 天内');
expect(temperatureWindowHint('implant', Temperature.COLD)).toBe('超周期:诊断后 180 天以上');
});
test('⭐⭐ 多码标签给**区间**,并说明各判各的 —— 给单个数会让人以为口径统一了', () => {
// 拔牙 ← K01(180/90) + K03(90/60):本来就不存在"标签级的那一个天数"
const h = temperatureWindowHint('extraction', Temperature.HOT);
expect(h).toContain('60–90');
expect(h).toContain('各判各的');
describe('窗口期 hover 文案 —— 只给区间,不给解释', () => {
test('⭐ 单码标签给确切区间(种植 黄金 120 / 窗 180)', () => {
expect(temperatureWindowHint('implant', Temperature.HOT)).toBe('≤ 120 天');
expect(temperatureWindowHint('implant', Temperature.WARM)).toBe('120–180 天');
expect(temperatureWindowHint('implant', Temperature.COLD)).toBe('> 180 天');
});
test('⭐⭐ 多码标签给各码区间的**并集** —— 挑一个码的数字会骗人', () => {
// 拔牙 = K01(黄金 90 / 窗 180) ∪ K03(黄金 60 / 窗 90)
expect(temperatureWindowHint('extraction', Temperature.HOT)).toBe('≤ 90 天');
expect(temperatureWindowHint('extraction', Temperature.WARM)).toBe('60–180 天');
expect(temperatureWindowHint('extraction', Temperature.COLD)).toBe('> 90 天');
});
test('⭐ 三档区间必须**覆盖整条数轴**,不留缝也不写反', () => {
// 缝 = 有人哪一档都不属于;写反 = 冷的下界跑到热的上界左边,主管一看就知道在乱说
for (const label of Object.keys(POTENTIAL_TREATMENT_SOURCE_CODES)) {
const hot = Number(temperatureWindowHint(label, Temperature.HOT).match(/\d+/)![0]);
const [warmLo, warmHi] = temperatureWindowHint(label, Temperature.WARM).match(/\d+/g)!.map(Number);
const cold = Number(temperatureWindowHint(label, Temperature.COLD).match(/\d+/)![0]);
// 单码时三档首尾**恰好相接**(温上界 == 冷下界),多码时并集会让温更宽 —— 都不许留缝
expect(warmLo).toBeLessThanOrEqual(hot);
expect(warmHi).toBeGreaterThanOrEqual(cold);
}
});
test('未知标签 → 空串(不编)', () => {
......
......@@ -13,8 +13,8 @@ import {
type ExecutionOutcome,
type ExecutionOutcomeGroup,
type PlanListItem,
potentialTreatmentCardLabel,
personaTagDimId,
potentialTreatmentCardLabel,
} from '@pac/types';
import { cn, formatGender } from '@/lib/utils';
import { actionTemplate } from '@/lib/action-url';
......@@ -170,28 +170,9 @@ export function PatientPickerRail({
// ⭐ T16:认领入口已从前端全部撤掉(后端 POST :id/assign 保留,见 plans-api.recycle 旁注)。
// 客服的单由主管派发;将来若放开客服主动性,是把入口加回来的事,不用改模型。
/**
* 移交助手 —— 把当前筛出来的人群交给助手出确认单。
*
* ⚠️ 业务侧只做两件事:发一个**语义**事件、说一句话。
* 「宠物怎么演」归 pet-brain(三层单向架构的大脑层),换演法不用动这里;
* 「助手怎么圈人」归后端 propose_assignment,这里不传 planId 列表 ——
* 传了就等于把收敛规则搬到前端,而那是会漂的。
*/
const handoffToAssistant = () => {
// 从当前筛选标签里认出「潜在治疗」那一维 —— 只为了让气泡和提问带上人话
// (「潜在种植」而不是 "potential_treatment:implant")。认不出就不带,不猜。
const tKey = [...tags].find((t) => t.startsWith('potential_treatment:'));
const treatmentLabel = tKey
? potentialTreatmentCardLabel(tKey.slice('potential_treatment:'.length))
: undefined;
emitPetEvent({ type: 'cohort_handoff', payload: { count: total, treatment: treatmentLabel } });
useAssistantStore.getState().ask(
treatmentLabel
? `帮我给「${treatmentLabel}」这批患者出一份分配方案`
: '帮我给当前召回池筛出来的这批患者出一份分配方案',
);
};
// ⛔ 「按当前筛选的人群移交助手」这条路已撤(2026-08 产品走查):入口只留矩阵一个。
// 生产线 ① 明写「主管在矩阵上点一格」—— 两个入口做同一件事,主管得先想用哪个。
// 要复活的话是加回一个按钮 + 一次 emitPetEvent/ask 的事,模型和后端都不用动。
/**
* 点矩阵格子 = 完成「初选」,人群随即交给助手(生产线 ① → ②)。
......@@ -285,17 +266,7 @@ export function PatientPickerRail({
</button>
</PopoverTrigger>
<PopoverContent align="end" sideOffset={6} className="w-[420px] p-0">
<PoolMatrix
selected={cell}
onPick={pickCell}
filtered={{
count: total,
onHandoff: () => {
setMatrixMode(false);
handoffToAssistant();
},
}}
/>
<PoolMatrix selected={cell} onPick={pickCell} />
</PopoverContent>
</Popover>
)}
......
......@@ -10,49 +10,50 @@ import { plansApi, type PoolMatrix, type PoolMatrixRow } from './plans-api';
* PoolMatrix — 初选矩阵(8 潜在治疗 × 3 窗口温度)。
*
* 生产线的**第一环**:主管在这里点一格,就完成了「初选」,人群随即交给助手出确认单。
* 矩阵是召回池的一个**视图模式**(列表 ⇄ 矩阵),⛔ 不新开路由 ——
* 矩阵是召回池的一个**视图模式**(tab 行「分配」浮层展开),⛔ 不新开路由 ——
* 主管本质也是客服、也要执行,割裂成两个页面会把他劈成两个身份(T16)。
*
* ═══ 三条配色纪律(五之三)═══════════════════════════════════════
* ⛔ **数量不参与配色**,底色**按列固定**。
* 否则「这格橙是因为热、还是因为人多」分不清 —— 实现上列头决定 class,
* cell 一个字都不参与计算。⛔ 别写任何 `count > N ? ... : ...`。
* ═══ 配色纪律(五之三 + 2026-08 产品走查)═══════════════════════════
* ⭐ **整片矩阵共用一条横向渐变**(橙 → 琥珀 → 蓝),格子本身**没有**自己的底色。
* 温度是**连续量**;给每格各刷一块色 = 把它退化成三个并列的分类,冷热轴的意思就没了。
* 实现上渐变挂在**行容器**上,格子透明浮在上面 —— 于是列与列、行与行连成一整片。
* ⛔ **数量不参与配色**(纪律没变):渐变只由**列的位置**决定,格子里的数字一个字都不参与。
* 否则「这格橙是因为热、还是因为人多」分不清。
* ⛔ **热不用红**:红太冲,是"出事了";橙才是"该动手了"。
* ⛔ **别用 brand-***:品牌蓝 #0032A0 比冷档的 blue-100 深太多,混用会让「冷」看着像选中态。
* ⛔ **别用 brand-***:品牌蓝比冷档深太多,混用会让「冷」看着像选中态。
*
* ⚠️ 三档之外还有一列**「待重算」** —— 上线到全量重算跑完之间,老画像没有窗口边界。
* 把它们并进「冷」能让行合计好看,但那是**假分布**(T14)。宁可多一列让主管问一句。
* ⚠️ 渐变的色标位置(20% / 50% / 80%)是**对着三列的列心**定的:
* 三列均分,列心在 16.7% / 50% / 83.3% —— 正好落在纯橙 / 纯琥珀 / 纯蓝上,数字对比度才够。
* 改列数或列宽必须同步改色标,否则中间那列的字会糊在过渡色里。
*/
/**
* 列定义:底色写死在这里,**与任何数字无关**(见文件头配色纪律)
* `scale` 是列头那条**温度计刻度**的颜色 —— 与格子底色同一族,让人一眼看出这是一条冷热轴
* 列定义。⚠️ 这里**只剩文字色** —— 底色统一由行容器的渐变给
* 想给某一列单独加底色前先看文件头:那会把"一条轴"重新切回"三个分类"
*/
const COLUMNS = [
{ key: 'hot', icon: '🔥', zh: '热', cell: 'bg-orange-400 text-white hover:bg-orange-500', scale: 'from-orange-500 to-orange-400', hint: '还在该治疗自己的黄金期内 —— 医生刚说过,患者还记得' },
{ key: 'warm', icon: '🌡', zh: '温', cell: 'bg-amber-100 text-amber-900 hover:bg-amber-200', scale: 'from-orange-300 to-amber-200', hint: '过了黄金期但没出临床周期 —— 还来得及,话术要给个理由' },
{ key: 'cold', icon: '❄️', zh: '冷', cell: 'bg-blue-100 text-blue-900 hover:bg-blue-200', scale: 'from-amber-100 to-blue-200', hint: '超出该治疗的临床周期 —— 情况可能已经变了,先问近况' },
{ key: 'hot', icon: '🔥', zh: '热', text: 'text-white' },
{ key: 'warm', icon: '🌡', zh: '温', text: 'text-amber-900' },
{ key: 'cold', icon: '❄️', zh: '冷', text: 'text-blue-900' },
] as const;
type TempKey = (typeof COLUMNS)[number]['key'];
/// 整片矩阵(以及列头那条刻度)共用的同一条渐变 —— 写两遍必然漂
const SCALE = 'bg-gradient-to-r from-orange-400 from-20% via-amber-100 via-50% to-blue-100 to-80%';
/// 行标签列宽。列头、刻度条、每一行都靠它对齐,别在某一处手改
const LABEL_W = 'w-[52px]';
export function PoolMatrix({
clinicId,
selected,
onPick,
filtered,
}: {
clinicId?: string;
/** 当前选中的格子(回显用) */
selected?: { treatment: string; temperature: TempKey } | null;
/** 点格子 → 初选完成,把这一格的人群交出去 */
onPick: (cell: { treatment: string; treatmentZh: string; temperature: TempKey; count: number }) => void;
/**
* 「按当前筛选的人群移交」的兜底入口 —— 主管用左栏筛选标签自己圈的那一批。
* ⭐ 收进浮层而不是常驻一条横幅:入口只留**一个**(「分配」),
* 但这条路不能丢 —— 矩阵只有「治疗项 × 温度」两轴,主管想按别的条件圈人时走它。
*/
filtered?: { count: number; onHandoff: () => void };
}) {
const [data, setData] = useState<PoolMatrix | null>(null);
const [error, setError] = useState<string | null>(null);
......@@ -81,67 +82,45 @@ export function PoolMatrix({
);
}
const showUnknown = data.unknownTotal > 0;
return (
<div className="flex-1 overflow-y-auto p-2">
<table className="w-full border-separate border-spacing-[3px] text-[12px]">
<thead>
<tr>
<th className="w-[66px] text-left font-normal text-slate-400">潜在治疗</th>
<div className="p-2">
{/* 列头 + 温度计刻度 */}
<div className="flex items-end">
<div className={cn(LABEL_W, 'shrink-0 text-[11px] text-slate-400')}>潜在治疗</div>
<div className="flex-1">
<div className="flex">
{COLUMNS.map((c) => (
<th key={c.key} title={c.hint} className="font-medium leading-tight text-slate-600">
<div key={c.key} className="flex-1 text-center text-[12px] font-medium text-slate-600">
{c.icon} {c.zh}
</th>
</div>
))}
{data.unknownTotal > 0 && (
<th
className="font-normal text-slate-400"
title="画像还没算出窗口边界,温度待重算。⛔ 没有并进「冷」——并进去数字好看,但那是假分布。"
>
待重算
</th>
)}
</tr>
{/* ⭐ 温度计刻度 —— **一条连续的**渐变横跨三列(不是三段各画各的)。
温度是连续量,切成三段独立色块就退化成"三个分类",冷热轴的意思就没了。
⚠️ 用 colSpan 跨列 + 取消该行的 border-spacing,段与段之间才不会裂开。 */}
<tr>
<th className="p-0" />
<th colSpan={3} className="p-0 pb-1">
<div className="h-1.5 rounded-full bg-gradient-to-r from-orange-500 via-amber-200 to-blue-200" />
</th>
{data.unknownTotal > 0 && <th className="p-0" />}
</tr>
</thead>
<tbody>
</div>
{/* 刻度与下面的格子是**同一条**渐变、首尾对齐 —— 它就是这片色块的图例 */}
<div className={cn('mt-1 h-1.5 rounded-full', SCALE)} />
</div>
{showUnknown && <div className="w-11 shrink-0 text-center text-[11px] text-slate-400">待重算</div>}
</div>
{/* 矩阵本体:每行一条渐变、行行紧贴 → 整片连成一块 */}
<div className="mt-1 overflow-hidden rounded">
{data.rows.map((row) => (
<MatrixRow
key={row.key}
row={row}
showUnknown={data.unknownTotal > 0}
showUnknown={showUnknown}
selected={selected}
onPick={onPick}
/>
))}
</tbody>
</table>
</div>
{/* ⚠️ 口径(「数字是去重患者数、点一格即移交助手」)不再占一段说明文字 ——
它已经落在**每个格子的 hover** 里(见 MatrixRow 的 title),那里才是主管会去看的地方。
常驻一段小字既挤地方又没人读,反而把矩阵本身冲淡了。 */}
{data.note && (
<p className="mt-1 rounded bg-amber-50 px-2 py-1.5 text-[10.5px] leading-relaxed text-amber-800">
<p className="mt-1.5 rounded bg-amber-50 px-2 py-1.5 text-[10.5px] leading-relaxed text-amber-800">
{data.note}
</p>
)}
{filtered && filtered.count > 0 && (
<button
type="button"
onClick={filtered.onHandoff}
className="mt-2 w-full rounded border border-dashed border-slate-200 py-1.5 text-[11.5px] text-slate-500 transition-colors hover:border-brand-200 hover:bg-brand-50/40 hover:text-brand-700"
>
或者:按左栏当前筛选的 {filtered.count} 人移交
</button>
)}
</div>
);
}
......@@ -158,59 +137,50 @@ function MatrixRow({
onPick: (cell: { treatment: string; treatmentZh: string; temperature: TempKey; count: number }) => void;
}) {
// ⛔ 中文一律查 labels.ts,别在组件里另写一张表 —— 改措辞要即时生效、且全站一处。
// 这里用**项目名**(种植 / 早矫 / 拔牙)而不是卡片措辞(种植治疗):
// 矩阵是一张 8×3 的表,每行都拖着「治疗」两个字纯属噪声,而且挤掉了列宽。
// 用**项目名**(种植 / 早矫 / 拔牙)而不是卡片措辞(种植治疗):
// 8×3 的表里每行都拖着「治疗」两个字纯属噪声,还挤掉列宽。
const zh = potentialTreatmentItemName(row.key);
return (
<tr>
<th
scope="row"
title={row.hint}
className="truncate text-left text-[11.5px] font-normal text-slate-600"
>
<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}
</th>
</div>
{/* ⭐ 渐变在这一层,三个格子透明浮在上面 —— 列间没有缝,整行是一条色带 */}
<div className={cn('flex flex-1', SCALE)}>
{COLUMNS.map((c) => {
const n = row[c.key];
const isSelected = selected?.treatment === row.key && selected.temperature === c.key;
return (
<td key={c.key} className="p-0">
<button
key={c.key}
type="button"
disabled={n === 0}
onClick={() => onPick({ treatment: row.key, treatmentZh: zh, temperature: c.key, count: n })}
// ⭐ hover 说清**凭什么算这一档** —— 天数来自 DiagnosisTreatmentMap 现算,
// 多码标签(拔牙/牙周)给的是区间,因为每条 gap 按自己那个码判档,
// 本来就不存在"标签级的那一个天数"(见 @pac/types temperatureWindowHint)。
title={
n === 0
? `${zh}·${c.zh}:暂无人\n${temperatureWindowHint(row.key, c.key as TemperatureValue)}`
: `${zh}·${c.zh}:${n} 位患者(已去重)\n${temperatureWindowHint(row.key, c.key as TemperatureValue)}\n点击即把这批人交给助手出分配方案`
}
// ⭐ hover 只给**天数区间**,不给解释:格子里已经有数字、列头已经写了热/温/冷,
// 再叠一段说明就是噪声。区间是主管唯一看不出来的那个信息。
title={temperatureWindowHint(row.key, c.key as TemperatureValue)}
className={cn(
'h-7 w-full rounded text-[12px] tabular-nums transition-colors',
// ⭐ 底色只看列(c.cell),**不看 n** —— 见文件头配色纪律
'flex-1 py-1.5 text-[12px] tabular-nums transition-colors',
// ⛔ 底色来自父层渐变;这里只管**文字色与交互态**(见文件头配色纪律)
n === 0
? 'cursor-default bg-slate-50 text-slate-300'
: cn(c.cell, 'cursor-pointer font-medium'),
isSelected && 'ring-2 ring-slate-800 ring-offset-1',
? 'cursor-default text-slate-400/70'
: cn(c.text, 'cursor-pointer font-medium hover:bg-white/30'),
isSelected && 'bg-white/40 ring-2 ring-inset ring-slate-800',
)}
>
{n}
{n || '—'}
</button>
</td>
);
})}
</div>
{showUnknown && (
<td className="p-0">
<div
className="flex h-7 items-center justify-center rounded bg-slate-50 text-[11.5px] tabular-nums text-slate-400"
title="温度待重算 —— 画像还没算出窗口边界。这是重算进度,不是数据缺失。"
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>
</td>
)}
</tr>
</div>
);
}
......@@ -180,27 +180,23 @@ export const POTENTIAL_TREATMENT_SOURCE_CODES: Record<string, readonly string[]>
};
/**
* 「这一格凭什么算热/温/冷」的一句话 —— 矩阵格子 hover 用
* 这一档对应的**天数区间** —— 矩阵格子 hover 用,只给区间,不给解释
*
* ⚠️ 多码标签(拔牙 ← K01+K03、牙周 ← K05+K06)给的是**区间**,不是一个数:
* 每条 gap 按**自己那个码**的窗口判档(见文件头 ①),本来就不存在"标签级的那一个天数"。
* 给单个数会让主管以为口径是统一的,那正是 Q-6 当初以为无解的地方。
* ⚠️ 多码标签(拔牙 ← K01+K03、牙周 ← K05+K06)给的是各码区间的**并集**:
* 每条 gap 按**自己那个码**的窗口判档(见文件头 ①),不存在"标签级的那一个天数"。
* 并集是对该档人群天数范围的**如实**描述 —— 比挑一个码的数字诚实,也比塞一堆解释短。
* 例:拔牙 = K01(黄金 90 / 窗 180) ∪ K03(黄金 60 / 窗 90)
* 热 [0,90]∪[0,60] = ≤90 天 · 温 (90,180]∪(60,90] = 60–180 天 · 冷 >180∪>90 = >90 天
*/
export function temperatureWindowHint(label: string, temp: TemperatureValue): string {
const codes = POTENTIAL_TREATMENT_SOURCE_CODES[label];
if (!codes?.length) return '';
const rules = codes.map((c) => lookupDxTreatment(c)).filter((r): r is NonNullable<typeof r> => !!r);
const rules = (codes ?? []).map((c) => lookupDxTreatment(c)).filter((r): r is NonNullable<typeof r> => !!r);
if (!rules.length) return '';
const span = (pick: (r: (typeof rules)[number]) => number) => {
const vs = [...new Set(rules.map(pick))].sort((a, b) => a - b);
return vs.length === 1 ? `${vs[0]}` : `${vs[0]}${vs[vs.length - 1]}`;
};
const multi = rules.length > 1 && span((r) => r.urgencyDayThreshold).includes('–');
const tail = multi ? '(按具体诊断码各判各的)' : '';
if (temp === Temperature.HOT) return `黄金期:诊断后 ${span((r) => r.urgencyDayThreshold)} 天内${tail}`;
if (temp === Temperature.WARM)
return `周期内:超过黄金期、但没超 ${span((r) => r.windowDays)}${tail}`;
return `超周期:诊断后 ${span((r) => r.windowDays)} 天以上${tail}`;
const urg = rules.map((r) => r.urgencyDayThreshold);
const win = rules.map((r) => r.windowDays);
if (temp === Temperature.HOT) return `≤ ${Math.max(...urg)} 天`;
if (temp === Temperature.WARM) return `${Math.min(...urg)}${Math.max(...win)} 天`;
return `> ${Math.min(...win)} 天`;
}
export function classifyTemperature(
......
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