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) => (
......
...@@ -13,22 +13,36 @@ export type FeatureTier = 'rule' | 'statistical' | 'model' | 'llm'; ...@@ -13,22 +13,36 @@ export type FeatureTier = 'rule' | 'statistical' | 'model' | 'llm';
export type FeatureTimeSemantics = 'snapshot' | 'window' | 'lifetime' | 'trend' | 'mixed'; export type FeatureTimeSemantics = 'snapshot' | 'window' | 'lifetime' | 'trend' | 'mixed';
/** /**
* 详情页分组 —— 按「客服打这通电话时的用途」分,不按数据血缘分 * ⭐ 标签类区 —— 出自《客户画像标签字典 v3.0》的 A/B/C/D 编号(每个标签上方注释里那个 `A.1.1` 就是它)
* *
* 原来 16 个标签在抽屉里等权重平铺,且顺序取自 `orderBy createdAt`,而同一版的 feature 行是 * 2026-07-30 起这是**唯一一套分类**:首屏 chip 的颜色、首屏顺序、画像详情抽屉的分组标题与顺序,
* 一条 createMany 写进去的、createdAt 完全相同 —— 排序实际未定义,生产上呈现为英文 key 字母序: * 全从这里出。此前抽屉另有一套「跟进要点 / 价值与阶段 / 基础属性」的三分组(按打电话的用途分),
* 「急迫等级=紧急」排在最后一个,「性别」「获客渠道」排在最前。 * 与业务字典并存 —— 两套分类同时活着,加标签时要想"这个归哪一组"两遍,而且两边会各自漂。
* 现在收成一套:业务字典怎么分,PAC 就怎么分、怎么排、怎么上色。
*/ */
export type PersonaFeatureGroup = 'action' | 'value' | 'basic'; export type PersonaFeatureCategory = 'A' | 'B' | 'C' | 'D';
export const PERSONA_FEATURE_GROUP_LABEL: Record<PersonaFeatureGroup, string> = { /**
action: '跟进要点', * 类区展示元数据 —— 中文名 + 颜色。
value: '价值与阶段', * 颜色沿用 PERSONA_FEATURE_META 那套 tone 语义(见 labels.ts 里"红色是稀缺资源"那段):
basic: '基础属性', * C 琥珀 = 有事可做(该聊什么) B 靛蓝 = 钱与阶段
* D 玫红 = 要小心 / 要避让 A 灰 = 背景信息,不需要判断
*/
export const PERSONA_FEATURE_CATEGORY_META: Record<
PersonaFeatureCategory,
{ label: string; tone: string }
> = {
C: { label: '临床需求', tone: 'amber' },
B: { label: '价值与阶段', tone: 'indigo' },
D: { label: '行为与偏好', tone: 'rose' },
A: { label: '基础属性', tone: 'slate' },
}; };
/** 分组自身的展示顺序 */ /**
export const PERSONA_FEATURE_GROUP_ORDER: PersonaFeatureGroup[] = ['action', 'value', 'basic']; * 类区展示顺序(业务定序 2026-07-30):**该聊什么 → 给多大力度 → 怎么谈 / 要避让 → 背景**。
* 不是字典的 A→D 字母序 —— 字母序把"基础属性"排第一,客服最不需要的东西占了首屏开头。
*/
export const PERSONA_FEATURE_CATEGORY_ORDER: PersonaFeatureCategory[] = ['C', 'B', 'D', 'A'];
/** /**
* ⭐ 身份卡「关键标签」—— 患者姓名下面直接露出的那几个,不是全部 16 个。 * ⭐ 身份卡「关键标签」—— 患者姓名下面直接露出的那几个,不是全部 16 个。
...@@ -37,11 +51,9 @@ export const PERSONA_FEATURE_GROUP_ORDER: PersonaFeatureGroup[] = ['action', 'va ...@@ -37,11 +51,9 @@ export const PERSONA_FEATURE_GROUP_ORDER: PersonaFeatureGroup[] = ['action', 'va
* 其余标签一个不少,仍在「画像标签 → 详情」抽屉里全量可查 —— * 其余标签一个不少,仍在「画像标签 → 详情」抽屉里全量可查 ——
* 这里取舍的是**首屏**,不是信息。 * 这里取舍的是**首屏**,不是信息。
* *
* ⭐ **数组顺序 = 首屏展示顺序**(业务定序,2026-07-29),不走 personaFeatureSortKey —— * ⚠️ **这个数组只管"哪些进首屏",不管顺序**(2026-07-30 起)。顺序统一走
* 抽屉那套序是按"跟进要点 / 价值与阶段 / 基础属性"分组的,首屏没有分组标题, * personaFeatureSortKey(类区序 C→B→D→A + 类区内序)—— 与画像详情抽屉同出一源。
* 照那个序排出来客服看不出章法。这里是业务自己排的一条线: * 以前这里是"数组序即展示序",于是同一批标签在首屏和抽屉里是两个顺序,客服要重新找一遍。
* 做过什么 → 还差什么 → 这人什么档位 / 走到哪一步 → 能怎么付 / 谈价底线 →
* 怎么来的 → 最后两颗是风险(禁忌 / 特别关注)。
* *
* 急迫等级不进首屏:顶栏「优先级」条已经表达同一件事(急迫是它的最大权重项), * 急迫等级不进首屏:顶栏「优先级」条已经表达同一件事(急迫是它的最大权重项),
* 同屏两处讲一件事,客服会以为是两个指标。 * 同屏两处讲一件事,客服会以为是两个指标。
...@@ -50,7 +62,7 @@ export const PERSONA_FEATURE_GROUP_ORDER: PersonaFeatureGroup[] = ['action', 'va ...@@ -50,7 +62,7 @@ export const PERSONA_FEATURE_GROUP_ORDER: PersonaFeatureGroup[] = ['action', 'va
* 年龄段 / 性别(姓名行已经有性别年龄,重复占位);家庭构成 / 转介绍达人 * 年龄段 / 性别(姓名行已经有性别年龄,重复占位);家庭构成 / 转介绍达人
* (有价值但不改变这通电话怎么开场)。 * (有价值但不改变这通电话怎么开场)。
* *
* ⚠️ 想加 key 先自问:客服扫一眼真会拿它做决定吗?加了要顺手排进上面那条线里。 * ⚠️ 想加 key 先自问:客服扫一眼真会拿它做决定吗?
*/ */
export const PERSONA_KEY_FEATURE_KEYS: readonly string[] = [ export const PERSONA_KEY_FEATURE_KEYS: readonly string[] = [
'treatment_history', 'treatment_history',
...@@ -68,11 +80,7 @@ export const PERSONA_KEY_FEATURE_KEYS: readonly string[] = [ ...@@ -68,11 +80,7 @@ export const PERSONA_KEY_FEATURE_KEYS: readonly string[] = [
export const isKeyPersonaFeature = (key: string): boolean => export const isKeyPersonaFeature = (key: string): boolean =>
PERSONA_KEY_FEATURE_KEYS.includes(key); PERSONA_KEY_FEATURE_KEYS.includes(key);
/** 首屏排序键 = 白名单下标;不在首屏的排最后(理论上不会被问到) */
export const personaKeyFeatureOrder = (key: string): number => {
const i = PERSONA_KEY_FEATURE_KEYS.indexOf(key);
return i < 0 ? PERSONA_KEY_FEATURE_KEYS.length : i;
};
/** /**
* 详情页 `?` 悬浮卡的展示内容 —— **面向客服的人话版**。 * 详情页 `?` 悬浮卡的展示内容 —— **面向客服的人话版**。
...@@ -101,22 +109,31 @@ export interface PersonaFeatureDisplay { ...@@ -101,22 +109,31 @@ export interface PersonaFeatureDisplay {
* 要精简就精简 body 的字数,不是砍掉结构。 * 要精简就精简 body 的字数,不是砍掉结构。
*/ */
rules: Array<{ label: string; body: string }>; rules: Array<{ label: string; body: string }>;
/** 详情页分组 */ /** 所属类区(业务字典 A/B/C/D;决定颜色 + 分组 + 排序) */
group: PersonaFeatureGroup; category: PersonaFeatureCategory;
/** 内排序(小的在前;留 10 的间隔便于插新标签) */ /** 类区内排序(小的在前;留 10 的间隔便于插新标签) */
order: number; order: number;
} }
/** 展示排序键:先按组、再按组内序;未登记的标签排最后(仍可见,不吞) */ /**
* ⭐ 展示排序键:先按类区序、再按类区内序;未登记的标签排最后(仍可见,不吞掉)。
* **首屏 chip 和画像详情抽屉都用这一个** —— 这就是"同出一源"的落点,别再各写一套 sort。
*/
export function personaFeatureSortKey(key: string): [number, number] { export function personaFeatureSortKey(key: string): [number, number] {
const d = PERSONA_FEATURE_SPECS[key]?.display; const d = PERSONA_FEATURE_SPECS[key]?.display;
if (!d) return [PERSONA_FEATURE_GROUP_ORDER.length, 0]; if (!d) return [PERSONA_FEATURE_CATEGORY_ORDER.length, 0];
return [PERSONA_FEATURE_GROUP_ORDER.indexOf(d.group), d.order]; return [PERSONA_FEATURE_CATEGORY_ORDER.indexOf(d.category), d.order];
} }
/** 该标签属于哪个组(未登记 → null,调用方自行归入"其他") */ /** 该标签属于哪个类区(未登记 → null,调用方自行归入"其他") */
export const personaFeatureGroup = (key: string): PersonaFeatureGroup | null => export const personaFeatureCategory = (key: string): PersonaFeatureCategory | null =>
PERSONA_FEATURE_SPECS[key]?.display?.group ?? null; PERSONA_FEATURE_SPECS[key]?.display?.category ?? null;
/** 类区颜色(tone key);未登记 → slate */
export const personaFeatureCategoryTone = (key: string): string => {
const c = personaFeatureCategory(key);
return c ? PERSONA_FEATURE_CATEGORY_META[c].tone : 'slate';
};
export interface PersonaFeatureSpec { export interface PersonaFeatureSpec {
key: string; // PersonaFeatureKey key: string; // PersonaFeatureKey
...@@ -173,7 +190,7 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = { ...@@ -173,7 +190,7 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
{ label: '挽留', body: '消费高,但很久没来了' }, { label: '挽留', body: '消费高,但很久没来了' },
{ label: '低活跃', body: '很久没来,或三项都偏低' }, { label: '低活跃', body: '很久没来,或三项都偏低' },
], ],
group: 'value', category: 'B',
order: 10, order: 10,
}, },
owner: 'pac-algo', owner: 'pac-algo',
...@@ -214,8 +231,8 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = { ...@@ -214,8 +231,8 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
{ label: '青少年 / 青年 / 中青年', body: '12-17 / 18-25 / 26-30 岁' }, { label: '青少年 / 青年 / 中青年', body: '12-17 / 18-25 / 26-30 岁' },
{ label: '中年 / 中老年 / 老年', body: '31-45 / 46-54 / 55 岁以上' }, { label: '中年 / 中老年 / 老年', body: '31-45 / 46-54 / 55 岁以上' },
], ],
group: 'basic', category: 'A',
order: 10, order: 20,
}, },
owner: 'pac-algo', owner: 'pac-algo',
version: 1, version: 1,
...@@ -238,8 +255,8 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = { ...@@ -238,8 +255,8 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
{ label: '男性 / 女性', body: '按建档性别' }, { label: '男性 / 女性', body: '按建档性别' },
{ label: '未知', body: '建档没填,或填的值无法识别' }, { label: '未知', body: '建档没填,或填的值无法识别' },
], ],
group: 'basic', category: 'A',
order: 20, order: 30,
}, },
owner: 'pac-algo', owner: 'pac-algo',
version: 1, version: 1,
...@@ -264,8 +281,8 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = { ...@@ -264,8 +281,8 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
{ label: '电商平台 / 自媒体网络', body: '线上平台来的' }, { label: '电商平台 / 自媒体网络', body: '线上平台来的' },
{ label: '内部员工 / 其他', body: '其余来源' }, { label: '内部员工 / 其他', body: '其余来源' },
], ],
group: 'basic', category: 'A',
order: 40, order: 10,
}, },
owner: 'pac-algo', owner: 'pac-algo',
version: 1, version: 1,
...@@ -294,8 +311,8 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = { ...@@ -294,8 +311,8 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
{ label: '两口之家', body: '有配偶' }, { label: '两口之家', body: '有配偶' },
{ label: '单身家庭', body: '只有旁系或朋友' }, { label: '单身家庭', body: '只有旁系或朋友' },
], ],
group: 'basic', category: 'A',
order: 30, order: 40,
}, },
owner: 'pac-algo', owner: 'pac-algo',
version: 1, version: 1,
...@@ -322,8 +339,8 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = { ...@@ -322,8 +339,8 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
{ label: '家庭型', body: '带的是家里人' }, { label: '家庭型', body: '带的是家里人' },
{ label: '社交型', body: '带的是家人以外的人' }, { label: '社交型', body: '带的是家人以外的人' },
], ],
group: 'value', category: 'B',
order: 50, order: 40,
}, },
owner: 'pac-algo', owner: 'pac-algo',
version: 1, version: 1,
...@@ -353,8 +370,8 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = { ...@@ -353,8 +370,8 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
{ label: '儿牙会员', body: '儿牙卡 / 涂氟年卡' }, { label: '儿牙会员', body: '儿牙卡 / 涂氟年卡' },
{ label: '医保客户', body: '用过医保结算' }, { label: '医保客户', body: '用过医保结算' },
], ],
group: 'value', category: 'B',
order: 40, order: 30,
}, },
owner: 'pac-algo', owner: 'pac-algo',
version: 2, version: 2,
...@@ -383,7 +400,7 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = { ...@@ -383,7 +400,7 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
{ label: '成长客 / 成熟客', body: '还在来,消费在涨 / 消费平稳' }, { label: '成长客 / 成熟客', body: '还在来,消费在涨 / 消费平稳' },
{ label: '待激活 / 沉睡客 / 流失客', body: '半年~1年半 / 1年半~2年 / 2年以上没来' }, { label: '待激活 / 沉睡客 / 流失客', body: '半年~1年半 / 1年半~2年 / 2年以上没来' },
], ],
group: 'value', category: 'B',
order: 20, order: 20,
}, },
owner: 'pac-algo', owner: 'pac-algo',
...@@ -411,8 +428,8 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = { ...@@ -411,8 +428,8 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
{ label: '修复史', body: '冠桥 / 贴面 / 嵌体' }, { label: '修复史', body: '冠桥 / 贴面 / 嵌体' },
{ label: '牙周治疗史', body: '洁牙 / 刮治 / 牙周序列' }, { label: '牙周治疗史', body: '洁牙 / 刮治 / 牙周序列' },
], ],
group: 'value', category: 'C',
order: 30, order: 10,
}, },
owner: 'pac-algo', owner: 'pac-algo',
version: 1, version: 1,
...@@ -439,8 +456,8 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = { ...@@ -439,8 +456,8 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
{ label: '工作日 / 周末', body: '近两年预约多在这几天' }, { label: '工作日 / 周末', body: '近两年预约多在这几天' },
{ label: '上午 / 下午 / 晚间', body: '8-12 / 12-18 / 18-21 点' }, { label: '上午 / 下午 / 晚间', body: '8-12 / 12-18 / 18-21 点' },
], ],
group: 'action', category: 'D',
order: 60, order: 50,
}, },
owner: 'pac-algo', owner: 'pac-algo',
version: 1, version: 1,
...@@ -466,8 +483,8 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = { ...@@ -466,8 +483,8 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
{ label: '最低折扣', body: '原价 500 元以上的治疗里谈成过的最深折扣' }, { label: '最低折扣', body: '原价 500 元以上的治疗里谈成过的最深折扣' },
{ label: '日期 / 项目', body: '那一次是什么时候、做的什么' }, { label: '日期 / 项目', body: '那一次是什么时候、做的什么' },
], ],
group: 'action', category: 'D',
order: 70, order: 10,
}, },
owner: 'pac-algo', owner: 'pac-algo',
version: 1, version: 1,
...@@ -496,7 +513,7 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = { ...@@ -496,7 +513,7 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
{ label: '免打扰', body: '已标记不接受主动联系' }, { label: '免打扰', body: '已标记不接受主动联系' },
{ label: '不可等候', body: '提过赶时间 / 不能等' }, { label: '不可等候', body: '提过赶时间 / 不能等' },
], ],
group: 'action', category: 'D',
order: 30, order: 30,
}, },
owner: 'pac-algo', owner: 'pac-algo',
...@@ -525,8 +542,8 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = { ...@@ -525,8 +542,8 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
{ label: '晕针 / 晕血', body: '提过怕打针 / 见血不适' }, { label: '晕针 / 晕血', body: '提过怕打针 / 见血不适' },
{ label: '密闭恐惧', body: '提过幽闭 / 长时间张口不适' }, { label: '密闭恐惧', body: '提过幽闭 / 长时间张口不适' },
], ],
group: 'action', category: 'D',
order: 50, order: 40,
}, },
owner: 'pac-algo', owner: 'pac-algo',
version: 1, version: 1,
...@@ -554,8 +571,8 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = { ...@@ -554,8 +571,8 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
{ label: '正畸 / 早矫', body: '成人正畸 13-40 岁 / 儿童早矫 3-12 岁' }, { label: '正畸 / 早矫', body: '成人正畸 13-40 岁 / 儿童早矫 3-12 岁' },
{ label: '根管 / 牙周 / 补牙', body: '牙髓、牙周、龋齿' }, { label: '根管 / 牙周 / 补牙', body: '牙髓、牙周、龋齿' },
], ],
group: 'action', category: 'C',
order: 10, order: 20,
}, },
owner: 'pac-algo', owner: 'pac-algo',
version: 1, version: 1,
...@@ -583,8 +600,8 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = { ...@@ -583,8 +600,8 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
{ label: '高', body: '30-90 天没来' }, { label: '高', body: '30-90 天没来' },
{ label: '中', body: '近期来过或新发现' }, { label: '中', body: '近期来过或新发现' },
], ],
group: 'action', category: 'C',
order: 20, order: 30,
}, },
owner: 'pac-algo', owner: 'pac-algo',
version: 1, version: 1,
...@@ -616,8 +633,8 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = { ...@@ -616,8 +633,8 @@ export const PERSONA_FEATURE_SPECS: Record<string, PersonaFeatureSpec> = {
{ label: '麻醉禁忌', body: '局麻药物过敏(利多卡因/普鲁卡因等);哮喘为相对禁忌' }, { label: '麻醉禁忌', body: '局麻药物过敏(利多卡因/普鲁卡因等);哮喘为相对禁忌' },
{ label: '正畸禁忌', body: '诊断为重度牙周炎 —— 牙周控制后可矫治,属相对禁忌' }, { label: '正畸禁忌', body: '诊断为重度牙周炎 —— 牙周控制后可矫治,属相对禁忌' },
], ],
group: 'action', category: 'D',
order: 40, order: 20,
}, },
owner: 'pac-algo', owner: 'pac-algo',
version: 2, version: 2,
......
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