@allbluecn/web-app 0.4.21 → 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 +25 -33
- package/src/components/command/command-palette.tsx +1 -1
- package/src/components/knowledge/knowledge-toolbar.tsx +29 -1
- 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/__tests__/rag.test.ts +37 -2
- package/src/lib/changelog/sdk-versions.ts +30 -30
- package/src/lib/commands/registry.ts +1 -1
- package/src/lib/commands/types.ts +3 -0
- package/src/lib/datetime.ts +1 -0
- package/src/lib/rag.ts +9 -2
- 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/ai-ports.ts +1 -1
- package/src/server/knowledge-public/github-sync.ts +1 -1
- 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__/knowledge-index.test.ts +178 -0
- 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/knowledge-index-utils.ts +104 -0
- package/src/server/serverFns/knowledge-index.ts +6 -67
- package/src/server/serverFns/knowledge.ts +8 -2
- 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/knowledge/knowledge-settings-panel.tsx +0 -148
- 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/lib/openai-client.ts +0 -62
- 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
|
@@ -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) } }
|
|
@@ -183,7 +183,7 @@ export const clearExpiredInvitationsFn = createServerFn({ method: 'POST' })
|
|
|
183
183
|
const session = await requireAuth(request)
|
|
184
184
|
const ownerId = session.user!.id!
|
|
185
185
|
const res = await prisma.teamInvitation.deleteMany({
|
|
186
|
-
where: { ownerId, status: 'expired' },
|
|
186
|
+
where: { ownerId, status: 'expired', channel: 'email' },
|
|
187
187
|
})
|
|
188
188
|
await auditLog({
|
|
189
189
|
workspaceId: '',
|
|
@@ -279,8 +279,9 @@ export const refreshTeamInvitationLinkFn = createServerFn({ method: 'POST' })
|
|
|
279
279
|
},
|
|
280
280
|
data: { status: 'invalidated', processedAt: new Date() },
|
|
281
281
|
})
|
|
282
|
-
await tx.teamInvitation.
|
|
282
|
+
await tx.teamInvitation.updateMany({
|
|
283
283
|
where: { ownerId, channel: 'link', status: { in: ['pending', 'pending_approval'] } },
|
|
284
|
+
data: { status: 'revoked' },
|
|
284
285
|
})
|
|
285
286
|
return tx.teamInvitation.create({
|
|
286
287
|
data: {
|
|
@@ -493,6 +494,54 @@ export const rejectTeamJoinRequestFn = createServerFn({ method: 'POST' })
|
|
|
493
494
|
return { success: true }
|
|
494
495
|
})
|
|
495
496
|
|
|
497
|
+
export const deleteTeamInvitationFn = createServerFn({ method: 'POST' })
|
|
498
|
+
.validator(revokeSchema)
|
|
499
|
+
.handler(async ({ data }) => {
|
|
500
|
+
const request = getRequest()
|
|
501
|
+
const session = await requireAuth(request)
|
|
502
|
+
const ownerId = session.user!.id!
|
|
503
|
+
|
|
504
|
+
const invRow = await prisma.teamInvitation.findUnique({ where: { id: data.invitationId } })
|
|
505
|
+
if (!invRow || invRow.ownerId !== ownerId) throw new Error('邀请不存在')
|
|
506
|
+
// link 渠道行下挂 joinRequest(级联删除),禁止单独删除
|
|
507
|
+
if (invRow.channel === 'link') throw new Error('链接邀请记录不可删除')
|
|
508
|
+
|
|
509
|
+
await prisma.teamInvitation.delete({ where: { id: invRow.id } })
|
|
510
|
+
|
|
511
|
+
await auditLog({
|
|
512
|
+
workspaceId: '',
|
|
513
|
+
userId: ownerId,
|
|
514
|
+
resourceType: 'teamInvitation',
|
|
515
|
+
resourceId: invRow.id,
|
|
516
|
+
action: 'team.invite.delete',
|
|
517
|
+
metadata: { email: invRow.email },
|
|
518
|
+
})
|
|
519
|
+
return { success: true }
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
export const deleteTeamJoinRequestFn = createServerFn({ method: 'POST' })
|
|
523
|
+
.validator(joinRequestSchema)
|
|
524
|
+
.handler(async ({ data }) => {
|
|
525
|
+
const request = getRequest()
|
|
526
|
+
const session = await requireAuth(request)
|
|
527
|
+
const ownerId = session.user!.id!
|
|
528
|
+
|
|
529
|
+
const reqRow = await prisma.teamJoinRequest.findUnique({ where: { id: data.requestId } })
|
|
530
|
+
if (!reqRow || reqRow.ownerId !== ownerId) throw new Error('申请不存在')
|
|
531
|
+
|
|
532
|
+
await prisma.teamJoinRequest.delete({ where: { id: reqRow.id } })
|
|
533
|
+
|
|
534
|
+
await auditLog({
|
|
535
|
+
workspaceId: '',
|
|
536
|
+
userId: ownerId,
|
|
537
|
+
resourceType: 'teamJoinRequest',
|
|
538
|
+
resourceId: reqRow.id,
|
|
539
|
+
action: 'team.join.delete',
|
|
540
|
+
metadata: { email: reqRow.email },
|
|
541
|
+
})
|
|
542
|
+
return { success: true }
|
|
543
|
+
})
|
|
544
|
+
|
|
496
545
|
export const listTeamUsersFn = createServerFn({ method: 'GET' }).handler(async () => {
|
|
497
546
|
const request = getRequest()
|
|
498
547
|
const session = await requireAuth(request)
|
|
@@ -1,148 +0,0 @@
|
|
|
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 { useState } from 'react'
|
|
19
|
-
import { useNavigate } from '@tanstack/react-router'
|
|
20
|
-
import { Globe, Lock, Trash2 } from 'lucide-react'
|
|
21
|
-
import {
|
|
22
|
-
Dialog,
|
|
23
|
-
DialogContent,
|
|
24
|
-
DialogDescription,
|
|
25
|
-
DialogFooter,
|
|
26
|
-
DialogHeader,
|
|
27
|
-
DialogTitle,
|
|
28
|
-
} from '@/components/ui/dialog'
|
|
29
|
-
import { Button } from '@/components/ui/button'
|
|
30
|
-
import { Label } from '@/components/ui/label'
|
|
31
|
-
import {
|
|
32
|
-
Select,
|
|
33
|
-
SelectContent,
|
|
34
|
-
SelectItem,
|
|
35
|
-
SelectTrigger,
|
|
36
|
-
SelectValue,
|
|
37
|
-
} from '@/components/ui/select'
|
|
38
|
-
import { deleteKnowledgeFn, updateKnowledgeFn } from '@/server/serverFns/knowledge'
|
|
39
|
-
import { useTranslation } from 'react-i18next'
|
|
40
|
-
|
|
41
|
-
interface KbData {
|
|
42
|
-
id: string
|
|
43
|
-
name: string
|
|
44
|
-
visibility?: string
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
interface KnowledgeSettingsPanelProps {
|
|
48
|
-
open: boolean
|
|
49
|
-
onOpenChange: (open: boolean) => void
|
|
50
|
-
kb: KbData
|
|
51
|
-
onUpdate?: () => void
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export function KnowledgeSettingsPanel({ open, onOpenChange, kb, onUpdate }: KnowledgeSettingsPanelProps) {
|
|
55
|
-
const { t } = useTranslation('knowledge')
|
|
56
|
-
const navigate = useNavigate()
|
|
57
|
-
const [visibility, setVisibility] = useState((kb as any).visibility || 'private')
|
|
58
|
-
const [deleting, setDeleting] = useState(false)
|
|
59
|
-
const [saving, setSaving] = useState(false)
|
|
60
|
-
|
|
61
|
-
const handleVisibilityChange = async (value: string) => {
|
|
62
|
-
const v = value as 'private' | 'team'
|
|
63
|
-
setVisibility(v)
|
|
64
|
-
setSaving(true)
|
|
65
|
-
try {
|
|
66
|
-
await updateKnowledgeFn({ data: { id: kb.id, visibility: v } })
|
|
67
|
-
onUpdate?.()
|
|
68
|
-
} catch (e) {
|
|
69
|
-
console.error('Update visibility failed:', e instanceof Error ? e.message : String(e))
|
|
70
|
-
}
|
|
71
|
-
setSaving(false)
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const handleDelete = async () => {
|
|
75
|
-
setDeleting(true)
|
|
76
|
-
try {
|
|
77
|
-
await deleteKnowledgeFn({ data: { id: kb.id } })
|
|
78
|
-
onOpenChange(false)
|
|
79
|
-
navigate({ to: '/knowledge' })
|
|
80
|
-
} catch (e) {
|
|
81
|
-
console.error('Delete failed:', e instanceof Error ? e.message : String(e))
|
|
82
|
-
}
|
|
83
|
-
setDeleting(false)
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
return (
|
|
87
|
-
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
88
|
-
<DialogContent className="sm:max-w-md">
|
|
89
|
-
<DialogHeader>
|
|
90
|
-
<DialogTitle>{t('kbSettings.title')}</DialogTitle>
|
|
91
|
-
<DialogDescription>{t('kbSettings.subtitle', { name: kb.name })}</DialogDescription>
|
|
92
|
-
</DialogHeader>
|
|
93
|
-
|
|
94
|
-
<div className="space-y-6 py-4">
|
|
95
|
-
<div className="space-y-2">
|
|
96
|
-
<Label>{t('kbSettings.ownership')}</Label>
|
|
97
|
-
<Select value={visibility} onValueChange={handleVisibilityChange} disabled={saving}>
|
|
98
|
-
<SelectTrigger>
|
|
99
|
-
<SelectValue />
|
|
100
|
-
</SelectTrigger>
|
|
101
|
-
<SelectContent>
|
|
102
|
-
<SelectItem value="private">
|
|
103
|
-
<div className="flex items-center gap-2">
|
|
104
|
-
<Lock className="size-4" />
|
|
105
|
-
{t('kbSettings.private')}
|
|
106
|
-
</div>
|
|
107
|
-
</SelectItem>
|
|
108
|
-
<SelectItem value="team">
|
|
109
|
-
<div className="flex items-center gap-2">
|
|
110
|
-
<Globe className="size-4" />
|
|
111
|
-
{t('kbSettings.team')}
|
|
112
|
-
</div>
|
|
113
|
-
</SelectItem>
|
|
114
|
-
</SelectContent>
|
|
115
|
-
</Select>
|
|
116
|
-
<p className="text-xs text-muted-foreground">
|
|
117
|
-
{t('kbSettings.teamHint')}
|
|
118
|
-
</p>
|
|
119
|
-
</div>
|
|
120
|
-
|
|
121
|
-
<div className="border-t pt-4">
|
|
122
|
-
<div className="space-y-2">
|
|
123
|
-
<Label className="text-destructive">{t('kbSettings.danger')}</Label>
|
|
124
|
-
<p className="text-xs text-muted-foreground">
|
|
125
|
-
{t('kbSettings.deleteHint')}
|
|
126
|
-
</p>
|
|
127
|
-
<Button
|
|
128
|
-
variant="destructive"
|
|
129
|
-
size="sm"
|
|
130
|
-
onClick={handleDelete}
|
|
131
|
-
disabled={deleting}
|
|
132
|
-
>
|
|
133
|
-
<Trash2 className="size-4 mr-1" />
|
|
134
|
-
{deleting ? t('kbSettings.deleting') : t('kbSettings.delete')}
|
|
135
|
-
</Button>
|
|
136
|
-
</div>
|
|
137
|
-
</div>
|
|
138
|
-
</div>
|
|
139
|
-
|
|
140
|
-
<DialogFooter>
|
|
141
|
-
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
142
|
-
{t('kbSettings.close')}
|
|
143
|
-
</Button>
|
|
144
|
-
</DialogFooter>
|
|
145
|
-
</DialogContent>
|
|
146
|
-
</Dialog>
|
|
147
|
-
)
|
|
148
|
-
}
|