Commit 35c10335 by luoqi

refactor(web): 矩阵按产品走查改版 —— 分配主按钮 + 浮层 + 温度计刻度

按产品对着界面逐条走查的意见改:

① 「矩阵」→「**分配**」主色实心按钮,点击**浮层展开**矩阵(不再替换左栏内容)
   —— 分配是主管在这一页的主行动,该给主色;浮层展开则挑格子时列表还在原处,
   关掉就回到原位置,不丢滚动位置和筛选。
② 去掉矩阵底部那段常驻说明文字 —— 口径("去重患者数""点一格即移交")挪进
   **每个格子的 hover**,那才是主管会去看的地方;常驻小字既挤地方又没人读。
③ 行标签去掉「治疗」二字(种植/正畸/早矫/根管/牙周/充填/修复/拔牙)——
   直接用现成的 potentialTreatmentItemName,不另写表。
④ 格子 hover 说明**窗口期**:「黄金期:诊断后 120 天内」,天数从 DiagnosisTreatmentMap 现算。
   ️ 多码标签(拔牙 ← K01+K03、牙周 ← K05+K06)给的是**区间**并注明"按具体诊断码各判各的"
   —— 每条 gap 按自己那个码判档,本来就不存在"标签级的那一个天数",
   给单个数会让主管以为口径统一了(那正是 Q-6 当初以为无解的地方)。
⑤ 坐标改成**温度计刻度**:一条连续渐变(橙→琥珀→蓝)横跨三列。
   ️ 是 colSpan 跨列的**一条**,不是三段各画各的 —— 温度是连续量,
   切成三段独立色块就退化成"三个分类",冷热轴的意思没了。

 常驻的「把这 N 人移交助手」横幅一并撤掉,入口收敛到「分配」一个;
   但那条路没删,收进浮层底部(「或者:按左栏当前筛选的 N 人移交」)——
   矩阵只有「治疗项 × 温度」两轴,主管想按别的条件圈人时仍要走筛选标签。

新增 POTENTIAL_TREATMENT_SOURCE_CODES(标签 ← 诊断码的投影)供展示层算窗口。
️ 它**不是真理源**(真理源是 classifyGapToLabel,那里还有年龄闸和诊断名判断),
已加回归把两者锁在一起 —— 漂了的话 hover 上的天数就是错的,而且不会报错。

浏览器实测:8 行标签、连续渐变条(327×6px)、单码/多码 hover 文案、浮层开合均正确;
next build 通过。968 tests passing。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent 2d8b146e
...@@ -4,6 +4,8 @@ import { ...@@ -4,6 +4,8 @@ import {
gapTemperatureBounds, gapTemperatureBounds,
hottestBounds, hottestBounds,
lookupDxTreatment, lookupDxTreatment,
POTENTIAL_TREATMENT_SOURCE_CODES,
temperatureWindowHint,
} from '@pac/types'; } from '@pac/types';
import { VOLATILE_DATA_KEYS } from '../src/modules/persona/persona-diff'; import { VOLATILE_DATA_KEYS } from '../src/modules/persona/persona-diff';
...@@ -143,3 +145,48 @@ describe('温度口径 —— 「不知道」不等于「冷」', () => { ...@@ -143,3 +145,48 @@ describe('温度口径 —— 「不知道」不等于「冷」', () => {
expect(hottestBounds([null, undefined])).toBeNull(); expect(hottestBounds([null, undefined])).toBeNull();
}); });
}); });
describe('标签 ← 诊断码的投影必须与 classifyGapToLabel 一致', () => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { classifyGapToLabel } = require('../src/modules/persona/features/potential-treatment.feature');
const gap = (primaryCode: string, nameZh = '') =>
({ primaryCode, nameZh, tooth: null, daysSince: 1, confidence: 1, signalType: 'diagnosis', factId: 'f', anchorAt: NOW });
test('⭐⭐ 投影表里的每个码,真的会被 classifyGapToLabel 归到那个标签', () => {
// 展示层拿投影表算「窗口期是多少天」;它跟真理源漂了,hover 上的天数就是错的,
// 而错了不会报错 —— 主管照着一个假天数理解为什么这格算热。
const ADULT = 30; // 过掉种植 >18 / 正畸 ≤40 的年龄闸
for (const [label, codes] of Object.entries(POTENTIAL_TREATMENT_SOURCE_CODES)) {
for (const code of codes) {
const age = label === 'early_ortho' ? 8 : ADULT;
// K03 一码两标签,靠诊断名分流(残根类 → 拔牙,否则 → 修复)
const nameZh = code === 'K03' && label === 'extraction' ? '残根' : '';
expect(classifyGapToLabel(gap(code, nameZh), age)?.key).toBe(label);
}
}
});
test('⭐ 8 个业务标签一个都不能少', () => {
expect(Object.keys(POTENTIAL_TREATMENT_SOURCE_CODES).sort()).toEqual(
['early_ortho', 'endo', 'extraction', 'filling', 'implant', 'ortho', 'perio', 'restoration'],
);
});
});
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('各判各的');
});
test('未知标签 → 空串(不编)', () => {
expect(temperatureWindowHint('not_a_label', Temperature.HOT)).toBe('');
});
});
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts"; import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
...@@ -4,7 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; ...@@ -4,7 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { plansApi } from './plans-api'; import { plansApi } from './plans-api';
import { ChevronDown, Filter, Loader2, PhoneCall, Search, Sparkles, X } from 'lucide-react'; import { ChevronDown, Filter, Loader2, PhoneCall, Search, X } from 'lucide-react';
import { import {
EXECUTION_OUTCOME_GROUP_META, EXECUTION_OUTCOME_GROUP_META,
EXECUTION_OUTCOME_META, EXECUTION_OUTCOME_META,
...@@ -270,16 +270,34 @@ export function PatientPickerRail({ ...@@ -270,16 +270,34 @@ export function PatientPickerRail({
{t.label} {t.label}
</button> </button>
))} ))}
{/* ⭐ 列表 ⇄ 矩阵:矩阵是召回池的**视图模式**,不新开路由(T16)。 {/* ⭐ 「分配」是主管在这一页的**主行动**,所以给主色实心 —— 其余控件都是次要的。
矩阵**浮层展开**而不是替换左栏:主管挑格子时列表还在原处,
关掉浮层就回到他原来的位置,不会丢滚动位置和筛选(T16:不新开路由、不换心智)。
⚠️ `<lg` 不提供 —— 8×3 在 375px 抽屉里做不出可读密度,主管基本桌面办公。 */} ⚠️ `<lg` 不提供 —— 8×3 在 375px 抽屉里做不出可读密度,主管基本桌面办公。 */}
{canDispatch && view === 'pool' && ( {canDispatch && view === 'pool' && (
<button <Popover open={matrixMode} onOpenChange={setMatrixMode}>
type="button" <PopoverTrigger asChild>
onClick={() => setMatrixMode((v) => !v)} <button
className="ml-auto hidden self-center rounded border border-slate-200 px-2 py-0.5 text-[11px] text-slate-500 transition-colors hover:bg-slate-50 hover:text-slate-800 lg:block" type="button"
> className="ml-auto hidden self-center rounded bg-brand-600 px-2.5 py-1 text-[11.5px] font-medium text-white transition-colors hover:bg-brand-700 lg:block"
{matrixMode ? '列表' : '矩阵'} >
</button> 分配
</button>
</PopoverTrigger>
<PopoverContent align="end" sideOffset={6} className="w-[420px] p-0">
<PoolMatrix
selected={cell}
onPick={pickCell}
filtered={{
count: total,
onHandoff: () => {
setMatrixMode(false);
handoffToAssistant();
},
}}
/>
</PopoverContent>
</Popover>
)} )}
<span <span
className={cn( className={cn(
...@@ -291,14 +309,7 @@ export function PatientPickerRail({ ...@@ -291,14 +309,7 @@ export function PatientPickerRail({
</span> </span>
</div> </div>
{/* ⭐ 矩阵视图 —— 生产线第一环。点一格 = 完成初选,人群随即交给助手。 {/* 已初选的那一格 —— 必须能一眼看见、且能一键撤掉。
三层动效架构在这里得到验证:发射点从「移交助手」按钮换到格子上,
只改了调用处,pet-events / pet-brain / pet-body 一个字没动。 */}
{matrixMode && canDispatch && view === 'pool' ? (
<PoolMatrix selected={cell} onPick={pickCell} />
) : (
<>
{/* 已初选的那一格 —— 必须能一眼看见、且能一键撤掉。
不给撤销入口的话,主管点错一格就只能刷新页面(而刷新会丢掉整个对话)。 */} 不给撤销入口的话,主管点错一格就只能刷新页面(而刷新会丢掉整个对话)。 */}
{cell && view === 'pool' && ( {cell && view === 'pool' && (
<div className="flex flex-none items-center gap-1.5 border-b border-slate-100 bg-slate-50 px-2 py-1.5 text-[11.5px] text-slate-600"> <div className="flex flex-none items-center gap-1.5 border-b border-slate-100 bg-slate-50 px-2 py-1.5 text-[11.5px] text-slate-600">
...@@ -317,18 +328,9 @@ export function PatientPickerRail({ ...@@ -317,18 +328,9 @@ export function PatientPickerRail({
</div> </div>
)} )}
{/* ⭐ 移交助手 —— 主管在召回池里筛好人群后,把这批交给助手出确认单。 {/* ⚠️ 常驻的「把这 N 人移交助手」横幅已撤 —— 入口收敛到 tab 行的「分配」一个。
与矩阵是两条并行的入口:矩阵按「治疗项 × 温度」初选,这里按筛选标签自由圈。 */} 那条路没丢,收进了矩阵浮层底部(见 PoolMatrix 的 filtered):
{canDispatch && view === 'pool' && total > 0 && ( 矩阵只有「治疗项 × 温度」两轴,主管想按别的条件圈人时仍走筛选标签。 */}
<button
type="button"
onClick={handoffToAssistant}
className="flex flex-none items-center justify-center gap-1.5 border-b border-slate-100 bg-brand-50/60 px-2 py-2 text-[12px] font-medium text-brand-700 transition-colors hover:bg-brand-50"
>
<Sparkles className="h-3.5 w-3.5" />
把这 {total} 人移交助手,出分配方案
</button>
)}
{/* 搜索 + 工具行 */} {/* 搜索 + 工具行 */}
<div className="flex-none space-y-1.5 border-b border-slate-100 p-2"> <div className="flex-none space-y-1.5 border-b border-slate-100 p-2">
...@@ -457,9 +459,7 @@ export function PatientPickerRail({ ...@@ -457,9 +459,7 @@ export function PatientPickerRail({
加载更多({items.length}/{total}) 加载更多({items.length}/{total})
</button> </button>
)} )}
</div> </div>
</>
)}
</aside> </aside>
); );
} }
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { potentialTreatmentCardLabel } from '@pac/types'; import { potentialTreatmentItemName, temperatureWindowHint, type TemperatureValue } from '@pac/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { plansApi, type PoolMatrix, type PoolMatrixRow } from './plans-api'; import { plansApi, type PoolMatrix, type PoolMatrixRow } from './plans-api';
...@@ -24,11 +24,14 @@ import { plansApi, type PoolMatrix, type PoolMatrixRow } from './plans-api'; ...@@ -24,11 +24,14 @@ import { plansApi, type PoolMatrix, type PoolMatrixRow } from './plans-api';
* 把它们并进「冷」能让行合计好看,但那是**假分布**(T14)。宁可多一列让主管问一句。 * 把它们并进「冷」能让行合计好看,但那是**假分布**(T14)。宁可多一列让主管问一句。
*/ */
/** 列定义:底色写死在这里,**与任何数字无关**(见文件头配色纪律) */ /**
* 列定义:底色写死在这里,**与任何数字无关**(见文件头配色纪律)。
* `scale` 是列头那条**温度计刻度**的颜色 —— 与格子底色同一族,让人一眼看出这是一条冷热轴。
*/
const COLUMNS = [ const COLUMNS = [
{ key: 'hot', icon: '🔥', zh: '热', cell: 'bg-orange-400 text-white hover:bg-orange-500', hint: '还在该治疗自己的黄金期内 —— 医生刚说过,患者还记得' }, { 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', 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', hint: '超出该治疗的临床周期 —— 情况可能已经变了,先问近况' }, { key: 'cold', icon: '❄️', zh: '冷', cell: 'bg-blue-100 text-blue-900 hover:bg-blue-200', scale: 'from-amber-100 to-blue-200', hint: '超出该治疗的临床周期 —— 情况可能已经变了,先问近况' },
] as const; ] as const;
type TempKey = (typeof COLUMNS)[number]['key']; type TempKey = (typeof COLUMNS)[number]['key'];
...@@ -37,12 +40,19 @@ export function PoolMatrix({ ...@@ -37,12 +40,19 @@ 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);
...@@ -78,7 +88,7 @@ export function PoolMatrix({ ...@@ -78,7 +88,7 @@ export function PoolMatrix({
<tr> <tr>
<th className="w-[66px] text-left font-normal text-slate-400">潜在治疗</th> <th className="w-[66px] text-left font-normal text-slate-400">潜在治疗</th>
{COLUMNS.map((c) => ( {COLUMNS.map((c) => (
<th key={c.key} title={c.hint} className="font-medium text-slate-600"> <th key={c.key} title={c.hint} className="font-medium leading-tight text-slate-600">
{c.icon} {c.zh} {c.icon} {c.zh}
</th> </th>
))} ))}
...@@ -91,6 +101,16 @@ export function PoolMatrix({ ...@@ -91,6 +101,16 @@ export function PoolMatrix({
</th> </th>
)} )}
</tr> </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> </thead>
<tbody> <tbody>
{data.rows.map((row) => ( {data.rows.map((row) => (
...@@ -105,17 +125,23 @@ export function PoolMatrix({ ...@@ -105,17 +125,23 @@ export function PoolMatrix({
</tbody> </tbody>
</table> </table>
{/* ⚠️ 口径必须写在脸上:矩阵按**患者**去重,列表按 plan 分页。 {/* ⚠️ 口径(「数字是去重患者数、点一格即移交助手」)不再占一段说明文字 ——
不写的话主管一对数就觉得系统在骗他(验证策略「口径对数」那一条)。 */} 它已经落在**每个格子的 hover** 里(见 MatrixRow 的 title),那里才是主管会去看的地方。
<p className="px-1 pt-2 text-[10.5px] leading-relaxed text-slate-400"> 常驻一段小字既挤地方又没人读,反而把矩阵本身冲淡了。 */}
数字是<span className="font-medium text-slate-500">位患者</span>(已去重),不是任务条数。
点一格即把这批人交给助手出分配方案。
</p>
{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 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>
); );
} }
...@@ -131,8 +157,10 @@ function MatrixRow({ ...@@ -131,8 +157,10 @@ function MatrixRow({
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;
}) { }) {
// ⛔ 中文一律查 labels.ts,别在组件里另写一张表 —— 改措辞要即时生效、且全站一处 // ⛔ 中文一律查 labels.ts,别在组件里另写一张表 —— 改措辞要即时生效、且全站一处。
const zh = potentialTreatmentCardLabel(row.key); // 这里用**项目名**(种植 / 早矫 / 拔牙)而不是卡片措辞(种植治疗):
// 矩阵是一张 8×3 的表,每行都拖着「治疗」两个字纯属噪声,而且挤掉了列宽。
const zh = potentialTreatmentItemName(row.key);
return ( return (
<tr> <tr>
<th <th
...@@ -151,7 +179,14 @@ function MatrixRow({ ...@@ -151,7 +179,14 @@ function MatrixRow({
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 })}
title={n === 0 ? `${zh}·${c.zh}:暂无人` : `${zh}·${c.zh}:${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点击即把这批人交给助手出分配方案`
}
className={cn( className={cn(
'h-7 w-full rounded text-[12px] tabular-nums transition-colors', 'h-7 w-full rounded text-[12px] tabular-nums transition-colors',
// ⭐ 底色只看列(c.cell),**不看 n** —— 见文件头配色纪律 // ⭐ 底色只看列(c.cell),**不看 n** —— 见文件头配色纪律
......
...@@ -160,6 +160,49 @@ export function hottestBounds( ...@@ -160,6 +160,49 @@ export function hottestBounds(
* 把"不知道"当成"冷"会让老数据静默塞满冷格子,主管看到的是一个假的分布 —— 违 T14。 * 把"不知道"当成"冷"会让老数据静默塞满冷格子,主管看到的是一个假的分布 —— 违 T14。
* 调用方应把 null 显式呈现为「温度未知」或排除出矩阵,并说明原因。 * 调用方应把 null 显式呈现为「温度未知」或排除出矩阵,并说明原因。
*/ */
/**
* 业务标签 ← 触发它的诊断码(**只是投影,不是真理源**)。
*
* ⚠️ 真理源是 `potential-treatment.feature` 的 `classifyGapToLabel` —— 那里还带年龄闸
* (种植要 >18、早矫 3-12)和名称判断(K03 含「残根/残冠」才归拔牙),不是纯码映射。
* 这里只取它在**码**这一维上的投影,供展示层算「窗口期是多少天」用。
* 两者一致性由 `tests/temperature.spec.ts` 锁住 —— 改那边必须同步改这里。
*/
export const POTENTIAL_TREATMENT_SOURCE_CODES: Record<string, readonly string[]> = {
implant: ['K08'],
ortho: ['K07'],
early_ortho: ['K07'],
endo: ['K04'],
perio: ['K05', 'K06'],
filling: ['K02'],
restoration: ['K03'],
extraction: ['K01', 'K03'],
};
/**
* 「这一格凭什么算热/温/冷」的一句话 —— 矩阵格子 hover 用。
*
* ⚠️ 多码标签(拔牙 ← K01+K03、牙周 ← K05+K06)给的是**区间**,不是一个数:
* 每条 gap 按**自己那个码**的窗口判档(见文件头 ①),本来就不存在"标签级的那一个天数"。
* 给单个数会让主管以为口径是统一的,那正是 Q-6 当初以为无解的地方。
*/
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);
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}`;
}
export function classifyTemperature( export function classifyTemperature(
bounds: { hotUntil?: string | null; warmUntil?: string | null } | null | undefined, bounds: { hotUntil?: string | null; warmUntil?: string | null } | null | undefined,
now: Date = new Date(), now: Date = new Date(),
......
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