@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,319 @@
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 { useTranslation } from "react-i18next";
20
+ import { Archive, MoreHorizontal, Trash2, Waypoints, CalendarCheck, CalendarPlus2 } from "lucide-react";
21
+ import { Button } from "@/components/ui/button";
22
+ import { Checkbox } from "@/components/ui/checkbox";
23
+ import {
24
+ DropdownMenu,
25
+ DropdownMenuContent,
26
+ DropdownMenuItem,
27
+ DropdownMenuSeparator,
28
+ DropdownMenuTrigger,
29
+ } from "@/components/ui/dropdown-menu";
30
+ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
31
+ import { useServerFn } from "@tanstack/react-start";
32
+ import { toast } from "sonner";
33
+ import {
34
+ updateStoryFn,
35
+ archiveStoryFn,
36
+ unarchiveStoryFn,
37
+ deleteStoryFn,
38
+ } from "@/server/serverFns/story";
39
+ import { PriorityEditor, StateEditor, AssigneeEditor, DateEditor, LabelsEditor } from "../inline-editors";
40
+ import { NameConfirmDialog } from "@/components/shared/name-confirm-dialog";
41
+ import { cn } from "@/lib/utils";
42
+ import { ProjectIconDisplay } from "@/components/project/project-icon";
43
+ import { Badge } from "@/components/ui/badge";
44
+ import { dependencyTypeOption } from "../constants";
45
+ import type { DisplayProperty } from "../display/types";
46
+ import type { StoryPatch } from "../display/group-fields";
47
+ import type {
48
+ StoryListItem,
49
+ UserBrief,
50
+ DependencyEdgeType,
51
+ DependencyGroup,
52
+ } from "../types";
53
+
54
+ /** 行内编辑/归档/删除后回传给面板的补丁载荷(StoryPatch 字段 + 面板控制信号) */
55
+ export type StoryRelationPatch = StoryPatch & {
56
+ archived?: boolean;
57
+ archivedAt?: string | null;
58
+ deleted?: boolean;
59
+ };
60
+
61
+ export interface StoryRefRowProps {
62
+ story: StoryListItem;
63
+ users: UserBrief[];
64
+ labels: { id: string; name: string; color: string }[];
65
+ parentProjectId: string | null;
66
+ projects: { id: string; name: string; icon: string; displayId: string | null }[];
67
+ selected: boolean;
68
+ properties: DisplayProperty[];
69
+ onToggle: (id: string) => void;
70
+ onOpenStory: (id: string) => void;
71
+ onStoryRefresh: (storyId: string, patch: StoryRelationPatch) => void;
72
+ /** 移除关系:子需求=移除子需求,依赖=移除依赖 */
73
+ onRemove: (storyId: string) => void;
74
+ removeMenuLabel: string;
75
+ /** 依赖行类型标签:传 depType + group 时行内渲染类型 Badge */
76
+ dependencyMeta?: { depType: DependencyEdgeType; group: keyof DependencyGroup };
77
+ /** 当前需求编号(tooltip 中以编号替代「我」指代当前需求) */
78
+ currentIdentifier?: string;
79
+ }
80
+
81
+ export function StoryRefRow({
82
+ story,
83
+ users,
84
+ labels,
85
+ parentProjectId,
86
+ projects,
87
+ selected,
88
+ properties,
89
+ onToggle,
90
+ onOpenStory,
91
+ onStoryRefresh,
92
+ onRemove,
93
+ removeMenuLabel,
94
+ dependencyMeta,
95
+ currentIdentifier,
96
+ }: StoryRefRowProps) {
97
+ const { t } = useTranslation("story");
98
+ const [storyState, setStoryState] = React.useState(story);
99
+ React.useEffect(() => {
100
+ setStoryState(story);
101
+ }, [story]);
102
+ const updateStory = useServerFn(updateStoryFn);
103
+ const archiveStory = useServerFn(archiveStoryFn);
104
+ const unarchiveStory = useServerFn(unarchiveStoryFn);
105
+ const deleteStory = useServerFn(deleteStoryFn);
106
+ const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
107
+
108
+ const crossProject = storyState.projectId !== parentProjectId;
109
+ const storyProjectInfo = projects.find((p) => p.id === storyState.projectId) ?? null;
110
+
111
+ const updateField = async (patch: Record<string, any>) => {
112
+ const updated = { ...storyState, ...patch };
113
+ setStoryState(updated);
114
+ try {
115
+ await updateStory({ data: { storyId: story.id, ...patch } });
116
+ onStoryRefresh(story.id, patch);
117
+ } catch (e) {
118
+ setStoryState(story);
119
+ toast.error((e as Error)?.message || t("children.updateFailed"));
120
+ }
121
+ };
122
+
123
+ const currentLabelIds = storyState.labels.map((l) => l.id);
124
+ const overdue = !!(
125
+ storyState.targetDate &&
126
+ storyState.state !== "done" &&
127
+ new Date(storyState.targetDate.slice(0, 10)) < new Date(new Date().toISOString().slice(0, 10))
128
+ );
129
+
130
+ const canArchiveSingle = storyState.state === "done" || storyState.state === "cancelled";
131
+
132
+ const handleArchive = async () => {
133
+ if (!canArchiveSingle) {
134
+ toast.error(t("children.archiveOnlyDone"));
135
+ return;
136
+ }
137
+ try {
138
+ await archiveStory({ data: { storyId: story.id } });
139
+ onStoryRefresh(story.id, { archived: true, archivedAt: new Date().toISOString() });
140
+ toast.success(t("children.archived"));
141
+ } catch (e) {
142
+ toast.error((e as Error)?.message || t("children.archiveFailed"));
143
+ }
144
+ };
145
+
146
+ const handleUnarchive = async () => {
147
+ try {
148
+ await unarchiveStory({ data: { storyId: story.id } });
149
+ onStoryRefresh(story.id, { archived: false, archivedAt: null });
150
+ toast.success(t("children.unarchived"));
151
+ } catch (e) {
152
+ toast.error((e as Error)?.message || t("children.unarchiveFailed"));
153
+ }
154
+ };
155
+
156
+ const handleDelete = async () => {
157
+ try {
158
+ await deleteStory({ data: { storyId: story.id } });
159
+ onStoryRefresh(story.id, { deleted: true });
160
+ toast.success(t("children.deleted"));
161
+ } catch (e) {
162
+ toast.error((e as Error)?.message || t("children.deleteFailed"));
163
+ }
164
+ };
165
+
166
+ const showArchived = !storyState.archived;
167
+
168
+ return (
169
+ <div
170
+ className={`group flex items-center gap-1.5 px-2 py-1.5 text-sm transition-colors ${selected ? "bg-muted/20" : "bg-card hover:bg-accent/40"} border-b border-border/40`}
171
+ >
172
+ <Checkbox
173
+ checked={selected}
174
+ onCheckedChange={() => onToggle(story.id)}
175
+ aria-label={t("children.selectStory", { name: story.name })}
176
+ className={cn(
177
+ "shrink-0 transition-opacity",
178
+ selected ? "opacity-100" : "opacity-0 group-hover:opacity-100",
179
+ )}
180
+ />
181
+ <button
182
+ type="button"
183
+ onClick={() => onOpenStory(storyState.id)}
184
+ className="flex h-8 min-w-0 flex-1 items-center gap-2 text-left"
185
+ >
186
+ {crossProject && storyProjectInfo ? (
187
+ <Tooltip>
188
+ <TooltipTrigger asChild>
189
+ <span
190
+ className="shrink-0"
191
+ style={{ color: "oklch(80.517% 0.20237 152.675 / 0.909)" }}
192
+ >
193
+ <ProjectIconDisplay name={storyProjectInfo.icon} size={14} />
194
+ </span>
195
+ </TooltipTrigger>
196
+ <TooltipContent side="top" sideOffset={6} className="flex flex-col items-start gap-0.5 text-xs">
197
+ <span className="opacity-80">{t("children.crossProject")}</span>
198
+ <span className="font-medium">{storyProjectInfo.name}</span>
199
+ </TooltipContent>
200
+ </Tooltip>
201
+ ) : storyProjectInfo ? (
202
+ <ProjectIconDisplay
203
+ name={storyProjectInfo.icon}
204
+ size={14}
205
+ className="shrink-0 text-muted-foreground"
206
+ />
207
+ ) : null}
208
+ {crossProject && storyProjectInfo ? (
209
+ <Tooltip>
210
+ <TooltipTrigger asChild>
211
+ <span
212
+ className="font-mono text-xs shrink-0"
213
+ style={{ color: "oklch(80.517% 0.20237 152.675 / 0.909)" }}
214
+ >
215
+ {storyState.identifier}
216
+ </span>
217
+ </TooltipTrigger>
218
+ <TooltipContent side="top" sideOffset={6} className="flex flex-col items-start gap-0.5 text-xs">
219
+ <span className="opacity-80">{t("children.crossProject")}</span>
220
+ <span className="font-medium">{storyProjectInfo.name}</span>
221
+ </TooltipContent>
222
+ </Tooltip>
223
+ ) : (
224
+ <span className="font-mono text-xs text-muted-foreground shrink-0">{storyState.identifier}</span>
225
+ )}
226
+ {dependencyMeta && (
227
+ <Tooltip>
228
+ <TooltipTrigger asChild>
229
+ <Badge variant="outline" className="max-w-56 shrink-0 truncate text-[10px] font-normal text-muted-foreground">
230
+ {t(dependencyTypeOption(dependencyMeta.group, dependencyMeta.depType).badgeLabelKey, {
231
+ identifier: storyState.identifier,
232
+ })}
233
+ </Badge>
234
+ </TooltipTrigger>
235
+ <TooltipContent side="top" className="text-xs">
236
+ {t(
237
+ currentIdentifier
238
+ ? dependencyTypeOption(dependencyMeta.group, dependencyMeta.depType).tooltipLabelKey
239
+ : dependencyTypeOption(dependencyMeta.group, dependencyMeta.depType).badgeLabelKey,
240
+ {
241
+ identifier: storyState.identifier,
242
+ me: currentIdentifier,
243
+ },
244
+ )}
245
+ </TooltipContent>
246
+ </Tooltip>
247
+ )}
248
+ <span className="min-w-0 flex-1 truncate font-medium">{storyState.name}</span>
249
+ </button>
250
+ {properties.includes("priority") && (
251
+ <PriorityEditor value={storyState.priority} onChange={(v) => updateField({ priority: v })} variant="button" />
252
+ )}
253
+ {properties.includes("state") && (
254
+ <StateEditor value={storyState.state} onChange={(v) => updateField({ state: v })} variant="button" />
255
+ )}
256
+ {properties.includes("assignee") && (
257
+ <AssigneeEditor assignee={storyState.assignee} users={users} onChange={(v) => updateField({ assigneeId: v })} variant="button" />
258
+ )}
259
+ {properties.includes("startDate") && (
260
+ <DateEditor value={storyState.startDate} onChange={(v) => updateField({ startDate: v })} label={t("children.startDate")} icon={CalendarPlus2} />
261
+ )}
262
+ {properties.includes("dueDate") && (
263
+ <DateEditor value={storyState.targetDate} onChange={(v) => updateField({ targetDate: v })} label={t("children.dueDate")} icon={CalendarCheck} overdue={overdue} />
264
+ )}
265
+ {properties.includes("labels") && (
266
+ <LabelsEditor labelIds={currentLabelIds} labels={labels} onChange={(v) => updateField({ labelIds: v })} variant="button" />
267
+ )}
268
+ <DropdownMenu>
269
+ <DropdownMenuTrigger asChild>
270
+ <Button variant="ghost" size="icon" className="size-7 p-0 ml-auto border border-border/60 rounded hover:bg-muted">
271
+ <MoreHorizontal className="size-4" />
272
+ </Button>
273
+ </DropdownMenuTrigger>
274
+ <DropdownMenuContent align="end" className="w-40" sideOffset={4}>
275
+ <DropdownMenuItem onClick={() => onRemove(story.id)}>
276
+ <Waypoints className="mr-2 size-4" />
277
+ {removeMenuLabel}
278
+ </DropdownMenuItem>
279
+ <DropdownMenuSeparator />
280
+ {showArchived ? (
281
+ <>
282
+ {canArchiveSingle && (
283
+ <>
284
+ <DropdownMenuItem onClick={handleArchive}>
285
+ <Archive className="mr-2 size-4" />
286
+ {t("children.archive")}
287
+ </DropdownMenuItem>
288
+ <DropdownMenuSeparator />
289
+ </>
290
+ )}
291
+ <DropdownMenuItem
292
+ onClick={() => setDeleteDialogOpen(true)}
293
+ className="text-destructive focus:text-destructive"
294
+ >
295
+ <Trash2 className="mr-2 size-4" />
296
+ {t("children.delete")}
297
+ </DropdownMenuItem>
298
+ </>
299
+ ) : (
300
+ <DropdownMenuItem onClick={handleUnarchive}>
301
+ <Archive className="mr-2 size-4" />
302
+ {t("children.unarchive")}
303
+ </DropdownMenuItem>
304
+ )}
305
+ </DropdownMenuContent>
306
+ </DropdownMenu>
307
+
308
+ <NameConfirmDialog
309
+ open={deleteDialogOpen}
310
+ onOpenChange={setDeleteDialogOpen}
311
+ title={t("children.deleteTitle")}
312
+ description={t("children.deleteDescription")}
313
+ confirmText={t("children.delete")}
314
+ expectedName={storyState.identifier ?? storyState.id ?? ""}
315
+ onConfirm={handleDelete}
316
+ />
317
+ </div>
318
+ );
319
+ }
@@ -170,8 +170,10 @@ export function StoryListView({ stories: initialStories, users: initialUsers, pr
170
170
 
171
171
  const {
172
172
  groups: pagedGroups,
173
+ isLoading: pagedGroupsLoading,
173
174
  refresh: refreshPagedGroups,
174
175
  loadMore: loadGroupMore,
176
+ patchStory: patchPagedStory,
175
177
  } = usePagedStoryGroups({
176
178
  filters,
177
179
  conditions: filterConditions,
@@ -501,7 +503,7 @@ export function StoryListView({ stories: initialStories, users: initialUsers, pr
501
503
  )}
502
504
  {view === "calendar" && <CalendarView stories={stories.items} onCreate={handleCreate} onOpenStory={handleOpenStory} orderBy={displayFilters.orderBy} />}
503
505
  {view === "list" && (
504
- <StoryList groups={pagedGroups} displayFilters={displayFilters} users={users} labels={labels} projects={projects} onRefresh={listRefresh} onOpenStory={handleOpenStory} onCreate={handleCreate} onLoadMoreGroup={loadGroupMore} projectContext={projectContext} projectId={projectId} />
506
+ <StoryList groups={pagedGroups} isLoading={pagedGroupsLoading} displayFilters={displayFilters} users={users} labels={labels} projects={projects} onRefresh={listRefresh} onPatchStory={patchPagedStory} onOpenStory={handleOpenStory} onCreate={handleCreate} onLoadMoreGroup={loadGroupMore} projectContext={projectContext} projectId={projectId} />
505
507
  )}
506
508
  {view === "timeline" && <TimelineView stories={stories.items} onOpenStory={handleOpenStory} orderBy={displayFilters.orderBy} onLoadMore={loadMore} hasMore={hasMore} />}
507
509
  </div>
@@ -217,7 +217,10 @@ export function StoryPanelHeader({
217
217
 
218
218
  {!hidePrevNext && (onPrevious || onNext) && (
219
219
  <>
220
- <Separator orientation="vertical" data-vertical className="h-4 data-vertical:self-center mx-1" />
220
+ {/* 外层 div 限高:Separator 默认 data-[orientation=vertical]:h-full(属性选择器特异性高于单类),直接写 h-4 无法覆盖,会撑满整个 header 高度 */}
221
+ <div className="mx-1 flex h-4 items-center">
222
+ <Separator orientation="vertical" className="h-full" />
223
+ </div>
221
224
  {onPrevious && (
222
225
  <Tooltip>
223
226
  <TooltipTrigger asChild>
@@ -111,9 +111,6 @@ export function serializeStoryDescription(html: string, markdown: string): strin
111
111
  return JSON.stringify({ html, markdown });
112
112
  }
113
113
 
114
- // 依赖类型
115
- export type DependencyType = "blocks" | "blocked_by" | "relates_to";
116
-
117
114
  // Story 简要信息(用于关联关系)
118
115
  export interface StoryBrief {
119
116
  id: string;
@@ -134,21 +131,6 @@ export interface StoryTreeNode {
134
131
  children?: StoryTreeNode[];
135
132
  }
136
133
 
137
- // 依赖项(去重)
138
- export interface DependencyItem {
139
- id: string;
140
- title: string;
141
- status: StoryState;
142
- type: DependencyType;
143
- }
144
-
145
- // 依赖分组
146
- export interface DependencyGroup {
147
- blocking: DependencyItem[];
148
- blockedBy: DependencyItem[];
149
- relatesTo: DependencyItem[];
150
- }
151
-
152
134
  // 链接项
153
135
  export interface StoryLinkItem {
154
136
  id: string;
@@ -163,7 +145,9 @@ export interface StoryAttachmentItem {
163
145
  fileName: string;
164
146
  fileSize: number;
165
147
  mimeType: string;
148
+ description?: string | null;
166
149
  storageUrl: string;
150
+ fileHash?: string | null;
167
151
  }
168
152
 
169
153
  // PRD 简要信息
@@ -176,6 +160,11 @@ export interface PrdBrief {
176
160
  export interface DocBrief {
177
161
  id: string;
178
162
  title: string;
163
+ kbId: string;
164
+ kbName: string;
165
+ kbIcon: string;
166
+ docIcon: string | null;
167
+ parentId: string | null;
179
168
  }
180
169
 
181
170
  // 关联 Tab 数据汇总
@@ -193,3 +182,26 @@ export interface RelationsData {
193
182
  docs: DocBrief[];
194
183
  };
195
184
  }
185
+
186
+ /** 关联附件面板统一数据类型 */
187
+ export type AssociatedItem =
188
+ | { kind: "link"; data: StoryLinkItem }
189
+ | { kind: "attachment"; data: StoryAttachmentItem }
190
+ | { kind: "doc"; data: DocBrief };
191
+
192
+ // ====== 依赖关系(对齐 Plane,新模型) ======
193
+
194
+ // 规范化存储类型:语义统一为「source 领先于 target」
195
+ export type DependencyEdgeType = "BLOCKS" | "STARTS_BEFORE" | "FINISHES_BEFORE";
196
+
197
+ // 依赖行 = 完整需求列表项 + 依赖元信息(支撑内联编辑与分组)
198
+ export interface DependencyRow extends StoryListItem {
199
+ depId: string; // story_dependencies 行 id(移除依赖用)
200
+ depType: DependencyEdgeType;
201
+ }
202
+
203
+ // 双视角分组(RelationsData.dependencies 最终形态,任务 2 切换引用)
204
+ export interface DependencyGroup {
205
+ dependsOn: DependencyRow[]; // 此需求依赖的(对方领先,边 targetId = 我)
206
+ dependents: DependencyRow[]; // 依赖此需求的(我领先,边 sourceId = 我)
207
+ }
@@ -17,7 +17,7 @@
17
17
 
18
18
  import { useState } from "react";
19
19
  import { useTranslation } from "react-i18next";
20
- import { ArrowUp, ArrowDown, Archive, Trash2, BrushCleaning, CalendarCheck, CalendarPlus2, GitCompareArrows } from "lucide-react";
20
+ import { ArrowUp, ArrowDown, Archive, Trash2, BrushCleaning, CalendarCheck, CalendarPlus2, GitCompareArrows, Waypoints } from "lucide-react";
21
21
  import { Checkbox } from "@/components/ui/checkbox";
22
22
  import { Button } from "@/components/ui/button";
23
23
  import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -53,6 +53,8 @@ export interface BatchToolbarProps {
53
53
  onMoveDown: () => void;
54
54
  onArchive: (storyIds: string[]) => void;
55
55
  onDelete: (storyIds: string[]) => void;
56
+ /** 可选:批量移除依赖关系(依赖面板传入时显示按钮;入参为需求 id) */
57
+ onRemoveRelations?: (storyIds: string[]) => void;
56
58
  onBulkUpdate: (patch: StoryPatch) => void;
57
59
  onClearSelection: () => void;
58
60
  onTransfer: (updatedStories?: Array<{ id: string; identifier: string; projectId: string; parentId: string | null }>) => void;
@@ -76,6 +78,7 @@ export function BatchToolbar({
76
78
  onMoveDown,
77
79
  onArchive,
78
80
  onDelete,
81
+ onRemoveRelations,
79
82
  onBulkUpdate,
80
83
  onClearSelection,
81
84
  onTransfer,
@@ -141,6 +144,14 @@ export function BatchToolbar({
141
144
  </Button>
142
145
  }
143
146
  />
147
+ {onRemoveRelations && (
148
+ <ToolbarIconButton
149
+ label={t("dependency.remove")}
150
+ icon={Waypoints}
151
+ disabled={busy}
152
+ onClick={() => onRemoveRelations(stories.map((s) => s.id))}
153
+ />
154
+ )}
144
155
  <ToolbarIconButton label={t("batchToolbar.archive")} icon={Archive} disabled={busy || !canArchive} onClick={() => setArchiveOpen(true)} />
145
156
  <ToolbarIconButton label={t("batchToolbar.delete")} icon={Trash2} disabled={busy} onClick={() => setDeleteOpen(true)} />
146
157
  <div className="mx-1 h-4 w-px bg-border" />
@@ -36,8 +36,9 @@ import { StoryListRow } from "./row";
36
36
  import { BatchToolbar } from "./batch-toolbar";
37
37
  import { computeMove, canMove } from "./move";
38
38
  import { computeGroupCheckState, toggleGroupSelection } from "./group-select";
39
- import { GROUP_FIELD, toGroupFieldPatch, type GroupField } from "../../display/group-fields";
39
+ import { GROUP_FIELD, toGroupFieldPatch, type GroupField, type StoryPatch } from "../../display/group-fields";
40
40
  import type { GroupKey } from "../../display/types";
41
+ import { WavePhysicsLoader } from "@/components/shared/wave-physics-loader";
41
42
 
42
43
  const HEADER_ROW_HEIGHT = 36;
43
44
  const STORY_ROW_HEIGHT = 44;
@@ -45,11 +46,15 @@ const OVERSCAN = 8;
45
46
 
46
47
  interface StoryListProps {
47
48
  groups: PagedGroupNode[];
49
+ /** 首屏加载中:显示骨架行而非「暂无需求」空态,避免加载期间闪烁空态文案 */
50
+ isLoading?: boolean;
48
51
  displayFilters: DisplayOptions;
49
52
  users: UserBrief[];
50
53
  labels: { id: string; name: string; color: string }[];
51
54
  projects: { id: string; name: string; displayId?: string | null; icon: string }[];
52
55
  onRefresh: () => void;
56
+ /** 行内编辑乐观原地更新(替代整表 refresh,保持未触发规则的行位置不变) */
57
+ onPatchStory: (storyId: string, patch: StoryPatch) => void;
53
58
  onOpenStory: (id: string) => void;
54
59
  onCreate?: (startDate?: string, state?: string) => void;
55
60
  onLoadMoreGroup?: (groupKey: string, subKey?: string) => void;
@@ -57,7 +62,7 @@ interface StoryListProps {
57
62
  projectId?: string;
58
63
  }
59
64
 
60
- export function StoryList({ groups, displayFilters, users, labels, projects, onRefresh, onOpenStory, onCreate, onLoadMoreGroup, projectContext, projectId }: StoryListProps) {
65
+ export function StoryList({ groups, isLoading = false, displayFilters, users, labels, projects, onRefresh, onPatchStory, onOpenStory, onCreate, onLoadMoreGroup, projectContext, projectId }: StoryListProps) {
61
66
  const { t } = useTranslation("story");
62
67
  const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
63
68
  const { groupBy, subGroupBy, properties } = displayFilters;
@@ -399,8 +404,8 @@ await bulkUpdateStoriesFn({ data: { storyIds: [...selectedIds], patch } });
399
404
  labels={labels}
400
405
  properties={properties}
401
406
  onOpenStory={onOpenStory}
402
- onRefresh={onRefresh}
403
- selected={selectedIds.has(row.story.id)}
407
+ onPatchStory={onPatchStory}
408
+ selected={selectedIds.has(row.story.id)}
404
409
  onToggle={toggleStory}
405
410
  />
406
411
  </div>
@@ -439,7 +444,13 @@ await bulkUpdateStoriesFn({ data: { storyIds: [...selectedIds], patch } });
439
444
  <div style={{ height: totalHeight, position: "relative" }}>
440
445
  {virtualRows.map((virtualRow) => renderVirtualRow(virtualRow))}
441
446
  </div>
442
- {groups.length === 0 && (
447
+ {isLoading && groups.length === 0 && (
448
+ <div className="flex flex-col items-center gap-3 py-12" aria-busy="true">
449
+ <WavePhysicsLoader />
450
+ <p className="text-sm text-muted-foreground">{t("group.loadingData")}</p>
451
+ </div>
452
+ )}
453
+ {!isLoading && groups.length === 0 && (
443
454
  <div className="flex h-40 items-center justify-center text-sm text-muted-foreground">
444
455
  {t("table.empty")}
445
456
  </div>
@@ -39,12 +39,13 @@ interface StoryListRowProps {
39
39
  labels: { id: string; name: string; color: string }[];
40
40
  properties: DisplayProperty[];
41
41
  onOpenStory: (id: string) => void;
42
- onRefresh: () => void;
42
+ /** 行内编辑保存成功后的乐观原地更新回调(未触发分组/排序规则时保持位置不变) */
43
+ onPatchStory: (storyId: string, patch: StoryPatch) => void;
43
44
  selected: boolean;
44
45
  onToggle: (id: string) => void;
45
46
  }
46
47
 
47
- export const StoryListRow = memo(function StoryListRow({ story, group, indent, isChild, users, labels, properties, onOpenStory, onRefresh, selected, onToggle }: StoryListRowProps) {
48
+ export const StoryListRow = memo(function StoryListRow({ story, group, indent, isChild, users, labels, properties, onOpenStory, onPatchStory, selected, onToggle }: StoryListRowProps) {
48
49
  const { t } = useTranslation("story");
49
50
  const updateStory = useServerFn(updateStoryFn);
50
51
 
@@ -52,12 +53,12 @@ export const StoryListRow = memo(function StoryListRow({ story, group, indent, i
52
53
  async (patch: StoryPatch) => {
53
54
  try {
54
55
  await updateStory({ data: { storyId: story.id, ...patch } });
55
- onRefresh();
56
+ onPatchStory(story.id, patch);
56
57
  } catch (e) {
57
58
  toast.error((e as Error)?.message || t("row.updateFailed"));
58
59
  }
59
60
  },
60
- [story.id, updateStory, onRefresh, t],
61
+ [story.id, updateStory, onPatchStory, t],
61
62
  );
62
63
 
63
64
  const setPriority = useCallback((v: StoryPriority) => updateField({ priority: v }), [updateField]);
@@ -34,7 +34,7 @@ function Separator({
34
34
  decorative={decorative}
35
35
  orientation={orientation}
36
36
  className={cn(
37
- "shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
37
+ "shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
38
38
  className
39
39
  )}
40
40
  {...props}
@@ -8,7 +8,9 @@ import type { NotificationSseValue, NotificationItem } from '@allbluecn/shared'
8
8
  import { isInDndWindow } from '@allbluecn/shared'
9
9
  import { getUnreadCountFn, getNotificationPreferencesFn } from '@/server/serverFns/notifications'
10
10
 
11
+ export const NOTIFICATION_ROOT_KEY = ['notifications'] as const
11
12
  export const NOTIFICATION_LIST_KEY = ['notifications', 'list'] as const
13
+ export const NOTIFICATION_POPOVER_KEY = ['notifications', 'popover'] as const
12
14
  export const NOTIFICATION_UNREAD_KEY = ['notifications', 'unread'] as const
13
15
  export const NOTIFICATION_PREFERENCES_KEY = ['notifications', 'preferences'] as const
14
16
 
@@ -92,7 +94,8 @@ export function useNotifications(options?: { toastEnabled?: boolean }) {
92
94
  const count = (old as { count?: number } | undefined)?.count ?? 0
93
95
  return { count: count + 1 }
94
96
  })
95
- queryClient.invalidateQueries({ queryKey: NOTIFICATION_LIST_KEY })
97
+ // 通知到达:所有通知相关查询(列表/详情页分页/popover)标记过期
98
+ queryClient.invalidateQueries({ queryKey: NOTIFICATION_ROOT_KEY })
96
99
  if (toastEnabled) {
97
100
  const prefs = queryClient.getQueryData<{ dndEnabled: boolean; dndStart: string; dndEnd: string }>(NOTIFICATION_PREFERENCES_KEY)
98
101
  const now = new Date()
@@ -16,7 +16,7 @@
16
16
  // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
 
18
18
  import { describe, it, expect } from 'vitest'
19
- import { formatDate, formatRelative, resolveTimezone } from '../datetime'
19
+ import { formatDate, formatRelative, resolveTimezone, formatTimestamp } from '../datetime'
20
20
 
21
21
  describe('resolveTimezone', () => {
22
22
  it('项目时区优先', () => {
@@ -71,4 +71,19 @@ describe('formatRelative', () => {
71
71
  const result = formatRelative(date, 'Asia/Shanghai', now)
72
72
  expect(result).toMatch(/2026-06-01/)
73
73
  })
74
+ })
75
+
76
+ describe('formatTimestamp', () => {
77
+ it('固定 Asia/Shanghai 输出,与运行环境 locale/时区无关', () => {
78
+ expect(formatTimestamp('2026-07-31T08:00:00.000Z')).toBe('2026-07-31 16:00')
79
+ })
80
+ it('接受 Date 对象', () => {
81
+ expect(formatTimestamp(new Date('2026-07-31T08:00:00.000Z'))).toBe('2026-07-31 16:00')
82
+ })
83
+ it('自定义格式(监控历史秒级精度)', () => {
84
+ expect(formatTimestamp('2026-07-31T08:00:05.000Z', 'yyyy-MM-dd HH:mm:ss')).toBe('2026-07-31 16:00:05')
85
+ })
86
+ it('无效输入返回占位符不抛错', () => {
87
+ expect(formatTimestamp('not-a-date')).toBe('-')
88
+ })
74
89
  })