Commit ee05257d by luoqi

feat(web): 工作台跨栏状态同步 + 小屏左栏抽屉化(左缘把手点击点)

1. 右侧操作 → 左栏实时同步:新增 plan-sync-store(zustand 事件总线);
   详情提交回写成功(submitExecution → planStatus)→ notify → 左栏 patchItem
   原地更新该行状态 pill(不重拉、不丢滚动/筛选)。
2. <xl 左栏收成固定抽屉:单实例 CSS 平移(不重挂 → 筛选/分页/滚动保留);
   左缘 teal 竖把手「选患者」作明确点击点;遮罩点击/抽屉沿 ✕/选中患者 三种方式收起;
   xl+ 常驻不变。

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 01663a75
'use client';
import { useState } from 'react';
import { useSelectedLayoutSegment } from 'next/navigation';
import { Users, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { PatientPickerRail } from '@/components/plans/patient-picker-rail';
/** 详情 TopBar 经 portal 渲到这个槽 → header 延伸到最左(在左栏之上)。 */
......@@ -11,10 +14,12 @@ export const PLAN_WORKSPACE_HEADER_SLOT = 'plan-workspace-header';
* ┌ header(详情 TopBar portal 到此,全宽延伸到最左)┐
* ├ 左栏选患者列 300px │ 详情内容(原三列) ┤
* 布局挂在动态段之上 → 切患者只重渲染右侧,左栏筛选/分页/滚动全保留。
* /plans 列表页(无 planId 段)不渲染本骨架。
* 响应式:<xl 左栏收成抽屉(单实例 CSS 平移,状态不丢)+ 左缘竖把手"选患者"作明确点击点;
* 选中患者后自动收起。/plans 列表页(无 planId 段)不渲染本骨架。
*/
export default function PlansLayout({ children }: { children: React.ReactNode }) {
const planId = useSelectedLayoutSegment();
const [railOpen, setRailOpen] = useState(false);
if (!planId) return <>{children}</>;
......@@ -22,8 +27,46 @@ export default function PlansLayout({ children }: { children: React.ReactNode })
<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="relative flex min-h-0 flex-1">
{/* 小屏遮罩 */}
{railOpen && (
<div className="fixed inset-0 z-30 bg-black/30 xl:hidden" onClick={() => setRailOpen(false)} />
)}
{/* 左栏:xl+ 常驻在流内;<xl 固定抽屉(CSS 平移,单实例不重挂 → 状态保留) */}
<div
className={cn(
'z-40 h-full transition-transform duration-200',
'fixed inset-y-0 left-0 shadow-xl xl:static xl:inset-auto xl:translate-x-0 xl:shadow-none',
railOpen ? 'translate-x-0' : '-translate-x-full xl:translate-x-0',
)}
>
{/* 小屏抽屉内关闭钮 */}
{railOpen && (
<button
type="button"
onClick={() => setRailOpen(false)}
className="absolute -right-9 top-3 z-50 rounded-full bg-white p-1.5 text-slate-500 shadow-md ring-1 ring-slate-200 xl:hidden"
title="收起"
>
<X className="h-4 w-4" />
</button>
)}
<PatientPickerRail currentPlanId={planId} onNavigated={() => setRailOpen(false)} />
</div>
{/* 小屏点击点:左缘竖把手 */}
{!railOpen && (
<button
type="button"
onClick={() => setRailOpen(true)}
className="fixed left-0 top-1/2 z-30 -translate-y-1/2 rounded-r-lg bg-teal-600 px-1.5 py-3 text-white shadow-lg hover:bg-teal-700 xl:hidden"
title="打开患者列表"
>
<span className="flex flex-col items-center gap-1">
<Users className="h-4 w-4" />
<span className="text-[10px] leading-tight [writing-mode:vertical-rl]">选患者</span>
</span>
</button>
)}
<div className="min-h-0 min-w-0 flex-1">{children}</div>
</div>
</div>
......
......@@ -20,6 +20,7 @@ import {
DropdownMenuRadioItem,
} from '@/components/ui/dropdown-menu';
import { plansApi } from '@/components/plans/plans-api';
import { usePlanSyncStore } from '@/stores/plan-sync-store';
import { useAuthStore } from '@/stores/auth-store';
import {
cn,
......@@ -273,6 +274,8 @@ export function PlanDetailApp({
status: result.planStatus,
contactAttempts: result.contactAttempts,
});
// 通知左栏选患者列同步该行状态(工作台跨栏一致)
usePlanSyncStore.getState().notify(plan.id, result.planStatus);
// 触达熔断是用户应知的"非预期"事件 → 保留 toast;普通归档不弹(setPlanOverride 已就地更新状态徽标即反馈)
if (result.breakerTripped) {
......
......@@ -11,6 +11,7 @@ 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 { usePlanSyncStore } from '@/stores/plan-sync-store';
import { usePatientPicker, type PickerFilters } from './use-patient-picker';
/**
......@@ -29,7 +30,14 @@ const VIEW_TABS: Array<{ v: View; label: string }> = [
{ v: 'all', label: '全部' },
];
export function PatientPickerRail({ currentPlanId }: { currentPlanId: string }) {
export function PatientPickerRail({
currentPlanId,
onNavigated,
}: {
currentPlanId: string;
/** 行点击跳转后回调(小屏抽屉模式用来收起) */
onNavigated?: () => void;
}) {
const router = useRouter();
const canViewAll = useHasPermission(Permission.PLAN_VIEW_ALL);
......@@ -66,6 +74,14 @@ export function PatientPickerRail({ currentPlanId }: { currentPlanId: string })
);
const { items, total, loading, hasMore, error, loadMore, patchItem } = usePatientPicker(filters);
// 右侧详情动作(提交归档等)→ 同步左栏对应行状态(不重拉)
const sync = usePlanSyncStore();
useEffect(() => {
if (!sync.planId) return;
if (sync.status) patchItem(sync.planId, { status: sync.status as PlanListItem['status'] });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sync.seq]);
// 认领:assign 给自己 → 原地把行改成"进行中"(不重拉,保滚动/筛选)
const claim = async (planId: string, name: string) => {
const sub = user?.sub;
......@@ -192,7 +208,10 @@ export function PatientPickerRail({ currentPlanId }: { currentPlanId: string })
plan={p}
active={p.id === currentPlanId}
clinicName={p.targetClinicId ? String(clinicDict?.[p.targetClinicId] ?? '') : ''}
onClick={() => router.push(`/plans/${p.id}`)}
onClick={() => {
router.push(`/plans/${p.id}`);
onNavigated?.();
}}
onClaim={
p.status === 'active' && !p.assigneeUserId
? () => void claim(p.id, p.patient.name ?? '')
......
'use client';
import { create } from 'zustand';
/**
* 工作台跨栏同步 — 详情页(右)动作改了 plan 状态后,通知左栏选患者列原地更新。
* 极简事件总线:seq 自增触发订阅;消费方按 planId patch 对应行(不重拉、不丢滚动)。
*/
interface PlanSyncState {
seq: number;
planId: string | null;
/** 新状态(submitExecution 回的 planStatus 等);null = 只提示"该行变了"(消费方自行 refresh) */
status: string | null;
notify: (planId: string, status?: string) => void;
}
export const usePlanSyncStore = create<PlanSyncState>((set) => ({
seq: 0,
planId: null,
status: null,
notify: (planId, status) =>
set((s) => ({ seq: s.seq + 1, planId, status: status ?? null })),
}));
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