Commit c9d70688 by luoqi

refactor(web): 矩阵列头改临床说法,撤掉温度配色

列头「热 / 温 / 冷」→「**黄金期 / 窗口内 / 窗口外**」,并把温度渐变整套撤掉。

 两件事是一件事:「热」是**比喻**,而这三档本来就有精确的临床语义。
名字说准之后,冷暖色就成了**第二套更弱的编码** —— 只是把列头已经说清的事再说一遍,
还要额外维护对比度、色标位置、深色模式。所以列头改名与撤配色一起做。

 在文件头和教条里都写死了「别再把冷暖色加回来」,尤其别按数量深浅上色:
「这格橙是因为在黄金期、还是因为人多」分不清 —— 数量已用数字表达、档位已用列头表达。
唯一允许的颜色是交互态(hover / 选中),那表达的是"你正指着谁",不是数据。

️ **代码名不动**:枚举值、API 参数 temperature=、persona_features.data.temperature 的
JSON 路径仍是 hot/warm/cold。改它们要同时动 API 契约、SQL 谓词、已落库的 JSON 路径
和前端 query,收益只是"看着顺眼"。已在 TEMPERATURE_META 上写明这条,免得下一个人顺手重构。

顺带收口一处漂移:TEMPERATURE_META 此前**定义了却没人用**,
rail 里另有两张内联中文表(初选 chip、宠物气泡)。现在三处都走 META,
改措辞只改一处 —— 否则这次改名就会漏掉那两处,而且不会报错。

浏览器实测:列头三档正确、矩阵内温度色节点归零(0)、hover 区间不变;
next build 通过,971 tests passing。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent ce100876
...@@ -15,6 +15,7 @@ import { ...@@ -15,6 +15,7 @@ import {
type PlanListItem, type PlanListItem,
personaTagDimId, personaTagDimId,
potentialTreatmentCardLabel, potentialTreatmentCardLabel,
TEMPERATURE_META,
} 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';
...@@ -189,7 +190,8 @@ export function PatientPickerRail({ ...@@ -189,7 +190,8 @@ export function PatientPickerRail({
temperature: 'hot' | 'warm' | 'cold'; temperature: 'hot' | 'warm' | 'cold';
count: number; count: number;
}) => { }) => {
const tempZh = { hot: '热', warm: '温', cold: '冷' }[c.temperature]; // ⛔ 别内联中文表:档位显示名的单一真理源是 TEMPERATURE_META(改措辞只改那一处)
const tempZh = TEMPERATURE_META[c.temperature].zh;
setCell({ treatment: c.treatment, treatmentZh: c.treatmentZh, temperature: c.temperature }); setCell({ treatment: c.treatment, treatmentZh: c.treatmentZh, temperature: c.temperature });
setMatrixMode(false); setMatrixMode(false);
emitPetEvent({ emitPetEvent({
...@@ -286,7 +288,7 @@ export function PatientPickerRail({ ...@@ -286,7 +288,7 @@ export function PatientPickerRail({
<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">
<span className="text-slate-400">初选</span> <span className="text-slate-400">初选</span>
<span className="font-medium text-slate-800"> <span className="font-medium text-slate-800">
{cell.treatmentZh} · {{ hot: '🔥 热', warm: '🌡 温', cold: '❄️ 冷' }[cell.temperature]} {cell.treatmentZh} · {TEMPERATURE_META[cell.temperature].zh}
</span> </span>
<button <button
type="button" type="button"
......
...@@ -2,7 +2,13 @@ ...@@ -2,7 +2,13 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { potentialTreatmentItemName, temperatureWindowHint, type TemperatureValue } from '@pac/types'; import {
TEMPERATURE_META,
TEMPERATURE_ORDER,
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';
...@@ -13,35 +19,22 @@ import { plansApi, type PoolMatrix, type PoolMatrixRow } from './plans-api'; ...@@ -13,35 +19,22 @@ import { plansApi, type PoolMatrix, type PoolMatrixRow } from './plans-api';
* 矩阵是召回池的一个**视图模式**(tab 行「分配」浮层展开),⛔ 不新开路由 —— * 矩阵是召回池的一个**视图模式**(tab 行「分配」浮层展开),⛔ 不新开路由 ——
* 主管本质也是客服、也要执行,割裂成两个页面会把他劈成两个身份(T16)。 * 主管本质也是客服、也要执行,割裂成两个页面会把他劈成两个身份(T16)。
* *
* ═══ 配色纪律(五之三 + 2026-08 产品走查)═══════════════════════════ * ═══ 为什么这里**没有**配色(2026-08 产品定,是撤回不是遗漏)═══════════
* ⭐ **整片矩阵共用一条横向渐变**(橙 → 琥珀 → 蓝),格子本身**没有**自己的底色。 * 列头从「热 / 温 / 冷」改成**临床说法**「黄金期 / 窗口内 / 窗口外」之后,
* 温度是**连续量**;给每格各刷一块色 = 把它退化成三个并列的分类,冷热轴的意思就没了。 * 温度渐变那套颜色就成了**第二套更弱的编码** —— 名字已经把语义说准了,
* 实现上渐变挂在**行容器**上,格子透明浮在上面 —— 于是列与列、行与行连成一整片。 * 再刷一层冷暖色只是重复,还得额外维护对比度、色标位置、深色模式。一并撤掉。
* ⛔ **数量不参与配色**(纪律没变):渐变只由**列的位置**决定,格子里的数字一个字都不参与。
* 否则「这格橙是因为热、还是因为人多」分不清。
* ⛔ **热不用红**:红太冲,是"出事了";橙才是"该动手了"。
* ⛔ **别用 brand-***:品牌蓝比冷档深太多,混用会让「冷」看着像选中态。
* *
* ⚠️ 渐变的色标位置(20% / 50% / 80%)是**对着三列的列心**定的: * ⛔ **别再把冷暖色加回来**(尤其别按数量深浅上色):
* 三列均分,列心在 16.7% / 50% / 83.3% —— 正好落在纯橙 / 纯琥珀 / 纯蓝上,数字对比度才够。 * 「这格橙是因为在黄金期、还是因为人多」分不清 —— 数量已经用数字表达了,
* 改列数或列宽必须同步改色标,否则中间那列的字会糊在过渡色里。 * 档位已经用列头表达了,两者都不需要颜色再说一遍。
* ⛔ 唯一允许的颜色是**交互态**(hover / 选中):那表达的是"你正指着谁",不是数据。
*/ */
/** /// 列序走共享的 TEMPERATURE_ORDER,中文走共享的 TEMPERATURE_META —— ⛔ 别在这里内联中文表
* 列定义。⚠️ 这里**只剩文字色** —— 底色统一由行容器的渐变给。 const COLUMNS = TEMPERATURE_ORDER;
* 想给某一列单独加底色前先看文件头:那会把"一条轴"重新切回"三个分类"。 type TempKey = TemperatureValue;
*/
const COLUMNS = [
{ 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]'; const LABEL_W = 'w-[52px]';
export function PoolMatrix({ export function PoolMatrix({
...@@ -86,25 +79,24 @@ export function PoolMatrix({ ...@@ -86,25 +79,24 @@ export function PoolMatrix({
return ( return (
<div className="p-2"> <div className="p-2">
{/* 列头 + 温度计刻度 */} {/* 列头 —— 三档用**临床说法**,不是「热/温/冷」那套比喻 */}
<div className="flex items-end"> <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> <div className={cn(LABEL_W, 'shrink-0 text-[11px] text-slate-400')}>潜在治疗</div>
<div className="flex-1"> <div className="flex flex-1">
<div className="flex"> {COLUMNS.map((c) => (
{COLUMNS.map((c) => ( <div
<div key={c.key} className="flex-1 text-center text-[12px] font-medium text-slate-600"> key={c}
{c.icon} {c.zh} title={TEMPERATURE_META[c].hint}
</div> className="flex-1 text-center text-[12px] font-medium text-slate-700"
))} >
</div> {TEMPERATURE_META[c].zh}
{/* 刻度与下面的格子是**同一条**渐变、首尾对齐 —— 它就是这片色块的图例 */} </div>
<div className={cn('mt-1 h-1.5 rounded-full', SCALE)} /> ))}
</div> </div>
{showUnknown && <div className="w-11 shrink-0 text-center text-[11px] text-slate-400">待重算</div>} {showUnknown && <div className="w-11 shrink-0 text-center text-[11px] text-slate-400">待重算</div>}
</div> </div>
{/* 矩阵本体:每行一条渐变、行行紧贴 → 整片连成一块 */} <div className="mt-0.5">
<div className="mt-1 overflow-hidden rounded">
{data.rows.map((row) => ( {data.rows.map((row) => (
<MatrixRow <MatrixRow
key={row.key} key={row.key}
...@@ -145,27 +137,26 @@ function MatrixRow({ ...@@ -145,27 +137,26 @@ function MatrixRow({
<div className={cn(LABEL_W, 'shrink-0 self-center pr-1.5 text-right text-[11.5px] text-slate-600')}> <div className={cn(LABEL_W, 'shrink-0 self-center pr-1.5 text-right text-[11.5px] text-slate-600')}>
{zh} {zh}
</div> </div>
{/* ⭐ 渐变在这一层,三个格子透明浮在上面 —— 列间没有缝,整行是一条色带 */} <div className="flex flex-1">
<div className={cn('flex flex-1', SCALE)}>
{COLUMNS.map((c) => { {COLUMNS.map((c) => {
const n = row[c.key]; const n = row[c];
const isSelected = selected?.treatment === row.key && selected.temperature === c.key; const isSelected = selected?.treatment === row.key && selected.temperature === c;
return ( return (
<button <button
key={c.key} key={c}
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, count: n })}
// ⭐ hover 只给**天数区间**,不给解释:格子里已经有数字、列头已经写了热/温/冷, // ⭐ hover 只给**天数区间**,不给解释:格子里已经有数字、列头已经写了档位名,
// 再叠一段说明就是噪声。区间是主管唯一看不出来的那个信息。 // 再叠一段说明就是噪声。区间是主管唯一看不出来的那个信息。
title={temperatureWindowHint(row.key, c.key as TemperatureValue)} title={temperatureWindowHint(row.key, c)}
className={cn( className={cn(
'flex-1 py-1.5 text-[12px] tabular-nums transition-colors', 'flex-1 rounded py-1.5 text-[12px] tabular-nums transition-colors',
// ⛔ 底色来自父层渐变;这里只管**文字色与交互态**(见文件头配色纪律) // ⛔ 颜色只表达**交互态**,不表达数据(见文件头)
n === 0 n === 0
? 'cursor-default text-slate-400/70' ? 'cursor-default text-slate-300'
: cn(c.text, 'cursor-pointer font-medium hover:bg-white/30'), : 'cursor-pointer font-medium text-slate-700 hover:bg-slate-100',
isSelected && 'bg-white/40 ring-2 ring-inset ring-slate-800', isSelected && 'bg-slate-100 ring-1 ring-inset ring-slate-700',
)} )}
> >
{n || '—'} {n || '—'}
......
...@@ -70,23 +70,30 @@ export const TEMPERATURE_ORDER: readonly TemperatureValue[] = [ ...@@ -70,23 +70,30 @@ export const TEMPERATURE_ORDER: readonly TemperatureValue[] = [
Temperature.COLD, Temperature.COLD,
]; ];
export const TEMPERATURE_META: Record< /**
TemperatureValue, * 档位的**显示名**与说明 —— 界面上一律走这里,⛔ 别再在组件里内联中文表。
{ zh: string; icon: string; hint: string } *
> = { * ⚠️ **代码名与显示名刻意不一致**,不是遗漏:
* 代码里是 `hot` / `warm` / `cold`(枚举值、API 参数 `temperature=`、
* `persona_features.data.temperature` 的路径都是它),显示名是「黄金期 / 窗口内 / 窗口外」。
* ⛔ **不要顺手把枚举也改成 golden/inWindow/outWindow** —— 那要同时改
* API 契约、SQL 谓词、已落库的 JSON 路径和前端 query,收益只是"看着顺眼"。
*
* ⭐ 显示名从「热/温/冷」改成临床说法(2026-08 产品定):
* 「热」是**比喻**,而这三档本来就有精确的临床语义(黄金期 / 临床周期内 / 超周期)。
* 名字精确之后,温度渐变那套颜色比喻就成了第二套更弱的编码 —— 一并撤掉,见 pool-matrix。
*/
export const TEMPERATURE_META: Record<TemperatureValue, { zh: string; hint: string }> = {
[Temperature.HOT]: { [Temperature.HOT]: {
zh: '热', zh: '黄金期',
icon: '🔥',
hint: '还在该治疗自己的黄金期内 —— 医生刚说过,患者还记得', hint: '还在该治疗自己的黄金期内 —— 医生刚说过,患者还记得',
}, },
[Temperature.WARM]: { [Temperature.WARM]: {
zh: '温', zh: '窗口内',
icon: '🌡',
hint: '过了黄金期但没出临床周期 —— 还来得及,话术要给个理由', hint: '过了黄金期但没出临床周期 —— 还来得及,话术要给个理由',
}, },
[Temperature.COLD]: { [Temperature.COLD]: {
zh: '冷', zh: '窗口外',
icon: '❄️',
hint: '超出该治疗的临床周期 —— 情况可能已经变了,先问近况再谈方案', hint: '超出该治疗的临床周期 —— 情况可能已经变了,先问近况再谈方案',
}, },
}; };
......
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