@allbluecn/web-app 0.4.22 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/package.json +8 -8
  2. package/src/components/layout/sidebar-03/app-sidebar.tsx +3 -4
  3. package/src/components/layout/sidebar-03/nav-notifications.tsx +135 -52
  4. package/src/components/layout/sidebar-03/user-menu.tsx +4 -2
  5. package/src/components/notifications/settings-notifications.tsx +198 -0
  6. package/src/components/notifications/team-notifications.tsx +343 -0
  7. package/src/components/settings/personal-team/team/team-client.tsx +51 -12
  8. package/src/components/settings/personal-team/team/team-invitations.tsx +181 -181
  9. package/src/components/shared/wave-physics-loader.tsx +155 -0
  10. package/src/components/story/__tests__/dependency-type-badge.test.ts +89 -0
  11. package/src/components/story/constants.ts +114 -51
  12. package/src/components/story/detail/story-detail-body.tsx +13 -3
  13. package/src/components/story/detail/story-detail-toolbar/children.tsx +49 -274
  14. package/src/components/story/detail/story-detail-toolbar/dependencies.tsx +13 -45
  15. package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +465 -0
  16. package/src/components/story/detail/story-detail-toolbar.tsx +84 -3
  17. package/src/components/story/display/__tests__/use-paged-story-groups.test.ts +152 -1
  18. package/src/components/story/display/use-paged-story-groups.ts +370 -28
  19. package/src/components/story/inline-editors/story-select-dialog.tsx +88 -12
  20. package/src/components/story/peek-panel.tsx +8 -1
  21. package/src/components/story/relations/story-ref-row.tsx +319 -0
  22. package/src/components/story/story-list-view.tsx +3 -1
  23. package/src/components/story/story-panel-header.tsx +4 -1
  24. package/src/components/story/types.ts +17 -18
  25. package/src/components/story/views/story-list/batch-toolbar.tsx +12 -1
  26. package/src/components/story/views/story-list/index.tsx +16 -5
  27. package/src/components/story/views/story-list/row.tsx +5 -4
  28. package/src/components/ui/separator.tsx +1 -1
  29. package/src/hooks/use-notifications.ts +4 -1
  30. package/src/lib/__tests__/datetime.test.ts +16 -1
  31. package/src/lib/changelog/sdk-versions.ts +19 -19
  32. package/src/lib/datetime.ts +1 -0
  33. package/src/routeTree.gen.ts +23 -0
  34. package/src/routes/_nav/notifications/route.lazy.tsx +22 -0
  35. package/src/routes/_nav/notifications/route.tsx +30 -0
  36. package/src/routes/_nav/settings/notifications/route.lazy.tsx +6 -5
  37. package/src/routes/api/notifications/stream.ts +4 -0
  38. package/src/server/notifications/__tests__/notification-service.test.ts +189 -0
  39. package/src/server/notifications/notification-service.ts +137 -27
  40. package/src/server/serverFns/__tests__/team-invitation.test.ts +73 -7
  41. package/src/server/serverFns/__tests__/team-join-request.test.ts +5 -3
  42. package/src/server/serverFns/locale.ts +15 -3
  43. package/src/server/serverFns/notifications.ts +18 -4
  44. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +239 -0
  45. package/src/server/serverFns/story/__tests__/story-groups.test.ts +19 -1
  46. package/src/server/serverFns/story/index.ts +1 -2
  47. package/src/server/serverFns/story/story-dependencies.ts +107 -163
  48. package/src/server/serverFns/story/story-groups.ts +23 -3
  49. package/src/server/serverFns/story/story-transfer.ts +5 -3
  50. package/src/server/serverFns/team.ts +51 -2
  51. package/src/components/notifications/notification-list.tsx +0 -232
  52. package/src/components/notifications/notification-preferences.tsx +0 -156
  53. package/src/components/story/relation-badge.tsx +0 -57
  54. package/src/components/story/relations/dependency-panel.tsx +0 -190
  55. package/src/components/story/relations/hierarchy-panel.tsx +0 -180
  56. package/src/components/story/relations/tab.tsx +0 -212
  57. package/src/config/sidebar-notifications.ts +0 -50
  58. package/src/plugins/.gen-manifest.json +0 -13
  59. package/src/plugins/modules.gen.ts +0 -20
  60. package/src/plugins/plugins.gen.ts +0 -20
  61. package/src/plugins/ui.gen.ts +0 -8
  62. package/src/routes/_nav/inspiration/route.tsx +0 -8
  63. package/src/routes/_nav/prd/$id/route.tsx +0 -9
  64. package/src/routes/_nav/prd/index.tsx +0 -9
  65. package/src/routes/_nav/prd/route.tsx +0 -8
  66. package/src/routes/_nav/tags/$id/route.tsx +0 -8
  67. package/src/routes/_nav/tags/index.tsx +0 -8
  68. package/src/routes/_nav/tags/route.tsx +0 -8
  69. package/src/routes/_nav/tags/settings/route.tsx +0 -9
  70. package/src/routes/share/prd/$prdId/route.tsx +0 -10
@@ -0,0 +1,239 @@
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 { describe, it, expect, vi, beforeEach } from "vitest";
19
+
20
+ vi.mock("@tanstack/react-start", () => ({
21
+ createServerFn: (config: any) => {
22
+ const api: any = {
23
+ _validator: undefined,
24
+ validator(schema: any) {
25
+ this._validator = schema;
26
+ return this;
27
+ },
28
+ handler(fn: Function) {
29
+ const wrapped = async (args: any) => {
30
+ let data = args?.data;
31
+ if (this._validator && data !== undefined) {
32
+ data = this._validator.parse ? this._validator.parse(data) : data;
33
+ }
34
+ return fn({ data });
35
+ };
36
+ return wrapped;
37
+ },
38
+ };
39
+ return api;
40
+ },
41
+ }));
42
+
43
+ vi.mock("@tanstack/react-start/server", () => ({
44
+ getRequest: () => ({}),
45
+ }));
46
+
47
+ const mocks = vi.hoisted(() => ({
48
+ depFindMany: vi.fn(),
49
+ depFindFirst: vi.fn(),
50
+ depCreate: vi.fn(),
51
+ depDelete: vi.fn(),
52
+ storyFindMany: vi.fn(),
53
+ currentSession: vi.fn(),
54
+ }));
55
+
56
+ vi.mock("@allbluecn/database", () => {
57
+ const prisma = {
58
+ storyDependency: {
59
+ findMany: mocks.depFindMany,
60
+ findFirst: mocks.depFindFirst,
61
+ create: mocks.depCreate,
62
+ delete: mocks.depDelete,
63
+ },
64
+ story: { findMany: mocks.storyFindMany },
65
+ };
66
+ return { prisma, Prisma: {} };
67
+ });
68
+
69
+ vi.mock("@/server/auth-helpers", () => ({
70
+ requireAuth: mocks.currentSession,
71
+ }));
72
+
73
+ import {
74
+ addDependenciesFn,
75
+ removeDependencyFn,
76
+ getDependenciesFn,
77
+ } from "../story-dependencies";
78
+
79
+ const STORY_ROW = (id: string) => ({
80
+ id,
81
+ identifier: `PROJ-${id}`,
82
+ sequenceId: 1,
83
+ name: `story ${id}`,
84
+ description: null,
85
+ state: "backlog",
86
+ priority: "medium",
87
+ archived: false,
88
+ startDate: null,
89
+ targetDate: null,
90
+ boardOrder: 0,
91
+ projectId: "p1",
92
+ parentId: null,
93
+ parent: null,
94
+ assignee: null,
95
+ createdBy: { id: "u1", username: "u1", email: "e", avatarConfig: null, bgShape: null, bgColor: null },
96
+ updatedBy: null,
97
+ labels: [],
98
+ createdAt: new Date("2026-01-01T00:00:00Z"),
99
+ updatedAt: new Date("2026-01-01T00:00:00Z"),
100
+ });
101
+
102
+ beforeEach(() => {
103
+ vi.clearAllMocks();
104
+ mocks.currentSession.mockResolvedValue({ user: { id: "u1" } });
105
+ // 默认 DB 无既有依赖边(handler 开头一次性取全边建邻接表,必须返回数组)
106
+ mocks.depFindMany.mockResolvedValue([]);
107
+ });
108
+
109
+ describe("addDependenciesFn", () => {
110
+ it("自依赖拒绝并计入 failed", async () => {
111
+ const res = await addDependenciesFn({
112
+ data: { items: [{ sourceId: "a", targetId: "a", type: "BLOCKS" }] },
113
+ });
114
+ expect(res.created).toHaveLength(0);
115
+ expect(res.failed[0].targetId).toBe("a");
116
+ expect(mocks.depCreate).not.toHaveBeenCalled();
117
+ });
118
+
119
+ it("pair 级判重:同 pair 任意方向任意类型已存在则拒绝", async () => {
120
+ mocks.depFindFirst.mockResolvedValue({ id: "d0" }); // 命中已有边
121
+ const res = await addDependenciesFn({
122
+ data: {
123
+ items: [
124
+ { sourceId: "a", targetId: "b", type: "STARTS_BEFORE" }, // 正向查 (a,b,*)
125
+ ],
126
+ },
127
+ });
128
+ expect(res.failed[0].targetId).toBe("b");
129
+ expect(mocks.depCreate).not.toHaveBeenCalled();
130
+ // 守住双向查询:实现退化为单方向(漏掉反向 OR)时本断言必须失败
131
+ expect(mocks.depFindFirst).toHaveBeenCalledWith(
132
+ expect.objectContaining({
133
+ where: expect.objectContaining({
134
+ OR: expect.arrayContaining([
135
+ { sourceId: "a", targetId: "b" },
136
+ { sourceId: "b", targetId: "a" },
137
+ ]),
138
+ }),
139
+ }),
140
+ );
141
+ });
142
+
143
+ it("循环依赖拒绝:target 可达 source(跨类型混合环)", async () => {
144
+ mocks.depFindFirst.mockResolvedValue(null); // 无重复
145
+ // 图:b -> a 已存在(b BLOCKS a);插入 a -> b 成环
146
+ // handler 开头一次性取全边,返回全图唯一一条边
147
+ mocks.depFindMany.mockResolvedValue([{ sourceId: "b", targetId: "a" }]);
148
+ const res = await addDependenciesFn({
149
+ data: { items: [{ sourceId: "a", targetId: "b", type: "BLOCKS" }] },
150
+ });
151
+ expect(res.failed[0].targetId).toBe("b");
152
+ expect(mocks.depCreate).not.toHaveBeenCalled();
153
+ });
154
+
155
+ it("批内互指环:第二条被逐条拦截", async () => {
156
+ mocks.depFindFirst.mockResolvedValue(null);
157
+ mocks.depFindMany.mockResolvedValue([]);
158
+ mocks.depCreate.mockImplementation(({ data }: any) =>
159
+ Promise.resolve({ id: `d-${data.sourceId}-${data.targetId}`, ...data }),
160
+ );
161
+ // 第一条插入 a->b 后,第二条 b->a 的环检测需看到它:
162
+ // 实现必须把本批已插入边并入检测图(depFindMany 之后的内存集合)
163
+ // 这里 mock depFindMany 每次返回空(DB 无边),实现需自维护本批边
164
+ const res = await addDependenciesFn({
165
+ data: {
166
+ items: [
167
+ { sourceId: "a", targetId: "b", type: "BLOCKS" },
168
+ { sourceId: "b", targetId: "a", type: "STARTS_BEFORE" },
169
+ ],
170
+ },
171
+ });
172
+ expect(res.created).toHaveLength(1);
173
+ expect(res.failed[0].targetId).toBe("a");
174
+ });
175
+
176
+ it("合法边创建成功", async () => {
177
+ mocks.depFindFirst.mockResolvedValue(null);
178
+ mocks.depFindMany.mockResolvedValue([]);
179
+ mocks.depCreate.mockResolvedValue({ id: "d1" });
180
+ const res = await addDependenciesFn({
181
+ data: { items: [{ sourceId: "a", targetId: "b", type: "BLOCKS" }] },
182
+ });
183
+ expect(res.created).toHaveLength(1);
184
+ expect(mocks.depCreate).toHaveBeenCalledOnce();
185
+ // 性能契约:环检测的图只查一次(取全边),且不带任何 where 过滤
186
+ expect(mocks.depFindMany).toHaveBeenCalledTimes(1);
187
+ expect(mocks.depFindMany).toHaveBeenCalledWith({
188
+ select: { sourceId: true, targetId: true },
189
+ });
190
+ });
191
+
192
+ it("validator 拒绝旧枚举值:BLOCKED_BY 不再合法", async () => {
193
+ await expect(
194
+ addDependenciesFn({
195
+ data: { items: [{ sourceId: "a", targetId: "b", type: "BLOCKED_BY" as any }] },
196
+ }),
197
+ ).rejects.toThrow();
198
+ expect(mocks.depCreate).not.toHaveBeenCalled();
199
+ });
200
+ });
201
+
202
+ describe("removeDependencyFn", () => {
203
+ it("按 depId 删除", async () => {
204
+ mocks.depDelete.mockResolvedValue({ id: "d1" });
205
+ const res = await removeDependencyFn({ data: { depId: "d1" } });
206
+ expect(res.id).toBe("d1");
207
+ expect(mocks.depDelete).toHaveBeenCalledWith({ where: { id: "d1" } });
208
+ });
209
+ });
210
+
211
+ describe("getDependenciesFn", () => {
212
+ it("双视角分组:dependsOn 取 source,dependents 取 target", async () => {
213
+ mocks.depFindMany.mockResolvedValue([
214
+ { id: "d1", sourceId: "s1", targetId: "me", type: "BLOCKS" },
215
+ { id: "d2", sourceId: "me", targetId: "t1", type: "STARTS_BEFORE" },
216
+ ]);
217
+ mocks.storyFindMany.mockImplementation(({ where }: any) => {
218
+ const rows = [STORY_ROW("s1"), STORY_ROW("t1"), STORY_ROW("me")];
219
+ return Promise.resolve(rows.filter((r) => where.id.in.includes(r.id)));
220
+ });
221
+ const res = await getDependenciesFn({ data: { storyId: "me" } });
222
+ expect(res.dependsOn).toHaveLength(1);
223
+ expect(res.dependsOn[0].id).toBe("s1");
224
+ expect(res.dependsOn[0].depId).toBe("d1");
225
+ expect(res.dependsOn[0].depType).toBe("BLOCKS");
226
+ expect(res.dependents).toHaveLength(1);
227
+ expect(res.dependents[0].id).toBe("t1");
228
+ expect(res.dependents[0].depType).toBe("STARTS_BEFORE");
229
+ // 日期已 ISO 化(castStoryUsers)
230
+ expect(res.dependsOn[0].createdAt).toBe("2026-01-01T00:00:00.000Z");
231
+ });
232
+
233
+ it("无边时返回空分组且不查需求表", async () => {
234
+ mocks.depFindMany.mockResolvedValue([]);
235
+ const res = await getDependenciesFn({ data: { storyId: "me" } });
236
+ expect(res).toEqual({ dependsOn: [], dependents: [] });
237
+ expect(mocks.storyFindMany).not.toHaveBeenCalled();
238
+ });
239
+ });
@@ -84,7 +84,7 @@ describe("listStoriesInGroupFn", () => {
84
84
  const arg = mocks.findMany.mock.calls[0][0] as any;
85
85
  expect(arg.where.AND).toContainEqual({ state: "todo" });
86
86
  expect(arg.where.archived).toBe(false);
87
- expect(arg.orderBy).toEqual({ createdAt: "desc" });
87
+ expect(arg.orderBy).toEqual([{ createdAt: "desc" }, { id: "asc" }]);
88
88
  expect(mocks.count).not.toHaveBeenCalled();
89
89
  });
90
90
 
@@ -222,6 +222,24 @@ describe("listStoryGroupsFn", () => {
222
222
  expect(out.groups.find((g) => g.key === "backlog")?.total).toBe(0);
223
223
  });
224
224
 
225
+ it("state/priority 组间顺序按枚举序排列(与筛选 pop 一致),不受 DB 返回顺序影响", async () => {
226
+ mocks.groupBy.mockResolvedValue([
227
+ { state: "done", _count: { _all: 91 } },
228
+ { state: "in_progress", _count: { _all: 2 } },
229
+ { state: "todo", _count: { _all: 1 } },
230
+ ]);
231
+ const stateOut = await listStoryGroupsFn({ data: { archived: false, groupBy: "state" } });
232
+ expect(stateOut.groups.map((g) => g.key)).toEqual(["todo", "in_progress", "done"]);
233
+
234
+ mocks.groupBy.mockResolvedValue([
235
+ { priority: "low", _count: { _all: 4 } },
236
+ { priority: "urgent", _count: { _all: 2 } },
237
+ { priority: "medium", _count: { _all: 1 } },
238
+ ]);
239
+ const priorityOut = await listStoryGroupsFn({ data: { archived: false, groupBy: "priority" } });
240
+ expect(priorityOut.groups.map((g) => g.key)).toEqual(["urgent", "medium", "low"]);
241
+ });
242
+
225
243
  it("labels 分组按 storyLabel.groupBy 聚合并计入 no-labels", async () => {
226
244
  mocks.storyLabelGroupBy.mockResolvedValue([
227
245
  { labelId: "l1", _count: { _all: 2 } },
@@ -81,10 +81,9 @@ export {
81
81
  } from "./story-children";
82
82
 
83
83
  export {
84
- addDependencyFn,
84
+ addDependenciesFn,
85
85
  removeDependencyFn,
86
86
  getDependenciesFn,
87
- validateDependencyFn,
88
87
  } from "./story-dependencies";
89
88
 
90
89
  export {
@@ -20,199 +20,143 @@ import { getRequest } from "@tanstack/react-start/server";
20
20
  import { z } from "zod";
21
21
  import { prisma } from "@allbluecn/database";
22
22
  import { requireAuth } from "@/server/auth-helpers";
23
- import type { DependencyType } from "@/components/story/types";
23
+ import { STORY_LIST_SELECT, castStoryUsers } from "./types";
24
+ import type { DependencyRow } from "@/components/story/types";
24
25
 
25
- async function hasCycle(sourceId: string, targetId: string): Promise<boolean> {
26
- const visited = new Set<string>();
26
+ const edgeTypeSchema = z.enum(["BLOCKS", "STARTS_BEFORE", "FINISHES_BEFORE"]);
27
27
 
28
- async function dfs(currentId: string): Promise<boolean> {
29
- if (visited.has(currentId)) return false;
30
- visited.add(currentId);
31
-
32
- const outgoing = await prisma.storyDependency.findMany({
33
- where: { sourceId: currentId, type: "BLOCKS" },
34
- select: { targetId: true },
35
- });
36
-
37
- for (const dep of outgoing) {
38
- if (dep.targetId === targetId) return true;
39
- if (await dfs(dep.targetId)) return true;
40
- }
41
-
42
- return false;
43
- }
44
-
45
- return dfs(sourceId);
46
- }
47
-
48
- export const addDependencyFn = createServerFn({ method: "POST" })
28
+ export const addDependenciesFn = createServerFn({ method: "POST" })
49
29
  .validator(
50
30
  z.object({
51
- sourceId: z.string(),
52
- targetId: z.string(),
53
- type: z.enum(["BLOCKS", "BLOCKED_BY", "RELATES_TO"]),
31
+ items: z
32
+ .array(
33
+ z.object({
34
+ sourceId: z.string(),
35
+ targetId: z.string(),
36
+ type: edgeTypeSchema,
37
+ }),
38
+ )
39
+ .min(1),
54
40
  }),
55
41
  )
56
42
  .handler(async ({ data }) => {
57
43
  const request = getRequest();
58
- const session = await requireAuth(request);
44
+ await requireAuth(request);
59
45
 
60
- const existing = await prisma.storyDependency.findUnique({
61
- where: {
62
- sourceId_targetId_type: {
63
- sourceId: data.sourceId,
64
- targetId: data.targetId,
65
- type: data.type,
66
- },
67
- },
46
+ // 一次性取全部出边(3 类型合并;不按 projectId 裁剪——跨项目边同样参与环判定),
47
+ // 构建内存邻接表供本批循环的环检测复用,避免 O(items × 可达节点) 次串行 RTT
48
+ const allEdges = await prisma.storyDependency.findMany({
49
+ select: { sourceId: true, targetId: true },
68
50
  });
69
- if (existing) {
70
- throw new Error("依赖关系已存在");
51
+ const adjacency = new Map<string, string[]>();
52
+ for (const e of allEdges) {
53
+ const list = adjacency.get(e.sourceId);
54
+ if (list) list.push(e.targetId);
55
+ else adjacency.set(e.sourceId, [e.targetId]);
71
56
  }
72
57
 
73
- const dep = await prisma.storyDependency.create({
74
- data: {
75
- sourceId: data.sourceId,
76
- targetId: data.targetId,
77
- type: data.type,
78
- },
79
- });
58
+ // 环检测:从 fromId 出发沿 source→target 有向边能否到达 toId(纯内存遍历)
59
+ // batchEdges:本批已插入边(防批内互指环漏检)
60
+ function reaches(
61
+ fromId: string,
62
+ toId: string,
63
+ batchEdges: Array<{ sourceId: string; targetId: string }>,
64
+ ): boolean {
65
+ const visited = new Set<string>();
66
+ const stack = [fromId];
67
+ while (stack.length > 0) {
68
+ const current = stack.pop()!;
69
+ if (current === toId) return true;
70
+ if (visited.has(current)) continue;
71
+ visited.add(current);
72
+ for (const next of adjacency.get(current) ?? []) {
73
+ if (!visited.has(next)) stack.push(next);
74
+ }
75
+ for (const e of batchEdges) {
76
+ if (e.sourceId === current && !visited.has(e.targetId)) stack.push(e.targetId);
77
+ }
78
+ }
79
+ return false;
80
+ }
80
81
 
81
- return dep;
82
+ const created: string[] = [];
83
+ const failed: Array<{ targetId: string; reason: "SELF" | "DUPLICATED" | "CYCLE" }> = [];
84
+ const batchEdges: Array<{ sourceId: string; targetId: string }> = [];
85
+
86
+ for (const item of data.items) {
87
+ if (item.sourceId === item.targetId) {
88
+ failed.push({ targetId: item.targetId, reason: "SELF" });
89
+ continue;
90
+ }
91
+ const existing = await prisma.storyDependency.findFirst({
92
+ where: {
93
+ OR: [
94
+ { sourceId: item.sourceId, targetId: item.targetId },
95
+ { sourceId: item.targetId, targetId: item.sourceId },
96
+ ],
97
+ },
98
+ select: { id: true },
99
+ });
100
+ if (existing) {
101
+ failed.push({ targetId: item.targetId, reason: "DUPLICATED" });
102
+ continue;
103
+ }
104
+ // 插入 (S,T) 后成环 ⇔ 已存在路径 T →…→ S(含本批边)
105
+ if (reaches(item.targetId, item.sourceId, batchEdges)) {
106
+ failed.push({ targetId: item.targetId, reason: "CYCLE" });
107
+ continue;
108
+ }
109
+ const dep = await prisma.storyDependency.create({ data: item });
110
+ created.push(dep.id);
111
+ batchEdges.push({ sourceId: item.sourceId, targetId: item.targetId });
112
+ }
113
+
114
+ return { created, failed };
82
115
  });
83
116
 
84
117
  export const removeDependencyFn = createServerFn({ method: "POST" })
85
- .validator(
86
- z.object({
87
- sourceId: z.string(),
88
- targetId: z.string(),
89
- type: z.enum(["BLOCKS", "BLOCKED_BY", "RELATES_TO"]),
90
- }),
91
- )
118
+ .validator(z.object({ depId: z.string() }))
92
119
  .handler(async ({ data }) => {
93
120
  const request = getRequest();
94
- const session = await requireAuth(request);
95
-
96
- const dep = await prisma.storyDependency.delete({
97
- where: {
98
- sourceId_targetId_type: {
99
- sourceId: data.sourceId,
100
- targetId: data.targetId,
101
- type: data.type,
102
- },
103
- },
104
- });
105
-
106
- return dep;
121
+ await requireAuth(request);
122
+ return prisma.storyDependency.delete({ where: { id: data.depId } });
107
123
  });
108
124
 
109
125
  export const getDependenciesFn = createServerFn({ method: "GET" })
110
126
  .validator(z.object({ storyId: z.string() }))
111
127
  .handler(async ({ data }) => {
112
128
  const request = getRequest();
113
- const session = await requireAuth(request);
129
+ await requireAuth(request);
114
130
 
115
131
  const deps = await prisma.storyDependency.findMany({
116
- where: {
117
- OR: [{ sourceId: data.storyId }, { targetId: data.storyId }],
118
- },
119
- include: {
120
- source: {
121
- select: { id: true, name: true, state: true },
122
- },
123
- target: {
124
- select: { id: true, name: true, state: true },
125
- },
126
- },
132
+ where: { OR: [{ targetId: data.storyId }, { sourceId: data.storyId }] },
133
+ select: { id: true, sourceId: true, targetId: true, type: true },
127
134
  });
128
-
129
- const grouped = {
130
- blocking: deps
131
- .filter((d) => d.sourceId === data.storyId && d.type === "BLOCKS")
132
- .map((d) => ({
133
- id: d.target.id,
134
- title: d.target.name,
135
- status: d.target.state,
136
- type: "blocks" as DependencyType,
137
- })),
138
- blockedBy: deps
139
- .filter((d) => d.targetId === data.storyId && d.type === "BLOCKS")
140
- .map((d) => ({
141
- id: d.source.id,
142
- title: d.source.name,
143
- status: d.source.state,
144
- type: "blocked_by" as DependencyType,
145
- })),
146
- relatesTo: deps
147
- .filter((d) => d.type === "RELATES_TO")
148
- .map((d) => {
149
- const isSource = d.sourceId === data.storyId;
150
- const other = isSource ? d.target : d.source;
151
- return {
152
- id: other.id,
153
- title: other.name,
154
- status: other.state,
155
- type: "relates_to" as DependencyType,
156
- };
157
- }),
158
- };
159
-
160
- return grouped;
161
- });
162
-
163
- export const validateDependencyFn = createServerFn({ method: "POST" })
164
- .validator(
165
- z.object({
166
- sourceId: z.string(),
167
- targetId: z.string(),
168
- type: z.enum(["BLOCKS", "BLOCKED_BY", "RELATES_TO"]),
169
- }),
170
- )
171
- .handler(async ({ data }) => {
172
- const request = getRequest();
173
- const session = await requireAuth(request);
174
-
175
- if (data.sourceId === data.targetId) {
176
- return { valid: false, reason: "不能创建自我依赖" };
177
- }
178
-
179
- if (data.type === "BLOCKS") {
180
- const reverse = await prisma.storyDependency.findUnique({
181
- where: {
182
- sourceId_targetId_type: {
183
- sourceId: data.targetId,
184
- targetId: data.sourceId,
185
- type: "BLOCKS",
186
- },
187
- },
188
- });
189
- if (!reverse) {
190
- return {
191
- valid: false,
192
- reason: "blocks 依赖必须成对出现(需要同时创建反向 blocks)",
193
- };
194
- }
135
+ if (deps.length === 0) {
136
+ return { dependsOn: [] as DependencyRow[], dependents: [] as DependencyRow[] };
195
137
  }
196
138
 
197
- const source = await prisma.story.findUnique({
198
- where: { id: data.sourceId },
199
- select: { parentId: true },
139
+ const ids = [...new Set(deps.flatMap((d) => [d.sourceId, d.targetId]))];
140
+ const stories = await prisma.story.findMany({
141
+ where: { id: { in: ids } },
142
+ select: STORY_LIST_SELECT,
200
143
  });
201
- const target = await prisma.story.findUnique({
202
- where: { id: data.targetId },
203
- select: { parentId: true },
204
- });
205
-
206
- if (source?.parentId === data.targetId || target?.parentId === data.sourceId) {
207
- return {
208
- valid: false,
209
- reason: "父子需求之间不能有 blocks/blocked_by 依赖",
210
- };
211
- }
144
+ const byId = new Map(stories.map((s) => [s.id, castStoryUsers(s)]));
212
145
 
213
- if (await hasCycle(data.sourceId, data.targetId)) {
214
- return { valid: false, reason: "会形成循环依赖" };
215
- }
146
+ const build = (d: (typeof deps)[number], storyId: string): DependencyRow | null => {
147
+ const otherId = d.sourceId === storyId ? d.targetId : d.sourceId;
148
+ const s = byId.get(otherId);
149
+ return s ? ({ ...s, depId: d.id, depType: d.type } as DependencyRow) : null;
150
+ };
216
151
 
217
- return { valid: true };
152
+ return {
153
+ dependsOn: deps
154
+ .filter((d) => d.targetId === data.storyId)
155
+ .map((d) => build(d, data.storyId))
156
+ .filter((r): r is DependencyRow => r !== null),
157
+ dependents: deps
158
+ .filter((d) => d.sourceId === data.storyId)
159
+ .map((d) => build(d, data.storyId))
160
+ .filter((r): r is DependencyRow => r !== null),
161
+ };
218
162
  });
@@ -78,9 +78,11 @@ export const listStoriesInGroupFn = createServerFn({ method: "GET" })
78
78
 
79
79
  const field = ORDER_FIELD[data.orderBy];
80
80
  const dir: "asc" | "desc" = data.orderDir === "asc" ? "asc" : "desc";
81
- const orderBy: Prisma.StoryOrderByWithRelationInput = field
81
+ const primary: Prisma.StoryOrderByWithRelationInput = field
82
82
  ? { [field]: dir }
83
83
  : { createdAt: dir };
84
+ // id tiebreaker:主排序键相同的行(同批创建等)顺序必须确定,否则重拉时顺序抖动
85
+ const orderBy: Prisma.StoryOrderByWithRelationInput[] = [primary, { id: "asc" }];
84
86
 
85
87
  const items = await prisma.story.findMany({
86
88
  where,
@@ -132,9 +134,11 @@ export const listAllGroupsItemsFn = createServerFn({ method: "GET" })
132
134
  const base = buildListWhere({ ...data, projectId });
133
135
  const field = ORDER_FIELD[data.orderBy];
134
136
  const dir: "asc" | "desc" = data.orderDir === "asc" ? "asc" : "desc";
135
- const orderBy: Prisma.StoryOrderByWithRelationInput = field
137
+ const primary: Prisma.StoryOrderByWithRelationInput = field
136
138
  ? { [field]: dir }
137
139
  : { createdAt: dir };
140
+ // id tiebreaker:与 listStoriesInGroupFn 保持一致,保证分页跨批次全序稳定
141
+ const orderBy: Prisma.StoryOrderByWithRelationInput[] = [primary, { id: "asc" }];
138
142
 
139
143
  const batchTake = data.take * data.groupCount;
140
144
  const items = await prisma.story.findMany({
@@ -216,6 +220,19 @@ function deriveGroupKey(groupBy: GroupKey, row: Record<string, unknown>): string
216
220
  }
217
221
  }
218
222
 
223
+ /** 组间显示顺序与筛选 pop 的值顺序一致(state/priority 按枚举序,其余维度保持 DB 返回序;未知 key 排最后) */
224
+ const GROUP_DISPLAY_ORDER: Partial<Record<GroupKey, readonly string[]>> = {
225
+ state: STORY_STATES.map((s) => s.value),
226
+ priority: STORY_PRIORITIES.map((p) => p.value),
227
+ };
228
+
229
+ function sortGroupKeys(groupBy: GroupKey, keys: string[]): string[] {
230
+ const order = GROUP_DISPLAY_ORDER[groupBy];
231
+ if (!order) return keys;
232
+ const rank = new Map(order.map((k, i) => [k, i]));
233
+ return [...keys].sort((a, b) => (rank.get(a) ?? order.length) - (rank.get(b) ?? order.length));
234
+ }
235
+
219
236
  export const listStoryGroupsFn = createServerFn({ method: "GET" })
220
237
  .validator(listGroupsSchema)
221
238
  .handler(async ({ data }) => {
@@ -293,6 +310,9 @@ export const listStoryGroupsFn = createServerFn({ method: "GET" })
293
310
  if (!keys.includes(k)) keys.push(k);
294
311
  }
295
312
  }
296
- const groups: Array<{ key: string; total: number }> = keys.map((k) => ({ key: k, total: totalMap.get(k) ?? 0 }));
313
+ const groups: Array<{ key: string; total: number }> = sortGroupKeys(data.groupBy, keys).map((k) => ({
314
+ key: k,
315
+ total: totalMap.get(k) ?? 0,
316
+ }));
297
317
  return { groups };
298
318
  });
@@ -349,7 +349,8 @@ const listProjectStoriesSchema = z.object({
349
349
  search: z.string().optional(),
350
350
  cursor: z.string().optional(),
351
351
  excludeStoryId: z.string().optional(),
352
- mode: z.enum(["parent", "child"]).default("parent"),
352
+ excludeIds: z.array(z.string()).optional(),
353
+ mode: z.enum(["parent", "child", "dependency"]).default("parent"),
353
354
  pageSize: z.number().int().min(1).max(50).default(20),
354
355
  });
355
356
 
@@ -365,16 +366,17 @@ export const listProjectStoriesFn = createServerFn({ method: "GET" })
365
366
  if (data.mode === "parent") {
366
367
  const descendants = await collectDescendantIds(data.excludeStoryId);
367
368
  for (const id of descendants) excludeIds.add(id);
368
- } else {
369
+ } else if (data.mode === "child") {
369
370
  const ancestors = await collectAncestorIds(data.excludeStoryId);
370
371
  for (const id of ancestors) excludeIds.add(id);
371
372
  }
372
373
  }
374
+ for (const id of data.excludeIds ?? []) excludeIds.add(id);
373
375
 
374
376
  const stories = await prisma.story.findMany({
375
377
  where: {
376
378
  ...(data.projectId ? { projectId: data.projectId } : {}),
377
- parentId: null,
379
+ ...(data.mode === "dependency" ? {} : { parentId: null }),
378
380
  archived: false,
379
381
  ...(excludeIds.size > 0
380
382
  ? { id: { notIn: Array.from(excludeIds) } }