Commit 5b72d147 by luoqi

fix(persona-ui): 说明写给客服不写给程序员 + 依据日期不折行 + 圈人标签给解释

## ① 依据行的日期折行了

定宽 68px 装不下 `2026-03-27`(11px 字号),每条依据的日期都断成两行。
改为按内容撑开 + nowrap;tabular-nums 保证各行等宽,不定宽也自然对齐。

## ② hover 说明太啰嗦,而且是写给程序员的

16 个标签的说明全部重写。原则:客服扫三秒能用,取值本身说得清就不再复述一遍。
典型对比(潜在治疗):

  改前 8 行 —— 副标题 + 3 条把 chip 上已有的词再列一遍 + 一句
    「口径与召回完全一致(就是召回在挖的机会),只是不受冷静期等时间限制」
  改后 3 行 —— 副标题「诊断或建议过、但还没做的项目」+ 取值 + 「已经在别处做掉的,这里看不到」

rules 现在允许为空(性别 / 治疗史 / 权益身份 / 治疗敏感 / 潜在治疗等 6 个都清空了),
note 只留会改变客服**做法**的那句。新增一条 CI 断言拦实现术语(fact / 字段 / K0 / data. …),
免得下次又写成实现说明。

## ③ 圈人面板的标签用户看不懂

「重要保持」vs「重要挽留」差在哪?「待激活」vs「沉睡客」几个月分界?这些名字是算法口径的
产物,客服只能凭感觉勾。给 PERSONA_TAG_FILTER_DIMS 的选项补 hint(64 项),内容是**判定条件**
的大白话,如「重要挽留 · 消费高,但很久没来了」「待激活 · 半年到一年半没来」。

展示方式**没用浮层**:这些选项是拿来横向比较的,浮层恰好盖住正要比的那几个(实测会遮住
2 个 chip)。改成面板底部一行定高说明,悬停即换,不遮挡、无 z-index 问题、扫读时一直在。

454 tests green · pac-web build 通过 · 三处均在本地真实数据上人工验过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent d7dc50eb
......@@ -41,15 +41,26 @@ describe('PERSONA_FEATURE_SPECS ↔ FeatureRegistry', () => {
});
describe('spec.display —— 详情页说明', () => {
test('⭐ 每个标签都要有客服版说明,且至少一条取值条目', () => {
test('⭐ 每个标签都要有一句副标题(rules 可为空 —— 取值自明就别复述)', () => {
for (const [key, spec] of Object.entries(PERSONA_FEATURE_SPECS)) {
expect(spec.display?.subtitle?.length ?? 0).toBeGreaterThan(0);
expect(spec.display?.rules?.length ?? 0).toBeGreaterThan(0);
for (const r of spec.display.rules) expect(r.body.length).toBeGreaterThan(0);
for (const r of spec.display.rules ?? []) expect(r.body.length).toBeGreaterThan(0);
expect(`${key}:${spec.display.subtitle}`).not.toContain('undefined');
}
});
test('⭐ 说明是给客服看的,不许出现实现术语', () => {
// 这些词一旦出现,说明写的是"程序怎么实现的"而不是"这个标签什么意思"
const JARGON = ['extractor', 'fact', 'jsonb', '字段', '接口', '口径与召回完全一致', 'K0', 'data.'];
const offenders: string[] = [];
for (const [key, spec] of Object.entries(PERSONA_FEATURE_SPECS)) {
const text = [spec.display.subtitle, spec.display.note ?? '',
...spec.display.rules.map((r) => `${r.label ?? ''}${r.body}`)].join(' ');
for (const w of JARGON) if (text.includes(w)) offenders.push(`${key}: ${w}`);
}
expect(offenders).toEqual([]);
});
test('每个标签都有中文展示名(前端 hover 标题取它)', async () => {
for (const key of await registryKeys()) expect(PERSONA_FEATURE_META[key]).toBeDefined();
});
......
......@@ -188,7 +188,9 @@ function FeatureCard({
const { badge, badgeTone, text: label } = factLabel(fact);
return (
<li key={fact.id} className="flex items-baseline gap-1.5 text-[11px] leading-relaxed">
<span className="w-[68px] flex-none tabular-nums text-slate-400">
{/* 日期不折行 —— 定宽 68px 装不下 `2026-03-27`(11px 字号)会断成两行;
改为按内容撑开 + nowrap,tabular-nums 保证各行等宽自然对齐 */}
<span className="flex-none whitespace-nowrap tabular-nums text-slate-400">
{(fact.occurredAt ?? fact.plannedFor ?? '').slice(0, 10) || '—'}
</span>
<span className={cn('flex-none rounded px-1 text-[10px]', badgeTone)}>{badge}</span>
......
......@@ -337,7 +337,7 @@ export function PatientPickerRail({
);
}
// ── 画像标签筛选 popover(4 维度分组多选)────────────────────
// ── 画像标签筛选 popover(维度分组多选)────────────────────
function PersonaTagFilter({
selected,
onToggle,
......@@ -347,6 +347,8 @@ function PersonaTagFilter({
onToggle: (kv: string) => void;
onClear: () => void;
}) {
/// 当前悬停选项的说明,渲染在面板底部固定行(见下方注释:为什么不用浮层)
const [hint, setHint] = useState<string | null>(null);
return (
<Popover>
<PopoverTrigger asChild>
......@@ -372,6 +374,9 @@ function PersonaTagFilter({
</button>
)}
</div>
{/* 悬停某个选项时在底部解释它。「重要保持」「待激活」这类名字是算法口径的产物,
光看名字猜不出边界(重要保持 vs 重要挽留差在哪?待激活 vs 沉睡客几个月分界?),
客服只能凭感觉勾。说明文案在 persona-tag-filters.ts 的 hint 字段。 */}
<div className="max-h-80 space-y-2 overflow-y-auto">
{PERSONA_TAG_FILTER_DIMS.map((dim) => (
<div key={dim.key}>
......@@ -387,6 +392,12 @@ function PersonaTagFilter({
key={kv}
type="button"
onClick={() => onToggle(kv)}
// 说明走面板底部的固定行,不用浮层:这些选项是用来**横向比较**的
// (重要保持 vs 重要挽留差在哪),浮层会盖住正要比的那几个。
onMouseEnter={() => o.hint && setHint(`${o.zh} · ${o.hint}`)}
onMouseLeave={() => setHint(null)}
onFocus={() => o.hint && setHint(`${o.zh} · ${o.hint}`)}
onBlur={() => setHint(null)}
className={cn(
'rounded-full border px-2 py-0.5 text-[11px] transition-colors',
on
......@@ -402,6 +413,14 @@ function PersonaTagFilter({
</div>
))}
</div>
{/* 定高:hover 进出不抖动 */}
<div className="mt-1 min-h-[30px] border-t border-slate-100 px-1 pt-1.5 text-[10.5px] leading-relaxed">
{hint ? (
<span className="text-slate-600">{hint}</span>
) : (
<span className="text-slate-300">悬停任一标签看它的含义</span>
)}
</div>
</PopoverContent>
</Popover>
);
......
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