@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
|
@@ -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 } },
|
|
@@ -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
|
|
23
|
+
import { STORY_LIST_SELECT, castStoryUsers } from "./types";
|
|
24
|
+
import type { DependencyRow } from "@/components/story/types";
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
const visited = new Set<string>();
|
|
26
|
+
const edgeTypeSchema = z.enum(["BLOCKS", "STARTS_BEFORE", "FINISHES_BEFORE"]);
|
|
27
27
|
|
|
28
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
44
|
+
await requireAuth(request);
|
|
59
45
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
129
|
+
await requireAuth(request);
|
|
114
130
|
|
|
115
131
|
const deps = await prisma.storyDependency.findMany({
|
|
116
|
-
where: {
|
|
117
|
-
|
|
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
|
-
|
|
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
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|
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
|
-
|
|
214
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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) => ({
|
|
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
|
-
|
|
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) } }
|