Commit 4d04c361 by luoqi

feat(web): 工作台布局改上下(header 全宽到最左)+ 左栏行/筛选五处打磨

按反馈:
1. 上下大布局:详情 TopBar 经 createPortal 渲到 plans/layout 顶部全宽槽
   (HeaderSlotPortal;无槽独立打开时原位渲染兜底)→ header 延伸到最左,左栏在其下;
   PlanDetailApp 根 h-screen→h-full,详情页包裹补高度链。
2. 左栏行加性别(formatGender,与列表页同源)+ 诊所名(token 字典翻译)。
3. 加诊所筛选(popover 多选 → 服务端 targetClinicIds,与列表页同款交互)。
4. 优先级展示对齐列表页:五格色条 + 10 分制数值 + PriorityHover 算分明细。
5. 行内去掉场景标签;状态改彩色 pill(待认领/进行中/已完成/已放弃,STATUS_META 同款)。

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 21645e8e
...@@ -69,7 +69,7 @@ function PlanDetailLoader({ planId }: { planId: string }) { ...@@ -69,7 +69,7 @@ function PlanDetailLoader({ planId }: { planId: string }) {
if (state.status === 'loading' || state.status === 'idle') { if (state.status === 'loading' || state.status === 'idle') {
return ( return (
<div className="flex h-screen items-center justify-center text-sm text-muted-foreground"> <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
加载召回任务详情… 加载召回任务详情…
</div> </div>
); );
...@@ -79,7 +79,7 @@ function PlanDetailLoader({ planId }: { planId: string }) { ...@@ -79,7 +79,7 @@ function PlanDetailLoader({ planId }: { planId: string }) {
// PLAN_NOT_FOUND 已 useEffect 跳走,这里只渲染过场骨架(避免一闪而过的 error UI 闪烁) // PLAN_NOT_FOUND 已 useEffect 跳走,这里只渲染过场骨架(避免一闪而过的 error UI 闪烁)
if (state.code === ApiCode.PLAN_NOT_FOUND) { if (state.code === ApiCode.PLAN_NOT_FOUND) {
return ( return (
<div className="flex h-screen items-center justify-center text-sm text-muted-foreground"> <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
召回任务已不存在,返回召回池… 召回任务已不存在,返回召回池…
</div> </div>
); );
...@@ -109,7 +109,7 @@ function PlanDetailLoader({ planId }: { planId: string }) { ...@@ -109,7 +109,7 @@ function PlanDetailLoader({ planId }: { planId: string }) {
// supersede 跳转中:不渲染老版快照,显示过场(避免陈旧数据一闪) // supersede 跳转中:不渲染老版快照,显示过场(避免陈旧数据一闪)
if (redirectTo) { if (redirectTo) {
return ( return (
<div className="flex h-screen items-center justify-center text-sm text-muted-foreground"> <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
该任务已更新,正在跳转到最新版本… 该任务已更新,正在跳转到最新版本…
</div> </div>
); );
...@@ -117,7 +117,7 @@ function PlanDetailLoader({ planId }: { planId: string }) { ...@@ -117,7 +117,7 @@ function PlanDetailLoader({ planId }: { planId: string }) {
// 直接渲染详情;左侧"选患者"固定栏由 plans/layout.tsx 提供(原 TaskDrawer 抽屉已被其取代) // 直接渲染详情;左侧"选患者"固定栏由 plans/layout.tsx 提供(原 TaskDrawer 抽屉已被其取代)
return ( return (
<div className="bg-slate-50"> <div className="h-full bg-slate-50">
<PlanDetailApp data={adaptData(state.data, dictionary)} onRefreshAggregate={refresh} /> <PlanDetailApp data={adaptData(state.data, dictionary)} onRefreshAggregate={refresh} />
</div> </div>
); );
......
...@@ -3,24 +3,29 @@ ...@@ -3,24 +3,29 @@
import { useSelectedLayoutSegment } from 'next/navigation'; import { useSelectedLayoutSegment } from 'next/navigation';
import { PatientPickerRail } from '@/components/plans/patient-picker-rail'; import { PatientPickerRail } from '@/components/plans/patient-picker-rail';
/** 详情 TopBar 经 portal 渲到这个槽 → header 延伸到最左(在左栏之上)。 */
export const PLAN_WORKSPACE_HEADER_SLOT = 'plan-workspace-header';
/** /**
* /plans 段布局 — 详情页的"一页式工作台"骨架。 * /plans 段布局 — 详情页"一页式工作台"骨架(上下大布局):
* * ┌ header(详情 TopBar portal 到此,全宽延伸到最左)┐
* 选中某个 plan(/plans/[planId])时:固定左栏(选患者列)+ 右侧详情(原三列)= 4 列。 * ├ 左栏选患者列 300px │ 详情内容(原三列) ┤
* 布局挂在动态段**之上** → 切患者(planId 变)只重渲染右侧 page,左栏不重挂: * 布局挂在动态段之上 → 切患者只重渲染右侧,左栏筛选/分页/滚动全保留。
* 筛选条件 / 已加载分页 / 滚动位置全部保留(这正是把列表并进详情页的意义)。 * /plans 列表页(无 planId 段)不渲染本骨架。
* /plans 列表页本身(无 planId 段)不渲染左栏,维持原样(该页面后续弃用)。
*/ */
export default function PlansLayout({ children }: { children: React.ReactNode }) { export default function PlansLayout({ children }: { children: React.ReactNode }) {
// 下一层动态段的值:/plans → null;/plans/<planId> → planId
const planId = useSelectedLayoutSegment(); const planId = useSelectedLayoutSegment();
if (!planId) return <>{children}</>; if (!planId) return <>{children}</>;
return ( return (
<div className="flex h-screen overflow-hidden bg-slate-50"> <div className="flex h-screen flex-col overflow-hidden bg-slate-50">
<PatientPickerRail currentPlanId={planId} /> {/* header 槽:PlanDetailApp 的 TopBar createPortal 进来(全宽) */}
<div className="min-w-0 flex-1 overflow-y-auto">{children}</div> <div id={PLAN_WORKSPACE_HEADER_SLOT} className="flex-none" />
<div className="flex min-h-0 flex-1">
<PatientPickerRail currentPlanId={planId} />
<div className="min-h-0 min-w-0 flex-1">{children}</div>
</div>
</div> </div>
); );
} }
'use client'; 'use client';
import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { RefreshCw, ChevronDown, ThumbsUp, ThumbsDown } from 'lucide-react'; import { RefreshCw, ChevronDown, ThumbsUp, ThumbsDown } from 'lucide-react';
import { import {
...@@ -289,18 +290,20 @@ export function PlanDetailApp({ ...@@ -289,18 +290,20 @@ export function PlanDetailApp({
return ( return (
<div <div
className="h-screen overflow-hidden bg-slate-50 text-slate-900 flex flex-col" className="h-full overflow-hidden bg-slate-50 text-slate-900 flex flex-col"
style={{ fontFamily: '"PingFang SC", "Noto Sans CJK SC", system-ui, sans-serif' }} style={{ fontFamily: '"PingFang SC", "Noto Sans CJK SC", system-ui, sans-serif' }}
> >
{banner} {banner}
<TopBar <HeaderSlotPortal>
plan={plan} <TopBar
reason={focusedReason ?? reasons[0]!} plan={plan}
patientId={patient.id} reason={focusedReason ?? reasons[0]!}
onRefreshAggregate={onRefreshAggregate} patientId={patient.id}
showToast={showToast} onRefreshAggregate={onRefreshAggregate}
fmtRel={fmtRel} showToast={showToast}
/> fmtRel={fmtRel}
/>
</HeaderSlotPortal>
{/* ⭐ 响应式 — xl≥1280 用 3 列 grid;<xl 用 shadcn Tabs(原因/话术/操作)。 {/* ⭐ 响应式 — xl≥1280 用 3 列 grid;<xl 用 shadcn Tabs(原因/话术/操作)。
抽出 leftPane/centerPane/rightPane 单实例,grid 和 tabs 共用,避免 state desync */} 抽出 leftPane/centerPane/rightPane 单实例,grid 和 tabs 共用,避免 state desync */}
...@@ -696,6 +699,15 @@ function RecallFeedbackControl({ ...@@ -696,6 +699,15 @@ function RecallFeedbackControl({
); );
} }
/** TopBar 渲染容器:工作台布局下 portal 到全宽 header 槽(延伸到最左);无槽(独立打开)原位渲染。 */
function HeaderSlotPortal({ children }: { children: React.ReactNode }) {
const [slot, setSlot] = useState<HTMLElement | null>(null);
useEffect(() => {
setSlot(document.getElementById('plan-workspace-header'));
}, []);
return slot ? createPortal(children, slot) : <>{children}</>;
}
function TopBar({ function TopBar({
plan, plan,
reason, reason,
......
...@@ -2,10 +2,12 @@ ...@@ -2,10 +2,12 @@
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 { 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 } from '@/lib/utils'; import { cn, formatGender } from '@/lib/utils';
import { useHasPermission } from '@/hooks/use-permission'; import { useHasPermission } from '@/hooks/use-permission';
import { useAuthStore } from '@/stores/auth-store';
import { PriorityHover, type PriorityBreakdown } from '@/components/priority-hover';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { usePatientPicker, type PickerFilters } from './use-patient-picker'; import { usePatientPicker, type PickerFilters } from './use-patient-picker';
...@@ -34,8 +36,16 @@ export function PatientPickerRail({ currentPlanId }: { currentPlanId: string }) ...@@ -34,8 +36,16 @@ export function PatientPickerRail({ currentPlanId }: { currentPlanId: string })
const [keyword, setKeyword] = useState(''); // 防抖后的值(真正上服务端) const [keyword, setKeyword] = useState(''); // 防抖后的值(真正上服务端)
const [sort, setSort] = useState<PickerFilters['sort']>('priority_desc'); const [sort, setSort] = useState<PickerFilters['sort']>('priority_desc');
const [realPhoneOnly, setRealPhoneOnly] = useState(false); const [realPhoneOnly, setRealPhoneOnly] = useState(false);
const [clinics, setClinics] = useState<string[]>([]);
const [tags, setTags] = useState<Set<string>>(new Set()); // "key:value" const [tags, setTags] = useState<Set<string>>(new Set()); // "key:value"
const user = useAuthStore((st) => st.user);
const clinicDict = user?.dictionary?.clinics;
const clinicOptions = useMemo(
() => (user?.clinicIds ?? []).map((id) => ({ id, name: String(clinicDict?.[id] ?? id) })),
[user?.clinicIds, clinicDict],
);
useEffect(() => { useEffect(() => {
const t = setTimeout(() => setKeyword(search.trim()), 300); const t = setTimeout(() => setKeyword(search.trim()), 300);
return () => clearTimeout(t); return () => clearTimeout(t);
...@@ -47,9 +57,10 @@ export function PatientPickerRail({ currentPlanId }: { currentPlanId: string }) ...@@ -47,9 +57,10 @@ export function PatientPickerRail({ currentPlanId }: { currentPlanId: string })
keyword: keyword || undefined, keyword: keyword || undefined,
sort, sort,
phoneVerified: realPhoneOnly ? true : undefined, phoneVerified: realPhoneOnly ? true : undefined,
targetClinicIds: clinics.length ? clinics : undefined,
personaTags: tags.size ? [...tags].join(',') : undefined, personaTags: tags.size ? [...tags].join(',') : undefined,
}), }),
[view, keyword, sort, realPhoneOnly, tags], [view, keyword, sort, realPhoneOnly, clinics, tags],
); );
const { items, total, loading, hasMore, error, loadMore } = usePatientPicker(filters); const { items, total, loading, hasMore, error, loadMore } = usePatientPicker(filters);
...@@ -130,6 +141,9 @@ export function PatientPickerRail({ currentPlanId }: { currentPlanId: string }) ...@@ -130,6 +141,9 @@ export function PatientPickerRail({ currentPlanId }: { currentPlanId: string })
</button> </button>
<PersonaTagFilter selected={tags} onToggle={toggleTag} onClear={() => setTags(new Set())} /> <PersonaTagFilter selected={tags} onToggle={toggleTag} onClear={() => setTags(new Set())} />
</div> </div>
{clinicOptions.length > 1 && (
<RailClinicFilter options={clinicOptions} selected={clinics} onChange={setClinics} />
)}
{/* 已选标签 chips */} {/* 已选标签 chips */}
{tags.size > 0 && ( {tags.size > 0 && (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
...@@ -162,6 +176,7 @@ export function PatientPickerRail({ currentPlanId }: { currentPlanId: string }) ...@@ -162,6 +176,7 @@ export function PatientPickerRail({ currentPlanId }: { currentPlanId: string })
key={p.id} key={p.id}
plan={p} plan={p}
active={p.id === currentPlanId} active={p.id === currentPlanId}
clinicName={p.targetClinicId ? String(clinicDict?.[p.targetClinicId] ?? '') : ''}
onClick={() => router.push(`/plans/${p.id}`)} onClick={() => router.push(`/plans/${p.id}`)}
/> />
))} ))}
...@@ -257,17 +272,115 @@ function PersonaTagFilter({ ...@@ -257,17 +272,115 @@ function PersonaTagFilter({
); );
} }
// ── 诊所筛选(popover 多选,样式与列表页 ClinicFilter 同源紧凑版)──────
function RailClinicFilter({
options,
selected,
onChange,
}: {
options: Array<{ id: string; name: string }>;
selected: string[];
onChange: (ids: string[]) => void;
}) {
const sel = new Set(selected);
const toggle = (id: string) => {
const next = new Set(sel);
if (next.has(id)) next.delete(id);
else next.add(id);
onChange([...next]);
};
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
'inline-flex h-6 w-full items-center justify-between rounded-md border px-1.5 text-[11px] transition-colors',
selected.length
? 'border-teal-300 bg-teal-50 font-medium text-teal-700'
: 'border-slate-200 bg-white text-slate-500 hover:bg-slate-50',
)}
>
<span className="truncate">
{selected.length === 0
? '全部诊所'
: selected.map((id) => options.find((o) => o.id === id)?.name ?? id).join('、')}
</span>
<ChevronDown className="h-3 w-3 flex-none text-slate-400" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="max-h-72 w-56 overflow-y-auto p-1.5">
<div className="mb-1 flex items-center justify-between border-b border-slate-100 px-1.5 py-1">
<span className="text-[10.5px] text-slate-400">按诊所筛选(可多选)</span>
{selected.length > 0 && (
<button type="button" onClick={() => onChange([])} className="text-[10.5px] text-teal-600 hover:underline">
清空
</button>
)}
</div>
{options.map((o) => (
<label key={o.id} className="flex cursor-pointer items-center gap-2 rounded px-1.5 py-1.5 hover:bg-slate-50">
<input
type="checkbox"
className="h-3.5 w-3.5 accent-teal-600"
checked={sel.has(o.id)}
onChange={() => toggle(o.id)}
/>
<span className="truncate text-[12px] text-slate-700">{o.name}</span>
</label>
))}
</PopoverContent>
</Popover>
);
}
// ── 状态 pill(与列表页 STATUS_META 同款)─────────────────────
const STATUS_META: Record<string, { label: string; tone: string }> = {
active: { label: '待认领', tone: 'bg-sky-100 text-sky-700' },
assigned: { label: '进行中', tone: 'bg-amber-100 text-amber-700' },
completed: { label: '已完成', tone: 'bg-emerald-100 text-emerald-700' },
abandoned: { label: '已放弃', tone: 'bg-rose-100 text-rose-700' },
superseded: { label: '已替代', tone: 'bg-slate-100 text-slate-600' },
};
function StatusPill({ status }: { status: string }) {
const m = STATUS_META[status] ?? { label: status, tone: 'bg-slate-100 text-slate-700' };
return <span className={cn('inline-block flex-none rounded px-1.5 py-0.5 text-[10px] font-medium', m.tone)}>{m.label}</span>;
}
// ── 优先级(与列表页 PriorityBar 同款:五格条 + 10 分制)────────
function PriorityBar({ score, raw }: { score: number; raw?: number }) {
const pct = Math.max(0, Math.min(1, score / 100));
const filled = Math.max(1, Math.round(pct * 5));
const colors = ['bg-emerald-400', 'bg-emerald-500', 'bg-amber-400', 'bg-amber-500', 'bg-rose-500'];
const labelTone =
pct >= 0.6 ? (pct >= 0.8 ? 'text-rose-700' : 'text-amber-700') : pct >= 0.2 ? 'text-emerald-700' : 'text-slate-500';
const disp = (raw ?? score / 10).toFixed(2);
return (
<span className="inline-flex flex-none items-center gap-1" title={`优先级 ${disp} / 10`}>
<span className="inline-flex items-center gap-0.5">
{Array.from({ length: 5 }).map((_, i) => (
<span key={i} className={cn('h-1.5 w-[6px] rounded-sm', i < filled ? colors[i] : 'bg-slate-200')} />
))}
</span>
<span className={cn('text-[10.5px] font-semibold tabular-nums', labelTone)}>{disp}</span>
</span>
);
}
// ── 患者行(紧凑单列卡)──────────────────────────────────── // ── 患者行(紧凑单列卡)────────────────────────────────────
function PatientRow({ function PatientRow({
plan: p, plan: p,
active, active,
clinicName,
onClick, onClick,
}: { }: {
plan: PlanListItem; plan: PlanListItem;
active: boolean; active: boolean;
clinicName: string;
onClick: () => void; onClick: () => void;
}) { }) {
const score = Math.round(p.priorityScore); const breakdown = (p.reasons[0]?.breakdown as { priority?: PriorityBreakdown } | null | undefined)?.priority;
return ( return (
<button <button
type="button" type="button"
...@@ -281,36 +394,33 @@ function PatientRow({ ...@@ -281,36 +394,33 @@ function PatientRow({
<span className={cn('truncate text-[13px] font-semibold', active ? 'text-teal-800' : 'text-slate-900')}> <span className={cn('truncate text-[13px] font-semibold', active ? 'text-teal-800' : 'text-slate-900')}>
{p.patient.name ?? p.patient.nameMasked ?? '—'} {p.patient.name ?? p.patient.nameMasked ?? '—'}
</span> </span>
<span className="flex-none text-[10.5px] text-slate-400 tabular-nums"> <span className="flex-none text-[10.5px] tabular-nums text-slate-500">
{p.patient.gender === 'male' ? '男' : p.patient.gender === 'female' ? '女' : ''} {formatGender(p.patient.gender)} · {p.patient.age ?? '?'}
{p.patient.age != null ? `·${p.patient.age}` : ''}
</span> </span>
{p.patient.phoneVerified && ( {p.patient.phoneVerified && (
<span className="flex-none rounded bg-teal-50 px-1 text-[9.5px] font-medium leading-4 text-teal-700 ring-1 ring-inset ring-teal-200"> <span className="flex-none rounded bg-teal-50 px-1 text-[9.5px] font-medium leading-4 text-teal-700 ring-1 ring-inset ring-teal-200">
</span> </span>
)} )}
<span <span className="ml-auto flex-none">
className={cn( <PriorityHover score={p.priorityScore} breakdown={breakdown}>
'ml-auto flex-none text-[13px] font-bold tabular-nums', <span className="cursor-help">
score >= 80 ? 'text-rose-600' : score >= 60 ? 'text-amber-600' : 'text-slate-400', <PriorityBar score={p.priorityScore} raw={breakdown?.raw} />
)} </span>
> </PriorityHover>
{score}
</span> </span>
</div> </div>
<div className="mt-0.5 flex items-center gap-1.5"> <div className="mt-0.5 flex items-center gap-1.5">
<span className="flex-none rounded bg-slate-100 px-1.5 py-0.5 text-[10px] text-slate-600"> <span className="truncate font-mono text-[10.5px] tabular-nums text-slate-400">
{p.scenarioLabel}
</span>
<span className="truncate font-mono text-[10.5px] text-slate-400 tabular-nums">
{p.patient.phoneMasked ?? ''} {p.patient.phoneMasked ?? ''}
</span> </span>
{p.status !== 'active' && ( {clinicName && (
<span className="ml-auto flex-none text-[10px] text-slate-400"> <span className="truncate text-[10px] text-slate-400" title={clinicName}>
{p.status === 'assigned' ? '进行中' : p.status === 'completed' ? '已完成' : p.status === 'abandoned' ? '已放弃' : p.status} · {clinicName}
</span> </span>
)} )}
<span className="ml-auto" />
<StatusPill status={p.status} />
</div> </div>
</button> </button>
); );
......
...@@ -12,6 +12,8 @@ export interface PickerFilters { ...@@ -12,6 +12,8 @@ export interface PickerFilters {
keyword?: string; keyword?: string;
sort: 'priority_desc' | 'priority_asc' | 'created_desc'; sort: 'priority_desc' | 'priority_asc' | 'created_desc';
phoneVerified?: true; phoneVerified?: true;
/** 诊所多选(target_clinic_id IN) */
targetClinicIds?: string[];
/** "key:value" 逗号串(PERSONA_TAG_FILTER_DIMS 维度) */ /** "key:value" 逗号串(PERSONA_TAG_FILTER_DIMS 维度) */
personaTags?: string; personaTags?: string;
} }
...@@ -52,6 +54,7 @@ export function usePatientPicker(filters: PickerFilters): UsePatientPicker { ...@@ -52,6 +54,7 @@ export function usePatientPicker(filters: PickerFilters): UsePatientPicker {
keyword: filters.keyword || undefined, keyword: filters.keyword || undefined,
sort: filters.sort, sort: filters.sort,
phoneVerified: filters.phoneVerified, phoneVerified: filters.phoneVerified,
targetClinicIds: filters.targetClinicIds?.length ? filters.targetClinicIds : undefined,
personaTags: filters.personaTags || undefined, personaTags: filters.personaTags || undefined,
page: p, page: p,
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
......
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