@allbluecn/web-app 0.4.22 → 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.
Files changed (70) hide show
  1. package/package.json +8 -8
  2. package/src/components/layout/sidebar-03/app-sidebar.tsx +3 -4
  3. package/src/components/layout/sidebar-03/nav-notifications.tsx +135 -52
  4. package/src/components/layout/sidebar-03/user-menu.tsx +4 -2
  5. package/src/components/notifications/settings-notifications.tsx +198 -0
  6. package/src/components/notifications/team-notifications.tsx +343 -0
  7. package/src/components/settings/personal-team/team/team-client.tsx +51 -12
  8. package/src/components/settings/personal-team/team/team-invitations.tsx +181 -181
  9. package/src/components/shared/wave-physics-loader.tsx +155 -0
  10. package/src/components/story/__tests__/dependency-type-badge.test.ts +89 -0
  11. package/src/components/story/constants.ts +114 -51
  12. package/src/components/story/detail/story-detail-body.tsx +13 -3
  13. package/src/components/story/detail/story-detail-toolbar/children.tsx +49 -274
  14. package/src/components/story/detail/story-detail-toolbar/dependencies.tsx +13 -45
  15. package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +465 -0
  16. package/src/components/story/detail/story-detail-toolbar.tsx +84 -3
  17. package/src/components/story/display/__tests__/use-paged-story-groups.test.ts +152 -1
  18. package/src/components/story/display/use-paged-story-groups.ts +370 -28
  19. package/src/components/story/inline-editors/story-select-dialog.tsx +88 -12
  20. package/src/components/story/peek-panel.tsx +8 -1
  21. package/src/components/story/relations/story-ref-row.tsx +319 -0
  22. package/src/components/story/story-list-view.tsx +3 -1
  23. package/src/components/story/story-panel-header.tsx +4 -1
  24. package/src/components/story/types.ts +17 -18
  25. package/src/components/story/views/story-list/batch-toolbar.tsx +12 -1
  26. package/src/components/story/views/story-list/index.tsx +16 -5
  27. package/src/components/story/views/story-list/row.tsx +5 -4
  28. package/src/components/ui/separator.tsx +1 -1
  29. package/src/hooks/use-notifications.ts +4 -1
  30. package/src/lib/__tests__/datetime.test.ts +16 -1
  31. package/src/lib/changelog/sdk-versions.ts +19 -19
  32. package/src/lib/datetime.ts +1 -0
  33. package/src/routeTree.gen.ts +23 -0
  34. package/src/routes/_nav/notifications/route.lazy.tsx +22 -0
  35. package/src/routes/_nav/notifications/route.tsx +30 -0
  36. package/src/routes/_nav/settings/notifications/route.lazy.tsx +6 -5
  37. package/src/routes/api/notifications/stream.ts +4 -0
  38. package/src/server/notifications/__tests__/notification-service.test.ts +189 -0
  39. package/src/server/notifications/notification-service.ts +137 -27
  40. package/src/server/serverFns/__tests__/team-invitation.test.ts +73 -7
  41. package/src/server/serverFns/__tests__/team-join-request.test.ts +5 -3
  42. package/src/server/serverFns/locale.ts +15 -3
  43. package/src/server/serverFns/notifications.ts +18 -4
  44. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +239 -0
  45. package/src/server/serverFns/story/__tests__/story-groups.test.ts +19 -1
  46. package/src/server/serverFns/story/index.ts +1 -2
  47. package/src/server/serverFns/story/story-dependencies.ts +107 -163
  48. package/src/server/serverFns/story/story-groups.ts +23 -3
  49. package/src/server/serverFns/story/story-transfer.ts +5 -3
  50. package/src/server/serverFns/team.ts +51 -2
  51. package/src/components/notifications/notification-list.tsx +0 -232
  52. package/src/components/notifications/notification-preferences.tsx +0 -156
  53. package/src/components/story/relation-badge.tsx +0 -57
  54. package/src/components/story/relations/dependency-panel.tsx +0 -190
  55. package/src/components/story/relations/hierarchy-panel.tsx +0 -180
  56. package/src/components/story/relations/tab.tsx +0 -212
  57. package/src/config/sidebar-notifications.ts +0 -50
  58. package/src/plugins/.gen-manifest.json +0 -13
  59. package/src/plugins/modules.gen.ts +0 -20
  60. package/src/plugins/plugins.gen.ts +0 -20
  61. package/src/plugins/ui.gen.ts +0 -8
  62. package/src/routes/_nav/inspiration/route.tsx +0 -8
  63. package/src/routes/_nav/prd/$id/route.tsx +0 -9
  64. package/src/routes/_nav/prd/index.tsx +0 -9
  65. package/src/routes/_nav/prd/route.tsx +0 -8
  66. package/src/routes/_nav/tags/$id/route.tsx +0 -8
  67. package/src/routes/_nav/tags/index.tsx +0 -8
  68. package/src/routes/_nav/tags/route.tsx +0 -8
  69. package/src/routes/_nav/tags/settings/route.tsx +0 -9
  70. 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 storyId={storyId} data={data} onRefresh={onRefresh} />
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) => {