@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.
- package/package.json +8 -8
- 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/changelog/sdk-versions.ts +19 -19
- package/src/lib/datetime.ts +1 -0
- 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/notifications/__tests__/notification-service.test.ts +189 -0
- package/src/server/notifications/notification-service.ts +137 -27
- 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/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/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/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
|
@@ -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:
|
|
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
|
});
|
|
@@ -24,6 +24,8 @@ import { serializeFilters } from "../filters";
|
|
|
24
24
|
import { listStoriesInGroupFn, listStoryGroupsFn, listAllGroupsItemsFn } from "@/server/serverFns/story";
|
|
25
25
|
import { resolveGroupLabel, resolveGroupIcon, resolveGroupColor } from "./group-resolver";
|
|
26
26
|
import type { DisplayOptions, GroupContext, GroupKey, GroupNode } from "./types";
|
|
27
|
+
import type { StoryPatch } from "./group-fields";
|
|
28
|
+
import { PRIORITY_RANK, STORY_PRIORITIES, STORY_STATES } from "../constants";
|
|
27
29
|
|
|
28
30
|
/** 服务端按组分页的组节点:在 GroupNode 上标记 total/hasMore/loading */
|
|
29
31
|
export interface PagedGroupNode extends GroupNode {
|
|
@@ -43,6 +45,8 @@ export interface PagedStoriesArgs {
|
|
|
43
45
|
projects: GroupContext["projects"];
|
|
44
46
|
/** 仅 view === "list" 时启用分页预取,避免非 list 视图发起无谓请求 */
|
|
45
47
|
enabled?: boolean;
|
|
48
|
+
/** 首屏骨架最短展示时长(ms),保证加载动画可见;测试可传 0 跳过 */
|
|
49
|
+
minLoadingMs?: number;
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
type PageState = {
|
|
@@ -72,11 +76,297 @@ function buildGroupParams(filters: StoryFilters, conditions: FilterCondition[]):
|
|
|
72
76
|
|
|
73
77
|
const UNGROUPED_KEY = "all";
|
|
74
78
|
|
|
79
|
+
/** 单层分组并行拉取时的组数估算(决定服务端批量拉取条数,宁大勿小保证每组首屏有数据) */
|
|
80
|
+
const GROUP_COUNT_ESTIMATE: Partial<Record<GroupKey, number>> = {
|
|
81
|
+
state: STORY_STATES.length,
|
|
82
|
+
priority: STORY_PRIORITIES.length,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
function estimateGroupCount(groupBy: GroupKey, users: UserBrief[]): number {
|
|
86
|
+
const known = GROUP_COUNT_ESTIMATE[groupBy];
|
|
87
|
+
if (known != null) return known;
|
|
88
|
+
// 负责人/创建人按项目成员数+未指派估算,项目等其他维度兜底 10
|
|
89
|
+
return Math.max(users.length + 1, 10);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 分组维度 → 触发重新分组的 patch 字段(null 表示该维度不受行内编辑影响) */
|
|
93
|
+
const GROUP_TRIGGER_FIELD: Record<GroupKey, keyof StoryPatch | "labels" | null> = {
|
|
94
|
+
state: "state",
|
|
95
|
+
priority: "priority",
|
|
96
|
+
assignee: "assigneeId",
|
|
97
|
+
project: "projectId",
|
|
98
|
+
createdBy: null,
|
|
99
|
+
labels: "labels",
|
|
100
|
+
none: null,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** 排序方式 → 触发组内重排的 patch 字段("any" 表示任意更新都触发,如 updated) */
|
|
104
|
+
const ORDER_TRIGGER_FIELD: Record<string, keyof StoryPatch | "any" | null> = {
|
|
105
|
+
manual: null,
|
|
106
|
+
created: null,
|
|
107
|
+
updated: "any",
|
|
108
|
+
startDate: "startDate",
|
|
109
|
+
dueDate: "targetDate",
|
|
110
|
+
priority: "priority",
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
type PatchContext = {
|
|
114
|
+
users: UserBrief[];
|
|
115
|
+
labels: { id: string; name: string; color: string }[];
|
|
116
|
+
groupBy: GroupKey;
|
|
117
|
+
subGroupBy: GroupKey;
|
|
118
|
+
orderBy: string;
|
|
119
|
+
orderDir: "asc" | "desc";
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/** "refresh":触发分组规则(或客户端无法精确挪组);null:未找到行;数组:乐观更新后的 groups */
|
|
123
|
+
type PatchResult = PagedGroupNode[] | "refresh" | null;
|
|
124
|
+
|
|
125
|
+
function patchTouches(trigger: keyof StoryPatch | "labels" | "any" | null, patch: StoryPatch): boolean {
|
|
126
|
+
if (!trigger) return false;
|
|
127
|
+
if (trigger === "any") return true;
|
|
128
|
+
return patch[trigger as keyof StoryPatch] !== undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 与服务端 deriveGroupKey 保持一致的客户端组 key 派生;labels 返回 null(无法精确归属) */
|
|
132
|
+
function deriveStoryGroupKey(groupBy: GroupKey, s: StoryListItem): string | null {
|
|
133
|
+
switch (groupBy) {
|
|
134
|
+
case "state":
|
|
135
|
+
return s.state ?? null;
|
|
136
|
+
case "priority":
|
|
137
|
+
return s.priority ?? null;
|
|
138
|
+
case "assignee":
|
|
139
|
+
return s.assignee?.id ?? "unassigned";
|
|
140
|
+
case "createdBy":
|
|
141
|
+
return s.createdBy?.id ?? null;
|
|
142
|
+
case "project":
|
|
143
|
+
return s.projectId ?? "no-project";
|
|
144
|
+
default:
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 把 StoryPatch 合并进 StoryListItem(id → 完整对象,供乐观显示),并刷新 updatedAt */
|
|
150
|
+
function applyStoryPatch(story: StoryListItem, patch: StoryPatch, ctx: PatchContext): StoryListItem {
|
|
151
|
+
const next: StoryListItem = { ...story, updatedAt: new Date().toISOString() };
|
|
152
|
+
if (patch.state !== undefined) next.state = patch.state;
|
|
153
|
+
if (patch.priority !== undefined) next.priority = patch.priority;
|
|
154
|
+
if (patch.assigneeId !== undefined) {
|
|
155
|
+
next.assignee = patch.assigneeId
|
|
156
|
+
? ctx.users.find((u) => u.id === patch.assigneeId) ?? story.assignee
|
|
157
|
+
: null;
|
|
158
|
+
}
|
|
159
|
+
if (patch.startDate !== undefined) next.startDate = patch.startDate;
|
|
160
|
+
if (patch.targetDate !== undefined) next.targetDate = patch.targetDate;
|
|
161
|
+
if (patch.labelIds !== undefined) {
|
|
162
|
+
next.labels = patch.labelIds
|
|
163
|
+
.map((id) => ctx.labels.find((l) => l.id === id))
|
|
164
|
+
.filter((l): l is { id: string; name: string; color: string } => l != null);
|
|
165
|
+
}
|
|
166
|
+
return next;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** null = +∞(对齐 PostgreSQL:asc nulls last / desc nulls first) */
|
|
170
|
+
function cmpNullable(x: string | null, y: string | null): number {
|
|
171
|
+
if (x === y) return 0;
|
|
172
|
+
if (x == null) return 1;
|
|
173
|
+
if (y == null) return -1;
|
|
174
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 与服务端 [主键, id asc] tiebreaker 全序一致的组内比较 */
|
|
178
|
+
function compareByOrder(a: StoryListItem, b: StoryListItem, orderBy: string, dir: "asc" | "desc"): number {
|
|
179
|
+
let c = 0;
|
|
180
|
+
switch (orderBy) {
|
|
181
|
+
case "priority":
|
|
182
|
+
c = PRIORITY_RANK[a.priority] - PRIORITY_RANK[b.priority];
|
|
183
|
+
break;
|
|
184
|
+
case "startDate":
|
|
185
|
+
c = cmpNullable(a.startDate, b.startDate);
|
|
186
|
+
break;
|
|
187
|
+
case "dueDate":
|
|
188
|
+
c = cmpNullable(a.targetDate, b.targetDate);
|
|
189
|
+
break;
|
|
190
|
+
case "created":
|
|
191
|
+
c = cmpNullable(a.createdAt, b.createdAt);
|
|
192
|
+
break;
|
|
193
|
+
case "updated":
|
|
194
|
+
c = cmpNullable(a.updatedAt, b.updatedAt);
|
|
195
|
+
break;
|
|
196
|
+
case "manual":
|
|
197
|
+
c = a.boardOrder - b.boardOrder;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
if (c !== 0) return dir === "asc" ? c : -c;
|
|
201
|
+
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** 在已加载 items 内找 moved 的插入位置(未加载页边界为近似,refresh 后校正) */
|
|
205
|
+
function findInsertIndex(items: StoryListItem[], moved: StoryListItem, orderBy: string, dir: "asc" | "desc"): number {
|
|
206
|
+
for (let i = 0; i < items.length; i++) {
|
|
207
|
+
if (compareByOrder(items[i], moved, orderBy, dir) > 0) return i;
|
|
208
|
+
}
|
|
209
|
+
return items.length;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function reinsert(items: StoryListItem[], storyId: string, nextStory: StoryListItem, orderBy: string, dir: "asc" | "desc"): StoryListItem[] {
|
|
213
|
+
const rest = items.filter((s) => s.id !== storyId);
|
|
214
|
+
rest.splice(findInsertIndex(rest, nextStory, orderBy, dir), 0, nextStory);
|
|
215
|
+
return rest;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** total 字段首屏可能为 undefined(组级 total),只在有值时维护 */
|
|
219
|
+
function bumpTotal(node: PagedGroupNode, delta: number): PagedGroupNode {
|
|
220
|
+
return { ...node, total: node.total != null ? node.total + delta : node.total };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function recountParentTotalCount(group: PagedGroupNode): PagedGroupNode {
|
|
224
|
+
if (!group.subGroups?.length) return group;
|
|
225
|
+
return {
|
|
226
|
+
...group,
|
|
227
|
+
totalCount: group.subGroups.reduce((sum, sg) => sum + (sg.total ?? sg.items.length), 0),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* 行内编辑乐观更新:
|
|
233
|
+
* - 未触发分组/排序规则 → 原位替换(位置不变)
|
|
234
|
+
* - 仅触发排序规则 → 在(子)分组内移除重插,不动分组
|
|
235
|
+
* - 触发分组规则(或 labels 等无法精确挪组的场景)→ 返回 "refresh" 交给全量重拉
|
|
236
|
+
*/
|
|
237
|
+
function applyPatchToGroups(groups: PagedGroupNode[], storyId: string, patch: StoryPatch, ctx: PatchContext): PatchResult {
|
|
238
|
+
// 定位行:先子组模式,再顶层直挂模式
|
|
239
|
+
for (let gi = 0; gi < groups.length; gi++) {
|
|
240
|
+
const g = groups[gi];
|
|
241
|
+
const subGroups = g.subGroups ?? [];
|
|
242
|
+
for (let si = 0; si < subGroups.length; si++) {
|
|
243
|
+
const idx = subGroups[si].items.findIndex((s) => s.id === storyId);
|
|
244
|
+
if (idx < 0) continue;
|
|
245
|
+
const nextStory = applyStoryPatch(subGroups[si].items[idx], patch, ctx);
|
|
246
|
+
return patchInParentWithSubGroups(groups, gi, si, nextStory, patch, ctx);
|
|
247
|
+
}
|
|
248
|
+
const idx = g.items.findIndex((s) => s.id === storyId);
|
|
249
|
+
if (idx >= 0) {
|
|
250
|
+
const nextStory = applyStoryPatch(g.items[idx], patch, ctx);
|
|
251
|
+
return patchInFlatGroups(groups, gi, nextStory, patch, ctx);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** 行在父组 g 的子组 si 内:父组(顶层维度)不变,仅可能在兄弟子组间挪动 */
|
|
258
|
+
function patchInParentWithSubGroups(
|
|
259
|
+
groups: PagedGroupNode[],
|
|
260
|
+
gi: number,
|
|
261
|
+
si: number,
|
|
262
|
+
nextStory: StoryListItem,
|
|
263
|
+
patch: StoryPatch,
|
|
264
|
+
ctx: PatchContext,
|
|
265
|
+
): PatchResult {
|
|
266
|
+
const g = groups[gi];
|
|
267
|
+
const subGroups = g.subGroups ?? [];
|
|
268
|
+
const source = subGroups[si];
|
|
269
|
+
|
|
270
|
+
const subTrigger = GROUP_TRIGGER_FIELD[ctx.subGroupBy];
|
|
271
|
+
const topTrigger = GROUP_TRIGGER_FIELD[ctx.groupBy];
|
|
272
|
+
// 顶层分组维度被编辑(或 labels)→ 跨父组挪动,交给 refresh
|
|
273
|
+
if (patchTouches(topTrigger, patch) || subTrigger === "labels") return "refresh";
|
|
274
|
+
|
|
275
|
+
let targetSub = source;
|
|
276
|
+
let moved = false;
|
|
277
|
+
if (patchTouches(subTrigger, patch)) {
|
|
278
|
+
const nextKey = deriveStoryGroupKey(ctx.subGroupBy, nextStory);
|
|
279
|
+
if (nextKey == null) return "refresh";
|
|
280
|
+
if (nextKey !== source.key) {
|
|
281
|
+
const ti = subGroups.findIndex((sg) => sg.key === nextKey);
|
|
282
|
+
if (ti < 0) return "refresh"; // 目标组未显示(showEmptyGroups=false 且为空)
|
|
283
|
+
targetSub = subGroups[ti];
|
|
284
|
+
moved = true;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const orderTrigger = patchTouches(ORDER_TRIGGER_FIELD[ctx.orderBy] ?? null, patch);
|
|
289
|
+
if (!moved && !orderTrigger) {
|
|
290
|
+
// 原位替换
|
|
291
|
+
return replaceInSubGroups(groups, gi, si, (items) => items.map((s) => (s.id === nextStory.id ? nextStory : s)));
|
|
292
|
+
}
|
|
293
|
+
const nextItems = moved && !orderTrigger
|
|
294
|
+
? targetSub.items.concat(nextStory)
|
|
295
|
+
: reinsert(targetSub.items, nextStory.id, nextStory, ctx.orderBy, ctx.orderDir);
|
|
296
|
+
const updatedTarget = bumpTotal({ ...targetSub, items: nextItems }, moved ? 1 : 0);
|
|
297
|
+
const updatedSource = moved ? bumpTotal(source, -1) : null;
|
|
298
|
+
return replaceSubNodes(groups, gi, source.key, targetSub.key, updatedSource, updatedTarget);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** 行直接挂在顶层组 items(无子组模式) */
|
|
302
|
+
function patchInFlatGroups(
|
|
303
|
+
groups: PagedGroupNode[],
|
|
304
|
+
gi: number,
|
|
305
|
+
nextStory: StoryListItem,
|
|
306
|
+
patch: StoryPatch,
|
|
307
|
+
ctx: PatchContext,
|
|
308
|
+
): PatchResult {
|
|
309
|
+
const trigger = GROUP_TRIGGER_FIELD[ctx.groupBy];
|
|
310
|
+
const groupTriggered = patchTouches(trigger, patch);
|
|
311
|
+
if (!groupTriggered) {
|
|
312
|
+
// 未触发分组规则:原位替换或组内重插
|
|
313
|
+
const orderTrigger = patchTouches(ORDER_TRIGGER_FIELD[ctx.orderBy] ?? null, patch);
|
|
314
|
+
if (!orderTrigger) {
|
|
315
|
+
return groups.map((g, i) =>
|
|
316
|
+
i === gi ? { ...g, items: g.items.map((s) => (s.id === nextStory.id ? nextStory : s)) } : g,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
return groups.map((g, i) =>
|
|
320
|
+
i === gi ? { ...g, items: reinsert(g.items, nextStory.id, nextStory, ctx.orderBy, ctx.orderDir) } : g,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
// 分组维度被编辑(含 labels)→ 交给 refresh
|
|
324
|
+
return "refresh";
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function replaceInSubGroups(
|
|
328
|
+
groups: PagedGroupNode[],
|
|
329
|
+
gi: number,
|
|
330
|
+
si: number,
|
|
331
|
+
mapItems: (items: StoryListItem[]) => StoryListItem[],
|
|
332
|
+
): PagedGroupNode[] {
|
|
333
|
+
return groups.map((g, i) => {
|
|
334
|
+
if (i !== gi) return g;
|
|
335
|
+
return recountParentTotalCount({
|
|
336
|
+
...g,
|
|
337
|
+
subGroups: (g.subGroups ?? []).map((sg, j) => (j === si ? { ...sg, items: mapItems(sg.items) } : sg)),
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function replaceSubNodes(
|
|
343
|
+
groups: PagedGroupNode[],
|
|
344
|
+
gi: number,
|
|
345
|
+
sourceKey: string,
|
|
346
|
+
targetKey: string,
|
|
347
|
+
updatedSource: PagedGroupNode | null,
|
|
348
|
+
updatedTarget: PagedGroupNode,
|
|
349
|
+
): PagedGroupNode[] {
|
|
350
|
+
return groups.map((g, i) => {
|
|
351
|
+
if (i !== gi) return g;
|
|
352
|
+
return recountParentTotalCount({
|
|
353
|
+
...g,
|
|
354
|
+
subGroups: (g.subGroups ?? []).map((sg) => {
|
|
355
|
+
if (sg.key === sourceKey && updatedSource) return updatedSource;
|
|
356
|
+
if (sg.key === targetKey) return updatedTarget;
|
|
357
|
+
return sg;
|
|
358
|
+
}),
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
75
363
|
export function usePagedStoryGroups(args: PagedStoriesArgs): {
|
|
76
364
|
groups: PagedGroupNode[];
|
|
77
365
|
isLoading: boolean;
|
|
78
366
|
loadMore: (groupKey: string, subGroupKey?: string) => void;
|
|
79
367
|
refresh: () => void;
|
|
368
|
+
/** 行内编辑乐观更新:未触发分组/排序规则时保持位置原地替换,仅触发排序时组内重插,触发分组时回退全量 refresh */
|
|
369
|
+
patchStory: (storyId: string, patch: StoryPatch) => void;
|
|
80
370
|
} {
|
|
81
371
|
const { filters, conditions, display, users, labels, projects, enabled = true } = args;
|
|
82
372
|
const { t } = useTranslation("story");
|
|
@@ -129,21 +419,52 @@ export function usePagedStoryGroups(args: PagedStoriesArgs): {
|
|
|
129
419
|
useEffect(() => {
|
|
130
420
|
if (!enabled) return;
|
|
131
421
|
let alive = true;
|
|
422
|
+
const startedAt = performance.now();
|
|
132
423
|
setState((s) => ({ ...s, isLoading: true }));
|
|
133
424
|
|
|
134
425
|
const ctx: GroupContext = { users, labels, projects };
|
|
135
426
|
const base = buildGroupParams(filters, conditions);
|
|
427
|
+
// 单层分组(state/priority/assignee/createdBy/project):组 keys 与首屏 items 并行拉取,1 个 RTT 完成首屏
|
|
428
|
+
const isSingleLayer = subGroupBy === "none" && groupBy !== "none" && groupBy !== "labels";
|
|
429
|
+
// 骨架最短展示:本地数据太快到达时保证加载动画至少完整起跳可见,消除「刚播一帧就消失」的闪烁
|
|
430
|
+
const minLoadingMs = args.minLoadingMs ?? 800;
|
|
431
|
+
|
|
432
|
+
const settleWithMinDelay = (settle: () => void) => {
|
|
433
|
+
const remaining = minLoadingMs - (performance.now() - startedAt);
|
|
434
|
+
if (remaining <= 0) {
|
|
435
|
+
settle();
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
const timer = setTimeout(() => {
|
|
439
|
+
if (alive) settle();
|
|
440
|
+
}, remaining);
|
|
441
|
+
// alive 由外层 cleanup 置 false,超时触发时不会再 setState
|
|
442
|
+
void timer;
|
|
443
|
+
};
|
|
136
444
|
|
|
137
445
|
(async () => {
|
|
138
446
|
try {
|
|
139
|
-
const
|
|
447
|
+
const [groupInfosResult, preloadedItems] = await Promise.all([
|
|
140
448
|
groupBy === "none"
|
|
141
|
-
? [{ key: UNGROUPED_KEY, total: 0 }]
|
|
142
|
-
: (
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
449
|
+
? Promise.resolve({ groups: [{ key: UNGROUPED_KEY, total: 0 }] })
|
|
450
|
+
: listStoryGroupsFn({
|
|
451
|
+
data: { ...base, groupBy, showEmptyGroups: display.showEmptyGroups },
|
|
452
|
+
}),
|
|
453
|
+
isSingleLayer
|
|
454
|
+
? listAllGroupsItemsFn({
|
|
455
|
+
data: {
|
|
456
|
+
...base,
|
|
457
|
+
groupBy,
|
|
458
|
+
orderBy: display.orderBy,
|
|
459
|
+
orderDir: display.orderDir,
|
|
460
|
+
skip: 0,
|
|
461
|
+
take: pageSize,
|
|
462
|
+
groupCount: estimateGroupCount(groupBy, users),
|
|
463
|
+
},
|
|
464
|
+
})
|
|
465
|
+
: Promise.resolve(null),
|
|
466
|
+
]);
|
|
467
|
+
const groupInfos = groupInfosResult.groups;
|
|
147
468
|
|
|
148
469
|
const scaffolds: PagedGroupNode[] = groupInfos.map((g) => ({
|
|
149
470
|
key: g.key,
|
|
@@ -155,7 +476,13 @@ export function usePagedStoryGroups(args: PagedStoriesArgs): {
|
|
|
155
476
|
total: g.total,
|
|
156
477
|
}));
|
|
157
478
|
|
|
158
|
-
if (
|
|
479
|
+
if (isSingleLayer && preloadedItems) {
|
|
480
|
+
const itemsByGroup = preloadedItems.grouped as Record<string, StoryListItem[]>;
|
|
481
|
+
scaffolds.forEach((s) => {
|
|
482
|
+
s.items = itemsByGroup[s.key] ?? [];
|
|
483
|
+
s.hasMore = s.total != null ? s.items.length < s.total : s.items.length >= pageSize;
|
|
484
|
+
});
|
|
485
|
+
} else if (subGroupBy !== "none") {
|
|
159
486
|
// 子组模式:按顶层组枚举子组 keys,并并发加载各子组首屏;父组不直接装 item
|
|
160
487
|
await Promise.all(
|
|
161
488
|
scaffolds.map(async (g) => {
|
|
@@ -203,24 +530,8 @@ export function usePagedStoryGroups(args: PagedStoriesArgs): {
|
|
|
203
530
|
g.totalCount = subScaffolds.reduce((sum, sg) => sum + (sg.total ?? sg.items.length), 0);
|
|
204
531
|
}),
|
|
205
532
|
);
|
|
206
|
-
} else if (groupBy !== "none" && groupBy !== "labels") {
|
|
207
|
-
const itemsData = await listAllGroupsItemsFn({
|
|
208
|
-
data: {
|
|
209
|
-
...base,
|
|
210
|
-
groupBy,
|
|
211
|
-
orderBy: display.orderBy,
|
|
212
|
-
orderDir: display.orderDir,
|
|
213
|
-
skip: 0,
|
|
214
|
-
take: pageSize,
|
|
215
|
-
groupCount: groupInfos.length,
|
|
216
|
-
},
|
|
217
|
-
}).catch(() => ({ grouped: {} as Record<string, StoryListItem[]> }));
|
|
218
|
-
const itemsByGroup = itemsData.grouped as Record<string, StoryListItem[]>;
|
|
219
|
-
scaffolds.forEach((s) => {
|
|
220
|
-
s.items = itemsByGroup[s.key] ?? [];
|
|
221
|
-
s.hasMore = s.total != null ? s.items.length < s.total : s.items.length >= pageSize;
|
|
222
|
-
});
|
|
223
533
|
} else {
|
|
534
|
+
// 无分组 / labels 分组:每组单独拉首屏
|
|
224
535
|
const loaded = await Promise.all(
|
|
225
536
|
scaffolds.map((g) =>
|
|
226
537
|
listStoriesInGroupFn({
|
|
@@ -235,9 +546,13 @@ export function usePagedStoryGroups(args: PagedStoriesArgs): {
|
|
|
235
546
|
});
|
|
236
547
|
}
|
|
237
548
|
|
|
238
|
-
|
|
549
|
+
settleWithMinDelay(() => {
|
|
550
|
+
if (alive) setState({ sig, groups: scaffolds, isLoading: false });
|
|
551
|
+
});
|
|
239
552
|
} catch {
|
|
240
|
-
|
|
553
|
+
settleWithMinDelay(() => {
|
|
554
|
+
if (alive) setState((s) => ({ ...s, isLoading: false }));
|
|
555
|
+
});
|
|
241
556
|
}
|
|
242
557
|
})();
|
|
243
558
|
|
|
@@ -358,5 +673,32 @@ export function usePagedStoryGroups(args: PagedStoriesArgs): {
|
|
|
358
673
|
|
|
359
674
|
const refresh = useCallback(() => setRefreshTick((t) => t + 1), []);
|
|
360
675
|
|
|
361
|
-
|
|
676
|
+
const patchStory = useCallback(
|
|
677
|
+
(storyId: string, patch: StoryPatch) => {
|
|
678
|
+
const req = requestRef.current;
|
|
679
|
+
const snapshot = stateRef.current;
|
|
680
|
+
if (snapshot.sig !== req.sig) {
|
|
681
|
+
refresh();
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
const result = applyPatchToGroups(snapshot.groups, storyId, patch, {
|
|
685
|
+
users: args.users,
|
|
686
|
+
labels: args.labels,
|
|
687
|
+
groupBy: req.groupBy,
|
|
688
|
+
subGroupBy: req.subGroupBy,
|
|
689
|
+
orderBy: req.orderBy,
|
|
690
|
+
orderDir: req.orderDir,
|
|
691
|
+
});
|
|
692
|
+
if (result === "refresh") {
|
|
693
|
+
refresh();
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
if (result == null) return;
|
|
697
|
+
setState((prev) => (prev.sig !== snapshot.sig ? prev : { ...prev, groups: result }));
|
|
698
|
+
},
|
|
699
|
+
// users/labels 经 args 读取以避免闭包过期;refresh 引用稳定
|
|
700
|
+
[args.users, args.labels, refresh],
|
|
701
|
+
);
|
|
702
|
+
|
|
703
|
+
return { groups: state.groups, isLoading: state.isLoading, loadMore, refresh, patchStory };
|
|
362
704
|
}
|