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({
// 治疗历史卡「详情」:同 facts 时间轴,默认只勾选治疗(可在 chip 里再放开看其余)
const txCount = facts.filter((f) => f.type === 'treatment_record').length;
title = `治疗时间轴(${txCount})`;
subtitle = '仅治疗 · 时间倒序';
subtitle = '仅治疗 · 时间倒序(治疗计划可在上方筛选打开)';
body = <FactsTimeline facts={facts} initialTypes={['treatment_record']} />;
width = 'w-[640px]';
} else if (kind === 'return-visits') {
......
......@@ -43,10 +43,14 @@ export function FactsTimeline({
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 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;
}, [facts]);
const allTypes = useMemo(() => [...typeCounts.keys()].sort(), [typeCounts]);
......@@ -89,7 +93,7 @@ export function FactsTimeline({
const sorted = useMemo(
() =>
[...facts]
.filter((f) => selected.has(f.type))
.filter((f) => selected.has(factFilterKey(f)))
.filter((f) => !toothFilter || factTeeth(f).includes(toothFilter))
.sort((a, b) => tkey(b) - tkey(a) || typeRank(b) - typeRank(a)),
[facts, selected, toothFilter],
......@@ -113,6 +117,7 @@ export function FactsTimeline({
label={meta.label}
count={typeCounts.get(t) ?? 0}
active={selected.has(t)}
tone={meta.tone}
onClick={() => toggleType(t)}
/>
);
......@@ -302,17 +307,21 @@ function TimelineRow({ fact }: { fact: AdaptedFact }) {
? (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 rightLines = rightColumn(fact);
return (
<div className="relative pb-3">
{/* icon dot — 默认 slate 灰,异常类型(rose/emerald/amber)着色;ring-white 保持时间轴脉络感 */}
{/* icon dot — 默认 slate 灰,异常类型(rose/emerald/amber)着色;计划治疗用虚线空心点(未发生);ring-white 保持时间轴脉络感 */}
<span
className={cn(
'absolute -left-[22px] top-0.5 w-5 h-5 rounded-full flex items-center justify-center ring-2 ring-white',
T.bg,
T.text,
isPlannedTx ? 'border border-dashed border-sky-300 bg-white text-sky-500' : cn(T.bg, T.text),
)}
>
<Icon className="w-3 h-3" />
......@@ -323,7 +332,7 @@ function TimelineRow({ fact }: { fact: AdaptedFact }) {
<div className="text-[10.5px] text-slate-500 tabular-nums">
<span className="font-mono">{dateStr}</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 className="mt-0.5 text-[13px] font-medium text-slate-900 leading-tight">{title}</div>
{note && (
......@@ -354,6 +363,8 @@ function computeEffectiveTone(fact: AdaptedFact, meta: FactMeta): string {
// 状态 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';
// 计划治疗专用徽标(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 行
interface RightLine { text: string; className?: string }
......@@ -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' });
}
if (fact.type === 'treatment_record') {
if (isPlannedTreatment(fact)) {
// 待做的计划治疗:醒目的"计划"徽标(sky)。已做掉的不进这里(归下方已完成)
out.push({ text: '计划', className: PLAN_BADGE });
} else {
const st = String(c.status ?? '');
if (st === 'completed' || st === 'fulfilled') out.push({ text: '已完成', className: STATUS_BADGE });
else if (st === 'cancelled') out.push({ text: '已取消', className: STATUS_BADGE });
}
}
break;
}
case 'payment_record':
......@@ -434,29 +450,28 @@ function FilterChip({
count,
active,
onClick,
tone = 'sky',
}: {
label: string;
count: number;
active: boolean;
onClick: () => void;
/// 选中色 = 该类型对应区块/行的颜色(诊断 rose / 治疗计划 sky / …),让 chip 跟内容一眼对得上
tone?: string;
}) {
const T = CHIP_TONE[tone] ?? CHIP_TONE_FALLBACK;
return (
<button
type="button"
onClick={onClick}
className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[11px] border transition-colors',
active
? 'bg-sky-50 text-sky-700 border-sky-200 font-medium'
: 'bg-white text-slate-500 border-slate-100 hover:bg-slate-50',
active ? cn(T.bg, T.text, T.border, 'font-medium') : 'bg-white text-slate-500 border-slate-100 hover:bg-slate-50',
)}
>
<span>{label}</span>
<span
className={cn(
'tabular-nums text-[10px] px-1 rounded',
active ? 'bg-sky-100 text-sky-700' : 'bg-slate-100 text-slate-500',
)}
className={cn('tabular-nums text-[10px] px-1 rounded', active ? cn('bg-white/70', T.text) : 'bg-slate-100 text-slate-500')}
>
{count}
</span>
......@@ -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 → 元信息(图标 + 中文 + 色)
// ─────────────────────────────────────────────
......@@ -485,6 +511,8 @@ const FACT_META: Record<string, FactMeta> = {
recharge_record: { label: '充值', tone: 'emerald', Icon: Wallet },
// 其它一律 slate(默认),仅 icon 区分
treatment_record: { label: '治疗', tone: 'slate', Icon: Stethoscope },
// 计划治疗(虚拟筛选键,见 factFilterKey)—— sky,跟时间轴里计划治疗行的 sky 一致
treatment_plan: { label: '治疗计划', tone: 'sky', Icon: Stethoscope },
recommendation_record: { label: '医嘱', tone: 'slate', Icon: Pill },
encounter_record: { label: '接诊', tone: 'slate', Icon: UserRound },
emr_record: { label: '病历', tone: 'slate', Icon: FileText },
......@@ -707,6 +735,19 @@ function toothBase(t: string): string {
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 数组)
function factTeeth(f: AdaptedFact): string[] {
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