Commit 7f459b48 by luoqi

feat(web): 详情页画像卡片→患者信息·标签云 + 优先级/标签 hover 全部对齐现状

- priority-hover:旧 6 因子表 → v3 三维(急迫×.4/价值×.3/意愿×.3 → ×新鲜度×置信度);
  意愿 sub(RFM/主诉/信任)入 hint;breakdown 类型同步(plan-detail-types + mock-data PlanReason)。
- persona-feature-hover ALGORITHMS:原只有 5 个已删 key(value/recall_risk/...) → 重写为现 16 特征
  的'怎么算的'说明(rfm/年龄/性别/获客/家庭/转介/生命周期/治疗史/时间/折扣/特别关注/敏感/禁忌/急迫/潜在治疗/权益)。
- 详情页'患者画像'卡片(top4 列表)→ '患者信息·标签'卡片:全部特征做成 chip 标签云,
  每个 chip hover 看算法说明(复用 PersonaFeatureHover);列表页优先级 hover 自动随之更新。
- 修详情页两处死 key 读取:value→rfm(累计消费 hint)、recall_risk→lifecycle(久未到诊提示)。
- web tsc 通过。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent ffc64273
...@@ -286,16 +286,18 @@ export type PlanReason = { ...@@ -286,16 +286,18 @@ export type PlanReason = {
occurredAt: string; occurredAt: string;
negative?: boolean; negative?: boolean;
}>; }>;
/// v2.1 6 因子算法 breakdown(详情页"为什么 X 分"渲染数据);旧 mock 数据可空 /// v3.0 三维算法 breakdown(详情页"为什么 X 分"渲染数据);旧 mock 数据可空
breakdown?: { breakdown?: {
priority: { priority: {
clinicalBase: number; urgency: number;
timeWindowFactor: number; value: number;
main: number; willingness: number;
valueBonus: number; rfmAdherence: number;
likelihoodBonus: number; intentBehavior: number;
urgencyBonus: number; trustBase: number;
freshness: number;
confidenceFactor: number; confidenceFactor: number;
base: number;
raw: number; raw: number;
}; };
subKey: string | null; subKey: string | null;
......
...@@ -67,48 +67,156 @@ interface AlgoMeta { ...@@ -67,48 +67,156 @@ interface AlgoMeta {
} }
const ALGORITHMS: Record<string, AlgoMeta> = { const ALGORITHMS: Record<string, AlgoMeta> = {
[PersonaFeatureKey.VALUE]: { [PersonaFeatureKey.RFM]: {
title: '患者价值', title: '价值分群 (RFM)',
subtitle: '生命周期付费档位', subtitle: 'R 最近 · F 频次 · M 金额 → 八象限',
formula: '累计付费 = 付款 + 充值 − 退款', formula: 'R(就诊间隔)+ F(就诊次)+ M(净消费分位)各 1-5 分',
rules: [ rules: [
{ label: 'VIP 钻卡', body: '累计 ≥ ¥30,000' }, { label: '重要价值', body: 'R≥4 F≥3 M≥4 — 近期高频高额' },
{ label: 'VIP 金卡', body: '累计 ≥ ¥10,000' }, { label: '重要保持/发展/挽留', body: '高额(M≥4)但 R 或 F 偏弱' },
{ label: 'VIP 银卡', body: '累计 ≥ ¥3,000' }, { label: '一般价值/保持/发展', body: '对应象限但 M<4(消费偏低)' },
{ label: '普通付费', body: '累计 ≥ ¥500' }, { label: '低活跃', body: 'R≤2 或各项普遍偏低' },
{ label: '新客 / 未消费', body: '< ¥500' },
], ],
note: '档位影响召回优先级"价值加分"(0 / 2 / 5 / 10 / 20)', note: 'M 分位按本租户群体算;另产 valueTier(¥档)+ 生命周期。喂优先级"意愿·RFM依从"',
}, },
[PersonaFeatureKey.TREATMENT_CHAIN_STATUS]: { [PersonaFeatureKey.AGE_BRACKET]: {
title: '治疗链状态', title: '年龄段',
subtitle: '诊断 vs 实际治疗对齐', subtitle: '从生日当下算(snapshot)',
rules: [ rules: [
{ label: '缺口', body: '有诊断但未做对应类别治疗 → 召回核心信号' }, { body: '婴幼儿 0-2 / 学龄前 3-6 / 替牙期 7-11 / 青少年 12-17' },
{ label: '在管', body: '诊断后有同类治疗记录,治疗链推进中' }, { body: '青年 18-25 / 中青年 26-30 / 中年 31-45 / 中老年 46-54 / 老年 ≥55' },
{ label: '闭环', body: '同类治疗已完成、无后续风险(牙周等终身维护类除外)' },
], ],
note: '诊断→治疗类别用统一映射表(如 K02 龋齿 → 充填/嵌体)', note: '生日缺失或年龄越界(<0 / >120)→ 不打标签',
}, },
[PersonaFeatureKey.RECALL_RISK]: { [PersonaFeatureKey.GENDER]: {
title: '流失风险', title: '性别',
subtitle: '复发 + 长期未触达', subtitle: '影响话术与项目推荐',
rules: [ rules: [
{ label: '高', body: '距上次临床事件 ≥ 540 天 + 治疗链有缺口' }, { label: '男性', body: 'gender = M / 男' },
{ label: '中', body: '距上次 ≥ 360 天,或 治疗链有缺口' }, { label: '女性', body: 'gender = F / 女' },
{ label: '低', body: '距上次 ≥ 180 天' }, { label: '未知', body: '其他取值(始终打标签)' },
{ label: '无', body: '< 180 天 且 链无缺口' },
], ],
note: '影响召回优先级"转化加分"(无=0 / 低=2 / 中=4 / 高=6)',
}, },
[PersonaFeatureKey.DO_NOT_CONTACT_STATUS]: { [PersonaFeatureKey.ACQUISITION_CHANNEL]: {
title: '不打扰状态', title: '获客渠道',
subtitle: '合规硬约束', subtitle: '初诊来源(数仓 L2)',
rules: [ rules: [
{ label: '不打扰', body: '主档标记不打扰 / 患者已故 / 有未关闭投诉记录' }, { body: '客户首次到诊的获客来源,数仓按初诊来源规则算好,一经判定不改' },
{ label: '可触达', body: '上述全无' }, { body: '一级 = PAC 标准枚举 / 二级 = host 原值' },
], ],
note: '不打扰时召回引擎硬拦截,不生成 / 已生成的不派单。电话缺失算"待补充电话",不算不打扰', note: '无渠道值(老数据/未摄入)→ 不打标签',
},
[PersonaFeatureKey.FAMILY_STRUCTURE]: {
title: '家庭构成',
subtitle: '直系亲属关系反推',
rules: [
{ label: '多代之家', body: '有长辈(父母/祖辈)— 跨代优先' },
{ label: '多口之家', body: '有子女(含单亲)' },
{ label: '两口之家', body: '有配偶' },
{ label: '单身家庭', body: '仅非直系边(同辈/朋友)' },
],
note: '无任何关系边 → 不打标签;依赖关系边覆盖,当前样本偏低',
},
[PersonaFeatureKey.REFERRAL_CHAMPION]: {
title: '转介绍达人',
subtitle: '老带新(有推荐且带来转化)',
rules: [
{ label: '家庭型', body: '带家人:直系家庭关系 ≥3 人' },
{ label: '社交型', body: '推外人:非同手机号推荐 ≥3 人' },
{ body: 'v1 用 DW 预聚合:推荐人数 + 带来转化总额(>0)' },
],
note: '推荐<3 或无转化额 → 不打标签。喂优先级"信任 +1"',
},
[PersonaFeatureKey.LIFECYCLE_STAGE]: {
title: '生命周期',
subtitle: '时间 + 消费规则',
rules: [
{ label: '潜客', body: '0 就诊 + 有预约/咨询' },
{ label: '新客', body: '首诊 ≤180 天 且 就诊 ≤3 次' },
{ label: '成长客', body: '近1年消费 > 历史年均 ×1.5' },
{ label: '成熟客', body: '末诊 ≤540 且 年均就诊 ≥0.5(稳定)' },
{ label: '待激活 / 沉睡 / 流失', body: '末诊 180-540 / 540-730 / >730 天' },
],
note: '喂优先级"意愿·信任基础"(成熟9 … 流失0)',
},
[PersonaFeatureKey.TREATMENT_HISTORY]: {
title: '治疗史',
subtitle: '读 treatment category(可多标签)',
rules: [
{ label: '种植史', body: 'implant' },
{ label: '正畸史', body: 'orthodontic' },
{ label: '修复史', body: 'prosthodontic / cosmetic(冠桥/贴面/嵌体)' },
{ label: '牙周治疗史', body: 'periodontic' },
],
note: '基础治疗(拔/补/根管)不计。喂优先级"信任·同类治疗史 +1"',
},
[PersonaFeatureKey.TIME_PREFERENCE]: {
title: '时间偏好',
subtitle: '预约时刻统计(可多标签)',
rules: [
{ label: '工作日 / 周末', body: '工作日占比 ≥60% / 周末 ≥50%' },
{ label: '上午 / 下午 / 晚间', body: '8-12 / 12-18 / 18-21 时占比 ≥50%' },
],
note: '近2年预约槽(北京时区),记录 <2 条 → 不打标签',
},
[PersonaFeatureKey.DISCOUNT_ANCHOR]: {
title: '折扣锚点',
subtitle: '历史最深折扣(价格底线)',
formula: '折扣率 = 1 − 折扣额 / 应收',
rules: [
{ body: '取真实治疗(原价 ≥¥500)上谈到的最深【部分】折扣 + 日期/项目' },
{ body: '排除免费洁牙/检查促销(全免 0 折)避免霸占锚点' },
],
note: '无折扣记录 → 不打标签(业务:无锚点则换推权益,不直接降价)',
},
[PersonaFeatureKey.SPECIAL_ATTENTION]: {
title: '特别关注',
subtitle: '排班/触达需注意(可多标签)',
rules: [
{ label: '屡次爽约', body: '近1年履约率 <50% 且决定数 ≥3' },
{ label: '经常迟到', body: '迟到(>15min)占比 ≥50% 且 ≥3 次' },
{ label: '免打扰', body: '主档标记不打扰' },
{ label: '不可等候', body: 'notes/标签/病历含 赶时间/不能等' },
],
},
[PersonaFeatureKey.TREATMENT_SENSITIVITY]: {
title: '治疗敏感',
subtitle: '病历关键词(可多标签)',
rules: [
{ label: '看牙恐惧', body: '牙科恐惧/害怕看牙/牙科焦虑' },
{ label: '晕针 / 晕血', body: '晕针·害怕打针 / 晕血·见血不适' },
{ label: '密闭恐惧', body: '幽闭/密闭/长时间张口不适' },
],
note: '关键词按真实数据精炼排假阳(不用裸"紧张/见血/张口受限")',
},
[PersonaFeatureKey.CONTRAINDICATION]: {
title: '禁忌标签',
subtitle: '治疗可行性 + 安全预警',
rules: [
{ label: '种植禁忌', body: 'v1 仅:年龄 ≤18(骨骼发育未完全)' },
{ body: '其余(糖尿/高血压/过敏/抗凝/妊娠…)依赖既往史自由文本 → 留 Layer C(LLM)' },
],
note: '年龄禁忌满 19 岁重算自动解除(validUntil 标到期日)',
},
[PersonaFeatureKey.URGENCY_LEVEL]: {
title: '急迫等级',
subtitle: '潜在治疗路径 × 末诊',
rules: [
{ label: '紧急', body: '末诊 >90 天(有未满足需求且久未回诊)' },
{ label: '高', body: '末诊 30-90 天' },
{ label: '中', body: '末诊 <30 天 或 新发现' },
],
note: '取所有潜在待转里最急的。喂优先级"急迫性 ×0.4"',
},
[PersonaFeatureKey.POTENTIAL_TREATMENT]: {
title: '潜在治疗',
subtitle: '诊断/建议了但没启动(可多标签)',
rules: [
{ label: '种植 / 修复 / 拔牙', body: 'K08(>18岁) / K03 / K01·K03残根' },
{ label: '正畸 / 早矫', body: 'K07(>12岁) / K07(3-12岁)' },
{ label: '根管 / 牙周 / 补牙', body: 'K04 / K05·K06 / K02' },
],
note: '与召回同源(共享 gap 核心)。喂优先级"价值性 ×0.3"',
}, },
[PersonaFeatureKey.ENTITLEMENT_STATUS]: { [PersonaFeatureKey.ENTITLEMENT_STATUS]: {
title: '权益身份', title: '权益身份',
......
...@@ -40,7 +40,8 @@ import { ...@@ -40,7 +40,8 @@ import {
import { AIStamp, Chip, PriorityBar, SidebarCard, tone } from './shared'; import { AIStamp, Chip, PriorityBar, SidebarCard, tone } from './shared';
import { PriorityHover, type PriorityBreakdown } from '@/components/priority-hover'; import { PriorityHover, type PriorityBreakdown } from '@/components/priority-hover';
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card'; import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card';
import { cleanPersonaValue, shortPersonaValueLabel } from './persona-display'; import { shortPersonaValueLabel } from './persona-display';
import { PersonaFeatureHover } from './persona-feature-hover';
import { ReasonLine } from './reason-line'; import { ReasonLine } from './reason-line';
import { ChainSidebar } from './chain-viz'; import { ChainSidebar } from './chain-viz';
import { ScriptView, type ScriptViewMode } from './script-viewer'; import { ScriptView, type ScriptViewMode } from './script-viewer';
...@@ -322,8 +323,8 @@ export function PlanDetailApp({ ...@@ -322,8 +323,8 @@ export function PlanDetailApp({
onOpenMedical={() => setDrawerOpen('medical')} onOpenMedical={() => setDrawerOpen('medical')}
/> />
<SidebarCard <SidebarCard
title="患者画像" title="患者信息 · 标签"
meta={`更新于 ${fmtRel(persona.computedAt)}`} meta={`${persona.features.length} 个 · 更新于 ${fmtRel(persona.computedAt)}`}
action={ action={
<button <button
onClick={() => setDrawerOpen('persona')} onClick={() => setDrawerOpen('persona')}
...@@ -333,7 +334,7 @@ export function PlanDetailApp({ ...@@ -333,7 +334,7 @@ export function PlanDetailApp({
</button> </button>
} }
> >
<PersonaQuickList features={persona.features.slice(0, 4)} /> <PersonaTagCloud features={persona.features} />
</SidebarCard> </SidebarCard>
<KeyFactsCard <KeyFactsCard
patient={patient} patient={patient}
...@@ -1227,7 +1228,8 @@ function KeyFactsCard({ ...@@ -1227,7 +1228,8 @@ function KeyFactsCard({
// 后端 description "新客/未消费(累计净消费 ¥58.00,含 ...)" 太长 → 取首段 "新客"(去 / 和括号), // 后端 description "新客/未消费(累计净消费 ¥58.00,含 ...)" 太长 → 取首段 "新客"(去 / 和括号),
// 跟主值 ¥58 互补不重复 // 跟主值 ¥58 互补不重复
const ltvYuan = (patient.profile.ltv ?? 0).toLocaleString(); const ltvYuan = (patient.profile.ltv ?? 0).toLocaleString();
const valueFeature = persona.features.find((f) => f.key === PersonaFeatureKey.VALUE); // W7:value 已并入 rfm(分群标签);取 rfm 短标签作累计消费 hint(如"重要价值")
const valueFeature = persona.features.find((f) => f.key === PersonaFeatureKey.RFM);
const valueHint = shortPersonaValueLabel(valueFeature?.value); const valueHint = shortPersonaValueLabel(valueFeature?.value);
// ─ 治疗类目 top 1-2 ─ facts 里 treatment_record.category 出现频次 top(用于主治医生右侧 hint) // ─ 治疗类目 top 1-2 ─ facts 里 treatment_record.category 出现频次 top(用于主治医生右侧 hint)
...@@ -1599,10 +1601,10 @@ function computeRisks({ ...@@ -1599,10 +1601,10 @@ function computeRisks({
if (patient.profile.doNotContact) out.push('⛔ 已标记不打扰'); if (patient.profile.doNotContact) out.push('⛔ 已标记不打扰');
if (patient.profile.deceased) out.push('⛔ 已故'); if (patient.profile.deceased) out.push('⛔ 已故');
// ② persona recall_risk // ② 流失风险:W7 起 recall_risk 并入 rfm/lifecycle;用生命周期(流失/沉睡客)代理
const riskFeature = persona.features.find((f) => f.key === 'recall_risk'); const lifecycleFeature = persona.features.find((f) => f.key === PersonaFeatureKey.LIFECYCLE_STAGE);
if (riskFeature && /高|high/i.test(riskFeature.value)) { if (lifecycleFeature && /流失|沉睡/.test(lifecycleFeature.value)) {
out.push('流失风险高 · 谨慎触达'); out.push('久未到诊 · 谨慎触达,先重新拉近');
} }
// ③ 历史退费 // ③ 历史退费
...@@ -1636,30 +1638,38 @@ function computeRisks({ ...@@ -1636,30 +1638,38 @@ function computeRisks({
} }
// ────────────────────────────────────────── // ──────────────────────────────────────────
// PersonaQuickList // PersonaTagCloud — 画像标签云(chip + hover「怎么算的」)
// 每个特征 = 一颗 chip(label + 短值),按 tone 着色;hover 展开该标签的算法说明
// (PersonaFeatureHover 读 feature.key)。空值特征不出 chip。
// ────────────────────────────────────────── // ──────────────────────────────────────────
function PersonaQuickList({ features }: { features: typeof mockPersona.features }) { function PersonaTagCloud({ features }: { features: typeof mockPersona.features }) {
if (!features.length) {
return <p className="text-[10.5px] text-slate-400">暂无画像标签(数据不足)</p>;
}
return ( return (
<ul className="space-y-1"> <div className="flex flex-wrap gap-1.5">
{features.map((f) => { {features.map((f) => {
const T = tone(f.tone); const T = tone(f.tone);
const { tag, text } = cleanPersonaValue(f.value); const short = shortPersonaValueLabel(f.value);
return ( return (
<li key={f.key} className="flex items-center gap-2"> <PersonaFeatureHover key={f.key} featureKey={f.key}>
<span className={cn('flex-none w-1.5 h-1.5 rounded-full', T.dot)} /> <span
<span className={cn('text-[10.5px] font-semibold', T.text)}>{f.label}</span> className={cn(
{tag && ( 'inline-flex items-center gap-1 px-1.5 py-0.5 rounded ring-1 cursor-help max-w-full',
<span className="text-[10px] px-1 py-0 rounded bg-slate-100 text-slate-600 tabular-nums"> T.bg,
{tag} T.ring,
</span> )}
)} title={f.value}
<span className="text-[10.5px] text-slate-700 truncate flex-1" title={f.value}> >
{text} <span className={cn('flex-none text-[10px] font-semibold', T.text)}>{f.label}</span>
{short && short !== f.label && (
<span className="text-[10.5px] text-slate-700 truncate">{short}</span>
)}
</span> </span>
</li> </PersonaFeatureHover>
); );
})} })}
</ul> </div>
); );
} }
......
...@@ -96,19 +96,21 @@ export type PlanDetailData = { ...@@ -96,19 +96,21 @@ export type PlanDetailData = {
agentInvocationIds?: string[]; agentInvocationIds?: string[];
ruleIds?: string[]; ruleIds?: string[];
} | null; } | null;
/// v2.1 算法可解释性 — 6 因子算分 breakdown(详情页"为什么 X 分"渲染数据) /// v3.0 算法可解释性 — 三维 breakdown(详情页"为什么 X 分"渲染数据)
/// null = scenario plugin 未产 breakdown(老 scenario / 手动添加) /// null = scenario plugin 未产 breakdown(老 scenario / 手动添加)
breakdown: breakdown:
| { | {
priority: { priority: {
clinicalBase: number; urgency: number; // 急迫性 0-10
timeWindowFactor: number; value: number; // 价值性 0-10
main: number; willingness: number; // 意愿度 0-10
valueBonus: number; rfmAdherence: number; // 意愿·RFM依从
likelihoodBonus: number; intentBehavior: number; // 意愿·主诉行为
urgencyBonus: number; trustBase: number; // 意愿·信任基础
confidenceFactor: number; freshness: number; // 新鲜度因子 0.4-1
raw: number; confidenceFactor: number; // 置信度因子 0.75-1
base: number; // 三维加权
raw: number; // 综合 ×因子后
}; };
subKey: string | null; subKey: string | null;
/// 同 scenario 多 sub-rule 命中合并时,记录所有合并的 subKey /// 同 scenario 多 sub-rule 命中合并时,记录所有合并的 subKey
......
...@@ -4,22 +4,26 @@ import * as React from 'react'; ...@@ -4,22 +4,26 @@ import * as React from 'react';
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card'; import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card';
/** /**
* PriorityHover — 优先级数字旁悬停展示 6 因子拆解。 * PriorityHover — 优先级数字旁悬停展示 v3.0 三维拆解。
* *
* 数据来源:plan.reasons[0].breakdown.priority(后端 priority-scorer 输出) * 数据来源:plan.reasons[0].breakdown.priority(后端 priority-scorer v3 输出)
* raw = (clinicalBase × timeWindowFactor + valueBonus + likelihoodBonus + urgencyBonus) × confidenceFactor * 综合 = 急迫性×0.4 + 价值性×0.3 + 意愿度×0.3
* 总分 = 综合 × 新鲜度 × 置信度 × 10
* 意愿度 = RFM依从×0.375 + 主诉行为×0.375 + 信任基础×0.25
* *
* 列表 + 详情页共用。breakdown 缺失时回退最简文案。 * 列表 + 详情页共用。breakdown 缺失时回退最简文案。
*/ */
export interface PriorityBreakdown { export interface PriorityBreakdown {
raw?: number; urgency?: number; // 急迫性 0-10
main?: number; // = clinicalBase × timeWindowFactor value?: number; // 价值性 0-10
clinicalBase?: number; // 临床基线(sub_scenario 类别) willingness?: number; // 意愿度 0-10
timeWindowFactor?: number; // 时间窗(0-1) rfmAdherence?: number; // 意愿·RFM依从
valueBonus?: number; // 价值(persona value) intentBehavior?: number; // 意愿·主诉行为
likelihoodBonus?: number; // 转化(历史触达 + recall_risk) trustBase?: number; // 意愿·信任基础
urgencyBonus?: number; // 紧迫(超临界天) freshness?: number; // 新鲜度因子 0.4-1
confidenceFactor?: number; // 信号置信(0-1) confidenceFactor?: number; // 置信度因子 0.75-1
base?: number; // 三维加权(未乘因子)
raw?: number; // 综合 ×因子后
} }
export function PriorityHover({ export function PriorityHover({
...@@ -35,7 +39,7 @@ export function PriorityHover({ ...@@ -35,7 +39,7 @@ export function PriorityHover({
<HoverCard openDelay={150} closeDelay={80}> <HoverCard openDelay={150} closeDelay={80}>
{/* 直接把 children 作为 trigger,radix 锚定 children 本身(避免多包一层 span 错位)*/} {/* 直接把 children 作为 trigger,radix 锚定 children 本身(避免多包一层 span 错位)*/}
<HoverCardTrigger asChild>{children}</HoverCardTrigger> <HoverCardTrigger asChild>{children}</HoverCardTrigger>
<HoverCardContent align="end" sideOffset={6} className="w-72 p-3 text-[11.5px]"> <HoverCardContent align="end" sideOffset={6} className="w-80 p-3 text-[11.5px]">
<PriorityBreakdownTable score={score} breakdown={breakdown} /> <PriorityBreakdownTable score={score} breakdown={breakdown} />
</HoverCardContent> </HoverCardContent>
</HoverCard> </HoverCard>
...@@ -50,40 +54,53 @@ function PriorityBreakdownTable({ ...@@ -50,40 +54,53 @@ function PriorityBreakdownTable({
breakdown?: PriorityBreakdown | null; breakdown?: PriorityBreakdown | null;
}) { }) {
const total = Math.round(score); const total = Math.round(score);
if (!breakdown) { if (!breakdown || breakdown.urgency === undefined) {
// 老数据 / 异常兜底 // 老数据 / 异常兜底(旧 6 因子 breakdown 也走这里)
return ( return (
<div className="space-y-1.5"> <div className="space-y-1.5">
<Header total={total} /> <Header total={total} />
<p className="text-slate-500 leading-relaxed"> <p className="text-slate-500 leading-relaxed">
由 6 因子算分:临床基线 × 时间窗 + 价值 + 转化 + 紧迫,× 信号置信 三维加权:急迫性 ×0.4 + 价值性 ×0.3 + 意愿度 ×0.3,再 × 新鲜度 × 置信度
<br /> <br />
(此条召回缺明细 — 重新计算后可恢复) (此条召回缺明细 — 重新计算后可恢复)
</p> </p>
</div> </div>
); );
} }
const base = breakdown.clinicalBase ?? 0; const urgency = breakdown.urgency ?? 0;
const tw = breakdown.timeWindowFactor ?? 0; const value = breakdown.value ?? 0;
const main = breakdown.main ?? Math.round(base * tw); const willing = breakdown.willingness ?? 0;
const value = breakdown.valueBonus ?? 0; const rfm = breakdown.rfmAdherence ?? 0;
const likelihood = breakdown.likelihoodBonus ?? 0; const intent = breakdown.intentBehavior ?? 0;
const urgency = breakdown.urgencyBonus ?? 0; const trust = breakdown.trustBase ?? 0;
const fresh = breakdown.freshness ?? 1;
const conf = breakdown.confidenceFactor ?? 1; const conf = breakdown.confidenceFactor ?? 1;
const subtotal = main + value + likelihood + urgency; const base = breakdown.base ?? urgency * 0.4 + value * 0.3 + willing * 0.3;
return ( return (
<div className="space-y-1.5"> <div className="space-y-1.5">
<Header total={total} /> <Header total={total} />
<table className="w-full tabular-nums"> <table className="w-full tabular-nums">
<tbody> <tbody>
<Row label="临床基线" value={base.toFixed(0)} hint="召回场景类别基线分" /> <Row label="急迫性" value={`${urgency.toFixed(0)} × 0.4`} hint="病情多急(末诊/超期)" />
<Row label="× 时间窗" value={tw.toFixed(2)} hint="医嘱黄金窗内剩余比例" /> <Row label="价值性" value={`${value.toFixed(0)} × 0.3`} hint="治疗类型 + 牙数预估收入" />
<Subtotal label="主体" value={main.toFixed(0)} /> <Row
<Row label="+ 价值" value={`+${value.toFixed(0)}`} hint="患者价值档位加权" tone="emerald" /> label="意愿度"
<Row label="+ 转化" value={`+${likelihood.toFixed(0)}`} hint="历史触达成功率 + 流失风险" tone="emerald" /> value={`${willing.toFixed(1)} × 0.3`}
<Row label="+ 紧迫" value={`+${urgency.toFixed(0)}`} hint="过黄金窗临界点" tone="emerald" /> hint={`RFM ${rfm} / 主诉 ${intent} / 信任 ${trust}`}
<Subtotal label="小计" value={subtotal.toFixed(0)} /> />
<Row label="× 置信" value={conf.toFixed(2)} hint="信号置信(诊断 1.0 / 建议 0.8)" tone={conf < 1 ? 'amber' : undefined} /> <Subtotal label="三维小计" value={base.toFixed(1)} />
<Row
label="× 新鲜度"
value={fresh.toFixed(2)}
hint="诊断越老越降权(止损)"
tone={fresh < 1 ? 'amber' : undefined}
/>
<Row
label="× 置信度"
value={conf.toFixed(2)}
hint="医生 1.0 / 影像·建议 0.9"
tone={conf < 1 ? 'amber' : undefined}
/>
<Subtotal label="= 总分" value={total.toString()} bold /> <Subtotal label="= 总分" value={total.toString()} bold />
</tbody> </tbody>
</table> </table>
...@@ -95,7 +112,7 @@ function Header({ total }: { total: number }) { ...@@ -95,7 +112,7 @@ function Header({ total }: { total: number }) {
return ( return (
<div className="flex items-baseline justify-between border-b border-slate-200 pb-1.5"> <div className="flex items-baseline justify-between border-b border-slate-200 pb-1.5">
<span className="text-[13px] font-semibold text-slate-900">优先级 {total} / 100</span> <span className="text-[13px] font-semibold text-slate-900">优先级 {total} / 100</span>
<span className="text-[10.5px] text-slate-500">6 因子算分</span> <span className="text-[10.5px] text-slate-500">急迫 × 价值 × 意愿</span>
</div> </div>
); );
} }
...@@ -112,13 +129,15 @@ function Row({ ...@@ -112,13 +129,15 @@ function Row({
tone?: 'emerald' | 'amber'; tone?: 'emerald' | 'amber';
}) { }) {
const valueClass = const valueClass =
tone === 'emerald' ? 'text-emerald-700 font-medium' tone === 'emerald'
: tone === 'amber' ? 'text-amber-700 font-medium' ? 'text-emerald-700 font-medium'
: 'text-slate-700'; : tone === 'amber'
? 'text-amber-700 font-medium'
: 'text-slate-700';
return ( return (
<tr> <tr>
<td className="py-0.5 text-slate-600">{label}</td> <td className="py-0.5 text-slate-600 whitespace-nowrap">{label}</td>
<td className={`py-0.5 text-right ${valueClass}`}>{value}</td> <td className={`py-0.5 text-right ${valueClass} whitespace-nowrap`}>{value}</td>
<td className="py-0.5 pl-2 text-[10.5px] text-slate-400">{hint ?? ''}</td> <td className="py-0.5 pl-2 text-[10.5px] text-slate-400">{hint ?? ''}</td>
</tr> </tr>
); );
...@@ -130,7 +149,11 @@ function Subtotal({ label, value, bold }: { label: string; value: string; bold?: ...@@ -130,7 +149,11 @@ function Subtotal({ label, value, bold }: { label: string; value: string; bold?:
<td colSpan={3} className="border-t border-slate-100 pt-1 pb-0.5"> <td colSpan={3} className="border-t border-slate-100 pt-1 pb-0.5">
<span className="flex justify-between"> <span className="flex justify-between">
<span className={`text-slate-700 ${bold ? 'font-semibold' : ''}`}>{label}</span> <span className={`text-slate-700 ${bold ? 'font-semibold' : ''}`}>{label}</span>
<span className={`tabular-nums ${bold ? 'text-rose-700 font-bold' : 'text-slate-800 font-medium'}`}>{value}</span> <span
className={`tabular-nums ${bold ? 'text-rose-700 font-bold' : 'text-slate-800 font-medium'}`}
>
{value}
</span>
</span> </span>
</td> </td>
</tr> </tr>
......
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