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