Commit 1b846327 by luoqi

feat(web): 画像卡去分割线 + hover 全中文/精简 + 优先级改 10 分制两位小数

按反馈:
- 身份卡画像标签去掉上方空分割线(border-t),直接贴手机号下。
- 标签 hovercard:① 英文术语全改中文(种植史/正畸史/冠桥贴面嵌体…,去 implant/orthodontic/channel= 等);
  ② 删'不打标签'等'不显示时'的说明(没标签就没 hover,无意义);③ 精简解释性 note(去 valueTier/
  Layer C/snapshot/DW/喂优先级 等内部术语),只留客服有用的。
- 优先级分数:0-100 → 10 分制两位小数(优先用 breakdown.raw 真精度,无则 score/10)。
  列表 PriorityBar、两页共用的 PriorityHover(头部 + 总分 + /10)同步。
- web tsc 通过。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent f138e5a3
......@@ -80,163 +80,139 @@ interface AlgoMeta {
const ALGORITHMS: Record<string, AlgoMeta> = {
[PersonaFeatureKey.RFM]: {
title: '价值分群 (RFM)',
subtitle: 'R 最近 · F 频次 · M 金额',
formula: 'R(就诊间隔)+ F(就诊次)+ M(净消费分位)各 1-5 分',
subtitle: '按最近就诊 · 频次 · 累计消费',
rules: [
{ label: '重要价值', body: 'R≥4 F≥3 M≥4 — 近期高频高额' },
{ label: '重要保持/发展/挽留', body: '高额(M≥4)但 R 或 F 偏弱' },
{ label: '一般价值/保持/发展', body: '对应象限但 M<4(消费偏低)' },
{ label: '低活跃', body: 'R≤2 或各项普遍偏低' },
{ label: '重要价值', body: '近期来过、高频、消费高' },
{ label: '重要保持/发展/挽留', body: '消费高,但最近度或频次偏弱' },
{ label: '一般价值/保持/发展', body: '消费偏低的对应象限' },
{ label: '低活跃', body: '很久没来,或各项都偏低' },
],
note: 'M 分位按本租户群体算;另产 valueTier(¥档)+ 生命周期。喂优先级"意愿·RFM依从"',
},
[PersonaFeatureKey.AGE_BRACKET]: {
title: '年龄段',
subtitle: '从生日当下算(snapshot)',
subtitle: '按生日当下计算',
rules: [
{ body: '婴幼儿 0-2 / 学龄前 3-6 / 替牙期 7-11 / 青少年 12-17' },
{ body: '青年 18-25 / 中青年 26-30 / 中年 31-45 / 中老年 46-54 / 老年 ≥55' },
],
note: '生日缺失或年龄越界(<0 / >120)→ 不打标签',
},
[PersonaFeatureKey.GENDER]: {
title: '性别',
subtitle: '影响话术与项目推荐',
rules: [
{ label: '男性', body: 'gender = M / 男' },
{ label: '女性', body: 'gender = F / 女' },
{ label: '未知', body: '其他取值(始终打标签)' },
],
rules: [{ label: '男性 / 女性 / 未知', body: '按建档性别' }],
},
[PersonaFeatureKey.ACQUISITION_CHANNEL]: {
title: '获客渠道',
subtitle: '初诊来源(数仓 L2)',
rules: [
{ body: '客户首次到诊的获客来源,数仓按初诊来源规则算好,一经判定不改' },
{ body: '一级 = PAC 标准枚举 / 二级 = host 原值' },
],
note: '无渠道值(老数据/未摄入)→ 不打标签',
subtitle: '客户首次到诊的来源',
rules: [{ body: '由数仓按初诊来源算定,一经判定不再变' }],
},
[PersonaFeatureKey.FAMILY_STRUCTURE]: {
title: '家庭构成',
subtitle: '直系亲属关系反推',
subtitle: '按直系亲属关系推断',
rules: [
{ label: '多代之家', body: '有长辈(父母/祖辈)— 跨代优先' },
{ label: '多口之家', body: '有子女(含单亲)' },
{ label: '多代之家', body: '有长辈(父母 / 祖辈)' },
{ label: '多口之家', body: '有子女' },
{ label: '两口之家', body: '有配偶' },
{ label: '单身家庭', body: '仅非直系边(同辈/朋友)' },
{ label: '单身家庭', body: '仅有同辈 / 朋友等非直系' },
],
note: '无任何关系边 → 不打标签;依赖关系边覆盖,当前样本偏低',
},
[PersonaFeatureKey.REFERRAL_CHAMPION]: {
title: '转介绍达人',
subtitle: '老带新(有推荐且带来转化)',
subtitle: '推荐过他人且带来成交',
rules: [
{ label: '家庭型', body: '带家人:直系家庭关系 ≥3 人' },
{ label: '社交型', body: '推外人:非同手机号推荐 ≥3 人' },
{ body: 'v1 用 DW 预聚合:推荐人数 + 带来转化总额(>0)' },
{ label: '家庭型', body: '带家人(直系 ≥3 人)' },
{ label: '社交型', body: '推外人(≥3 人)' },
],
note: '推荐<3 或无转化额 → 不打标签。喂优先级"信任 +1"',
},
[PersonaFeatureKey.LIFECYCLE_STAGE]: {
title: '生命周期',
subtitle: '时间 + 消费规则',
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 天' },
{ label: '潜客', body: '尚未到诊,只约过 / 咨询过' },
{ label: '新客', body: '首诊半年内、就诊 ≤3 次' },
{ label: '成长客', body: '近一年消费明显上升' },
{ label: '成熟客', body: '到诊稳定、消费平稳' },
{ label: '待激活 / 沉睡 / 流失', body: '末次就诊越久,越靠后' },
],
note: '喂优先级"意愿·信任基础"(成熟9 … 流失0)',
},
[PersonaFeatureKey.TREATMENT_HISTORY]: {
title: '治疗史',
subtitle: '读 treatment category(可多标签)',
subtitle: '做过的核心治疗类型',
rules: [
{ label: '种植史', body: 'implant' },
{ label: '正畸史', body: 'orthodontic' },
{ label: '修复史', body: 'prosthodontic / cosmetic(冠桥/贴面/嵌体)' },
{ label: '牙周治疗史', body: 'periodontic' },
{ label: '种植史', body: '种过牙' },
{ label: '正畸史', body: '做过矫正' },
{ label: '修复史', body: '冠桥 / 贴面 / 嵌体' },
{ label: '牙周治疗史', body: '洁牙 / 刮治 / 牙周序列' },
],
note: '基础治疗(拔/补/根管)不计。喂优先级"信任·同类治疗史 +1"',
note: '基础治疗(拔牙 / 补牙 / 根管)不计入',
},
[PersonaFeatureKey.TIME_PREFERENCE]: {
title: '时间偏好',
subtitle: '预约时刻统计(可多标签)',
subtitle: '习惯的就诊时段',
rules: [
{ label: '工作日 / 周末', body: '工作日占比 ≥60% / 周末 ≥50%' },
{ label: '上午 / 下午 / 晚间', body: '8-12 / 12-18 / 18-21 时占比 ≥50%' },
{ label: '工作日 / 周末', body: '按近两年预约时间占比' },
{ label: '上午 / 下午 / 晚间', body: '同上,占比过半即标' },
],
note: '近2年预约槽(北京时区),记录 <2 条 → 不打标签',
},
[PersonaFeatureKey.DISCOUNT_ANCHOR]: {
title: '折扣锚点',
subtitle: '历史最深折扣(价格底线)',
formula: '折扣率 = 1 − 折扣额 / 应收',
subtitle: '历史最深的一次折扣',
rules: [
{ body: '取真实治疗(原价 ≥¥500)上谈到的最深【部分】折扣 + 日期/项目' },
{ body: '排除免费洁牙/检查促销(全免 0 折)避免霸占锚点' },
{ body: '取真实治疗(原价 ≥¥500)上谈到的最低折扣 + 日期 / 项目' },
{ body: '免费洁牙 / 检查等促销不计入' },
],
note: '无折扣记录 → 不打标签(业务:无锚点则换推权益,不直接降价)',
note: '作谈优惠的价格底线参考',
},
[PersonaFeatureKey.SPECIAL_ATTENTION]: {
title: '特别关注',
subtitle: '排班/触达需注意(可多标签)',
subtitle: '排班 / 触达需注意',
rules: [
{ label: '屡次爽约', body: '近1年履约率 <50% 且决定数 ≥3' },
{ label: '经常迟到', body: '迟到(>15min)占比 ≥50% 且 ≥3 次' },
{ label: '免打扰', body: '主档标记不打扰' },
{ label: '不可等候', body: 'notes/标签/病历含 赶时间/不能等' },
{ label: '屡次爽约', body: '近一年履约率偏低' },
{ label: '经常迟到', body: '多次迟到超 15 分钟' },
{ label: '免打扰', body: '标记不打扰' },
{ label: '不可等候', body: '病历 / 备注提到赶时间、不能等' },
],
},
[PersonaFeatureKey.TREATMENT_SENSITIVITY]: {
title: '治疗敏感',
subtitle: '病历关键词(可多标签)',
subtitle: '沟通需安抚的点',
rules: [
{ label: '看牙恐惧', body: '牙科恐惧/害怕看牙/牙科焦虑' },
{ label: '晕针 / 晕血', body: '晕针·怕打针 / 晕血·见血不适' },
{ label: '密闭恐惧', body: '幽闭/密闭/长时间张口不适' },
{ label: '看牙恐惧', body: '牙科恐惧 / 害怕看牙' },
{ label: '晕针 / 晕血', body: '晕针·怕打针 / 晕血·见血不适' },
{ label: '密闭恐惧', body: '幽闭 / 长时间张口不适' },
],
note: '关键词按真实数据精炼排假阳(不用裸"紧张/见血/张口受限")',
},
[PersonaFeatureKey.CONTRAINDICATION]: {
title: '禁忌标签',
subtitle: '治疗可行性 + 安全预警',
rules: [
{ label: '种植禁忌', body: 'v1 仅:年龄 ≤18(骨骼发育未完全)' },
{ body: '其余(糖尿/高血压/过敏/抗凝/妊娠…)依赖既往史自由文本 → 留 Layer C(LLM)' },
],
note: '年龄禁忌满 19 岁重算自动解除(validUntil 标到期日)',
subtitle: '治疗安全预警',
rules: [{ label: '种植禁忌', body: '未满 18 岁(骨骼未发育完全)' }],
note: '满 19 岁后自动解除',
},
[PersonaFeatureKey.URGENCY_LEVEL]: {
title: '急迫等级',
subtitle: '潜在治疗路径 × 末诊',
subtitle: '按末次就诊距今',
rules: [
{ label: '紧急', body: '末诊 >90 天(有未满足需求且久未回诊)' },
{ label: '高', body: '末诊 30-90 天' },
{ label: '中', body: '末诊 <30 天 或 新发现' },
{ label: '紧急', body: '有待治项且 90 天以上没来' },
{ label: '高', body: '30-90 天没来' },
{ label: '中', body: '近期来过或新发现' },
],
note: '取所有潜在待转里最急的。喂优先级"急迫性 ×0.4"',
},
[PersonaFeatureKey.POTENTIAL_TREATMENT]: {
title: '潜在治疗',
subtitle: '诊断/建议了但没启动(可多标签)',
subtitle: '诊断 / 建议了但还没做',
rules: [
{ label: '种植 / 修复 / 拔牙', body: 'K08(>18岁) / K03 / K01·K03残根' },
{ label: '正畸 / 早矫', body: 'K07(>12岁) / K07(3-12岁)' },
{ label: '根管 / 牙周 / 补牙', body: 'K04 / K05·K06 / K02' },
{ label: '种植 / 修复 / 拔牙', body: '缺牙、需冠桥、残根需拔' },
{ label: '正畸 / 早矫', body: '按年龄分成人正畸 / 儿童早矫' },
{ label: '根管 / 牙周 / 补牙', body: '牙髓、牙周、龋齿' },
],
note: '与召回同源(共享 gap 核心)。喂优先级"价值性 ×0.3"',
},
[PersonaFeatureKey.ENTITLEMENT_STATUS]: {
title: '权益身份',
subtitle: '商保 / 医保结算史(事实投影,史+最近日期)',
subtitle: '保险结算历史',
rules: [
{ label: '商保客户', body: '历史用商业保险结算过(channel=保险 或 有保司名),展示保司 + 最近一次日期' },
{ label: '医保结算', body: '用过社保医保结算(普惠,非高端 VIP 信号)' },
{ label: '不打标签', body: '无任何保险结算记录' },
{ label: '商保客户', body: '用过商业保险结算(显示保司 + 最近日期)' },
{ label: '医保结算', body: '用过社保医保结算' },
],
note: '商保强时效(雇主团险换工作即失效,DW 无保单有效期)→ 只陈述"曾经+最近日期",不断言"当前在保";新鲜度由人/打分器按最近日期判断',
note: '只表示曾用保险结算,不代表当前在保',
},
};
......@@ -1066,9 +1066,9 @@ function IdentityCard({
</span>
)}
</div>
{/* 画像标签(并入身份卡)— hover 看"是什么 + 怎么算的";无标题/详情入口(内部细节不外露)*/}
{/* 画像标签(并入身份卡)— hover 看"是什么 + 怎么算的";无标题/分割线/详情入口(内部细节不外露)*/}
{features.length > 0 && (
<div className="mt-2 pt-2 border-t border-slate-100">
<div className="mt-2">
<PersonaTagCloud features={features} />
</div>
)}
......
......@@ -765,7 +765,10 @@ function PatientPlanCard({
breakdown={(p.reasons[0]?.breakdown as { priority?: PriorityBreakdown } | null | undefined)?.priority}
>
<span className="inline-flex items-center gap-1 cursor-help group/score">
<PriorityBar score={p.priorityScore} />
<PriorityBar
score={p.priorityScore}
raw={(p.reasons[0]?.breakdown as { priority?: PriorityBreakdown } | null | undefined)?.priority?.raw}
/>
<svg className="w-3 h-3 text-slate-400 opacity-0 group-hover/score:opacity-100 transition-opacity" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4M12 8h.01" strokeLinecap="round" />
......@@ -866,7 +869,7 @@ function PatientPlanCard({
// 原子组件
// ─────────────────────────────────────────────────────────────────
function PriorityBar({ score }: { score: number }) {
function PriorityBar({ score, raw }: { score: number; raw?: number }) {
const pct = Math.max(0, Math.min(1, score / 100));
const filled = Math.max(1, Math.round(pct * 5));
const colors = ['bg-emerald-400', 'bg-emerald-500', 'bg-amber-400', 'bg-amber-500', 'bg-rose-500'];
......@@ -876,17 +879,19 @@ function PriorityBar({ score }: { score: number }) {
: pct >= 0.4 ? 'text-amber-700'
: pct >= 0.2 ? 'text-emerald-700'
: 'text-slate-500';
// 10 分制(保留两位小数):优先 breakdown.raw,无则 score/10
const disp = (raw ?? score / 10).toFixed(2);
return (
<span
className="inline-flex flex-none items-center gap-1"
title={`优先级 ${Math.round(score)} / 100`}
title={`优先级 ${disp} / 10`}
>
<span className="inline-flex items-center gap-0.5">
{Array.from({ length: 5 }).map((_, i) => (
<span key={i} className={cn('h-2 w-[7px] rounded-sm', i < filled ? colors[i] : 'bg-slate-200')} />
))}
</span>
<span className={cn('text-[10.5px] font-semibold nums', labelTone)}>{Math.round(score)}</span>
<span className={cn('text-[10.5px] font-semibold nums', labelTone)}>{disp}</span>
</span>
);
}
......
......@@ -53,12 +53,13 @@ function PriorityBreakdownTable({
score: number;
breakdown?: PriorityBreakdown | null;
}) {
const total = Math.round(score);
// 10 分制(保留两位小数):优先用 breakdown.raw(真 2 位精度),无则 score/10
const disp = (breakdown?.raw ?? score / 10).toFixed(2);
if (!breakdown || breakdown.urgency === undefined) {
// 老数据 / 异常兜底(旧 6 因子 breakdown 也走这里)
return (
<div className="space-y-1.5">
<Header total={total} />
<Header disp={disp} />
<p className="text-slate-500 leading-relaxed">
三维加权:急迫性 ×0.4 + 价值性 ×0.3 + 意愿度 ×0.3,再 × 新鲜度 × 置信度。
<br />
......@@ -78,7 +79,7 @@ function PriorityBreakdownTable({
const base = breakdown.base ?? urgency * 0.4 + value * 0.3 + willing * 0.3;
return (
<div className="space-y-1.5">
<Header total={total} />
<Header disp={disp} />
<table className="w-full tabular-nums">
<tbody>
<Row label="急迫性" value={`${urgency.toFixed(0)} × 0.4`} hint="病情多急(末诊/超期)" />
......@@ -101,17 +102,17 @@ function PriorityBreakdownTable({
hint="医生 1.0 / 影像·建议 0.9"
tone={conf < 1 ? 'amber' : undefined}
/>
<Subtotal label="= 总分" value={total.toString()} bold />
<Subtotal label="= 总分" value={disp} bold />
</tbody>
</table>
</div>
);
}
function Header({ total }: { total: number }) {
function Header({ disp }: { disp: string }) {
return (
<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">优先级 {disp} / 10</span>
<span className="text-[10.5px] text-slate-500">急迫 × 价值 × 意愿</span>
</div>
);
......
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