@allbluecn/web-app 0.4.22 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +12 -8
- package/src/components/knowledge/knowledge-toolbar.tsx +124 -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 +124 -51
- package/src/components/story/create-story-dialog.tsx +12 -6
- package/src/components/story/detail/__tests__/associated-attachments-panel.test.tsx +114 -0
- package/src/components/story/detail/activity-timeline.tsx +15 -0
- package/src/components/story/detail/story-detail-body.tsx +14 -3
- package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +748 -0
- package/src/components/story/detail/story-detail-toolbar/categories.tsx +23 -18
- package/src/components/story/detail/story-detail-toolbar/children.tsx +51 -274
- package/src/components/story/detail/story-detail-toolbar/dependencies.tsx +13 -45
- package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +473 -0
- package/src/components/story/detail/story-detail-toolbar.tsx +237 -10
- 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 +107 -16
- package/src/components/story/peek-panel.tsx +8 -1
- package/src/components/story/relations/__tests__/attachment-manage-dialog.test.tsx +171 -0
- package/src/components/story/relations/__tests__/knowledge-select-dialog.test.tsx +174 -0
- package/src/components/story/relations/__tests__/link-manage-dialog.test.tsx +93 -0
- package/src/components/story/relations/attachment-manage-dialog.tsx +490 -0
- package/src/components/story/relations/file-preview.tsx +173 -0
- package/src/components/story/relations/format.ts +120 -0
- package/src/{config/sidebar-notifications.ts → components/story/relations/general-groups.ts} +12 -30
- package/src/components/story/relations/knowledge-select-dialog.tsx +403 -0
- package/src/components/story/relations/link-manage-dialog.tsx +191 -0
- 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 +30 -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/lib/story/__tests__/video-embed.test.ts +77 -0
- package/src/lib/story/video-embed.ts +59 -0
- package/src/routeTree.gen.ts +65 -0
- package/src/routes/_nav/notifications/route.lazy.tsx +22 -0
- package/src/routes/_nav/notifications/route.tsx +30 -0
- package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +74 -3
- package/src/routes/_nav/projects/$projectId/settings/route.tsx +6 -2
- package/src/routes/_nav/settings/notifications/route.lazy.tsx +6 -5
- package/src/routes/api/attachments/$id/content.ts +60 -0
- package/src/routes/api/attachments/upload.ts +148 -0
- 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__/project-kb.test.ts +147 -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.ts +50 -4
- package/src/server/serverFns/locale.ts +15 -3
- package/src/server/serverFns/notifications.ts +18 -4
- package/src/server/serverFns/project.ts +43 -0
- package/src/server/serverFns/story/__tests__/kb-scope.test.ts +56 -0
- package/src/server/serverFns/story/__tests__/story-assoc.test.ts +222 -0
- package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +265 -0
- package/src/server/serverFns/story/__tests__/story-groups.test.ts +19 -1
- package/src/server/serverFns/story/index.ts +7 -6
- package/src/server/serverFns/story/kb-general.ts +39 -0
- package/src/server/serverFns/story/kb-scope.ts +52 -0
- package/src/server/serverFns/story/link-preview.ts +115 -0
- package/src/server/serverFns/story/story-assoc.ts +339 -50
- package/src/server/serverFns/story/story-attachments.ts +12 -43
- package/src/server/serverFns/story/story-crud.ts +21 -4
- package/src/server/serverFns/story/story-dependencies.ts +138 -162
- package/src/server/serverFns/story/story-groups.ts +23 -3
- package/src/server/serverFns/story/story-links.ts +13 -1
- package/src/server/serverFns/story/story-transfer.ts +5 -3
- package/src/server/serverFns/story/story-tree.ts +28 -1
- package/src/server/serverFns/story/types.ts +37 -0
- package/src/server/serverFns/team.ts +51 -2
- package/src/styles/globals.css +25 -0
- package/vite.shared.ts +2 -0
- package/src/components/notifications/notification-list.tsx +0 -232
- package/src/components/notifications/notification-preferences.tsx +0 -156
- package/src/components/story/detail/story-detail-toolbar/attachments.tsx +0 -73
- package/src/components/story/detail/story-detail-toolbar/knowledge.tsx +0 -73
- package/src/components/story/detail/story-detail-toolbar/links.tsx +0 -73
- package/src/components/story/relation-badge.tsx +0 -57
- package/src/components/story/relations/attachments-panel.tsx +0 -179
- 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/knowledge-panel.tsx +0 -284
- package/src/components/story/relations/links-panel.tsx +0 -240
- package/src/components/story/relations/tab.tsx +0 -212
|
@@ -21,36 +21,6 @@ import { z } from "zod";
|
|
|
21
21
|
import { prisma } from "@allbluecn/database";
|
|
22
22
|
import { requireAuth } from "@/server/auth-helpers";
|
|
23
23
|
|
|
24
|
-
export const uploadAttachmentFn = createServerFn({ method: "POST" })
|
|
25
|
-
.handler(async () => {
|
|
26
|
-
const request = getRequest();
|
|
27
|
-
const session = await requireAuth(request);
|
|
28
|
-
const formData = await request.formData();
|
|
29
|
-
|
|
30
|
-
const storyId = formData.get("storyId") as string;
|
|
31
|
-
const file = formData.get("file") as File;
|
|
32
|
-
|
|
33
|
-
if (!storyId || !file) {
|
|
34
|
-
throw new Error("缺少 storyId 或 file");
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const storageUrl = `/mock-storage/${Date.now()}-${file.name}`;
|
|
38
|
-
const fileSize = BigInt(file.size);
|
|
39
|
-
const mimeType = file.type;
|
|
40
|
-
|
|
41
|
-
const attachment = await prisma.storyAttachment.create({
|
|
42
|
-
data: {
|
|
43
|
-
storyId,
|
|
44
|
-
fileName: file.name,
|
|
45
|
-
fileSize,
|
|
46
|
-
mimeType,
|
|
47
|
-
storageUrl,
|
|
48
|
-
},
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
return attachment;
|
|
52
|
-
});
|
|
53
|
-
|
|
54
24
|
export const removeAttachmentFn = createServerFn({ method: "POST" })
|
|
55
25
|
.validator(z.object({ attachmentId: z.string() }))
|
|
56
26
|
.handler(async ({ data }) => {
|
|
@@ -73,6 +43,14 @@ export const getAttachmentsFn = createServerFn({ method: "GET" })
|
|
|
73
43
|
const attachments = await prisma.storyAttachment.findMany({
|
|
74
44
|
where: { storyId: data.storyId },
|
|
75
45
|
orderBy: { createdAt: "desc" },
|
|
46
|
+
select: {
|
|
47
|
+
id: true,
|
|
48
|
+
fileName: true,
|
|
49
|
+
fileSize: true,
|
|
50
|
+
mimeType: true,
|
|
51
|
+
description: true,
|
|
52
|
+
fileHash: true,
|
|
53
|
+
},
|
|
76
54
|
});
|
|
77
55
|
|
|
78
56
|
return attachments.map((att) => ({
|
|
@@ -80,18 +58,9 @@ export const getAttachmentsFn = createServerFn({ method: "GET" })
|
|
|
80
58
|
fileName: att.fileName,
|
|
81
59
|
fileSize: Number(att.fileSize),
|
|
82
60
|
mimeType: att.mimeType,
|
|
83
|
-
|
|
61
|
+
description: att.description,
|
|
62
|
+
fileHash: att.fileHash,
|
|
63
|
+
// 读取入口统一为内容路由(历史 mock 行无文件体,预览会 404)
|
|
64
|
+
storageUrl: `/api/attachments/${att.id}/content`,
|
|
84
65
|
}));
|
|
85
66
|
});
|
|
86
|
-
|
|
87
|
-
export const mockCloudStorageFn = createServerFn({ method: "POST" })
|
|
88
|
-
.validator(z.object({ file: z.any() }))
|
|
89
|
-
.handler(async ({ data }) => {
|
|
90
|
-
const { file } = data;
|
|
91
|
-
const mockUrl = `/mock-storage/${Date.now()}-${file.name}`;
|
|
92
|
-
|
|
93
|
-
return {
|
|
94
|
-
url: mockUrl,
|
|
95
|
-
provider: "mock",
|
|
96
|
-
};
|
|
97
|
-
});
|
|
@@ -25,7 +25,7 @@ import { isUserInTeam } from "@/server/team-access";
|
|
|
25
25
|
import { BUILTIN_INDEPENDENT_DISPLAY_ID } from "@/lib/builtin-project";
|
|
26
26
|
import type { ActivityItem } from "@/components/story/types";
|
|
27
27
|
import { diffDescriptionBlocks } from "@/lib/story/description-diff";
|
|
28
|
-
import { USER_BRIEF_SELECT, STORY_LIST_SELECT, castStoryUsers, castUserBrief, toDateOnly } from "./types";
|
|
28
|
+
import { USER_BRIEF_SELECT, STORY_LIST_SELECT, castStoryUsers, castUserBrief, toDateOnly, ACTIVITY_SELECT, toActivityItem } from "./types";
|
|
29
29
|
import { buildListWhere } from "./story-list-query";
|
|
30
30
|
|
|
31
31
|
async function validateAssignee(projectId: string, assigneeId: string | null | undefined) {
|
|
@@ -116,9 +116,10 @@ export const createStoryFn = createServerFn({ method: "POST" })
|
|
|
116
116
|
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
117
117
|
try {
|
|
118
118
|
return await prisma.$transaction(async (tx) => {
|
|
119
|
+
let parentStory: { id: string; identifier: string; name: string } | null = null;
|
|
119
120
|
if (data.parentId) {
|
|
120
|
-
|
|
121
|
-
if (!
|
|
121
|
+
parentStory = await tx.story.findUnique({ where: { id: data.parentId }, select: { id: true, identifier: true, name: true } });
|
|
122
|
+
if (!parentStory) throw new Error("父需求不存在");
|
|
122
123
|
}
|
|
123
124
|
let sequenceId: number;
|
|
124
125
|
let identifier: string;
|
|
@@ -177,7 +178,23 @@ export const createStoryFn = createServerFn({ method: "POST" })
|
|
|
177
178
|
data: { storyId: story.id, userId, action: "created", isAI: data.isAI },
|
|
178
179
|
});
|
|
179
180
|
|
|
180
|
-
|
|
181
|
+
// 创建时带父需求:父需求侧补「添加子需求」活动
|
|
182
|
+
let parentActivity: ReturnType<typeof toActivityItem> | null = null;
|
|
183
|
+
if (parentStory) {
|
|
184
|
+
const pa = await tx.storyActivity.create({
|
|
185
|
+
data: {
|
|
186
|
+
storyId: parentStory.id,
|
|
187
|
+
userId,
|
|
188
|
+
action: "child_added",
|
|
189
|
+
newValue: `${story.identifier} ${story.name}`.trim(),
|
|
190
|
+
isAI: data.isAI,
|
|
191
|
+
},
|
|
192
|
+
select: ACTIVITY_SELECT,
|
|
193
|
+
});
|
|
194
|
+
parentActivity = toActivityItem(pa);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return { ...castStoryUsers(story), activities: parentActivity ? [parentActivity] : [] };
|
|
181
198
|
});
|
|
182
199
|
} catch (e: unknown) {
|
|
183
200
|
const isUniqueViolation =
|
|
@@ -20,199 +20,175 @@ 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, ACTIVITY_SELECT, toActivityItem } 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
|
const session = await requireAuth(request);
|
|
45
|
+
const userId = session.user!.id!;
|
|
59
46
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
targetId: data.targetId,
|
|
65
|
-
type: data.type,
|
|
66
|
-
},
|
|
67
|
-
},
|
|
47
|
+
// 一次性取全部出边(3 类型合并;不按 projectId 裁剪——跨项目边同样参与环判定),
|
|
48
|
+
// 构建内存邻接表供本批循环的环检测复用,避免 O(items × 可达节点) 次串行 RTT
|
|
49
|
+
const allEdges = await prisma.storyDependency.findMany({
|
|
50
|
+
select: { sourceId: true, targetId: true },
|
|
68
51
|
});
|
|
69
|
-
|
|
70
|
-
|
|
52
|
+
const adjacency = new Map<string, string[]>();
|
|
53
|
+
for (const e of allEdges) {
|
|
54
|
+
const list = adjacency.get(e.sourceId);
|
|
55
|
+
if (list) list.push(e.targetId);
|
|
56
|
+
else adjacency.set(e.sourceId, [e.targetId]);
|
|
71
57
|
}
|
|
72
58
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
|
|
59
|
+
// 环检测:从 fromId 出发沿 source→target 有向边能否到达 toId(纯内存遍历)
|
|
60
|
+
// batchEdges:本批已插入边(防批内互指环漏检)
|
|
61
|
+
function reaches(
|
|
62
|
+
fromId: string,
|
|
63
|
+
toId: string,
|
|
64
|
+
batchEdges: Array<{ sourceId: string; targetId: string }>,
|
|
65
|
+
): boolean {
|
|
66
|
+
const visited = new Set<string>();
|
|
67
|
+
const stack = [fromId];
|
|
68
|
+
while (stack.length > 0) {
|
|
69
|
+
const current = stack.pop()!;
|
|
70
|
+
if (current === toId) return true;
|
|
71
|
+
if (visited.has(current)) continue;
|
|
72
|
+
visited.add(current);
|
|
73
|
+
for (const next of adjacency.get(current) ?? []) {
|
|
74
|
+
if (!visited.has(next)) stack.push(next);
|
|
75
|
+
}
|
|
76
|
+
for (const e of batchEdges) {
|
|
77
|
+
if (e.sourceId === current && !visited.has(e.targetId)) stack.push(e.targetId);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const created: string[] = [];
|
|
84
|
+
const failed: Array<{ targetId: string; reason: "SELF" | "DUPLICATED" | "CYCLE" }> = [];
|
|
85
|
+
const batchEdges: Array<{ sourceId: string; targetId: string }> = [];
|
|
80
86
|
|
|
81
|
-
|
|
87
|
+
for (const item of data.items) {
|
|
88
|
+
if (item.sourceId === item.targetId) {
|
|
89
|
+
failed.push({ targetId: item.targetId, reason: "SELF" });
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const existing = await prisma.storyDependency.findFirst({
|
|
93
|
+
where: {
|
|
94
|
+
OR: [
|
|
95
|
+
{ sourceId: item.sourceId, targetId: item.targetId },
|
|
96
|
+
{ sourceId: item.targetId, targetId: item.sourceId },
|
|
97
|
+
],
|
|
98
|
+
},
|
|
99
|
+
select: { id: true },
|
|
100
|
+
});
|
|
101
|
+
if (existing) {
|
|
102
|
+
failed.push({ targetId: item.targetId, reason: "DUPLICATED" });
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
// 插入 (S,T) 后成环 ⇔ 已存在路径 T →…→ S(含本批边)
|
|
106
|
+
if (reaches(item.targetId, item.sourceId, batchEdges)) {
|
|
107
|
+
failed.push({ targetId: item.targetId, reason: "CYCLE" });
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const dep = await prisma.storyDependency.create({ data: item });
|
|
111
|
+
created.push(dep.id);
|
|
112
|
+
batchEdges.push({ sourceId: item.sourceId, targetId: item.targetId });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 活动记录:依赖两端各记一条「添加依赖关系」(newValue 为对方需求描述),
|
|
116
|
+
// 返回带 storyId 供 UI 过滤当前需求侧的记录
|
|
117
|
+
let activities: Array<ReturnType<typeof toActivityItem> & { storyId: string }> = [];
|
|
118
|
+
if (created.length > 0) {
|
|
119
|
+
const endpointIds = [...new Set(batchEdges.flatMap((e) => [e.sourceId, e.targetId]))];
|
|
120
|
+
const endpoints = await prisma.story.findMany({
|
|
121
|
+
where: { id: { in: endpointIds } },
|
|
122
|
+
select: { id: true, identifier: true, name: true },
|
|
123
|
+
});
|
|
124
|
+
const labelById = new Map(
|
|
125
|
+
endpoints.map((s) => [s.id, `${s.identifier} ${s.name}`.trim()]),
|
|
126
|
+
);
|
|
127
|
+
const createdActivities = await Promise.all(
|
|
128
|
+
batchEdges.flatMap(({ sourceId, targetId }) => {
|
|
129
|
+
const sourceLabel = labelById.get(sourceId) ?? "";
|
|
130
|
+
const targetLabel = labelById.get(targetId) ?? "";
|
|
131
|
+
return [
|
|
132
|
+
prisma.storyActivity.create({
|
|
133
|
+
data: { storyId: sourceId, userId, action: "dependency_added", newValue: targetLabel },
|
|
134
|
+
select: { ...ACTIVITY_SELECT, storyId: true },
|
|
135
|
+
}),
|
|
136
|
+
prisma.storyActivity.create({
|
|
137
|
+
data: { storyId: targetId, userId, action: "dependency_added", newValue: sourceLabel },
|
|
138
|
+
select: { ...ACTIVITY_SELECT, storyId: true },
|
|
139
|
+
}),
|
|
140
|
+
];
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
activities = createdActivities.map((a) => ({ ...toActivityItem(a), storyId: a.storyId }));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { created, failed, activities };
|
|
82
147
|
});
|
|
83
148
|
|
|
84
149
|
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
|
-
)
|
|
150
|
+
.validator(z.object({ depId: z.string() }))
|
|
92
151
|
.handler(async ({ data }) => {
|
|
93
152
|
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;
|
|
153
|
+
await requireAuth(request);
|
|
154
|
+
return prisma.storyDependency.delete({ where: { id: data.depId } });
|
|
107
155
|
});
|
|
108
156
|
|
|
109
157
|
export const getDependenciesFn = createServerFn({ method: "GET" })
|
|
110
158
|
.validator(z.object({ storyId: z.string() }))
|
|
111
159
|
.handler(async ({ data }) => {
|
|
112
160
|
const request = getRequest();
|
|
113
|
-
|
|
161
|
+
await requireAuth(request);
|
|
114
162
|
|
|
115
163
|
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
|
-
},
|
|
164
|
+
where: { OR: [{ targetId: data.storyId }, { sourceId: data.storyId }] },
|
|
165
|
+
select: { id: true, sourceId: true, targetId: true, type: true },
|
|
127
166
|
});
|
|
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: "不能创建自我依赖" };
|
|
167
|
+
if (deps.length === 0) {
|
|
168
|
+
return { dependsOn: [] as DependencyRow[], dependents: [] as DependencyRow[] };
|
|
177
169
|
}
|
|
178
170
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
const source = await prisma.story.findUnique({
|
|
198
|
-
where: { id: data.sourceId },
|
|
199
|
-
select: { parentId: true },
|
|
200
|
-
});
|
|
201
|
-
const target = await prisma.story.findUnique({
|
|
202
|
-
where: { id: data.targetId },
|
|
203
|
-
select: { parentId: true },
|
|
171
|
+
const ids = [...new Set(deps.flatMap((d) => [d.sourceId, d.targetId]))];
|
|
172
|
+
const stories = await prisma.story.findMany({
|
|
173
|
+
where: { id: { in: ids } },
|
|
174
|
+
select: STORY_LIST_SELECT,
|
|
204
175
|
});
|
|
176
|
+
const byId = new Map(stories.map((s) => [s.id, castStoryUsers(s)]));
|
|
205
177
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
if (await hasCycle(data.sourceId, data.targetId)) {
|
|
214
|
-
return { valid: false, reason: "会形成循环依赖" };
|
|
215
|
-
}
|
|
178
|
+
const build = (d: (typeof deps)[number], storyId: string): DependencyRow | null => {
|
|
179
|
+
const otherId = d.sourceId === storyId ? d.targetId : d.sourceId;
|
|
180
|
+
const s = byId.get(otherId);
|
|
181
|
+
return s ? ({ ...s, depId: d.id, depType: d.type } as DependencyRow) : null;
|
|
182
|
+
};
|
|
216
183
|
|
|
217
|
-
return {
|
|
184
|
+
return {
|
|
185
|
+
dependsOn: deps
|
|
186
|
+
.filter((d) => d.targetId === data.storyId)
|
|
187
|
+
.map((d) => build(d, data.storyId))
|
|
188
|
+
.filter((r): r is DependencyRow => r !== null),
|
|
189
|
+
dependents: deps
|
|
190
|
+
.filter((d) => d.sourceId === data.storyId)
|
|
191
|
+
.map((d) => build(d, data.storyId))
|
|
192
|
+
.filter((r): r is DependencyRow => r !== null),
|
|
193
|
+
};
|
|
218
194
|
});
|
|
@@ -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
|
});
|
|
@@ -20,12 +20,14 @@ 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 { ACTIVITY_SELECT, toActivityItem } from "./types";
|
|
23
24
|
|
|
24
25
|
export const addLinkFn = createServerFn({ method: "POST" })
|
|
25
26
|
.validator(z.object({ storyId: z.string(), url: z.string().url(), title: z.string().optional(), description: z.string().optional() }))
|
|
26
27
|
.handler(async ({ data }) => {
|
|
27
28
|
const request = getRequest();
|
|
28
29
|
const session = await requireAuth(request);
|
|
30
|
+
const userId = session.user!.id!;
|
|
29
31
|
|
|
30
32
|
const link = await prisma.storyLink.create({
|
|
31
33
|
data: {
|
|
@@ -36,7 +38,17 @@ export const addLinkFn = createServerFn({ method: "POST" })
|
|
|
36
38
|
},
|
|
37
39
|
});
|
|
38
40
|
|
|
39
|
-
|
|
41
|
+
const activity = await prisma.storyActivity.create({
|
|
42
|
+
data: {
|
|
43
|
+
storyId: data.storyId,
|
|
44
|
+
userId,
|
|
45
|
+
action: "link_added",
|
|
46
|
+
newValue: data.title || data.url,
|
|
47
|
+
},
|
|
48
|
+
select: ACTIVITY_SELECT,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return { ...link, activity: toActivityItem(activity) };
|
|
40
52
|
});
|
|
41
53
|
|
|
42
54
|
export const updateLinkFn = createServerFn({ method: "POST" })
|
|
@@ -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) } }
|
|
@@ -54,7 +54,8 @@ export const addChildFn = createServerFn({ method: "POST" })
|
|
|
54
54
|
.validator(z.object({ parentId: z.string(), childId: z.string() }))
|
|
55
55
|
.handler(async ({ data }) => {
|
|
56
56
|
const request = getRequest();
|
|
57
|
-
await requireAuth(request);
|
|
57
|
+
const session = await requireAuth(request);
|
|
58
|
+
const userId = session.user!.id!;
|
|
58
59
|
|
|
59
60
|
const existingChild = await prisma.story.findUnique({
|
|
60
61
|
where: { id: data.childId },
|
|
@@ -68,11 +69,37 @@ export const addChildFn = createServerFn({ method: "POST" })
|
|
|
68
69
|
throw new Error("不能将子需求设为其父需求的子需求(循环依赖)");
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
const parent = await prisma.story.findUnique({
|
|
73
|
+
where: { id: data.parentId },
|
|
74
|
+
select: { id: true, identifier: true, name: true },
|
|
75
|
+
});
|
|
76
|
+
|
|
71
77
|
const child = await prisma.story.update({
|
|
72
78
|
where: { id: data.childId },
|
|
73
79
|
data: { parentId: data.parentId },
|
|
74
80
|
});
|
|
75
81
|
|
|
82
|
+
// 活动记录:父需求记「添加子需求」,子需求记「变更父需求」(与 updateStoryFn 格式一致)
|
|
83
|
+
if (parent) {
|
|
84
|
+
const childLabel = `${existingChild?.identifier ?? ""} ${existingChild?.name ?? ""}`.trim();
|
|
85
|
+
const parentLabel = `${parent.identifier} ${parent.name}`.trim();
|
|
86
|
+
await prisma.$transaction([
|
|
87
|
+
prisma.storyActivity.create({
|
|
88
|
+
data: { storyId: parent.id, userId, action: "child_added", newValue: childLabel },
|
|
89
|
+
}),
|
|
90
|
+
prisma.storyActivity.create({
|
|
91
|
+
data: {
|
|
92
|
+
storyId: child.id,
|
|
93
|
+
userId,
|
|
94
|
+
action: "parent_changed",
|
|
95
|
+
field: "parentId",
|
|
96
|
+
oldValue: "",
|
|
97
|
+
newValue: parentLabel,
|
|
98
|
+
},
|
|
99
|
+
}),
|
|
100
|
+
]);
|
|
101
|
+
}
|
|
102
|
+
|
|
76
103
|
return child;
|
|
77
104
|
});
|
|
78
105
|
|
|
@@ -21,6 +21,7 @@ import type { StoryState, StoryPriority } from "@/components/story/constants";
|
|
|
21
21
|
import type {
|
|
22
22
|
UserBrief,
|
|
23
23
|
ReactionGroup,
|
|
24
|
+
ActivityItem,
|
|
24
25
|
} from "@/components/story/types";
|
|
25
26
|
|
|
26
27
|
export const USER_BRIEF_SELECT = {
|
|
@@ -140,6 +141,42 @@ export function castUserBrief(u: {
|
|
|
140
141
|
};
|
|
141
142
|
}
|
|
142
143
|
|
|
144
|
+
/** 活动记录 select(含 user brief),供 serverFn 返回新创建的活动给 UI 即时追加 */
|
|
145
|
+
export const ACTIVITY_SELECT = {
|
|
146
|
+
id: true,
|
|
147
|
+
action: true,
|
|
148
|
+
field: true,
|
|
149
|
+
oldValue: true,
|
|
150
|
+
newValue: true,
|
|
151
|
+
isAI: true,
|
|
152
|
+
createdAt: true,
|
|
153
|
+
user: { select: USER_BRIEF_SELECT },
|
|
154
|
+
} as const;
|
|
155
|
+
|
|
156
|
+
export interface ActivityRow {
|
|
157
|
+
id: string;
|
|
158
|
+
action: string;
|
|
159
|
+
field: string | null;
|
|
160
|
+
oldValue: string | null;
|
|
161
|
+
newValue: string | null;
|
|
162
|
+
isAI: boolean;
|
|
163
|
+
createdAt: Date;
|
|
164
|
+
user: { id: string; username: string; email: string; avatarConfig: unknown; bgShape: string | null; bgColor: string | null };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function toActivityItem(a: ActivityRow): ActivityItem {
|
|
168
|
+
return {
|
|
169
|
+
id: a.id,
|
|
170
|
+
action: a.action,
|
|
171
|
+
field: a.field,
|
|
172
|
+
oldValue: a.oldValue,
|
|
173
|
+
newValue: a.newValue,
|
|
174
|
+
isAI: a.isAI,
|
|
175
|
+
createdAt: a.createdAt.toISOString(),
|
|
176
|
+
user: castUserBrief(a.user),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
143
180
|
export function aggregateReactions(
|
|
144
181
|
reactions: { emoji: string; userId: string }[],
|
|
145
182
|
currentUserId: string,
|