@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.
- package/package.json +12 -8
- package/src/components/knowledge/knowledge-toolbar.tsx +124 -1
- package/src/components/layout/sidebar-03/app-sidebar.tsx +3 -4
- package/src/components/layout/sidebar-03/nav-notifications.tsx +135 -52
- package/src/components/layout/sidebar-03/user-menu.tsx +4 -2
- package/src/components/notifications/settings-notifications.tsx +198 -0
- package/src/components/notifications/team-notifications.tsx +343 -0
- package/src/components/settings/personal-team/team/team-client.tsx +51 -12
- package/src/components/settings/personal-team/team/team-invitations.tsx +181 -181
- package/src/components/shared/wave-physics-loader.tsx +155 -0
- package/src/components/story/__tests__/dependency-type-badge.test.ts +89 -0
- package/src/components/story/constants.ts +124 -51
- 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 +14 -3
- 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 +51 -274
- package/src/components/story/detail/story-detail-toolbar/dependencies.tsx +13 -45
- package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +473 -0
- package/src/components/story/detail/story-detail-toolbar.tsx +237 -10
- package/src/components/story/display/__tests__/use-paged-story-groups.test.ts +152 -1
- package/src/components/story/display/use-paged-story-groups.ts +370 -28
- package/src/components/story/inline-editors/story-select-dialog.tsx +107 -16
- package/src/components/story/peek-panel.tsx +8 -1
- 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/{config/sidebar-notifications.ts → components/story/relations/general-groups.ts} +12 -30
- 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/relations/story-ref-row.tsx +319 -0
- package/src/components/story/story-list-view.tsx +3 -1
- package/src/components/story/story-panel-header.tsx +4 -1
- package/src/components/story/types.ts +30 -18
- package/src/components/story/views/story-list/batch-toolbar.tsx +12 -1
- package/src/components/story/views/story-list/index.tsx +16 -5
- package/src/components/story/views/story-list/row.tsx +5 -4
- package/src/components/ui/separator.tsx +1 -1
- package/src/hooks/use-notifications.ts +4 -1
- package/src/lib/__tests__/datetime.test.ts +16 -1
- package/src/lib/changelog/sdk-versions.ts +19 -19
- package/src/lib/datetime.ts +1 -0
- package/src/lib/story/__tests__/video-embed.test.ts +77 -0
- package/src/lib/story/video-embed.ts +59 -0
- package/src/routeTree.gen.ts +65 -0
- package/src/routes/_nav/notifications/route.lazy.tsx +22 -0
- package/src/routes/_nav/notifications/route.tsx +30 -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/settings/notifications/route.lazy.tsx +6 -5
- package/src/routes/api/attachments/$id/content.ts +60 -0
- package/src/routes/api/attachments/upload.ts +148 -0
- package/src/routes/api/notifications/stream.ts +4 -0
- package/src/server/notifications/__tests__/notification-service.test.ts +189 -0
- package/src/server/notifications/notification-service.ts +137 -27
- package/src/server/serverFns/__tests__/project-kb.test.ts +147 -0
- package/src/server/serverFns/__tests__/team-invitation.test.ts +73 -7
- package/src/server/serverFns/__tests__/team-join-request.test.ts +5 -3
- package/src/server/serverFns/knowledge.ts +50 -4
- package/src/server/serverFns/locale.ts +15 -3
- package/src/server/serverFns/notifications.ts +18 -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 +265 -0
- package/src/server/serverFns/story/__tests__/story-groups.test.ts +19 -1
- package/src/server/serverFns/story/index.ts +7 -6
- 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 +138 -162
- package/src/server/serverFns/story/story-groups.ts +23 -3
- package/src/server/serverFns/story/story-links.ts +13 -1
- package/src/server/serverFns/story/story-transfer.ts +5 -3
- package/src/server/serverFns/story/story-tree.ts +28 -1
- package/src/server/serverFns/story/types.ts +37 -0
- package/src/server/serverFns/team.ts +51 -2
- package/src/styles/globals.css +25 -0
- package/vite.shared.ts +2 -0
- package/src/components/notifications/notification-list.tsx +0 -232
- package/src/components/notifications/notification-preferences.tsx +0 -156
- 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/relation-badge.tsx +0 -57
- package/src/components/story/relations/attachments-panel.tsx +0 -179
- package/src/components/story/relations/dependency-panel.tsx +0 -190
- package/src/components/story/relations/hierarchy-panel.tsx +0 -180
- package/src/components/story/relations/knowledge-panel.tsx +0 -284
- package/src/components/story/relations/links-panel.tsx +0 -240
- 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 {
|
|
19
|
-
import type {
|
|
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
|
-
// ======
|
|
125
|
-
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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?: (
|
|
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) {
|
|
@@ -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>{
|
|
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>{
|
|
586
|
+
<span>{formatTimestamp(story.updatedAt)}</span>
|
|
576
587
|
</div>
|
|
577
588
|
</div>
|
|
578
589
|
|