Commit a33528bc by luoqi

fix(ai-web): preserve session and clinic state across navigation

parent 584bd3dd
Pipeline #3805 failed in 0 seconds
import type { Me } from '../../ai-web/src/lib/api';
import { WorkbenchSession } from '../../ai-web/src/lib/workbench-session';
const profile = (id = 'a'): Me => ({
id,
tenantId: 'tenant-' + id,
displayName: id,
role: 'staff',
permissions: [],
needsProfile: false,
profile: { orgUnit: null, jobTitle: null },
dataScope: 'tenant',
tenantName: '测试机构',
mustChangePassword: false,
account: null,
accounts: [],
clinics: [
{ id: 'first', name: '第一家', kind: 'clinic', isActive: true },
{ id: 'remembered', name: '选中的店', kind: 'clinic', isActive: true },
],
});
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
function fixture() {
let sid: string | null = 'session-a';
let now = 100;
const load = jest.fn<Promise<Me>, []>().mockResolvedValue(profile());
const loadWorkspaces = jest.fn<Promise<number>, []>().mockResolvedValue(2);
const readClinic = jest.fn(() => 'remembered');
const saveClinic = jest.fn();
const store = new WorkbenchSession({
sessionId: () => sid,
load,
loadWorkspaces,
readClinic,
saveClinic,
now: () => now,
});
return {
store,
load,
loadWorkspaces,
readClinic,
saveClinic,
switchTo: (id: string | null) => {
sid = id;
},
age: () => {
now += 60_001;
},
};
}
it('首次读取合并并发请求,连续切页面复用结果', async () => {
const { store, load } = fixture();
await Promise.all([store.load(), store.load(), store.load()]);
await store.load();
expect(load).toHaveBeenCalledTimes(1);
expect(store.getSnapshot().me?.id).toBe('a');
});
it('首屏直接使用记住的诊所;跨页面选择不触发用户重查', async () => {
const { store, load, saveClinic } = fixture();
const displayed: (string | null)[] = [];
store.subscribe(() => {
if (store.getSnapshot().me) displayed.push(store.getSnapshot().clinicId);
});
await store.load();
expect(displayed).toEqual(['remembered']);
store.pickClinic('first');
await store.load();
expect(store.getSnapshot().clinicId).toBe('first');
expect(saveClinic).toHaveBeenCalledWith(
expect.objectContaining({ id: 'a' }),
'session-a',
'first',
);
expect(load).toHaveBeenCalledTimes(1);
});
it('过期更新期间保留机构和托管显示,更新失败也不闪为空态', async () => {
const { store, load, age } = fixture();
await store.load();
const before = store.getSnapshot();
age();
const pending = deferred<Me>();
load.mockReturnValueOnce(pending.promise);
const refresh = store.load();
expect(store.getSnapshot()).toBe(before);
pending.reject(new Error('网络暂不可用'));
await refresh;
expect(store.getSnapshot().me).toBe(before.me);
expect(store.getSnapshot().clinicId).toBe('remembered');
});
it('后台校验内容未变时保留引用,不重置业务页的选中项', async () => {
const { store, age, load } = fixture();
await store.load();
const before = store.getSnapshot();
age();
load.mockResolvedValueOnce(profile());
await store.load();
expect(store.getSnapshot()).toBe(before);
});
it('可切换工作台查询合并并发与跨页面重复读取,切身份后重新读取', async () => {
const { store, loadWorkspaces, switchTo } = fixture();
await Promise.all([store.loadWorkspaces(), store.loadWorkspaces()]);
await store.load();
await store.loadWorkspaces();
expect(loadWorkspaces).toHaveBeenCalledTimes(1);
expect(store.getSnapshot().workspaceCount).toBe(2);
switchTo('session-b');
loadWorkspaces.mockResolvedValueOnce(1);
await store.loadWorkspaces();
expect(loadWorkspaces).toHaveBeenCalledTimes(2);
expect(store.getSnapshot().workspaceCount).toBe(1);
});
it('退出后旧读取完成不能恢复用户信息', async () => {
const { store, load, switchTo } = fixture();
const pending = deferred<Me>();
load.mockReturnValueOnce(pending.promise);
const request = store.load();
switchTo(null);
store.reset();
pending.resolve(profile());
await request;
expect(store.getSnapshot().me).toBeNull();
});
it('换账号立刻隐藏旧状态,旧请求及旧写回均不能覆盖新账号', async () => {
const { store, load, switchTo, age } = fixture();
await store.load();
age();
const pending = deferred<Me>();
load.mockReturnValueOnce(pending.promise);
const oldRequest = store.load();
switchTo('session-b');
expect(store.getSnapshot().me).toBeNull();
load.mockResolvedValueOnce(profile('b'));
await store.load();
store.setMe(profile(), 'session-a');
pending.resolve(profile());
await oldRequest;
expect(store.getSnapshot().me?.id).toBe('b');
});
it('绑定后的显式更新优先于此前发出的旧读取', async () => {
const { store, load } = fixture();
const pending = deferred<Me>();
load.mockReturnValueOnce(pending.promise);
const request = store.load();
const changed = { ...profile(), displayName: '更新后的状态' };
store.setMe(changed, 'session-a');
pending.resolve(profile());
await request;
expect(store.getSnapshot().me).toBe(changed);
});
it('授权变化后选中诊所失效会回到有效候选,非法选择不会生效', async () => {
const { store } = fixture();
await store.load();
store.setMe({ ...profile(), clinics: profile().clinics.slice(0, 1) }, 'session-a');
expect(store.getSnapshot().clinicId).toBe('first');
store.pickClinic('unauthorized');
expect(store.getSnapshot().clinicId).toBe('first');
});
'use client';
import { useWorkbenchMe } from '@/components/workbench-provider';
import { loginDestination } from '@/lib/auth-session';
import { useCallback, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { api, ApiError, can, PERM, type Me, type NotifySettings } from '@/lib/api';
import { api, ApiError, can, PERM, type NotifySettings } from '@/lib/api';
import { AccountsMenu, type BindRequest } from '@/components/accounts-menu';
import { NoAccess } from '@/components/no-access';
import { Sidebar } from '@/components/sidebar';
......@@ -54,7 +55,7 @@ import { Button } from '@/components/ui/button';
*/
export default function NotifyPage() {
const router = useRouter();
const [me, setMe] = useState<Me | null>(null);
const { me, setMe } = useWorkbenchMe();
const { clinics: switchable, clinicId, pickClinic } = useClinic(me);
const [showAccounts, setShowAccounts] = useState(false);
const [bind, setBind] = useState<BindRequest | null>(null);
......@@ -75,12 +76,8 @@ export default function NotifyPage() {
let gone = false;
void (async () => {
try {
const [m, d] = await Promise.all([
api.get<Me>('/auth/me'),
api.get<NotifySettings>('/notify/settings'),
]);
const d = await api.get<NotifySettings>('/notify/settings');
if (gone) return;
setMe(m);
setData(d);
} catch (e) {
if (!gone) guard(e);
......
'use client';
import { useWorkbenchMe } from '@/components/workbench-provider';
import { hostAllows } from '@/lib/api';
import { loginDestination } from '@/lib/auth-session';
......@@ -14,7 +15,6 @@ import {
type ConfirmTaskCard,
type ConfirmTaskDetail,
type ConfirmTaskPage,
type Me,
} from '@/lib/api';
import { AccountsMenu, type BindRequest } from '@/components/accounts-menu';
import { Sidebar } from '@/components/sidebar';
......@@ -96,7 +96,7 @@ const TABS: { key: ConfirmBucket; label: string; hint: string }[] = [
export default function AppointmentConfirmPage() {
const router = useRouter();
const [me, setMe] = useState<Me | null>(null);
const { me, setMe } = useWorkbenchMe();
/** 「我的企微号」抽屉 —— 侧栏底部那一行是它的触发器 */
const [showAccounts, setShowAccounts] = useState(false);
/** ⭐ 扫码请求。⚠️ 完全受控(见 AccountsMenu)—— ⛔ 别收进组件内部 */
......@@ -121,13 +121,6 @@ export default function AppointmentConfirmPage() {
[router],
);
useEffect(() => {
api
.get<Me>('/auth/me')
.then((m) => (m.mustChangePassword ? router.replace(loginDestination()) : setMe(m)))
.catch(guard);
}, [guard, router]);
/**
* ⚠️⚠️ **「后发的先回来」守卫**。会话页那边三处都做了(`refreshDetail` / `refreshList` /
* 患者卡),这一页原来一处都没有 —— 而这一页比那边更需要:
......
'use client';
import { useWorkbenchMe } from '@/components/workbench-provider';
import { loginDestination } from '@/lib/auth-session';
......@@ -24,7 +25,6 @@ import {
type ClinicListItem,
type ClinicProfileData,
type ClinicProfileView,
type Me,
type Tri,
} from '@/lib/api';
import { AccountsMenu, type BindRequest } from '@/components/accounts-menu';
......@@ -56,7 +56,7 @@ import { notify } from '@/components/ui/sonner';
*/
export default function ClinicPage() {
const router = useRouter();
const [me, setMe] = useState<Me | null>(null);
const { me, setMe } = useWorkbenchMe();
const { clinics: switchable, clinicId, pickClinic } = useClinic(me);
const [showAccounts, setShowAccounts] = useState(false);
const [bind, setBind] = useState<BindRequest | null>(null);
......@@ -84,13 +84,6 @@ export default function ClinicPage() {
[router],
);
useEffect(() => {
api
.get<Me>('/auth/me')
.then((m) => (m.mustChangePassword ? router.replace(loginDestination()) : setMe(m)))
.catch(guard);
}, [guard, router]);
const loadList = useCallback(() => {
api
.clinicProfiles()
......
'use client';
import { useWorkbenchMe } from '@/components/workbench-provider';
import { loginDestination } from '@/lib/auth-session';
......@@ -12,7 +13,6 @@ import {
type ContactBucket,
type ContactCard,
type ContactPage,
type Me,
type PacPatient,
type PatientLink,
type FriendRequestItem,
......@@ -98,7 +98,7 @@ function Contacts() {
*/
const focusFor = params.get('for');
const [me, setMe] = useState<Me | null>(null);
const { me, setMe } = useWorkbenchMe();
/** 「我的企微号」抽屉 —— 侧栏底部那一行是它的触发器 */
const [showAccounts, setShowAccounts] = useState(false);
/** ⭐ 扫码请求。⚠️ 完全受控(见 AccountsMenu)—— ⛔ 别收进组件内部 */
......@@ -149,16 +149,6 @@ function Contacts() {
[router],
);
useEffect(() => {
api
.get<Me>('/auth/me')
.then((m) => {
if (m.mustChangePassword) router.replace(loginDestination());
else setMe(m);
})
.catch(guard);
}, [guard, router]);
const load = useCallback(
async (b: ContactBucket, text: string, append = false) => {
if (append) setMore(true);
......
'use client';
import { useWorkbenchMe } from '@/components/workbench-provider';
import { loginDestination } from '@/lib/auth-session';
......@@ -22,7 +23,6 @@ import {
type CorpusNodesResp,
type CorpusSlotView,
type CorpusZoneView,
type Me,
} from '@/lib/api';
import { AccountsMenu, type BindRequest } from '@/components/accounts-menu';
import { NoAccess } from '@/components/no-access';
......@@ -62,7 +62,7 @@ import { notify } from '@/components/ui/sonner';
*/
export default function CorpusPage() {
const router = useRouter();
const [me, setMe] = useState<Me | null>(null);
const { me, setMe } = useWorkbenchMe();
const { clinics: switchable, clinicId, pickClinic } = useClinic(me);
const [showAccounts, setShowAccounts] = useState(false);
const [bind, setBind] = useState<BindRequest | null>(null);
......@@ -99,13 +99,6 @@ export default function CorpusPage() {
[router],
);
useEffect(() => {
api
.get<Me>('/auth/me')
.then((m) => (m.mustChangePassword ? router.replace(loginDestination()) : setMe(m)))
.catch(guard);
}, [guard, router]);
const loadTree = useCallback(() => {
api.corpusNodes().then(setTree).catch(guard);
}, [guard]);
......
'use client';
import { useWorkbenchMe } from '@/components/workbench-provider';
import { loginDestination } from '@/lib/auth-session';
......@@ -12,7 +13,6 @@ import {
type DebugRoundItem,
type DebugRoundRow,
type DebugRounds,
type Me,
} from '@/lib/api';
import { AccountsMenu, type BindRequest } from '@/components/accounts-menu';
import { NoAccess } from '@/components/no-access';
......@@ -48,7 +48,7 @@ function DebugPageInner() {
const conv = params.get('conv');
const picked = params.get('round');
const [me, setMe] = useState<Me | null>(null);
const { me, setMe } = useWorkbenchMe();
const { clinics: switchable, clinicId, pickClinic } = useClinic(me);
const [showAccounts, setShowAccounts] = useState(false);
const [bind, setBind] = useState<BindRequest | null>(null);
......@@ -67,13 +67,6 @@ function DebugPageInner() {
[router],
);
useEffect(() => {
api
.get<Me>('/auth/me')
.then(setMe)
.catch((e) => guard(e));
}, [guard]);
const allowed = !!me && can(me, PERM.AI_DEBUG_VIEW);
const query = useCallback(
......
......@@ -4,6 +4,7 @@ import type { ReactNode } from 'react';
import './globals.css';
import { AuthSessionBoundary } from '@/components/auth-session-boundary';
import { Toaster } from '@/components/ui/sonner';
import { WorkbenchProvider } from '@/components/workbench-provider';
export const metadata: Metadata = {
title: 'Friday · 疗效保障',
......@@ -21,7 +22,9 @@ export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="zh-CN">
<body>
<AuthSessionBoundary>{children}</AuthSessionBoundary>
<AuthSessionBoundary>
<WorkbenchProvider>{children}</WorkbenchProvider>
</AuthSessionBoundary>
{/* ⚠️ 挂在根布局 —— 提示条要能从任何一页弹出来,⛔ 不能每页各挂一个(会叠出多份) */}
<Toaster />
<BaiduAnalytics />
......
'use client';
import { useWorkbenchMe } from '@/components/workbench-provider';
import { hostAllows } from '@/lib/api';
import { loginDestination } from '@/lib/auth-session';
......@@ -13,7 +14,6 @@ import {
type ContactPage,
type ConvCard,
type ConvDetail as Detail,
type Me,
type PatientProfile,
} from '@/lib/api';
import { Sidebar } from '@/components/sidebar';
......@@ -82,7 +82,7 @@ function Workbench() {
* ⇒ 这个参数是那条设计的另一半,⛔ 删了它那颗按钮就变成一句空话。
*/
const focusConv = useSearchParams().get('conv');
const [me, setMe] = useState<Me | null>(null);
const { me, setMe } = useWorkbenchMe();
/**
* ⚠️ 默认是「全部」不是「等待中」—— 那一档的胶囊已经去掉了(见 conv-list 的 FILTERS)。
* ⛔ 默认值留成 'waiting' 的话,界面上没有任何胶囊是选中的,
......@@ -176,21 +176,6 @@ function Workbench() {
[router],
);
useEffect(() => {
api
.get<Me>('/auth/me')
.then((m) => {
// ⚠️ 拿临时密码进来的人**什么都做不了**(守卫只放行 /auth/me 和 /auth/password)。
// 不在这儿拦的话,工作台会渲染出来、然后每个接口各自 401 —— 一屏的错。
if (m.mustChangePassword) {
router.replace(loginDestination());
return;
}
setMe(m);
})
.catch((e) => guard(e));
}, [guard, router]);
/**
* 当前诊所 —— ⭐ **和侧栏、通讯录、预约确认共用同一份状态**(见 `useClinic`)。
* ⛔ 别在这里再读一次 localStorage:两份必然漂。
......
'use client';
import { useWorkbenchMe } from '@/components/workbench-provider';
import { loginDestination } from '@/lib/auth-session';
import { useCallback, useEffect, useState, type ReactNode } from 'react';
import { useCallback, useState, type ReactNode } from 'react';
import { useRouter } from 'next/navigation';
import { ADMIN_PERMS, api, ApiError, can, type Me } from '@/lib/api';
import { AccountsMenu, type BindRequest } from '@/components/accounts-menu';
......@@ -25,7 +26,7 @@ export function AdminShell({
children: (me: Me, guard: (e: unknown) => void) => ReactNode;
}) {
const router = useRouter();
const [me, setMe] = useState<Me | null>(null);
const { me, setMe } = useWorkbenchMe();
const { clinics, clinicId, pickClinic } = useClinic(me);
const [showAccounts, setShowAccounts] = useState(false);
const [bind, setBind] = useState<BindRequest | null>(null);
......@@ -38,13 +39,6 @@ export function AdminShell({
[router],
);
useEffect(() => {
api
.get<Me>('/auth/me')
.then((m) => (m.mustChangePassword ? router.replace(loginDestination()) : setMe(m)))
.catch(guard);
}, [guard, router]);
const allowed = !!me && ADMIN_PERMS.some((p) => can(me, p));
return (
......
......@@ -350,7 +350,9 @@ export function Sidebar({
⭐ 部分掉线要**能看出是部分** —— 「1/2 掉线」比「已掉线」准确,
而准确在这里就是"要不要现在去处理"的判断依据。
*/}
{acct.state === 'none' ? (
{!me ? (
<div className="text-[10.5px] text-slate-300">加载中…</div>
) : acct.state === 'none' ? (
/*
⭐⭐ **未绑时也能点**(2026-09-21 用户提的,刚入驻、在组织架构页上撞到)。
⚠️ 原来这里是纯文字,理由是「主区那张引导卡已经拿着唯一的动作」——
......
'use client';
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
useSyncExternalStore,
type ReactNode,
} from 'react';
import { useRouter } from 'next/navigation';
import { api, ApiError, type Me } from '@/lib/api';
import { AUTH_CHANGED, currentSessionId, loginDestination } from '@/lib/auth-session';
import { clinicStorageKey } from '@/lib/clinic-selection';
import { EMPTY_WORKBENCH, WorkbenchSession } from '@/lib/workbench-session';
import { notify } from '@/components/ui/sonner';
const Context = createContext<WorkbenchSession | null>(null);
const serverSnapshot = () => EMPTY_WORKBENCH;
export function WorkbenchProvider({ children }: { children: ReactNode }) {
const router = useRouter();
const [store] = useState(
() =>
new WorkbenchSession({
sessionId: currentSessionId,
load: () => api.get<Me>('/auth/me'),
loadWorkspaces: async () =>
(await api.get<{ workspaces: string[] }>('/auth/workspaces')).workspaces.length,
readClinic: (me, sid) => {
try {
return localStorage.getItem(clinicStorageKey(me, sid));
} catch {
return null;
}
},
saveClinic: (me, sid, id) => {
try {
localStorage.setItem(clinicStorageKey(me, sid), id);
} catch {
/* 存储不可用仍可切换 */
}
},
}),
);
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, serverSnapshot);
useEffect(() => {
const changed = () => {
store.reset();
// 活跃页面的 useWorkbenchMe 会以新会话重新读取。
};
const refresh = () => {
if (document.visibilityState === 'visible' && store.getSnapshot().me) void store.load();
};
window.addEventListener(AUTH_CHANGED, changed);
window.addEventListener('focus', refresh);
document.addEventListener('visibilitychange', refresh);
return () => {
window.removeEventListener(AUTH_CHANGED, changed);
window.removeEventListener('focus', refresh);
document.removeEventListener('visibilitychange', refresh);
};
}, [store]);
useEffect(() => {
if (
snapshot.me?.mustChangePassword ||
(snapshot.error instanceof ApiError && snapshot.error.unauthorized)
)
router.replace(loginDestination());
else if (snapshot.error) notify.fail(snapshot.error, '加载失败,请稍后再试');
}, [snapshot.me?.mustChangePassword, snapshot.error, router]);
return <Context.Provider value={store}>{children}</Context.Provider>;
}
export function useWorkbenchState() {
const store = useContext(Context);
if (!store) throw new Error('工作台必须在 WorkbenchProvider 内');
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, serverSnapshot);
return { store, snapshot };
}
export function useWorkbenchMe() {
const { store, snapshot } = useWorkbenchState();
const sid = currentSessionId();
const empty = !snapshot.me;
useEffect(() => {
void store.load();
}, [store, sid, empty]);
const setMe = useCallback((me: Me) => store.setMe(me, sid), [store, sid]);
return { me: snapshot.me?.mustChangePassword ? null : snapshot.me, setMe };
}
'use client';
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import Link from 'next/link';
import { api } from '@/lib/api';
import { currentHostContext } from '@/lib/auth-session';
import { currentHostContext, currentSessionId } from '@/lib/auth-session';
import { useWorkbenchState } from '@/components/workbench-provider';
import { Button } from '@/components/ui/button';
/** 仅双重身份显示;宿主内嵌保持宿主授予的范围。 */
export function WorkspaceSwitch({ confirmLeave }: { confirmLeave?: () => boolean }) {
const [visible, setVisible] = useState(false);
const { store, snapshot } = useWorkbenchState();
const sid = currentSessionId();
useEffect(() => {
if (currentHostContext()) return;
let cancelled = false;
api
.get<{ workspaces: string[] }>('/auth/workspaces')
.then((r) => {
if (!cancelled) setVisible(r.workspaces.length > 1);
})
.catch(() => {
if (!cancelled) setVisible(false);
});
return () => {
cancelled = true;
};
}, []);
if (!visible) return null;
void store.loadWorkspaces();
}, [store, sid]);
if ((snapshot.workspaceCount ?? 0) < 2) return null;
return (
<Button variant="ghost" asChild>
<Link
......
'use client';
import { useCallback, useEffect, useState, useMemo } from 'react';
import type { ClinicOption, Me } from '@/lib/api';
import { currentSessionId } from '@/lib/auth-session';
import { clinicStorageKey, pickDefaultClinic } from '@/lib/clinic-selection';
import { useWorkbenchState } from '@/components/workbench-provider';
export { pickDefaultClinic } from '@/lib/clinic-selection';
/** 同身份/窗口内各业务页面共用选择,其他集团、用户或 iframe 不覆盖它。 */
export function useClinic(me:Me|null):{clinics:ClinicOption[];clinicId:string|null;pickClinic:(id:string)=>void} {
const key=me ? clinicStorageKey(me,currentSessionId()) : null;
const [selection,setSelection]=useState<{key:string;id:string|null}|null>(null);
useEffect(()=>{
if(!key || !me)return;
let remembered:string|null=null;
try {remembered=localStorage.getItem(key);} catch { /* 存储不可用仍能操作 */ }
// 新 SSO 会话第一次使用宿主初始诊所;后续刷新优先该窗口已保存的选择。
const id=remembered??me.hostContext?.clinicId??null;
setSelection({key,id});
if(id)try{localStorage.setItem(key,id);}catch{ /* 留在内存 */ }
},[key,me]);
const clinics=useMemo(()=>me?.clinics??[],[me]);
const selected=selection?.key===key ? selection.id : me?.hostContext?.clinicId??null;
const clinicId=pickDefaultClinic(clinics,selected)?.id??null;
const pickClinic=useCallback((id:string)=>{
if(!key || !clinics.some(c=>c.id===id))return;
setSelection({key,id});try{localStorage.setItem(key,id);}catch{ /* 留在内存 */ }
},[key,clinics]);
return {clinics,clinicId,pickClinic};
/** 同身份/窗口内共用选择;用户信息与有效诊所一起更新,不经过默认诊所中间态。 */
export function useClinic(me: Me | null): {
clinics: ClinicOption[];
clinicId: string | null;
pickClinic: (id: string) => void;
} {
const { store, snapshot } = useWorkbenchState();
return {
clinics: me?.clinics ?? [],
clinicId: me ? snapshot.clinicId : null,
pickClinic: store.pickClinic,
};
}
import type { Me } from './api';
import { pickDefaultClinic } from './clinic-selection';
export interface WorkbenchSnapshot {
sessionId: string | null;
me: Me | null;
clinicId: string | null;
workspaceCount: number | null;
error: unknown;
}
export const EMPTY_WORKBENCH: WorkbenchSnapshot = {
sessionId: null,
me: null,
clinicId: null,
workspaceCount: null,
error: null,
};
const FRESH_MS = 60_000;
/** 根布局持有一份;只在内存保存,退出/换身份后立即失效。 */
export class WorkbenchSession {
private snapshot = EMPTY_WORKBENCH;
private updatedAt = 0;
private generation = 0;
private identityGeneration = 0;
private workspacesUpdatedAt = 0;
private workspacesPending: Promise<void> | null = null;
private pending: Promise<void> | null = null;
private listeners = new Set<() => void>();
constructor(
private readonly deps: {
sessionId: () => string | null;
load: () => Promise<Me>;
loadWorkspaces: () => Promise<number>;
readClinic: (me: Me, sid: string | null) => string | null;
saveClinic: (me: Me, sid: string | null, id: string) => void;
now?: () => number;
},
) {}
subscribe = (listener: () => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
getSnapshot = (): WorkbenchSnapshot =>
this.snapshot.sessionId === this.deps.sessionId() ? this.snapshot : EMPTY_WORKBENCH;
private publish(snapshot: WorkbenchSnapshot) {
this.snapshot = snapshot;
this.listeners.forEach((listener) => listener());
}
reset = () => {
this.generation++;
this.identityGeneration++;
this.pending = null;
this.workspacesPending = null;
this.workspacesUpdatedAt = 0;
this.updatedAt = 0;
this.publish({ ...EMPTY_WORKBENCH, sessionId: this.deps.sessionId() });
};
setMe = (me: Me, expectedSid: string | null) => {
if (expectedSid !== this.deps.sessionId()) return;
const old = this.getSnapshot();
const sameUser = old.me?.id === me.id && old.me.tenantId === me.tenantId;
const picked = sameUser ? old.clinicId : this.deps.readClinic(me, expectedSid);
const clinicId =
pickDefaultClinic(me.clinics, picked ?? me.hostContext?.clinicId ?? null)?.id ?? null;
// 内容未变时保留引用,避免业务页把后台校验误当成一次身份/范围变更。
const stableMe = old.me && JSON.stringify(old.me) === JSON.stringify(me) ? old.me : me;
// 显式更新优先于在途旧读取(例如绑定/转交结束后回来的新数据)。
this.generation++;
this.pending = null;
this.updatedAt = (this.deps.now ?? Date.now)();
if (old.me !== stableMe || old.clinicId !== clinicId || old.error)
this.publish({ ...old, sessionId: expectedSid, me: stableMe, clinicId, error: null });
};
pickClinic = (id: string) => {
const current = this.getSnapshot();
if (!current.me || !current.me.clinics.some((clinic) => clinic.id === id)) return;
this.deps.saveClinic(current.me, current.sessionId, id);
this.publish({ ...current, clinicId: id });
};
/** 新页面复用近期结果;过期时后台更新,保留当前显示;并发读取合并。 */
load = (force = false): Promise<void> => {
const sid = this.deps.sessionId();
if (this.snapshot.sessionId !== sid) this.reset();
if (this.pending) return this.pending;
if (!force && this.snapshot.me && (this.deps.now ?? Date.now)() - this.updatedAt < FRESH_MS)
return Promise.resolve();
const generation = this.generation;
const current = () => generation === this.generation && sid === this.deps.sessionId();
const request = this.deps
.load()
.then(
(me) => {
if (current()) this.setMe(me, sid);
},
(error: unknown) => {
if (current()) this.publish({ ...this.snapshot, error });
},
)
.finally(() => {
if (this.pending === request) this.pending = null;
});
this.pending = request;
return request;
};
/** 可切换工作台同样属于会话信息,侧栏重新挂载时复用。 */
loadWorkspaces = (): Promise<void> => {
const sid = this.deps.sessionId();
if (this.snapshot.sessionId !== sid) this.reset();
if (this.workspacesPending) return this.workspacesPending;
if (
this.snapshot.workspaceCount !== null &&
(this.deps.now ?? Date.now)() - this.workspacesUpdatedAt < FRESH_MS
)
return Promise.resolve();
const generation = this.identityGeneration;
const update = (workspaceCount: number) => {
if (generation !== this.identityGeneration || sid !== this.deps.sessionId()) return;
this.workspacesUpdatedAt = (this.deps.now ?? Date.now)();
if (this.snapshot.workspaceCount !== workspaceCount)
this.publish({ ...this.snapshot, workspaceCount });
};
const request = this.deps
.loadWorkspaces()
.then(update, () => update(0))
.finally(() => {
if (this.workspacesPending === request) this.workspacesPending = null;
});
this.workspacesPending = request;
return request;
};
}
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