@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.
- package/package.json +11 -7
- package/src/components/knowledge/knowledge-toolbar.tsx +124 -1
- package/src/components/story/constants.ts +11 -1
- package/src/components/story/create-story-dialog.tsx +12 -6
- package/src/components/story/detail/__tests__/associated-attachments-panel.test.tsx +114 -0
- package/src/components/story/detail/activity-timeline.tsx +15 -0
- package/src/components/story/detail/story-detail-body.tsx +1 -0
- package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +748 -0
- package/src/components/story/detail/story-detail-toolbar/categories.tsx +23 -18
- package/src/components/story/detail/story-detail-toolbar/children.tsx +2 -0
- package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +29 -21
- package/src/components/story/detail/story-detail-toolbar.tsx +154 -8
- package/src/components/story/inline-editors/story-select-dialog.tsx +19 -4
- package/src/components/story/relations/__tests__/attachment-manage-dialog.test.tsx +171 -0
- package/src/components/story/relations/__tests__/knowledge-select-dialog.test.tsx +174 -0
- package/src/components/story/relations/__tests__/link-manage-dialog.test.tsx +93 -0
- package/src/components/story/relations/attachment-manage-dialog.tsx +490 -0
- package/src/components/story/relations/file-preview.tsx +173 -0
- package/src/components/story/relations/format.ts +120 -0
- package/src/components/story/relations/general-groups.ts +32 -0
- package/src/components/story/relations/knowledge-select-dialog.tsx +403 -0
- package/src/components/story/relations/link-manage-dialog.tsx +191 -0
- package/src/components/story/types.ts +13 -0
- package/src/lib/changelog/sdk-versions.ts +19 -19
- package/src/lib/story/__tests__/video-embed.test.ts +77 -0
- package/src/lib/story/video-embed.ts +59 -0
- package/src/plugins/.gen-manifest.json +13 -0
- package/src/plugins/modules.gen.ts +20 -0
- package/src/plugins/plugins.gen.ts +20 -0
- package/src/plugins/ui.gen.ts +8 -0
- package/src/routeTree.gen.ts +42 -0
- package/src/routes/_nav/inspiration/route.tsx +8 -0
- package/src/routes/_nav/prd/$id/route.tsx +9 -0
- package/src/routes/_nav/prd/index.tsx +9 -0
- package/src/routes/_nav/prd/route.tsx +8 -0
- package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +74 -3
- package/src/routes/_nav/projects/$projectId/settings/route.tsx +6 -2
- package/src/routes/_nav/tags/$id/route.tsx +8 -0
- package/src/routes/_nav/tags/index.tsx +8 -0
- package/src/routes/_nav/tags/route.tsx +8 -0
- package/src/routes/_nav/tags/settings/route.tsx +9 -0
- package/src/routes/api/attachments/$id/content.ts +60 -0
- package/src/routes/api/attachments/upload.ts +148 -0
- package/src/routes/share/prd/$prdId/route.tsx +10 -0
- package/src/server/serverFns/__tests__/project-kb.test.ts +147 -0
- package/src/server/serverFns/knowledge.ts +50 -4
- package/src/server/serverFns/project.ts +43 -0
- package/src/server/serverFns/story/__tests__/kb-scope.test.ts +56 -0
- package/src/server/serverFns/story/__tests__/story-assoc.test.ts +222 -0
- package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +26 -0
- package/src/server/serverFns/story/index.ts +6 -4
- package/src/server/serverFns/story/kb-general.ts +39 -0
- package/src/server/serverFns/story/kb-scope.ts +52 -0
- package/src/server/serverFns/story/link-preview.ts +115 -0
- package/src/server/serverFns/story/story-assoc.ts +339 -50
- package/src/server/serverFns/story/story-attachments.ts +12 -43
- package/src/server/serverFns/story/story-crud.ts +21 -4
- package/src/server/serverFns/story/story-dependencies.ts +35 -3
- package/src/server/serverFns/story/story-links.ts +13 -1
- package/src/server/serverFns/story/story-tree.ts +28 -1
- package/src/server/serverFns/story/types.ts +37 -0
- package/src/styles/globals.css +25 -0
- package/vite.shared.ts +2 -0
- package/src/components/story/detail/story-detail-toolbar/attachments.tsx +0 -73
- package/src/components/story/detail/story-detail-toolbar/knowledge.tsx +0 -73
- package/src/components/story/detail/story-detail-toolbar/links.tsx +0 -73
- package/src/components/story/relations/attachments-panel.tsx +0 -179
- package/src/components/story/relations/knowledge-panel.tsx +0 -284
- 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
|
-
<
|
|
54
|
-
|
|
55
|
-
<
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
</
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
359
|
+
sectionKey: DependencySection,
|
|
358
360
|
rows: DependencyRow[],
|
|
359
361
|
groups: GroupNode[],
|
|
360
|
-
) =>
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
<
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
<
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
<
|
|
566
|
-
|
|
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
|
-
|
|
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
|
-
</
|
|
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
|
+
});
|