Commit 114e7fdc by luoqi

feat(persona): 标签按业务字典 A/B/C/D 类区上色+定序,抽屉与首屏收成一套;两栏宽 320

## ① 类区 = 唯一一套分类
A/B/C/D 本来就在 PERSONA_FEATURE_SPECS 每个标签上方的注释里(《客户画像标签字典 v3.0》的
编号 A.1.1 / B.1.1 / C.1.1 / D.2.4…),16 个全有,只是没结构化。现在提成 display.category:

  C 临床需求(琥珀)  治疗史 · 潜在治疗 · 急迫等级
  B 价值与阶段(靛蓝) 价值分群 · 生命周期 · 权益身份 · 转介绍达人
  D 行为与偏好(玫红) 折扣锚点 · 禁忌标签 · 特别关注 · 治疗敏感 · 时间偏好
  A 基础属性(灰)    获客渠道 · 年龄段 · 性别 · 家庭构成

类区序 C→B→D→A(业务定,不是字母序 —— 字母序会把"基础属性"排首屏开头,客服最不需要的
东西占最显眼的位置)。类区内序沿用 7-29 业务给的相对顺序。

 **删掉了原来那套三分组**(跟进要点/价值与阶段/基础属性)。两套分类并存的代价是加标签时
要想"归哪组"两遍、而且两边各自漂;更直接的问题是同一批标签在首屏和抽屉里是**两个顺序**,
客服在抽屉里得重新找一遍。现在首屏 chip 和抽屉共用 personaFeatureSortKey,颜色共用
PERSONA_FEATURE_CATEGORY_META —— 真正同出一源。

首屏 chip 从统一素色改成**按类区 4 色**(不是按标签 16 色 —— 一标签一色等于没有层次);
抽屉分组标题补同色小圆点,两处认的是同一套色。

## ② 两栏宽度 300 → 320(选患者列 + 详情左栏)
️ 副作用:xl 断点(1280)恰好那档,中栏被压到 196px(原 236px)。宽屏无影响,
1280 附近本来就挤,这次更挤了 —— 要治得动断点,不在本次范围,已在回话里说明。

## ③ Tailwind 陷阱记在注释里
chip 的颜色类必须整串来自 TONE 表(字面量),别写 `'hover:' + C.bg` —— Tailwind 静态扫源码,
拼出来的类名不会被生成,表现为"颜色没生效"且难查。

新增 5 条防漂移测试:每个标签必有合法类区、类区序覆盖四类且等于 C→B→D→A、
每类有中文名+颜色、同类区内 order 不重复、首屏白名单排完类区下标单调不减。
708 tests / 46 suites + web typecheck 通过。本地实测首屏三色与抽屉分组色一致。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent 47c079ef
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import { PERSONA_FEATURE_SPECS, PERSONA_TAG_FILTER_DIMS, PERSONA_KEY_FEATURE_KEYS, import { PERSONA_FEATURE_SPECS, PERSONA_TAG_FILTER_DIMS, PERSONA_KEY_FEATURE_KEYS,
personaTagDimId, PERSONA_FEATURE_META } from '@pac/types'; personaTagDimId, PERSONA_FEATURE_META, PERSONA_FEATURE_CATEGORY_META,
PERSONA_FEATURE_CATEGORY_ORDER, personaFeatureSortKey } from '@pac/types';
import { import {
FeatureRegistry, FeatureRegistry,
FEATURE_EXTRACTOR_PROVIDERS, FEATURE_EXTRACTOR_PROVIDERS,
...@@ -139,3 +140,56 @@ describe('PERSONA_TAG_FILTER_DIMS —— 圈人字典', () => { ...@@ -139,3 +140,56 @@ describe('PERSONA_TAG_FILTER_DIMS —— 圈人字典', () => {
expect(new Set(ids).size).toBe(ids.length); expect(new Set(ids).size).toBe(ids.length);
}); });
}); });
/**
* 标签类区(业务字典 A/B/C/D)—— 2026-07-30 起是**唯一一套分类**:
* 首屏 chip 的颜色 / 首屏顺序 / 画像详情抽屉的分组与顺序全从它出。
* 这里守住"别再长出第二套":每个标签必须有类区、类区序必须齐全、两处排序必须同键。
*/
describe('标签类区 A/B/C/D —— 分组 / 排序 / 上色的唯一源', () => {
test('⭐ 16 个标签每个都要有类区,且是 A/B/C/D 之一', () => {
const bad = Object.entries(PERSONA_FEATURE_SPECS)
.filter(([, spec]) => !['A', 'B', 'C', 'D'].includes(spec.display.category))
.map(([k]) => k);
expect(bad).toEqual([]);
});
test('⭐ 类区序覆盖全部四类,顺序是业务定的 C→B→D→A(重要的在前)', () => {
expect(PERSONA_FEATURE_CATEGORY_ORDER).toEqual(['C', 'B', 'D', 'A']);
expect(Object.keys(PERSONA_FEATURE_CATEGORY_META).sort()).toEqual(['A', 'B', 'C', 'D']);
});
test('每个类区都有中文名 + 颜色(缺了抽屉标题会空、chip 会退灰)', () => {
for (const [c, meta] of Object.entries(PERSONA_FEATURE_CATEGORY_META)) {
expect(meta.label.length).toBeGreaterThan(0);
expect(meta.tone.length).toBeGreaterThan(0);
expect(c).toMatch(/^[ABCD]$/);
}
});
test('⭐ 同类区内 order 不许重复 —— 重了排序就退化成不稳定的兜底比较', () => {
const seen = new Map<string, number[]>();
for (const spec of Object.values(PERSONA_FEATURE_SPECS)) {
const arr = seen.get(spec.display.category) ?? [];
arr.push(spec.display.order);
seen.set(spec.display.category, arr);
}
for (const [cat, orders] of seen) {
expect(new Set(orders).size).toBe(orders.length); // cat 见 message
expect(cat).toMatch(/^[ABCD]$/);
}
});
test('⭐ 首屏白名单里的标签排序落在类区序内(首屏与抽屉同一个 sortKey)', () => {
const keys = [...PERSONA_KEY_FEATURE_KEYS];
const sorted = [...keys].sort((a, b) => {
const [ca, oa] = personaFeatureSortKey(a);
const [cb, ob] = personaFeatureSortKey(b);
return ca - cb || oa - ob;
});
// 排完之后类区下标必须单调不减 —— 若有标签没登记类区会被推到最后,这条会挂
const cats = sorted.map((k) => personaFeatureSortKey(k)[0]);
for (let i = 1; i < cats.length; i++) expect(cats[i]!).toBeGreaterThanOrEqual(cats[i - 1]!);
expect(cats[cats.length - 1]!).toBeLessThan(PERSONA_FEATURE_CATEGORY_ORDER.length);
});
});
...@@ -13,7 +13,7 @@ export const PLAN_WORKSPACE_HEADER_SLOT = 'plan-workspace-header'; ...@@ -13,7 +13,7 @@ export const PLAN_WORKSPACE_HEADER_SLOT = 'plan-workspace-header';
/** /**
* /plans 段布局 — 详情页"一页式工作台"骨架(上下大布局): * /plans 段布局 — 详情页"一页式工作台"骨架(上下大布局):
* ┌ header(详情 TopBar portal 到此,全宽延伸到最左)┐ * ┌ header(详情 TopBar portal 到此,全宽延伸到最左)┐
* ├ 左栏选患者列 300px │ 详情内容(原三列) ┤ * ├ 左栏选患者列 320px │ 详情内容(原三列) ┤
* 布局挂在动态段之上 → 切患者只重渲染右侧,左栏筛选/分页/滚动全保留。 * 布局挂在动态段之上 → 切患者只重渲染右侧,左栏筛选/分页/滚动全保留。
* 响应式:<lg(1024)左栏收成抽屉(单实例 CSS 平移,状态不丢)+ 左缘竖把手"选患者"作明确点击点; * 响应式:<lg(1024)左栏收成抽屉(单实例 CSS 平移,状态不丢)+ 左缘竖把手"选患者"作明确点击点;
* 选中患者后自动收起。/plans 列表页(无 planId 段)不渲染本骨架。 * 选中患者后自动收起。/plans 列表页(无 planId 段)不渲染本骨架。
......
...@@ -2,11 +2,11 @@ ...@@ -2,11 +2,11 @@
import * as React from 'react'; import * as React from 'react';
import { import {
PERSONA_FEATURE_GROUP_LABEL, PERSONA_FEATURE_CATEGORY_META,
PERSONA_FEATURE_GROUP_ORDER, PERSONA_FEATURE_CATEGORY_ORDER,
personaFeatureGroup, personaFeatureCategory,
personaFeatureSortKey, personaFeatureSortKey,
type PersonaFeatureGroup, type PersonaFeatureCategory,
} from '@pac/types'; } from '@pac/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { tone } from './shared'; import { tone } from './shared';
...@@ -21,7 +21,9 @@ import type { AdaptedFact } from './adapt-data'; ...@@ -21,7 +21,9 @@ import type { AdaptedFact } from './adapt-data';
* 相对旧版改了三件事: * 相对旧版改了三件事:
* ① **分组 + 定序**。原来 16 张卡等权重平铺,顺序来自 `orderBy createdAt`;而同一版的 feature * ① **分组 + 定序**。原来 16 张卡等权重平铺,顺序来自 `orderBy createdAt`;而同一版的 feature
* 行是一条 createMany 写进去的、createdAt 完全相同 → 排序实际未定义,生产上呈现为英文 key * 行是一条 createMany 写进去的、createdAt 完全相同 → 排序实际未定义,生产上呈现为英文 key
* 字母序:「急迫等级 = 紧急」排在最后一个,「性别」「获客渠道」排在最前。现按用途分三组。 * 字母序:「急迫等级 = 紧急」排在最后一个,「性别」「获客渠道」排在最前。
* 现按**业务字典的 A/B/C/D 类区**分组(2026-07-30 起,与身份卡首屏 chip 同出一源;
* 此前这里是另一套「跟进要点 / 价值与阶段 / 基础属性」三分组,两套分类并存必然漂)。
* ② **给出依据**。后端一直存着 evidence.factIds(rfm + lifecycle 两个特征在生产就占 1 GB), * ② **给出依据**。后端一直存着 evidence.factIds(rfm + lifecycle 两个特征在生产就占 1 GB),
* 但前端 adapt-data 把它写死成空数组 —— 客服只看得到结论、看不到出处。现在反查同一份 facts * 但前端 adapt-data 把它写死成空数组 —— 客服只看得到结论、看不到出处。现在反查同一份 facts
* 渲染最近几条,并给出总条数。 * 渲染最近几条,并给出总条数。
...@@ -80,31 +82,38 @@ export function PersonaDetailList({ ...@@ -80,31 +82,38 @@ export function PersonaDetailList({
const [gb, ob] = personaFeatureSortKey(b.key); const [gb, ob] = personaFeatureSortKey(b.key);
return ga - gb || oa - ob || a.key.localeCompare(b.key); return ga - gb || oa - ob || a.key.localeCompare(b.key);
}); });
const buckets = new Map<PersonaFeatureGroup | 'other', PersonaFeature[]>(); const buckets = new Map<PersonaFeatureCategory | 'other', PersonaFeature[]>();
for (const f of sorted) { for (const f of sorted) {
const g = personaFeatureGroup(f.key) ?? 'other'; const c = personaFeatureCategory(f.key) ?? 'other';
buckets.set(g, [...(buckets.get(g) ?? []), f]); buckets.set(c, [...(buckets.get(c) ?? []), f]);
} }
return buckets; return buckets;
}, [features]); }, [features]);
const sections: Array<[string, PersonaFeature[]]> = [ // 分组标题带类区色点 —— 与首屏 chip 的颜色一一对应,客服在两处认的是同一套色
...PERSONA_FEATURE_GROUP_ORDER.map( const sections: Array<[string, string, PersonaFeature[]]> = [
(g) => [PERSONA_FEATURE_GROUP_LABEL[g], grouped.get(g) ?? []] as [string, PersonaFeature[]], ...PERSONA_FEATURE_CATEGORY_ORDER.map(
(c) =>
[PERSONA_FEATURE_CATEGORY_META[c].label, PERSONA_FEATURE_CATEGORY_META[c].tone, grouped.get(c) ?? []] as [
string,
string,
PersonaFeature[],
],
), ),
// 注册表里没登记的标签不吞掉,兜到最后一组(比"标签凭空消失"好排查) // 注册表里没登记的标签不吞掉,兜到最后一组(比"标签凭空消失"好排查)
['其他', grouped.get('other') ?? []], ['其他', 'slate', grouped.get('other') ?? []],
]; ];
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{sections {sections
.filter(([, items]) => items.length > 0) .filter(([, , items]) => items.length > 0)
.map(([label, items]) => ( .map(([label, sectionTone, items]) => (
<section key={label} className="space-y-2"> <section key={label} className="space-y-2">
<h3 className="text-[10.5px] font-semibold tracking-wide text-slate-400"> <h3 className="flex items-center gap-1.5 text-[10.5px] font-semibold tracking-wide text-slate-400">
<span className={cn('h-1.5 w-1.5 flex-none rounded-full', tone(sectionTone).dot)} />
{label} {label}
<span className="ml-1.5 font-normal text-slate-300">{items.length}</span> <span className="font-normal text-slate-300">{items.length}</span>
</h3> </h3>
{items.map((f) => ( {items.map((f) => (
<FeatureCard <FeatureCard
......
...@@ -49,7 +49,7 @@ import { ...@@ -49,7 +49,7 @@ import {
potentialTreatmentItemName, potentialTreatmentItemName,
HOST_CASE_STAGE_CONSULTED, HOST_CASE_STAGE_CONSULTED,
type HostPotentialTreatmentPayload, type HostPotentialTreatmentPayload,
personaKeyFeatureOrder, personaFeatureCategoryTone,
type AbandonReason, type AbandonReason,
type ExecutionOutcome, type ExecutionOutcome,
} from '@pac/types'; } from '@pac/types';
...@@ -809,7 +809,8 @@ function ResponsiveDetail({ ...@@ -809,7 +809,8 @@ function ResponsiveDetail({
{isXl ? ( {isXl ? (
<div <div
className="grid h-full gap-3" className="grid h-full gap-3"
style={{ gridTemplateColumns: rightPane ? '300px 1fr 380px' : '300px 1fr' }} // 左栏 320px:与选患者列同宽(业务 2026-07-30)。原 300px 下画像标签 chip 折得太碎。
style={{ gridTemplateColumns: rightPane ? '320px 1fr 380px' : '320px 1fr' }}
> >
{leftPane} {leftPane}
{centerPane} {centerPane}
...@@ -1349,12 +1350,18 @@ function IdentityCard({ ...@@ -1349,12 +1350,18 @@ function IdentityCard({
onOpenProfile: () => void; onOpenProfile: () => void;
}) { }) {
// 关键标签 —— 姓名下面直接展示,客服不用点开抽屉就能判断「能不能打、该聊什么」。 // 关键标签 —— 姓名下面直接展示,客服不用点开抽屉就能判断「能不能打、该聊什么」。
// 选哪些 + 什么顺序都是业务口径,一并收在 PERSONA_KEY_FEATURE_KEYS(数组序即展示序)。 // 选哪些 = PERSONA_KEY_FEATURE_KEYS 白名单;顺序和颜色 = 业务字典的 A/B/C/D 类区(见 types)。
const keyFeatures = useMemo( const keyFeatures = useMemo(
() => () =>
features features
.filter((f) => isKeyPersonaFeature(f.key)) .filter((f) => isKeyPersonaFeature(f.key))
.sort((a, b) => personaKeyFeatureOrder(a.key) - personaKeyFeatureOrder(b.key)), // ⭐ 与画像详情抽屉**同一个排序键**(类区序 C→B→D→A + 类区内序)——
// 以前首屏走白名单数组序、抽屉走分组序,同一批标签两个顺序,客服得重新找一遍。
.sort((a, b) => {
const [ca, oa] = personaFeatureSortKey(a.key);
const [cb, ob] = personaFeatureSortKey(b.key);
return ca - cb || oa - ob;
}),
[features], [features],
); );
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
...@@ -2398,14 +2405,26 @@ function PersonaTagCloud({ ...@@ -2398,14 +2405,26 @@ function PersonaTagCloud({
); );
// 首屏:真 button —— 可 Tab 聚焦、读屏能念出标签名(视觉上 label 已去掉,靠 aria-label 补) // 首屏:真 button —— 可 Tab 聚焦、读屏能念出标签名(视觉上 label 已去掉,靠 aria-label 补)
// ⭐ 颜色按**类区**(4 色)而不是按标签(16 色):业务 2026-07-30。
// 一标签一色等于没有色彩层次(全是重点=没重点);按类区上色,客服扫一眼就知道
// "这排是该聊什么、那排是要避让的"。色板与抽屉分组标题同源,两处不会各自漂。
if (plain) { if (plain) {
const C = tone(personaFeatureCategoryTone(f.key));
return ( return (
<button <button
key={f.key} key={f.key}
type="button" type="button"
onClick={() => onSelect?.(f.key)} onClick={() => onSelect?.(f.key)}
aria-label={`${f.label}:${text} —— 查看画像详情`} aria-label={`${f.label}:${text} —— 查看画像详情`}
className="inline-flex max-w-full items-center gap-1 rounded px-1 py-px text-[10px] leading-4 text-slate-600 ring-1 ring-inset ring-slate-200 hover:bg-slate-50 hover:text-slate-900 hover:ring-slate-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500" // ⚠️ 颜色类必须整串来自 TONE 表(shared.tsx 里是字面量,Tailwind 扫得到)。
// 别写 `'hover:' + C.bg` 这种拼接 —— Tailwind 是静态扫源码的,拼出来的类名不会被生成,
// 页面上表现为"这个颜色没生效",还很难查。
className={cn(
'inline-flex max-w-full items-center gap-1 rounded px-1 py-px text-[10px] leading-4 ring-1 ring-inset hover:brightness-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500',
C.bg,
C.text,
C.ring,
)}
> >
{inner} {inner}
</button> </button>
......
...@@ -191,7 +191,7 @@ export function PatientPickerRail({ ...@@ -191,7 +191,7 @@ export function PatientPickerRail({
}); });
return ( return (
<aside className="flex h-full w-[300px] flex-none flex-col border-r border-slate-100 bg-white"> <aside className="flex h-full w-[320px] flex-none flex-col border-r border-slate-100 bg-white">
{/* view tabs */} {/* view tabs */}
<div className="flex flex-none gap-1 border-b border-slate-100 px-2 pt-2"> <div className="flex flex-none gap-1 border-b border-slate-100 px-2 pt-2">
{VIEW_TABS.filter((t) => t.v !== 'all' || canViewAll).map((t) => ( {VIEW_TABS.filter((t) => t.v !== 'all' || canViewAll).map((t) => (
......
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