@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@allbluecn/web-app",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"license": "AGPL-3.0-or-later",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
"@fontsource-variable/geist-mono": "^5.3.0",
|
|
29
29
|
"@iconify/react": "^6.0.2",
|
|
30
30
|
"@lobehub/icons": "^5.16.0",
|
|
31
|
+
"@open-file-viewer/core": "^0.1.43",
|
|
32
|
+
"@open-file-viewer/react": "^0.1.43",
|
|
31
33
|
"@prisma/client": "^7.8.0",
|
|
32
34
|
"@radix-ui/react-dialog": "^1.1.23",
|
|
33
35
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
@@ -108,6 +110,8 @@
|
|
|
108
110
|
"next-themes": "^0.4.6",
|
|
109
111
|
"nitro": "3.0.260603-beta",
|
|
110
112
|
"ogl": "^1.0.11",
|
|
113
|
+
"open-file-viewer-workspace": "github:xushanpei/open-file-viewer",
|
|
114
|
+
"pdfjs-dist": "^6.3.289",
|
|
111
115
|
"prisma": "^7.8.0",
|
|
112
116
|
"radix-ui": "^1.4.3",
|
|
113
117
|
"react": "19.2.4",
|
|
@@ -127,14 +131,14 @@
|
|
|
127
131
|
"three": "^0.180.0",
|
|
128
132
|
"tw-animate-css": "^1.4.0",
|
|
129
133
|
"zod": "^4.4.3",
|
|
130
|
-
"@allbluecn/
|
|
134
|
+
"@allbluecn/ai-gateway": "0.4.17",
|
|
131
135
|
"@allbluecn/kernel": "0.3.1",
|
|
132
|
-
"@allbluecn/plugins-core": "0.4.
|
|
136
|
+
"@allbluecn/plugins-core": "0.4.16",
|
|
137
|
+
"@allbluecn/shared": "0.4.17",
|
|
138
|
+
"@allbluecn/ui": "0.4.17",
|
|
133
139
|
"@allbluecn/prototype": "0.1.1",
|
|
134
|
-
"@allbluecn/
|
|
135
|
-
"@allbluecn/
|
|
136
|
-
"@allbluecn/tiptap": "0.4.16",
|
|
137
|
-
"@allbluecn/ui": "0.4.16"
|
|
140
|
+
"@allbluecn/tiptap": "0.4.17",
|
|
141
|
+
"@allbluecn/database": "0.4.16"
|
|
138
142
|
},
|
|
139
143
|
"devDependencies": {
|
|
140
144
|
"@babel/parser": "^8.0.4",
|
|
@@ -72,6 +72,10 @@ import { toast } from '@/components/ui/sonner'
|
|
|
72
72
|
import { KnowledgeFormDialog } from '@/components/knowledge/dialog/knowledge-form-dialog'
|
|
73
73
|
import { cn } from '@/lib/utils'
|
|
74
74
|
import { useTranslation } from 'react-i18next'
|
|
75
|
+
import { Checkbox } from '@/components/ui/checkbox'
|
|
76
|
+
import { Link as LinkIcon } from 'lucide-react'
|
|
77
|
+
import { listKbProjectsFn, setKbProjectsFn } from '@/server/serverFns/knowledge'
|
|
78
|
+
import { listProjectsBriefFn } from '@/server/serverFns/project'
|
|
75
79
|
|
|
76
80
|
interface KbData {
|
|
77
81
|
id: string
|
|
@@ -116,6 +120,10 @@ export function KnowledgeToolbar({ kb, onUpdate, breadcrumb, docType, treeCollap
|
|
|
116
120
|
const [createPopover, setCreatePopover] = useState(false)
|
|
117
121
|
const [createType, setCreateType] = useState('doc')
|
|
118
122
|
const [createTitle, setCreateTitle] = useState('')
|
|
123
|
+
const [projectsDialogOpen, setProjectsDialogOpen] = useState(false)
|
|
124
|
+
const [projectCandidates, setProjectCandidates] = useState<{ id: string; name: string; icon: string }[]>([])
|
|
125
|
+
const [projectChecked, setProjectChecked] = useState<Set<string>>(new Set())
|
|
126
|
+
const [savingProjects, setSavingProjects] = useState(false)
|
|
119
127
|
const createInputRef = useRef<HTMLInputElement>(null)
|
|
120
128
|
|
|
121
129
|
useEffect(() => {
|
|
@@ -148,13 +156,40 @@ export function KnowledgeToolbar({ kb, onUpdate, breadcrumb, docType, treeCollap
|
|
|
148
156
|
if (value === localVisibility) return
|
|
149
157
|
setLocalVisibility(value)
|
|
150
158
|
try {
|
|
151
|
-
await updateKnowledgeFn({ data: { id: kb.id, visibility: value as 'private' | 'team' } })
|
|
159
|
+
await updateKnowledgeFn({ data: { id: kb.id, visibility: value as 'private' | 'team' | 'general' } })
|
|
152
160
|
onUpdate?.()
|
|
153
161
|
} catch (e) {
|
|
154
162
|
console.error('Update visibility failed:', e instanceof Error ? e.message : String(e))
|
|
155
163
|
}
|
|
156
164
|
}
|
|
157
165
|
|
|
166
|
+
const openProjectsDialog = async () => {
|
|
167
|
+
setProjectsDialogOpen(true);
|
|
168
|
+
try {
|
|
169
|
+
const [linked, all] = await Promise.all([
|
|
170
|
+
listKbProjectsFn({ data: { kbId: kb.id } }),
|
|
171
|
+
listProjectsBriefFn(),
|
|
172
|
+
]);
|
|
173
|
+
setProjectCandidates(all);
|
|
174
|
+
setProjectChecked(new Set(linked.map((p) => p.id)));
|
|
175
|
+
} catch {
|
|
176
|
+
toast.error('加载项目列表失败');
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const saveProjects = async () => {
|
|
181
|
+
setSavingProjects(true);
|
|
182
|
+
try {
|
|
183
|
+
await setKbProjectsFn({ data: { kbId: kb.id, projectIds: [...projectChecked] } });
|
|
184
|
+
toast.success('项目关联已更新');
|
|
185
|
+
setProjectsDialogOpen(false);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
toast.error(err instanceof Error ? err.message : '保存失败');
|
|
188
|
+
} finally {
|
|
189
|
+
setSavingProjects(false);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
158
193
|
const handleDelete = async () => {
|
|
159
194
|
if (deleteConfirm !== kb.name) return
|
|
160
195
|
setDeleting(true)
|
|
@@ -426,6 +461,56 @@ export function KnowledgeToolbar({ kb, onUpdate, breadcrumb, docType, treeCollap
|
|
|
426
461
|
{localVisibility === 'team' ? t('toolbar.team') : t('toolbar.private')}
|
|
427
462
|
</Badge>
|
|
428
463
|
|
|
464
|
+
{localVisibility === 'team' && (
|
|
465
|
+
<Button
|
|
466
|
+
variant="ghost"
|
|
467
|
+
size="sm"
|
|
468
|
+
className="h-7 gap-1.5 px-2.5 text-xs font-normal hover:bg-muted rounded-md shrink-0"
|
|
469
|
+
onClick={openProjectsDialog}
|
|
470
|
+
>
|
|
471
|
+
<LinkIcon className="size-3.5 text-muted-foreground" />
|
|
472
|
+
{t('toolbar.linkProjects')}
|
|
473
|
+
</Button>
|
|
474
|
+
)}
|
|
475
|
+
|
|
476
|
+
{localVisibility === 'general' && (
|
|
477
|
+
<Tooltip>
|
|
478
|
+
<TooltipTrigger asChild>
|
|
479
|
+
<Button
|
|
480
|
+
variant="ghost"
|
|
481
|
+
size="sm"
|
|
482
|
+
className="h-7 gap-1.5 px-2.5 text-xs font-normal hover:bg-muted rounded-md shrink-0"
|
|
483
|
+
disabled
|
|
484
|
+
>
|
|
485
|
+
<LinkIcon className="size-3.5 text-muted-foreground" />
|
|
486
|
+
{t('toolbar.linkProjects')}
|
|
487
|
+
</Button>
|
|
488
|
+
</TooltipTrigger>
|
|
489
|
+
<TooltipContent>
|
|
490
|
+
<p>{t('toolbar.generalLinkDisabled')}</p>
|
|
491
|
+
</TooltipContent>
|
|
492
|
+
</Tooltip>
|
|
493
|
+
)}
|
|
494
|
+
|
|
495
|
+
{localVisibility === 'private' && (
|
|
496
|
+
<Tooltip>
|
|
497
|
+
<TooltipTrigger asChild>
|
|
498
|
+
<Button
|
|
499
|
+
variant="ghost"
|
|
500
|
+
size="sm"
|
|
501
|
+
className="h-7 gap-1.5 px-2.5 text-xs font-normal hover:bg-muted rounded-md shrink-0"
|
|
502
|
+
disabled
|
|
503
|
+
>
|
|
504
|
+
<LinkIcon className="size-3.5 text-muted-foreground" />
|
|
505
|
+
{t('toolbar.linkProjects')}
|
|
506
|
+
</Button>
|
|
507
|
+
</TooltipTrigger>
|
|
508
|
+
<TooltipContent>
|
|
509
|
+
<p>{t('toolbar.privateLinkDisabled')}</p>
|
|
510
|
+
</TooltipContent>
|
|
511
|
+
</Tooltip>
|
|
512
|
+
)}
|
|
513
|
+
|
|
429
514
|
<div className="ml-auto">
|
|
430
515
|
<DropdownMenu>
|
|
431
516
|
<DropdownMenuTrigger asChild>
|
|
@@ -544,6 +629,44 @@ export function KnowledgeToolbar({ kb, onUpdate, breadcrumb, docType, treeCollap
|
|
|
544
629
|
kb={{ id: kb.id, name: kb.name, description: kb.description ?? null, icon: kb.icon ?? 'notebook' }}
|
|
545
630
|
onSuccess={onUpdate}
|
|
546
631
|
/>
|
|
632
|
+
|
|
633
|
+
<Dialog open={projectsDialogOpen} onOpenChange={setProjectsDialogOpen}>
|
|
634
|
+
<DialogContent className="sm:max-w-260 -mt-50">
|
|
635
|
+
<DialogHeader>
|
|
636
|
+
<DialogTitle>{t('toolbar.linkProjects')}</DialogTitle>
|
|
637
|
+
</DialogHeader>
|
|
638
|
+
<div className="flex flex-col gap-2 max-h-96 overflow-y-auto">
|
|
639
|
+
{projectCandidates.length === 0 ? (
|
|
640
|
+
<p className="text-sm text-muted-foreground">暂无项目</p>
|
|
641
|
+
) : (
|
|
642
|
+
projectCandidates.map((proj) => (
|
|
643
|
+
<label key={proj.id} className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm cursor-pointer">
|
|
644
|
+
<Checkbox
|
|
645
|
+
checked={projectChecked.has(proj.id)}
|
|
646
|
+
onCheckedChange={(v) =>
|
|
647
|
+
setProjectChecked((prev) => {
|
|
648
|
+
const next = new Set(prev);
|
|
649
|
+
if (v) next.add(proj.id);
|
|
650
|
+
else next.delete(proj.id);
|
|
651
|
+
return next;
|
|
652
|
+
})
|
|
653
|
+
}
|
|
654
|
+
/>
|
|
655
|
+
<span className="flex-1">{proj.name}</span>
|
|
656
|
+
</label>
|
|
657
|
+
))
|
|
658
|
+
)}
|
|
659
|
+
</div>
|
|
660
|
+
<DialogFooter>
|
|
661
|
+
<Button variant="outline" onClick={() => setProjectsDialogOpen(false)}>
|
|
662
|
+
{t('knowledge:toolbar.cancel')}
|
|
663
|
+
</Button>
|
|
664
|
+
<Button onClick={saveProjects} disabled={savingProjects}>
|
|
665
|
+
{savingProjects ? '保存中...' : t('settings.kbSection.save')}
|
|
666
|
+
</Button>
|
|
667
|
+
</DialogFooter>
|
|
668
|
+
</DialogContent>
|
|
669
|
+
</Dialog>
|
|
547
670
|
</div>
|
|
548
671
|
)
|
|
549
672
|
}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// You should have received a copy of the GNU Affero General Public License
|
|
16
16
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
17
|
|
|
18
|
-
import { GitPullRequestArrow, CircleFadingArrowUp, Repeat2, CalendarPlus2, Hash, MessageCircleCheck, RefreshCcwDot, CircleFadingPlus, CircleArrowUp, CircleArrowRight, CircleArrowDown, CircleSlash, CircleDashed, CircleCheckBig, LoaderCircle, CheckCircle2, XCircle, FilePlus, FilePenLine, MessageSquareText, Flag, UserRound, Text, Calendar, CalendarCheck, Tags, TextInitial, Building2, GitBranch, type LucideIcon } from "lucide-react";
|
|
18
|
+
import { GitPullRequestArrow, CircleFadingArrowUp, Repeat2, CalendarPlus2, Hash, MessageCircleCheck, RefreshCcwDot, CircleFadingPlus, CircleArrowUp, CircleArrowRight, CircleArrowDown, CircleSlash, CircleDashed, CircleCheckBig, LoaderCircle, CheckCircle2, XCircle, FilePlus, FilePenLine, MessageSquareText, Flag, UserRound, Text, Calendar, CalendarCheck, Tags, TextInitial, Building2, GitBranch, ListTree, ArrowLeftRight, Link2, Paperclip, BookOpen, type LucideIcon } from "lucide-react";
|
|
19
19
|
import type { DependencyEdgeType, DependencyGroup } from "./types";
|
|
20
20
|
|
|
21
21
|
export type StoryState = "backlog" | "todo" | "in_progress" | "done" | "cancelled";
|
|
@@ -101,6 +101,11 @@ export const ACTIVITY_LABEL: Record<string, string> = {
|
|
|
101
101
|
project_transferred: "activity.project_transferred",
|
|
102
102
|
parent_changed: "activity.parent_changed",
|
|
103
103
|
parent_removed: "activity.parent_removed",
|
|
104
|
+
child_added: "activity.child_added",
|
|
105
|
+
dependency_added: "activity.dependency_added",
|
|
106
|
+
link_added: "activity.link_added",
|
|
107
|
+
attachment_added: "activity.attachment_added",
|
|
108
|
+
kb_linked: "activity.kb_linked",
|
|
104
109
|
};
|
|
105
110
|
|
|
106
111
|
export const ACTIVITY_ICON: Record<string, LucideIcon> = {
|
|
@@ -119,6 +124,11 @@ startDate_changed: CalendarPlus2,
|
|
|
119
124
|
project_transferred: Repeat2,
|
|
120
125
|
parent_changed: GitPullRequestArrow,
|
|
121
126
|
parent_removed: GitPullRequestArrow,
|
|
127
|
+
child_added: ListTree,
|
|
128
|
+
dependency_added: ArrowLeftRight,
|
|
129
|
+
link_added: Link2,
|
|
130
|
+
attachment_added: Paperclip,
|
|
131
|
+
kb_linked: BookOpen,
|
|
122
132
|
};
|
|
123
133
|
|
|
124
134
|
// ====== 依赖关系常量(对齐 Plane,新模型) ======
|
|
@@ -63,7 +63,10 @@ interface CreateStoryDialogProps {
|
|
|
63
63
|
initialState?: string
|
|
64
64
|
initialParentId?: string
|
|
65
65
|
initialProjectId?: string
|
|
66
|
-
onSuccess?: (
|
|
66
|
+
onSuccess?: (
|
|
67
|
+
story: { id: string; name: string; identifier?: string },
|
|
68
|
+
activities?: import("./types").ActivityItem[],
|
|
69
|
+
) => void
|
|
67
70
|
}
|
|
68
71
|
|
|
69
72
|
export function CreateStoryDialog({
|
|
@@ -176,11 +179,14 @@ export function CreateStoryDialog({
|
|
|
176
179
|
form.reset()
|
|
177
180
|
setDescription({ html: "", markdown: "" })
|
|
178
181
|
if (!continuousCreate) onOpenChange(false)
|
|
179
|
-
onSuccess?.(
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
182
|
+
onSuccess?.(
|
|
183
|
+
{
|
|
184
|
+
id: res.id,
|
|
185
|
+
name: res.name,
|
|
186
|
+
identifier: res.identifier,
|
|
187
|
+
},
|
|
188
|
+
res.activities,
|
|
189
|
+
)
|
|
184
190
|
} catch (err) {
|
|
185
191
|
setError(err instanceof Error ? err.message : t("create.failed"))
|
|
186
192
|
} finally {
|
|
@@ -0,0 +1,114 @@
|
|
|
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 React from "react";
|
|
20
|
+
import { render, screen, fireEvent } from "@testing-library/react";
|
|
21
|
+
|
|
22
|
+
vi.mock("@tanstack/react-start", () => ({
|
|
23
|
+
useServerFn: (fn: any) => vi.fn().mockImplementation((args: any) => fn(args)),
|
|
24
|
+
}));
|
|
25
|
+
vi.mock("@/server/serverFns/story", () => ({
|
|
26
|
+
removeLinkFn: vi.fn(async () => ({})),
|
|
27
|
+
removeAttachmentFn: vi.fn(async () => ({})),
|
|
28
|
+
unlinkDocFn: vi.fn(async () => ({})),
|
|
29
|
+
checkLinkEmbeddableFn: vi.fn(async () => ({ embeddable: true })),
|
|
30
|
+
}));
|
|
31
|
+
vi.mock("@/server/serverFns/knowledge", () => ({
|
|
32
|
+
getDocumentFn: vi.fn(async () => ({ content: { html: "<p>hello</p>", markdown: "hello" } })),
|
|
33
|
+
}));
|
|
34
|
+
vi.mock("sonner", () => ({
|
|
35
|
+
toast: { success: vi.fn(), error: vi.fn() },
|
|
36
|
+
}));
|
|
37
|
+
vi.mock("@tanstack/react-router", () => ({
|
|
38
|
+
Link: ({ children, to, params }: any) => React.createElement("a", { href: to, "data-to": to, ...params }, children),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
import { AssociatedAttachmentsPanel } from "../story-detail-toolbar/associated-attachments-panel";
|
|
42
|
+
import type { RelationsData } from "../../types";
|
|
43
|
+
|
|
44
|
+
const data = {
|
|
45
|
+
links: [{ id: "l1", url: "https://a.com", title: "站点A", description: null }],
|
|
46
|
+
attachments: [
|
|
47
|
+
{ id: "a1", fileName: "文件.png", fileSize: 2048, mimeType: "image/png", storageUrl: "/m/1" },
|
|
48
|
+
],
|
|
49
|
+
knowledge: {
|
|
50
|
+
prds: [],
|
|
51
|
+
docs: [
|
|
52
|
+
{ id: "d1", title: "知识页", kbId: "kb1", kbName: "团队库", kbIcon: "notebook", docIcon: null, parentId: null },
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
} as unknown as RelationsData;
|
|
56
|
+
|
|
57
|
+
function setup(props?: { data: RelationsData }) {
|
|
58
|
+
const onOpenDialog = vi.fn();
|
|
59
|
+
render(
|
|
60
|
+
<AssociatedAttachmentsPanel
|
|
61
|
+
storyId="s1"
|
|
62
|
+
data={props?.data ?? data}
|
|
63
|
+
onRefresh={vi.fn()}
|
|
64
|
+
onOpenDialog={onOpenDialog}
|
|
65
|
+
/>,
|
|
66
|
+
);
|
|
67
|
+
return { onOpenDialog };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe("AssociatedAttachmentsPanel", () => {
|
|
71
|
+
it("三类资源合并渲染,计数 badge 为总数 3,默认列表视图", () => {
|
|
72
|
+
setup();
|
|
73
|
+
expect(screen.getByText("关联附件")).toBeInTheDocument();
|
|
74
|
+
expect(screen.getByText("3")).toBeInTheDocument();
|
|
75
|
+
expect(screen.getByText("站点A")).toBeInTheDocument();
|
|
76
|
+
expect(screen.getByText("文件.png")).toBeInTheDocument();
|
|
77
|
+
expect(screen.getByText("知识页")).toBeInTheDocument();
|
|
78
|
+
expect(screen.getByText("团队库")).toBeInTheDocument();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("点击预览按钮切换到预览模式,渲染网格布局", () => {
|
|
82
|
+
setup();
|
|
83
|
+
fireEvent.click(screen.getByRole("button", { name: "预览" }));
|
|
84
|
+
expect(screen.getByTestId("associated-items").getAttribute("data-mode")).toBe("preview");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("空数据整面板不渲染", () => {
|
|
88
|
+
const empty = {
|
|
89
|
+
links: [],
|
|
90
|
+
attachments: [],
|
|
91
|
+
knowledge: { prds: [], docs: [] },
|
|
92
|
+
} as unknown as RelationsData;
|
|
93
|
+
const { container } = render(
|
|
94
|
+
<AssociatedAttachmentsPanel
|
|
95
|
+
storyId="s1"
|
|
96
|
+
data={empty}
|
|
97
|
+
onRefresh={vi.fn()}
|
|
98
|
+
onOpenDialog={vi.fn()}
|
|
99
|
+
/>,
|
|
100
|
+
);
|
|
101
|
+
expect(container.querySelector("[data-testid='associated-panel']")).toBeNull();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("点击编辑链接回调 onOpenDialog('link', link)", () => {
|
|
105
|
+
const { onOpenDialog } = setup();
|
|
106
|
+
const editBtns = screen.getAllByRole("button", { name: "编辑" });
|
|
107
|
+
// 第一个是链接的编辑按钮,第二个是附件的编辑按钮
|
|
108
|
+
fireEvent.click(editBtns[0]);
|
|
109
|
+
expect(onOpenDialog).toHaveBeenCalledWith(
|
|
110
|
+
"link",
|
|
111
|
+
expect.objectContaining({ id: "l1" }),
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -322,11 +322,26 @@ interface ActivityTimelineProps {
|
|
|
322
322
|
users?: UserBrief[];
|
|
323
323
|
}
|
|
324
324
|
|
|
325
|
+
/** 关联类 action 集合:描述文案带 {{value}} 插值(对象名或数量) */
|
|
326
|
+
const OBJECT_ACTIVITY_ACTIONS: Record<string, true> = {
|
|
327
|
+
child_added: true,
|
|
328
|
+
dependency_added: true,
|
|
329
|
+
link_added: true,
|
|
330
|
+
attachment_added: true,
|
|
331
|
+
kb_linked: true,
|
|
332
|
+
};
|
|
333
|
+
|
|
325
334
|
function describeActivity(a: ActivityItem, t: TFunction): string {
|
|
326
335
|
if (a.action === "project_transferred" || a.action === "identifier_changed") {
|
|
327
336
|
return t(ACTIVITY_LABEL[a.action] ?? a.action);
|
|
328
337
|
}
|
|
329
338
|
|
|
339
|
+
// 关联类动作(添加子需求/依赖/链接/附件/知识库):newValue 为对象描述或数量,走插值文案
|
|
340
|
+
if (a.action in OBJECT_ACTIVITY_ACTIONS) {
|
|
341
|
+
const key = ACTIVITY_LABEL[a.action];
|
|
342
|
+
return t(key, { value: a.newValue ?? "" });
|
|
343
|
+
}
|
|
344
|
+
|
|
330
345
|
const fieldLabel = ACTIVITY_LABEL[a.action] ?? a.action;
|
|
331
346
|
|
|
332
347
|
if (!a.field) {
|
|
@@ -545,6 +545,7 @@ export function StoryDetailBody({
|
|
|
545
545
|
projectId={story.projectId}
|
|
546
546
|
data={relationsData}
|
|
547
547
|
onRefresh={onRelationsUpdate ?? (() => { })}
|
|
548
|
+
onAppendActivities={onAppendActivities}
|
|
548
549
|
users={users}
|
|
549
550
|
projects={projects?.map((p) => ({ ...p, displayId: p.displayId ?? null }))}
|
|
550
551
|
labels={labels}
|