Commit 487041a9 by luoqi

feat(plan): 卡片换行序 + 标签不折叠;筛选面板改「筛选标签」并按 Tier 重排

卡片(业务 2026-07-29):
- rail 行序调换:「潜在治疗 + 上次到诊·医生」升到第二行,手机/诊所/操作按钮沉到第三行。
  理由记在注释里:客服扫列表是先看「这人有什么可谈」(治疗项 + 多久没来),
  决定要跟进了才看联系方式、按认领。
- 潜在治疗标签**不再折叠**(原 rail 封顶 2 个、卡片封顶 3 个 + N)。标签多的患者本来就该优先跟,
  让它换行占地方是对的;折成 +N 等于逼客服多点一次。两处口径一致,过时注释一并订正。

筛选面板:
- 文案「画像标签」→「筛选标签」,面板标题「按画像圈人」→「按标签圈人」(空态文案同步)。
- PERSONA_TAG_FILTER_DIMS 按业务 Tier 重排:
    Tier1 治疗维度:潜在治疗、治疗史
    Tier3 属性维度:价值分群、紧迫度、年龄段、权益身份、转介绍达人、家庭构成、性别
- 下线 5 个维度:时间偏好、生命周期、治疗敏感、特殊关注、禁忌。
  **注释保留而非删除** —— 业务口径反复过不止一次,留着比重写便宜;persona_features 仍在写
  这些特征、偏索引(迁移 20260728020000)也还在,随时可复活。
- Tier2(偏好医生 / 上次医生 / 上次时间)留了位置和原因,未实现:三者都缺数据基础,
  不是加个 option 能解决的(详见文件内注释与给用户的说明)。

️ 禁忌下线的**只是「圈人筛选」这一处**:它仍在患者详情画像卡展示、仍参与 persona 计算。
   安全信号不因为不能拿来圈人就消失。这一条与 2026-07-27 用户「禁忌要做成四类筛选」的
   指示相反,是本次业务文档要求,已在给用户的回复里单独点出待确认。

654 tests / 42 suites 全过;types / service / web 三处 typecheck 通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent 6248a4f8
......@@ -91,7 +91,7 @@ function EntryResolver() {
</span>
<div className="text-[15px] font-medium text-slate-700">当前没有待跟进的召回任务</div>
<p className="max-w-sm text-[12.5px] leading-relaxed text-slate-500">
我的任务与召回池都是空的。可以在左侧调整筛选(视图 / 诊所 / 画像标签)自行查找,
我的任务与召回池都是空的。可以在左侧调整筛选(视图 / 诊所 / 筛选标签)自行查找,
或等召回引擎下次重算后刷新。
</p>
<button
......
......@@ -28,7 +28,7 @@ import { usePatientPicker, type PickerFilters } from './use-patient-picker';
* PatientPickerRail — 详情页固定左栏(第 4 列):一页式工作台的"选患者"列。
*
* 吸收列表页核心要素(列表页将弃用):view tab / 搜索 / 排序 / 真实号码 /
* ⭐画像标签筛选(价值分群 / 生命周期 / 紧迫度 / 潜在治疗,同维 OR 跨维 AND,服务端 SQL)。
* ⭐筛选标签(治疗维度 / 属性维度…,同维 OR 跨维 AND,服务端 SQL;分层见 PERSONA_TAG_FILTER_DIMS)。
* 不带 KPI 面板(按需求剔除)。点患者 → 路由切详情;本组件挂在 plans/layout(路由段之上),
* 切患者不重挂、列表状态(筛选/滚动位置/已加载页)全保留。
*/
......@@ -343,7 +343,7 @@ export function PatientPickerRail({
);
}
// ── 画像标签筛选 popover(多维度分组多选)────────────────────
// ── 筛选标签 popover(多维度分组多选;维度与顺序见 PERSONA_TAG_FILTER_DIMS)────────
function PersonaTagFilter({
selected,
onToggle,
......@@ -368,12 +368,12 @@ function PersonaTagFilter({
)}
>
<Filter className="h-3 w-3" />
画像标签{selected.size > 0 ? ` ${selected.size}` : ''}
筛选标签{selected.size > 0 ? ` ${selected.size}` : ''}
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-2">
<div className="mb-1 flex items-center justify-between px-1">
<span className="text-[11px] font-medium text-slate-500">画像圈人</span>
<span className="text-[11px] font-medium text-slate-500">标签圈人</span>
{selected.size > 0 && (
<button type="button" onClick={onClear} className="text-[10.5px] text-teal-700 hover:underline">
清空
......@@ -536,15 +536,15 @@ function OutcomeTag({ outcome }: { outcome: string }) {
/**
* 潜在治疗标签(画像 potential_treatment.labels)—— 与列表页 PlanCard 同款,尺寸小一号。
* 刻意比状态药丸弱一档(无底色、细描边):这是"这人还有哪些没做"的背景信息,
* 不该跟"这次为什么召"抢注意力。rail 一屏要放十几行,最多铺 2 个,其余折 +N
* 不该跟"这次为什么召"抢注意力。业务要求**全列不折叠**,容器自然换行
*/
function PotentialTreatmentChips({ labels }: { labels: string[] }) {
if (!labels.length) return null;
const shown = labels.slice(0, 2);
const rest = labels.length - shown.length;
// 不折叠 —— 业务要求全列。「这人还有哪些没做」是客服决定要不要跟的依据,
// 折成 +N 等于逼人多点一次;标签多的患者本来就是该优先跟的,让它换行占地方是对的。
return (
<>
{shown.map((l) => (
{labels.map((l) => (
<span
key={l}
className="inline-block flex-none rounded px-1 py-px text-[10px] leading-4 text-slate-600 ring-1 ring-inset ring-slate-200"
......@@ -552,14 +552,6 @@ function PotentialTreatmentChips({ labels }: { labels: string[] }) {
{potentialTreatmentCardLabel(l)}
</span>
))}
{rest > 0 && (
<span
className="flex-none text-[10px] text-slate-400"
title={labels.slice(2).map(potentialTreatmentCardLabel).join(' · ')}
>
+{rest}
</span>
)}
</>
);
}
......@@ -648,6 +640,25 @@ function PatientRow({
</PriorityHover>
</span>
</div>
{/* 第二行:潜在治疗(左) + 上次到诊·医生(右)。
放在"手机/诊所/操作按钮"那行**之上** —— 客服扫列表是先看"这人有什么可谈"(治疗项 + 多久没来),
确定要跟进了才看联系方式、按认领。两者都缺则整行不渲染,行高不虚占。 */}
{(p.patient.potentialTreatments.length > 0 || p.patient.lastVisitAt) && (
<div className="mt-0.5 flex items-start gap-1">
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-1">
<PotentialTreatmentChips labels={p.patient.potentialTreatments} />
</span>
{p.patient.lastVisitAt && (
<span
className="flex-none whitespace-nowrap text-[10px] leading-4 tabular-nums text-slate-400"
title="上次到诊"
>
{p.patient.lastVisitAt}
{p.patient.lastVisitDoctor && ` · ${p.patient.lastVisitDoctor}`}
</span>
)}
</div>
)}
<div className="mt-0.5 flex items-center gap-1.5">
{!hidePhone && (
<span className="flex-none font-mono text-[10.5px] tabular-nums text-slate-400">
......@@ -693,23 +704,6 @@ function PatientRow({
);
})()}
</div>
{/* 第三行:潜在治疗(左) + 上次到诊·医生(右)。两者都可能缺,全缺则整行不渲染,行高不虚占。 */}
{(p.patient.potentialTreatments.length > 0 || p.patient.lastVisitAt) && (
<div className="mt-1 flex items-center gap-1">
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-1">
<PotentialTreatmentChips labels={p.patient.potentialTreatments} />
</span>
{p.patient.lastVisitAt && (
<span
className="flex-none whitespace-nowrap text-[10px] tabular-nums text-slate-400"
title="上次到诊"
>
{p.patient.lastVisitAt}
{p.patient.lastVisitDoctor && ` · ${p.patient.lastVisitDoctor}`}
</span>
)}
</div>
)}
</div>
);
}
......@@ -838,7 +838,7 @@ function PatientPlanCard({
<ScenarioChip label={p.scenarioLabel} scenario={p.scenario} />
<StatusPill status={p.status} />
{/* 潜在治疗(出自画像 potential_treatment):覆盖该患者全部"诊断了没做"的项,
比下面的召回理由更宽 —— 理由只是这次值得召回的那条。最多 3 个,其余折成 +N。 */}
比下面的召回理由更宽 —— 理由只是这次值得召回的那条。全列不折叠。 */}
<PotentialTreatmentChips labels={p.patient.potentialTreatments} />
</div>
......@@ -978,15 +978,14 @@ function StatusPill({ status }: { status: string }) {
*
* 样式上刻意比 ScenarioChip 弱一档(无底色、细描边、slate 色):场景 chip 是"这次为什么召",
* 是主信息;潜在治疗是"这人还有哪些没做",是背景信息。抢色会让客服分不清主次。
* 最多铺 3 个,其余折成 +N —— 卡片是扫读用的,标签一多就不是信息是墙
* 全列不折叠(业务口径):标签多的患者本来就该优先跟,让它换行占地方是对的
*/
function PotentialTreatmentChips({ labels }: { labels: string[] }) {
if (!labels.length) return null;
const shown = labels.slice(0, 3);
const rest = labels.length - shown.length;
// 不折叠(与 rail 同口径,见 patient-picker-rail 注释):全列出来,让容器自然换行。
return (
<>
{shown.map((l) => (
{labels.map((l) => (
<span
key={l}
className="inline-block rounded px-1.5 py-0.5 text-[10.5px] text-slate-600 ring-1 ring-inset ring-slate-200"
......@@ -994,14 +993,6 @@ function PotentialTreatmentChips({ labels }: { labels: string[] }) {
{potentialTreatmentCardLabel(l)}
</span>
))}
{rest > 0 && (
<span
className="text-[10.5px] text-slate-400"
title={labels.slice(3).map(potentialTreatmentCardLabel).join(' · ')}
>
+{rest}
</span>
)}
</>
);
}
......
......@@ -42,6 +42,37 @@ export interface PersonaTagFilterDim {
}
export const PERSONA_TAG_FILTER_DIMS: PersonaTagFilterDim[] = [
// ══════════════ Tier1 · 治疗维度 —— 客服最先问的是「这人有什么可谈」 ══════════════
{
key: 'potential_treatment',
nameZh: '潜在治疗',
dataPath: 'types',
isArray: true,
options: [
{ value: 'implant', zh: '种植', hint: '诊断缺牙,还没种' },
{ value: 'ortho', zh: '正畸', hint: '建议矫正,还没做(13-40 岁)' },
{ value: 'early_ortho', zh: '早矫', hint: '建议儿童早期矫治(3-12 岁)' },
{ value: 'endo', zh: '根管', hint: '诊断牙髓问题,还没做根管' },
{ value: 'perio', zh: '牙周', hint: '诊断牙周问题,还没治' },
{ value: 'filling', zh: '补牙', hint: '诊断龋齿,还没补' },
{ value: 'restoration', zh: '修复', hint: '需要冠桥 / 贴面,还没做' },
{ value: 'extraction', zh: '拔牙', hint: '残根残冠等需拔,还没拔' },
],
},
{
key: 'treatment_history',
nameZh: '治疗史',
dataPath: 'types',
isArray: true,
options: [
{ value: 'implant_history', zh: '种植史', hint: '种过牙' },
{ value: 'ortho_history', zh: '正畸史', hint: '做过矫正' },
{ value: 'prostho_history', zh: '修复史', hint: '做过冠桥 / 贴面 / 嵌体' },
{ value: 'perio_history', zh: '牙周治疗史', hint: '做过洁牙 / 刮治 / 牙周序列治疗' },
],
},
// ══════════════ Tier3 · 属性维度 —— 定语气与话术分寸,不决定要不要打 ══════════════
{
key: 'rfm',
nameZh: '价值分群',
......@@ -58,20 +89,6 @@ export const PERSONA_TAG_FILTER_DIMS: PersonaTagFilterDim[] = [
],
},
{
key: 'lifecycle_stage',
nameZh: '生命周期',
dataPath: 'stage',
options: [
{ value: 'prospect', zh: '潜客', hint: '还没到过诊,只约过或咨询过' },
{ value: 'new', zh: '新客', hint: '首诊半年内,就诊不超过 3 次' },
{ value: 'growth', zh: '成长客', hint: '还在来,近一年消费明显上涨' },
{ value: 'mature', zh: '成熟客', hint: '还在来,消费平稳' },
{ value: 'reactivate', zh: '待激活', hint: '半年到一年半没来' },
{ value: 'dormant', zh: '沉睡客', hint: '一年半到两年没来' },
{ value: 'churned', zh: '流失客', hint: '两年以上没来' },
],
},
{
key: 'urgency_level',
nameZh: '紧迫度',
dataPath: 'level',
......@@ -98,26 +115,6 @@ export const PERSONA_TAG_FILTER_DIMS: PersonaTagFilterDim[] = [
],
},
{
key: 'gender',
nameZh: '性别',
dataPath: 'gender',
options: [
{ value: 'male', zh: '男' },
{ value: 'female', zh: '女' },
],
},
{
key: 'family_structure',
nameZh: '家庭构成',
dataPath: 'structure',
options: [
{ value: 'multigen', zh: '多代之家', hint: '院内登记有长辈(父母 / 祖辈)' },
{ value: 'nuclear', zh: '多口之家', hint: '院内登记有子女' },
{ value: 'couple', zh: '两口之家', hint: '院内登记有配偶' },
{ value: 'single', zh: '单身家庭', hint: '只有旁系或朋友关系' },
],
},
{
key: 'entitlement_status',
nameZh: '权益身份',
dataPath: 'types',
......@@ -131,55 +128,6 @@ export const PERSONA_TAG_FILTER_DIMS: PersonaTagFilterDim[] = [
],
},
{
key: 'treatment_history',
nameZh: '治疗史',
dataPath: 'types',
isArray: true,
options: [
{ value: 'implant_history', zh: '种植史', hint: '种过牙' },
{ value: 'ortho_history', zh: '正畸史', hint: '做过矫正' },
{ value: 'prostho_history', zh: '修复史', hint: '做过冠桥 / 贴面 / 嵌体' },
{ value: 'perio_history', zh: '牙周治疗史', hint: '做过洁牙 / 刮治 / 牙周序列治疗' },
],
},
{
key: 'treatment_sensitivity',
nameZh: '治疗敏感',
dataPath: 'types',
isArray: true,
options: [
{ value: 'dental_fear', zh: '看牙恐惧', hint: '病历里提过害怕看牙' },
{ value: 'needle_faint', zh: '晕针', hint: '病历里提过晕针 / 怕打针' },
{ value: 'blood_faint', zh: '晕血', hint: '病历里提过晕血' },
{ value: 'claustrophobia', zh: '密闭恐惧', hint: '病历里提过幽闭 / 长时间张口不适' },
],
},
{
key: 'special_attention',
nameZh: '特殊关注',
dataPath: 'types',
isArray: true,
options: [
{ value: 'frequent_no_show', zh: '屡次爽约', hint: '近一年至少 3 次预约,履约不到一半' },
{ value: 'often_late', zh: '经常迟到', hint: '近一年多次晚到 15 分钟以上' },
{ value: 'do_not_disturb', zh: '免打扰', hint: '已标记不接受主动联系,不要外呼' },
{ value: 'cannot_wait', zh: '不可等候', hint: '病历或备注提过赶时间 / 不能等' },
],
},
{
key: 'time_preference',
nameZh: '时间偏好',
dataPath: 'types',
isArray: true,
options: [
{ value: 'weekday', zh: '工作日', hint: '近两年预约多在工作日' },
{ value: 'weekend', zh: '周末', hint: '近两年预约多在周末' },
{ value: 'morning', zh: '上午', hint: '多约在 8-12 点' },
{ value: 'afternoon', zh: '下午', hint: '多约在 12-18 点' },
{ value: 'evening', zh: '晚间', hint: '多约在 18-21 点' },
],
},
{
key: 'referral_champion',
nameZh: '转介绍达人',
dataPath: 'type',
......@@ -189,47 +137,44 @@ export const PERSONA_TAG_FILTER_DIMS: PersonaTagFilterDim[] = [
],
},
{
key: 'contraindication',
nameZh: '禁忌',
/**
* 按【治疗域】筛,不按病因 —— data.domains 而非 data.types。
*
* 客服圈人时想的是「哪些人不能做种植」,不是「哪些人在吃抗骨吸收药」;
* 病因(抗凝药/放疗史/双膦酸盐…)是算法口径,列成筛选项等于把内部实现摊给业务看。
* 病因照常在关键事实卡和画像详情里露出(带医生原话),要查"所有放疗史患者"是另一个
* 场景(风险排查),需要时再加一个独立维度,别混进这里。
*
* 不列「拍片禁忌」:唯一来源是妊娠,而妊娠同时禁种植和手术 —— 勾那两项就已经覆盖,
* 单列只会让面板变长。data.domains 里仍有 xray,详情页照常显示。
*
* 核查项(糖尿病/高血压/骨质疏松/吸烟)不在这里 —— 它们落 data.checks 而非 data.domains,
* 按定义就不是禁忌,放进来会让客服把「问一句」当成「不能治」。
*/
dataPath: 'domains',
isArray: true,
key: 'family_structure',
nameZh: '家庭构成',
dataPath: 'structure',
options: [
{ value: 'surgery', zh: '手术禁忌', hint: '拔牙 / 翻瓣 / 植骨 —— 抗凝药、凝血障碍、抗骨吸收药、放疗史、妊娠期' },
{ value: 'implant', zh: '种植禁忌', hint: '未满 19 岁,或放疗史 / 抗骨吸收药 / 抗凝药 / 心血管手术史 / 妊娠哺乳' },
{ value: 'anesthesia', zh: '麻醉禁忌', hint: '局麻药物过敏;哮喘为相对禁忌' },
{ value: 'orthodontic', zh: '正畸禁忌', hint: '诊断为重度牙周炎 —— 牙周控制后可矫治,属相对禁忌' },
{ value: 'multigen', zh: '多代之家', hint: '院内登记有长辈(父母 / 祖辈)' },
{ value: 'nuclear', zh: '多口之家', hint: '院内登记有子女' },
{ value: 'couple', zh: '两口之家', hint: '院内登记有配偶' },
{ value: 'single', zh: '单身家庭', hint: '只有旁系或朋友关系' },
],
},
{
key: 'potential_treatment',
nameZh: '潜在治疗',
dataPath: 'types',
isArray: true,
key: 'gender',
nameZh: '性别',
dataPath: 'gender',
options: [
{ value: 'implant', zh: '种植', hint: '诊断缺牙,还没种' },
{ value: 'ortho', zh: '正畸', hint: '建议矫正,还没做(13-40 岁)' },
{ value: 'early_ortho', zh: '早矫', hint: '建议儿童早期矫治(3-12 岁)' },
{ value: 'endo', zh: '根管', hint: '诊断牙髓问题,还没做根管' },
{ value: 'perio', zh: '牙周', hint: '诊断牙周问题,还没治' },
{ value: 'filling', zh: '补牙', hint: '诊断龋齿,还没补' },
{ value: 'restoration', zh: '修复', hint: '需要冠桥 / 贴面,还没做' },
{ value: 'extraction', zh: '拔牙', hint: '残根残冠等需拔,还没拔' },
{ value: 'male', zh: '男' },
{ value: 'female', zh: '女' },
],
},
// ══════════════ Tier2 · 行为维度(偏好医生 / 上次医生 / 上次时间)—— 留位待接入 ══════════════
// 业务 2026-07-29 提的三个维度,数据基础还不到位,先留位不上:
// · 偏好医生:DW fact_client_out.favor_doctor_id 有,但 **PAC 尚未摄入**,且没有医生字典表;
// · 上次医生:目前只存在于 patient_facts.content.doctor_name(JSONB)。直接拿它筛 = 全表 JSONB 扫,
// 正是 2026-07-29 刚修完那个「圈人筛选 20 秒」的翻版,必须先落成可索引字段;
// · 上次时间区间(0-3 / 3-6 / 6-12 / 12-18 / 18+ 月):同上,建议做成画像特征(recency 分档),
// 那样与其余维度共用同一套偏索引,零额外查询代价。
// 注:生命周期(潜客/新客/…/流失客)本质就是 recency 分档。业务要下线它、同时新增「上次时间」,
// 两者信息高度重叠,只是后者对客服更直白 —— 接入时一起想清楚,别做成两套。
// ══════════════ 已下线的维度(业务 2026-07-29 判定与召回决策关系不大)══════════════
// 刻意**注释保留而非删除**:业务口径反复过不止一次,留着比重写便宜;
// 且 persona_features 仍在写这些特征、偏索引也还在(见迁移 20260728020000),随时可复活。
// time_preference 时间偏好 / lifecycle_stage 生命周期 /
// treatment_sensitivity 治疗敏感 / special_attention 特殊关注 / contraindication 禁忌
//
// ⚠️ 禁忌下线的**只是「圈人筛选」这一处**:它仍在患者详情的画像卡上展示、仍参与 persona 计算。
// 安全信号不因为不能拿来圈人就消失。
];
/** "key:value" 串(query 上线格式)解析;非法项丢弃。 */
......
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