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>
)}
......
......@@ -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