Commit 01663a75 by luoqi

feat(web): 画像标签全量圈人(14维)+ 左栏认领按钮 + 切患者不再白闪

1. 画像标签筛选扩到全部枚举型标签(4→14 维):新增 年龄段9/性别2/家庭构成4/权益身份5/
   治疗史4/治疗敏感4/特殊关注4/时间偏好5/转介绍达人2/禁忌1;code中文逐一对齐各 extractor
   的 data 写入值。未收录并注明原因:获客渠道(取值随宿主非稳定枚举)、折扣锚点(数值型)。
   后端零改动(字典驱动,plan.service 通吃)。
2. 左栏行 hover 出「认领」(仅 待认领+未分配)→ assign 给自己 → patchItem 原地改"进行中"
   (不重拉、不丢滚动/筛选)+ toast;usePatientPicker 加 patchItem。
3. 切患者白闪修复 — 保留 URL 路由(可刷新/分享/回退),loader 加 stale-while-loading:
   模块级缓存上一份聚合,加载期旧详情垫底(60% 透明)+ "切换中…"小罩,新数据到无缝替换。

web tsc 0 + Next 生产构建过。仅本地,未部署。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 4d04c361
......@@ -42,10 +42,17 @@ export default function PlanDetailRoutePage({ params }: { params: Promise<{ plan
);
}
// 模块级缓存上一次 ready 的聚合数据:切患者时 page 会整体重挂(state 全丢),
// 用它做 stale-while-loading —— 旧详情垫底 + 半透明加载罩,消掉整页白闪。
let lastAggregateCache: { planId: string; data: Parameters<typeof adaptData>[0] } | null = null;
function PlanDetailLoader({ planId }: { planId: string }) {
const { state, refresh } = usePlanAggregate(planId);
const dictionary = useAuthStore((s) => s.user?.dictionary);
const router = useRouter();
if (state.status === 'ready') {
lastAggregateCache = { planId, data: state.data };
}
// W4 末:单患者刷新后 plan 可能已被 supersede / abandoned(scenario 不再命中)
// → /full 接口返回 PLAN_NOT_FOUND → 这里捕获后 toast + 跳列表(避免用户卡在 error 页)
......@@ -68,6 +75,22 @@ function PlanDetailLoader({ planId }: { planId: string }) {
}, [redirectTo, router]);
if (state.status === 'loading' || state.status === 'idle') {
// 有上一份详情 → 垫底显示 + 加载罩(切患者不白闪,体感 SPA);首次进入仍是居中提示
if (lastAggregateCache) {
return (
<div className="relative h-full">
<div className="pointer-events-none h-full opacity-60">
<PlanDetailApp data={adaptData(lastAggregateCache.data, dictionary)} onRefreshAggregate={refresh} />
</div>
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/30">
<div className="flex items-center gap-2 rounded-full bg-white px-3 py-1.5 text-[12px] text-slate-500 shadow-md ring-1 ring-slate-200">
<span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-teal-500 border-t-transparent" />
切换中…
</div>
</div>
</div>
);
}
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
加载召回任务详情…
......
......@@ -2,6 +2,8 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { plansApi } from './plans-api';
import { ChevronDown, Filter, Loader2, PhoneCall, Search, X } from 'lucide-react';
import { PERSONA_TAG_FILTER_DIMS, Permission, type PlanListItem } from '@pac/types';
import { cn, formatGender } from '@/lib/utils';
......@@ -62,7 +64,20 @@ export function PatientPickerRail({ currentPlanId }: { currentPlanId: string })
}),
[view, keyword, sort, realPhoneOnly, clinics, tags],
);
const { items, total, loading, hasMore, error, loadMore } = usePatientPicker(filters);
const { items, total, loading, hasMore, error, loadMore, patchItem } = usePatientPicker(filters);
// 认领:assign 给自己 → 原地把行改成"进行中"(不重拉,保滚动/筛选)
const claim = async (planId: string, name: string) => {
const sub = user?.sub;
if (!sub) return;
try {
await plansApi.assign(planId, sub);
patchItem(planId, { status: 'assigned', assigneeUserId: sub });
toast.success(`已认领 ${name}`);
} catch (err) {
toast.error(err instanceof Error ? err.message : '认领失败');
}
};
// 触底加载
const listRef = useRef<HTMLDivElement>(null);
......@@ -178,6 +193,11 @@ export function PatientPickerRail({ currentPlanId }: { currentPlanId: string })
active={p.id === currentPlanId}
clinicName={p.targetClinicId ? String(clinicDict?.[p.targetClinicId] ?? '') : ''}
onClick={() => router.push(`/plans/${p.id}`)}
onClaim={
p.status === 'active' && !p.assigneeUserId
? () => void claim(p.id, p.patient.name ?? '')
: undefined
}
/>
))}
{loading && (
......@@ -230,7 +250,7 @@ function PersonaTagFilter({
</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">
清空
......@@ -374,19 +394,23 @@ function PatientRow({
active,
clinicName,
onClick,
onClaim,
}: {
plan: PlanListItem;
active: boolean;
clinicName: string;
onClick: () => void;
onClaim?: () => void;
}) {
const breakdown = (p.reasons[0]?.breakdown as { priority?: PriorityBreakdown } | null | undefined)?.priority;
return (
<button
type="button"
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => e.key === 'Enter' && onClick()}
className={cn(
'block w-full border-b border-slate-100 px-3 py-2 text-left transition-colors',
'group block w-full cursor-pointer border-b border-slate-100 px-3 py-2 text-left transition-colors',
active ? 'bg-teal-50/70 ring-1 ring-inset ring-teal-200' : 'hover:bg-slate-50',
)}
>
......@@ -420,8 +444,26 @@ function PatientRow({
</span>
)}
<span className="ml-auto" />
<StatusPill status={p.status} />
{onClaim ? (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onClaim();
}}
className="hidden flex-none rounded-md bg-teal-600 px-2 py-0.5 text-[10.5px] font-medium text-white hover:bg-teal-700 group-hover:inline-block"
>
认领
</button>
<span className="group-hover:hidden">
<StatusPill status={p.status} />
</span>
</>
) : (
<StatusPill status={p.status} />
)}
</div>
</button>
</div>
);
}
......@@ -26,6 +26,8 @@ export interface UsePatientPicker {
error: string | null;
loadMore: () => void;
refresh: () => void;
/** 原地更新一条(如认领后改状态),不重拉、不丢滚动位置 */
patchItem: (id: string, patch: Partial<PlanListItem>) => void;
}
/**
......@@ -95,5 +97,9 @@ export function usePatientPicker(filters: PickerFilters): UsePatientPicker {
void fetchPage(1);
}, [fetchPage]);
return { items, total, loading, hasMore, error, loadMore, refresh };
const patchItem = useCallback((id: string, patch: Partial<PlanListItem>) => {
setItems((prev) => prev.map((x) => (x.id === id ? { ...x, ...patch } : x)));
}, []);
return { items, total, loading, hasMore, error, loadMore, refresh, patchItem };
}
......@@ -3,6 +3,7 @@
*
* 与 PERSONA_FEATURE_SPECS(标签卡全集)的关系:这里是**精选的可筛子集**,
* 每项声明它在 persona_features.data 里的结构化取值位置(dataPath)+ code↔中文。
* 未收录:acquisition_channel(取值随宿主,非稳定枚举)、discount_anchor(数值型非标签)。
* code 必须与对应 feature extractor 写入 data 的值一致(rfm.feature.ts 等);
* 改 extractor 枚举时同步改这里(CI 不强校验,靠 PR review)。
*
......@@ -67,6 +68,120 @@ export const PERSONA_TAG_FILTER_DIMS: PersonaTagFilterDim[] = [
],
},
{
key: 'age_bracket',
nameZh: '年龄段',
dataPath: 'bracket',
options: [
{ value: 'infant', zh: '婴幼儿' },
{ value: 'preschool', zh: '学龄前' },
{ value: 'mixed_dentition', zh: '替牙期' },
{ value: 'adolescent', zh: '青少年' },
{ value: 'youth', zh: '青年' },
{ value: 'young_adult', zh: '中青年' },
{ value: 'middle_aged', zh: '中年' },
{ value: 'pre_senior', zh: '中老年' },
{ value: 'senior', zh: '老年' },
],
},
{
key: 'gender',
nameZh: '性别',
dataPath: 'gender',
options: [
{ value: 'male', zh: '男' },
{ value: 'female', zh: '女' },
],
},
{
key: 'family_structure',
nameZh: '家庭构成',
dataPath: 'structure',
options: [
{ value: 'multigen', zh: '多代之家' },
{ value: 'nuclear', zh: '多口之家' },
{ value: 'couple', zh: '两口之家' },
{ value: 'single', zh: '单身家庭' },
],
},
{
key: 'entitlement_status',
nameZh: '权益身份',
dataPath: 'types',
isArray: true,
options: [
{ value: 'high_insurance', zh: '高端保险直付' },
{ value: 'bank_vip', zh: '银行私行权益' },
{ value: 'stored_value', zh: '储值会员' },
{ value: 'pedo_member', zh: '儿牙会员' },
{ value: 'medical', zh: '医保客户' },
],
},
{
key: 'treatment_history',
nameZh: '治疗史',
dataPath: 'types',
isArray: true,
options: [
{ value: 'implant_history', zh: '种植史' },
{ value: 'ortho_history', zh: '正畸史' },
{ value: 'prostho_history', zh: '修复史' },
{ value: 'perio_history', zh: '牙周治疗史' },
],
},
{
key: 'treatment_sensitivity',
nameZh: '治疗敏感',
dataPath: 'types',
isArray: true,
options: [
{ value: 'dental_fear', zh: '看牙恐惧' },
{ value: 'needle_faint', zh: '晕针' },
{ value: 'blood_faint', zh: '晕血' },
{ value: 'claustrophobia', zh: '密闭恐惧' },
],
},
{
key: 'special_attention',
nameZh: '特殊关注',
dataPath: 'types',
isArray: true,
options: [
{ value: 'frequent_no_show', zh: '屡次爽约' },
{ value: 'often_late', zh: '经常迟到' },
{ value: 'do_not_disturb', zh: '免打扰' },
{ value: 'cannot_wait', zh: '不可等候' },
],
},
{
key: 'time_preference',
nameZh: '时间偏好',
dataPath: 'types',
isArray: true,
options: [
{ value: 'weekday', zh: '工作日' },
{ value: 'weekend', zh: '周末' },
{ value: 'morning', zh: '上午' },
{ value: 'afternoon', zh: '下午' },
{ value: 'evening', zh: '晚间' },
],
},
{
key: 'referral_champion',
nameZh: '转介绍达人',
dataPath: 'type',
options: [
{ value: 'family', zh: '家庭型' },
{ value: 'social', zh: '社交型' },
],
},
{
key: 'contraindication',
nameZh: '禁忌',
dataPath: 'types',
isArray: true,
options: [{ value: 'implant_age', zh: '种植年龄禁忌' }],
},
{
key: 'potential_treatment',
nameZh: '潜在治疗',
dataPath: 'types',
......
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