Commit 6248a4f8 by luoqi

refactor(plan): 潜在治疗标签改「XX治疗」措辞 + 卡片字段顺序对齐详情页

- 措辞:潜在种植 → 种植治疗、潜在补牙 → **充填治疗**、潜在早矫 → **早期矫治**,余类推。
- 顺序:姓名 → 病历号 → 性别·年龄(原来病历号在最后),与详情页患者卡一致。

 中文改在**展示层字典**(POTENTIAL_TREATMENT_CARD_LABEL),不改画像 extractor 的 zh:
  画像里的 labels 是算画像那一刻写死进 JSON 的,改措辞得**全量重算画像**(百万级,几小时)
  才看得到。所以接口改成透 potential_treatment.**types**(code),中文由前端查表 →
  改词即时生效、全站一处。未知 code 原样显示,不静默吞标签。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent 844d19be
...@@ -228,11 +228,13 @@ export class PlanService { ...@@ -228,11 +228,13 @@ export class PlanService {
select: { data: true, persona: { select: { patientId: true } } }, select: { data: true, persona: { select: { patientId: true } } },
}); });
for (const r of rows) { for (const r of rows) {
const labels = (r.data as { labels?: unknown } | null)?.labels; // ⭐ 取 types(code)不取 labels(中文):labels 是算画像那刻写死进 JSON 的,
if (!Array.isArray(labels)) continue; // 改措辞得全量重算才生效;code 稳定,中文由前端查 POTENTIAL_TREATMENT_CARD_LABEL。
const types = (r.data as { types?: unknown } | null)?.types;
if (!Array.isArray(types)) continue;
out.set( out.set(
r.persona.patientId, r.persona.patientId,
labels.filter((l): l is string => typeof l === 'string' && l.length > 0), types.filter((t): t is string => typeof t === 'string' && t.length > 0),
); );
} }
return out; return out;
......
...@@ -13,6 +13,7 @@ import { ...@@ -13,6 +13,7 @@ import {
type ExecutionOutcome, type ExecutionOutcome,
type ExecutionOutcomeGroup, type ExecutionOutcomeGroup,
type PlanListItem, type PlanListItem,
potentialTreatmentCardLabel,
} 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';
...@@ -548,11 +549,14 @@ function PotentialTreatmentChips({ labels }: { labels: string[] }) { ...@@ -548,11 +549,14 @@ function PotentialTreatmentChips({ labels }: { labels: string[] }) {
key={l} key={l}
className="inline-block flex-none rounded px-1 py-px text-[10px] leading-4 text-slate-600 ring-1 ring-inset ring-slate-200" className="inline-block flex-none rounded px-1 py-px text-[10px] leading-4 text-slate-600 ring-1 ring-inset ring-slate-200"
> >
{l} {potentialTreatmentCardLabel(l)}
</span> </span>
))} ))}
{rest > 0 && ( {rest > 0 && (
<span className="flex-none text-[10px] text-slate-400" title={labels.slice(2).join(' · ')}> <span
className="flex-none text-[10px] text-slate-400"
title={labels.slice(2).map(potentialTreatmentCardLabel).join(' · ')}
>
+{rest} +{rest}
</span> </span>
)} )}
...@@ -621,15 +625,16 @@ function PatientRow({ ...@@ -621,15 +625,16 @@ function PatientRow({
<span className={cn('truncate text-[13px] font-semibold', active ? 'text-teal-800' : 'text-slate-900')}> <span className={cn('truncate text-[13px] font-semibold', active ? 'text-teal-800' : 'text-slate-900')}>
{p.patient.name ?? p.patient.nameMasked ?? '—'} {p.patient.name ?? p.patient.nameMasked ?? '—'}
</span> </span>
<span className="flex-none text-[10.5px] tabular-nums text-slate-500"> {/* 顺序对齐详情页患者卡:姓名 → 病历号 → 性别·年龄。
{formatGender(p.patient.gender)} · {p.patient.age ?? '?'} 宿主不一定给病历号(jvs-dw 91% / friday 63%),缺就不占位 */}
</span>
{/* 病历号:客服口头核对身份用。宿主不一定给(jvs-dw 91% / friday 63%),缺就不占位 */}
{p.patient.medicalRecordNumber && ( {p.patient.medicalRecordNumber && (
<span className="min-w-0 truncate font-mono text-[10px] tabular-nums text-slate-400" title="病历号"> <span className="min-w-0 truncate font-mono text-[10px] tabular-nums text-slate-400" title="病历号">
{p.patient.medicalRecordNumber} {p.patient.medicalRecordNumber}
</span> </span>
)} )}
<span className="flex-none text-[10.5px] tabular-nums text-slate-500">
{formatGender(p.patient.gender)} · {p.patient.age ?? '?'}
</span>
{p.patient.phoneVerified && ( {p.patient.phoneVerified && (
<span className="flex-none rounded bg-teal-50 px-1 text-[9.5px] font-medium leading-4 text-teal-700 ring-1 ring-inset ring-teal-200"> <span className="flex-none rounded bg-teal-50 px-1 text-[9.5px] font-medium leading-4 text-teal-700 ring-1 ring-inset ring-teal-200">
......
...@@ -17,7 +17,7 @@ import { ...@@ -17,7 +17,7 @@ import {
X, X,
} from 'lucide-react'; } from 'lucide-react';
import type { ListPlansQuery, PlanListItem } from '@pac/types'; import type { ListPlansQuery, PlanListItem } from '@pac/types';
import { Permission } from '@pac/types'; import { Permission, potentialTreatmentCardLabel } from '@pac/types';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
...@@ -789,15 +789,16 @@ function PatientPlanCard({ ...@@ -789,15 +789,16 @@ function PatientPlanCard({
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-1.5"> <div className="flex min-w-0 flex-wrap items-center gap-1.5">
<span className={cn('truncate font-semibold leading-tight text-slate-900', titleSize)}>{displayName}</span> <span className={cn('truncate font-semibold leading-tight text-slate-900', titleSize)}>{displayName}</span>
<span className="text-[10.5px] text-slate-500 nums"> {/* 顺序对齐详情页患者卡:姓名 → 病历号 → 性别·年龄。
{formatGender(p.patient.gender)} · {p.patient.age ?? '?'} 宿主不一定给病历号(jvs-dw 91% / friday 63%),缺就整块不占位 */}
</span>
{/* 病历号:客服口头核对身份用。宿主不一定给(jvs-dw 91% / friday 63%),缺就整块不占位 */}
{p.patient.medicalRecordNumber && ( {p.patient.medicalRecordNumber && (
<span className="truncate font-mono text-[10.5px] text-slate-400 nums" title="病历号"> <span className="truncate font-mono text-[10.5px] text-slate-400 nums" title="病历号">
{p.patient.medicalRecordNumber} {p.patient.medicalRecordNumber}
</span> </span>
)} )}
<span className="text-[10.5px] text-slate-500 nums">
{formatGender(p.patient.gender)} · {p.patient.age ?? '?'}
</span>
</div> </div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500"> <div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-slate-500">
<span className="font-mono nums">{p.patient.phoneMasked ?? '电话未知'}</span> <span className="font-mono nums">{p.patient.phoneMasked ?? '电话未知'}</span>
...@@ -990,11 +991,14 @@ function PotentialTreatmentChips({ labels }: { labels: string[] }) { ...@@ -990,11 +991,14 @@ function PotentialTreatmentChips({ labels }: { labels: string[] }) {
key={l} key={l}
className="inline-block rounded px-1.5 py-0.5 text-[10.5px] text-slate-600 ring-1 ring-inset ring-slate-200" className="inline-block rounded px-1.5 py-0.5 text-[10.5px] text-slate-600 ring-1 ring-inset ring-slate-200"
> >
{l} {potentialTreatmentCardLabel(l)}
</span> </span>
))} ))}
{rest > 0 && ( {rest > 0 && (
<span className="text-[10.5px] text-slate-400" title={labels.slice(3).join(' · ')}> <span
className="text-[10.5px] text-slate-400"
title={labels.slice(3).map(potentialTreatmentCardLabel).join(' · ')}
>
+{rest} +{rest}
</span> </span>
)} )}
......
...@@ -78,3 +78,30 @@ export const PERSONA_FEATURE_META: Record<string, { label: string; tone: Tone }> ...@@ -78,3 +78,30 @@ export const PERSONA_FEATURE_META: Record<string, { label: string; tone: Tone }>
export const personaFeatureMeta = (key: string): { label: string; tone: Tone } => export const personaFeatureMeta = (key: string): { label: string; tone: Tone } =>
PERSONA_FEATURE_META[key] ?? { label: key, tone: 'slate' }; PERSONA_FEATURE_META[key] ?? { label: key, tone: 'slate' };
/**
* 潜在治疗 —— **卡片展示用**中文(与画像内部存的 `潜在种植` 等不同)。
*
* 为什么单独一份字典、而不是直接用 persona data 里的 labels:
* labels 是**算画像那一刻写死进 JSON 的**,改措辞要全量重算画像才生效(百万级,几小时)。
* 卡片只拿 code(implant/perio/…)、中文在这里查表 → 改词即时生效、且全站一处。
*
* 措辞对齐业务口径(2026-07-29):统一「XX治疗」,filling 用「充填治疗」不用「补牙治疗」,
* early_ortho 用「早期矫治」(「早矫治疗」不通)。
* code 来源:potential-treatment.feature 的 classifyGapToLabel,改那边要同步这里。
*/
export const POTENTIAL_TREATMENT_CARD_LABEL: Record<string, string> = {
implant: '种植治疗',
ortho: '正畸治疗',
early_ortho: '早期矫治',
endo: '根管治疗',
perio: '牙周治疗',
filling: '充填治疗',
restoration: '修复治疗',
extraction: '拔牙治疗',
};
/// code → 卡片中文;未知 code 原样返回(宁可露出生码,也不静默吞掉一个标签)
export function potentialTreatmentCardLabel(code: string): string {
return POTENTIAL_TREATMENT_CARD_LABEL[code] ?? code;
}
...@@ -172,7 +172,9 @@ export const PlanPatientBriefSchema = z.object({ ...@@ -172,7 +172,9 @@ export const PlanPatientBriefSchema = z.object({
/// 否则会出现"日期是A次、医生是B次"的错配。doctor_name 只有 emr_record 带 /// 否则会出现"日期是A次、医生是B次"的错配。doctor_name 只有 emr_record 带
/// (encounter_record 实测 0% 有名),故同日并列时优先取 emr。取不到 → null,前端不显示。 /// (encounter_record 实测 0% 有名),故同日并列时优先取 emr。取不到 → null,前端不显示。
lastVisitDoctor: z.string().nullable(), lastVisitDoctor: z.string().nullable(),
/// 潜在治疗标签(画像 potential_treatment.labels,如「潜在种植」「潜在牙周」)。 /// 潜在治疗 **code**(画像 potential_treatment.types,如 implant / perio)。
/// 刻意给 code 不给中文:中文在前端查 POTENTIAL_TREATMENT_CARD_LABEL,改措辞即时生效;
/// 若透画像里存的 labels,改词就得全量重算画像才看得到。
/// 无画像 / 无该特征 → 空数组。 /// 无画像 / 无该特征 → 空数组。
potentialTreatments: z.array(z.string()), potentialTreatments: z.array(z.string()),
}); });
......
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