@allbluecn/web-app 0.4.21 → 0.5.0
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 +25 -33
- package/src/components/command/command-palette.tsx +1 -1
- package/src/components/knowledge/knowledge-toolbar.tsx +29 -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 +114 -51
- package/src/components/story/detail/story-detail-body.tsx +13 -3
- package/src/components/story/detail/story-detail-toolbar/children.tsx +49 -274
- package/src/components/story/detail/story-detail-toolbar/dependencies.tsx +13 -45
- package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +465 -0
- package/src/components/story/detail/story-detail-toolbar.tsx +84 -3
- 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 +88 -12
- package/src/components/story/peek-panel.tsx +8 -1
- 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 +17 -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/__tests__/rag.test.ts +37 -2
- package/src/lib/changelog/sdk-versions.ts +30 -30
- package/src/lib/commands/registry.ts +1 -1
- package/src/lib/commands/types.ts +3 -0
- package/src/lib/datetime.ts +1 -0
- package/src/lib/rag.ts +9 -2
- package/src/routeTree.gen.ts +23 -0
- package/src/routes/_nav/notifications/route.lazy.tsx +22 -0
- package/src/routes/_nav/notifications/route.tsx +30 -0
- package/src/routes/_nav/settings/notifications/route.lazy.tsx +6 -5
- package/src/routes/api/notifications/stream.ts +4 -0
- package/src/server/ai-ports.ts +1 -1
- package/src/server/knowledge-public/github-sync.ts +1 -1
- 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__/knowledge-index.test.ts +178 -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-index-utils.ts +104 -0
- package/src/server/serverFns/knowledge-index.ts +6 -67
- package/src/server/serverFns/knowledge.ts +8 -2
- package/src/server/serverFns/locale.ts +15 -3
- package/src/server/serverFns/notifications.ts +18 -4
- package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +239 -0
- package/src/server/serverFns/story/__tests__/story-groups.test.ts +19 -1
- package/src/server/serverFns/story/index.ts +1 -2
- package/src/server/serverFns/story/story-dependencies.ts +107 -163
- package/src/server/serverFns/story/story-groups.ts +23 -3
- package/src/server/serverFns/story/story-transfer.ts +5 -3
- package/src/server/serverFns/team.ts +51 -2
- package/src/components/knowledge/knowledge-settings-panel.tsx +0 -148
- package/src/components/notifications/notification-list.tsx +0 -232
- package/src/components/notifications/notification-preferences.tsx +0 -156
- package/src/components/story/relation-badge.tsx +0 -57
- 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/tab.tsx +0 -212
- package/src/config/sidebar-notifications.ts +0 -50
- package/src/lib/openai-client.ts +0 -62
- package/src/plugins/.gen-manifest.json +0 -13
- package/src/plugins/modules.gen.ts +0 -20
- package/src/plugins/plugins.gen.ts +0 -20
- package/src/plugins/ui.gen.ts +0 -8
- package/src/routes/_nav/inspiration/route.tsx +0 -8
- package/src/routes/_nav/prd/$id/route.tsx +0 -9
- package/src/routes/_nav/prd/index.tsx +0 -9
- package/src/routes/_nav/prd/route.tsx +0 -8
- package/src/routes/_nav/tags/$id/route.tsx +0 -8
- package/src/routes/_nav/tags/index.tsx +0 -8
- package/src/routes/_nav/tags/route.tsx +0 -8
- package/src/routes/_nav/tags/settings/route.tsx +0 -9
- package/src/routes/share/prd/$prdId/route.tsx +0 -10
|
@@ -0,0 +1,465 @@
|
|
|
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 * as React from "react";
|
|
19
|
+
import { useNavigate } from "@tanstack/react-router";
|
|
20
|
+
import { useTranslation } from "react-i18next";
|
|
21
|
+
import { Plus, ChevronDown, ChartNoAxesGantt } from "lucide-react";
|
|
22
|
+
import { Button } from "@/components/ui/button";
|
|
23
|
+
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
24
|
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
|
25
|
+
import { useServerFn } from "@tanstack/react-start";
|
|
26
|
+
import { toast } from "sonner";
|
|
27
|
+
import {
|
|
28
|
+
removeDependencyFn,
|
|
29
|
+
bulkArchiveStoriesFn,
|
|
30
|
+
bulkDeleteStoriesFn,
|
|
31
|
+
bulkUpdateStoriesFn,
|
|
32
|
+
} from "@/server/serverFns/story";
|
|
33
|
+
import { BatchToolbar } from "../../views/story-list/batch-toolbar";
|
|
34
|
+
import { useGroupedStories } from "../../display/use-grouped-stories";
|
|
35
|
+
import { VirtualGroupHeader } from "../../views/story-list/group";
|
|
36
|
+
import { DisplayOptionsBody } from "../../display/display-dropdown";
|
|
37
|
+
import { StoryRefRow } from "../../relations/story-ref-row";
|
|
38
|
+
import type { DisplayOptions, GroupNode } from "../../display/types";
|
|
39
|
+
import type { StoryPatch } from "../../display/group-fields";
|
|
40
|
+
import type { RelationsData, UserBrief, DependencyRow } from "../../types";
|
|
41
|
+
|
|
42
|
+
type DependencySection = "dependsOn" | "dependents";
|
|
43
|
+
|
|
44
|
+
// fetch 网络层失败(服务未启动/断网)时浏览器抛 TypeError,文案为英文原始信息
|
|
45
|
+
function isNetworkError(e: unknown): boolean {
|
|
46
|
+
const msg = e instanceof Error ? e.message : "";
|
|
47
|
+
return /failed to fetch|load failed|network error|fetch failed/i.test(msg);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface StoryDetailDependencyPanelProps {
|
|
51
|
+
storyId: string;
|
|
52
|
+
/** 当前需求编号(tooltip 文案用) */
|
|
53
|
+
storyIdentifier?: string;
|
|
54
|
+
projectId: string | null;
|
|
55
|
+
data: RelationsData["dependencies"];
|
|
56
|
+
onRefresh: (next: (prev: RelationsData) => RelationsData) => void;
|
|
57
|
+
refreshDependencies: () => void;
|
|
58
|
+
users?: UserBrief[];
|
|
59
|
+
projects?: { id: string; name: string; icon: string; displayId: string | null }[];
|
|
60
|
+
labels?: { id: string; name: string; color: string }[];
|
|
61
|
+
onOpenAdd: () => void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function StoryDetailDependencyPanel({
|
|
65
|
+
storyId,
|
|
66
|
+
storyIdentifier,
|
|
67
|
+
projectId,
|
|
68
|
+
data,
|
|
69
|
+
onRefresh,
|
|
70
|
+
refreshDependencies,
|
|
71
|
+
users,
|
|
72
|
+
projects,
|
|
73
|
+
labels,
|
|
74
|
+
onOpenAdd,
|
|
75
|
+
}: StoryDetailDependencyPanelProps) {
|
|
76
|
+
const { t } = useTranslation("story");
|
|
77
|
+
const navigate = useNavigate();
|
|
78
|
+
const removeDependency = useServerFn(removeDependencyFn);
|
|
79
|
+
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(() => new Set());
|
|
80
|
+
const [busy, setBusy] = React.useState(false);
|
|
81
|
+
const [collapsed, setCollapsed] = React.useState<Record<string, boolean>>({});
|
|
82
|
+
const [filters, setFilters] = React.useState<DisplayOptions>(() => ({
|
|
83
|
+
groupBy: "none",
|
|
84
|
+
subGroupBy: "none",
|
|
85
|
+
orderBy: "created",
|
|
86
|
+
orderDir: "desc",
|
|
87
|
+
properties: ["id", "priority", "state", "assignee", "startDate", "dueDate", "labels"],
|
|
88
|
+
showEmptyGroups: false,
|
|
89
|
+
showSubItems: false,
|
|
90
|
+
pageSize: 100,
|
|
91
|
+
}));
|
|
92
|
+
|
|
93
|
+
const allRows = React.useMemo(
|
|
94
|
+
() => [...data.dependsOn, ...data.dependents],
|
|
95
|
+
[data],
|
|
96
|
+
);
|
|
97
|
+
const rowById = React.useMemo(
|
|
98
|
+
() => new Map(allRows.map((r) => [r.id, r])),
|
|
99
|
+
[allRows],
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const dependsOnGroups = useGroupedStories({
|
|
103
|
+
stories: data.dependsOn,
|
|
104
|
+
filters,
|
|
105
|
+
users: users ?? [],
|
|
106
|
+
labels: labels ?? [],
|
|
107
|
+
projects: projects ?? [],
|
|
108
|
+
});
|
|
109
|
+
const dependentsGroups = useGroupedStories({
|
|
110
|
+
stories: data.dependents,
|
|
111
|
+
filters,
|
|
112
|
+
users: users ?? [],
|
|
113
|
+
labels: labels ?? [],
|
|
114
|
+
projects: projects ?? [],
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const selectedStories = React.useMemo(
|
|
118
|
+
() => allRows.filter((s) => selectedIds.has(s.id)),
|
|
119
|
+
[allRows, selectedIds],
|
|
120
|
+
);
|
|
121
|
+
const canArchive = React.useMemo(
|
|
122
|
+
() => selectedStories.some((s) => s.state === "done" || s.state === "cancelled"),
|
|
123
|
+
[selectedStories],
|
|
124
|
+
);
|
|
125
|
+
const allSelected = allRows.length > 0 && selectedIds.size === allRows.length;
|
|
126
|
+
const showSelect = selectedIds.size > 0;
|
|
127
|
+
const totalCount = data.dependsOn.length + data.dependents.length;
|
|
128
|
+
|
|
129
|
+
if (totalCount === 0) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const updateFilters = (patch: Partial<DisplayOptions>) => {
|
|
134
|
+
setFilters((prev) => ({ ...prev, ...patch }));
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const handleOpenStory = (id: string) => {
|
|
138
|
+
navigate({ to: "/stories/$storyId", params: { storyId: id } });
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const applyRowsUpdate = (
|
|
142
|
+
next: (rows: DependencyRow[]) => DependencyRow[],
|
|
143
|
+
group: DependencySection,
|
|
144
|
+
) => {
|
|
145
|
+
onRefresh((prev) => ({
|
|
146
|
+
...prev,
|
|
147
|
+
dependencies: { ...prev.dependencies, [group]: next(prev.dependencies[group]) },
|
|
148
|
+
}));
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const handleRemoveRow = async (rowId: string) => {
|
|
152
|
+
const row = rowById.get(rowId);
|
|
153
|
+
if (!row) return;
|
|
154
|
+
const group: DependencySection = data.dependsOn.some((r) => r.id === rowId)
|
|
155
|
+
? "dependsOn"
|
|
156
|
+
: "dependents";
|
|
157
|
+
try {
|
|
158
|
+
await removeDependency({ data: { depId: row.depId } });
|
|
159
|
+
toast.success(t("dependency.removed"));
|
|
160
|
+
applyRowsUpdate((rows) => rows.filter((r) => r.id !== rowId), group);
|
|
161
|
+
setSelectedIds((prev) => {
|
|
162
|
+
const next = new Set(prev);
|
|
163
|
+
next.delete(rowId);
|
|
164
|
+
return next;
|
|
165
|
+
});
|
|
166
|
+
} catch (e) {
|
|
167
|
+
toast.error(isNetworkError(e) ? t("dependency.networkFailed") : (e as Error)?.message || t("dependency.removeFailed"));
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const handleBulkRemoveRelations = async (storyIds: string[]) => {
|
|
172
|
+
if (storyIds.length === 0) return;
|
|
173
|
+
setBusy(true);
|
|
174
|
+
try {
|
|
175
|
+
for (const id of storyIds) {
|
|
176
|
+
const row = rowById.get(id);
|
|
177
|
+
if (row) await removeDependency({ data: { depId: row.depId } });
|
|
178
|
+
}
|
|
179
|
+
toast.success(t("dependency.removed"));
|
|
180
|
+
setSelectedIds(new Set());
|
|
181
|
+
refreshDependencies();
|
|
182
|
+
} catch (e) {
|
|
183
|
+
toast.error(isNetworkError(e) ? t("dependency.networkFailed") : (e as Error)?.message || t("dependency.removeFailed"));
|
|
184
|
+
} finally {
|
|
185
|
+
setBusy(false);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const handleBulkArchive = async (storyIds?: string[]) => {
|
|
190
|
+
const ids = storyIds ?? [...selectedIds];
|
|
191
|
+
if (ids.length === 0) return;
|
|
192
|
+
setBusy(true);
|
|
193
|
+
try {
|
|
194
|
+
const res = await bulkArchiveStoriesFn({ data: { storyIds: ids } });
|
|
195
|
+
toast.success(
|
|
196
|
+
res.skipped > 0
|
|
197
|
+
? t("children.bulkArchivedSkipped", { archived: res.archived, skipped: res.skipped })
|
|
198
|
+
: t("children.bulkArchived", { count: res.archived }),
|
|
199
|
+
);
|
|
200
|
+
setSelectedIds(new Set());
|
|
201
|
+
refreshDependencies();
|
|
202
|
+
} catch (e) {
|
|
203
|
+
toast.error((e as Error)?.message || t("children.archiveFailed"));
|
|
204
|
+
} finally {
|
|
205
|
+
setBusy(false);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const handleBulkDelete = async (storyIds?: string[]) => {
|
|
210
|
+
const ids = storyIds ?? [...selectedIds];
|
|
211
|
+
if (ids.length === 0) return;
|
|
212
|
+
setBusy(true);
|
|
213
|
+
try {
|
|
214
|
+
const res = await bulkDeleteStoriesFn({ data: { storyIds: ids } });
|
|
215
|
+
toast.success(t("children.bulkDeleted", { count: res.deleted }));
|
|
216
|
+
setSelectedIds(new Set());
|
|
217
|
+
refreshDependencies();
|
|
218
|
+
} catch (e) {
|
|
219
|
+
toast.error((e as Error)?.message || t("children.deleteFailed"));
|
|
220
|
+
} finally {
|
|
221
|
+
setBusy(false);
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const handleBulkUpdate = async (patch: StoryPatch) => {
|
|
226
|
+
if (selectedIds.size === 0) return;
|
|
227
|
+
setBusy(true);
|
|
228
|
+
try {
|
|
229
|
+
await bulkUpdateStoriesFn({ data: { storyIds: [...selectedIds], patch } });
|
|
230
|
+
setSelectedIds(new Set());
|
|
231
|
+
toast.success(t("dependency.bulkUpdated"));
|
|
232
|
+
refreshDependencies();
|
|
233
|
+
} catch (e) {
|
|
234
|
+
toast.error((e as Error)?.message || t("children.bulkUpdateFailed"));
|
|
235
|
+
} finally {
|
|
236
|
+
setBusy(false);
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const toggleStory = (id: string) => {
|
|
241
|
+
setSelectedIds((prev) => {
|
|
242
|
+
const next = new Set(prev);
|
|
243
|
+
if (next.has(id)) next.delete(id);
|
|
244
|
+
else next.add(id);
|
|
245
|
+
return next;
|
|
246
|
+
});
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const toggleGroup = (ids: string[]) => {
|
|
250
|
+
setSelectedIds((prev) => {
|
|
251
|
+
const next = new Set(prev);
|
|
252
|
+
const allChosen = ids.length > 0 && ids.every((id) => next.has(id));
|
|
253
|
+
if (allChosen) {
|
|
254
|
+
for (const id of ids) next.delete(id);
|
|
255
|
+
} else {
|
|
256
|
+
for (const id of ids) next.add(id);
|
|
257
|
+
}
|
|
258
|
+
return next;
|
|
259
|
+
});
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const toggleCollapse = (key: string) => {
|
|
263
|
+
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }));
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const computeGroupCheckState = (ids: string[]): boolean | "indeterminate" => {
|
|
267
|
+
if (ids.length === 0) return false;
|
|
268
|
+
let count = 0;
|
|
269
|
+
for (const id of ids) if (selectedIds.has(id)) count++;
|
|
270
|
+
if (count === ids.length) return true;
|
|
271
|
+
return count > 0 ? "indeterminate" : false;
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const handleStoryRefresh = (rowId: string, patch: Record<string, unknown>) => {
|
|
275
|
+
for (const group of ["dependsOn", "dependents"] as const) {
|
|
276
|
+
if (patch.deleted) {
|
|
277
|
+
applyRowsUpdate((rows) => rows.filter((r) => r.id !== rowId), group);
|
|
278
|
+
} else {
|
|
279
|
+
applyRowsUpdate(
|
|
280
|
+
(rows) => rows.map((r) => (r.id === rowId ? ({ ...r, ...patch } as DependencyRow) : r)),
|
|
281
|
+
group,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (patch.deleted) {
|
|
286
|
+
setSelectedIds((prev) => {
|
|
287
|
+
const next = new Set(prev);
|
|
288
|
+
next.delete(rowId);
|
|
289
|
+
return next;
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const renderRow = (row: DependencyRow, group: DependencySection) => (
|
|
295
|
+
<StoryRefRow
|
|
296
|
+
key={row.depId}
|
|
297
|
+
story={row}
|
|
298
|
+
users={users ?? []}
|
|
299
|
+
labels={labels ?? []}
|
|
300
|
+
parentProjectId={projectId}
|
|
301
|
+
projects={projects ?? []}
|
|
302
|
+
selected={selectedIds.has(row.id)}
|
|
303
|
+
properties={filters.properties}
|
|
304
|
+
onToggle={toggleStory}
|
|
305
|
+
onOpenStory={handleOpenStory}
|
|
306
|
+
onRemove={handleRemoveRow}
|
|
307
|
+
removeMenuLabel={t("dependency.remove")}
|
|
308
|
+
dependencyMeta={{ depType: row.depType, group }}
|
|
309
|
+
currentIdentifier={storyIdentifier}
|
|
310
|
+
onStoryRefresh={handleStoryRefresh}
|
|
311
|
+
/>
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
const renderGroupItems = (node: GroupNode, group: DependencySection, depth: number, collapseKey: string) => {
|
|
315
|
+
const isCollapsed = collapsed[collapseKey] ?? false;
|
|
316
|
+
const ids = node.items.map((s) => s.id);
|
|
317
|
+
return (
|
|
318
|
+
<React.Fragment key={collapseKey}>
|
|
319
|
+
<div style={{ height: 36 }}>
|
|
320
|
+
<VirtualGroupHeader
|
|
321
|
+
node={node}
|
|
322
|
+
depth={depth}
|
|
323
|
+
collapsed={isCollapsed}
|
|
324
|
+
collapseKey={collapseKey}
|
|
325
|
+
onToggle={toggleCollapse}
|
|
326
|
+
selectState={computeGroupCheckState(ids)}
|
|
327
|
+
onToggleSelect={() => toggleGroup(ids)}
|
|
328
|
+
showSelect={showSelect}
|
|
329
|
+
showRoleLabel={
|
|
330
|
+
filters.subGroupBy === "assignee" || filters.subGroupBy === "createdBy"
|
|
331
|
+
}
|
|
332
|
+
groupDimension={depth === 0 ? filters.groupBy : filters.subGroupBy}
|
|
333
|
+
/>
|
|
334
|
+
</div>
|
|
335
|
+
{!isCollapsed && (
|
|
336
|
+
<React.Fragment>
|
|
337
|
+
{node.subGroups && node.subGroups.length > 0 ? (
|
|
338
|
+
node.subGroups.map((subGroup) =>
|
|
339
|
+
renderGroupItems(
|
|
340
|
+
subGroup,
|
|
341
|
+
group,
|
|
342
|
+
depth + 1,
|
|
343
|
+
`${collapseKey}/${subGroup.key}`,
|
|
344
|
+
),
|
|
345
|
+
)
|
|
346
|
+
) : (
|
|
347
|
+
node.items.map((s) => renderRow(s as DependencyRow, group))
|
|
348
|
+
)}
|
|
349
|
+
</React.Fragment>
|
|
350
|
+
)}
|
|
351
|
+
</React.Fragment>
|
|
352
|
+
);
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const renderSection = (
|
|
356
|
+
titleKey: string,
|
|
357
|
+
group: DependencySection,
|
|
358
|
+
rows: DependencyRow[],
|
|
359
|
+
groups: GroupNode[],
|
|
360
|
+
) => (
|
|
361
|
+
<div>
|
|
362
|
+
<div className="flex h-9 items-center gap-1.5 bg-muted/50 px-2">
|
|
363
|
+
<span className="text-sm font-medium">{t(titleKey)}</span>
|
|
364
|
+
<span className="inline-flex h-4 shrink-0 items-center rounded border px-1.5 text-[10px] font-normal text-muted-foreground whitespace-nowrap">
|
|
365
|
+
{rows.length}
|
|
366
|
+
</span>
|
|
367
|
+
</div>
|
|
368
|
+
{rows.length === 0 ? (
|
|
369
|
+
<p className="flex items-center gap-1.5 px-2 py-1.5 text-xs text-muted-foreground">
|
|
370
|
+
<span className="w-4 shrink-0" aria-hidden />
|
|
371
|
+
{t("relation.none")}
|
|
372
|
+
</p>
|
|
373
|
+
) : filters.groupBy === "none" ? (
|
|
374
|
+
rows.map((row) => renderRow(row, group))
|
|
375
|
+
) : (
|
|
376
|
+
groups.map((node) => renderGroupItems(node, group, 0, `${group}:${node.key}`))
|
|
377
|
+
)}
|
|
378
|
+
</div>
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
return (
|
|
382
|
+
<Collapsible defaultOpen>
|
|
383
|
+
<CollapsibleTrigger
|
|
384
|
+
asChild
|
|
385
|
+
className="group flex w-full cursor-pointer items-center gap-2 pb-3 pt-4 text-xs font-semibold hover:text-foreground transition-colors"
|
|
386
|
+
>
|
|
387
|
+
<div
|
|
388
|
+
role="button"
|
|
389
|
+
tabIndex={0}
|
|
390
|
+
onKeyDown={(e) => {
|
|
391
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
392
|
+
e.preventDefault();
|
|
393
|
+
(e.currentTarget as HTMLDivElement).click();
|
|
394
|
+
}
|
|
395
|
+
}}
|
|
396
|
+
>
|
|
397
|
+
<div className="flex items-center gap-1.5">
|
|
398
|
+
<span>{t("relations.dependency")}</span>
|
|
399
|
+
<span className="inline-flex min-w-[18px] items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-primary-foreground">
|
|
400
|
+
{totalCount}
|
|
401
|
+
</span>
|
|
402
|
+
<ChevronDown className="size-3 text-muted-foreground transition-transform group-data-[state=open]:rotate-0 group-data-[state=closed]:-rotate-90" />
|
|
403
|
+
</div>
|
|
404
|
+
<div className="flex items-center gap-1 ml-auto" onClick={(e) => e.stopPropagation()}>
|
|
405
|
+
<Popover>
|
|
406
|
+
<PopoverTrigger asChild>
|
|
407
|
+
<Button variant="ghost" size="sm" className="size-7 p-0">
|
|
408
|
+
<ChartNoAxesGantt className="size-3.5" />
|
|
409
|
+
</Button>
|
|
410
|
+
</PopoverTrigger>
|
|
411
|
+
<PopoverContent align="end" className="w-75 p-0">
|
|
412
|
+
<div className="max-h-[60vh] overflow-y-auto">
|
|
413
|
+
<DisplayOptionsBody
|
|
414
|
+
filters={filters}
|
|
415
|
+
onChange={updateFilters}
|
|
416
|
+
layout="list"
|
|
417
|
+
showSubItems={false}
|
|
418
|
+
/>
|
|
419
|
+
</div>
|
|
420
|
+
</PopoverContent>
|
|
421
|
+
</Popover>
|
|
422
|
+
<Button variant="ghost" size="sm" className="size-7 p-0" onClick={onOpenAdd}>
|
|
423
|
+
<Plus className="size-3.5" />
|
|
424
|
+
</Button>
|
|
425
|
+
</div>
|
|
426
|
+
</div>
|
|
427
|
+
</CollapsibleTrigger>
|
|
428
|
+
<CollapsibleContent className="pb-2">
|
|
429
|
+
<div className="flex flex-col gap-0 border rounded-lg overflow-hidden">
|
|
430
|
+
{renderSection("dependency.dependsOnTitle", "dependsOn", data.dependsOn, dependsOnGroups)}
|
|
431
|
+
{renderSection("dependency.dependentsTitle", "dependents", data.dependents, dependentsGroups)}
|
|
432
|
+
<span className="-ml-1">
|
|
433
|
+
<BatchToolbar
|
|
434
|
+
selectedCount={selectedIds.size}
|
|
435
|
+
totalLoaded={allRows.length}
|
|
436
|
+
allSelected={allSelected}
|
|
437
|
+
canArchive={canArchive}
|
|
438
|
+
canMove={false}
|
|
439
|
+
canMoveUp={false}
|
|
440
|
+
canMoveDown={false}
|
|
441
|
+
busy={busy}
|
|
442
|
+
users={users ?? []}
|
|
443
|
+
labels={labels ?? []}
|
|
444
|
+
projects={projects ?? []}
|
|
445
|
+
stories={selectedStories}
|
|
446
|
+
onToggleAll={() =>
|
|
447
|
+
setSelectedIds(
|
|
448
|
+
allSelected ? new Set() : new Set(allRows.map((r) => r.id)),
|
|
449
|
+
)
|
|
450
|
+
}
|
|
451
|
+
onMoveUp={() => {}}
|
|
452
|
+
onMoveDown={() => {}}
|
|
453
|
+
onArchive={handleBulkArchive}
|
|
454
|
+
onDelete={handleBulkDelete}
|
|
455
|
+
onBulkUpdate={handleBulkUpdate}
|
|
456
|
+
onClearSelection={() => setSelectedIds(new Set())}
|
|
457
|
+
onTransfer={() => {}}
|
|
458
|
+
onRemoveRelations={handleBulkRemoveRelations}
|
|
459
|
+
/>
|
|
460
|
+
</span>
|
|
461
|
+
</div>
|
|
462
|
+
</CollapsibleContent>
|
|
463
|
+
</Collapsible>
|
|
464
|
+
);
|
|
465
|
+
}
|
|
@@ -17,19 +17,25 @@
|
|
|
17
17
|
|
|
18
18
|
import * as React from "react";
|
|
19
19
|
import { useTranslation } from "react-i18next";
|
|
20
|
+
import { useServerFn } from "@tanstack/react-start";
|
|
21
|
+
import { toast } from "sonner";
|
|
20
22
|
import { StoryDetailChildrenButton, StoryDetailChildrenPanel } from "./story-detail-toolbar/children";
|
|
21
23
|
import { StoryDetailDependencies } from "./story-detail-toolbar/dependencies";
|
|
24
|
+
import { StoryDetailDependencyPanel } from "./story-detail-toolbar/dependency-panel";
|
|
22
25
|
import { StoryDetailLinks } from "./story-detail-toolbar/links";
|
|
23
26
|
import { StoryDetailAttachments } from "./story-detail-toolbar/attachments";
|
|
24
27
|
import { StoryDetailKnowledge } from "./story-detail-toolbar/knowledge";
|
|
25
28
|
import { StoryDetailCategories } from "./story-detail-toolbar/categories";
|
|
26
29
|
import { StorySelectDialog } from "../inline-editors/story-select-dialog";
|
|
27
30
|
import { CreateStoryDialog } from "../create-story-dialog";
|
|
28
|
-
import { listChildStoriesFn } from "@/server/serverFns/story";
|
|
31
|
+
import { addDependenciesFn, getDependenciesFn, listChildStoriesFn } from "@/server/serverFns/story";
|
|
32
|
+
import { DEPENDENCY_TYPE_OPTIONS, toDependencyEdge, type DependencyTypeOption } from "../constants";
|
|
29
33
|
import type { RelationsData, UserBrief } from "../types";
|
|
30
34
|
|
|
31
35
|
interface StoryDetailToolbarProps {
|
|
32
36
|
storyId: string;
|
|
37
|
+
/** 当前需求编号(依赖 tooltip 文案用) */
|
|
38
|
+
storyIdentifier?: string;
|
|
33
39
|
projectId: string | null;
|
|
34
40
|
data: RelationsData;
|
|
35
41
|
onRefresh: (next: (prev: RelationsData) => RelationsData) => void;
|
|
@@ -38,9 +44,52 @@ interface StoryDetailToolbarProps {
|
|
|
38
44
|
labels?: { id: string; name: string; color: string }[];
|
|
39
45
|
}
|
|
40
46
|
|
|
41
|
-
export function StoryDetailToolbar({ storyId, projectId, data, onRefresh, users, projects, labels }: StoryDetailToolbarProps) {
|
|
47
|
+
export function StoryDetailToolbar({ storyId, storyIdentifier, projectId, data, onRefresh, users, projects, labels }: StoryDetailToolbarProps) {
|
|
42
48
|
const { t } = useTranslation("story");
|
|
43
49
|
const [actionOpen, setActionOpen] = React.useState<"create" | "select" | null>(null);
|
|
50
|
+
const [dependencyDialogOpen, setDependencyDialogOpen] = React.useState(false);
|
|
51
|
+
const [dependencyType, setDependencyType] = React.useState<DependencyTypeOption["key"] | undefined>(undefined);
|
|
52
|
+
const addDependencies = useServerFn(addDependenciesFn);
|
|
53
|
+
|
|
54
|
+
const dependencyExcludeIds = React.useMemo(
|
|
55
|
+
() => [
|
|
56
|
+
storyId,
|
|
57
|
+
...data.dependencies.dependsOn.map((r) => r.id),
|
|
58
|
+
...data.dependencies.dependents.map((r) => r.id),
|
|
59
|
+
],
|
|
60
|
+
[storyId, data.dependencies],
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const refreshDependencies = async () => {
|
|
64
|
+
try {
|
|
65
|
+
const dependencies = await getDependenciesFn({ data: { storyId } });
|
|
66
|
+
onRefresh((prev) => ({ ...prev, dependencies }));
|
|
67
|
+
} catch {
|
|
68
|
+
// 静默兜底:保留现有面板数据
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const handleDependencySubmit = async (ids: string | string[]) => {
|
|
73
|
+
const option = DEPENDENCY_TYPE_OPTIONS.find((o) => o.key === dependencyType);
|
|
74
|
+
if (!option) return;
|
|
75
|
+
const targetIds = Array.isArray(ids) ? ids : [ids];
|
|
76
|
+
const items = targetIds.map((targetId) => toDependencyEdge(option, storyId, targetId));
|
|
77
|
+
try {
|
|
78
|
+
const res = await addDependencies({ data: { items } });
|
|
79
|
+
if (res.created.length > 0) {
|
|
80
|
+
toast.success(t("dependency.added", { count: res.created.length }));
|
|
81
|
+
}
|
|
82
|
+
if (res.failed.length > 0) {
|
|
83
|
+
const reasons = [...new Set(res.failed.map((f) => t(`dependency.errors.${f.reason}`)))].join("、");
|
|
84
|
+
toast.warning(t("dependency.addPartialFailed", { failed: res.failed.length, reasons }));
|
|
85
|
+
} else {
|
|
86
|
+
setDependencyDialogOpen(false);
|
|
87
|
+
}
|
|
88
|
+
refreshDependencies();
|
|
89
|
+
} catch (e) {
|
|
90
|
+
toast.error((e as Error)?.message || t("dependency.addFailed"));
|
|
91
|
+
}
|
|
92
|
+
};
|
|
44
93
|
|
|
45
94
|
const handleChildAction = (action: "create" | "select") => {
|
|
46
95
|
setActionOpen(action);
|
|
@@ -80,7 +129,7 @@ export function StoryDetailToolbar({ storyId, projectId, data, onRefresh, users,
|
|
|
80
129
|
|
|
81
130
|
<span className="mx-0.5 h-4 w-px shrink-0 bg-muted-foreground/20" />
|
|
82
131
|
|
|
83
|
-
<StoryDetailDependencies
|
|
132
|
+
<StoryDetailDependencies onOpenAdd={() => setDependencyDialogOpen(true)} />
|
|
84
133
|
|
|
85
134
|
<span className="mx-0.5 h-4 w-px shrink-0 bg-muted-foreground/20" />
|
|
86
135
|
|
|
@@ -105,6 +154,19 @@ export function StoryDetailToolbar({ storyId, projectId, data, onRefresh, users,
|
|
|
105
154
|
onAction={handleChildAction}
|
|
106
155
|
/>
|
|
107
156
|
|
|
157
|
+
<StoryDetailDependencyPanel
|
|
158
|
+
storyId={storyId}
|
|
159
|
+
storyIdentifier={storyIdentifier}
|
|
160
|
+
projectId={projectId}
|
|
161
|
+
data={data.dependencies}
|
|
162
|
+
onRefresh={onRefresh}
|
|
163
|
+
refreshDependencies={refreshDependencies}
|
|
164
|
+
users={users}
|
|
165
|
+
projects={projects}
|
|
166
|
+
labels={labels}
|
|
167
|
+
onOpenAdd={() => setDependencyDialogOpen(true)}
|
|
168
|
+
/>
|
|
169
|
+
|
|
108
170
|
<StorySelectDialog
|
|
109
171
|
open={actionOpen === "select"}
|
|
110
172
|
onOpenChange={(open) => {
|
|
@@ -122,6 +184,25 @@ export function StoryDetailToolbar({ storyId, projectId, data, onRefresh, users,
|
|
|
122
184
|
}}
|
|
123
185
|
/>
|
|
124
186
|
|
|
187
|
+
<StorySelectDialog
|
|
188
|
+
open={dependencyDialogOpen}
|
|
189
|
+
onOpenChange={(open) => {
|
|
190
|
+
if (!open) setDependencyDialogOpen(false);
|
|
191
|
+
}}
|
|
192
|
+
storyId={storyId}
|
|
193
|
+
projects={projects ?? []}
|
|
194
|
+
initialProjectId={projectId ?? undefined}
|
|
195
|
+
mode="dependency"
|
|
196
|
+
skipTransfer
|
|
197
|
+
title={t("dependency.add")}
|
|
198
|
+
excludeIds={dependencyExcludeIds}
|
|
199
|
+
dependencyType={dependencyType}
|
|
200
|
+
onDependencyTypeChange={setDependencyType}
|
|
201
|
+
onSelected={(ids) => {
|
|
202
|
+
void handleDependencySubmit(ids);
|
|
203
|
+
}}
|
|
204
|
+
/>
|
|
205
|
+
|
|
125
206
|
<CreateStoryDialog
|
|
126
207
|
open={actionOpen === "create"}
|
|
127
208
|
onOpenChange={(open) => {
|