@allbluecn/web-app 0.4.22 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/package.json +12 -8
  2. package/src/components/knowledge/knowledge-toolbar.tsx +124 -1
  3. package/src/components/layout/sidebar-03/app-sidebar.tsx +3 -4
  4. package/src/components/layout/sidebar-03/nav-notifications.tsx +135 -52
  5. package/src/components/layout/sidebar-03/user-menu.tsx +4 -2
  6. package/src/components/notifications/settings-notifications.tsx +198 -0
  7. package/src/components/notifications/team-notifications.tsx +343 -0
  8. package/src/components/settings/personal-team/team/team-client.tsx +51 -12
  9. package/src/components/settings/personal-team/team/team-invitations.tsx +181 -181
  10. package/src/components/shared/wave-physics-loader.tsx +155 -0
  11. package/src/components/story/__tests__/dependency-type-badge.test.ts +89 -0
  12. package/src/components/story/constants.ts +124 -51
  13. package/src/components/story/create-story-dialog.tsx +12 -6
  14. package/src/components/story/detail/__tests__/associated-attachments-panel.test.tsx +114 -0
  15. package/src/components/story/detail/activity-timeline.tsx +15 -0
  16. package/src/components/story/detail/story-detail-body.tsx +14 -3
  17. package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +748 -0
  18. package/src/components/story/detail/story-detail-toolbar/categories.tsx +23 -18
  19. package/src/components/story/detail/story-detail-toolbar/children.tsx +51 -274
  20. package/src/components/story/detail/story-detail-toolbar/dependencies.tsx +13 -45
  21. package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +473 -0
  22. package/src/components/story/detail/story-detail-toolbar.tsx +237 -10
  23. package/src/components/story/display/__tests__/use-paged-story-groups.test.ts +152 -1
  24. package/src/components/story/display/use-paged-story-groups.ts +370 -28
  25. package/src/components/story/inline-editors/story-select-dialog.tsx +107 -16
  26. package/src/components/story/peek-panel.tsx +8 -1
  27. package/src/components/story/relations/__tests__/attachment-manage-dialog.test.tsx +171 -0
  28. package/src/components/story/relations/__tests__/knowledge-select-dialog.test.tsx +174 -0
  29. package/src/components/story/relations/__tests__/link-manage-dialog.test.tsx +93 -0
  30. package/src/components/story/relations/attachment-manage-dialog.tsx +490 -0
  31. package/src/components/story/relations/file-preview.tsx +173 -0
  32. package/src/components/story/relations/format.ts +120 -0
  33. package/src/{config/sidebar-notifications.ts → components/story/relations/general-groups.ts} +12 -30
  34. package/src/components/story/relations/knowledge-select-dialog.tsx +403 -0
  35. package/src/components/story/relations/link-manage-dialog.tsx +191 -0
  36. package/src/components/story/relations/story-ref-row.tsx +319 -0
  37. package/src/components/story/story-list-view.tsx +3 -1
  38. package/src/components/story/story-panel-header.tsx +4 -1
  39. package/src/components/story/types.ts +30 -18
  40. package/src/components/story/views/story-list/batch-toolbar.tsx +12 -1
  41. package/src/components/story/views/story-list/index.tsx +16 -5
  42. package/src/components/story/views/story-list/row.tsx +5 -4
  43. package/src/components/ui/separator.tsx +1 -1
  44. package/src/hooks/use-notifications.ts +4 -1
  45. package/src/lib/__tests__/datetime.test.ts +16 -1
  46. package/src/lib/changelog/sdk-versions.ts +19 -19
  47. package/src/lib/datetime.ts +1 -0
  48. package/src/lib/story/__tests__/video-embed.test.ts +77 -0
  49. package/src/lib/story/video-embed.ts +59 -0
  50. package/src/routeTree.gen.ts +65 -0
  51. package/src/routes/_nav/notifications/route.lazy.tsx +22 -0
  52. package/src/routes/_nav/notifications/route.tsx +30 -0
  53. package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +74 -3
  54. package/src/routes/_nav/projects/$projectId/settings/route.tsx +6 -2
  55. package/src/routes/_nav/settings/notifications/route.lazy.tsx +6 -5
  56. package/src/routes/api/attachments/$id/content.ts +60 -0
  57. package/src/routes/api/attachments/upload.ts +148 -0
  58. package/src/routes/api/notifications/stream.ts +4 -0
  59. package/src/server/notifications/__tests__/notification-service.test.ts +189 -0
  60. package/src/server/notifications/notification-service.ts +137 -27
  61. package/src/server/serverFns/__tests__/project-kb.test.ts +147 -0
  62. package/src/server/serverFns/__tests__/team-invitation.test.ts +73 -7
  63. package/src/server/serverFns/__tests__/team-join-request.test.ts +5 -3
  64. package/src/server/serverFns/knowledge.ts +50 -4
  65. package/src/server/serverFns/locale.ts +15 -3
  66. package/src/server/serverFns/notifications.ts +18 -4
  67. package/src/server/serverFns/project.ts +43 -0
  68. package/src/server/serverFns/story/__tests__/kb-scope.test.ts +56 -0
  69. package/src/server/serverFns/story/__tests__/story-assoc.test.ts +222 -0
  70. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +265 -0
  71. package/src/server/serverFns/story/__tests__/story-groups.test.ts +19 -1
  72. package/src/server/serverFns/story/index.ts +7 -6
  73. package/src/server/serverFns/story/kb-general.ts +39 -0
  74. package/src/server/serverFns/story/kb-scope.ts +52 -0
  75. package/src/server/serverFns/story/link-preview.ts +115 -0
  76. package/src/server/serverFns/story/story-assoc.ts +339 -50
  77. package/src/server/serverFns/story/story-attachments.ts +12 -43
  78. package/src/server/serverFns/story/story-crud.ts +21 -4
  79. package/src/server/serverFns/story/story-dependencies.ts +138 -162
  80. package/src/server/serverFns/story/story-groups.ts +23 -3
  81. package/src/server/serverFns/story/story-links.ts +13 -1
  82. package/src/server/serverFns/story/story-transfer.ts +5 -3
  83. package/src/server/serverFns/story/story-tree.ts +28 -1
  84. package/src/server/serverFns/story/types.ts +37 -0
  85. package/src/server/serverFns/team.ts +51 -2
  86. package/src/styles/globals.css +25 -0
  87. package/vite.shared.ts +2 -0
  88. package/src/components/notifications/notification-list.tsx +0 -232
  89. package/src/components/notifications/notification-preferences.tsx +0 -156
  90. package/src/components/story/detail/story-detail-toolbar/attachments.tsx +0 -73
  91. package/src/components/story/detail/story-detail-toolbar/knowledge.tsx +0 -73
  92. package/src/components/story/detail/story-detail-toolbar/links.tsx +0 -73
  93. package/src/components/story/relation-badge.tsx +0 -57
  94. package/src/components/story/relations/attachments-panel.tsx +0 -179
  95. package/src/components/story/relations/dependency-panel.tsx +0 -190
  96. package/src/components/story/relations/hierarchy-panel.tsx +0 -180
  97. package/src/components/story/relations/knowledge-panel.tsx +0 -284
  98. package/src/components/story/relations/links-panel.tsx +0 -240
  99. package/src/components/story/relations/tab.tsx +0 -212
@@ -0,0 +1,89 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ DEPENDENCY_TYPE_OPTIONS,
4
+ dependencyTypeBadgeKey,
5
+ dependencyTypeOption,
6
+ toDependencyEdge,
7
+ } from "../constants";
8
+
9
+ describe("DEPENDENCY_TYPE_OPTIONS", () => {
10
+ it("共 6 项,key 唯一,每组各 3 项", () => {
11
+ expect(DEPENDENCY_TYPE_OPTIONS).toHaveLength(6);
12
+ const keys = DEPENDENCY_TYPE_OPTIONS.map((o) => o.key);
13
+ expect(new Set(keys).size).toBe(6);
14
+ expect(
15
+ DEPENDENCY_TYPE_OPTIONS.filter((o) => o.group === "dependsOn"),
16
+ ).toHaveLength(3);
17
+ expect(
18
+ DEPENDENCY_TYPE_OPTIONS.filter((o) => o.group === "dependents"),
19
+ ).toHaveLength(3);
20
+ });
21
+
22
+ it("每项含 optionLabelKey/badgeLabelKey/tooltipLabelKey/edge/group,edge 为三种正向类型且每组各覆盖一次", () => {
23
+ for (const group of ["dependsOn", "dependents"] as const) {
24
+ const edges = new Set(
25
+ DEPENDENCY_TYPE_OPTIONS.filter((o) => o.group === group).map(
26
+ (o) => o.edge,
27
+ ),
28
+ );
29
+ expect(edges).toEqual(
30
+ new Set(["BLOCKS", "STARTS_BEFORE", "FINISHES_BEFORE"]),
31
+ );
32
+ }
33
+ for (const o of DEPENDENCY_TYPE_OPTIONS) {
34
+ expect(o.optionLabelKey).toMatch(/^dependency\.options\./);
35
+ expect(o.badgeLabelKey).toMatch(/^dependency\.labels\./);
36
+ expect(o.tooltipLabelKey).toMatch(/^dependency\.tooltips\./);
37
+ expect(o.reversed).toBe(o.group === "dependsOn");
38
+ }
39
+ });
40
+ });
41
+
42
+ describe("dependencyTypeOption / dependencyTypeBadgeKey", () => {
43
+ it("dependents 组(我领先)→ blocking/starts_before/finishes_before", () => {
44
+ expect(dependencyTypeBadgeKey("dependents", "BLOCKS")).toBe("blocking");
45
+ expect(
46
+ dependencyTypeBadgeKey("dependents", "STARTS_BEFORE"),
47
+ ).toBe("starts_before");
48
+ expect(
49
+ dependencyTypeBadgeKey("dependents", "FINISHES_BEFORE"),
50
+ ).toBe("finishes_before");
51
+ });
52
+
53
+ it("dependsOn 组(对方领先)→ blocked_by/starts_after/finishes_after", () => {
54
+ expect(dependencyTypeBadgeKey("dependsOn", "BLOCKS")).toBe("blocked_by");
55
+ expect(
56
+ dependencyTypeBadgeKey("dependsOn", "STARTS_BEFORE"),
57
+ ).toBe("starts_after");
58
+ expect(
59
+ dependencyTypeBadgeKey("dependsOn", "FINISHES_BEFORE"),
60
+ ).toBe("finishes_after");
61
+ });
62
+
63
+ it("dependencyTypeOption 返回含句式 label 的完整选项", () => {
64
+ const o = dependencyTypeOption("dependsOn", "BLOCKS");
65
+ expect(o.key).toBe("blocked_by");
66
+ expect(o.optionLabelKey).toBe("dependency.options.blocked_by");
67
+ expect(o.badgeLabelKey).toBe("dependency.labels.blocked_by");
68
+ });
69
+ });
70
+
71
+ describe("toDependencyEdge", () => {
72
+ it("reversed=false:我领先,source=我", () => {
73
+ const o = DEPENDENCY_TYPE_OPTIONS.find((x) => x.key === "blocking")!;
74
+ expect(toDependencyEdge(o, "s1", "s2")).toEqual({
75
+ sourceId: "s1",
76
+ targetId: "s2",
77
+ type: "BLOCKS",
78
+ });
79
+ });
80
+
81
+ it("reversed=true:对方领先,source/target 交换", () => {
82
+ const o = DEPENDENCY_TYPE_OPTIONS.find((x) => x.key === "blocked_by")!;
83
+ expect(toDependencyEdge(o, "s1", "s2")).toEqual({
84
+ sourceId: "s2",
85
+ targetId: "s1",
86
+ type: "BLOCKS",
87
+ });
88
+ });
89
+ });
@@ -15,8 +15,8 @@
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 { ArrowUpRight, ArrowDownRight, Link, 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";
19
- import type { DependencyType } from "./types";
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
+ import type { DependencyEdgeType, DependencyGroup } from "./types";
20
20
 
21
21
  export type StoryState = "backlog" | "todo" | "in_progress" | "done" | "cancelled";
22
22
  export type StoryPriority = "urgent" | "high" | "medium" | "low" | "none";
@@ -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,54 +124,122 @@ 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
- // ====== P3:关联关系常量 ======
125
-
126
- // 依赖类型常量
127
- export const DEPENDENCY_TYPES: {
128
- BLOCKS: DependencyType;
129
- BLOCKED_BY: DependencyType;
130
- RELATES_TO: DependencyType;
131
- } = {
132
- BLOCKS: "blocks",
133
- BLOCKED_BY: "blocked_by",
134
- RELATES_TO: "relates_to",
135
- } as const;
136
-
137
- // 依赖类型标签
138
- export const DEPENDENCY_LABELS: Record<DependencyType, string> = {
139
- blocks: "dependency.blocks",
140
- blocked_by: "dependency.blocked_by",
141
- relates_to: "dependency.relates_to",
142
- } as const;
143
-
144
- // 依赖类型图标
145
- export const DEPENDENCY_ICONS: Record<DependencyType, LucideIcon> = {
146
- blocks: ArrowUpRight,
147
- blocked_by: ArrowDownRight,
148
- relates_to: Link,
149
- } as const;
150
-
151
- // 依赖类型颜色(Tailwind 类名)
152
- export const DEPENDENCY_COLORS: Record<DependencyType, { text: string; bg: string }> = {
153
- blocks: { text: "text-red-600", bg: "bg-red-50" },
154
- blocked_by: { text: "text-amber-600", bg: "bg-amber-50" },
155
- relates_to: { text: "text-blue-600", bg: "bg-blue-50" },
156
- } as const;
157
-
158
- // 依赖方向提示
159
- export const DEPENDENCY_DIRECTIONS: Record<DependencyType, string> = {
160
- blocks: "dependency.direction.blocks",
161
- blocked_by: "dependency.direction.blocked_by",
162
- relates_to: "dependency.direction.relates_to",
163
- } as const;
164
-
165
- // Accordion item value(用于 collapsible 状态)
166
- export const RELATIONS_ACCORDION_ITEMS = {
167
- HIERARCHY: "hierarchy",
168
- DEPENDENCIES: "dependencies",
169
- LINKS: "links",
170
- ATTACHMENTS: "attachments",
171
- KNOWLEDGE: "knowledge",
172
- } as const;
134
+ // ====== 依赖关系常量(对齐 Plane,新模型) ======
135
+
136
+ // UI 依赖类型选择项(Plane 模型:3 种依赖类型 FS/SS/FF × 2 个方向视角)
137
+ // group="dependents"(我领先,所选需求等我):存 (X, Y, edge)
138
+ // group="dependsOn"(对方领先,我等所选需求):存 (Y, X, edge)
139
+ export interface DependencyTypeOption {
140
+ key:
141
+ | "blocking"
142
+ | "blocked_by"
143
+ | "starts_before"
144
+ | "starts_after"
145
+ | "finishes_before"
146
+ | "finishes_after";
147
+ group: keyof DependencyGroup;
148
+ /** 选择器文案 key(被选需求未知,用「所选需求」占位) */
149
+ optionLabelKey: string;
150
+ /** 行内标签文案 key({{identifier}} 插值被选需求编号) */
151
+ badgeLabelKey: string;
152
+ /** tooltip 文案 key({{identifier}} 插值被选需求编号 + {{me}} 插值当前需求编号) */
153
+ tooltipLabelKey: string;
154
+ edge: DependencyEdgeType;
155
+ /** group 等价的方向标志:true = 存 (Y, X, edge) */
156
+ reversed: boolean;
157
+ }
158
+
159
+ export const DEPENDENCY_TYPE_OPTIONS: DependencyTypeOption[] = [
160
+ {
161
+ key: "blocked_by",
162
+ group: "dependsOn",
163
+ optionLabelKey: "dependency.options.blocked_by",
164
+ badgeLabelKey: "dependency.labels.blocked_by",
165
+ tooltipLabelKey: "dependency.tooltips.blocked_by",
166
+ edge: "BLOCKS",
167
+ reversed: true,
168
+ },
169
+ {
170
+ key: "starts_after",
171
+ group: "dependsOn",
172
+ optionLabelKey: "dependency.options.starts_after",
173
+ badgeLabelKey: "dependency.labels.starts_after",
174
+ tooltipLabelKey: "dependency.tooltips.starts_after",
175
+ edge: "STARTS_BEFORE",
176
+ reversed: true,
177
+ },
178
+ {
179
+ key: "finishes_after",
180
+ group: "dependsOn",
181
+ optionLabelKey: "dependency.options.finishes_after",
182
+ badgeLabelKey: "dependency.labels.finishes_after",
183
+ tooltipLabelKey: "dependency.tooltips.finishes_after",
184
+ edge: "FINISHES_BEFORE",
185
+ reversed: true,
186
+ },
187
+ {
188
+ key: "blocking",
189
+ group: "dependents",
190
+ optionLabelKey: "dependency.options.blocking",
191
+ badgeLabelKey: "dependency.labels.blocking",
192
+ tooltipLabelKey: "dependency.tooltips.blocking",
193
+ edge: "BLOCKS",
194
+ reversed: false,
195
+ },
196
+ {
197
+ key: "starts_before",
198
+ group: "dependents",
199
+ optionLabelKey: "dependency.options.starts_before",
200
+ badgeLabelKey: "dependency.labels.starts_before",
201
+ tooltipLabelKey: "dependency.tooltips.starts_before",
202
+ edge: "STARTS_BEFORE",
203
+ reversed: false,
204
+ },
205
+ {
206
+ key: "finishes_before",
207
+ group: "dependents",
208
+ optionLabelKey: "dependency.options.finishes_before",
209
+ badgeLabelKey: "dependency.labels.finishes_before",
210
+ tooltipLabelKey: "dependency.tooltips.finishes_before",
211
+ edge: "FINISHES_BEFORE",
212
+ reversed: false,
213
+ },
214
+ ];
215
+
216
+ // 展示推导:分组 × 存储边类型 → 选择项(查 DEPENDENCY_TYPE_OPTIONS,映射真相单一)
217
+ export function dependencyTypeOption(
218
+ group: keyof DependencyGroup,
219
+ depType: DependencyEdgeType,
220
+ ): DependencyTypeOption {
221
+ const option = DEPENDENCY_TYPE_OPTIONS.find(
222
+ (o) => o.edge === depType && o.group === group,
223
+ );
224
+ if (!option) throw new Error(`未知的依赖组合: ${group} ${depType}`);
225
+ return option;
226
+ }
227
+
228
+ // 兼容保留:仅需要 key 时使用
229
+ export function dependencyTypeBadgeKey(
230
+ group: keyof DependencyGroup,
231
+ depType: DependencyEdgeType,
232
+ ): DependencyTypeOption["key"] {
233
+ return dependencyTypeOption(group, depType).key;
234
+ }
235
+
236
+ // 选择项 → 规范化存储边(提交时方向映射用)
237
+ export function toDependencyEdge(
238
+ option: DependencyTypeOption,
239
+ currentStoryId: string,
240
+ targetStoryId: string,
241
+ ): { sourceId: string; targetId: string; type: DependencyEdgeType } {
242
+ return option.reversed
243
+ ? { sourceId: targetStoryId, targetId: currentStoryId, type: option.edge }
244
+ : { sourceId: currentStoryId, targetId: targetStoryId, type: option.edge };
245
+ }
@@ -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) {
@@ -35,6 +35,7 @@ import { Button } from "@/components/ui/button";
35
35
  import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
36
36
  import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
37
37
  import { updateStoryFn, listStoryReactionsFn, toggleStoryReactionFn } from "@/server/serverFns/story";
38
+ import { formatTimestamp } from "@/lib/datetime";
38
39
  import { toast } from "sonner";
39
40
  import type { StoryDetail, UserBrief, CommentWithReplies, ActivityItem, ReactionGroup, RelationsData } from "../types";
40
41
  import { parseStoryDescription, serializeStoryDescription } from "../types";
@@ -321,13 +322,21 @@ export function StoryDetailBody({
321
322
  updateField("description", json);
322
323
  }, [updateField]);
323
324
 
325
+ // 截止日期已过期(未完成/未取消)→ 红色,与子需求列表样式一致
326
+ const dueOverdue = !!(
327
+ story.targetDate &&
328
+ story.state !== "done" &&
329
+ story.state !== "cancelled" &&
330
+ new Date(story.targetDate.slice(0, 10)) < new Date(new Date().toISOString().slice(0, 10))
331
+ );
332
+
324
333
  const metaBar = (
325
334
  <div className="flex flex-wrap items-center gap-2 px-6">
326
335
  <StateEditor value={story.state} onChange={(v) => updateField("state", v)} variant="button" />
327
336
  <PriorityEditor value={story.priority} onChange={(v) => updateField("priority", v)} variant="button" />
328
337
  <AssigneeEditor assignee={story.assignee} users={users} onChange={(v) => updateField("assigneeId", v)} variant="button" />
329
338
  <DateEditor value={story.startDate} onChange={(v) => updateField("startDate", v)} label={t("detail.startDate")} icon={CalendarPlus2} />
330
- <DateEditor value={story.targetDate} onChange={(v) => updateField("targetDate", v)} label={t("detail.dueDate")} icon={CalendarCheck} />
339
+ <DateEditor value={story.targetDate} onChange={(v) => updateField("targetDate", v)} label={t("detail.dueDate")} icon={CalendarCheck} overdue={dueOverdue} />
331
340
  </div>
332
341
  );
333
342
 
@@ -532,9 +541,11 @@ export function StoryDetailBody({
532
541
  <div className="py-2">
533
542
  <StoryDetailToolbar
534
543
  storyId={story.id}
544
+ storyIdentifier={identifier}
535
545
  projectId={story.projectId}
536
546
  data={relationsData}
537
547
  onRefresh={onRelationsUpdate ?? (() => { })}
548
+ onAppendActivities={onAppendActivities}
538
549
  users={users}
539
550
  projects={projects?.map((p) => ({ ...p, displayId: p.displayId ?? null }))}
540
551
  labels={labels}
@@ -567,12 +578,12 @@ export function StoryDetailBody({
567
578
  <div className="flex items-center gap-2 text-xs">
568
579
  <ClockPlus className="size-4 shrink-0 text-muted-foreground" />
569
580
  <span className="text-muted-foreground shrink-0">{t("detail.createdAt")}</span>
570
- <span>{new Date(story.createdAt).toLocaleString()}</span>
581
+ <span>{formatTimestamp(story.createdAt)}</span>
571
582
  </div>
572
583
  <div className="flex items-center gap-2 text-xs">
573
584
  <ClockArrowUp className="size-4 shrink-0 text-muted-foreground" />
574
585
  <span className="text-muted-foreground shrink-0">{t("detail.updatedAt")}</span>
575
- <span>{new Date(story.updatedAt).toLocaleString()}</span>
586
+ <span>{formatTimestamp(story.updatedAt)}</span>
576
587
  </div>
577
588
  </div>
578
589