@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allbluecn/web-app",
3
- "version": "0.5.0",
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/database": "0.4.15",
134
+ "@allbluecn/ai-gateway": "0.4.17",
131
135
  "@allbluecn/kernel": "0.3.1",
132
- "@allbluecn/plugins-core": "0.4.15",
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/ai-gateway": "0.4.16",
135
- "@allbluecn/shared": "0.4.16",
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?: (story: { id: string; name: string; identifier?: string }) => void
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
- id: res.id,
181
- name: res.name,
182
- identifier: res.identifier,
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}