Commit 21645e8e by luoqi

feat(web): 详情页一页式工作台 — 固定左栏选患者列(4列)+ 画像标签筛选

把"我的任务"hover 抽屉升级为详情页固定左栏(列表页将弃用,核心要素并入;KPI 面板按需求不带):

- plans/layout.tsx:布局挂在动态段之上 — 选中 planId 时渲染「左栏 300px + 详情(原三列)」=4列;
  切患者只重渲染右侧,左栏不重挂(筛选/已加载分页/滚动位置全保留);/plans 列表页不受影响。
- PatientPickerRail:召回池/我的/全部(权限)tab + 搜索(300ms 防抖)+ 排序 + 真实号码开关 +
  画像标签筛选(popover 四维分组多选:价值分群8/生命周期7/紧迫度3/潜在治疗8,选中 chips 可单删);
  紧凑患者卡(名/性别年龄/真角标/优先级分色/场景/掩码号/状态),触底+按钮双加载;选中 teal 高亮。
- usePatientPicker:use-my-tasks 泛化版 — 全套筛选上服务端,筛选变化自动回第一页。
- 后端:ListPlansQuery.personaTags("key:value" 逗号串)→ plan.service 匹配患者当前版画像
  (personas.supersededAt IS NULL)的 features:同维多选 OR、跨维 AND;数组型(潜在治疗)
  用 JSON array_contains;非法 key/value 静默丢弃。
- @pac/types persona-tag-filters.ts:可筛维度字典(code中文,单一真理源,与 extractor 枚举对齐)。
- 详情页移除 TaskDrawer(被左栏取代)。

验证:API 对 DB 直查口径一致(重要价值 57==57;OR 173 / AND 10 / 数组 contains 40 / 非法=全量);
web tsc 0 + Next 生产构建过。仅本地,未部署。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent c52200fe
......@@ -17,6 +17,7 @@ import {
} from '@pac/types';
import { maskName, maskPhone } from '@pac/utils';
import { PrismaService } from '../../prisma/prisma.service';
import { PERSONA_TAG_FILTER_DIMS, parsePersonaTags } from '@pac/types';
import type { TenantScopeContext } from '../../common/decorators/tenant-scope.decorator';
import type { PlanEngineService } from './engine/plan-engine.service';
import type {
......@@ -114,6 +115,41 @@ export class PlanService {
if (query.phoneVerified !== undefined) {
patientWhere.phoneVerified = query.phoneVerified;
}
// 画像标签筛选:匹配患者**当前版**画像(supersededAt IS NULL)的 features。
// 同一维度多选 = OR(该 feature 的 data 值命中任一);跨维度 = AND(每个维度各自 some)。
// 维度/取值/data 路径以 PERSONA_TAG_FILTER_DIMS 为单一真理源(非法 key/value 静默丢弃)。
if (query.personaTags) {
const byKey = new Map<string, string[]>();
for (const { key, value } of parsePersonaTags(query.personaTags)) {
const dim = PERSONA_TAG_FILTER_DIMS.find((d) => d.key === key);
if (!dim || !dim.options.some((o) => o.value === value)) continue;
const arr = byKey.get(key) ?? [];
arr.push(value);
byKey.set(key, arr);
}
const personaConds: Prisma.PatientWhereInput[] = [];
for (const [key, values] of byKey) {
const dim = PERSONA_TAG_FILTER_DIMS.find((d) => d.key === key)!;
const valueConds = values.map((v) =>
dim.isArray
? { data: { path: [dim.dataPath], array_contains: v } }
: { data: { path: [dim.dataPath], equals: v } },
);
personaConds.push({
personas: {
some: {
supersededAt: null,
features: { some: { key, OR: valueConds } },
},
},
});
}
if (personaConds.length > 0) {
patientWhere.AND = [...((patientWhere.AND as Prisma.PatientWhereInput[]) ?? []), ...personaConds];
}
}
if (Object.keys(patientWhere).length > 0) {
where.patient = patientWhere;
}
......
......@@ -13,7 +13,6 @@ import { PlanDetailApp } from '@/components/plan-detail/plan-detail-app';
import { adaptData } from '@/components/plan-detail/adapt-data';
import { usePlanAggregate } from '@/components/plans/use-plan-aggregate';
import { useAuthStore } from '@/stores/auth-store';
import { TaskDrawer } from '@/components/plan-detail/task-drawer';
/**
* /plans/[planId] — Plan 详情(生产版)。
......@@ -116,11 +115,9 @@ function PlanDetailLoader({ planId }: { planId: string }) {
);
}
// 直接渲染详情;返回按钮挂在 PlanDetailApp 内部 TopBar(后续如需再加)
// 左侧"我的任务"沉浸抽屉(hover 滑出 + 触底分页 + 跨任务跳转)
// 直接渲染详情;左侧"选患者"固定栏由 plans/layout.tsx 提供(原 TaskDrawer 抽屉已被其取代)
return (
<div className="bg-slate-50">
<TaskDrawer currentPlanId={planId} />
<PlanDetailApp data={adaptData(state.data, dictionary)} onRefreshAggregate={refresh} />
</div>
);
......
'use client';
import { useSelectedLayoutSegment } from 'next/navigation';
import { PatientPickerRail } from '@/components/plans/patient-picker-rail';
/**
* /plans 段布局 — 详情页的"一页式工作台"骨架。
*
* 选中某个 plan(/plans/[planId])时:固定左栏(选患者列)+ 右侧详情(原三列)= 4 列。
* 布局挂在动态段**之上** → 切患者(planId 变)只重渲染右侧 page,左栏不重挂:
* 筛选条件 / 已加载分页 / 滚动位置全部保留(这正是把列表并进详情页的意义)。
* /plans 列表页本身(无 planId 段)不渲染左栏,维持原样(该页面后续弃用)。
*/
export default function PlansLayout({ children }: { children: React.ReactNode }) {
// 下一层动态段的值:/plans → null;/plans/<planId> → planId
const planId = useSelectedLayoutSegment();
if (!planId) return <>{children}</>;
return (
<div className="flex h-screen overflow-hidden bg-slate-50">
<PatientPickerRail currentPlanId={planId} />
<div className="min-w-0 flex-1 overflow-y-auto">{children}</div>
</div>
);
}
......@@ -21,6 +21,8 @@ export const plansApi = {
targetClinicIds: q.targetClinicIds?.length ? q.targetClinicIds.join(',') : undefined,
assigneeUserId: q.assigneeUserId,
keyword: q.keyword,
// 画像标签筛选("key:value" 逗号串,维度见 PERSONA_TAG_FILTER_DIMS)
personaTags: q.personaTags,
// 只看真实号码:布尔上线成 'true'(后端 preprocess 还原);undefined 不带
phoneVerified: q.phoneVerified === undefined ? undefined : String(q.phoneVerified),
sort: q.sort,
......
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { ListPlansQuery, PlanListItem } from '@pac/types';
import { plansApi } from './plans-api';
const PAGE_SIZE = 25;
/** 左栏选人列表的服务端筛选(全部上后端,跨全表)。 */
export interface PickerFilters {
view: 'pool' | 'mine' | 'all';
keyword?: string;
sort: 'priority_desc' | 'priority_asc' | 'created_desc';
phoneVerified?: true;
/** "key:value" 逗号串(PERSONA_TAG_FILTER_DIMS 维度) */
personaTags?: string;
}
export interface UsePatientPicker {
items: PlanListItem[];
total: number;
loading: boolean;
hasMore: boolean;
error: string | null;
loadMore: () => void;
refresh: () => void;
}
/**
* 详情页左栏"选患者"列表 — 服务端分页 + 触底累加;筛选变化自动回第一页重拉。
* (use-my-tasks 的泛化版:不只 mine,带全套筛选)
*/
export function usePatientPicker(filters: PickerFilters): UsePatientPicker {
const [items, setItems] = useState<PlanListItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadingRef = useRef(false);
// 筛选签名:变了 → 重置回第一页
const sig = JSON.stringify(filters);
const fetchPage = useCallback(
async (p: number) => {
if (loadingRef.current) return;
loadingRef.current = true;
setLoading(true);
setError(null);
try {
const q: Partial<ListPlansQuery> = {
view: filters.view,
keyword: filters.keyword || undefined,
sort: filters.sort,
phoneVerified: filters.phoneVerified,
personaTags: filters.personaTags || undefined,
page: p,
pageSize: PAGE_SIZE,
};
const data = await plansApi.list(q);
setTotal(data.total);
setItems((prev) => {
if (p === 1) return data.items;
const seen = new Set(prev.map((x) => x.id));
return [...prev, ...data.items.filter((x) => !seen.has(x.id))];
});
setPage(p);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
loadingRef.current = false;
setLoading(false);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[sig],
);
useEffect(() => {
setItems([]);
setTotal(0);
setPage(1);
void fetchPage(1);
}, [fetchPage]);
const hasMore = items.length < total;
const loadMore = useCallback(() => {
if (loadingRef.current || !hasMore) return;
void fetchPage(page + 1);
}, [fetchPage, hasMore, page]);
const refresh = useCallback(() => {
void fetchPage(1);
}, [fetchPage]);
return { items, total, loading, hasMore, error, loadMore, refresh };
}
......@@ -4,3 +4,4 @@ export * from './utils';
export * from './labels';
export * from './canonical-codes';
export * from './persona-feature-specs';
export * from './persona-tag-filters';
/**
* 画像标签筛选字典 — 列表/选人侧"按画像圈人"的可筛维度(单一真理源)。
*
* 与 PERSONA_FEATURE_SPECS(标签卡全集)的关系:这里是**精选的可筛子集**,
* 每项声明它在 persona_features.data 里的结构化取值位置(dataPath)+ code↔中文。
* code 必须与对应 feature extractor 写入 data 的值一致(rfm.feature.ts 等);
* 改 extractor 枚举时同步改这里(CI 不强校验,靠 PR review)。
*
* 筛选语义(plan.service 实现):同一维度多选 = OR;跨维度 = AND;
* 匹配的是患者**当前版**画像(personas.supersededAt IS NULL)。
*/
export interface PersonaTagOption {
/** data 里的结构化取值(英文 code) */
value: string;
zh: string;
}
export interface PersonaTagFilterDim {
/** persona_features.key */
key: string;
nameZh: string;
/** data 里取值的字段名(单层) */
dataPath: string;
/** true = data[dataPath] 是 string[](用 array_contains);false = 标量 equals */
isArray?: boolean;
options: PersonaTagOption[];
}
export const PERSONA_TAG_FILTER_DIMS: PersonaTagFilterDim[] = [
{
key: 'rfm',
nameZh: '价值分群',
dataPath: 'segment',
options: [
{ value: 'important_value', zh: '重要价值' },
{ value: 'important_retain', zh: '重要保持' },
{ value: 'important_develop', zh: '重要发展' },
{ value: 'important_winback', zh: '重要挽留' },
{ value: 'general_value', zh: '一般价值' },
{ value: 'general_retain', zh: '一般保持' },
{ value: 'general_develop', zh: '一般发展' },
{ value: 'low_active', zh: '低活跃' },
],
},
{
key: 'lifecycle_stage',
nameZh: '生命周期',
dataPath: 'stage',
options: [
{ value: 'prospect', zh: '潜客' },
{ value: 'new', zh: '新客' },
{ value: 'growth', zh: '成长客' },
{ value: 'mature', zh: '成熟客' },
{ value: 'reactivate', zh: '待激活' },
{ value: 'dormant', zh: '沉睡客' },
{ value: 'churned', zh: '流失客' },
],
},
{
key: 'urgency_level',
nameZh: '紧迫度',
dataPath: 'level',
options: [
{ value: 'urgent', zh: '紧急' },
{ value: 'high', zh: '高' },
{ value: 'mid', zh: '中' },
],
},
{
key: 'potential_treatment',
nameZh: '潜在治疗',
dataPath: 'types',
isArray: true,
options: [
{ value: 'implant', zh: '种植' },
{ value: 'ortho', zh: '正畸' },
{ value: 'early_ortho', zh: '早矫' },
{ value: 'endo', zh: '根管' },
{ value: 'perio', zh: '牙周' },
{ value: 'filling', zh: '补牙' },
{ value: 'restoration', zh: '修复' },
{ value: 'extraction', zh: '拔牙' },
],
},
];
/** "key:value" 串(query 上线格式)解析;非法项丢弃。 */
export function parsePersonaTags(raw: string): Array<{ key: string; value: string }> {
return raw
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.map((s) => {
const i = s.indexOf(':');
if (i <= 0) return null;
return { key: s.slice(0, i), value: s.slice(i + 1) };
})
.filter((x): x is { key: string; value: string } => x !== null);
}
......@@ -138,6 +138,9 @@ export const ListPlansQuerySchema = z.object({
.describe('"pool" = active+unassigned; "mine" = assigned to caller; "all" = no extra filter'),
/// 关键字搜索:服务端按 patient.name / phone / externalId 模糊匹配(W3 末加,替代前端本页 filter)
keyword: z.string().trim().min(1).optional(),
/// 画像标签筛选:"key:value" 逗号串(如 "rfm:important_value,urgency_level:high")。
/// 维度/取值见 PERSONA_TAG_FILTER_DIMS;同维多选 OR,跨维 AND,匹配当前版画像。
personaTags: z.string().trim().min(1).optional(),
/// 只看真实号码(patient.phoneVerified=true,外部对照表核实过的)。query 串传 'true'。
phoneVerified: z.preprocess(
(v) => (v === 'true' ? true : v === 'false' ? false : v),
......
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