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 }) {
if (state.status === 'loading' || state.status === 'idle') {
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>
);
......@@ -79,7 +79,7 @@ function PlanDetailLoader({ planId }: { planId: string }) {
// PLAN_NOT_FOUND 已 useEffect 跳走,这里只渲染过场骨架(避免一闪而过的 error UI 闪烁)
if (state.code === ApiCode.PLAN_NOT_FOUND) {
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>
);
......@@ -109,7 +109,7 @@ function PlanDetailLoader({ planId }: { planId: string }) {
// supersede 跳转中:不渲染老版快照,显示过场(避免陈旧数据一闪)
if (redirectTo) {
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>
);
......@@ -117,7 +117,7 @@ function PlanDetailLoader({ planId }: { planId: string }) {
// 直接渲染详情;左侧"选患者"固定栏由 plans/layout.tsx 提供(原 TaskDrawer 抽屉已被其取代)
return (
<div className="bg-slate-50">
<div className="h-full bg-slate-50">
<PlanDetailApp data={adaptData(state.data, dictionary)} onRefreshAggregate={refresh} />
</div>
);
......
......@@ -3,24 +3,29 @@
import { useSelectedLayoutSegment } from 'next/navigation';
import { PatientPickerRail } from '@/components/plans/patient-picker-rail';
/** 详情 TopBar 经 portal 渲到这个槽 → header 延伸到最左(在左栏之上)。 */
export const PLAN_WORKSPACE_HEADER_SLOT = 'plan-workspace-header';
/**
* /plans 段布局 — 详情页的"一页式工作台"骨架。
*
* 选中某个 plan(/plans/[planId])时:固定左栏(选患者列)+ 右侧详情(原三列)= 4 列。
* 布局挂在动态段**之上** → 切患者(planId 变)只重渲染右侧 page,左栏不重挂:
* 筛选条件 / 已加载分页 / 滚动位置全部保留(这正是把列表并进详情页的意义)。
* /plans 列表页本身(无 planId 段)不渲染左栏,维持原样(该页面后续弃用)。
* /plans 段布局 — 详情页"一页式工作台"骨架(上下大布局):
* ┌ header(详情 TopBar portal 到此,全宽延伸到最左)┐
* ├ 左栏选患者列 300px │ 详情内容(原三列) ┤
* 布局挂在动态段之上 → 切患者只重渲染右侧,左栏筛选/分页/滚动全保留。
* /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">
<div className="flex h-screen flex-col overflow-hidden bg-slate-50">
{/* header 槽:PlanDetailApp 的 TopBar createPortal 进来(全宽) */}
<div id={PLAN_WORKSPACE_HEADER_SLOT} className="flex-none" />
<div className="flex min-h-0 flex-1">
<PatientPickerRail currentPlanId={planId} />
<div className="min-w-0 flex-1 overflow-y-auto">{children}</div>
<div className="min-h-0 min-w-0 flex-1">{children}</div>
</div>
</div>
);
}
'use client';
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { toast } from 'sonner';
import { RefreshCw, ChevronDown, ThumbsUp, ThumbsDown } from 'lucide-react';
import {
......@@ -289,10 +290,11 @@ export function PlanDetailApp({
return (
<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' }}
>
{banner}
<HeaderSlotPortal>
<TopBar
plan={plan}
reason={focusedReason ?? reasons[0]!}
......@@ -301,6 +303,7 @@ export function PlanDetailApp({
showToast={showToast}
fmtRel={fmtRel}
/>
</HeaderSlotPortal>
{/* ⭐ 响应式 — xl≥1280 用 3 列 grid;<xl 用 shadcn Tabs(原因/话术/操作)。
抽出 leftPane/centerPane/rightPane 单实例,grid 和 tabs 共用,避免 state desync */}
......@@ -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({
plan,
reason,
......
......@@ -2,10 +2,12 @@
import { useEffect, useMemo, useRef, useState } from 'react';
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 { cn } from '@/lib/utils';
import { cn, formatGender } from '@/lib/utils';
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 { usePatientPicker, type PickerFilters } from './use-patient-picker';
......@@ -34,8 +36,16 @@ export function PatientPickerRail({ currentPlanId }: { currentPlanId: string })
const [keyword, setKeyword] = useState(''); // 防抖后的值(真正上服务端)
const [sort, setSort] = useState<PickerFilters['sort']>('priority_desc');
const [realPhoneOnly, setRealPhoneOnly] = useState(false);
const [clinics, setClinics] = useState<string[]>([]);
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(() => {
const t = setTimeout(() => setKeyword(search.trim()), 300);
return () => clearTimeout(t);
......@@ -47,9 +57,10 @@ export function PatientPickerRail({ currentPlanId }: { currentPlanId: string })
keyword: keyword || undefined,
sort,
phoneVerified: realPhoneOnly ? true : undefined,
targetClinicIds: clinics.length ? clinics : 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);
......@@ -130,6 +141,9 @@ export function PatientPickerRail({ currentPlanId }: { currentPlanId: string })
</button>
<PersonaTagFilter selected={tags} onToggle={toggleTag} onClear={() => setTags(new Set())} />
</div>
{clinicOptions.length > 1 && (
<RailClinicFilter options={clinicOptions} selected={clinics} onChange={setClinics} />
)}
{/* 已选标签 chips */}
{tags.size > 0 && (
<div className="flex flex-wrap gap-1">
......@@ -162,6 +176,7 @@ export function PatientPickerRail({ currentPlanId }: { currentPlanId: string })
key={p.id}
plan={p}
active={p.id === currentPlanId}
clinicName={p.targetClinicId ? String(clinicDict?.[p.targetClinicId] ?? '') : ''}
onClick={() => router.push(`/plans/${p.id}`)}
/>
))}
......@@ -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({
plan: p,
active,
clinicName,
onClick,
}: {
plan: PlanListItem;
active: boolean;
clinicName: string;
onClick: () => void;
}) {
const score = Math.round(p.priorityScore);
const breakdown = (p.reasons[0]?.breakdown as { priority?: PriorityBreakdown } | null | undefined)?.priority;
return (
<button
type="button"
......@@ -281,36 +394,33 @@ function PatientRow({
<span className={cn('truncate text-[13px] font-semibold', active ? 'text-teal-800' : 'text-slate-900')}>
{p.patient.name ?? p.patient.nameMasked ?? '—'}
</span>
<span className="flex-none text-[10.5px] text-slate-400 tabular-nums">
{p.patient.gender === 'male' ? '男' : p.patient.gender === 'female' ? '女' : ''}
{p.patient.age != null ? `·${p.patient.age}` : ''}
<span className="flex-none text-[10.5px] tabular-nums text-slate-500">
{formatGender(p.patient.gender)} · {p.patient.age ?? '?'}
</span>
{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>
)}
<span
className={cn(
'ml-auto flex-none text-[13px] font-bold tabular-nums',
score >= 80 ? 'text-rose-600' : score >= 60 ? 'text-amber-600' : 'text-slate-400',
)}
>
{score}
<span className="ml-auto flex-none">
<PriorityHover score={p.priorityScore} breakdown={breakdown}>
<span className="cursor-help">
<PriorityBar score={p.priorityScore} raw={breakdown?.raw} />
</span>
</PriorityHover>
</span>
</div>
<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">
{p.scenarioLabel}
</span>
<span className="truncate font-mono text-[10.5px] text-slate-400 tabular-nums">
<span className="truncate font-mono text-[10.5px] tabular-nums text-slate-400">
{p.patient.phoneMasked ?? ''}
</span>
{p.status !== 'active' && (
<span className="ml-auto flex-none text-[10px] text-slate-400">
{p.status === 'assigned' ? '进行中' : p.status === 'completed' ? '已完成' : p.status === 'abandoned' ? '已放弃' : p.status}
{clinicName && (
<span className="truncate text-[10px] text-slate-400" title={clinicName}>
· {clinicName}
</span>
)}
<span className="ml-auto" />
<StatusPill status={p.status} />
</div>
</button>
);
......
......@@ -12,6 +12,8 @@ export interface PickerFilters {
keyword?: string;
sort: 'priority_desc' | 'priority_asc' | 'created_desc';
phoneVerified?: true;
/** 诊所多选(target_clinic_id IN) */
targetClinicIds?: string[];
/** "key:value" 逗号串(PERSONA_TAG_FILTER_DIMS 维度) */
personaTags?: string;
}
......@@ -52,6 +54,7 @@ export function usePatientPicker(filters: PickerFilters): UsePatientPicker {
keyword: filters.keyword || undefined,
sort: filters.sort,
phoneVerified: filters.phoneVerified,
targetClinicIds: filters.targetClinicIds?.length ? filters.targetClinicIds : undefined,
personaTags: filters.personaTags || undefined,
page: p,
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