@allbluecn/web-app 0.5.0 → 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 (69) hide show
  1. package/package.json +11 -7
  2. package/src/components/knowledge/knowledge-toolbar.tsx +124 -1
  3. package/src/components/story/constants.ts +11 -1
  4. package/src/components/story/create-story-dialog.tsx +12 -6
  5. package/src/components/story/detail/__tests__/associated-attachments-panel.test.tsx +114 -0
  6. package/src/components/story/detail/activity-timeline.tsx +15 -0
  7. package/src/components/story/detail/story-detail-body.tsx +1 -0
  8. package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +748 -0
  9. package/src/components/story/detail/story-detail-toolbar/categories.tsx +23 -18
  10. package/src/components/story/detail/story-detail-toolbar/children.tsx +2 -0
  11. package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +29 -21
  12. package/src/components/story/detail/story-detail-toolbar.tsx +154 -8
  13. package/src/components/story/inline-editors/story-select-dialog.tsx +19 -4
  14. package/src/components/story/relations/__tests__/attachment-manage-dialog.test.tsx +171 -0
  15. package/src/components/story/relations/__tests__/knowledge-select-dialog.test.tsx +174 -0
  16. package/src/components/story/relations/__tests__/link-manage-dialog.test.tsx +93 -0
  17. package/src/components/story/relations/attachment-manage-dialog.tsx +490 -0
  18. package/src/components/story/relations/file-preview.tsx +173 -0
  19. package/src/components/story/relations/format.ts +120 -0
  20. package/src/components/story/relations/general-groups.ts +32 -0
  21. package/src/components/story/relations/knowledge-select-dialog.tsx +403 -0
  22. package/src/components/story/relations/link-manage-dialog.tsx +191 -0
  23. package/src/components/story/types.ts +13 -0
  24. package/src/lib/changelog/sdk-versions.ts +19 -19
  25. package/src/lib/story/__tests__/video-embed.test.ts +77 -0
  26. package/src/lib/story/video-embed.ts +59 -0
  27. package/src/plugins/.gen-manifest.json +13 -0
  28. package/src/plugins/modules.gen.ts +20 -0
  29. package/src/plugins/plugins.gen.ts +20 -0
  30. package/src/plugins/ui.gen.ts +8 -0
  31. package/src/routeTree.gen.ts +42 -0
  32. package/src/routes/_nav/inspiration/route.tsx +8 -0
  33. package/src/routes/_nav/prd/$id/route.tsx +9 -0
  34. package/src/routes/_nav/prd/index.tsx +9 -0
  35. package/src/routes/_nav/prd/route.tsx +8 -0
  36. package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +74 -3
  37. package/src/routes/_nav/projects/$projectId/settings/route.tsx +6 -2
  38. package/src/routes/_nav/tags/$id/route.tsx +8 -0
  39. package/src/routes/_nav/tags/index.tsx +8 -0
  40. package/src/routes/_nav/tags/route.tsx +8 -0
  41. package/src/routes/_nav/tags/settings/route.tsx +9 -0
  42. package/src/routes/api/attachments/$id/content.ts +60 -0
  43. package/src/routes/api/attachments/upload.ts +148 -0
  44. package/src/routes/share/prd/$prdId/route.tsx +10 -0
  45. package/src/server/serverFns/__tests__/project-kb.test.ts +147 -0
  46. package/src/server/serverFns/knowledge.ts +50 -4
  47. package/src/server/serverFns/project.ts +43 -0
  48. package/src/server/serverFns/story/__tests__/kb-scope.test.ts +56 -0
  49. package/src/server/serverFns/story/__tests__/story-assoc.test.ts +222 -0
  50. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +26 -0
  51. package/src/server/serverFns/story/index.ts +6 -4
  52. package/src/server/serverFns/story/kb-general.ts +39 -0
  53. package/src/server/serverFns/story/kb-scope.ts +52 -0
  54. package/src/server/serverFns/story/link-preview.ts +115 -0
  55. package/src/server/serverFns/story/story-assoc.ts +339 -50
  56. package/src/server/serverFns/story/story-attachments.ts +12 -43
  57. package/src/server/serverFns/story/story-crud.ts +21 -4
  58. package/src/server/serverFns/story/story-dependencies.ts +35 -3
  59. package/src/server/serverFns/story/story-links.ts +13 -1
  60. package/src/server/serverFns/story/story-tree.ts +28 -1
  61. package/src/server/serverFns/story/types.ts +37 -0
  62. package/src/styles/globals.css +25 -0
  63. package/vite.shared.ts +2 -0
  64. package/src/components/story/detail/story-detail-toolbar/attachments.tsx +0 -73
  65. package/src/components/story/detail/story-detail-toolbar/knowledge.tsx +0 -73
  66. package/src/components/story/detail/story-detail-toolbar/links.tsx +0 -73
  67. package/src/components/story/relations/attachments-panel.tsx +0 -179
  68. package/src/components/story/relations/knowledge-panel.tsx +0 -284
  69. package/src/components/story/relations/links-panel.tsx +0 -240
@@ -50,24 +50,29 @@ export function StoryDetailCategories() {
50
50
  return (
51
51
  <>
52
52
  {CATEGORIES.map((cat) => (
53
- <Popover key={cat.key}>
54
- <PopoverTrigger asChild>
55
- <Button
56
- variant="ghost"
57
- size="sm"
58
- className="h-7 gap-1.5 px-2.5 text-xs font-normal hover:bg-muted rounded-md"
59
- title={t(cat.tooltip)}
60
- >
61
- <cat.icon className="size-3.5 text-muted-foreground" />
62
- {t(cat.label)}
63
- </Button>
64
- </PopoverTrigger>
65
- <PopoverContent align="start" className="w-[320px] p-4">
66
- <div className="text-sm text-muted-foreground">
67
- {t(cat.placeholder)}
68
- </div>
69
- </PopoverContent>
70
- </Popover>
53
+ <React.Fragment key={cat.key}>
54
+ {cat.key === "bug" && (
55
+ <span className="mx-0.5 h-4 w-px shrink-0 bg-muted-foreground/20" />
56
+ )}
57
+ <Popover>
58
+ <PopoverTrigger asChild>
59
+ <Button
60
+ variant="ghost"
61
+ size="sm"
62
+ className="h-7 gap-1.5 px-2.5 text-xs font-normal hover:bg-muted rounded-md"
63
+ title={t(cat.tooltip)}
64
+ >
65
+ <cat.icon className="size-3.5 text-muted-foreground" />
66
+ {t(cat.label)}
67
+ </Button>
68
+ </PopoverTrigger>
69
+ <PopoverContent align="start" className="w-[320px] p-4">
70
+ <div className="text-sm text-muted-foreground">
71
+ {t(cat.placeholder)}
72
+ </div>
73
+ </PopoverContent>
74
+ </Popover>
75
+ </React.Fragment>
71
76
  ))}
72
77
  </>
73
78
  );
@@ -448,6 +448,7 @@ export function StoryDetailChildrenPanel({
448
448
  children: prev.hierarchy.children.map((c) => c.id === storyId ? { ...c, ...patch } : c),
449
449
  },
450
450
  }));
451
+ refreshChildren();
451
452
  }
452
453
  }}
453
454
  />
@@ -495,6 +496,7 @@ export function StoryDetailChildrenPanel({
495
496
  children: prev.hierarchy.children.map((c) => c.id === storyId ? { ...c, ...patch } : c),
496
497
  },
497
498
  }));
499
+ refreshChildren();
498
500
  }
499
501
  }}
500
502
  />
@@ -288,6 +288,8 @@ export function StoryDetailDependencyPanel({
288
288
  next.delete(rowId);
289
289
  return next;
290
290
  });
291
+ } else {
292
+ refreshDependencies();
291
293
  }
292
294
  };
293
295
 
@@ -354,29 +356,35 @@ export function StoryDetailDependencyPanel({
354
356
 
355
357
  const renderSection = (
356
358
  titleKey: string,
357
- group: DependencySection,
359
+ sectionKey: DependencySection,
358
360
  rows: DependencyRow[],
359
361
  groups: GroupNode[],
360
- ) => (
361
- <div>
362
- <div className="flex h-9 items-center gap-1.5 bg-muted/50 px-2">
363
- <span className="text-sm font-medium">{t(titleKey)}</span>
364
- <span className="inline-flex h-4 shrink-0 items-center rounded border px-1.5 text-[10px] font-normal text-muted-foreground whitespace-nowrap">
365
- {rows.length}
366
- </span>
367
- </div>
368
- {rows.length === 0 ? (
369
- <p className="flex items-center gap-1.5 px-2 py-1.5 text-xs text-muted-foreground">
370
- <span className="w-4 shrink-0" aria-hidden />
371
- {t("relation.none")}
372
- </p>
373
- ) : filters.groupBy === "none" ? (
374
- rows.map((row) => renderRow(row, group))
375
- ) : (
376
- groups.map((node) => renderGroupItems(node, group, 0, `${group}:${node.key}`))
377
- )}
378
- </div>
379
- );
362
+ ) => {
363
+ const isCollapsed = collapsed[`${sectionKey}:root`] ?? false;
364
+ return (
365
+ <Collapsible open={!isCollapsed} onOpenChange={(open) => setCollapsed((p) => ({ ...p, [`${sectionKey}:root`]: !open }))}>
366
+ <CollapsibleTrigger asChild>
367
+ <div className="flex h-9 cursor-pointer items-center gap-1.5 bg-muted/50 px-3 text-sm font-medium hover:text-foreground transition-colors">
368
+ <ChevronDown className={`size-3 shrink-0 text-muted-foreground transition-transform ${isCollapsed ? "-rotate-90" : "rotate-0"}`} />
369
+ <span>{t(titleKey)}</span>
370
+ <span className="ml-1 text-xs text-muted-foreground">{rows.length}</span>
371
+ </div>
372
+ </CollapsibleTrigger>
373
+ <CollapsibleContent>
374
+ {rows.length === 0 ? (
375
+ <p className="flex items-center gap-1.5 px-2 py-1.5 text-xs text-muted-foreground">
376
+ <span className="w-4 shrink-0" aria-hidden />
377
+ {t("relation.none")}
378
+ </p>
379
+ ) : filters.groupBy === "none" ? (
380
+ rows.map((row) => renderRow(row, sectionKey))
381
+ ) : (
382
+ groups.map((node) => renderGroupItems(node, sectionKey, 0, `${sectionKey}:${node.key}`))
383
+ )}
384
+ </CollapsibleContent>
385
+ </Collapsible>
386
+ );
387
+ };
380
388
 
381
389
  return (
382
390
  <Collapsible defaultOpen>
@@ -19,13 +19,20 @@ import * as React from "react";
19
19
  import { useTranslation } from "react-i18next";
20
20
  import { useServerFn } from "@tanstack/react-start";
21
21
  import { toast } from "sonner";
22
+ import { LinkIcon, Paperclip, BookOpenText } from "lucide-react";
23
+ import { Button } from "@/components/ui/button";
22
24
  import { StoryDetailChildrenButton, StoryDetailChildrenPanel } from "./story-detail-toolbar/children";
23
25
  import { StoryDetailDependencies } from "./story-detail-toolbar/dependencies";
24
26
  import { StoryDetailDependencyPanel } from "./story-detail-toolbar/dependency-panel";
25
- import { StoryDetailLinks } from "./story-detail-toolbar/links";
26
- import { StoryDetailAttachments } from "./story-detail-toolbar/attachments";
27
- import { StoryDetailKnowledge } from "./story-detail-toolbar/knowledge";
28
27
  import { StoryDetailCategories } from "./story-detail-toolbar/categories";
28
+ import {
29
+ AssociatedAttachmentsPanel,
30
+ type AssociatedDialogKind,
31
+ } from "./story-detail-toolbar/associated-attachments-panel";
32
+ import { LinkManageDialog } from "../relations/link-manage-dialog";
33
+ import { AttachmentManageDialog } from "../relations/attachment-manage-dialog";
34
+ import { KnowledgeSelectDialog } from "../relations/knowledge-select-dialog";
35
+ import { getLinksFn, getAttachmentsFn, getLinkedDocsFn } from "@/server/serverFns/story";
29
36
  import { StorySelectDialog } from "../inline-editors/story-select-dialog";
30
37
  import { CreateStoryDialog } from "../create-story-dialog";
31
38
  import { addDependenciesFn, getDependenciesFn, listChildStoriesFn } from "@/server/serverFns/story";
@@ -39,15 +46,20 @@ interface StoryDetailToolbarProps {
39
46
  projectId: string | null;
40
47
  data: RelationsData;
41
48
  onRefresh: (next: (prev: RelationsData) => RelationsData) => void;
49
+ /** 关联类操作产生的活动即时追加到时间线 */
50
+ onAppendActivities?: (items: import("../types").ActivityItem[]) => void;
42
51
  users?: UserBrief[];
43
52
  projects?: { id: string; name: string; icon: string; displayId: string | null }[];
44
53
  labels?: { id: string; name: string; color: string }[];
45
54
  }
46
55
 
47
- export function StoryDetailToolbar({ storyId, storyIdentifier, projectId, data, onRefresh, users, projects, labels }: StoryDetailToolbarProps) {
56
+ export function StoryDetailToolbar({ storyId, storyIdentifier, projectId, data, onRefresh, onAppendActivities, users, projects, labels }: StoryDetailToolbarProps) {
48
57
  const { t } = useTranslation("story");
49
58
  const [actionOpen, setActionOpen] = React.useState<"create" | "select" | null>(null);
50
59
  const [dependencyDialogOpen, setDependencyDialogOpen] = React.useState(false);
60
+ const [assocDialogOpen, setAssocDialogOpen] = React.useState<AssociatedDialogKind | null>(null);
61
+ const [linkInitialEdit, setLinkInitialEdit] = React.useState<import("../types").StoryLinkItem | null>(null);
62
+ const [editAttachment, setEditAttachment] = React.useState<import("../types").StoryAttachmentItem | null>(null);
51
63
  const [dependencyType, setDependencyType] = React.useState<DependencyTypeOption["key"] | undefined>(undefined);
52
64
  const addDependencies = useServerFn(addDependenciesFn);
53
65
 
@@ -78,6 +90,10 @@ export function StoryDetailToolbar({ storyId, storyIdentifier, projectId, data,
78
90
  const res = await addDependencies({ data: { items } });
79
91
  if (res.created.length > 0) {
80
92
  toast.success(t("dependency.added", { count: res.created.length }));
93
+ const mine = res.activities
94
+ .filter((a) => a.storyId === storyId)
95
+ .map(({ storyId: _sid, ...rest }) => rest);
96
+ if (mine.length > 0) onAppendActivities?.(mine);
81
97
  }
82
98
  if (res.failed.length > 0) {
83
99
  const reasons = [...new Set(res.failed.map((f) => t(`dependency.errors.${f.reason}`)))].join("、");
@@ -117,9 +133,46 @@ export function StoryDetailToolbar({ storyId, storyIdentifier, projectId, data,
117
133
  }
118
134
  };
119
135
 
120
- const handleCreateSuccess = () => {
136
+ const handleCreateSuccess = (
137
+ _story: { id: string; name: string; identifier?: string },
138
+ activities?: import("../types").ActivityItem[],
139
+ ) => {
121
140
  setActionOpen(null);
122
141
  refreshChildren();
142
+ if (activities && activities.length > 0) onAppendActivities?.(activities);
143
+ };
144
+
145
+ const refreshLinks = async () => {
146
+ try {
147
+ const links = await getLinksFn({ data: { storyId } });
148
+ onRefresh((prev) => ({ ...prev, links }));
149
+ } catch {
150
+ // 静默兜底
151
+ }
152
+ };
153
+
154
+ const refreshAttachments = async () => {
155
+ try {
156
+ const attachments = await getAttachmentsFn({ data: { storyId } });
157
+ onRefresh((prev) => ({ ...prev, attachments }));
158
+ } catch {
159
+ // 静默兜底
160
+ }
161
+ };
162
+
163
+ const refreshKnowledge = async () => {
164
+ try {
165
+ const docs = await getLinkedDocsFn({ data: { storyId } });
166
+ onRefresh((prev) => ({ ...prev, knowledge: { ...prev.knowledge, docs } }));
167
+ } catch {
168
+ // 静默兜底
169
+ }
170
+ };
171
+
172
+ const handleOpenAssocDialog = (kind: AssociatedDialogKind, editLink?: import("../types").StoryLinkItem, editAttachmentItem?: import("../types").StoryAttachmentItem) => {
173
+ setLinkInitialEdit(editLink ?? null);
174
+ setEditAttachment(editAttachmentItem ?? null);
175
+ setAssocDialogOpen(kind);
123
176
  };
124
177
 
125
178
  return (
@@ -133,9 +186,48 @@ export function StoryDetailToolbar({ storyId, storyIdentifier, projectId, data,
133
186
 
134
187
  <span className="mx-0.5 h-4 w-px shrink-0 bg-muted-foreground/20" />
135
188
 
136
- <StoryDetailLinks storyId={storyId} data={data} onRefresh={onRefresh} />
137
- <StoryDetailAttachments storyId={storyId} data={data} onRefresh={onRefresh} />
138
- <StoryDetailKnowledge storyId={storyId} data={data} onRefresh={onRefresh} />
189
+ <Button
190
+ variant="ghost"
191
+ size="sm"
192
+ className="h-7 gap-1.5 px-2.5 text-xs font-normal hover:bg-muted rounded-md"
193
+ onClick={() => handleOpenAssocDialog("link")}
194
+ >
195
+ <LinkIcon className="size-3.5 text-muted-foreground" />
196
+ {t("linkDialog.button")}
197
+ {data.links.length > 0 && (
198
+ <span className="ml-0.5 inline-flex min-w-[18px] items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-primary-foreground">
199
+ {data.links.length}
200
+ </span>
201
+ )}
202
+ </Button>
203
+ <Button
204
+ variant="ghost"
205
+ size="sm"
206
+ className="h-7 gap-1.5 px-2.5 text-xs font-normal hover:bg-muted rounded-md"
207
+ onClick={() => handleOpenAssocDialog("attachment")}
208
+ >
209
+ <Paperclip className="size-3.5 text-muted-foreground" />
210
+ {t("attachmentDialog.button")}
211
+ {data.attachments.length > 0 && (
212
+ <span className="ml-0.5 inline-flex min-w-[18px] items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-primary-foreground">
213
+ {data.attachments.length}
214
+ </span>
215
+ )}
216
+ </Button>
217
+ <Button
218
+ variant="ghost"
219
+ size="sm"
220
+ className="h-7 gap-1.5 px-2.5 text-xs font-normal hover:bg-muted rounded-md"
221
+ onClick={() => handleOpenAssocDialog("knowledge")}
222
+ >
223
+ <BookOpenText className="size-3.5 text-muted-foreground" />
224
+ {t("knowledgeDialog.button")}
225
+ {data.knowledge.docs.length > 0 && (
226
+ <span className="ml-0.5 inline-flex min-w-[18px] items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-primary-foreground">
227
+ {data.knowledge.docs.length}
228
+ </span>
229
+ )}
230
+ </Button>
139
231
 
140
232
  <span className="mx-0.5 h-4 w-px shrink-0 bg-muted-foreground/20" />
141
233
 
@@ -167,6 +259,13 @@ export function StoryDetailToolbar({ storyId, storyIdentifier, projectId, data,
167
259
  onOpenAdd={() => setDependencyDialogOpen(true)}
168
260
  />
169
261
 
262
+ <AssociatedAttachmentsPanel
263
+ storyId={storyId}
264
+ data={data}
265
+ onRefresh={onRefresh}
266
+ onOpenDialog={handleOpenAssocDialog}
267
+ />
268
+
170
269
  <StorySelectDialog
171
270
  open={actionOpen === "select"}
172
271
  onOpenChange={(open) => {
@@ -216,6 +315,53 @@ export function StoryDetailToolbar({ storyId, storyIdentifier, projectId, data,
216
315
  initialProjectId={projectId ?? undefined}
217
316
  onSuccess={handleCreateSuccess}
218
317
  />
318
+
319
+ <LinkManageDialog
320
+ open={assocDialogOpen === "link"}
321
+ onOpenChange={(v) => {
322
+ if (!v) {
323
+ setAssocDialogOpen(null);
324
+ setLinkInitialEdit(null);
325
+ }
326
+ }}
327
+ storyId={storyId}
328
+ data={data.links}
329
+ initialEdit={linkInitialEdit}
330
+ onActivity={(a) => onAppendActivities?.([a])}
331
+ onUpdate={(links) => {
332
+ onRefresh((prev) => ({ ...prev, links }));
333
+ void refreshLinks();
334
+ }}
335
+ />
336
+
337
+ <AttachmentManageDialog
338
+ open={assocDialogOpen === "attachment"}
339
+ onOpenChange={(v) => {
340
+ if (!v) { setAssocDialogOpen(null); setEditAttachment(null); }
341
+ }}
342
+ storyId={storyId}
343
+ data={data.attachments}
344
+ editItem={editAttachment}
345
+ onActivity={(a) => onAppendActivities?.([a])}
346
+ onUpdate={(attachments) => {
347
+ onRefresh((prev) => ({ ...prev, attachments }));
348
+ void refreshAttachments();
349
+ }}
350
+ />
351
+
352
+ <KnowledgeSelectDialog
353
+ open={assocDialogOpen === "knowledge"}
354
+ onOpenChange={(v) => {
355
+ if (!v) setAssocDialogOpen(null);
356
+ }}
357
+ storyId={storyId}
358
+ linkedDocs={data.knowledge.docs}
359
+ onActivity={(a) => onAppendActivities?.([a])}
360
+ onUpdate={(docs) => {
361
+ onRefresh((prev) => ({ ...prev, knowledge: { ...prev.knowledge, docs } }));
362
+ void refreshKnowledge();
363
+ }}
364
+ />
219
365
  </div>
220
366
  );
221
367
  }
@@ -562,8 +562,9 @@ export function StorySelectDialog({
562
562
  const owner = projectById.get(story.projectId);
563
563
  return (
564
564
  <li key={story.id}>
565
- <button
566
- type="button"
565
+ <div
566
+ role="button"
567
+ tabIndex={0}
567
568
  onClick={() => {
568
569
  if (isMulti) {
569
570
  setSelectedIds((prev) =>
@@ -575,7 +576,21 @@ export function StorySelectDialog({
575
576
  setSelectedIds([story.id]);
576
577
  }
577
578
  }}
578
- 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 ${
579
594
  isSelected
580
595
  ? "bg-accent ring-1 ring-primary/30"
581
596
  : "hover:bg-muted"
@@ -617,7 +632,7 @@ export function StorySelectDialog({
617
632
  {!isMulti && isSelected && (
618
633
  <Check className="size-4 shrink-0 text-primary" />
619
634
  )}
620
- </button>
635
+ </div>
621
636
  </li>
622
637
  );
623
638
  })}
@@ -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
+ });