@allbluecn/web-app 0.10.3 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +21 -11
- package/public/favicon.ico +0 -0
- package/public/logo/allblue-logo-app-icon.svg +7 -0
- package/public/logo/allblue-logo-dark-square.svg +4 -0
- package/public/logo/allblue-logo-dark.svg +4 -0
- package/public/logo/allblue-logo-light-square.svg +4 -0
- package/public/logo/allblue-logo-light.svg +4 -0
- package/src/__tests__/bench/baselines/format-timestamp.json +44 -0
- package/src/__tests__/bench/core-hot-path.bench.ts +71 -0
- package/src/__tests__/integration/db-crud.integration.test.ts +79 -0
- package/src/__tests__/rejects-tothrow-regression.test.ts +31 -0
- package/src/__tests__/security/ai/prompt-injection.test.ts +105 -0
- package/src/__tests__/security/ssrf-url-whitelist.test.ts +54 -0
- package/src/__tests__/security/xss-sanitize.test.ts +99 -0
- package/src/__tests__/setup.ts +9 -0
- package/src/__tests__/visual/__screenshots__/theme-surface.vrt.test.tsx/theme-surface-dark-baseline-1-chromium-darwin.png +0 -0
- package/src/__tests__/visual/__screenshots__/theme-surface.vrt.test.tsx/theme-surface-light-baseline-1-chromium-darwin.png +0 -0
- package/src/__tests__/visual/__screenshots__/user-avatar.vrt.test.tsx/user-avatar-initial-fallback-baseline-1-chromium-darwin.png +0 -0
- package/src/__tests__/visual/theme-surface.vrt.test.tsx +54 -0
- package/src/__tests__/visual/user-avatar.vrt.test.tsx +32 -0
- package/src/components/error/global-error-boundary.tsx +2 -2
- package/src/components/knowledge/general-groups.ts +15 -0
- package/src/components/knowledge/kb-filter-popover.tsx +143 -0
- package/src/components/knowledge-public/legal-compliance-editor.tsx +4 -2
- package/src/components/layout/sidebar-03/__tests__/nav-workspace.test.tsx +9 -6
- package/src/components/layout/sidebar-03/nav-knowledge.tsx +19 -17
- package/src/components/layout/sidebar-03/nav-workspace.tsx +20 -22
- package/src/components/shared-kit/avatars/notion-avatar.tsx +2 -1
- package/src/components/shared-kit/icons/__tests__/lucide-curated-icons.test.ts +2 -2
- package/src/components/shared-kit/icons/__tests__/lucide-icon-picker.test.tsx +3 -2
- package/src/components/shared-kit/icons/lucide-icon-picker/curated-icons.ts +5 -10
- package/src/components/shared-kit/icons/lucide-icon-picker/lucide-icon-by-name.tsx +3 -3
- package/src/components/shared-kit/theme/theme-surface.tsx +52 -0
- package/src/components/story/relations/general-groups.ts +1 -15
- package/src/components/story/relations/knowledge-select-dialog.tsx +13 -87
- package/src/components/story/story-list-view.tsx +7 -1
- package/src/components/tiptap/node/table-node/ui/table-move-row-column-button/use-table-move-row-column.ts +0 -2
- package/src/components/workspace/__tests__/activity/workspace-activity-card.test.tsx +36 -0
- package/src/components/workspace/__tests__/contribution/workspace-contribution-card.test.tsx +17 -0
- package/src/components/workspace/__tests__/dialogs/create-workspace-dialog.test.tsx +63 -0
- package/src/components/workspace/__tests__/dialogs/delete-workspace-options.test.tsx +89 -0
- package/src/components/workspace/__tests__/dialogs/edit-workspace-dialog.test.tsx +68 -0
- package/src/components/workspace/__tests__/header/workspace-header.test.tsx +40 -0
- package/src/components/workspace/__tests__/kb/workspace-kb-card.test.tsx +154 -0
- package/src/components/workspace/__tests__/members/workspace-member-item.test.tsx +24 -0
- package/src/components/workspace/__tests__/members/workspace-members-manager.test.tsx +51 -0
- package/src/components/workspace/__tests__/projects/workspace-project-list.test.tsx +46 -0
- package/src/components/workspace/{create-workspace-dialog.tsx → dialogs/create-workspace-dialog.tsx} +1 -1
- package/src/components/workspace/{edit-workspace-dialog.tsx → dialogs/edit-workspace-dialog.tsx} +1 -1
- package/src/components/workspace/kb/workspace-kb-card.tsx +594 -0
- package/src/components/workspace/kb/workspace-kb-manage-dialog.tsx +174 -0
- package/src/components/workspace/kb/workspace-quick-doc-dialog.tsx +431 -0
- package/src/components/workspace/{workspace-members-manager.tsx → members/workspace-members-manager.tsx} +78 -40
- package/src/lib/__tests__/msw-sample.test.ts +68 -0
- package/src/lib/__tests__/pgvector.test.ts +2 -2
- package/src/lib/changelog/sdk-versions.ts +21 -21
- package/src/lib/collections/knowledge/knowledge-bases-collection.ts +3 -1
- package/src/lib/collections/reference/workspaces-collection.ts +3 -1
- package/src/lib/fetch-json.ts +23 -0
- package/src/{components/workspace/workspace-kb-card.tsx → lib/i18n/resolver.ts} +6 -12
- package/src/lib/sanitize.ts +14 -1
- package/src/lib/session.ts +9 -1
- package/src/lib/vector-store/__tests__/factory.test.ts +1 -1
- package/src/lib/vector-store/__tests__/sqlite-vec-store.test.ts +2 -2
- package/src/plugins/core/tags/__tests__/tags.test.ts +10 -10
- package/src/routes/__tests__/-edition-routing.test.tsx +4 -4
- package/src/routes/__tests__/route-tree-structure.test.ts +65 -0
- package/src/routes/_nav/knowledge/compliance/index.lazy.tsx +5 -2
- package/src/routes/_nav/knowledge/index.lazy.tsx +1 -1
- package/src/routes/_nav/knowledge/laws/index.lazy.tsx +5 -2
- package/src/routes/_nav/route.tsx +21 -0
- package/src/routes/_nav/workspaces/$workspaceId/route.lazy.tsx +13 -11
- package/src/routes/_nav/workspaces/$workspaceId/route.tsx +4 -2
- package/src/routes/_nav/workspaces/$workspaceId/settings/route.lazy.tsx +1 -1
- package/src/routes/_nav/workspaces/index.lazy.tsx +3 -3
- package/src/server/__tests__/agent-crud.test.ts +4 -4
- package/src/server/__tests__/chat-conversations.test.ts +1 -3
- package/src/server/__tests__/fetch-guard.test.ts +99 -0
- package/src/server/__tests__/permissions.test.ts +1 -3
- package/src/server/__tests__/resources.team-seats.test.ts +2 -0
- package/src/server/__tests__/skill-crud.test.ts +3 -3
- package/src/server/ai-gateway/__tests__/ai-output-guard.test.ts +95 -0
- package/src/server/ai-gateway/__tests__/kb-context-guard.test.ts +136 -0
- package/src/server/ai-gateway/__tests__/knowledge-search-fence.test.ts +240 -0
- package/src/server/ai-gateway/__tests__/llm-fence.test.ts +125 -0
- package/src/server/ai-gateway/__tests__/run-store.test.ts +52 -0
- package/src/server/ai-gateway/ai-output-guard.ts +77 -0
- package/src/server/ai-gateway/index.ts +5 -0
- package/src/server/ai-gateway/kb-context-guard.ts +46 -0
- package/src/server/ai-gateway/llm-fence.ts +82 -0
- package/src/server/ai-gateway/persistence/run-store.ts +19 -0
- package/src/server/ai-gateway/story-prompt.ts +4 -2
- package/src/server/ai-gateway/tools/definitions.ts +12 -6
- package/src/server/ai-gateway/tools/server.ts +97 -54
- package/src/server/builtin-project.ts +21 -0
- package/src/server/email/sender.ts +8 -2
- package/src/server/fetch-guard.ts +170 -0
- package/src/server/middlewares/security-headers.ts +113 -0
- package/src/server/serverFns/__tests__/billing.test.ts +263 -0
- package/src/server/serverFns/__tests__/link-preview.test.ts +239 -0
- package/src/server/serverFns/__tests__/project-kb.test.ts +3 -3
- package/src/server/serverFns/__tests__/project-team-assign.test.ts +1 -1
- package/src/server/serverFns/__tests__/story-comments.test.ts +265 -0
- package/src/server/serverFns/__tests__/story-crud-archive-detail.test.ts +266 -0
- package/src/server/serverFns/__tests__/story-crud-labels-users.test.ts +227 -0
- package/src/server/serverFns/__tests__/story-crud.test.ts +526 -0
- package/src/server/serverFns/__tests__/story-links.test.ts +262 -0
- package/src/server/serverFns/__tests__/story-list-query.test.ts +259 -0
- package/src/server/serverFns/__tests__/story-reactions.test.ts +230 -0
- package/src/server/serverFns/__tests__/story-tree.test.ts +317 -0
- package/src/server/serverFns/__tests__/story-types.test.ts +294 -0
- package/src/server/serverFns/__tests__/team-accept.test.ts +6 -6
- package/src/server/serverFns/__tests__/team-invitation.test.ts +3 -3
- package/src/server/serverFns/__tests__/team-join-request.test.ts +8 -8
- package/src/server/serverFns/__tests__/update-seats.test.ts +4 -4
- package/src/server/serverFns/__tests__/workspace-kb-idor.test.ts +160 -0
- package/src/server/serverFns/__tests__/workspace-kb-utils.test.ts +54 -0
- package/src/server/serverFns/__tests__/workspace-kb.test.ts +348 -0
- package/src/server/serverFns/__tests__/workspace.test.ts +28 -8
- package/src/server/serverFns/iteration/__tests__/iteration-crud.test.ts +3 -5
- package/src/server/serverFns/iteration/__tests__/iteration-stories.test.ts +1 -1
- package/src/server/serverFns/iteration/__tests__/iteration-transfer.test.ts +2 -2
- package/src/server/serverFns/kb-doc-search.ts +111 -0
- package/src/server/serverFns/knowledge-public/compliance/create.ts +2 -1
- package/src/server/serverFns/knowledge-public/legal/create.ts +2 -1
- package/src/server/serverFns/story/__tests__/bulk.test.ts +1 -1
- package/src/server/serverFns/story/__tests__/label-suggest-fn.test.ts +2 -6
- package/src/server/serverFns/story/__tests__/story-assoc.test.ts +2 -4
- package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +2 -2
- package/src/server/serverFns/story/__tests__/story-groups.test.ts +2 -2
- package/src/server/serverFns/story/kb-general-consts.ts +39 -0
- package/src/server/serverFns/story/kb-general.ts +30 -17
- package/src/server/serverFns/story/story-assoc.ts +13 -103
- package/src/server/serverFns/story/story-crud.ts +2 -5
- package/src/server/serverFns/story/story-groups.ts +4 -13
- package/src/server/serverFns/team.ts +1 -1
- package/src/server/serverFns/workspace-kb-utils.ts +138 -0
- package/src/server/serverFns/workspace-kb.ts +403 -0
- package/src/server/serverFns/workspace.ts +4 -1
- package/src/shared/ai-cli/__tests__/adapter.test.ts +67 -0
- package/src/shared/ai-cli/__tests__/cli-detector.test.ts +99 -0
- package/src/shared/ai-cli/adapter.ts +2 -2
- package/src/shared/ai-cli/cli-detector.ts +18 -9
- package/src/start.ts +2 -1
- package/src/styles/globals.css +114 -0
- package/src/types/jest-dom-vitest.d.ts +8 -7
- package/vite.shared.ts +18 -1
- package/public/file.svg +0 -1
- package/public/globe.svg +0 -1
- package/public/window.svg +0 -1
- package/src/lib/__tests__/cli-detector.test.ts +0 -80
- /package/src/components/workspace/{workspace-activity-card.tsx → activity/workspace-activity-card.tsx} +0 -0
- /package/src/components/workspace/{workspace-contribution-card.tsx → contribution/workspace-contribution-card.tsx} +0 -0
- /package/src/components/workspace/{delete-workspace-options.tsx → dialogs/delete-workspace-options.tsx} +0 -0
- /package/src/components/workspace/{workspace-header.tsx → header/workspace-header.tsx} +0 -0
- /package/src/components/workspace/{workspace-member-item.tsx → members/workspace-member-item.tsx} +0 -0
- /package/src/components/workspace/{workspace-project-list.tsx → projects/workspace-project-list.tsx} +0 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { isAllowedUri, sanitizeUrl } from '@/components/tiptap/lib/utils/url'
|
|
3
|
+
|
|
4
|
+
// 安全测试样板:SSRF / 协议注入防护(富文本编辑器 URL 白名单)。
|
|
5
|
+
// 对应攻击面:用户构造恶意链接协议(javascript:/data:/file:)进入 DOM。
|
|
6
|
+
// 防线:isAllowedUri 协议白名单(http/https/ftp/ftps/mailto/tel/callto/sms/cid/xmpp)。
|
|
7
|
+
|
|
8
|
+
describe('SSRF 防护:isAllowedUri 协议白名单', () => {
|
|
9
|
+
it.each([
|
|
10
|
+
'javascript:alert(1)',
|
|
11
|
+
'JavaScript:alert(1)', // 大小写绕过
|
|
12
|
+
'data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==',
|
|
13
|
+
'vbscript:msgbox(1)',
|
|
14
|
+
'file:///etc/passwd',
|
|
15
|
+
'chrome://settings',
|
|
16
|
+
])('拒绝危险协议:%s', (uri) => {
|
|
17
|
+
expect(isAllowedUri(uri)).toBeFalsy()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it.each([
|
|
21
|
+
'https://example.com/page',
|
|
22
|
+
'http://example.com',
|
|
23
|
+
'mailto:user@example.com',
|
|
24
|
+
'tel:+8613800000000',
|
|
25
|
+
'/relative/path',
|
|
26
|
+
'#anchor',
|
|
27
|
+
undefined,
|
|
28
|
+
])('放行安全 URI:%s', (uri) => {
|
|
29
|
+
expect(isAllowedUri(uri)).toBeTruthy()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('空白字符混淆绕过被拦截(\\u0000-\\u0020 控制字符剥离后仍校验协议)', () => {
|
|
33
|
+
// "java\tscript:" 类混淆:控制字符剥离后按协议匹配拒绝
|
|
34
|
+
expect(isAllowedUri('java\tscript:alert(1)')).toBeFalsy()
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('SSRF 防护:sanitizeUrl 兜底为 #', () => {
|
|
39
|
+
it('危险协议链接降级为 #(不进入 DOM 的 href)', () => {
|
|
40
|
+
expect(sanitizeUrl('javascript:alert(1)', 'https://example.com')).toBe('#')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('合法 URL 解析为绝对地址放行', () => {
|
|
44
|
+
expect(sanitizeUrl('/docs/a', 'https://example.com')).toBe('https://example.com/docs/a')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// 已知边界(gap,非缺陷):isAllowedUri 只校验协议不校验主机。
|
|
48
|
+
// 云元数据地址(169.254.169.254)/内网 IP 在协议白名单内会被放行。
|
|
49
|
+
// 编辑器场景(仅生成链接不服务端 fetch)可接受;服务端 fetch 场景
|
|
50
|
+
// (SearXNG web_search / 附件抓取)需要独立的主机级白名单,见测试方案设计文档。
|
|
51
|
+
it('边界记录:内网地址协议合法时被放行(仅协议级校验)', () => {
|
|
52
|
+
expect(isAllowedUri('http://169.254.169.254/latest/meta-data')).toBeTruthy()
|
|
53
|
+
})
|
|
54
|
+
})
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { sanitize, sanitizeSvg } from '@/lib/sanitize'
|
|
3
|
+
|
|
4
|
+
// 安全测试样板:XSS 防护(渲染链路 DOMPurify 清理)。
|
|
5
|
+
// 对应攻击面:用户输入 → 富文本/HTML 渲染 → 存储型/反射型 XSS。
|
|
6
|
+
// 防线:AGENTS.md 约定「所有 HTML 渲染必须经过 sanitize() 清理」。
|
|
7
|
+
|
|
8
|
+
describe('XSS 防护:sanitize() 攻击载荷清理', () => {
|
|
9
|
+
it.each([
|
|
10
|
+
['script 标签注入', '<script>alert("xss")</script>', 'script'],
|
|
11
|
+
['img onerror 事件注入', '<img src=x onerror="alert(1)">', 'onerror'],
|
|
12
|
+
['内联事件属性注入', '<div onclick="steal()">click</div>', 'onclick'],
|
|
13
|
+
['javascript: 协议链接', '<a href="javascript:alert(1)">click</a>', 'javascript:'],
|
|
14
|
+
['iframe 嵌套注入', '<iframe src="https://evil.example"></iframe>', 'iframe'],
|
|
15
|
+
['SVG onload 注入', '<svg onload="alert(1)"></svg>', 'onload'],
|
|
16
|
+
])('清理 %s', (_name, payload, marker) => {
|
|
17
|
+
const cleaned = sanitize(payload)
|
|
18
|
+
expect(cleaned.toLowerCase()).not.toContain(marker)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('合法内容不被误杀(保真性)', () => {
|
|
22
|
+
const html = '<p>hello <strong>world</strong> <a href="https://example.com">link</a></p>'
|
|
23
|
+
const cleaned = sanitize(html)
|
|
24
|
+
expect(cleaned).toContain('<p>hello <strong>world</strong>')
|
|
25
|
+
expect(cleaned).toContain('href="https://example.com"')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('混合攻击载荷与正常内容的定向清理', () => {
|
|
29
|
+
const html = '<p>正常段落</p><script>evil()</script><p>另一段</p>'
|
|
30
|
+
const cleaned = sanitize(html)
|
|
31
|
+
expect(cleaned).toContain('正常段落')
|
|
32
|
+
expect(cleaned).toContain('另一段')
|
|
33
|
+
expect(cleaned).not.toContain('evil()')
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
// sanitizeSvg 双向断言:
|
|
38
|
+
// - 正向(不误杀):NotionAvatar 依赖的 SVG 滤镜链结构完整保留,任何 DOMPurify 升级
|
|
39
|
+
// 若静默改变默认配置破坏滤镜链,此处立即报红,防止头像渲染白屏
|
|
40
|
+
// - 反向(不放过):SVG 内嵌的 script / 事件处理器 / javascript: 协议仍被清除,
|
|
41
|
+
// 静态资源被篡改时提供纵深防御
|
|
42
|
+
describe('XSS 防护:sanitizeSvg() SVG 结构完整性 + 恶意内容清理', () => {
|
|
43
|
+
// 与 notion-avatar.tsx 中 SVGFilter 常量 + buildAvatar() 输出同构的样例
|
|
44
|
+
const avatarSvg = `<svg viewBox="0 0 1080 1080" fill="none" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:100%">
|
|
45
|
+
<defs>
|
|
46
|
+
<filter id="notion-avatar-filter" x="-20%" y="-20%" width="140%" height="140%" filterUnits="objectBoundingBox" primitiveUnits="userSpaceOnUse" color-interpolation-filters="linearRGB">
|
|
47
|
+
<feMorphology operator="dilate" radius="20" in="SourceAlpha" result="morphology"/>
|
|
48
|
+
<feFlood flood-color="#ffffff" flood-opacity="1" result="flood"/>
|
|
49
|
+
<feComposite in="flood" in2="morphology" operator="in" result="composite"/>
|
|
50
|
+
<feMerge result="merge">
|
|
51
|
+
<feMergeNode in="composite" result="mergeNode"/>
|
|
52
|
+
<feMergeNode in="SourceGraphic" result="mergeNode1"/>
|
|
53
|
+
</feMerge>
|
|
54
|
+
</filter>
|
|
55
|
+
</defs>
|
|
56
|
+
<g id="avatar" transform="scale(-1,1) translate(-1080, 0)">
|
|
57
|
+
<path d="M10 20 L100 50" stroke="#000" fill="#fff"/>
|
|
58
|
+
</g>
|
|
59
|
+
</svg>`
|
|
60
|
+
|
|
61
|
+
it.each([
|
|
62
|
+
['<defs> 元素', /<defs[\s>]/],
|
|
63
|
+
['<filter> 元素', /<filter[\s>]/],
|
|
64
|
+
['<feMorphology> 元素', /<feMorphology[\s>]/],
|
|
65
|
+
['<feFlood> 元素', /<feFlood[\s>]/],
|
|
66
|
+
['<feComposite> 元素', /<feComposite[\s>]/],
|
|
67
|
+
['<feMerge> 元素', /<feMerge[\s>]/],
|
|
68
|
+
['<feMergeNode> 元素', /<feMergeNode[\s>]/],
|
|
69
|
+
['<g> 元素', /<g[\s>]/],
|
|
70
|
+
['<path> 元素', /<path[\s>]/],
|
|
71
|
+
['filterUnits 属性', /filterUnits=/],
|
|
72
|
+
['primitiveUnits 属性', /primitiveUnits=/],
|
|
73
|
+
['color-interpolation-filters 属性', /color-interpolation-filters=/],
|
|
74
|
+
['operator 属性', /operator=/],
|
|
75
|
+
['radius 属性', /radius=/],
|
|
76
|
+
['flood-color 属性', /flood-color=/],
|
|
77
|
+
['flood-opacity 属性', /flood-opacity=/],
|
|
78
|
+
[' in 属性', / in=/],
|
|
79
|
+
[' in2 属性', / in2=/],
|
|
80
|
+
[' result 属性', / result=/],
|
|
81
|
+
['transform 属性', /transform=/],
|
|
82
|
+
['viewBox 属性', /viewBox=/],
|
|
83
|
+
['xmlns 属性', /xmlns=/],
|
|
84
|
+
])('%s 保留', (_name, re) => {
|
|
85
|
+
const cleaned = sanitizeSvg(avatarSvg)
|
|
86
|
+
expect(cleaned).toMatch(re)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it.each([
|
|
90
|
+
['script 标签', '<svg><script>alert(1)</script></svg>', /<script/i],
|
|
91
|
+
['path onload 事件', '<svg><path onload="alert(1)"/></svg>', /onload=/i],
|
|
92
|
+
['svg onload 事件', '<svg onload="alert(1)"></svg>', /onload=/i],
|
|
93
|
+
['a javascript: 协议', '<svg><a href="javascript:alert(1)">x</a></svg>', /javascript:/i],
|
|
94
|
+
['foreignObject 嵌套', '<svg><foreignObject><body onload="alert(1)"></foreignObject></svg>', /onload=/i],
|
|
95
|
+
])('%s 被清除', (_name, payload, marker) => {
|
|
96
|
+
const cleaned = sanitizeSvg(payload)
|
|
97
|
+
expect(cleaned).not.toMatch(marker)
|
|
98
|
+
})
|
|
99
|
+
})
|
package/src/__tests__/setup.ts
CHANGED
|
@@ -20,6 +20,15 @@ import '@testing-library/jest-dom/vitest'
|
|
|
20
20
|
|
|
21
21
|
import { overwriteGetLocale } from '@/paraglide/runtime.js'
|
|
22
22
|
|
|
23
|
+
// jsdom 无 ResizeObserver(@dnd-kit/dom 等库模块加载时引用)
|
|
24
|
+
if (typeof globalThis.ResizeObserver === 'undefined') {
|
|
25
|
+
globalThis.ResizeObserver = class ResizeObserver {
|
|
26
|
+
observe() {}
|
|
27
|
+
unobserve() {}
|
|
28
|
+
disconnect() {}
|
|
29
|
+
} as unknown as typeof globalThis.ResizeObserver
|
|
30
|
+
}
|
|
31
|
+
|
|
23
32
|
// Global i18n key resolver for test mocks
|
|
24
33
|
const zhMessages = JSON.parse(
|
|
25
34
|
require('fs').readFileSync(
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
//
|
|
5
|
+
// This program is free software: you can redistribute it and/or modify
|
|
6
|
+
// it under the terms of the GNU Affero General Public License as published by
|
|
7
|
+
// the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
// (at your option) any later version.
|
|
9
|
+
//
|
|
10
|
+
// This program is distributed in the hope that it will be useful,
|
|
11
|
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
// GNU Affero General Public License for more details.
|
|
14
|
+
//
|
|
15
|
+
// You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
// VRT 视觉回归:主题表面聚合组件的亮色/暗色渲染基线。
|
|
19
|
+
// 基线由 darwin 本机生成并提交入库;本机即门禁环境(同一台机器生成+对比)。
|
|
20
|
+
|
|
21
|
+
import { expect, test } from 'vitest'
|
|
22
|
+
import { page } from 'vitest/browser'
|
|
23
|
+
import { render } from 'vitest-browser-react'
|
|
24
|
+
import { ThemeProvider } from 'next-themes'
|
|
25
|
+
import { TooltipProvider } from '@/components/ui/tooltip'
|
|
26
|
+
import { ThemeSurface } from '@/components/shared-kit/theme/theme-surface'
|
|
27
|
+
|
|
28
|
+
test('theme surface light baseline', async () => {
|
|
29
|
+
await render(
|
|
30
|
+
<ThemeProvider forcedTheme="light">
|
|
31
|
+
<TooltipProvider>
|
|
32
|
+
<ThemeSurface />
|
|
33
|
+
</TooltipProvider>
|
|
34
|
+
</ThemeProvider>,
|
|
35
|
+
)
|
|
36
|
+
await expect(page.getByTestId('theme-surface')).toMatchScreenshot({
|
|
37
|
+
comparatorName: 'pixelmatch',
|
|
38
|
+
comparatorOptions: { threshold: 0.2, allowedMismatchedPixelRatio: 0.01 },
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('theme surface dark baseline', async () => {
|
|
43
|
+
await render(
|
|
44
|
+
<ThemeProvider forcedTheme="dark">
|
|
45
|
+
<TooltipProvider>
|
|
46
|
+
<ThemeSurface />
|
|
47
|
+
</TooltipProvider>
|
|
48
|
+
</ThemeProvider>,
|
|
49
|
+
)
|
|
50
|
+
await expect(page.getByTestId('theme-surface')).toMatchScreenshot({
|
|
51
|
+
comparatorName: 'pixelmatch',
|
|
52
|
+
comparatorOptions: { threshold: 0.2, allowedMismatchedPixelRatio: 0.01 },
|
|
53
|
+
})
|
|
54
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
//
|
|
5
|
+
// This program is free software: you can redistribute it and/or modify
|
|
6
|
+
// it under the terms of the GNU Affero General Public License as published by
|
|
7
|
+
// the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
// (at your option) any later version.
|
|
9
|
+
//
|
|
10
|
+
// This program is distributed in the hope that it will be useful,
|
|
11
|
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
// GNU Affero General Public License for more details.
|
|
14
|
+
//
|
|
15
|
+
// You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
// VRT 视觉回归:UserAvatar 首字母 fallback 渲染基线。
|
|
19
|
+
|
|
20
|
+
import { expect, test } from 'vitest'
|
|
21
|
+
import { page } from 'vitest/browser'
|
|
22
|
+
import { render } from 'vitest-browser-react'
|
|
23
|
+
import { UserAvatar } from '@/components/shared-kit'
|
|
24
|
+
|
|
25
|
+
test('user avatar initial fallback baseline', async () => {
|
|
26
|
+
const screen = await render(<UserAvatar name="张三" size="lg" />)
|
|
27
|
+
const root = screen.container.firstChild as HTMLElement
|
|
28
|
+
await expect(page.elementLocator(root)).toMatchScreenshot({
|
|
29
|
+
comparatorName: 'pixelmatch',
|
|
30
|
+
comparatorOptions: { threshold: 0.2, allowedMismatchedPixelRatio: 0.01 },
|
|
31
|
+
})
|
|
32
|
+
})
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { Link, useLocation } from '@tanstack/react-router'
|
|
19
19
|
import { useMemo } from 'react'
|
|
20
|
-
import { LogIn,
|
|
20
|
+
import { LogIn, CircleAlert } from 'lucide-react'
|
|
21
21
|
import { isUnauthorizedError } from '@/lib/auth/anonymous-safe'
|
|
22
22
|
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'
|
|
23
23
|
import { Button } from '@/components/ui/button'
|
|
@@ -41,7 +41,7 @@ function GlobalErrorBoundaryInner({ error, info }: { error: unknown; info?: { co
|
|
|
41
41
|
<Empty>
|
|
42
42
|
<EmptyHeader>
|
|
43
43
|
<EmptyMedia variant="icon">
|
|
44
|
-
{unauthorized ? <LogIn /> : <
|
|
44
|
+
{unauthorized ? <LogIn className="size-5" /> : <CircleAlert className="size-5" />}
|
|
45
45
|
</EmptyMedia>
|
|
46
46
|
<EmptyTitle>
|
|
47
47
|
{unauthorized ? m.common_errorPage_loginRequired() : m.common_errorPage_error()}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { m } from '@/paraglide/messages.js'
|
|
2
|
+
|
|
3
|
+
const GROUP_LABELS: Record<string, () => string> = {
|
|
4
|
+
legal: m.story_knowledgeDialog_groupLegal,
|
|
5
|
+
compliance: m.story_knowledgeDialog_groupCompliance,
|
|
6
|
+
dictionary: m.story_knowledgeDialog_groupDictionary,
|
|
7
|
+
bookmark: m.story_knowledgeDialog_groupBookmark,
|
|
8
|
+
summary: m.story_knowledgeDialog_groupSummary,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** 通用知识库分组 key → 标签(未知 key 原样返回) */
|
|
12
|
+
export function generalGroupLabel(group: string): string {
|
|
13
|
+
const labelFn = GROUP_LABELS[group]
|
|
14
|
+
return labelFn ? labelFn() : group
|
|
15
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
//
|
|
5
|
+
// This program is free software: you can redistribute it and/or modify
|
|
6
|
+
// it under the terms of the GNU Affero General Public License as published by
|
|
7
|
+
// the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
// (at your option) any later version.
|
|
9
|
+
//
|
|
10
|
+
// This program is distributed in the hope that it will be useful,
|
|
11
|
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
// GNU Affero General Public License for more details.
|
|
14
|
+
//
|
|
15
|
+
// You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
import * as React from "react";
|
|
19
|
+
import { BookOpenText, Bookmark, BookMarked, Check, ChevronsUpDown, Scale, ShieldCheck, Sprout } from "lucide-react";
|
|
20
|
+
import { Button } from "@/components/ui/button";
|
|
21
|
+
import { Input } from "@/components/ui/input";
|
|
22
|
+
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
23
|
+
import { LucideIconByName } from "@/components/shared-kit/icons/lucide-icon-picker/lucide-icon-by-name";
|
|
24
|
+
import { generalGroupLabel } from "./general-groups";
|
|
25
|
+
import { m } from "@/paraglide/messages.js";
|
|
26
|
+
|
|
27
|
+
export type KbFilter = { type: "kb"; id: string } | { type: "general"; group: string } | null;
|
|
28
|
+
|
|
29
|
+
const GENERAL_GROUP_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
|
30
|
+
legal: Scale,
|
|
31
|
+
compliance: ShieldCheck,
|
|
32
|
+
dictionary: BookMarked,
|
|
33
|
+
bookmark: Bookmark,
|
|
34
|
+
summary: Sprout,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const GENERAL_GROUP_META = [
|
|
38
|
+
{ key: "legal" },
|
|
39
|
+
{ key: "compliance" },
|
|
40
|
+
{ key: "dictionary" },
|
|
41
|
+
{ key: "bookmark" },
|
|
42
|
+
{ key: "summary" },
|
|
43
|
+
] as const;
|
|
44
|
+
|
|
45
|
+
interface KbFilterPopoverProps {
|
|
46
|
+
search: string;
|
|
47
|
+
onSearchChange: (q: string) => void;
|
|
48
|
+
filter: KbFilter;
|
|
49
|
+
onFilterChange: (f: KbFilter) => void;
|
|
50
|
+
userKbs: Array<{ id: string; name: string; icon?: string | null }>;
|
|
51
|
+
/** 文案覆盖(缺省用 workspace_kb_* 通用文案);story 弹窗传入保持原文案零变化 */
|
|
52
|
+
searchPlaceholder?: string;
|
|
53
|
+
allLabel?: string;
|
|
54
|
+
userKbsLabel?: string;
|
|
55
|
+
generalKbsLabel?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 搜索框 + 知识库筛选下拉(用户 team 库 + 通用知识库两组)。story 关联弹窗与工作空间快捷文档弹窗共用。 */
|
|
59
|
+
export function KbFilterPopover({
|
|
60
|
+
search, onSearchChange, filter, onFilterChange, userKbs,
|
|
61
|
+
searchPlaceholder, allLabel, userKbsLabel, generalKbsLabel,
|
|
62
|
+
}: KbFilterPopoverProps) {
|
|
63
|
+
const [open, setOpen] = React.useState(false);
|
|
64
|
+
const activeKb = filter?.type === "kb" ? userKbs.find((k) => k.id === filter.id) : undefined;
|
|
65
|
+
|
|
66
|
+
const select = (f: KbFilter) => {
|
|
67
|
+
onFilterChange(f);
|
|
68
|
+
setOpen(false);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className="flex items-center gap-2">
|
|
73
|
+
<Input
|
|
74
|
+
placeholder={searchPlaceholder ?? m.workspace_kb_searchPlaceholder()}
|
|
75
|
+
value={search}
|
|
76
|
+
onChange={(e) => onSearchChange(e.target.value)}
|
|
77
|
+
className="h-8"
|
|
78
|
+
/>
|
|
79
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
80
|
+
<PopoverTrigger asChild>
|
|
81
|
+
<Button variant="outline" size="sm" className="h-8 shrink-0 gap-1">
|
|
82
|
+
<BookOpenText className="size-3.5" />
|
|
83
|
+
{activeKb
|
|
84
|
+
? activeKb.name
|
|
85
|
+
: filter?.type === "general"
|
|
86
|
+
? generalGroupLabel(filter.group)
|
|
87
|
+
: (allLabel ?? m.workspace_kb_allKbs())}
|
|
88
|
+
<ChevronsUpDown className="size-3.5 opacity-50" />
|
|
89
|
+
</Button>
|
|
90
|
+
</PopoverTrigger>
|
|
91
|
+
<PopoverContent align="end" className="w-60 p-1">
|
|
92
|
+
<button
|
|
93
|
+
type="button"
|
|
94
|
+
className="mt-1 flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm hover:bg-muted"
|
|
95
|
+
onClick={() => select(null)}
|
|
96
|
+
>
|
|
97
|
+
{allLabel ?? m.workspace_kb_allKbs()}
|
|
98
|
+
{filter === null && <Check className="size-3.5" />}
|
|
99
|
+
</button>
|
|
100
|
+
<div className="border-t px-2 pt-3 text-xs font-medium text-muted-foreground">
|
|
101
|
+
{userKbsLabel ?? m.workspace_kb_userKbs()}
|
|
102
|
+
</div>
|
|
103
|
+
{userKbs.map((kb) => (
|
|
104
|
+
<button
|
|
105
|
+
key={kb.id}
|
|
106
|
+
type="button"
|
|
107
|
+
className="flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm hover:bg-muted"
|
|
108
|
+
onClick={() => select({ type: "kb", id: kb.id })}
|
|
109
|
+
>
|
|
110
|
+
<span className="flex min-w-0 items-center gap-2">
|
|
111
|
+
{kb.icon && (
|
|
112
|
+
<LucideIconByName name={kb.icon} size={14} className="shrink-0 text-muted-foreground" />
|
|
113
|
+
)}
|
|
114
|
+
<span className="truncate">{kb.name}</span>
|
|
115
|
+
</span>
|
|
116
|
+
{filter?.type === "kb" && filter.id === kb.id && <Check className="size-3.5" />}
|
|
117
|
+
</button>
|
|
118
|
+
))}
|
|
119
|
+
<div className="border-t px-2 pt-3 text-xs font-medium text-muted-foreground">
|
|
120
|
+
{generalKbsLabel ?? m.workspace_kb_generalKbs()}
|
|
121
|
+
</div>
|
|
122
|
+
{GENERAL_GROUP_META.map((group) => {
|
|
123
|
+
const Icon = GENERAL_GROUP_ICONS[group.key];
|
|
124
|
+
return (
|
|
125
|
+
<button
|
|
126
|
+
key={group.key}
|
|
127
|
+
type="button"
|
|
128
|
+
className="flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm hover:bg-muted"
|
|
129
|
+
onClick={() => select({ type: "general", group: group.key })}
|
|
130
|
+
>
|
|
131
|
+
<span className="flex min-w-0 items-center gap-2">
|
|
132
|
+
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
|
133
|
+
<span className="truncate">{generalGroupLabel(group.key)}</span>
|
|
134
|
+
</span>
|
|
135
|
+
{filter?.type === "general" && filter.group === group.key && <Check className="size-3.5" />}
|
|
136
|
+
</button>
|
|
137
|
+
);
|
|
138
|
+
})}
|
|
139
|
+
</PopoverContent>
|
|
140
|
+
</Popover>
|
|
141
|
+
</div>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
@@ -197,8 +197,9 @@ export function LegalComplianceEditor({ type, editingId }: Props) {
|
|
|
197
197
|
} else {
|
|
198
198
|
await updateComplianceFn({ data: payload })
|
|
199
199
|
}
|
|
200
|
+
qc.invalidateQueries({ queryKey: [type] })
|
|
200
201
|
},
|
|
201
|
-
[editingId, type],
|
|
202
|
+
[editingId, type, qc],
|
|
202
203
|
)
|
|
203
204
|
|
|
204
205
|
const handlePropertyChange = useCallback((key: string, value: unknown) => {
|
|
@@ -237,9 +238,10 @@ export function LegalComplianceEditor({ type, editingId }: Props) {
|
|
|
237
238
|
} else {
|
|
238
239
|
await updateComplianceFn({ data: payload })
|
|
239
240
|
}
|
|
241
|
+
qc.invalidateQueries({ queryKey: [type] })
|
|
240
242
|
},
|
|
241
243
|
}),
|
|
242
|
-
[editingId, type, saveField],
|
|
244
|
+
[editingId, type, saveField, qc],
|
|
243
245
|
)
|
|
244
246
|
|
|
245
247
|
if (!record) {
|
|
@@ -4,7 +4,7 @@ import { overwriteGetLocale } from "@/paraglide/runtime.js";
|
|
|
4
4
|
|
|
5
5
|
overwriteGetLocale(() => "zh");
|
|
6
6
|
|
|
7
|
-
const
|
|
7
|
+
const mockUseQuery = vi.fn();
|
|
8
8
|
const mockUseLocation = vi.fn();
|
|
9
9
|
const mockUseSidebar = vi.fn();
|
|
10
10
|
|
|
@@ -13,10 +13,12 @@ vi.mock("@tanstack/react-router", () => ({
|
|
|
13
13
|
<a href={to}>{children}</a>
|
|
14
14
|
),
|
|
15
15
|
useLocation: () => mockUseLocation(),
|
|
16
|
+
useRouter: () => ({ navigate: vi.fn() }),
|
|
16
17
|
}));
|
|
17
18
|
|
|
18
|
-
vi.mock("@tanstack/react-
|
|
19
|
-
|
|
19
|
+
vi.mock("@tanstack/react-query", () => ({
|
|
20
|
+
useQuery: (...args: any[]) => mockUseQuery(...args),
|
|
21
|
+
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
|
20
22
|
}));
|
|
21
23
|
|
|
22
24
|
vi.mock("@/components/ui/sidebar", () => ({
|
|
@@ -52,10 +54,11 @@ vi.mock("@/lib/utils", () => ({
|
|
|
52
54
|
}));
|
|
53
55
|
|
|
54
56
|
vi.mock("@/lib/collections/reference/workspaces-collection", () => ({
|
|
55
|
-
|
|
57
|
+
WORKSPACES_QUERY_KEY: ["workspaces"],
|
|
56
58
|
}));
|
|
57
59
|
|
|
58
60
|
vi.mock("@/server/serverFns/workspace", () => ({
|
|
61
|
+
listPinnedWorkspacesBriefFn: vi.fn().mockResolvedValue([]),
|
|
59
62
|
toggleWorkspacePinFn: vi.fn().mockResolvedValue({ success: true }),
|
|
60
63
|
}));
|
|
61
64
|
|
|
@@ -68,7 +71,7 @@ beforeEach(() => {
|
|
|
68
71
|
cleanup();
|
|
69
72
|
mockUseLocation.mockReturnValue({ pathname: "/workspaces" });
|
|
70
73
|
mockUseSidebar.mockReturnValue({ state: "expanded" });
|
|
71
|
-
|
|
74
|
+
mockUseQuery.mockReturnValue({
|
|
72
75
|
data: [
|
|
73
76
|
{ id: "ws1", name: "工作空间 A", icon: "folder" },
|
|
74
77
|
{ id: "ws2", name: "工作空间 B", icon: "folder-kanban" },
|
|
@@ -103,7 +106,7 @@ describe("NavWorkspace", () => {
|
|
|
103
106
|
});
|
|
104
107
|
|
|
105
108
|
it("空 collection 时不渲染工作空间列表项", async () => {
|
|
106
|
-
|
|
109
|
+
mockUseQuery.mockReturnValue({ data: [] });
|
|
107
110
|
render(<NavWorkspace />);
|
|
108
111
|
await waitFor(() => {
|
|
109
112
|
expect(screen.queryByTestId("project-icon")).not.toBeInTheDocument();
|
|
@@ -70,11 +70,14 @@ import { Link, useLocation } from "@tanstack/react-router";
|
|
|
70
70
|
import { cn } from "@/lib/utils";
|
|
71
71
|
import { isParentActive } from "@/lib/sidebar-utils";
|
|
72
72
|
import { useEffect, useState } from "react";
|
|
73
|
-
import {
|
|
74
|
-
import {
|
|
73
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
74
|
+
import { KNOWLEDGE_BASES_QUERY_KEY } from "@/lib/collections/knowledge/knowledge-bases-collection";
|
|
75
|
+
import {
|
|
76
|
+
listKnowledgeFn,
|
|
77
|
+
toggleKnowledgePinFn,
|
|
78
|
+
} from "@/server/serverFns/knowledge";
|
|
75
79
|
import { KnowledgeFormDialog } from "@/components/knowledge/dialog/knowledge-form-dialog";
|
|
76
80
|
import { useServerFn } from "@tanstack/react-start";
|
|
77
|
-
import { toggleKnowledgePinFn } from "@/server/serverFns/knowledge";
|
|
78
81
|
|
|
79
82
|
import { AnimatedGradientText } from "@/components/magicui/animated-gradient-text";
|
|
80
83
|
import { m } from "@/paraglide/messages.js";
|
|
@@ -151,6 +154,7 @@ interface KnowledgeItem {
|
|
|
151
154
|
|
|
152
155
|
function UnpinButton({ kb }: { kb: KnowledgeItem }) {
|
|
153
156
|
const togglePin = useServerFn(toggleKnowledgePinFn);
|
|
157
|
+
const queryClient = useQueryClient();
|
|
154
158
|
const [pinned, setPinned] = useState(kb.pinnedToSidebar);
|
|
155
159
|
|
|
156
160
|
const handleClick = async () => {
|
|
@@ -158,7 +162,7 @@ function UnpinButton({ kb }: { kb: KnowledgeItem }) {
|
|
|
158
162
|
setPinned(newPinned);
|
|
159
163
|
try {
|
|
160
164
|
await togglePin({ data: { kbId: kb.id, pinned: newPinned } });
|
|
161
|
-
(
|
|
165
|
+
void queryClient.invalidateQueries({ queryKey: KNOWLEDGE_BASES_QUERY_KEY });
|
|
162
166
|
window.dispatchEvent(new CustomEvent("knowledge-pinned", { detail: { kbId: kb.id, pinned: newPinned } }));
|
|
163
167
|
} catch {
|
|
164
168
|
setPinned(!newPinned);
|
|
@@ -183,11 +187,13 @@ function UnpinButton({ kb }: { kb: KnowledgeItem }) {
|
|
|
183
187
|
}
|
|
184
188
|
|
|
185
189
|
function KnowledgeBaseLinks({ pathname }: { pathname: string }) {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
(
|
|
190
|
+
// useQuery 直读 per-request queryClient(_nav loader 已 SSR 预取),
|
|
191
|
+
// 列表随 SSR HTML 直出,刷新无需等待;避免 module-level collection 的跨请求泄漏
|
|
192
|
+
const { data: items } = useQuery({
|
|
193
|
+
queryKey: KNOWLEDGE_BASES_QUERY_KEY,
|
|
194
|
+
queryFn: () => listKnowledgeFn(),
|
|
195
|
+
});
|
|
196
|
+
const itemsList = (items as unknown as Array<KnowledgeItem> | undefined) ?? [];
|
|
191
197
|
|
|
192
198
|
if (itemsList.length === 0) return null;
|
|
193
199
|
|
|
@@ -228,18 +234,14 @@ export function NavKnowledge() {
|
|
|
228
234
|
const { state } = useSidebar();
|
|
229
235
|
const { pathname } = useLocation();
|
|
230
236
|
const isCollapsed = state === "collapsed";
|
|
231
|
-
const [mounted, setMounted] = useState(false);
|
|
232
237
|
const [open, setOpen] = useState(false);
|
|
233
238
|
const [createOpen, setCreateOpen] = useState(false);
|
|
239
|
+
const queryClient = useQueryClient();
|
|
234
240
|
const active = isParentActive(pathname, "/knowledge");
|
|
235
241
|
|
|
236
|
-
useEffect(() => {
|
|
237
|
-
setMounted(true);
|
|
238
|
-
}, []);
|
|
239
|
-
|
|
240
242
|
useEffect(() => {
|
|
241
243
|
const refresh = () => {
|
|
242
|
-
(
|
|
244
|
+
void queryClient.invalidateQueries({ queryKey: KNOWLEDGE_BASES_QUERY_KEY });
|
|
243
245
|
};
|
|
244
246
|
window.addEventListener("knowledge-created", refresh);
|
|
245
247
|
window.addEventListener("knowledge-updated", refresh);
|
|
@@ -251,7 +253,7 @@ export function NavKnowledge() {
|
|
|
251
253
|
window.removeEventListener("knowledge-deleted", refresh);
|
|
252
254
|
window.removeEventListener("knowledge-pinned", refresh);
|
|
253
255
|
};
|
|
254
|
-
}, []);
|
|
256
|
+
}, [queryClient]);
|
|
255
257
|
|
|
256
258
|
return (
|
|
257
259
|
<SidebarGroup className="p-0 space-y-1">
|
|
@@ -350,7 +352,7 @@ export function NavKnowledge() {
|
|
|
350
352
|
)}
|
|
351
353
|
</Collapsible>
|
|
352
354
|
|
|
353
|
-
{!isCollapsed &&
|
|
355
|
+
{!isCollapsed && (
|
|
354
356
|
<KnowledgeBaseLinks pathname={pathname} />
|
|
355
357
|
)}
|
|
356
358
|
|