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
efc2f644
Commit
efc2f644
authored
Oct 14, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Plain Diff
Merge FridayAI sidebar state fix into main
parents
584bd3dd
a33528bc
Pipeline
#3811
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 @
efc2f644
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 @
efc2f644
'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
);
...
...
apps/ai-web/src/app/agents/appointment-confirm/page.tsx
View file @
efc2f644
'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` /
* 患者卡),这一页原来一处都没有 —— 而这一页比那边更需要:
...
...
apps/ai-web/src/app/clinic/page.tsx
View file @
efc2f644
'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
()
...
...
apps/ai-web/src/app/contacts/page.tsx
View file @
efc2f644
'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
);
...
...
apps/ai-web/src/app/corpus/page.tsx
View file @
efc2f644
'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
]);
...
...
apps/ai-web/src/app/debug/page.tsx
View file @
efc2f644
'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
(
...
...
apps/ai-web/src/app/layout.tsx
View file @
efc2f644
...
...
@@ -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
/>
...
...
apps/ai-web/src/app/page.tsx
View file @
efc2f644
'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:两份必然漂。
...
...
apps/ai-web/src/components/admin/admin-shell.tsx
View file @
efc2f644
'use client'
;
import
{
useWorkbenchMe
}
from
'@/components/workbench-provider'
;
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
{
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
(
...
...
apps/ai-web/src/components/sidebar.tsx
View file @
efc2f644
...
...
@@ -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 用户提的,刚入驻、在组织架构页上撞到)。
⚠️ 原来这里是纯文字,理由是「主区那张引导卡已经拿着唯一的动作」——
...
...
apps/ai-web/src/components/workbench-provider.tsx
0 → 100644
View file @
efc2f644
'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 @
efc2f644
'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
...
...
apps/ai-web/src/lib/use-clinic.ts
View file @
efc2f644
'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
,
};
}
apps/ai-web/src/lib/workbench-session.ts
0 → 100644
View file @
efc2f644
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