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
668da7d0
Commit
668da7d0
authored
Oct 14, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Plain Diff
Merge FridayAI navigation state fix into test
parents
79b447bf
a33528bc
Pipeline
#3806
failed in 0 seconds
Changes
16
Pipelines
1
Hide whitespace changes
Inline
Side-by-side
Showing
16 changed files
with
475 additions
and
126 deletions
+475
-126
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
docs/qa/2026-10-14-push-latency-local.md
+23
-1
No files found.
apps/ai-service/tests/workbench-session.spec.ts
0 → 100644
View file @
668da7d0
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 @
668da7d0
'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 @
668da7d0
'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 @
668da7d0
'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 @
668da7d0
'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 @
668da7d0
'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 @
668da7d0
'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 @
668da7d0
...
@@ -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 @
668da7d0
'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 @
668da7d0
'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 @
668da7d0
...
@@ -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 @
668da7d0
'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 @
668da7d0
'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 @
668da7d0
'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 @
668da7d0
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
;
};
}
docs/qa/2026-10-14-push-latency-local.md
View file @
668da7d0
# Push 关键路径优化与排障(本地)
# Push 关键路径优化与排障(本地)
分支
`codex/push-latency-fix`
,基于 main
`99fb2648cacdafcd0f26ec8540d04c8465ecbb87`
。独立 worktree,主目录及正在运行的本地服务未变。
未提交、未部署
。
分支
`codex/push-latency-fix`
,基于 main
`99fb2648cacdafcd0f26ec8540d04c8465ecbb87`
。独立 worktree,主目录及正在运行的本地服务未变。
已提交并部署测试;生产未改动
。
## 最终边界
## 最终边界
...
@@ -38,3 +38,25 @@
...
@@ -38,3 +38,25 @@
## 后续验证
## 后续验证
上线前执行唯一索引迁移;CONCURRENTLY 不包事务,中断后检查索引有效性。上线后按断连 traceId → evidenceId/syncLogId → 阶段耗时,与同期 pg_stat_activity/磁盘读写关联。不能用本地通过或健康接口快替代真实慢批次验证。
上线前执行唯一索引迁移;CONCURRENTLY 不包事务,中断后检查索引有效性。上线后按断连 traceId → evidenceId/syncLogId → 阶段耗时,与同期 pg_stat_activity/磁盘读写关联。不能用本地通过或健康接口快替代真实慢批次验证。
## 测试发布(2026-10-14)
-
功能提交
`a94d8dc`
;test 合并提交
`79b447bffe5b167ac32f721bb112d6b724aaa77a`
,远端已核对。main 仍为
`99fb2648cacdafcd0f26ec8540d04c8465ecbb87`
,未推 main。
-
测试发布北京时间 18:46:27—18:52:09,标准发布脚本
`--no-pull`
,退出码 0。源码、旧镜像标签、私密 env 备份及完整日志位于测试机
`/root/pac-ops/push-latency-20261014`
。
-
后端镜像
`e05406bc3705`
,工作台镜像
`0e3429264cde`
,发布脚本确认实际运行镜像一致。配置文件逐字比对未变。
-
唯一迁移
`20261014110100_push_drift_index`
于 18:51:47 完成;
`indisready=true`
、
`indisvalid=true`
,Prisma 无待执行迁移。历史表约 2,890,157 数据块,建索引及校验占本轮主要发布时间。
-
索引验证:按测试 Friday host + appointment + created_at DESC LIMIT 50 的 EXPLAIN 命中
`patient_txn_drift_sample_idx`
;没有在线执行全表基准。
-
本机
`/health`
、公网
`https://pac.jarvismedical.asia/health`
均返回 JSON status=ok;本机工作台
`/plans`
200。
-
新容器编译产物存在
`response-finished`
日志。发布后初次检查没有新的真实 push;最近主档批次 18:47:18(旧版),不能作为新版本性能结果。
-
已创建本线程每 5 分钟只读跟踪,限测试 Friday 预约批次;不制造数据、不重放、不触碰生产。无变化静默;取得真实样本后汇总并暂停,最迟次日 19:00 无样本时提醒并暂停。
-
测试文档站同步发布退出码 0;
`/docs/integration/channel-push`
返回 200。
## 新预约实测(2026-10-14 18:56:25 北京时间)
-
用户在 FridaySaaS 测试环境新建预约后,PAC 收到
`appointment_base`
1 行;syncLogId
`ef40865b-f60a-4f6b-9757-8590cf22fdb4`
,traceId
`07a7951a-cd27-449f-8962-70cc64bdeb26`
。
-
业务结果 success,failed=0,transactions_written=1,facts_emitted=1;sync 记录 104ms,HTTP 响应完成 167ms,无本请求断连日志。
-
验签 4ms、原文留档 3ms、完整业务管线 151ms、画像入队 7ms。管线内预约处理 89ms,其中流水写入 28ms、查回 6ms、事实写入 46ms;其余为查找/转换、初始化、记录等。这些是嵌套阶段,不能重复相加。
-
历史字段审计在后台完成 20ms,不阻塞响应。同期主档请求 195ms。健康 JSON status=ok。
-
本条真实预约没有慢环节,确认业务落库后毫秒级返回;它不能证明此前分钟级超时的具体根因,也不是宿主建预约到消费响应的端到端计时。
-
已获得真实预约样本,暂停本轮定时跟踪。生产未修改。
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