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