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() { ...@@ -91,7 +91,7 @@ function EntryResolver() {
</span> </span>
<div className="text-[15px] font-medium text-slate-700">当前没有待跟进的召回任务</div> <div className="text-[15px] font-medium text-slate-700">当前没有待跟进的召回任务</div>
<p className="max-w-sm text-[12.5px] leading-relaxed text-slate-500"> <p className="max-w-sm text-[12.5px] leading-relaxed text-slate-500">
我的任务与召回池都是空的。可以在左侧调整筛选(视图 / 诊所 / 画像标签)自行查找, 我的任务与召回池都是空的。可以在左侧调整筛选(视图 / 诊所 / 筛选标签)自行查找,
或等召回引擎下次重算后刷新。 或等召回引擎下次重算后刷新。
</p> </p>
<button <button
......
...@@ -28,7 +28,7 @@ import { usePatientPicker, type PickerFilters } from './use-patient-picker'; ...@@ -28,7 +28,7 @@ import { usePatientPicker, type PickerFilters } from './use-patient-picker';
* PatientPickerRail — 详情页固定左栏(第 4 列):一页式工作台的"选患者"列。 * PatientPickerRail — 详情页固定左栏(第 4 列):一页式工作台的"选患者"列。
* *
* 吸收列表页核心要素(列表页将弃用):view tab / 搜索 / 排序 / 真实号码 / * 吸收列表页核心要素(列表页将弃用):view tab / 搜索 / 排序 / 真实号码 /
* ⭐画像标签筛选(价值分群 / 生命周期 / 紧迫度 / 潜在治疗,同维 OR 跨维 AND,服务端 SQL)。 * ⭐筛选标签(治疗维度 / 属性维度…,同维 OR 跨维 AND,服务端 SQL;分层见 PERSONA_TAG_FILTER_DIMS)。
* 不带 KPI 面板(按需求剔除)。点患者 → 路由切详情;本组件挂在 plans/layout(路由段之上), * 不带 KPI 面板(按需求剔除)。点患者 → 路由切详情;本组件挂在 plans/layout(路由段之上),
* 切患者不重挂、列表状态(筛选/滚动位置/已加载页)全保留。 * 切患者不重挂、列表状态(筛选/滚动位置/已加载页)全保留。
*/ */
...@@ -343,7 +343,7 @@ export function PatientPickerRail({ ...@@ -343,7 +343,7 @@ export function PatientPickerRail({
); );
} }
// ── 画像标签筛选 popover(多维度分组多选)──────────────────── // ── 筛选标签 popover(多维度分组多选;维度与顺序见 PERSONA_TAG_FILTER_DIMS)────────
function PersonaTagFilter({ function PersonaTagFilter({
selected, selected,
onToggle, onToggle,
...@@ -368,12 +368,12 @@ function PersonaTagFilter({ ...@@ -368,12 +368,12 @@ function PersonaTagFilter({
)} )}
> >
<Filter className="h-3 w-3" /> <Filter className="h-3 w-3" />
画像标签{selected.size > 0 ? ` ${selected.size}` : ''} 筛选标签{selected.size > 0 ? ` ${selected.size}` : ''}
</button> </button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align="start" className="w-64 p-2"> <PopoverContent align="start" className="w-64 p-2">
<div className="mb-1 flex items-center justify-between px-1"> <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 && ( {selected.size > 0 && (
<button type="button" onClick={onClear} className="text-[10.5px] text-teal-700 hover:underline"> <button type="button" onClick={onClear} className="text-[10.5px] text-teal-700 hover:underline">
清空 清空
...@@ -536,15 +536,15 @@ function OutcomeTag({ outcome }: { outcome: string }) { ...@@ -536,15 +536,15 @@ function OutcomeTag({ outcome }: { outcome: string }) {
/** /**
* 潜在治疗标签(画像 potential_treatment.labels)—— 与列表页 PlanCard 同款,尺寸小一号。 * 潜在治疗标签(画像 potential_treatment.labels)—— 与列表页 PlanCard 同款,尺寸小一号。
* 刻意比状态药丸弱一档(无底色、细描边):这是"这人还有哪些没做"的背景信息, * 刻意比状态药丸弱一档(无底色、细描边):这是"这人还有哪些没做"的背景信息,
* 不该跟"这次为什么召"抢注意力。rail 一屏要放十几行,最多铺 2 个,其余折 +N * 不该跟"这次为什么召"抢注意力。业务要求**全列不折叠**,容器自然换行
*/ */
function PotentialTreatmentChips({ labels }: { labels: string[] }) { function PotentialTreatmentChips({ labels }: { labels: string[] }) {
if (!labels.length) return null; if (!labels.length) return null;
const shown = labels.slice(0, 2); // 不折叠 —— 业务要求全列。「这人还有哪些没做」是客服决定要不要跟的依据,
const rest = labels.length - shown.length; // 折成 +N 等于逼人多点一次;标签多的患者本来就是该优先跟的,让它换行占地方是对的。
return ( return (
<> <>
{shown.map((l) => ( {labels.map((l) => (
<span <span
key={l} 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" 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[] }) { ...@@ -552,14 +552,6 @@ function PotentialTreatmentChips({ labels }: { labels: string[] }) {
{potentialTreatmentCardLabel(l)} {potentialTreatmentCardLabel(l)}
</span> </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({ ...@@ -648,6 +640,25 @@ function PatientRow({
</PriorityHover> </PriorityHover>
</span> </span>
</div> </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"> <div className="mt-0.5 flex items-center gap-1.5">
{!hidePhone && ( {!hidePhone && (
<span className="flex-none font-mono text-[10.5px] tabular-nums text-slate-400"> <span className="flex-none font-mono text-[10.5px] tabular-nums text-slate-400">
...@@ -693,23 +704,6 @@ function PatientRow({ ...@@ -693,23 +704,6 @@ function PatientRow({
); );
})()} })()}
</div> </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> </div>
); );
} }
...@@ -838,7 +838,7 @@ function PatientPlanCard({ ...@@ -838,7 +838,7 @@ function PatientPlanCard({
<ScenarioChip label={p.scenarioLabel} scenario={p.scenario} /> <ScenarioChip label={p.scenarioLabel} scenario={p.scenario} />
<StatusPill status={p.status} /> <StatusPill status={p.status} />
{/* 潜在治疗(出自画像 potential_treatment):覆盖该患者全部"诊断了没做"的项, {/* 潜在治疗(出自画像 potential_treatment):覆盖该患者全部"诊断了没做"的项,
比下面的召回理由更宽 —— 理由只是这次值得召回的那条。最多 3 个,其余折成 +N。 */} 比下面的召回理由更宽 —— 理由只是这次值得召回的那条。全列不折叠。 */}
<PotentialTreatmentChips labels={p.patient.potentialTreatments} /> <PotentialTreatmentChips labels={p.patient.potentialTreatments} />
</div> </div>
...@@ -978,15 +978,14 @@ function StatusPill({ status }: { status: string }) { ...@@ -978,15 +978,14 @@ function StatusPill({ status }: { status: string }) {
* *
* 样式上刻意比 ScenarioChip 弱一档(无底色、细描边、slate 色):场景 chip 是"这次为什么召", * 样式上刻意比 ScenarioChip 弱一档(无底色、细描边、slate 色):场景 chip 是"这次为什么召",
* 是主信息;潜在治疗是"这人还有哪些没做",是背景信息。抢色会让客服分不清主次。 * 是主信息;潜在治疗是"这人还有哪些没做",是背景信息。抢色会让客服分不清主次。
* 最多铺 3 个,其余折成 +N —— 卡片是扫读用的,标签一多就不是信息是墙 * 全列不折叠(业务口径):标签多的患者本来就该优先跟,让它换行占地方是对的
*/ */
function PotentialTreatmentChips({ labels }: { labels: string[] }) { function PotentialTreatmentChips({ labels }: { labels: string[] }) {
if (!labels.length) return null; if (!labels.length) return null;
const shown = labels.slice(0, 3); // 不折叠(与 rail 同口径,见 patient-picker-rail 注释):全列出来,让容器自然换行。
const rest = labels.length - shown.length;
return ( return (
<> <>
{shown.map((l) => ( {labels.map((l) => (
<span <span
key={l} 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" 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[] }) { ...@@ -994,14 +993,6 @@ function PotentialTreatmentChips({ labels }: { labels: string[] }) {
{potentialTreatmentCardLabel(l)} {potentialTreatmentCardLabel(l)}
</span> </span>
))} ))}
{rest > 0 && (
<span
className="text-[10.5px] text-slate-400"
title={labels.slice(3).map(potentialTreatmentCardLabel).join(' · ')}
>
+{rest}
</span>
)}
</> </>
); );
} }
......
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