Commit e13e526f by luoqi

feat(web): 治疗时间轴拆「治疗 / 治疗计划」+ chip 配色对齐 + 状态感知

计划治疗(treatment_record kind=planned 且未完成)拆成独立筛选 chip「治疗计划」(虚拟键
treatment_plan),sky 标签/「计划」徽标/虚线空心点,跟已做的「治疗」(slate「已完成」)区分;
已做掉的计划归回「治疗 / 已完成」(状态感知 isPlannedTreatment)。筛选 chip 选中色 = 各区块色。
治疗历史详情默认只激活「治疗」。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent c05d5c3c
...@@ -100,7 +100,7 @@ export function Drawer({ ...@@ -100,7 +100,7 @@ export function Drawer({
// 治疗历史卡「详情」:同 facts 时间轴,默认只勾选治疗(可在 chip 里再放开看其余) // 治疗历史卡「详情」:同 facts 时间轴,默认只勾选治疗(可在 chip 里再放开看其余)
const txCount = facts.filter((f) => f.type === 'treatment_record').length; const txCount = facts.filter((f) => f.type === 'treatment_record').length;
title = `治疗时间轴(${txCount})`; title = `治疗时间轴(${txCount})`;
subtitle = '仅治疗 · 时间倒序'; subtitle = '仅治疗 · 时间倒序(治疗计划可在上方筛选打开)';
body = <FactsTimeline facts={facts} initialTypes={['treatment_record']} />; body = <FactsTimeline facts={facts} initialTypes={['treatment_record']} />;
width = 'w-[640px]'; width = 'w-[640px]';
} else if (kind === 'return-visits') { } else if (kind === 'return-visits') {
......
...@@ -43,10 +43,14 @@ export function FactsTimeline({ ...@@ -43,10 +43,14 @@ export function FactsTimeline({
return <div className="text-center py-12 text-sm text-slate-400">无 fact</div>; return <div className="text-center py-12 text-sm text-slate-400">无 fact</div>;
} }
// ─ 按 type 统计 + 默认全选 ─ // ─ 按筛选键统计 + 默认全选 ─
// 计划治疗(treatment_record kind=planned)拆成独立筛选键 treatment_plan,跟已做的治疗分开筛
const typeCounts = useMemo(() => { const typeCounts = useMemo(() => {
const c = new Map<string, number>(); const c = new Map<string, number>();
for (const f of facts) c.set(f.type, (c.get(f.type) ?? 0) + 1); for (const f of facts) {
const k = factFilterKey(f);
c.set(k, (c.get(k) ?? 0) + 1);
}
return c; return c;
}, [facts]); }, [facts]);
const allTypes = useMemo(() => [...typeCounts.keys()].sort(), [typeCounts]); const allTypes = useMemo(() => [...typeCounts.keys()].sort(), [typeCounts]);
...@@ -89,7 +93,7 @@ export function FactsTimeline({ ...@@ -89,7 +93,7 @@ export function FactsTimeline({
const sorted = useMemo( const sorted = useMemo(
() => () =>
[...facts] [...facts]
.filter((f) => selected.has(f.type)) .filter((f) => selected.has(factFilterKey(f)))
.filter((f) => !toothFilter || factTeeth(f).includes(toothFilter)) .filter((f) => !toothFilter || factTeeth(f).includes(toothFilter))
.sort((a, b) => tkey(b) - tkey(a) || typeRank(b) - typeRank(a)), .sort((a, b) => tkey(b) - tkey(a) || typeRank(b) - typeRank(a)),
[facts, selected, toothFilter], [facts, selected, toothFilter],
...@@ -113,6 +117,7 @@ export function FactsTimeline({ ...@@ -113,6 +117,7 @@ export function FactsTimeline({
label={meta.label} label={meta.label}
count={typeCounts.get(t) ?? 0} count={typeCounts.get(t) ?? 0}
active={selected.has(t)} active={selected.has(t)}
tone={meta.tone}
onClick={() => toggleType(t)} onClick={() => toggleType(t)}
/> />
); );
...@@ -302,17 +307,21 @@ function TimelineRow({ fact }: { fact: AdaptedFact }) { ...@@ -302,17 +307,21 @@ function TimelineRow({ fact }: { fact: AdaptedFact }) {
? (fact.occurredAt ? '' : '约 ') + new Date(tIso).toLocaleDateString('zh-CN').replace(/\//g, '.') ? (fact.occurredAt ? '' : '约 ') + new Date(tIso).toLocaleDateString('zh-CN').replace(/\//g, '.')
: '—'; : '—';
// 计划治疗(treatment_record kind=planned)≠ 已做的治疗:单独标"治疗计划" + 虚线空心点 + "计划"徽标,
// 避免跟已完成的治疗只靠"约"前缀区分(客服反馈分不清)。
const isPlannedTx = isPlannedTreatment(fact);
const typeLabel = isPlannedTx ? '治疗计划' : meta.label;
const { title, note } = factSummary(fact); const { title, note } = factSummary(fact);
const rightLines = rightColumn(fact); const rightLines = rightColumn(fact);
return ( return (
<div className="relative pb-3"> <div className="relative pb-3">
{/* icon dot — 默认 slate 灰,异常类型(rose/emerald/amber)着色;ring-white 保持时间轴脉络感 */} {/* icon dot — 默认 slate 灰,异常类型(rose/emerald/amber)着色;计划治疗用虚线空心点(未发生);ring-white 保持时间轴脉络感 */}
<span <span
className={cn( className={cn(
'absolute -left-[22px] top-0.5 w-5 h-5 rounded-full flex items-center justify-center ring-2 ring-white', 'absolute -left-[22px] top-0.5 w-5 h-5 rounded-full flex items-center justify-center ring-2 ring-white',
T.bg, isPlannedTx ? 'border border-dashed border-sky-300 bg-white text-sky-500' : cn(T.bg, T.text),
T.text,
)} )}
> >
<Icon className="w-3 h-3" /> <Icon className="w-3 h-3" />
...@@ -323,7 +332,7 @@ function TimelineRow({ fact }: { fact: AdaptedFact }) { ...@@ -323,7 +332,7 @@ function TimelineRow({ fact }: { fact: AdaptedFact }) {
<div className="text-[10.5px] text-slate-500 tabular-nums"> <div className="text-[10.5px] text-slate-500 tabular-nums">
<span className="font-mono">{dateStr}</span> <span className="font-mono">{dateStr}</span>
<span className="mx-1 text-slate-300">·</span> <span className="mx-1 text-slate-300">·</span>
<span>{meta.label}</span> <span className={isPlannedTx ? 'font-medium text-sky-600' : undefined}>{typeLabel}</span>
</div> </div>
<div className="mt-0.5 text-[13px] font-medium text-slate-900 leading-tight">{title}</div> <div className="mt-0.5 text-[13px] font-medium text-slate-900 leading-tight">{title}</div>
{note && ( {note && (
...@@ -354,6 +363,8 @@ function computeEffectiveTone(fact: AdaptedFact, meta: FactMeta): string { ...@@ -354,6 +363,8 @@ function computeEffectiveTone(fact: AdaptedFact, meta: FactMeta): string {
// 状态 badge 统一样式(slate)— W4 末降噪:语义色集中在时间轴 icon dot,badge 不重复表达 // 状态 badge 统一样式(slate)— W4 末降噪:语义色集中在时间轴 icon dot,badge 不重复表达
const STATUS_BADGE = 'px-1.5 py-px rounded border text-[10.5px] bg-slate-50 text-slate-600 border-slate-100'; const STATUS_BADGE = 'px-1.5 py-px rounded border text-[10.5px] bg-slate-50 text-slate-600 border-slate-100';
// 计划治疗专用徽标(sky)— 跟"已完成"(slate)区分:这是还没做的计划,不是已发生的事实
const PLAN_BADGE = 'px-1.5 py-px rounded border text-[10.5px] bg-sky-50 text-sky-700 border-sky-200';
// 右列数据 — 按 fact 类型挑最关键的 1-2 行 // 右列数据 — 按 fact 类型挑最关键的 1-2 行
interface RightLine { text: string; className?: string } interface RightLine { text: string; className?: string }
...@@ -370,10 +381,15 @@ function rightColumn(fact: AdaptedFact): RightLine[] { ...@@ -370,10 +381,15 @@ function rightColumn(fact: AdaptedFact): RightLine[] {
out.push({ text: teeth.length > 4 ? `${teeth.slice(0, 3).join(';')}${teeth.length} 颗` : teeth.join(';'), className: 'font-medium text-slate-700' }); out.push({ text: teeth.length > 4 ? `${teeth.slice(0, 3).join(';')}${teeth.length} 颗` : teeth.join(';'), className: 'font-medium text-slate-700' });
} }
if (fact.type === 'treatment_record') { if (fact.type === 'treatment_record') {
if (isPlannedTreatment(fact)) {
// 待做的计划治疗:醒目的"计划"徽标(sky)。已做掉的不进这里(归下方已完成)
out.push({ text: '计划', className: PLAN_BADGE });
} else {
const st = String(c.status ?? ''); const st = String(c.status ?? '');
if (st === 'completed' || st === 'fulfilled') out.push({ text: '已完成', className: STATUS_BADGE }); if (st === 'completed' || st === 'fulfilled') out.push({ text: '已完成', className: STATUS_BADGE });
else if (st === 'cancelled') out.push({ text: '已取消', className: STATUS_BADGE }); else if (st === 'cancelled') out.push({ text: '已取消', className: STATUS_BADGE });
} }
}
break; break;
} }
case 'payment_record': case 'payment_record':
...@@ -434,29 +450,28 @@ function FilterChip({ ...@@ -434,29 +450,28 @@ function FilterChip({
count, count,
active, active,
onClick, onClick,
tone = 'sky',
}: { }: {
label: string; label: string;
count: number; count: number;
active: boolean; active: boolean;
onClick: () => void; onClick: () => void;
/// 选中色 = 该类型对应区块/行的颜色(诊断 rose / 治疗计划 sky / …),让 chip 跟内容一眼对得上
tone?: string;
}) { }) {
const T = CHIP_TONE[tone] ?? CHIP_TONE_FALLBACK;
return ( return (
<button <button
type="button" type="button"
onClick={onClick} onClick={onClick}
className={cn( className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[11px] border transition-colors', 'inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[11px] border transition-colors',
active active ? cn(T.bg, T.text, T.border, 'font-medium') : 'bg-white text-slate-500 border-slate-100 hover:bg-slate-50',
? 'bg-sky-50 text-sky-700 border-sky-200 font-medium'
: 'bg-white text-slate-500 border-slate-100 hover:bg-slate-50',
)} )}
> >
<span>{label}</span> <span>{label}</span>
<span <span
className={cn( className={cn('tabular-nums text-[10px] px-1 rounded', active ? cn('bg-white/70', T.text) : 'bg-slate-100 text-slate-500')}
'tabular-nums text-[10px] px-1 rounded',
active ? 'bg-sky-100 text-sky-700' : 'bg-slate-100 text-slate-500',
)}
> >
{count} {count}
</span> </span>
...@@ -464,6 +479,17 @@ function FilterChip({ ...@@ -464,6 +479,17 @@ function FilterChip({
); );
} }
// chip 选中态配色(跟时间轴行/区块同色系)。slate 用更可读的浅灰选中样式。
const CHIP_TONE_FALLBACK = { bg: 'bg-sky-50', text: 'text-sky-700', border: 'border-sky-200' };
const CHIP_TONE: Record<string, { bg: string; text: string; border: string }> = {
rose: { bg: 'bg-rose-50', text: 'text-rose-700', border: 'border-rose-200' },
emerald: { bg: 'bg-emerald-50', text: 'text-emerald-700', border: 'border-emerald-200' },
amber: { bg: 'bg-amber-50', text: 'text-amber-800', border: 'border-amber-200' },
sky: { bg: 'bg-sky-50', text: 'text-sky-700', border: 'border-sky-200' },
indigo: { bg: 'bg-indigo-50', text: 'text-indigo-700', border: 'border-indigo-200' },
slate: { bg: 'bg-slate-100', text: 'text-slate-700', border: 'border-slate-300' },
};
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
// fact.type → 元信息(图标 + 中文 + 色) // fact.type → 元信息(图标 + 中文 + 色)
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
...@@ -485,6 +511,8 @@ const FACT_META: Record<string, FactMeta> = { ...@@ -485,6 +511,8 @@ const FACT_META: Record<string, FactMeta> = {
recharge_record: { label: '充值', tone: 'emerald', Icon: Wallet }, recharge_record: { label: '充值', tone: 'emerald', Icon: Wallet },
// 其它一律 slate(默认),仅 icon 区分 // 其它一律 slate(默认),仅 icon 区分
treatment_record: { label: '治疗', tone: 'slate', Icon: Stethoscope }, treatment_record: { label: '治疗', tone: 'slate', Icon: Stethoscope },
// 计划治疗(虚拟筛选键,见 factFilterKey)—— sky,跟时间轴里计划治疗行的 sky 一致
treatment_plan: { label: '治疗计划', tone: 'sky', Icon: Stethoscope },
recommendation_record: { label: '医嘱', tone: 'slate', Icon: Pill }, recommendation_record: { label: '医嘱', tone: 'slate', Icon: Pill },
encounter_record: { label: '接诊', tone: 'slate', Icon: UserRound }, encounter_record: { label: '接诊', tone: 'slate', Icon: UserRound },
emr_record: { label: '病历', tone: 'slate', Icon: FileText }, emr_record: { label: '病历', tone: 'slate', Icon: FileText },
...@@ -707,6 +735,19 @@ function toothBase(t: string): string { ...@@ -707,6 +735,19 @@ function toothBase(t: string): string {
return m ? m[1]!.toUpperCase() : t.trim().toUpperCase(); return m ? m[1]!.toUpperCase() : t.trim().toUpperCase();
} }
/// 是否"待做的"计划治疗 = planned 且还没做(状态非完成/履约)。
/// 已经做掉的计划(status=completed/fulfilled)不算治疗计划 —— 归到"治疗 / 已完成"。
function isPlannedTreatment(f: AdaptedFact): boolean {
if (f.type !== 'treatment_record' || f.kind !== 'planned') return false;
const st = String((f.content as Record<string, unknown> | null)?.status ?? '');
return st !== 'completed' && st !== 'fulfilled';
}
/// 筛选键:待做的计划治疗拆成独立的 treatment_plan,跟已做治疗分开筛
function factFilterKey(f: AdaptedFact): string {
return isPlannedTreatment(f) ? 'treatment_plan' : f.type;
}
/// 取一条 fact 的牙位集合(content.tooth_position 字符串 或 tooth_positions 数组) /// 取一条 fact 的牙位集合(content.tooth_position 字符串 或 tooth_positions 数组)
function factTeeth(f: AdaptedFact): string[] { function factTeeth(f: AdaptedFact): string[] {
const c = (f.content ?? {}) as Record<string, unknown>; const c = (f.content ?? {}) as Record<string, unknown>;
......
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