Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
P
pac
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ai-tools
pac
Commits
a33528bc
Commit
a33528bc
authored
Oct 14, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix(ai-web): preserve session and clinic state across navigation
parent
584bd3dd
Pipeline
#3805
failed in 0 seconds
Changes
15
Pipelines
1
Hide whitespace changes
Inline
Side-by-side
Showing
15 changed files
with
452 additions
and
125 deletions
+452
-125
apps/ai-service/tests/workbench-session.spec.ts
+167
-0
apps/ai-web/src/app/admin/notify/page.tsx
+4
-7
apps/ai-web/src/app/agents/appointment-confirm/page.tsx
+2
-9
apps/ai-web/src/app/clinic/page.tsx
+2
-9
apps/ai-web/src/app/contacts/page.tsx
+2
-12
apps/ai-web/src/app/corpus/page.tsx
+2
-9
apps/ai-web/src/app/debug/page.tsx
+2
-9
apps/ai-web/src/app/layout.tsx
+4
-1
apps/ai-web/src/app/page.tsx
+2
-17
apps/ai-web/src/components/admin/admin-shell.tsx
+3
-9
apps/ai-web/src/components/sidebar.tsx
+3
-1
apps/ai-web/src/components/workbench-provider.tsx
+95
-0
apps/ai-web/src/components/workspace-switch.tsx
+8
-18
apps/ai-web/src/lib/use-clinic.ts
+14
-24
apps/ai-web/src/lib/workbench-session.ts
+142
-0
No files found.
apps/ai-service/tests/workbench-session.spec.ts
0 → 100644
View file @
a33528bc
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'
);
});
apps/ai-web/src/app/admin/notify/page.tsx
View file @
a33528bc
'use client'
;
'use client'
;
import
{
useWorkbenchMe
}
from
'@/components/workbench-provider'
;
import
{
loginDestination
}
from
'@/lib/auth-session'
;
import
{
loginDestination
}
from
'@/lib/auth-session'
;
import
{
useCallback
,
useEffect
,
useState
}
from
'react'
;
import
{
useCallback
,
useEffect
,
useState
}
from
'react'
;
import
{
useRouter
}
from
'next/navigation'
;
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
{
AccountsMenu
,
type
BindRequest
}
from
'@/components/accounts-menu'
;
import
{
NoAccess
}
from
'@/components/no-access'
;
import
{
NoAccess
}
from
'@/components/no-access'
;
import
{
Sidebar
}
from
'@/components/sidebar'
;
import
{
Sidebar
}
from
'@/components/sidebar'
;
...
@@ -54,7 +55,7 @@ import { Button } from '@/components/ui/button';
...
@@ -54,7 +55,7 @@ import { Button } from '@/components/ui/button';
*/
*/
export
default
function
NotifyPage
()
{
export
default
function
NotifyPage
()
{
const
router
=
useRouter
();
const
router
=
useRouter
();
const
[
me
,
setMe
]
=
useState
<
Me
|
null
>
(
null
);
const
{
me
,
setMe
}
=
useWorkbenchMe
(
);
const
{
clinics
:
switchable
,
clinicId
,
pickClinic
}
=
useClinic
(
me
);
const
{
clinics
:
switchable
,
clinicId
,
pickClinic
}
=
useClinic
(
me
);
const
[
showAccounts
,
setShowAccounts
]
=
useState
(
false
);
const
[
showAccounts
,
setShowAccounts
]
=
useState
(
false
);
const
[
bind
,
setBind
]
=
useState
<
BindRequest
|
null
>
(
null
);
const
[
bind
,
setBind
]
=
useState
<
BindRequest
|
null
>
(
null
);
...
@@ -75,12 +76,8 @@ export default function NotifyPage() {
...
@@ -75,12 +76,8 @@ export default function NotifyPage() {
let
gone
=
false
;
let
gone
=
false
;
void
(
async
()
=>
{
void
(
async
()
=>
{
try
{
try
{
const
[
m
,
d
]
=
await
Promise
.
all
([
const
d
=
await
api
.
get
<
NotifySettings
>
('/notify/settings');
api
.
get
<
Me
>
('/auth/me'),
api.get
<
NotifySettings
>
('/notify/settings'),
]);
if (gone) return;
if (gone) return;
setMe(m);
setData(d);
setData(d);
} catch (e)
{
} catch (e)
{
if
(
!
gone
)
guard
(
e
);
if
(
!
gone
)
guard
(
e
);
...
...
apps/ai-web/src/app/agents/appointment-confirm/page.tsx
View file @
a33528bc
'use client'
;
'use client'
;
import
{
useWorkbenchMe
}
from
'@/components/workbench-provider'
;
import
{
hostAllows
}
from
'@/lib/api'
;
import
{
hostAllows
}
from
'@/lib/api'
;
import
{
loginDestination
}
from
'@/lib/auth-session'
;
import
{
loginDestination
}
from
'@/lib/auth-session'
;
...
@@ -14,7 +15,6 @@ import {
...
@@ -14,7 +15,6 @@ import {
type
ConfirmTaskCard
,
type
ConfirmTaskCard
,
type
ConfirmTaskDetail
,
type
ConfirmTaskDetail
,
type
ConfirmTaskPage
,
type
ConfirmTaskPage
,
type
Me
,
}
from
'@/lib/api'
;
}
from
'@/lib/api'
;
import
{
AccountsMenu
,
type
BindRequest
}
from
'@/components/accounts-menu'
;
import
{
AccountsMenu
,
type
BindRequest
}
from
'@/components/accounts-menu'
;
import
{
Sidebar
}
from
'@/components/sidebar'
;
import
{
Sidebar
}
from
'@/components/sidebar'
;
...
@@ -96,7 +96,7 @@ const TABS: { key: ConfirmBucket; label: string; hint: string }[] = [
...
@@ -96,7 +96,7 @@ const TABS: { key: ConfirmBucket; label: string; hint: string }[] = [
export
default
function
AppointmentConfirmPage
()
{
export
default
function
AppointmentConfirmPage
()
{
const
router
=
useRouter
();
const
router
=
useRouter
();
const
[
me
,
setMe
]
=
useState
<
Me
|
null
>
(
null
);
const
{
me
,
setMe
}
=
useWorkbenchMe
(
);
/** 「我的企微号」抽屉 —— 侧栏底部那一行是它的触发器 */
/** 「我的企微号」抽屉 —— 侧栏底部那一行是它的触发器 */
const
[
showAccounts
,
setShowAccounts
]
=
useState
(
false
);
const
[
showAccounts
,
setShowAccounts
]
=
useState
(
false
);
/** ⭐ 扫码请求。⚠️ 完全受控(见 AccountsMenu)—— ⛔ 别收进组件内部 */
/** ⭐ 扫码请求。⚠️ 完全受控(见 AccountsMenu)—— ⛔ 别收进组件内部 */
...
@@ -121,13 +121,6 @@ export default function AppointmentConfirmPage() {
...
@@ -121,13 +121,6 @@ export default function AppointmentConfirmPage() {
[
router
],
[
router
],
);
);
useEffect
(()
=>
{
api
.
get
<
Me
>
('/auth/me')
.then((m) =
>
(m.mustChangePassword ? router.replace(loginDestination()) : setMe(m)))
.catch(guard);
}, [guard, router]);
/**
/**
* ⚠️⚠️ **「后发的先回来」守卫**。会话页那边三处都做了(`refreshDetail` / `refreshList` /
* ⚠️⚠️ **「后发的先回来」守卫**。会话页那边三处都做了(`refreshDetail` / `refreshList` /
* 患者卡),这一页原来一处都没有 —— 而这一页比那边更需要:
* 患者卡),这一页原来一处都没有 —— 而这一页比那边更需要:
...
...
apps/ai-web/src/app/clinic/page.tsx
View file @
a33528bc
'use client'
;
'use client'
;
import
{
useWorkbenchMe
}
from
'@/components/workbench-provider'
;
import
{
loginDestination
}
from
'@/lib/auth-session'
;
import
{
loginDestination
}
from
'@/lib/auth-session'
;
...
@@ -24,7 +25,6 @@ import {
...
@@ -24,7 +25,6 @@ import {
type
ClinicListItem
,
type
ClinicListItem
,
type
ClinicProfileData
,
type
ClinicProfileData
,
type
ClinicProfileView
,
type
ClinicProfileView
,
type
Me
,
type
Tri
,
type
Tri
,
}
from
'@/lib/api'
;
}
from
'@/lib/api'
;
import
{
AccountsMenu
,
type
BindRequest
}
from
'@/components/accounts-menu'
;
import
{
AccountsMenu
,
type
BindRequest
}
from
'@/components/accounts-menu'
;
...
@@ -56,7 +56,7 @@ import { notify } from '@/components/ui/sonner';
...
@@ -56,7 +56,7 @@ import { notify } from '@/components/ui/sonner';
*/
*/
export
default
function
ClinicPage
()
{
export
default
function
ClinicPage
()
{
const
router
=
useRouter
();
const
router
=
useRouter
();
const
[
me
,
setMe
]
=
useState
<
Me
|
null
>
(
null
);
const
{
me
,
setMe
}
=
useWorkbenchMe
(
);
const
{
clinics
:
switchable
,
clinicId
,
pickClinic
}
=
useClinic
(
me
);
const
{
clinics
:
switchable
,
clinicId
,
pickClinic
}
=
useClinic
(
me
);
const
[
showAccounts
,
setShowAccounts
]
=
useState
(
false
);
const
[
showAccounts
,
setShowAccounts
]
=
useState
(
false
);
const
[
bind
,
setBind
]
=
useState
<
BindRequest
|
null
>
(
null
);
const
[
bind
,
setBind
]
=
useState
<
BindRequest
|
null
>
(
null
);
...
@@ -84,13 +84,6 @@ export default function ClinicPage() {
...
@@ -84,13 +84,6 @@ export default function ClinicPage() {
[
router
],
[
router
],
);
);
useEffect
(()
=>
{
api
.
get
<
Me
>
('/auth/me')
.then((m) =
>
(m.mustChangePassword ? router.replace(loginDestination()) : setMe(m)))
.catch(guard);
}, [guard, router]);
const
loadList
=
useCallback
(()
=>
{
const
loadList
=
useCallback
(()
=>
{
api
api
.
clinicProfiles
()
.
clinicProfiles
()
...
...
apps/ai-web/src/app/contacts/page.tsx
View file @
a33528bc
'use client'
;
'use client'
;
import
{
useWorkbenchMe
}
from
'@/components/workbench-provider'
;
import
{
loginDestination
}
from
'@/lib/auth-session'
;
import
{
loginDestination
}
from
'@/lib/auth-session'
;
...
@@ -12,7 +13,6 @@ import {
...
@@ -12,7 +13,6 @@ import {
type
ContactBucket
,
type
ContactBucket
,
type
ContactCard
,
type
ContactCard
,
type
ContactPage
,
type
ContactPage
,
type
Me
,
type
PacPatient
,
type
PacPatient
,
type
PatientLink
,
type
PatientLink
,
type
FriendRequestItem
,
type
FriendRequestItem
,
...
@@ -98,7 +98,7 @@ function Contacts() {
...
@@ -98,7 +98,7 @@ function Contacts() {
*/
*/
const
focusFor
=
params
.
get
(
'for'
);
const
focusFor
=
params
.
get
(
'for'
);
const
[
me
,
setMe
]
=
useState
<
Me
|
null
>
(
null
);
const
{
me
,
setMe
}
=
useWorkbenchMe
(
);
/** 「我的企微号」抽屉 —— 侧栏底部那一行是它的触发器 */
/** 「我的企微号」抽屉 —— 侧栏底部那一行是它的触发器 */
const
[
showAccounts
,
setShowAccounts
]
=
useState
(
false
);
const
[
showAccounts
,
setShowAccounts
]
=
useState
(
false
);
/** ⭐ 扫码请求。⚠️ 完全受控(见 AccountsMenu)—— ⛔ 别收进组件内部 */
/** ⭐ 扫码请求。⚠️ 完全受控(见 AccountsMenu)—— ⛔ 别收进组件内部 */
...
@@ -149,16 +149,6 @@ function Contacts() {
...
@@ -149,16 +149,6 @@ function Contacts() {
[
router
],
[
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
(
const
load
=
useCallback
(
async
(
b
:
ContactBucket
,
text
:
string
,
append
=
false
)
=>
{
async
(
b
:
ContactBucket
,
text
:
string
,
append
=
false
)
=>
{
if
(
append
)
setMore
(
true
);
if
(
append
)
setMore
(
true
);
...
...
apps/ai-web/src/app/corpus/page.tsx
View file @
a33528bc
'use client'
;
'use client'
;
import
{
useWorkbenchMe
}
from
'@/components/workbench-provider'
;
import
{
loginDestination
}
from
'@/lib/auth-session'
;
import
{
loginDestination
}
from
'@/lib/auth-session'
;
...
@@ -22,7 +23,6 @@ import {
...
@@ -22,7 +23,6 @@ import {
type
CorpusNodesResp
,
type
CorpusNodesResp
,
type
CorpusSlotView
,
type
CorpusSlotView
,
type
CorpusZoneView
,
type
CorpusZoneView
,
type
Me
,
}
from
'@/lib/api'
;
}
from
'@/lib/api'
;
import
{
AccountsMenu
,
type
BindRequest
}
from
'@/components/accounts-menu'
;
import
{
AccountsMenu
,
type
BindRequest
}
from
'@/components/accounts-menu'
;
import
{
NoAccess
}
from
'@/components/no-access'
;
import
{
NoAccess
}
from
'@/components/no-access'
;
...
@@ -62,7 +62,7 @@ import { notify } from '@/components/ui/sonner';
...
@@ -62,7 +62,7 @@ import { notify } from '@/components/ui/sonner';
*/
*/
export
default
function
CorpusPage
()
{
export
default
function
CorpusPage
()
{
const
router
=
useRouter
();
const
router
=
useRouter
();
const
[
me
,
setMe
]
=
useState
<
Me
|
null
>
(
null
);
const
{
me
,
setMe
}
=
useWorkbenchMe
(
);
const
{
clinics
:
switchable
,
clinicId
,
pickClinic
}
=
useClinic
(
me
);
const
{
clinics
:
switchable
,
clinicId
,
pickClinic
}
=
useClinic
(
me
);
const
[
showAccounts
,
setShowAccounts
]
=
useState
(
false
);
const
[
showAccounts
,
setShowAccounts
]
=
useState
(
false
);
const
[
bind
,
setBind
]
=
useState
<
BindRequest
|
null
>
(
null
);
const
[
bind
,
setBind
]
=
useState
<
BindRequest
|
null
>
(
null
);
...
@@ -99,13 +99,6 @@ export default function CorpusPage() {
...
@@ -99,13 +99,6 @@ export default function CorpusPage() {
[
router
],
[
router
],
);
);
useEffect
(()
=>
{
api
.
get
<
Me
>
('/auth/me')
.then((m) =
>
(m.mustChangePassword ? router.replace(loginDestination()) : setMe(m)))
.catch(guard);
}, [guard, router]);
const
loadTree
=
useCallback
(()
=>
{
const
loadTree
=
useCallback
(()
=>
{
api
.
corpusNodes
().
then
(
setTree
).
catch
(
guard
);
api
.
corpusNodes
().
then
(
setTree
).
catch
(
guard
);
},
[
guard
]);
},
[
guard
]);
...
...
apps/ai-web/src/app/debug/page.tsx
View file @
a33528bc
'use client'
;
'use client'
;
import
{
useWorkbenchMe
}
from
'@/components/workbench-provider'
;
import
{
loginDestination
}
from
'@/lib/auth-session'
;
import
{
loginDestination
}
from
'@/lib/auth-session'
;
...
@@ -12,7 +13,6 @@ import {
...
@@ -12,7 +13,6 @@ import {
type
DebugRoundItem
,
type
DebugRoundItem
,
type
DebugRoundRow
,
type
DebugRoundRow
,
type
DebugRounds
,
type
DebugRounds
,
type
Me
,
}
from
'@/lib/api'
;
}
from
'@/lib/api'
;
import
{
AccountsMenu
,
type
BindRequest
}
from
'@/components/accounts-menu'
;
import
{
AccountsMenu
,
type
BindRequest
}
from
'@/components/accounts-menu'
;
import
{
NoAccess
}
from
'@/components/no-access'
;
import
{
NoAccess
}
from
'@/components/no-access'
;
...
@@ -48,7 +48,7 @@ function DebugPageInner() {
...
@@ -48,7 +48,7 @@ function DebugPageInner() {
const
conv
=
params
.
get
(
'conv'
);
const
conv
=
params
.
get
(
'conv'
);
const
picked
=
params
.
get
(
'round'
);
const
picked
=
params
.
get
(
'round'
);
const
[
me
,
setMe
]
=
useState
<
Me
|
null
>
(
null
);
const
{
me
,
setMe
}
=
useWorkbenchMe
(
);
const
{
clinics
:
switchable
,
clinicId
,
pickClinic
}
=
useClinic
(
me
);
const
{
clinics
:
switchable
,
clinicId
,
pickClinic
}
=
useClinic
(
me
);
const
[
showAccounts
,
setShowAccounts
]
=
useState
(
false
);
const
[
showAccounts
,
setShowAccounts
]
=
useState
(
false
);
const
[
bind
,
setBind
]
=
useState
<
BindRequest
|
null
>
(
null
);
const
[
bind
,
setBind
]
=
useState
<
BindRequest
|
null
>
(
null
);
...
@@ -67,13 +67,6 @@ function DebugPageInner() {
...
@@ -67,13 +67,6 @@ function DebugPageInner() {
[
router
],
[
router
],
);
);
useEffect
(()
=>
{
api
.
get
<
Me
>
('/auth/me')
.then(setMe)
.catch((e) =
>
guard(e));
}, [guard]);
const
allowed
=
!!
me
&&
can
(
me
,
PERM
.
AI_DEBUG_VIEW
);
const
allowed
=
!!
me
&&
can
(
me
,
PERM
.
AI_DEBUG_VIEW
);
const
query
=
useCallback
(
const
query
=
useCallback
(
...
...
apps/ai-web/src/app/layout.tsx
View file @
a33528bc
...
@@ -4,6 +4,7 @@ import type { ReactNode } from 'react';
...
@@ -4,6 +4,7 @@ import type { ReactNode } from 'react';
import
'./globals.css'
;
import
'./globals.css'
;
import
{
AuthSessionBoundary
}
from
'@/components/auth-session-boundary'
;
import
{
AuthSessionBoundary
}
from
'@/components/auth-session-boundary'
;
import
{
Toaster
}
from
'@/components/ui/sonner'
;
import
{
Toaster
}
from
'@/components/ui/sonner'
;
import
{
WorkbenchProvider
}
from
'@/components/workbench-provider'
;
export
const
metadata
:
Metadata
=
{
export
const
metadata
:
Metadata
=
{
title
:
'Friday · 疗效保障'
,
title
:
'Friday · 疗效保障'
,
...
@@ -21,7 +22,9 @@ export default function RootLayout({ children }: { children: ReactNode }) {
...
@@ -21,7 +22,9 @@ export default function RootLayout({ children }: { children: ReactNode }) {
return
(
return
(
<
html
lang=
"zh-CN"
>
<
html
lang=
"zh-CN"
>
<
body
>
<
body
>
<
AuthSessionBoundary
>
{
children
}
</
AuthSessionBoundary
>
<
AuthSessionBoundary
>
<
WorkbenchProvider
>
{
children
}
</
WorkbenchProvider
>
</
AuthSessionBoundary
>
{
/* ⚠️ 挂在根布局 —— 提示条要能从任何一页弹出来,⛔ 不能每页各挂一个(会叠出多份) */
}
{
/* ⚠️ 挂在根布局 —— 提示条要能从任何一页弹出来,⛔ 不能每页各挂一个(会叠出多份) */
}
<
Toaster
/>
<
Toaster
/>
<
BaiduAnalytics
/>
<
BaiduAnalytics
/>
...
...
apps/ai-web/src/app/page.tsx
View file @
a33528bc
'use client'
;
'use client'
;
import
{
useWorkbenchMe
}
from
'@/components/workbench-provider'
;
import
{
hostAllows
}
from
'@/lib/api'
;
import
{
hostAllows
}
from
'@/lib/api'
;
import
{
loginDestination
}
from
'@/lib/auth-session'
;
import
{
loginDestination
}
from
'@/lib/auth-session'
;
...
@@ -13,7 +14,6 @@ import {
...
@@ -13,7 +14,6 @@ import {
type
ContactPage
,
type
ContactPage
,
type
ConvCard
,
type
ConvCard
,
type
ConvDetail
as
Detail
,
type
ConvDetail
as
Detail
,
type
Me
,
type
PatientProfile
,
type
PatientProfile
,
}
from
'@/lib/api'
;
}
from
'@/lib/api'
;
import
{
Sidebar
}
from
'@/components/sidebar'
;
import
{
Sidebar
}
from
'@/components/sidebar'
;
...
@@ -82,7 +82,7 @@ function Workbench() {
...
@@ -82,7 +82,7 @@ function Workbench() {
* ⇒ 这个参数是那条设计的另一半,⛔ 删了它那颗按钮就变成一句空话。
* ⇒ 这个参数是那条设计的另一半,⛔ 删了它那颗按钮就变成一句空话。
*/
*/
const
focusConv
=
useSearchParams
().
get
(
'conv'
);
const
focusConv
=
useSearchParams
().
get
(
'conv'
);
const
[
me
,
setMe
]
=
useState
<
Me
|
null
>
(
null
);
const
{
me
,
setMe
}
=
useWorkbenchMe
(
);
/**
/**
* ⚠️ 默认是「全部」不是「等待中」—— 那一档的胶囊已经去掉了(见 conv-list 的 FILTERS)。
* ⚠️ 默认是「全部」不是「等待中」—— 那一档的胶囊已经去掉了(见 conv-list 的 FILTERS)。
* ⛔ 默认值留成 'waiting' 的话,界面上没有任何胶囊是选中的,
* ⛔ 默认值留成 'waiting' 的话,界面上没有任何胶囊是选中的,
...
@@ -176,21 +176,6 @@ function Workbench() {
...
@@ -176,21 +176,6 @@ function Workbench() {
[
router
],
[
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`)。
* 当前诊所 —— ⭐ **和侧栏、通讯录、预约确认共用同一份状态**(见 `useClinic`)。
* ⛔ 别在这里再读一次 localStorage:两份必然漂。
* ⛔ 别在这里再读一次 localStorage:两份必然漂。
...
...
apps/ai-web/src/components/admin/admin-shell.tsx
View file @
a33528bc
'use client'
;
'use client'
;
import
{
useWorkbenchMe
}
from
'@/components/workbench-provider'
;
import
{
loginDestination
}
from
'@/lib/auth-session'
;
import
{
loginDestination
}
from
'@/lib/auth-session'
;
import
{
useCallback
,
use
Effect
,
use
State
,
type
ReactNode
}
from
'react'
;
import
{
useCallback
,
useState
,
type
ReactNode
}
from
'react'
;
import
{
useRouter
}
from
'next/navigation'
;
import
{
useRouter
}
from
'next/navigation'
;
import
{
ADMIN_PERMS
,
api
,
ApiError
,
can
,
type
Me
}
from
'@/lib/api'
;
import
{
ADMIN_PERMS
,
api
,
ApiError
,
can
,
type
Me
}
from
'@/lib/api'
;
import
{
AccountsMenu
,
type
BindRequest
}
from
'@/components/accounts-menu'
;
import
{
AccountsMenu
,
type
BindRequest
}
from
'@/components/accounts-menu'
;
...
@@ -25,7 +26,7 @@ export function AdminShell({
...
@@ -25,7 +26,7 @@ export function AdminShell({
children
:
(
me
:
Me
,
guard
:
(
e
:
unknown
)
=>
void
)
=>
ReactNode
;
children
:
(
me
:
Me
,
guard
:
(
e
:
unknown
)
=>
void
)
=>
ReactNode
;
})
{
})
{
const
router
=
useRouter
();
const
router
=
useRouter
();
const
[
me
,
setMe
]
=
useState
<
Me
|
null
>
(
null
);
const
{
me
,
setMe
}
=
useWorkbenchMe
(
);
const
{
clinics
,
clinicId
,
pickClinic
}
=
useClinic
(
me
);
const
{
clinics
,
clinicId
,
pickClinic
}
=
useClinic
(
me
);
const
[
showAccounts
,
setShowAccounts
]
=
useState
(
false
);
const
[
showAccounts
,
setShowAccounts
]
=
useState
(
false
);
const
[
bind
,
setBind
]
=
useState
<
BindRequest
|
null
>
(
null
);
const
[
bind
,
setBind
]
=
useState
<
BindRequest
|
null
>
(
null
);
...
@@ -38,13 +39,6 @@ export function AdminShell({
...
@@ -38,13 +39,6 @@ export function AdminShell({
[
router
],
[
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
));
const
allowed
=
!!
me
&&
ADMIN_PERMS
.
some
((
p
)
=>
can
(
me
,
p
));
return
(
return
(
...
...
apps/ai-web/src/components/sidebar.tsx
View file @
a33528bc
...
@@ -350,7 +350,9 @@ export function Sidebar({
...
@@ -350,7 +350,9 @@ export function Sidebar({
⭐ 部分掉线要**能看出是部分** —— 「1/2 掉线」比「已掉线」准确,
⭐ 部分掉线要**能看出是部分** —— 「1/2 掉线」比「已掉线」准确,
而准确在这里就是"要不要现在去处理"的判断依据。
而准确在这里就是"要不要现在去处理"的判断依据。
*/
}
*/
}
{
acct
.
state
===
'none'
?
(
{
!
me
?
(
<
div
className=
"text-[10.5px] text-slate-300"
>
加载中…
</
div
>
)
:
acct
.
state
===
'none'
?
(
/*
/*
⭐⭐ **未绑时也能点**(2026-09-21 用户提的,刚入驻、在组织架构页上撞到)。
⭐⭐ **未绑时也能点**(2026-09-21 用户提的,刚入驻、在组织架构页上撞到)。
⚠️ 原来这里是纯文字,理由是「主区那张引导卡已经拿着唯一的动作」——
⚠️ 原来这里是纯文字,理由是「主区那张引导卡已经拿着唯一的动作」——
...
...
apps/ai-web/src/components/workbench-provider.tsx
0 → 100644
View file @
a33528bc
'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
};
}
apps/ai-web/src/components/workspace-switch.tsx
View file @
a33528bc
'use client'
;
'use client'
;
import
{
useEffect
,
useState
}
from
'react'
;
import
{
useEffect
}
from
'react'
;
import
Link
from
'next/link'
;
import
Link
from
'next/link'
;
import
{
api
}
from
'@/lib/api
'
;
import
{
currentHostContext
,
currentSessionId
}
from
'@/lib/auth-session
'
;
import
{
currentHostContext
}
from
'@/lib/auth-session
'
;
import
{
useWorkbenchState
}
from
'@/components/workbench-provider
'
;
import
{
Button
}
from
'@/components/ui/button'
;
import
{
Button
}
from
'@/components/ui/button'
;
/** 仅双重身份显示;宿主内嵌保持宿主授予的范围。 */
/** 仅双重身份显示;宿主内嵌保持宿主授予的范围。 */
export
function
WorkspaceSwitch
({
confirmLeave
}:
{
confirmLeave
?:
()
=>
boolean
})
{
export
function
WorkspaceSwitch
({
confirmLeave
}:
{
confirmLeave
?:
()
=>
boolean
})
{
const
[
visible
,
setVisible
]
=
useState
(
false
);
const
{
store
,
snapshot
}
=
useWorkbenchState
();
const
sid
=
currentSessionId
();
useEffect
(()
=>
{
useEffect
(()
=>
{
if
(
currentHostContext
())
return
;
if
(
currentHostContext
())
return
;
let
cancelled
=
false
;
void
store
.
loadWorkspaces
();
api
},
[
store
,
sid
]);
.
get
<
{
workspaces
:
string
[]
}
>
('/auth/workspaces')
if
((
snapshot
.
workspaceCount
??
0
)
<
2
)
return
null
;
.then((r) =
>
{
if
(
!
cancelled
)
setVisible
(
r
.
workspaces
.
length
>
1
);
}
)
.catch(() =
>
{
if
(
!
cancelled
)
setVisible
(
false
);
}
);
return () =
>
{
cancelled
=
true
;
}
;
}, []);
if (!visible) return null;
return
(
return
(
<
Button
variant=
"ghost"
asChild
>
<
Button
variant=
"ghost"
asChild
>
<
Link
<
Link
...
...
apps/ai-web/src/lib/use-clinic.ts
View file @
a33528bc
'use client'
;
'use client'
;
import
{
useCallback
,
useEffect
,
useState
,
useMemo
}
from
'react'
;
import
type
{
ClinicOption
,
Me
}
from
'@/lib/api'
;
import
type
{
ClinicOption
,
Me
}
from
'@/lib/api'
;
import
{
currentSessionId
}
from
'@/lib/auth-session'
;
import
{
useWorkbenchState
}
from
'@/components/workbench-provider'
;
import
{
clinicStorageKey
,
pickDefaultClinic
}
from
'@/lib/clinic-selection'
;
export
{
pickDefaultClinic
}
from
'@/lib/clinic-selection'
;
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
;
export
function
useClinic
(
me
:
Me
|
null
):
{
const
[
selection
,
setSelection
]
=
useState
<
{
key
:
string
;
id
:
string
|
null
}
|
null
>
(
null
);
clinics
:
ClinicOption
[];
useEffect
(()
=>
{
clinicId
:
string
|
null
;
if
(
!
key
||
!
me
)
return
;
pickClinic
:
(
id
:
string
)
=>
void
;
let
remembered
:
string
|
null
=
null
;
}
{
try
{
remembered
=
localStorage
.
getItem
(
key
);}
catch
{
/* 存储不可用仍能操作 */
}
const
{
store
,
snapshot
}
=
useWorkbenchState
();
// 新 SSO 会话第一次使用宿主初始诊所;后续刷新优先该窗口已保存的选择。
return
{
const
id
=
remembered
??
me
.
hostContext
?.
clinicId
??
null
;
clinics
:
me
?.
clinics
??
[],
setSelection
({
key
,
id
});
clinicId
:
me
?
snapshot
.
clinicId
:
null
,
if
(
id
)
try
{
localStorage
.
setItem
(
key
,
id
);}
catch
{
/* 留在内存 */
}
pickClinic
:
store
.
pickClinic
,
},[
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
};
}
}
apps/ai-web/src/lib/workbench-session.ts
0 → 100644
View file @
a33528bc
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
;
};
}
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment