@allbluecn/web-app 0.4.22 → 0.5.1

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 (99) hide show
  1. package/package.json +12 -8
  2. package/src/components/knowledge/knowledge-toolbar.tsx +124 -1
  3. package/src/components/layout/sidebar-03/app-sidebar.tsx +3 -4
  4. package/src/components/layout/sidebar-03/nav-notifications.tsx +135 -52
  5. package/src/components/layout/sidebar-03/user-menu.tsx +4 -2
  6. package/src/components/notifications/settings-notifications.tsx +198 -0
  7. package/src/components/notifications/team-notifications.tsx +343 -0
  8. package/src/components/settings/personal-team/team/team-client.tsx +51 -12
  9. package/src/components/settings/personal-team/team/team-invitations.tsx +181 -181
  10. package/src/components/shared/wave-physics-loader.tsx +155 -0
  11. package/src/components/story/__tests__/dependency-type-badge.test.ts +89 -0
  12. package/src/components/story/constants.ts +124 -51
  13. package/src/components/story/create-story-dialog.tsx +12 -6
  14. package/src/components/story/detail/__tests__/associated-attachments-panel.test.tsx +114 -0
  15. package/src/components/story/detail/activity-timeline.tsx +15 -0
  16. package/src/components/story/detail/story-detail-body.tsx +14 -3
  17. package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +748 -0
  18. package/src/components/story/detail/story-detail-toolbar/categories.tsx +23 -18
  19. package/src/components/story/detail/story-detail-toolbar/children.tsx +51 -274
  20. package/src/components/story/detail/story-detail-toolbar/dependencies.tsx +13 -45
  21. package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +473 -0
  22. package/src/components/story/detail/story-detail-toolbar.tsx +237 -10
  23. package/src/components/story/display/__tests__/use-paged-story-groups.test.ts +152 -1
  24. package/src/components/story/display/use-paged-story-groups.ts +370 -28
  25. package/src/components/story/inline-editors/story-select-dialog.tsx +107 -16
  26. package/src/components/story/peek-panel.tsx +8 -1
  27. package/src/components/story/relations/__tests__/attachment-manage-dialog.test.tsx +171 -0
  28. package/src/components/story/relations/__tests__/knowledge-select-dialog.test.tsx +174 -0
  29. package/src/components/story/relations/__tests__/link-manage-dialog.test.tsx +93 -0
  30. package/src/components/story/relations/attachment-manage-dialog.tsx +490 -0
  31. package/src/components/story/relations/file-preview.tsx +173 -0
  32. package/src/components/story/relations/format.ts +120 -0
  33. package/src/{config/sidebar-notifications.ts → components/story/relations/general-groups.ts} +12 -30
  34. package/src/components/story/relations/knowledge-select-dialog.tsx +403 -0
  35. package/src/components/story/relations/link-manage-dialog.tsx +191 -0
  36. package/src/components/story/relations/story-ref-row.tsx +319 -0
  37. package/src/components/story/story-list-view.tsx +3 -1
  38. package/src/components/story/story-panel-header.tsx +4 -1
  39. package/src/components/story/types.ts +30 -18
  40. package/src/components/story/views/story-list/batch-toolbar.tsx +12 -1
  41. package/src/components/story/views/story-list/index.tsx +16 -5
  42. package/src/components/story/views/story-list/row.tsx +5 -4
  43. package/src/components/ui/separator.tsx +1 -1
  44. package/src/hooks/use-notifications.ts +4 -1
  45. package/src/lib/__tests__/datetime.test.ts +16 -1
  46. package/src/lib/changelog/sdk-versions.ts +19 -19
  47. package/src/lib/datetime.ts +1 -0
  48. package/src/lib/story/__tests__/video-embed.test.ts +77 -0
  49. package/src/lib/story/video-embed.ts +59 -0
  50. package/src/routeTree.gen.ts +65 -0
  51. package/src/routes/_nav/notifications/route.lazy.tsx +22 -0
  52. package/src/routes/_nav/notifications/route.tsx +30 -0
  53. package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +74 -3
  54. package/src/routes/_nav/projects/$projectId/settings/route.tsx +6 -2
  55. package/src/routes/_nav/settings/notifications/route.lazy.tsx +6 -5
  56. package/src/routes/api/attachments/$id/content.ts +60 -0
  57. package/src/routes/api/attachments/upload.ts +148 -0
  58. package/src/routes/api/notifications/stream.ts +4 -0
  59. package/src/server/notifications/__tests__/notification-service.test.ts +189 -0
  60. package/src/server/notifications/notification-service.ts +137 -27
  61. package/src/server/serverFns/__tests__/project-kb.test.ts +147 -0
  62. package/src/server/serverFns/__tests__/team-invitation.test.ts +73 -7
  63. package/src/server/serverFns/__tests__/team-join-request.test.ts +5 -3
  64. package/src/server/serverFns/knowledge.ts +50 -4
  65. package/src/server/serverFns/locale.ts +15 -3
  66. package/src/server/serverFns/notifications.ts +18 -4
  67. package/src/server/serverFns/project.ts +43 -0
  68. package/src/server/serverFns/story/__tests__/kb-scope.test.ts +56 -0
  69. package/src/server/serverFns/story/__tests__/story-assoc.test.ts +222 -0
  70. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +265 -0
  71. package/src/server/serverFns/story/__tests__/story-groups.test.ts +19 -1
  72. package/src/server/serverFns/story/index.ts +7 -6
  73. package/src/server/serverFns/story/kb-general.ts +39 -0
  74. package/src/server/serverFns/story/kb-scope.ts +52 -0
  75. package/src/server/serverFns/story/link-preview.ts +115 -0
  76. package/src/server/serverFns/story/story-assoc.ts +339 -50
  77. package/src/server/serverFns/story/story-attachments.ts +12 -43
  78. package/src/server/serverFns/story/story-crud.ts +21 -4
  79. package/src/server/serverFns/story/story-dependencies.ts +138 -162
  80. package/src/server/serverFns/story/story-groups.ts +23 -3
  81. package/src/server/serverFns/story/story-links.ts +13 -1
  82. package/src/server/serverFns/story/story-transfer.ts +5 -3
  83. package/src/server/serverFns/story/story-tree.ts +28 -1
  84. package/src/server/serverFns/story/types.ts +37 -0
  85. package/src/server/serverFns/team.ts +51 -2
  86. package/src/styles/globals.css +25 -0
  87. package/vite.shared.ts +2 -0
  88. package/src/components/notifications/notification-list.tsx +0 -232
  89. package/src/components/notifications/notification-preferences.tsx +0 -156
  90. package/src/components/story/detail/story-detail-toolbar/attachments.tsx +0 -73
  91. package/src/components/story/detail/story-detail-toolbar/knowledge.tsx +0 -73
  92. package/src/components/story/detail/story-detail-toolbar/links.tsx +0 -73
  93. package/src/components/story/relation-badge.tsx +0 -57
  94. package/src/components/story/relations/attachments-panel.tsx +0 -179
  95. package/src/components/story/relations/dependency-panel.tsx +0 -190
  96. package/src/components/story/relations/hierarchy-panel.tsx +0 -180
  97. package/src/components/story/relations/knowledge-panel.tsx +0 -284
  98. package/src/components/story/relations/links-panel.tsx +0 -240
  99. package/src/components/story/relations/tab.tsx +0 -212
@@ -19,7 +19,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
19
19
  import { useTranslation } from "react-i18next";
20
20
  import { useDebouncedCallback, SEARCH_DEBOUNCE_MS } from "@/lib/pacer";
21
21
  import { cn } from "@/lib/utils";
22
- import { Search, Sprout, ChevronsUpDown, Check, Trees, ListFilter, Flag } from "lucide-react";
22
+ import { Search, Sprout, ChevronsUpDown, Check, Trees, ListFilter, Flag, Waypoints } from "lucide-react";
23
23
  import { Badge } from "@/components/ui/badge";
24
24
  import { Checkbox } from "@/components/ui/checkbox";
25
25
  import { ProjectIconDisplay } from "@/components/project/project-icon";
@@ -50,7 +50,9 @@ import {
50
50
  STATE_LABEL,
51
51
  STORY_STATES,
52
52
  STORY_PRIORITIES,
53
+ DEPENDENCY_TYPE_OPTIONS,
53
54
  type StoryState,
55
+ type DependencyTypeOption,
54
56
  } from "../constants";
55
57
  import { PriorityIcon } from "../priority-icon";
56
58
 
@@ -71,7 +73,7 @@ interface StorySelectDialogProps {
71
73
  initialProjectId?: string | null;
72
74
  defaultSelectedParentId?: string | null;
73
75
  defaultSelectedParentIds?: string[];
74
- mode?: "parent" | "child";
76
+ mode?: "parent" | "child" | "dependency";
75
77
  onSelected: (parentId: string | string[]) => void;
76
78
  onActivities?: (activities: ActivityItem[]) => void;
77
79
  skipTransfer?: boolean;
@@ -79,9 +81,15 @@ interface StorySelectDialogProps {
79
81
  excludeProjectIds?: string[];
80
82
  hideAllProjects?: boolean;
81
83
  filteredTip?: string;
84
+ /** dependency 模式:额外排除的需求 id(已建立依赖的对方) */
85
+ excludeIds?: string[];
86
+ /** dependency 模式:当前选中的依赖类型(受控),由父组件持有以便提交时映射方向 */
87
+ dependencyType?: DependencyTypeOption["key"];
88
+ onDependencyTypeChange?: (key: DependencyTypeOption["key"]) => void;
82
89
  }
83
90
 
84
91
  const ALL_PROJECTS = "__all__";
92
+ const EMPTY_IDS: string[] = [];
85
93
  const ALL_STATES = STORY_STATES.map((s) => s.value);
86
94
  const ALL_PRIORITIES = STORY_PRIORITIES.map((p) => p.value);
87
95
 
@@ -101,10 +109,13 @@ export function StorySelectDialog({
101
109
  excludeProjectIds = [],
102
110
  hideAllProjects = false,
103
111
  filteredTip,
112
+ excludeIds = EMPTY_IDS,
113
+ dependencyType,
114
+ onDependencyTypeChange,
104
115
  }: StorySelectDialogProps) {
105
116
  const { t } = useTranslation("story");
106
117
  const resolvedTitle = title ?? t("select.defaultTitle");
107
- const isMulti = mode === "child";
118
+ const isMulti = mode === "child" || mode === "dependency";
108
119
  const [projectFilter, setProjectFilter] = useState<string>(ALL_PROJECTS);
109
120
  const [items, setItems] = useState<ParentStoryItem[]>([]);
110
121
  const [search, setSearch] = useState("");
@@ -117,6 +128,7 @@ export function StorySelectDialog({
117
128
  const [projectPopoverOpen, setProjectPopoverOpen] = useState(false);
118
129
  const [statePopoverOpen, setStatePopoverOpen] = useState(false);
119
130
  const [priorityPopoverOpen, setPriorityPopoverOpen] = useState(false);
131
+ const [depPopoverOpen, setDepPopoverOpen] = useState(false);
120
132
  const [selectedStates, setSelectedStates] = useState<Set<string>>(
121
133
  () => new Set(ALL_STATES),
122
134
  );
@@ -209,6 +221,7 @@ export function StorySelectDialog({
209
221
  projectId: projectFilter === ALL_PROJECTS ? undefined : projectFilter,
210
222
  search: debouncedSearch || undefined,
211
223
  excludeStoryId: storyId,
224
+ excludeIds: excludeIds.length > 0 ? excludeIds : undefined,
212
225
  mode,
213
226
  },
214
227
  })
@@ -226,7 +239,7 @@ export function StorySelectDialog({
226
239
  if (reqId !== requestIdRef.current) return;
227
240
  setLoading(false);
228
241
  });
229
- }, [open, projectFilter, debouncedSearch, storyId, mode]);
242
+ }, [open, projectFilter, debouncedSearch, storyId, excludeIds, mode]);
230
243
 
231
244
  const loadMore = useCallback(async () => {
232
245
  if (loading || !hasMore || !nextCursor) return;
@@ -239,6 +252,7 @@ export function StorySelectDialog({
239
252
  search: debouncedSearch || undefined,
240
253
  cursor: nextCursor,
241
254
  excludeStoryId: storyId,
255
+ excludeIds: excludeIds.length > 0 ? excludeIds : undefined,
242
256
  mode,
243
257
  },
244
258
  });
@@ -253,11 +267,17 @@ export function StorySelectDialog({
253
267
  if (reqId !== requestIdRef.current) return;
254
268
  setLoading(false);
255
269
  }
256
- }, [loading, hasMore, nextCursor, projectFilter, debouncedSearch, storyId, mode]);
270
+ }, [loading, hasMore, nextCursor, projectFilter, debouncedSearch, storyId, excludeIds, mode]);
257
271
 
258
272
  // 点击 Popover 外部时关闭(Dialog 嵌套下 Radix 事件被拦截)
259
273
  useEffect(() => {
260
- if (!projectPopoverOpen && !statePopoverOpen && !priorityPopoverOpen) return;
274
+ if (
275
+ !projectPopoverOpen &&
276
+ !statePopoverOpen &&
277
+ !priorityPopoverOpen &&
278
+ !depPopoverOpen
279
+ )
280
+ return;
261
281
  const handlePointerDown = (e: PointerEvent) => {
262
282
  const target = e.target as Node;
263
283
  const content = document.querySelector('[data-slot="popover-content"]');
@@ -271,11 +291,12 @@ export function StorySelectDialog({
271
291
  setProjectPopoverOpen(false);
272
292
  setStatePopoverOpen(false);
273
293
  setPriorityPopoverOpen(false);
294
+ setDepPopoverOpen(false);
274
295
  }
275
296
  };
276
297
  document.addEventListener("pointerdown", handlePointerDown);
277
298
  return () => document.removeEventListener("pointerdown", handlePointerDown);
278
- }, [projectPopoverOpen, statePopoverOpen, priorityPopoverOpen]);
299
+ }, [projectPopoverOpen, statePopoverOpen, priorityPopoverOpen, depPopoverOpen]);
279
300
 
280
301
  const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
281
302
  const el = e.currentTarget;
@@ -288,8 +309,8 @@ export function StorySelectDialog({
288
309
  const handleSubmit = async () => {
289
310
  if (selectedIds.length === 0) return;
290
311
  if (skipTransfer) {
312
+ // dependency 模式:关闭时机由回调方控制(部分失败时保留弹窗与已选项)
291
313
  const parentId = isMulti ? selectedIds : selectedIds[0];
292
- onOpenChange(false);
293
314
  onSelected(parentId);
294
315
  return;
295
316
  }
@@ -384,7 +405,7 @@ export function StorySelectDialog({
384
405
  )}
385
406
  >
386
407
  <Icon className={cn("size-3.5 shrink-0", STATE_ICON_COLOR[s.value])} />
387
- <span className="flex-1">{s.label}</span>
408
+ <span className="flex-1">{t(s.label)}</span>
388
409
  {checked && <Check className="size-3.5 shrink-0 text-primary" />}
389
410
  </button>
390
411
  );
@@ -432,7 +453,7 @@ export function StorySelectDialog({
432
453
  )}
433
454
  >
434
455
  <PriorityIcon priority={p.value} />
435
- <span className="flex-1">{p.label}</span>
456
+ <span className="flex-1">{t(p.label)}</span>
436
457
  {checked && <Check className="size-3.5 shrink-0 text-primary" />}
437
458
  </button>
438
459
  );
@@ -541,8 +562,9 @@ export function StorySelectDialog({
541
562
  const owner = projectById.get(story.projectId);
542
563
  return (
543
564
  <li key={story.id}>
544
- <button
545
- type="button"
565
+ <div
566
+ role="button"
567
+ tabIndex={0}
546
568
  onClick={() => {
547
569
  if (isMulti) {
548
570
  setSelectedIds((prev) =>
@@ -554,7 +576,21 @@ export function StorySelectDialog({
554
576
  setSelectedIds([story.id]);
555
577
  }
556
578
  }}
557
- className={`flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-left text-sm transition-colors ${
579
+ onKeyDown={(e) => {
580
+ if (e.key === "Enter" || e.key === " ") {
581
+ e.preventDefault();
582
+ if (isMulti) {
583
+ setSelectedIds((prev) =>
584
+ prev.includes(story.id)
585
+ ? prev.filter((id) => id !== story.id)
586
+ : [...prev, story.id],
587
+ );
588
+ } else {
589
+ setSelectedIds([story.id]);
590
+ }
591
+ }
592
+ }}
593
+ className={`flex w-full cursor-pointer items-center gap-2.5 rounded-md px-3 py-2 text-left text-sm transition-colors ${
558
594
  isSelected
559
595
  ? "bg-accent ring-1 ring-primary/30"
560
596
  : "hover:bg-muted"
@@ -579,7 +615,7 @@ export function StorySelectDialog({
579
615
  STATE_COLOR[state] ?? STATE_COLOR.backlog
580
616
  }`}
581
617
  >
582
- {STATE_LABEL[state] ?? state}
618
+ {t(STATE_LABEL[state] ?? state)}
583
619
  </span>
584
620
  <PriorityIcon priority={priority} />
585
621
  <span className="shrink-0 font-mono text-xs text-muted-foreground">
@@ -596,7 +632,7 @@ export function StorySelectDialog({
596
632
  {!isMulti && isSelected && (
597
633
  <Check className="size-4 shrink-0 text-primary" />
598
634
  )}
599
- </button>
635
+ </div>
600
636
  </li>
601
637
  );
602
638
  })}
@@ -638,6 +674,57 @@ export function StorySelectDialog({
638
674
  <span className="text-xs text-muted-foreground">
639
675
  {selectedCount > 0 ? t("select.selectedCount", { count: selectedCount }) : t("select.pleaseSelect")}
640
676
  </span>
677
+ {mode === "dependency" && (
678
+ <Popover open={depPopoverOpen} onOpenChange={setDepPopoverOpen}>
679
+ <PopoverTrigger asChild>
680
+ <Button
681
+ variant="ghost"
682
+ size="sm"
683
+ className={cn(
684
+ "h-9 gap-1.5",
685
+ !dependencyType && "text-muted-foreground",
686
+ )}
687
+ >
688
+ <Waypoints className="size-3.5" />
689
+ {dependencyType
690
+ ? t(
691
+ DEPENDENCY_TYPE_OPTIONS.find((o) => o.key === dependencyType)!
692
+ .optionLabelKey,
693
+ )
694
+ : t("dependency.typePlaceholder")}
695
+ </Button>
696
+ </PopoverTrigger>
697
+ <PopoverContent align="start" className="w-80 p-1">
698
+ {(["dependsOn", "dependents"] as const).map((group, gi) => (
699
+ <div key={group} className={cn(gi > 0 && "mt-1 border-t pt-1")}>
700
+ <p className="px-2 py-1 text-xs font-medium text-muted-foreground">
701
+ {t(`dependency.optionGroups.${group}`)}
702
+ </p>
703
+ {DEPENDENCY_TYPE_OPTIONS.filter((o) => o.group === group).map((opt) => {
704
+ const checked = dependencyType === opt.key;
705
+ return (
706
+ <button
707
+ key={opt.key}
708
+ type="button"
709
+ onClick={() => {
710
+ onDependencyTypeChange?.(opt.key);
711
+ setDepPopoverOpen(false);
712
+ }}
713
+ className={cn(
714
+ "flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-muted",
715
+ checked && "font-medium",
716
+ )}
717
+ >
718
+ <span className="flex-1">{t(opt.optionLabelKey)}</span>
719
+ {checked && <Check className="size-3.5 shrink-0 text-primary" />}
720
+ </button>
721
+ );
722
+ })}
723
+ </div>
724
+ ))}
725
+ </PopoverContent>
726
+ </Popover>
727
+ )}
641
728
  </div>
642
729
  )}
643
730
  <div className="flex items-center gap-3">
@@ -650,7 +737,11 @@ export function StorySelectDialog({
650
737
  </Button>
651
738
  <Button
652
739
  onClick={handleSubmit}
653
- disabled={(isMulti ? selectedCount === 0 : selectedIds.length === 0) || submitting}
740
+ disabled={
741
+ (isMulti ? selectedCount === 0 : selectedIds.length === 0) ||
742
+ submitting ||
743
+ (mode === "dependency" && !dependencyType)
744
+ }
654
745
  >
655
746
  {submitting ? t("select.loading") : isMulti ? t("select.confirmWithCount", { count: selectedCount }) : t("select.confirm")}
656
747
  </Button>
@@ -246,7 +246,14 @@ export function StoryPeekPanel({
246
246
  <>
247
247
  <div
248
248
  className="absolute inset-y-0 right-0 z-20 flex w-[min(100%,max(85vw,560px),1200px)] flex-col overflow-hidden border-l bg-popover rounded-r-2xl rounded-l-none shadow-xl ring-1 ring-foreground/10 @max-[1399px]:rounded-l-2xl"
249
- onClick={(e) => e.stopPropagation()}
249
+ onClick={(e) => {
250
+ // 仅拦截本容器 DOM 内的点击(防止冒泡到列表背景误关 peek)。
251
+ // Radix 弹层 portal 到 body、不在本容器 DOM 内,其合成事件冒泡会经过这里,
252
+ // 若 stopPropagation 会同时终止原生 click 冒泡到 document,
253
+ // 导致 DismissableLayer 的 outside-dismiss 判定失效(弹窗内 popover 无法关闭)。
254
+ if (!e.currentTarget.contains(e.target as Node)) return;
255
+ e.stopPropagation();
256
+ }}
250
257
  >
251
258
  {header}
252
259
  {body}
@@ -0,0 +1,171 @@
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
+ // jsdom 缺少 ResizeObserver,Radix Dialog 依赖它
19
+ const MockResizeObserver = class ResizeObserver {
20
+ observe() {}
21
+ unobserve() {}
22
+ disconnect() {}
23
+ };
24
+ if (typeof globalThis.ResizeObserver === "undefined") {
25
+ (globalThis as any).ResizeObserver = MockResizeObserver;
26
+ }
27
+
28
+ import { describe, it, expect, vi, beforeEach } from "vitest";
29
+ import { render, screen, fireEvent, waitFor, type RenderResult } from "@testing-library/react";
30
+ import { TooltipProvider } from "@/components/ui/tooltip";
31
+
32
+ vi.mock("sonner", () => ({
33
+ toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
34
+ }));
35
+
36
+ const fns = vi.hoisted(() => ({ uploadFetch: vi.fn() }));
37
+
38
+ // 上传走 /api/attachments/upload(multipart fetch),mock 全局 fetch
39
+ vi.stubGlobal("fetch", fns.uploadFetch);
40
+
41
+ function uploadResponse(body: Record<string, unknown>) {
42
+ return {
43
+ ok: true,
44
+ json: () => Promise.resolve(body),
45
+ };
46
+ }
47
+
48
+ import { AttachmentManageDialog } from "../attachment-manage-dialog";
49
+ import type { StoryAttachmentItem } from "../../types";
50
+
51
+ const ATTS: StoryAttachmentItem[] = [
52
+ { id: "a1", fileName: "设计稿.png", fileSize: 2048, mimeType: "image/png", storageUrl: "/mock-storage/1" },
53
+ { id: "a2", fileName: "说明.pdf", fileSize: 1048576, mimeType: "application/pdf", storageUrl: "/mock-storage/2" },
54
+ ];
55
+
56
+ function setup(): RenderResult & { onUpdate: ReturnType<typeof vi.fn>; onOpenChange: ReturnType<typeof vi.fn> } {
57
+ const onUpdate = vi.fn();
58
+ const onOpenChange = vi.fn();
59
+ const view = render(
60
+ <TooltipProvider>
61
+ <AttachmentManageDialog
62
+ open
63
+ onOpenChange={onOpenChange}
64
+ storyId="s1"
65
+ data={ATTS}
66
+ onUpdate={onUpdate}
67
+ />
68
+ </TooltipProvider>,
69
+ );
70
+ return { ...view, onUpdate, onOpenChange };
71
+ }
72
+
73
+ function getFileInput() {
74
+ return document.querySelector('input[type="file"]') as HTMLInputElement;
75
+ }
76
+
77
+ function stageFile(file: File) {
78
+ fireEvent.change(getFileInput(), { target: { files: [file] } });
79
+ }
80
+
81
+ beforeEach(() => {
82
+ vi.clearAllMocks();
83
+ fns.uploadFetch.mockResolvedValue(
84
+ uploadResponse({
85
+ id: "a3",
86
+ fileName: "新文件.txt",
87
+ fileSize: 5,
88
+ mimeType: "text/plain",
89
+ storageUrl: "/api/attachments/a3/content",
90
+ }),
91
+ );
92
+ });
93
+
94
+ describe("AttachmentManageDialog", () => {
95
+ it("不渲染已有附件列表,仅保留上传区", () => {
96
+ setup();
97
+ expect(screen.queryByText("设计稿.png")).not.toBeInTheDocument();
98
+ expect(screen.getByText("点击或拖拽上传")).toBeInTheDocument();
99
+ });
100
+
101
+ it("选择文件后先在弹窗内预览(暂存),点击确认上传才发起 POST", async () => {
102
+ setup();
103
+ const file = new File(["hello"], "新文件.txt", { type: "text/plain" });
104
+ stageFile(file);
105
+ // 暂存预览:文件名 + 移除按钮出现
106
+ expect(screen.getByRole("button", { name: "移除" })).toBeInTheDocument();
107
+ expect(screen.getByRole("button", { name: "移除" })).toBeInTheDocument();
108
+ // 未确认前不发起上传
109
+ expect(fns.uploadFetch).not.toHaveBeenCalled();
110
+ fireEvent.click(screen.getByRole("button", { name: "确认上传" }));
111
+ await waitFor(() => expect(fns.uploadFetch).toHaveBeenCalled());
112
+ const [url, init] = fns.uploadFetch.mock.calls[0];
113
+ expect(url).toBe("/api/attachments/upload");
114
+ expect(init.method).toBe("POST");
115
+ const formData = init.body as FormData;
116
+ expect(formData.get("storyId")).toBe("s1");
117
+ expect(formData.get("file")).toBeInstanceOf(File);
118
+ });
119
+
120
+ it("确认上传成功后回调 onUpdate(含内容路由 URL)并关闭弹窗", async () => {
121
+ const { onUpdate, onOpenChange } = setup();
122
+ const file = new File(["hello"], "新文件.txt", { type: "text/plain" });
123
+ stageFile(file);
124
+ fireEvent.click(screen.getByRole("button", { name: "确认上传" }));
125
+ await waitFor(() => expect(onUpdate).toHaveBeenCalled());
126
+ const added = onUpdate.mock.calls[0][0][0];
127
+ expect(added.storageUrl).toBe("/api/attachments/a3/content");
128
+ expect(onOpenChange).toHaveBeenCalledWith(false);
129
+ });
130
+
131
+ it("描述随确认上传提交到 FormData", async () => {
132
+ setup();
133
+ const file = new File(["hello"], "新文件.txt", { type: "text/plain" });
134
+ stageFile(file);
135
+ fireEvent.change(screen.getByPlaceholderText("补充说明…"), { target: { value: "测试描述" } });
136
+ fireEvent.click(screen.getByRole("button", { name: "确认上传" }));
137
+ await waitFor(() => expect(fns.uploadFetch).toHaveBeenCalled());
138
+ const formData = fns.uploadFetch.mock.calls[0][1].body as FormData;
139
+ expect(formData.get("description")).toBe("测试描述");
140
+ });
141
+
142
+ it("上传接口报错时 toast 提示且不回调 onUpdate", async () => {
143
+ const { toast } = await import("sonner");
144
+ fns.uploadFetch.mockResolvedValue({ ok: false, json: () => Promise.resolve({ error: "文件大小需在 10MB 以内" }) });
145
+ const { onUpdate } = setup();
146
+ const file = new File(["hello"], "新文件.txt", { type: "text/plain" });
147
+ stageFile(file);
148
+ fireEvent.click(screen.getByRole("button", { name: "确认上传" }));
149
+ await waitFor(() => expect(toast.error).toHaveBeenCalled());
150
+ expect(onUpdate).not.toHaveBeenCalled();
151
+ });
152
+
153
+ it("超过 10MB 的文件被拒绝且不入暂存列表", async () => {
154
+ const { toast } = await import("sonner");
155
+ setup();
156
+ const big = new File([new ArrayBuffer(11 * 1024 * 1024)], "大文件.zip", { type: "application/zip" });
157
+ stageFile(big);
158
+ await waitFor(() => expect(toast.error).toHaveBeenCalled());
159
+ expect(fns.uploadFetch).not.toHaveBeenCalled();
160
+ expect(screen.queryByText("大文件.zip")).not.toBeInTheDocument();
161
+ });
162
+
163
+ it("取消按钮关闭弹窗", () => {
164
+ const { onOpenChange } = setup();
165
+ const file = new File(["hello"], "新文件.txt", { type: "text/plain" });
166
+ stageFile(file);
167
+ expect(screen.getByRole("button", { name: "移除" })).toBeInTheDocument();
168
+ fireEvent.click(screen.getByRole("button", { name: "取消" }));
169
+ expect(onOpenChange).toHaveBeenCalledWith(false);
170
+ });
171
+ });
@@ -0,0 +1,174 @@
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 { describe, it, expect, vi, beforeEach } from "vitest";
19
+ import { render, screen, fireEvent, waitFor } from "@testing-library/react";
20
+
21
+ vi.mock("@tanstack/react-start", () => ({
22
+ useServerFn: (fn: any) => vi.fn().mockImplementation((args: any) => fn(args)),
23
+ }));
24
+
25
+ const fns = vi.hoisted(() => ({
26
+ listKbs: vi.fn(),
27
+ search: vi.fn(),
28
+ link: vi.fn(),
29
+ unlink: vi.fn(),
30
+ }));
31
+
32
+ vi.mock("@/server/serverFns/story", () => ({
33
+ listStoryAvailableKbsFn: fns.listKbs,
34
+ searchKbDocsFn: fns.search,
35
+ linkDocsFn: fns.link,
36
+ unlinkDocFn: fns.unlink,
37
+ }));
38
+
39
+ vi.mock("sonner", () => ({
40
+ toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn() },
41
+ }));
42
+
43
+ import { KnowledgeSelectDialog } from "../knowledge-select-dialog";
44
+ import type { DocBrief } from "../../types";
45
+
46
+ const doc = (id: string, title: string, parentId: string | null = null): DocBrief => ({
47
+ id,
48
+ title,
49
+ parentId,
50
+ kbId: "kb1",
51
+ kbName: "团队库",
52
+ kbIcon: "notebook",
53
+ docIcon: null,
54
+ });
55
+
56
+ const LINKED: DocBrief[] = [doc("d1", "已关联页")];
57
+
58
+ function setup() {
59
+ const onUpdate = vi.fn();
60
+ render(
61
+ <KnowledgeSelectDialog
62
+ open
63
+ onOpenChange={() => {}}
64
+ storyId="s1"
65
+ linkedDocs={LINKED}
66
+ onUpdate={onUpdate}
67
+ />,
68
+ );
69
+ return { onUpdate };
70
+ }
71
+
72
+ beforeEach(() => {
73
+ vi.clearAllMocks();
74
+ fns.listKbs.mockResolvedValue([
75
+ { id: "kb1", name: "团队库", icon: "notebook", visibility: "team" },
76
+ { id: "kb2", name: "通用库", icon: "book", visibility: "general" },
77
+ ]);
78
+ fns.search.mockResolvedValue({
79
+ items: [doc("d1", "已关联页"), doc("d2", "子页", "d1"), doc("d3", "普通页")],
80
+ nextCursor: null,
81
+ });
82
+ });
83
+
84
+ describe("KnowledgeSelectDialog", () => {
85
+ it("加载可用 KB 与页面列表,已关联项默认勾选且带 badge", async () => {
86
+ setup();
87
+ // KB 名称出现在 Popover 触发器和文档列表中,用含角色断言避免歧义
88
+ await waitFor(() => expect(screen.getByText("已关联页")).toBeInTheDocument());
89
+ expect(screen.getByText("已关联")).toBeInTheDocument();
90
+ });
91
+
92
+ it("切换勾选后确认:新增走 linkDocsFn、取消走 unlinkDocFn", async () => {
93
+ fns.link.mockResolvedValue({ linked: ["d2", "d3"], skipped: [], failed: [] });
94
+ fns.unlink.mockResolvedValue({ ok: true });
95
+ const { onUpdate } = setup();
96
+ await waitFor(() => screen.getByText("普通页"));
97
+ // 勾选 d3(新增)
98
+ const checkboxes = screen.getAllByRole("checkbox");
99
+ // d3 是第三个(已关联页、子页、普通页)
100
+ fireEvent.click(checkboxes[2]);
101
+ // 取消 d1
102
+ fireEvent.click(checkboxes[0]);
103
+ fireEvent.click(screen.getByRole("button", { name: "确认" }));
104
+ await waitFor(() => expect(fns.link).toHaveBeenCalledWith({
105
+ data: { storyId: "s1", docIds: expect.arrayContaining(["d3"]) },
106
+ }));
107
+ await waitFor(() => expect(fns.unlink).toHaveBeenCalledWith({
108
+ data: { storyId: "s1", docId: "d1" },
109
+ }));
110
+ await waitFor(() => expect(onUpdate).toHaveBeenCalled());
111
+ });
112
+
113
+ it("linkDocsFn 返回 failed 时 toast warning 且不关弹窗", async () => {
114
+ fns.link.mockResolvedValue({ linked: [], skipped: [], failed: [{ docId: "d9", reason: "outOfScope" }] });
115
+ const { toast } = await import("sonner");
116
+ setup();
117
+ await waitFor(() => screen.getByText("普通页"));
118
+ const checkboxes = screen.getAllByRole("checkbox");
119
+ fireEvent.click(checkboxes[2]);
120
+ fireEvent.click(screen.getByRole("button", { name: "确认" }));
121
+ await waitFor(() => expect(toast.warning).toHaveBeenCalled());
122
+ });
123
+
124
+ it("KB 选择弹层含我的知识库与通用知识库两个分组区(五个通用组)", async () => {
125
+ setup();
126
+ await waitFor(() => screen.getByText("普通页"));
127
+ fireEvent.click(screen.getByRole("button", { name: /全部知识库/ }));
128
+ expect(screen.getByText("我的知识库")).toBeInTheDocument();
129
+ expect(screen.getByText("通用知识库")).toBeInTheDocument();
130
+ for (const label of ["法律法规", "行业合规", "词典词条", "网站收藏", "周报月报"]) {
131
+ expect(screen.getByText(label)).toBeInTheDocument();
132
+ }
133
+ });
134
+
135
+ it("选择通用知识库分组后搜索带 generalGroup 参数", async () => {
136
+ setup();
137
+ await waitFor(() => screen.getByText("普通页"));
138
+ fireEvent.click(screen.getByRole("button", { name: /全部知识库/ }));
139
+ fireEvent.click(screen.getByText("法律法规"));
140
+ await waitFor(() =>
141
+ expect(fns.search).toHaveBeenCalledWith(
142
+ expect.objectContaining({ data: expect.objectContaining({ generalGroup: "legal" }) }),
143
+ ),
144
+ );
145
+ });
146
+
147
+ it("底部全选复选框支持全选与部分选中(indeterminate)状态", async () => {
148
+ setup();
149
+ await waitFor(() => screen.getByText("普通页"));
150
+ const footerCheckbox = screen.getByRole("checkbox", { name: "全选" });
151
+ // 初始:仅 d1 已勾选(部分选中)→ indeterminate
152
+ expect(footerCheckbox.getAttribute("aria-checked")).toBe("mixed");
153
+ // 全选
154
+ fireEvent.click(footerCheckbox);
155
+ const checkboxes = screen.getAllByRole("checkbox");
156
+ for (const cb of checkboxes) expect(cb.getAttribute("aria-checked")).toBe("true");
157
+ // 再次点击取消全选
158
+ fireEvent.click(footerCheckbox);
159
+ expect(footerCheckbox.getAttribute("aria-checked")).toBe("false");
160
+ });
161
+
162
+ it("点击行任意位置切换选中(勾选框点击不重复触发)", async () => {
163
+ setup();
164
+ await waitFor(() => screen.getByText("普通页"));
165
+ // 点击行文本区域 → 选中
166
+ fireEvent.click(screen.getByText("普通页"));
167
+ const checkboxes = screen.getAllByRole("checkbox");
168
+ // d3(普通页)对应第三个行勾选框
169
+ expect(checkboxes[2].getAttribute("aria-checked")).toBe("true");
170
+ // 再次点击行 → 取消选中
171
+ fireEvent.click(screen.getByText("普通页"));
172
+ expect(checkboxes[2].getAttribute("aria-checked")).toBe("false");
173
+ });
174
+ });