@allbluecn/web-app 0.10.2 → 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.
Files changed (169) hide show
  1. package/package.json +26 -11
  2. package/public/favicon.ico +0 -0
  3. package/public/logo/allblue-logo-app-icon.svg +7 -0
  4. package/public/logo/allblue-logo-dark-square.svg +4 -0
  5. package/public/logo/allblue-logo-dark.svg +4 -0
  6. package/public/logo/allblue-logo-light-square.svg +4 -0
  7. package/public/logo/allblue-logo-light.svg +4 -0
  8. package/src/__tests__/bench/baselines/format-timestamp.json +44 -0
  9. package/src/__tests__/bench/core-hot-path.bench.ts +71 -0
  10. package/src/__tests__/integration/db-crud.integration.test.ts +79 -0
  11. package/src/__tests__/rejects-tothrow-regression.test.ts +31 -0
  12. package/src/__tests__/security/ai/prompt-injection.test.ts +105 -0
  13. package/src/__tests__/security/ssrf-url-whitelist.test.ts +54 -0
  14. package/src/__tests__/security/xss-sanitize.test.ts +99 -0
  15. package/src/__tests__/setup.ts +9 -0
  16. package/src/__tests__/visual/__screenshots__/theme-surface.vrt.test.tsx/theme-surface-dark-baseline-1-chromium-darwin.png +0 -0
  17. package/src/__tests__/visual/__screenshots__/theme-surface.vrt.test.tsx/theme-surface-light-baseline-1-chromium-darwin.png +0 -0
  18. package/src/__tests__/visual/__screenshots__/user-avatar.vrt.test.tsx/user-avatar-initial-fallback-baseline-1-chromium-darwin.png +0 -0
  19. package/src/__tests__/visual/theme-surface.vrt.test.tsx +54 -0
  20. package/src/__tests__/visual/user-avatar.vrt.test.tsx +32 -0
  21. package/src/components/error/global-error-boundary.tsx +2 -2
  22. package/src/components/knowledge/general-groups.ts +15 -0
  23. package/src/components/knowledge/kb-filter-popover.tsx +143 -0
  24. package/src/components/knowledge-public/legal-compliance-editor.tsx +4 -2
  25. package/src/components/layout/sidebar-03/__tests__/nav-workspace.test.tsx +9 -6
  26. package/src/components/layout/sidebar-03/nav-knowledge.tsx +19 -17
  27. package/src/components/layout/sidebar-03/nav-secondary.tsx +57 -47
  28. package/src/components/layout/sidebar-03/nav-workspace.tsx +20 -22
  29. package/src/components/mdx.tsx +9 -0
  30. package/src/components/shared-kit/avatars/notion-avatar.tsx +2 -1
  31. package/src/components/shared-kit/icons/__tests__/lucide-curated-icons.test.ts +2 -2
  32. package/src/components/shared-kit/icons/__tests__/lucide-icon-picker.test.tsx +3 -2
  33. package/src/components/shared-kit/icons/lucide-icon-picker/curated-icons.ts +5 -10
  34. package/src/components/shared-kit/icons/lucide-icon-picker/lucide-icon-by-name.tsx +3 -3
  35. package/src/components/shared-kit/theme/theme-surface.tsx +52 -0
  36. package/src/components/story/relations/general-groups.ts +1 -15
  37. package/src/components/story/relations/knowledge-select-dialog.tsx +13 -87
  38. package/src/components/story/story-list-view.tsx +7 -1
  39. package/src/components/tiptap/node/table-node/ui/table-move-row-column-button/use-table-move-row-column.ts +0 -2
  40. package/src/components/workspace/__tests__/activity/workspace-activity-card.test.tsx +36 -0
  41. package/src/components/workspace/__tests__/contribution/workspace-contribution-card.test.tsx +17 -0
  42. package/src/components/workspace/__tests__/dialogs/create-workspace-dialog.test.tsx +63 -0
  43. package/src/components/workspace/__tests__/dialogs/delete-workspace-options.test.tsx +89 -0
  44. package/src/components/workspace/__tests__/dialogs/edit-workspace-dialog.test.tsx +68 -0
  45. package/src/components/workspace/__tests__/header/workspace-header.test.tsx +40 -0
  46. package/src/components/workspace/__tests__/kb/workspace-kb-card.test.tsx +154 -0
  47. package/src/components/workspace/__tests__/members/workspace-member-item.test.tsx +24 -0
  48. package/src/components/workspace/__tests__/members/workspace-members-manager.test.tsx +51 -0
  49. package/src/components/workspace/__tests__/projects/workspace-project-list.test.tsx +46 -0
  50. package/src/components/workspace/{create-workspace-dialog.tsx → dialogs/create-workspace-dialog.tsx} +1 -1
  51. package/src/components/workspace/{edit-workspace-dialog.tsx → dialogs/edit-workspace-dialog.tsx} +1 -1
  52. package/src/components/workspace/kb/workspace-kb-card.tsx +594 -0
  53. package/src/components/workspace/kb/workspace-kb-manage-dialog.tsx +174 -0
  54. package/src/components/workspace/kb/workspace-quick-doc-dialog.tsx +431 -0
  55. package/src/components/workspace/{workspace-members-manager.tsx → members/workspace-members-manager.tsx} +78 -40
  56. package/src/lib/__tests__/msw-sample.test.ts +68 -0
  57. package/src/lib/__tests__/pgvector.test.ts +2 -2
  58. package/src/lib/changelog/sdk-versions.ts +21 -21
  59. package/src/lib/collections/knowledge/knowledge-bases-collection.ts +3 -1
  60. package/src/lib/collections/reference/workspaces-collection.ts +3 -1
  61. package/src/lib/fetch-json.ts +23 -0
  62. package/src/lib/fumadocs-i18n.ts +7 -0
  63. package/src/{components/workspace/workspace-kb-card.tsx → lib/i18n/resolver.ts} +6 -12
  64. package/src/lib/layout.shared.tsx +40 -0
  65. package/src/lib/sanitize.ts +14 -1
  66. package/src/lib/session.ts +9 -1
  67. package/src/lib/source.ts +15 -0
  68. package/src/lib/vector-store/__tests__/factory.test.ts +1 -1
  69. package/src/lib/vector-store/__tests__/sqlite-vec-store.test.ts +2 -2
  70. package/src/plugins/core/tags/__tests__/tags.test.ts +10 -10
  71. package/src/routeTree.gen.ts +65 -0
  72. package/src/routes/$lang/docs/$/route.lazy.tsx +62 -0
  73. package/src/routes/$lang/docs/$/route.tsx +52 -0
  74. package/src/routes/-tests/docs-redirect.test.ts +45 -0
  75. package/src/routes/__root.tsx +13 -7
  76. package/src/routes/__tests__/-edition-routing.test.tsx +4 -4
  77. package/src/routes/__tests__/route-tree-structure.test.ts +65 -0
  78. package/src/routes/_nav/knowledge/compliance/index.lazy.tsx +5 -2
  79. package/src/routes/_nav/knowledge/index.lazy.tsx +1 -1
  80. package/src/routes/_nav/knowledge/laws/index.lazy.tsx +5 -2
  81. package/src/routes/_nav/route.tsx +21 -0
  82. package/src/routes/_nav/workspaces/$workspaceId/route.lazy.tsx +13 -11
  83. package/src/routes/_nav/workspaces/$workspaceId/route.tsx +4 -2
  84. package/src/routes/_nav/workspaces/$workspaceId/settings/route.lazy.tsx +1 -1
  85. package/src/routes/_nav/workspaces/index.lazy.tsx +3 -3
  86. package/src/routes/api/search.ts +13 -0
  87. package/src/routes/docs.tsx +9 -0
  88. package/src/server/__tests__/agent-crud.test.ts +4 -4
  89. package/src/server/__tests__/chat-conversations.test.ts +1 -3
  90. package/src/server/__tests__/fetch-guard.test.ts +99 -0
  91. package/src/server/__tests__/permissions.test.ts +1 -3
  92. package/src/server/__tests__/resources.team-seats.test.ts +2 -0
  93. package/src/server/__tests__/skill-crud.test.ts +3 -3
  94. package/src/server/ai-gateway/__tests__/ai-output-guard.test.ts +95 -0
  95. package/src/server/ai-gateway/__tests__/kb-context-guard.test.ts +136 -0
  96. package/src/server/ai-gateway/__tests__/knowledge-search-fence.test.ts +240 -0
  97. package/src/server/ai-gateway/__tests__/llm-fence.test.ts +125 -0
  98. package/src/server/ai-gateway/__tests__/run-store.test.ts +52 -0
  99. package/src/server/ai-gateway/ai-output-guard.ts +77 -0
  100. package/src/server/ai-gateway/index.ts +5 -0
  101. package/src/server/ai-gateway/kb-context-guard.ts +46 -0
  102. package/src/server/ai-gateway/llm-fence.ts +82 -0
  103. package/src/server/ai-gateway/persistence/run-store.ts +19 -0
  104. package/src/server/ai-gateway/story-prompt.ts +4 -2
  105. package/src/server/ai-gateway/tools/definitions.ts +12 -6
  106. package/src/server/ai-gateway/tools/server.ts +97 -54
  107. package/src/server/builtin-project.ts +21 -0
  108. package/src/server/email/sender.ts +8 -2
  109. package/src/server/fetch-guard.ts +170 -0
  110. package/src/server/middlewares/security-headers.ts +113 -0
  111. package/src/server/serverFns/__tests__/billing.test.ts +263 -0
  112. package/src/server/serverFns/__tests__/link-preview.test.ts +239 -0
  113. package/src/server/serverFns/__tests__/project-kb.test.ts +3 -3
  114. package/src/server/serverFns/__tests__/project-team-assign.test.ts +1 -1
  115. package/src/server/serverFns/__tests__/story-comments.test.ts +265 -0
  116. package/src/server/serverFns/__tests__/story-crud-archive-detail.test.ts +266 -0
  117. package/src/server/serverFns/__tests__/story-crud-labels-users.test.ts +227 -0
  118. package/src/server/serverFns/__tests__/story-crud.test.ts +526 -0
  119. package/src/server/serverFns/__tests__/story-links.test.ts +262 -0
  120. package/src/server/serverFns/__tests__/story-list-query.test.ts +259 -0
  121. package/src/server/serverFns/__tests__/story-reactions.test.ts +230 -0
  122. package/src/server/serverFns/__tests__/story-tree.test.ts +317 -0
  123. package/src/server/serverFns/__tests__/story-types.test.ts +294 -0
  124. package/src/server/serverFns/__tests__/team-accept.test.ts +6 -6
  125. package/src/server/serverFns/__tests__/team-invitation.test.ts +3 -3
  126. package/src/server/serverFns/__tests__/team-join-request.test.ts +8 -8
  127. package/src/server/serverFns/__tests__/update-seats.test.ts +4 -4
  128. package/src/server/serverFns/__tests__/workspace-kb-idor.test.ts +160 -0
  129. package/src/server/serverFns/__tests__/workspace-kb-utils.test.ts +54 -0
  130. package/src/server/serverFns/__tests__/workspace-kb.test.ts +348 -0
  131. package/src/server/serverFns/__tests__/workspace.test.ts +28 -8
  132. package/src/server/serverFns/iteration/__tests__/iteration-crud.test.ts +3 -5
  133. package/src/server/serverFns/iteration/__tests__/iteration-stories.test.ts +1 -1
  134. package/src/server/serverFns/iteration/__tests__/iteration-transfer.test.ts +2 -2
  135. package/src/server/serverFns/kb-doc-search.ts +111 -0
  136. package/src/server/serverFns/knowledge-public/compliance/create.ts +2 -1
  137. package/src/server/serverFns/knowledge-public/legal/create.ts +2 -1
  138. package/src/server/serverFns/story/__tests__/bulk.test.ts +1 -1
  139. package/src/server/serverFns/story/__tests__/label-suggest-fn.test.ts +2 -6
  140. package/src/server/serverFns/story/__tests__/story-assoc.test.ts +2 -4
  141. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +2 -2
  142. package/src/server/serverFns/story/__tests__/story-groups.test.ts +2 -2
  143. package/src/server/serverFns/story/kb-general-consts.ts +39 -0
  144. package/src/server/serverFns/story/kb-general.ts +30 -17
  145. package/src/server/serverFns/story/story-assoc.ts +13 -103
  146. package/src/server/serverFns/story/story-crud.ts +2 -5
  147. package/src/server/serverFns/story/story-groups.ts +4 -13
  148. package/src/server/serverFns/team.ts +1 -1
  149. package/src/server/serverFns/workspace-kb-utils.ts +138 -0
  150. package/src/server/serverFns/workspace-kb.ts +403 -0
  151. package/src/server/serverFns/workspace.ts +4 -1
  152. package/src/shared/ai-cli/__tests__/adapter.test.ts +67 -0
  153. package/src/shared/ai-cli/__tests__/cli-detector.test.ts +99 -0
  154. package/src/shared/ai-cli/adapter.ts +2 -2
  155. package/src/shared/ai-cli/cli-detector.ts +18 -9
  156. package/src/start.ts +2 -1
  157. package/src/styles/globals.css +116 -0
  158. package/src/types/jest-dom-vitest.d.ts +8 -7
  159. package/vite.shared.ts +18 -1
  160. package/public/file.svg +0 -1
  161. package/public/globe.svg +0 -1
  162. package/public/window.svg +0 -1
  163. package/src/lib/__tests__/cli-detector.test.ts +0 -80
  164. /package/src/components/workspace/{workspace-activity-card.tsx → activity/workspace-activity-card.tsx} +0 -0
  165. /package/src/components/workspace/{workspace-contribution-card.tsx → contribution/workspace-contribution-card.tsx} +0 -0
  166. /package/src/components/workspace/{delete-workspace-options.tsx → dialogs/delete-workspace-options.tsx} +0 -0
  167. /package/src/components/workspace/{workspace-header.tsx → header/workspace-header.tsx} +0 -0
  168. /package/src/components/workspace/{workspace-member-item.tsx → members/workspace-member-item.tsx} +0 -0
  169. /package/src/components/workspace/{workspace-project-list.tsx → projects/workspace-project-list.tsx} +0 -0
@@ -18,7 +18,7 @@
18
18
  import * as React from "react";
19
19
  import { useServerFn } from "@tanstack/react-start";
20
20
  import { toast } from "sonner";
21
- import { BookMarked, BookOpenText, Bookmark, Check, ChevronsUpDown, Scale, ShieldCheck, Sprout } from "lucide-react";
21
+ import { BookOpenText, BookMarked, Bookmark, Check, Scale, ShieldCheck, Sprout } from "lucide-react";
22
22
  import { Button } from "@/components/ui/button";
23
23
  import { Input } from "@/components/ui/input";
24
24
  import { Badge } from "@/components/ui/badge";
@@ -40,6 +40,7 @@ import {
40
40
  type SearchDocRow,
41
41
  } from "@/server/serverFns/story";
42
42
  import { generalGroupLabel } from "./general-groups";
43
+ import { KbFilterPopover, type KbFilter } from "@/components/knowledge/kb-filter-popover";
43
44
  import type { DocBrief } from "../types";
44
45
  import { m } from "@/paraglide/messages.js";
45
46
 
@@ -50,24 +51,6 @@ interface KbBrief {
50
51
  visibility: string;
51
52
  }
52
53
 
53
- type KbFilter = { type: "kb"; id: string } | { type: "general"; group: string } | null;
54
-
55
- const GENERAL_GROUP_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
56
- legal: Scale,
57
- compliance: ShieldCheck,
58
- dictionary: BookMarked,
59
- bookmark: Bookmark,
60
- summary: Sprout,
61
- };
62
-
63
- const GENERAL_GROUP_META = [
64
- { key: "legal" },
65
- { key: "compliance" },
66
- { key: "dictionary" },
67
- { key: "bookmark" },
68
- { key: "summary" },
69
- ] as const;
70
-
71
54
  export interface KnowledgeSelectDialogProps {
72
55
  open: boolean;
73
56
  onOpenChange: (open: boolean) => void;
@@ -98,7 +81,6 @@ export function KnowledgeSelectDialog({
98
81
  const [selected, setSelected] = React.useState<Set<string>>(new Set());
99
82
  const [loading, setLoading] = React.useState(false);
100
83
  const [submitting, setSubmitting] = React.useState(false);
101
- const [kbPopoverOpen, setKbPopoverOpen] = React.useState(false);
102
84
 
103
85
  const linkedIds = React.useMemo(() => new Set(linkedDocs.map((d) => d.id)), [linkedDocs]);
104
86
 
@@ -156,7 +138,6 @@ export function KnowledgeSelectDialog({
156
138
 
157
139
  const handleKbSelect = (filter: KbFilter) => {
158
140
  setKbFilter(filter);
159
- setKbPopoverOpen(false);
160
141
  void loadDocs(filter, search);
161
142
  };
162
143
 
@@ -281,7 +262,6 @@ export function KnowledgeSelectDialog({
281
262
  }
282
263
  };
283
264
 
284
- const activeKb = kbFilter?.type === "kb" ? kbs.find((k) => k.id === kbFilter.id) : undefined;
285
265
 
286
266
  return (
287
267
  <Dialog open={open} onOpenChange={onOpenChange}>
@@ -290,71 +270,17 @@ export function KnowledgeSelectDialog({
290
270
  <DialogTitle>{m.story_knowledgeDialog_title()}</DialogTitle>
291
271
  </DialogHeader>
292
272
 
293
- <div className="flex items-center gap-2">
294
- <Input
295
- placeholder={m.story_knowledgeDialog_searchPlaceholder()}
296
- value={search}
297
- onChange={(e) => handleSearch(e.target.value)}
298
- className="h-8"
299
- />
300
- <Popover open={kbPopoverOpen} onOpenChange={setKbPopoverOpen}>
301
- <PopoverTrigger asChild>
302
- <Button variant="outline" size="sm" className="h-8 shrink-0 gap-1">
303
- <BookOpenText className="size-3.5" />
304
- {activeKb
305
- ? activeKb.name
306
- : kbFilter?.type === "general"
307
- ? generalGroupLabel(kbFilter.group)
308
- : m.story_knowledgeDialog_allKbs()}
309
- <ChevronsUpDown className="size-3.5 opacity-50" />
310
- </Button>
311
- </PopoverTrigger>
312
- <PopoverContent align="end" className="w-60 p-1">
313
- <button
314
- type="button"
315
- className="flex w-full items-center justify-between rounded-sm px-2 py-1.5 mt-1 text-sm hover:bg-muted"
316
- onClick={() => handleKbSelect(null)}
317
- >
318
- {m.story_knowledgeDialog_allKbs()}
319
- {kbFilter === null && <Check className="size-3.5" />}
320
- </button>
321
- <div className="border-t px-2 pt-3 text-xs font-medium text-muted-foreground">
322
- {m.story_knowledgeDialog_userKbs()}
323
- </div>
324
- {kbs.map((kb) => (
325
- <button
326
- key={kb.id}
327
- type="button"
328
- className="flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm hover:bg-muted"
329
- onClick={() => handleKbSelect({ type: "kb", id: kb.id })}
330
- >
331
- <span className="truncate">{kb.name}</span>
332
- {kbFilter?.type === "kb" && kbFilter.id === kb.id && <Check className="size-3.5" />}
333
- </button>
334
- ))}
335
- <div className=" border-t px-2 pt-3 text-xs font-medium text-muted-foreground">
336
- {m.story_knowledgeDialog_generalKbs()}
337
- </div>
338
- {GENERAL_GROUP_META.map((group) => {
339
- const Icon = GENERAL_GROUP_ICONS[group.key];
340
- return (
341
- <button
342
- key={group.key}
343
- type="button"
344
- className="flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm hover:bg-muted"
345
- onClick={() => handleKbSelect({ type: "general", group: group.key })}
346
- >
347
- <span className="flex min-w-0 items-center gap-2">
348
- <Icon className="size-3.5 shrink-0 text-muted-foreground" />
349
- <span className="truncate">{generalGroupLabel(group.key)}</span>
350
- </span>
351
- {kbFilter?.type === "general" && kbFilter.group === group.key && <Check className="size-3.5" />}
352
- </button>
353
- );
354
- })}
355
- </PopoverContent>
356
- </Popover>
357
- </div>
273
+ <KbFilterPopover
274
+ search={search}
275
+ onSearchChange={handleSearch}
276
+ filter={kbFilter}
277
+ onFilterChange={(f) => { setKbFilter(f); void loadDocs(f, search); }}
278
+ userKbs={kbs}
279
+ searchPlaceholder={m.story_knowledgeDialog_searchPlaceholder()}
280
+ allLabel={m.story_knowledgeDialog_allKbs()}
281
+ userKbsLabel={m.story_knowledgeDialog_userKbs()}
282
+ generalKbsLabel={m.story_knowledgeDialog_generalKbs()}
283
+ />
358
284
 
359
285
  <div className="h-80 overflow-y-auto rounded-md border p-1">
360
286
  {loading ? (
@@ -79,7 +79,13 @@ export function StoryListView({ stories: initialStories, users: initialUsers, pr
79
79
  const [users, setUsers] = useState<UserBrief[]>(initialUsers);
80
80
  const [projectId, setProjectId] = useState<string | undefined>(() => {
81
81
  try {
82
- return localStorage.getItem(STORY_PROJECT_FILTER_KEY) ?? undefined;
82
+ const stored = localStorage.getItem(STORY_PROJECT_FILTER_KEY) ?? undefined;
83
+ // 清理过期残留(项目已删除或 localStorage 跨账号残留),避免列表查询空集
84
+ if (stored && stored !== "__archived__" && !initialProjects.some((p) => p.id === stored)) {
85
+ localStorage.removeItem(STORY_PROJECT_FILTER_KEY);
86
+ return undefined;
87
+ }
88
+ return stored;
83
89
  } catch {
84
90
  return undefined;
85
91
  }
@@ -315,8 +315,6 @@ function tableMoveRowColumn({
315
315
  const moveOperation =
316
316
  finalOrientation === "row" ? moveTableRow : moveTableColumn
317
317
 
318
- console.log({ from, to, finalOrientation, direction })
319
-
320
318
  const dispatch = (tr: Transaction) => editor.view.dispatch(tr)
321
319
 
322
320
  if (editor.state.selection instanceof CellSelection) {
@@ -0,0 +1,36 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { WorkspaceActivityCard } from "../../activity/workspace-activity-card";
4
+
5
+ vi.mock("@/components/project/project-activity-list", () => ({
6
+ ProjectActivityList: ({ activities }: { activities: any[] }) => (
7
+ <ul data-testid="activity-list">
8
+ {activities.map((a: any) => (
9
+ <li key={a.id}>{a.action}</li>
10
+ ))}
11
+ </ul>
12
+ ),
13
+ }));
14
+
15
+ vi.mock("@tanstack/react-router", () => ({
16
+ Link: ({ children }: any) => <a>{children}</a>,
17
+ }));
18
+
19
+ describe("WorkspaceActivityCard", () => {
20
+ it("渲染活动列表", () => {
21
+ const ACTIVITIES = [
22
+ {
23
+ id: "a1",
24
+ action: "创建项目",
25
+ projectId: "p1",
26
+ userId: "u1",
27
+ targetType: null,
28
+ targetId: null,
29
+ metadata: {},
30
+ createdAt: new Date(),
31
+ },
32
+ ];
33
+ render(<WorkspaceActivityCard activities={ACTIVITIES} workspaceId="ws1" />);
34
+ expect(screen.getByTestId("activity-list")).toBeInTheDocument();
35
+ });
36
+ });
@@ -0,0 +1,17 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { WorkspaceContributionCard } from "../../contribution/workspace-contribution-card";
4
+
5
+ vi.mock("@/components/shared-kit/data-viz/contribution-graph-tooltip", () => ({
6
+ ContributionGraphWithTooltip: ({ title }: { title: string }) => (
7
+ <div data-testid="contribution-graph">{title}</div>
8
+ ),
9
+ }));
10
+
11
+ describe("WorkspaceContributionCard", () => {
12
+ it("渲染贡献图卡片", () => {
13
+ const data = [{ date: "2024-01-01", count: 5, level: 3 }];
14
+ render(<WorkspaceContributionCard data={data} />);
15
+ expect(screen.getByTestId("contribution-graph")).toBeInTheDocument();
16
+ });
17
+ });
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { CreateWorkspaceDialog } from "../../dialogs/create-workspace-dialog";
4
+
5
+ vi.mock("@/components/ui/button", () => ({
6
+ Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
7
+ }));
8
+
9
+ vi.mock("@/components/ui/input", () => ({
10
+ Input: (props: any) => <input {...props} />,
11
+ }));
12
+
13
+ vi.mock("@/components/ui/dialog", () => ({
14
+ Dialog: ({ children }: any) => <div>{children}</div>,
15
+ DialogContent: ({ children }: any) => <div>{children}</div>,
16
+ DialogFooter: ({ children }: any) => <div>{children}</div>,
17
+ DialogHeader: ({ children }: any) => <div>{children}</div>,
18
+ DialogTitle: ({ children }: any) => <h2>{children}</h2>,
19
+ }));
20
+
21
+ vi.mock("@/components/shared-kit/icons/lucide-icon-picker", () => ({
22
+ LucideIconPicker: () => <div data-testid="icon-picker" />,
23
+ }));
24
+
25
+ vi.mock("@tanstack/react-router", () => ({
26
+ useRouter: () => ({ navigate: vi.fn() }),
27
+ Link: ({ children }: any) => <a>{children}</a>,
28
+ }));
29
+
30
+ vi.mock("@tanstack/react-form", () => {
31
+ const makeField = (value: string) => ({
32
+ value,
33
+ handleChange: vi.fn(),
34
+ handleBlur: vi.fn(),
35
+ meta: { errors: [] },
36
+ });
37
+ return {
38
+ useForm: () => {
39
+ const fields = {
40
+ name: makeField(""),
41
+ icon: makeField("folder"),
42
+ description: makeField(""),
43
+ };
44
+ return {
45
+ handleSubmit: (e: any) => e.preventDefault(),
46
+ setFieldValue: () => {},
47
+ state: { values: { name: "", icon: "folder", description: "" } },
48
+ fields,
49
+ Field: ({ name, children }: { name: "name" | "icon" | "description"; children: unknown }) =>
50
+ typeof children === "function"
51
+ ? (children as (field: unknown) => unknown)(fields[name])
52
+ : null,
53
+ };
54
+ },
55
+ };
56
+ });
57
+
58
+ describe("CreateWorkspaceDialog", () => {
59
+ it("打开时渲染标题", () => {
60
+ render(<CreateWorkspaceDialog open={true} onOpenChange={() => {}} />);
61
+ expect(screen.getByRole("heading")).toBeInTheDocument();
62
+ });
63
+ });
@@ -0,0 +1,89 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import { render } from "@testing-library/react";
4
+ import { describe, expect, it, vi } from "vitest";
5
+
6
+ import { DeleteWorkspaceOptions } from "../../dialogs/delete-workspace-options";
7
+
8
+ vi.mock("@/components/ui/field", () => ({
9
+ Field: ({ children }: { children: ReactNode }) => <div>{children}</div>,
10
+ FieldContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
11
+ FieldDescription: ({ children }: { children: ReactNode }) => (
12
+ <p>{children}</p>
13
+ ),
14
+ FieldLabel: ({ children }: { children: ReactNode }) => <div>{children}</div>,
15
+ FieldTitle: ({ children }: { children: ReactNode }) => <span>{children}</span>,
16
+ }));
17
+
18
+ vi.mock("@/components/ui/radio-group", () => ({
19
+ RadioGroup: ({ children }: { children: ReactNode }) => (
20
+ <div data-radix-radio-group>{children}</div>
21
+ ),
22
+ RadioGroupItem: () => <input type="radio" />,
23
+ }));
24
+
25
+ vi.mock("@/components/ui/select", () => ({
26
+ Select: ({ children }: { children: ReactNode }) => <div>{children}</div>,
27
+ SelectContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
28
+ SelectItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
29
+ SelectTrigger: ({ children }: { children: ReactNode }) => (
30
+ <div data-radix-select-trigger>{children}</div>
31
+ ),
32
+ SelectValue: () => <span />,
33
+ }));
34
+
35
+ vi.mock("@/components/ui/separator", () => ({
36
+ Separator: () => <hr />,
37
+ }));
38
+
39
+ describe("DeleteWorkspaceOptions", () => {
40
+ const baseProps = {
41
+ projectCount: 2,
42
+ deleteMode: "transfer" as const,
43
+ onDeleteModeChange: () => {},
44
+ storyMode: "transfer" as const,
45
+ onStoryModeChange: () => {},
46
+ knowledgeBaseMode: "delete" as const,
47
+ onKnowledgeBaseModeChange: () => {},
48
+ targetWorkspaceId: "ws-2",
49
+ onTargetWorkspaceIdChange: () => {},
50
+ workspaceOptions: [
51
+ { id: "ws-2", name: "其他工作区" },
52
+ { id: "ws-3", name: "另一个工作区" },
53
+ ],
54
+ };
55
+
56
+ it("转移模式下渲染两组处理选项并显示目标工作区下拉", () => {
57
+ const { container } = render(<DeleteWorkspaceOptions {...baseProps} />);
58
+
59
+ expect(
60
+ container.querySelectorAll("[data-radix-radio-group]"),
61
+ ).toHaveLength(2);
62
+ expect(container.querySelector("[data-radix-select-trigger]")).not.toBeNull();
63
+ });
64
+
65
+ it("清空模式下渲染三组处理选项且无目标工作区下拉", () => {
66
+ const { container } = render(
67
+ <DeleteWorkspaceOptions
68
+ {...baseProps}
69
+ deleteMode="clear"
70
+ storyMode="clear"
71
+ />,
72
+ );
73
+
74
+ expect(
75
+ container.querySelectorAll("[data-radix-radio-group]"),
76
+ ).toHaveLength(3);
77
+ expect(container.querySelector("[data-radix-select-trigger]")).toBeNull();
78
+ });
79
+
80
+ it("无项目时仅渲染知识库处理选项", () => {
81
+ const { container } = render(
82
+ <DeleteWorkspaceOptions {...baseProps} projectCount={0} />,
83
+ );
84
+
85
+ expect(
86
+ container.querySelectorAll("[data-radix-radio-group]"),
87
+ ).toHaveLength(1);
88
+ });
89
+ });
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { EditWorkspaceDialog } from "../../dialogs/edit-workspace-dialog";
4
+
5
+ vi.mock("@/components/ui/button", () => ({
6
+ Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
7
+ }));
8
+
9
+ vi.mock("@/components/ui/input", () => ({
10
+ Input: (props: any) => <input {...props} />,
11
+ }));
12
+
13
+ vi.mock("@/components/ui/dialog", () => ({
14
+ Dialog: ({ children }: any) => <div>{children}</div>,
15
+ DialogContent: ({ children }: any) => <div>{children}</div>,
16
+ DialogFooter: ({ children }: any) => <div>{children}</div>,
17
+ DialogHeader: ({ children }: any) => <div>{children}</div>,
18
+ DialogTitle: ({ children }: any) => <h2>{children}</h2>,
19
+ }));
20
+
21
+ vi.mock("@/components/shared-kit/icons/lucide-icon-picker", () => ({
22
+ LucideIconPicker: () => <div data-testid="icon-picker" />,
23
+ }));
24
+
25
+ vi.mock("@tanstack/react-router", () => ({
26
+ useRouter: () => ({ navigate: vi.fn() }),
27
+ }));
28
+
29
+ vi.mock("@tanstack/react-form", () => {
30
+ const makeField = (value: string) => ({
31
+ value,
32
+ handleChange: vi.fn(),
33
+ handleBlur: vi.fn(),
34
+ meta: { errors: [] },
35
+ });
36
+ return {
37
+ useForm: () => {
38
+ const fields = {
39
+ name: makeField("测试"),
40
+ icon: makeField("folder"),
41
+ description: makeField(""),
42
+ };
43
+ return {
44
+ handleSubmit: (e: any) => e.preventDefault(),
45
+ setFieldValue: () => {},
46
+ state: { values: { name: "测试", icon: "folder", description: "" } },
47
+ fields,
48
+ Field: ({ name, children }: { name: "name" | "icon" | "description"; children: unknown }) =>
49
+ typeof children === "function"
50
+ ? (children as (field: unknown) => unknown)(fields[name])
51
+ : null,
52
+ };
53
+ },
54
+ };
55
+ });
56
+
57
+ describe("EditWorkspaceDialog", () => {
58
+ it("打开时渲染标题", () => {
59
+ render(
60
+ <EditWorkspaceDialog
61
+ open={true}
62
+ onOpenChange={() => {}}
63
+ workspace={{ id: "ws1", name: "测试", icon: "folder", description: null }}
64
+ />
65
+ );
66
+ expect(screen.getByRole("heading")).toBeInTheDocument();
67
+ });
68
+ });
@@ -0,0 +1,40 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { WorkspaceHeader } from "../../header/workspace-header";
4
+
5
+ vi.mock("@/components/ui/button", () => ({
6
+ Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
7
+ }));
8
+
9
+ vi.mock("@/components/ui/dropdown-menu", () => ({
10
+ DropdownMenu: ({ children }: any) => <div>{children}</div>,
11
+ DropdownMenuTrigger: ({ children }: any) => <div>{children}</div>,
12
+ DropdownMenuContent: ({ children }: any) => <div>{children}</div>,
13
+ DropdownMenuItem: ({ children }: any) => <div>{children}</div>,
14
+ }));
15
+
16
+ vi.mock("@/components/ui/separator", () => ({
17
+ Separator: () => <div data-testid="separator" />,
18
+ }));
19
+
20
+ vi.mock("@/components/shared-kit/icons/lucide-icon-picker/lucide-icon-by-name", () => ({
21
+ LucideIconByName: () => <div data-testid="icon" />,
22
+ }));
23
+
24
+ vi.mock("@tanstack/react-router", () => ({
25
+ Link: ({ children }: any) => <a>{children}</a>,
26
+ }));
27
+
28
+ describe("WorkspaceHeader", () => {
29
+ it("渲染工作空间名称", () => {
30
+ const WORKSPACE = {
31
+ id: "ws1",
32
+ name: "测试工作空间",
33
+ description: null,
34
+ icon: "folder",
35
+ _count: { projects: 0 },
36
+ };
37
+ render(<WorkspaceHeader workspace={WORKSPACE} onEdit={() => {}} onDelete={() => {}} />);
38
+ expect(screen.getByText("测试工作空间")).toBeInTheDocument();
39
+ });
40
+ });
@@ -0,0 +1,154 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { render, screen, fireEvent, waitFor } from "@testing-library/react";
3
+
4
+ const mocks = vi.hoisted(() => ({
5
+ invalidate: vi.fn(),
6
+ setKbs: vi.fn(),
7
+ renameDoc: vi.fn(),
8
+ removeDoc: vi.fn(),
9
+ reorderDocs: vi.fn(),
10
+ }));
11
+
12
+ vi.mock("@tanstack/react-router", () => ({
13
+ useRouter: () => ({ invalidate: mocks.invalidate }),
14
+ Link: ({ to, children, ...rest }: any) => (
15
+ <a href={typeof to === "string" ? to : "#"} {...rest}>{children}</a>
16
+ ),
17
+ }));
18
+ vi.mock("@tanstack/react-start", () => ({
19
+ useServerFn: (fn: any) => async (...args: any[]) => fn(...args),
20
+ }));
21
+ vi.mock("@/server/serverFns/workspace-kb", () => ({
22
+ getWorkspaceKbCardFn: vi.fn().mockResolvedValue({ kbs: [], quickDocs: [] }),
23
+ listWorkspaceAvailableKbsFn: vi.fn(),
24
+ setWorkspaceKbsFn: mocks.setKbs,
25
+ renameQuickDocFn: mocks.renameDoc,
26
+ removeQuickDocFn: mocks.removeDoc,
27
+ reorderQuickDocsFn: mocks.reorderDocs,
28
+ searchQuickDocCandidatesFn: vi.fn(),
29
+ addQuickDocsFn: vi.fn(),
30
+ }));
31
+ vi.mock("@dnd-kit/react", () => ({
32
+ DragDropProvider: ({ children }: any) => <div>{children}</div>,
33
+ useDroppable: () => ({ ref: null, isDropTarget: false }),
34
+ }));
35
+ vi.mock("@dnd-kit/react/sortable", () => ({
36
+ useSortable: () => ({ ref: undefined, handleRef: undefined, isDragging: false }),
37
+ }));
38
+ vi.mock("sonner", () => ({
39
+ toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn() },
40
+ }));
41
+ vi.mock("../workspace-kb-manage-dialog", () => ({
42
+ WorkspaceKbManageDialog: () => null,
43
+ }));
44
+ vi.mock("../workspace-quick-doc-dialog", () => ({
45
+ WorkspaceQuickDocDialog: () => null,
46
+ }));
47
+ vi.mock("@/components/ui/tooltip", () => ({
48
+ Tooltip: ({ children }: any) => <span data-testid="tooltip-wrapper">{children}</span>,
49
+ TooltipContent: ({ children }: any) => <span>{children}</span>,
50
+ TooltipTrigger: ({ children }: any) => <span data-testid="tooltip-trigger">{children}</span>,
51
+ }));
52
+
53
+ import { WorkspaceKbCard } from "../../kb/workspace-kb-card";
54
+
55
+ const KB = { id: "kb1", name: "团队库", icon: "notebook", visibility: "team" };
56
+ const DOC_ROW = {
57
+ id: "q1", displayTitle: "接入指南", columnIndex: 0, sortOrder: 0, label: null, icon: null, source: "doc" as const,
58
+ docId: "d1", kbId: "kb1", kbName: "团队库", docIcon: null,
59
+ group: null, externalId: null, faviconUrl: null, sourceTitle: "接入指南", url: null, stale: false,
60
+ };
61
+ const STALE_ROW = {
62
+ ...DOC_ROW, id: "q2", displayTitle: "旧法条", source: "general" as const,
63
+ docId: null, kbId: null, kbName: null, group: "legal" as const, externalId: "gone",
64
+ faviconUrl: null, sourceTitle: null, url: null, stale: true,
65
+ };
66
+ const EXTERNAL_URL_ROW = {
67
+ ...DOC_ROW, id: "q3", displayTitle: "外部工具", source: "general" as const,
68
+ docId: null, kbId: null, kbName: null, group: "bookmark" as const, externalId: "ext123",
69
+ faviconUrl: null, sourceTitle: "PNG to ICO", url: "https://tool.xuecan.net/png-to-ico/", stale: false,
70
+ };
71
+
72
+ beforeEach(() => {
73
+ vi.clearAllMocks();
74
+ });
75
+
76
+ describe("WorkspaceKbCard 渲染", () => {
77
+ it("空数据显示空状态且「前往知识库」禁用", () => {
78
+ render(<WorkspaceKbCard workspaceId="ws1" data={{ kbs: [], quickDocs: [] }} />);
79
+ expect(screen.getByText(/快捷知识入口/)).toBeInTheDocument();
80
+ // 前往知识库图标按钮 disabled,无 aria-label
81
+ const buttons = screen.getAllByRole("button");
82
+ const disabledBtn = buttons.find((b) => b.getAttribute("disabled") !== null);
83
+ expect(disabledBtn).toBeTruthy();
84
+ });
85
+
86
+ it("渲染快捷文档行(5列布局)", () => {
87
+ render(<WorkspaceKbCard workspaceId="ws1" data={{ kbs: [], quickDocs: [DOC_ROW] }} />);
88
+ expect(screen.getByText("接入指南")).toBeInTheDocument();
89
+ });
90
+
91
+ it("失效条目显示「源已失效」", () => {
92
+ render(<WorkspaceKbCard workspaceId="ws1" data={{ kbs: [], quickDocs: [STALE_ROW] }} />);
93
+ expect(screen.getByText(/源已失效/)).toBeInTheDocument();
94
+ });
95
+
96
+ it("已关联知识库时不显示知识库名称(chip 已移除),前往按钮 tooltip 显示库名", () => {
97
+ render(<WorkspaceKbCard workspaceId="ws1" data={{ kbs: [KB], quickDocs: [] }} />);
98
+ // chip 行已移除,但 goToKb tooltip 仍显示库名
99
+ expect(screen.getByText("团队库")).toBeInTheDocument();
100
+ });
101
+ });
102
+
103
+ describe("WorkspaceKbCard 乐观更新", () => {
104
+ it("删除确认后乐观移除并调 removeQuickDocFn", async () => {
105
+ render(<WorkspaceKbCard workspaceId="ws1" data={{ kbs: [], quickDocs: [DOC_ROW] }} />);
106
+ fireEvent.click(screen.getByRole("button", { name: "删除" }));
107
+ fireEvent.click(screen.getByRole("button", { name: "确认" }));
108
+ await waitFor(() => {
109
+ expect(mocks.removeDoc).toHaveBeenCalledWith({ data: { quickDocId: "q1" } });
110
+ });
111
+ expect(screen.queryByText("接入指南")).not.toBeInTheDocument();
112
+ });
113
+
114
+ it("重命名提交调用 renameQuickDocFn 且 UI 更新", async () => {
115
+ render(<WorkspaceKbCard workspaceId="ws1" data={{ kbs: [], quickDocs: [DOC_ROW] }} />);
116
+ fireEvent.click(screen.getByRole("button", { name: "重命名" }));
117
+ const input = screen.getByRole("textbox");
118
+ fireEvent.change(input, { target: { value: "新标题" } });
119
+ fireEvent.keyDown(input, { key: "Enter" });
120
+ await waitFor(() => {
121
+ expect(mocks.renameDoc).toHaveBeenCalledWith({
122
+ data: { quickDocId: "q1", displayTitle: "新标题" },
123
+ });
124
+ });
125
+ expect(screen.getByText("新标题")).toBeInTheDocument();
126
+ });
127
+
128
+ it("外部 URL 卡片使用 <a href> 而非 Link", () => {
129
+ render(<WorkspaceKbCard workspaceId="ws1" data={{ kbs: [], quickDocs: [EXTERNAL_URL_ROW] }} />);
130
+ // 外部 URL 应渲染为普通 <a> 标签,而非 TanStack Router Link(mock 为 <a>)
131
+ const link = screen.getByText("外部工具").closest("a");
132
+ expect(link).toBeInTheDocument();
133
+ expect((link as HTMLAnchorElement).href).toBe("https://tool.xuecan.net/png-to-ico/");
134
+ expect((link as HTMLAnchorElement).target).toBe("_blank");
135
+ });
136
+
137
+ it("编辑状态下点击行不触发导航", async () => {
138
+ render(<WorkspaceKbCard workspaceId="ws1" data={{ kbs: [], quickDocs: [DOC_ROW] }} />);
139
+ // 进入编辑模式
140
+ fireEvent.click(screen.getByRole("button", { name: "重命名" }));
141
+ // 编辑态:快捷文档行的主链接不应存在(渲染为 span,无导航能力)
142
+ const row = document.querySelector("[data-quick-doc-row]");
143
+ expect(row).toBeInTheDocument();
144
+ // 主链接区域(包含 title 的 <a>)应不存在
145
+ const mainLink = row!.querySelector<HTMLAnchorElement>('.flex-1.truncate > a');
146
+ expect(mainLink).not.toBeInTheDocument();
147
+ // Input 应存在且可编辑
148
+ expect(screen.getByRole("textbox")).toBeInTheDocument();
149
+ // 点击行区域不应有导航(因为没有 Link)
150
+ fireEvent.click(row!);
151
+ // 编辑状态应保持(点击行内不会意外退出)
152
+ expect(screen.getByRole("textbox")).toBeInTheDocument();
153
+ });
154
+ });
@@ -0,0 +1,24 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { WorkspaceMemberItem } from "../../members/workspace-member-item";
4
+
5
+ vi.mock("@/components/shared-kit", () => ({
6
+ UserAvatar: ({ name }: { name?: string }) => <div data-testid="user-avatar">{name?.[0] ?? "?"}</div>,
7
+ }));
8
+
9
+ const MEMBER = {
10
+ id: "m1",
11
+ userId: "u1",
12
+ role: "member" as const,
13
+ status: "active" as const,
14
+ joinedAt: new Date(),
15
+ user: { id: "u1", username: "张三", email: "zhangsan@example.com" },
16
+ };
17
+
18
+ describe("WorkspaceMemberItem", () => {
19
+ it("渲染成员用户名和邮箱", () => {
20
+ render(<WorkspaceMemberItem member={MEMBER} workspaceOwnerId="owner1" onRemove={() => {}} />);
21
+ expect(screen.getByText("张三")).toBeInTheDocument();
22
+ expect(screen.getByText("zhangsan@example.com")).toBeInTheDocument();
23
+ });
24
+ });