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 ...@@ -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 }) { function PlanDetailLoader({ planId }: { planId: string }) {
const { state, refresh } = usePlanAggregate(planId); const { state, refresh } = usePlanAggregate(planId);
const dictionary = useAuthStore((s) => s.user?.dictionary); const dictionary = useAuthStore((s) => s.user?.dictionary);
const router = useRouter(); const router = useRouter();
if (state.status === 'ready') {
lastAggregateCache = { planId, data: state.data };
}
// W4 末:单患者刷新后 plan 可能已被 supersede / abandoned(scenario 不再命中) // W4 末:单患者刷新后 plan 可能已被 supersede / abandoned(scenario 不再命中)
// → /full 接口返回 PLAN_NOT_FOUND → 这里捕获后 toast + 跳列表(避免用户卡在 error 页) // → /full 接口返回 PLAN_NOT_FOUND → 这里捕获后 toast + 跳列表(避免用户卡在 error 页)
...@@ -68,6 +75,22 @@ function PlanDetailLoader({ planId }: { planId: string }) { ...@@ -68,6 +75,22 @@ function PlanDetailLoader({ planId }: { planId: string }) {
}, [redirectTo, router]); }, [redirectTo, router]);
if (state.status === 'loading' || state.status === 'idle') { 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 ( return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground"> <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
加载召回任务详情… 加载召回任务详情…
......
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useRouter } from 'next/navigation'; 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 { ChevronDown, Filter, Loader2, PhoneCall, Search, X } from 'lucide-react';
import { PERSONA_TAG_FILTER_DIMS, Permission, type PlanListItem } from '@pac/types'; import { PERSONA_TAG_FILTER_DIMS, Permission, type PlanListItem } from '@pac/types';
import { cn, formatGender } from '@/lib/utils'; import { cn, formatGender } from '@/lib/utils';
...@@ -62,7 +64,20 @@ export function PatientPickerRail({ currentPlanId }: { currentPlanId: string }) ...@@ -62,7 +64,20 @@ export function PatientPickerRail({ currentPlanId }: { currentPlanId: string })
}), }),
[view, keyword, sort, realPhoneOnly, clinics, tags], [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); const listRef = useRef<HTMLDivElement>(null);
...@@ -178,6 +193,11 @@ export function PatientPickerRail({ currentPlanId }: { currentPlanId: string }) ...@@ -178,6 +193,11 @@ export function PatientPickerRail({ currentPlanId }: { currentPlanId: string })
active={p.id === currentPlanId} active={p.id === currentPlanId}
clinicName={p.targetClinicId ? String(clinicDict?.[p.targetClinicId] ?? '') : ''} clinicName={p.targetClinicId ? String(clinicDict?.[p.targetClinicId] ?? '') : ''}
onClick={() => router.push(`/plans/${p.id}`)} onClick={() => router.push(`/plans/${p.id}`)}
onClaim={
p.status === 'active' && !p.assigneeUserId
? () => void claim(p.id, p.patient.name ?? '')
: undefined
}
/> />
))} ))}
{loading && ( {loading && (
...@@ -230,7 +250,7 @@ function PersonaTagFilter({ ...@@ -230,7 +250,7 @@ function PersonaTagFilter({
</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">
清空 清空
...@@ -374,19 +394,23 @@ function PatientRow({ ...@@ -374,19 +394,23 @@ function PatientRow({
active, active,
clinicName, clinicName,
onClick, onClick,
onClaim,
}: { }: {
plan: PlanListItem; plan: PlanListItem;
active: boolean; active: boolean;
clinicName: string; clinicName: string;
onClick: () => void; onClick: () => void;
onClaim?: () => void;
}) { }) {
const breakdown = (p.reasons[0]?.breakdown as { priority?: PriorityBreakdown } | null | undefined)?.priority; const breakdown = (p.reasons[0]?.breakdown as { priority?: PriorityBreakdown } | null | undefined)?.priority;
return ( return (
<button <div
type="button" role="button"
tabIndex={0}
onClick={onClick} onClick={onClick}
onKeyDown={(e) => e.key === 'Enter' && onClick()}
className={cn( 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', active ? 'bg-teal-50/70 ring-1 ring-inset ring-teal-200' : 'hover:bg-slate-50',
)} )}
> >
...@@ -420,8 +444,26 @@ function PatientRow({ ...@@ -420,8 +444,26 @@ function PatientRow({
</span> </span>
)} )}
<span className="ml-auto" /> <span className="ml-auto" />
{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} /> <StatusPill status={p.status} />
)}
</div>
</div> </div>
</button>
); );
} }
...@@ -26,6 +26,8 @@ export interface UsePatientPicker { ...@@ -26,6 +26,8 @@ export interface UsePatientPicker {
error: string | null; error: string | null;
loadMore: () => void; loadMore: () => void;
refresh: () => void; refresh: () => void;
/** 原地更新一条(如认领后改状态),不重拉、不丢滚动位置 */
patchItem: (id: string, patch: Partial<PlanListItem>) => void;
} }
/** /**
...@@ -95,5 +97,9 @@ export function usePatientPicker(filters: PickerFilters): UsePatientPicker { ...@@ -95,5 +97,9 @@ export function usePatientPicker(filters: PickerFilters): UsePatientPicker {
void fetchPage(1); void fetchPage(1);
}, [fetchPage]); }, [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 @@ ...@@ -3,6 +3,7 @@
* *
* 与 PERSONA_FEATURE_SPECS(标签卡全集)的关系:这里是**精选的可筛子集**, * 与 PERSONA_FEATURE_SPECS(标签卡全集)的关系:这里是**精选的可筛子集**,
* 每项声明它在 persona_features.data 里的结构化取值位置(dataPath)+ code↔中文。 * 每项声明它在 persona_features.data 里的结构化取值位置(dataPath)+ code↔中文。
* 未收录:acquisition_channel(取值随宿主,非稳定枚举)、discount_anchor(数值型非标签)。
* code 必须与对应 feature extractor 写入 data 的值一致(rfm.feature.ts 等); * code 必须与对应 feature extractor 写入 data 的值一致(rfm.feature.ts 等);
* 改 extractor 枚举时同步改这里(CI 不强校验,靠 PR review)。 * 改 extractor 枚举时同步改这里(CI 不强校验,靠 PR review)。
* *
...@@ -67,6 +68,120 @@ export const PERSONA_TAG_FILTER_DIMS: PersonaTagFilterDim[] = [ ...@@ -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', key: 'potential_treatment',
nameZh: '潜在治疗', nameZh: '潜在治疗',
dataPath: 'types', 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