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 = {
occurredAt: string;
negative?: boolean;
}>;
/// v2.1 6 因子算法 breakdown(详情页"为什么 X 分"渲染数据);旧 mock 数据可空
/// v3.0 三维算法 breakdown(详情页"为什么 X 分"渲染数据);旧 mock 数据可空
breakdown?: {
priority: {
clinicalBase: number;
timeWindowFactor: number;
main: number;
valueBonus: number;
likelihoodBonus: number;
urgencyBonus: number;
urgency: number;
value: number;
willingness: number;
rfmAdherence: number;
intentBehavior: number;
trustBase: number;
freshness: number;
confidenceFactor: number;
base: number;
raw: number;
};
subKey: string | null;
......
......@@ -67,48 +67,156 @@ interface AlgoMeta {
}
const ALGORITHMS: Record<string, AlgoMeta> = {
[PersonaFeatureKey.VALUE]: {
title: '患者价值',
subtitle: '生命周期付费档位',
formula: '累计付费 = 付款 + 充值 − 退款',
[PersonaFeatureKey.RFM]: {
title: '价值分群 (RFM)',
subtitle: 'R 最近 · F 频次 · M 金额 → 八象限',
formula: 'R(就诊间隔)+ F(就诊次)+ M(净消费分位)各 1-5 分',
rules: [
{ label: 'VIP 钻卡', body: '累计 ≥ ¥30,000' },
{ label: 'VIP 金卡', body: '累计 ≥ ¥10,000' },
{ label: 'VIP 银卡', body: '累计 ≥ ¥3,000' },
{ label: '普通付费', body: '累计 ≥ ¥500' },
{ label: '新客 / 未消费', body: '< ¥500' },
{ label: '重要价值', body: 'R≥4 F≥3 M≥4 — 近期高频高额' },
{ label: '重要保持/发展/挽留', body: '高额(M≥4)但 R 或 F 偏弱' },
{ label: '一般价值/保持/发展', body: '对应象限但 M<4(消费偏低)' },
{ label: '低活跃', body: 'R≤2 或各项普遍偏低' },
],
note: '档位影响召回优先级"价值加分"(0 / 2 / 5 / 10 / 20)',
note: 'M 分位按本租户群体算;另产 valueTier(¥档)+ 生命周期。喂优先级"意愿·RFM依从"',
},
[PersonaFeatureKey.TREATMENT_CHAIN_STATUS]: {
title: '治疗链状态',
subtitle: '诊断 vs 实际治疗对齐',
[PersonaFeatureKey.AGE_BRACKET]: {
title: '年龄段',
subtitle: '从生日当下算(snapshot)',
rules: [
{ label: '缺口', body: '有诊断但未做对应类别治疗 → 召回核心信号' },
{ label: '在管', body: '诊断后有同类治疗记录,治疗链推进中' },
{ label: '闭环', body: '同类治疗已完成、无后续风险(牙周等终身维护类除外)' },
{ body: '婴幼儿 0-2 / 学龄前 3-6 / 替牙期 7-11 / 青少年 12-17' },
{ body: '青年 18-25 / 中青年 26-30 / 中年 31-45 / 中老年 46-54 / 老年 ≥55' },
],
note: '诊断→治疗类别用统一映射表(如 K02 龋齿 → 充填/嵌体)',
note: '生日缺失或年龄越界(<0 / >120)→ 不打标签',
},
[PersonaFeatureKey.RECALL_RISK]: {
title: '流失风险',
subtitle: '复发 + 长期未触达',
[PersonaFeatureKey.GENDER]: {
title: '性别',
subtitle: '影响话术与项目推荐',
rules: [
{ label: '高', body: '距上次临床事件 ≥ 540 天 + 治疗链有缺口' },
{ label: '中', body: '距上次 ≥ 360 天,或 治疗链有缺口' },
{ label: '低', body: '距上次 ≥ 180 天' },
{ label: '无', body: '< 180 天 且 链无缺口' },
{ label: '男性', body: 'gender = M / 男' },
{ label: '女性', body: 'gender = F / 女' },
{ label: '未知', body: '其他取值(始终打标签)' },
],
note: '影响召回优先级"转化加分"(无=0 / 低=2 / 中=4 / 高=6)',
},
[PersonaFeatureKey.DO_NOT_CONTACT_STATUS]: {
title: '不打扰状态',
subtitle: '合规硬约束',
[PersonaFeatureKey.ACQUISITION_CHANNEL]: {
title: '获客渠道',
subtitle: '初诊来源(数仓 L2)',
rules: [
{ label: '不打扰', body: '主档标记不打扰 / 患者已故 / 有未关闭投诉记录' },
{ label: '可触达', body: '上述全无' },
{ 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]: {
title: '权益身份',
......
......@@ -40,7 +40,8 @@ import {
import { AIStamp, Chip, PriorityBar, SidebarCard, tone } from './shared';
import { PriorityHover, type PriorityBreakdown } from '@/components/priority-hover';
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 { ChainSidebar } from './chain-viz';
import { ScriptView, type ScriptViewMode } from './script-viewer';
......@@ -322,8 +323,8 @@ export function PlanDetailApp({
onOpenMedical={() => setDrawerOpen('medical')}
/>
<SidebarCard
title="患者画像"
meta={`更新于 ${fmtRel(persona.computedAt)}`}
title="患者信息 · 标签"
meta={`${persona.features.length} 个 · 更新于 ${fmtRel(persona.computedAt)}`}
action={
<button
onClick={() => setDrawerOpen('persona')}
......@@ -333,7 +334,7 @@ export function PlanDetailApp({
</button>
}
>
<PersonaQuickList features={persona.features.slice(0, 4)} />
<PersonaTagCloud features={persona.features} />
</SidebarCard>
<KeyFactsCard
patient={patient}
......@@ -1227,7 +1228,8 @@ function KeyFactsCard({
// 后端 description "新客/未消费(累计净消费 ¥58.00,含 ...)" 太长 → 取首段 "新客"(去 / 和括号),
// 跟主值 ¥58 互补不重复
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);
// ─ 治疗类目 top 1-2 ─ facts 里 treatment_record.category 出现频次 top(用于主治医生右侧 hint)
......@@ -1599,10 +1601,10 @@ function computeRisks({
if (patient.profile.doNotContact) out.push('⛔ 已标记不打扰');
if (patient.profile.deceased) out.push('⛔ 已故');
// ② persona recall_risk
const riskFeature = persona.features.find((f) => f.key === 'recall_risk');
if (riskFeature && /高|high/i.test(riskFeature.value)) {
out.push('流失风险高 · 谨慎触达');
// ② 流失风险:W7 起 recall_risk 并入 rfm/lifecycle;用生命周期(流失/沉睡客)代理
const lifecycleFeature = persona.features.find((f) => f.key === PersonaFeatureKey.LIFECYCLE_STAGE);
if (lifecycleFeature && /流失|沉睡/.test(lifecycleFeature.value)) {
out.push('久未到诊 · 谨慎触达,先重新拉近');
}
// ③ 历史退费
......@@ -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 (
<ul className="space-y-1">
<div className="flex flex-wrap gap-1.5">
{features.map((f) => {
const T = tone(f.tone);
const { tag, text } = cleanPersonaValue(f.value);
const short = shortPersonaValueLabel(f.value);
return (
<li key={f.key} className="flex items-center gap-2">
<span className={cn('flex-none w-1.5 h-1.5 rounded-full', T.dot)} />
<span className={cn('text-[10.5px] font-semibold', T.text)}>{f.label}</span>
{tag && (
<span className="text-[10px] px-1 py-0 rounded bg-slate-100 text-slate-600 tabular-nums">
{tag}
</span>
)}
<span className="text-[10.5px] text-slate-700 truncate flex-1" title={f.value}>
{text}
<PersonaFeatureHover key={f.key} featureKey={f.key}>
<span
className={cn(
'inline-flex items-center gap-1 px-1.5 py-0.5 rounded ring-1 cursor-help max-w-full',
T.bg,
T.ring,
)}
title={f.value}
>
<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>
</li>
</PersonaFeatureHover>
);
})}
</ul>
</div>
);
}
......
......@@ -96,19 +96,21 @@ export type PlanDetailData = {
agentInvocationIds?: string[];
ruleIds?: string[];
} | null;
/// v2.1 算法可解释性 — 6 因子算分 breakdown(详情页"为什么 X 分"渲染数据)
/// v3.0 算法可解释性 — 三维 breakdown(详情页"为什么 X 分"渲染数据)
/// null = scenario plugin 未产 breakdown(老 scenario / 手动添加)
breakdown:
| {
priority: {
clinicalBase: number;
timeWindowFactor: number;
main: number;
valueBonus: number;
likelihoodBonus: number;
urgencyBonus: number;
confidenceFactor: number;
raw: number;
urgency: number; // 急迫性 0-10
value: number; // 价值性 0-10
willingness: number; // 意愿度 0-10
rfmAdherence: number; // 意愿·RFM依从
intentBehavior: number; // 意愿·主诉行为
trustBase: number; // 意愿·信任基础
freshness: number; // 新鲜度因子 0.4-1
confidenceFactor: number; // 置信度因子 0.75-1
base: number; // 三维加权
raw: number; // 综合 ×因子后
};
subKey: string | null;
/// 同 scenario 多 sub-rule 命中合并时,记录所有合并的 subKey
......
......@@ -4,22 +4,26 @@ import * as React from 'react';
import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card';
/**
* PriorityHover — 优先级数字旁悬停展示 6 因子拆解。
* PriorityHover — 优先级数字旁悬停展示 v3.0 三维拆解。
*
* 数据来源:plan.reasons[0].breakdown.priority(后端 priority-scorer 输出)
* raw = (clinicalBase × timeWindowFactor + valueBonus + likelihoodBonus + urgencyBonus) × confidenceFactor
* 数据来源:plan.reasons[0].breakdown.priority(后端 priority-scorer v3 输出)
* 综合 = 急迫性×0.4 + 价值性×0.3 + 意愿度×0.3
* 总分 = 综合 × 新鲜度 × 置信度 × 10
* 意愿度 = RFM依从×0.375 + 主诉行为×0.375 + 信任基础×0.25
*
* 列表 + 详情页共用。breakdown 缺失时回退最简文案。
*/
export interface PriorityBreakdown {
raw?: number;
main?: number; // = clinicalBase × timeWindowFactor
clinicalBase?: number; // 临床基线(sub_scenario 类别)
timeWindowFactor?: number; // 时间窗(0-1)
valueBonus?: number; // 价值(persona value)
likelihoodBonus?: number; // 转化(历史触达 + recall_risk)
urgencyBonus?: number; // 紧迫(超临界天)
confidenceFactor?: number; // 信号置信(0-1)
urgency?: number; // 急迫性 0-10
value?: number; // 价值性 0-10
willingness?: number; // 意愿度 0-10
rfmAdherence?: number; // 意愿·RFM依从
intentBehavior?: number; // 意愿·主诉行为
trustBase?: number; // 意愿·信任基础
freshness?: number; // 新鲜度因子 0.4-1
confidenceFactor?: number; // 置信度因子 0.75-1
base?: number; // 三维加权(未乘因子)
raw?: number; // 综合 ×因子后
}
export function PriorityHover({
......@@ -35,7 +39,7 @@ export function PriorityHover({
<HoverCard openDelay={150} closeDelay={80}>
{/* 直接把 children 作为 trigger,radix 锚定 children 本身(避免多包一层 span 错位)*/}
<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} />
</HoverCardContent>
</HoverCard>
......@@ -50,40 +54,53 @@ function PriorityBreakdownTable({
breakdown?: PriorityBreakdown | null;
}) {
const total = Math.round(score);
if (!breakdown) {
// 老数据 / 异常兜底
if (!breakdown || breakdown.urgency === undefined) {
// 老数据 / 异常兜底(旧 6 因子 breakdown 也走这里)
return (
<div className="space-y-1.5">
<Header total={total} />
<p className="text-slate-500 leading-relaxed">
由 6 因子算分:临床基线 × 时间窗 + 价值 + 转化 + 紧迫,× 信号置信
三维加权:急迫性 ×0.4 + 价值性 ×0.3 + 意愿度 ×0.3,再 × 新鲜度 × 置信度
<br />
(此条召回缺明细 — 重新计算后可恢复)
</p>
</div>
);
}
const base = breakdown.clinicalBase ?? 0;
const tw = breakdown.timeWindowFactor ?? 0;
const main = breakdown.main ?? Math.round(base * tw);
const value = breakdown.valueBonus ?? 0;
const likelihood = breakdown.likelihoodBonus ?? 0;
const urgency = breakdown.urgencyBonus ?? 0;
const urgency = breakdown.urgency ?? 0;
const value = breakdown.value ?? 0;
const willing = breakdown.willingness ?? 0;
const rfm = breakdown.rfmAdherence ?? 0;
const intent = breakdown.intentBehavior ?? 0;
const trust = breakdown.trustBase ?? 0;
const fresh = breakdown.freshness ?? 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 (
<div className="space-y-1.5">
<Header total={total} />
<table className="w-full tabular-nums">
<tbody>
<Row label="临床基线" value={base.toFixed(0)} hint="召回场景类别基线分" />
<Row label="× 时间窗" value={tw.toFixed(2)} hint="医嘱黄金窗内剩余比例" />
<Subtotal label="主体" value={main.toFixed(0)} />
<Row label="+ 价值" value={`+${value.toFixed(0)}`} hint="患者价值档位加权" tone="emerald" />
<Row label="+ 转化" value={`+${likelihood.toFixed(0)}`} hint="历史触达成功率 + 流失风险" tone="emerald" />
<Row label="+ 紧迫" value={`+${urgency.toFixed(0)}`} hint="过黄金窗临界点" tone="emerald" />
<Subtotal label="小计" value={subtotal.toFixed(0)} />
<Row label="× 置信" value={conf.toFixed(2)} hint="信号置信(诊断 1.0 / 建议 0.8)" tone={conf < 1 ? 'amber' : undefined} />
<Row label="急迫性" value={`${urgency.toFixed(0)} × 0.4`} hint="病情多急(末诊/超期)" />
<Row label="价值性" value={`${value.toFixed(0)} × 0.3`} hint="治疗类型 + 牙数预估收入" />
<Row
label="意愿度"
value={`${willing.toFixed(1)} × 0.3`}
hint={`RFM ${rfm} / 主诉 ${intent} / 信任 ${trust}`}
/>
<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 />
</tbody>
</table>
......@@ -95,7 +112,7 @@ function Header({ total }: { total: number }) {
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-[10.5px] text-slate-500">6 因子算分</span>
<span className="text-[10.5px] text-slate-500">急迫 × 价值 × 意愿</span>
</div>
);
}
......@@ -112,13 +129,15 @@ function Row({
tone?: 'emerald' | 'amber';
}) {
const valueClass =
tone === 'emerald' ? 'text-emerald-700 font-medium'
: tone === 'amber' ? 'text-amber-700 font-medium'
: 'text-slate-700';
tone === 'emerald'
? 'text-emerald-700 font-medium'
: tone === 'amber'
? 'text-amber-700 font-medium'
: 'text-slate-700';
return (
<tr>
<td className="py-0.5 text-slate-600">{label}</td>
<td className={`py-0.5 text-right ${valueClass}`}>{value}</td>
<td className="py-0.5 text-slate-600 whitespace-nowrap">{label}</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>
</tr>
);
......@@ -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">
<span className="flex justify-between">
<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>
</td>
</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