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">
{/* header 槽:PlanDetailApp 的 TopBar createPortal 进来(全宽) */}
<div id={PLAN_WORKSPACE_HEADER_SLOT} className="flex-none" />
<div className="flex min-h-0 flex-1">
<PatientPickerRail currentPlanId={planId} /> <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> </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,10 +290,11 @@ export function PlanDetailApp({ ...@@ -289,10 +290,11 @@ 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}
<HeaderSlotPortal>
<TopBar <TopBar
plan={plan} plan={plan}
reason={focusedReason ?? reasons[0]!} reason={focusedReason ?? reasons[0]!}
...@@ -301,6 +303,7 @@ export function PlanDetailApp({ ...@@ -301,6 +303,7 @@ export function PlanDetailApp({
showToast={showToast} showToast={showToast}
fmtRel={fmtRel} 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,
......
...@@ -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