@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
@@ -17,30 +17,95 @@
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";
22
+ import { LinkIcon, Paperclip, BookOpenText } from "lucide-react";
23
+ import { Button } from "@/components/ui/button";
20
24
  import { StoryDetailChildrenButton, StoryDetailChildrenPanel } from "./story-detail-toolbar/children";
21
25
  import { StoryDetailDependencies } from "./story-detail-toolbar/dependencies";
22
- import { StoryDetailLinks } from "./story-detail-toolbar/links";
23
- import { StoryDetailAttachments } from "./story-detail-toolbar/attachments";
24
- import { StoryDetailKnowledge } from "./story-detail-toolbar/knowledge";
26
+ import { StoryDetailDependencyPanel } from "./story-detail-toolbar/dependency-panel";
25
27
  import { StoryDetailCategories } from "./story-detail-toolbar/categories";
28
+ import {
29
+ AssociatedAttachmentsPanel,
30
+ type AssociatedDialogKind,
31
+ } from "./story-detail-toolbar/associated-attachments-panel";
32
+ import { LinkManageDialog } from "../relations/link-manage-dialog";
33
+ import { AttachmentManageDialog } from "../relations/attachment-manage-dialog";
34
+ import { KnowledgeSelectDialog } from "../relations/knowledge-select-dialog";
35
+ import { getLinksFn, getAttachmentsFn, getLinkedDocsFn } from "@/server/serverFns/story";
26
36
  import { StorySelectDialog } from "../inline-editors/story-select-dialog";
27
37
  import { CreateStoryDialog } from "../create-story-dialog";
28
- import { listChildStoriesFn } from "@/server/serverFns/story";
38
+ import { addDependenciesFn, getDependenciesFn, listChildStoriesFn } from "@/server/serverFns/story";
39
+ import { DEPENDENCY_TYPE_OPTIONS, toDependencyEdge, type DependencyTypeOption } from "../constants";
29
40
  import type { RelationsData, UserBrief } from "../types";
30
41
 
31
42
  interface StoryDetailToolbarProps {
32
43
  storyId: string;
44
+ /** 当前需求编号(依赖 tooltip 文案用) */
45
+ storyIdentifier?: string;
33
46
  projectId: string | null;
34
47
  data: RelationsData;
35
48
  onRefresh: (next: (prev: RelationsData) => RelationsData) => void;
49
+ /** 关联类操作产生的活动即时追加到时间线 */
50
+ onAppendActivities?: (items: import("../types").ActivityItem[]) => void;
36
51
  users?: UserBrief[];
37
52
  projects?: { id: string; name: string; icon: string; displayId: string | null }[];
38
53
  labels?: { id: string; name: string; color: string }[];
39
54
  }
40
55
 
41
- export function StoryDetailToolbar({ storyId, projectId, data, onRefresh, users, projects, labels }: StoryDetailToolbarProps) {
56
+ export function StoryDetailToolbar({ storyId, storyIdentifier, projectId, data, onRefresh, onAppendActivities, users, projects, labels }: StoryDetailToolbarProps) {
42
57
  const { t } = useTranslation("story");
43
58
  const [actionOpen, setActionOpen] = React.useState<"create" | "select" | null>(null);
59
+ const [dependencyDialogOpen, setDependencyDialogOpen] = React.useState(false);
60
+ const [assocDialogOpen, setAssocDialogOpen] = React.useState<AssociatedDialogKind | null>(null);
61
+ const [linkInitialEdit, setLinkInitialEdit] = React.useState<import("../types").StoryLinkItem | null>(null);
62
+ const [editAttachment, setEditAttachment] = React.useState<import("../types").StoryAttachmentItem | null>(null);
63
+ const [dependencyType, setDependencyType] = React.useState<DependencyTypeOption["key"] | undefined>(undefined);
64
+ const addDependencies = useServerFn(addDependenciesFn);
65
+
66
+ const dependencyExcludeIds = React.useMemo(
67
+ () => [
68
+ storyId,
69
+ ...data.dependencies.dependsOn.map((r) => r.id),
70
+ ...data.dependencies.dependents.map((r) => r.id),
71
+ ],
72
+ [storyId, data.dependencies],
73
+ );
74
+
75
+ const refreshDependencies = async () => {
76
+ try {
77
+ const dependencies = await getDependenciesFn({ data: { storyId } });
78
+ onRefresh((prev) => ({ ...prev, dependencies }));
79
+ } catch {
80
+ // 静默兜底:保留现有面板数据
81
+ }
82
+ };
83
+
84
+ const handleDependencySubmit = async (ids: string | string[]) => {
85
+ const option = DEPENDENCY_TYPE_OPTIONS.find((o) => o.key === dependencyType);
86
+ if (!option) return;
87
+ const targetIds = Array.isArray(ids) ? ids : [ids];
88
+ const items = targetIds.map((targetId) => toDependencyEdge(option, storyId, targetId));
89
+ try {
90
+ const res = await addDependencies({ data: { items } });
91
+ if (res.created.length > 0) {
92
+ toast.success(t("dependency.added", { count: res.created.length }));
93
+ const mine = res.activities
94
+ .filter((a) => a.storyId === storyId)
95
+ .map(({ storyId: _sid, ...rest }) => rest);
96
+ if (mine.length > 0) onAppendActivities?.(mine);
97
+ }
98
+ if (res.failed.length > 0) {
99
+ const reasons = [...new Set(res.failed.map((f) => t(`dependency.errors.${f.reason}`)))].join("、");
100
+ toast.warning(t("dependency.addPartialFailed", { failed: res.failed.length, reasons }));
101
+ } else {
102
+ setDependencyDialogOpen(false);
103
+ }
104
+ refreshDependencies();
105
+ } catch (e) {
106
+ toast.error((e as Error)?.message || t("dependency.addFailed"));
107
+ }
108
+ };
44
109
 
45
110
  const handleChildAction = (action: "create" | "select") => {
46
111
  setActionOpen(action);
@@ -68,9 +133,46 @@ export function StoryDetailToolbar({ storyId, projectId, data, onRefresh, users,
68
133
  }
69
134
  };
70
135
 
71
- const handleCreateSuccess = () => {
136
+ const handleCreateSuccess = (
137
+ _story: { id: string; name: string; identifier?: string },
138
+ activities?: import("../types").ActivityItem[],
139
+ ) => {
72
140
  setActionOpen(null);
73
141
  refreshChildren();
142
+ if (activities && activities.length > 0) onAppendActivities?.(activities);
143
+ };
144
+
145
+ const refreshLinks = async () => {
146
+ try {
147
+ const links = await getLinksFn({ data: { storyId } });
148
+ onRefresh((prev) => ({ ...prev, links }));
149
+ } catch {
150
+ // 静默兜底
151
+ }
152
+ };
153
+
154
+ const refreshAttachments = async () => {
155
+ try {
156
+ const attachments = await getAttachmentsFn({ data: { storyId } });
157
+ onRefresh((prev) => ({ ...prev, attachments }));
158
+ } catch {
159
+ // 静默兜底
160
+ }
161
+ };
162
+
163
+ const refreshKnowledge = async () => {
164
+ try {
165
+ const docs = await getLinkedDocsFn({ data: { storyId } });
166
+ onRefresh((prev) => ({ ...prev, knowledge: { ...prev.knowledge, docs } }));
167
+ } catch {
168
+ // 静默兜底
169
+ }
170
+ };
171
+
172
+ const handleOpenAssocDialog = (kind: AssociatedDialogKind, editLink?: import("../types").StoryLinkItem, editAttachmentItem?: import("../types").StoryAttachmentItem) => {
173
+ setLinkInitialEdit(editLink ?? null);
174
+ setEditAttachment(editAttachmentItem ?? null);
175
+ setAssocDialogOpen(kind);
74
176
  };
75
177
 
76
178
  return (
@@ -80,13 +182,52 @@ export function StoryDetailToolbar({ storyId, projectId, data, onRefresh, users,
80
182
 
81
183
  <span className="mx-0.5 h-4 w-px shrink-0 bg-muted-foreground/20" />
82
184
 
83
- <StoryDetailDependencies storyId={storyId} data={data} onRefresh={onRefresh} />
185
+ <StoryDetailDependencies onOpenAdd={() => setDependencyDialogOpen(true)} />
84
186
 
85
187
  <span className="mx-0.5 h-4 w-px shrink-0 bg-muted-foreground/20" />
86
188
 
87
- <StoryDetailLinks storyId={storyId} data={data} onRefresh={onRefresh} />
88
- <StoryDetailAttachments storyId={storyId} data={data} onRefresh={onRefresh} />
89
- <StoryDetailKnowledge storyId={storyId} data={data} onRefresh={onRefresh} />
189
+ <Button
190
+ variant="ghost"
191
+ size="sm"
192
+ className="h-7 gap-1.5 px-2.5 text-xs font-normal hover:bg-muted rounded-md"
193
+ onClick={() => handleOpenAssocDialog("link")}
194
+ >
195
+ <LinkIcon className="size-3.5 text-muted-foreground" />
196
+ {t("linkDialog.button")}
197
+ {data.links.length > 0 && (
198
+ <span className="ml-0.5 inline-flex min-w-[18px] items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-primary-foreground">
199
+ {data.links.length}
200
+ </span>
201
+ )}
202
+ </Button>
203
+ <Button
204
+ variant="ghost"
205
+ size="sm"
206
+ className="h-7 gap-1.5 px-2.5 text-xs font-normal hover:bg-muted rounded-md"
207
+ onClick={() => handleOpenAssocDialog("attachment")}
208
+ >
209
+ <Paperclip className="size-3.5 text-muted-foreground" />
210
+ {t("attachmentDialog.button")}
211
+ {data.attachments.length > 0 && (
212
+ <span className="ml-0.5 inline-flex min-w-[18px] items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-primary-foreground">
213
+ {data.attachments.length}
214
+ </span>
215
+ )}
216
+ </Button>
217
+ <Button
218
+ variant="ghost"
219
+ size="sm"
220
+ className="h-7 gap-1.5 px-2.5 text-xs font-normal hover:bg-muted rounded-md"
221
+ onClick={() => handleOpenAssocDialog("knowledge")}
222
+ >
223
+ <BookOpenText className="size-3.5 text-muted-foreground" />
224
+ {t("knowledgeDialog.button")}
225
+ {data.knowledge.docs.length > 0 && (
226
+ <span className="ml-0.5 inline-flex min-w-[18px] items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-primary-foreground">
227
+ {data.knowledge.docs.length}
228
+ </span>
229
+ )}
230
+ </Button>
90
231
 
91
232
  <span className="mx-0.5 h-4 w-px shrink-0 bg-muted-foreground/20" />
92
233
 
@@ -105,6 +246,26 @@ export function StoryDetailToolbar({ storyId, projectId, data, onRefresh, users,
105
246
  onAction={handleChildAction}
106
247
  />
107
248
 
249
+ <StoryDetailDependencyPanel
250
+ storyId={storyId}
251
+ storyIdentifier={storyIdentifier}
252
+ projectId={projectId}
253
+ data={data.dependencies}
254
+ onRefresh={onRefresh}
255
+ refreshDependencies={refreshDependencies}
256
+ users={users}
257
+ projects={projects}
258
+ labels={labels}
259
+ onOpenAdd={() => setDependencyDialogOpen(true)}
260
+ />
261
+
262
+ <AssociatedAttachmentsPanel
263
+ storyId={storyId}
264
+ data={data}
265
+ onRefresh={onRefresh}
266
+ onOpenDialog={handleOpenAssocDialog}
267
+ />
268
+
108
269
  <StorySelectDialog
109
270
  open={actionOpen === "select"}
110
271
  onOpenChange={(open) => {
@@ -122,6 +283,25 @@ export function StoryDetailToolbar({ storyId, projectId, data, onRefresh, users,
122
283
  }}
123
284
  />
124
285
 
286
+ <StorySelectDialog
287
+ open={dependencyDialogOpen}
288
+ onOpenChange={(open) => {
289
+ if (!open) setDependencyDialogOpen(false);
290
+ }}
291
+ storyId={storyId}
292
+ projects={projects ?? []}
293
+ initialProjectId={projectId ?? undefined}
294
+ mode="dependency"
295
+ skipTransfer
296
+ title={t("dependency.add")}
297
+ excludeIds={dependencyExcludeIds}
298
+ dependencyType={dependencyType}
299
+ onDependencyTypeChange={setDependencyType}
300
+ onSelected={(ids) => {
301
+ void handleDependencySubmit(ids);
302
+ }}
303
+ />
304
+
125
305
  <CreateStoryDialog
126
306
  open={actionOpen === "create"}
127
307
  onOpenChange={(open) => {
@@ -135,6 +315,53 @@ export function StoryDetailToolbar({ storyId, projectId, data, onRefresh, users,
135
315
  initialProjectId={projectId ?? undefined}
136
316
  onSuccess={handleCreateSuccess}
137
317
  />
318
+
319
+ <LinkManageDialog
320
+ open={assocDialogOpen === "link"}
321
+ onOpenChange={(v) => {
322
+ if (!v) {
323
+ setAssocDialogOpen(null);
324
+ setLinkInitialEdit(null);
325
+ }
326
+ }}
327
+ storyId={storyId}
328
+ data={data.links}
329
+ initialEdit={linkInitialEdit}
330
+ onActivity={(a) => onAppendActivities?.([a])}
331
+ onUpdate={(links) => {
332
+ onRefresh((prev) => ({ ...prev, links }));
333
+ void refreshLinks();
334
+ }}
335
+ />
336
+
337
+ <AttachmentManageDialog
338
+ open={assocDialogOpen === "attachment"}
339
+ onOpenChange={(v) => {
340
+ if (!v) { setAssocDialogOpen(null); setEditAttachment(null); }
341
+ }}
342
+ storyId={storyId}
343
+ data={data.attachments}
344
+ editItem={editAttachment}
345
+ onActivity={(a) => onAppendActivities?.([a])}
346
+ onUpdate={(attachments) => {
347
+ onRefresh((prev) => ({ ...prev, attachments }));
348
+ void refreshAttachments();
349
+ }}
350
+ />
351
+
352
+ <KnowledgeSelectDialog
353
+ open={assocDialogOpen === "knowledge"}
354
+ onOpenChange={(v) => {
355
+ if (!v) setAssocDialogOpen(null);
356
+ }}
357
+ storyId={storyId}
358
+ linkedDocs={data.knowledge.docs}
359
+ onActivity={(a) => onAppendActivities?.([a])}
360
+ onUpdate={(docs) => {
361
+ onRefresh((prev) => ({ ...prev, knowledge: { ...prev.knowledge, docs } }));
362
+ void refreshKnowledge();
363
+ }}
364
+ />
138
365
  </div>
139
366
  );
140
367
  }
@@ -39,6 +39,10 @@ const makeStory = (over: Partial<StoryListItem> & { id: string }): StoryListItem
39
39
  ...over,
40
40
  });
41
41
 
42
+ const makeUser = (id: string): PagedStoriesArgs["users"][number] => ({
43
+ id, username: `user-${id}`, email: `${id}@test.dev`, avatarConfig: null, bgShape: null, bgColor: null,
44
+ });
45
+
42
46
  const makeArgs = (over: Partial<PagedStoriesArgs> = {}): PagedStoriesArgs => ({
43
47
  filters: { projectId: undefined, search: undefined },
44
48
  conditions: [],
@@ -46,6 +50,7 @@ const makeArgs = (over: Partial<PagedStoriesArgs> = {}): PagedStoriesArgs => ({
46
50
  users: [],
47
51
  labels: [],
48
52
  projects: [],
53
+ minLoadingMs: 0,
49
54
  ...over,
50
55
  });
51
56
 
@@ -71,10 +76,40 @@ describe("usePagedStoryGroups", () => {
71
76
  data: expect.objectContaining({ archived: false, groupBy: "state" }),
72
77
  });
73
78
  expect(h.listAllGroupsItemsFn.mock.calls[0][0].data).toEqual(
74
- expect.objectContaining({ groupBy: "state", groupCount: 2, skip: 0, take: 100 }),
79
+ expect.objectContaining({ groupBy: "state", groupCount: 5, skip: 0, take: 100 }),
75
80
  );
76
81
  });
77
82
 
83
+ it("初始加载并行拉取:组 keys 与首屏 items 同时请求(1 个 RTT)", async () => {
84
+ h.listStoryGroupsFn.mockResolvedValue({ groups: [{ key: "todo", total: 1 }, { key: "done", total: 1 }] });
85
+ h.listAllGroupsItemsFn.mockResolvedValue({
86
+ grouped: { todo: [makeStory({ id: "a", state: "todo" })], done: [makeStory({ id: "b", state: "done" })] },
87
+ });
88
+
89
+ const { result } = renderHook(() => usePagedStoryGroups(makeArgs()));
90
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
91
+
92
+ // 组 keys 请求与 items 请求都已发出(并行)
93
+ expect(h.listStoryGroupsFn).toHaveBeenCalledTimes(1);
94
+ expect(h.listAllGroupsItemsFn).toHaveBeenCalledTimes(1);
95
+ // items 直接按组 keys 组装,无需第二次请求
96
+ expect(result.current.groups[0].items.map((s) => s.id)).toEqual(["a"]);
97
+ expect(result.current.groups[1].items.map((s) => s.id)).toEqual(["b"]);
98
+ });
99
+
100
+ it("数据先于最短展示时间到达时,骨架保持到 minLoadingMs 后才切换", async () => {
101
+ h.listStoryGroupsFn.mockResolvedValue({ groups: [{ key: "todo", total: 1 }] });
102
+ h.listAllGroupsItemsFn.mockResolvedValue({ grouped: { todo: [makeStory({ id: "a" })] } });
103
+
104
+ const { result } = renderHook(() => usePagedStoryGroups(makeArgs({ minLoadingMs: 200 })));
105
+ expect(result.current.isLoading).toBe(true);
106
+ // 数据已返回但骨架仍在最短展示窗口内(150ms < 200ms)
107
+ await new Promise((r) => setTimeout(r, 150));
108
+ expect(result.current.isLoading).toBe(true);
109
+ await waitFor(() => expect(result.current.isLoading).toBe(false), { timeout: 2000 });
110
+ expect(result.current.groups[0].items.map((s) => s.id)).toEqual(["a"]);
111
+ });
112
+
78
113
  it("loadMore 追加下一页并去重、更新 total/hasMore", async () => {
79
114
  h.listStoryGroupsFn.mockResolvedValue({ groups: [{ key: "todo", total: 3 }] });
80
115
  h.listAllGroupsItemsFn.mockResolvedValue({ grouped: { todo: [makeStory({ id: "a" })] } });
@@ -270,4 +305,120 @@ describe("usePagedStoryGroups", () => {
270
305
  expect.objectContaining({ archived: true, projectId: undefined, search: "foo" }),
271
306
  );
272
307
  });
308
+
309
+ describe("patchStory 乐观原地更新", () => {
310
+ const setup = async (
311
+ display: Partial<typeof DEFAULT_DISPLAY_OPTIONS> = {},
312
+ opts: { items?: StoryListItem[]; users?: PagedStoriesArgs["users"] } = {},
313
+ ) => {
314
+ // items 顺序模拟服务端已排序的返回(客户端不重排首屏数据)
315
+ const items = opts.items ?? [
316
+ makeStory({ id: "a", priority: "low", createdAt: "2024-01-01" }),
317
+ makeStory({ id: "b", priority: "high", createdAt: "2024-01-02" }),
318
+ ];
319
+ h.listStoryGroupsFn.mockResolvedValue({ groups: [{ key: "todo", total: items.length }] });
320
+ h.listAllGroupsItemsFn.mockResolvedValue({ grouped: { todo: items } });
321
+ const { result } = renderHook(() =>
322
+ usePagedStoryGroups(
323
+ makeArgs({ display: { ...DEFAULT_DISPLAY_OPTIONS, ...display }, users: opts.users ?? [] }),
324
+ ),
325
+ );
326
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
327
+ return result;
328
+ };
329
+
330
+ it("未触发分组/排序规则:原位替换,位置与分组不变", async () => {
331
+ const result = await setup({}, { users: [makeUser("u9")] });
332
+ const callsBefore = h.listStoryGroupsFn.mock.calls.length;
333
+
334
+ act(() =>
335
+ result.current.patchStory("a", { assigneeId: "u9" }),
336
+ );
337
+
338
+ const todo = result.current.groups[0];
339
+ expect(todo.items.map((s) => s.id)).toEqual(["a", "b"]); // 位置不变
340
+ expect(todo.items[0].assignee?.id).toBe("u9"); // 值已乐观更新
341
+ expect(h.listStoryGroupsFn.mock.calls.length).toBe(callsBefore); // 未触发 refresh
342
+ });
343
+
344
+ it("仅触发排序规则:组内重插,分组不变", async () => {
345
+ // 服务端 priority desc 已排序:b(high) 在前,a(low) 在后
346
+ const result = await setup(
347
+ { orderBy: "priority", orderDir: "desc" },
348
+ {
349
+ items: [
350
+ makeStory({ id: "b", priority: "high" }),
351
+ makeStory({ id: "a", priority: "low" }),
352
+ ],
353
+ },
354
+ );
355
+ expect(result.current.groups[0].items.map((s) => s.id)).toEqual(["b", "a"]);
356
+
357
+ act(() => result.current.patchStory("a", { priority: "urgent" }));
358
+
359
+ expect(result.current.groups[0].key).toBe("todo"); // 分组不变
360
+ expect(result.current.groups[0].items.map((s) => s.id)).toEqual(["a", "b"]);
361
+ expect(result.current.groups[0].items[0].priority).toBe("urgent");
362
+ });
363
+
364
+ it("触发分组规则:回退全量 refresh", async () => {
365
+ const result = await setup();
366
+
367
+ act(() => result.current.patchStory("a", { state: "done" }));
368
+
369
+ expect(h.listStoryGroupsFn.mock.calls.length).toBeGreaterThan(1); // refresh 触发重新加载
370
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
371
+ });
372
+
373
+ it("排序=updated 时任意字段编辑触发组内重插(updatedAt 变最新)", async () => {
374
+ // 服务端 updatedAt desc 已排序:b(01-02) 在前,a(01-01) 在后
375
+ const result = await setup(
376
+ { orderBy: "updated", orderDir: "desc" },
377
+ {
378
+ items: [
379
+ makeStory({ id: "b", updatedAt: "2024-01-02" }),
380
+ makeStory({ id: "a", updatedAt: "2024-01-01" }),
381
+ ],
382
+ },
383
+ );
384
+ expect(result.current.groups[0].items.map((s) => s.id)).toEqual(["b", "a"]);
385
+
386
+ act(() => result.current.patchStory("a", { assigneeId: null }));
387
+
388
+ expect(result.current.groups[0].items.map((s) => s.id)).toEqual(["a", "b"]); // a 的 updatedAt 变最新
389
+ });
390
+
391
+ it("labels 分组维度被编辑时回退 refresh", async () => {
392
+ const result = await setup({ groupBy: "labels" });
393
+ const callsBefore = h.listStoryGroupsFn.mock.calls.length;
394
+
395
+ act(() => result.current.patchStory("a", { labelIds: ["l1"] }));
396
+
397
+ expect(h.listStoryGroupsFn.mock.calls.length).toBeGreaterThan(callsBefore);
398
+ });
399
+
400
+ it("子分组内未触发规则:原位替换且父组计数不变", async () => {
401
+ h.listStoryGroupsFn
402
+ .mockResolvedValueOnce({ groups: [{ key: "todo", total: 2 }] })
403
+ .mockResolvedValueOnce({ groups: [{ key: "u9", total: 2 }] });
404
+ h.listStoriesInGroupFn.mockResolvedValue({
405
+ items: [makeStory({ id: "a" }), makeStory({ id: "b" })],
406
+ total: 2,
407
+ hasMore: false,
408
+ });
409
+ const { result } = renderHook(() =>
410
+ usePagedStoryGroups(
411
+ makeArgs({ display: { ...DEFAULT_DISPLAY_OPTIONS, groupBy: "state", subGroupBy: "assignee" } }),
412
+ ),
413
+ );
414
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
415
+
416
+ act(() => result.current.patchStory("a", { priority: "low" }));
417
+
418
+ const parent = result.current.groups[0];
419
+ expect(parent.subGroups?.[0].items.map((s) => s.id)).toEqual(["a", "b"]); // 位置不变
420
+ expect(parent.subGroups?.[0].items[0].priority).toBe("low");
421
+ expect(parent.totalCount).toBe(2); // 父组计数不变
422
+ });
423
+ });
273
424
  });