@allbluecn/web-app 0.5.1 → 0.6.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 +230 -30
- package/src/components/iteration/auto-schedule-dialog.tsx +150 -0
- package/src/components/iteration/burndown-chart.tsx +63 -0
- package/src/components/iteration/constants.ts +28 -0
- package/src/components/iteration/create-iteration-dialog.tsx +140 -0
- package/src/components/iteration/iteration-card.tsx +60 -0
- package/src/components/iteration/iteration-editor.tsx +116 -0
- package/src/components/iteration/iteration-progress-bar.tsx +44 -0
- package/src/components/iteration/iteration-status-badge.tsx +33 -0
- package/src/components/knowledge/dialog/knowledge-form-dialog.tsx +21 -30
- package/src/components/layout/sidebar-03/__tests__/nav-workspace.test.tsx +2 -2
- package/src/components/layout/sidebar-03/nav-workspace.tsx +2 -2
- package/src/components/project/create-project-dialog.tsx +44 -57
- package/src/components/shared/__tests__/lucide-curated-icons.test.ts +83 -0
- package/src/components/shared/__tests__/lucide-icon-data.test.ts +131 -0
- package/src/components/shared/__tests__/lucide-icon-picker.test.tsx +197 -0
- package/src/components/shared/lucide-icon-picker/curated-icons.ts +478 -0
- package/src/components/shared/lucide-icon-picker/icon-data.ts +109 -0
- package/src/components/shared/lucide-icon-picker/index.tsx +82 -0
- package/src/components/shared/lucide-icon-picker/lucide-icon-by-name.tsx +78 -0
- package/src/components/shared/lucide-icon-picker/panel.tsx +163 -0
- package/src/components/story/__tests__/constants.test.ts +255 -0
- package/src/components/story/ai/story-ai-panel.tsx +23 -15
- package/src/components/story/ai/story-ai-preview-list.tsx +19 -0
- package/src/components/story/constants.ts +33 -1
- package/src/components/story/create-story-dialog.tsx +30 -10
- package/src/components/story/detail/__tests__/categories.test.tsx +62 -0
- package/src/components/story/detail/__tests__/dependencies-button.test.tsx +49 -0
- package/src/components/story/detail/activity-timeline.tsx +11 -4
- package/src/components/story/detail/story-detail-body.tsx +89 -38
- package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +118 -9
- package/src/components/story/detail/story-detail-toolbar/children.tsx +7 -4
- package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +67 -7
- package/src/components/story/detail/story-detail-toolbar.tsx +6 -1
- package/src/components/story/detail/story-properties.tsx +21 -4
- package/src/components/story/display/__tests__/group-fields.test.ts +107 -0
- package/src/components/story/display/group-resolver.tsx +2 -2
- package/src/components/story/inline-editors/__tests__/dependency-type-select.test.tsx +155 -0
- package/src/components/story/inline-editors/__tests__/labels-editor.test.tsx +155 -0
- package/src/components/story/inline-editors/__tests__/priority-editor.test.tsx +173 -0
- package/src/components/story/inline-editors/__tests__/state-editor.test.tsx +174 -0
- package/src/components/story/inline-editors/dependency-type-select.tsx +180 -0
- package/src/components/story/inline-editors/index.ts +2 -1
- package/src/components/story/inline-editors/labels-editor.tsx +159 -3
- package/src/components/story/inline-editors/story-select-dialog.tsx +22 -59
- package/src/components/story/peek-panel.tsx +121 -21
- package/src/components/story/relations/link-manage-dialog.tsx +2 -1
- package/src/components/story/relations/story-ref-row.tsx +17 -27
- package/src/components/story/story-panel-header.tsx +25 -19
- package/src/components/story/story-toolbar.tsx +3 -3
- package/src/components/story/transfer-story-popover.tsx +2 -2
- package/src/components/story/types.ts +1 -0
- package/src/components/story/views/create-view-dialog.tsx +3 -3
- package/src/components/story/views/story-list/__tests__/group-header.test.tsx +219 -0
- package/src/components/story/views/story-list/group.tsx +1 -1
- package/src/components/story/views/story-list/inline-story-creator.tsx +2 -2
- package/src/components/story/views/story-list/row.tsx +1 -1
- package/src/components/workspace/create-workspace-dialog.tsx +41 -49
- package/src/lib/__tests__/project-display-id.test.ts +8 -5
- package/src/lib/changelog/sdk-versions.ts +20 -20
- package/src/lib/iteration/__tests__/status.test.ts +41 -0
- package/src/lib/iteration/status.ts +28 -0
- package/src/lib/project-display-id.ts +14 -5
- package/src/lib/project-module-meta.ts +77 -0
- package/src/lib/utils.d.ts +1 -1
- package/src/providers/index.tsx +6 -0
- package/src/routeTree.gen.ts +54 -0
- package/src/routes/__root.tsx +0 -8
- package/src/routes/_nav/knowledge/index.lazy.tsx +2 -2
- package/src/routes/_nav/projects/$projectId/$storyId/route.lazy.tsx +52 -25
- package/src/routes/_nav/projects/$projectId/$storyId/route.tsx +6 -3
- package/src/routes/_nav/projects/$projectId/index.lazy.tsx +32 -15
- package/src/routes/_nav/projects/$projectId/iterations/$iterationId/route.lazy.tsx +289 -0
- package/src/routes/_nav/projects/$projectId/iterations/$iterationId/route.tsx +27 -0
- package/src/routes/_nav/projects/$projectId/iterations/index.lazy.tsx +111 -0
- package/src/routes/_nav/projects/$projectId/iterations/index.tsx +24 -0
- package/src/routes/_nav/projects/$projectId/route.tsx +4 -2
- package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +4 -4
- package/src/routes/_nav/stories/$storyId/route.lazy.tsx +5 -1
- package/src/routes/_nav/stories/$storyId/route.tsx +4 -0
- package/src/routes/_nav/workspaces/$workspaceId/route.lazy.tsx +3 -3
- package/src/routes/_nav/workspaces/$workspaceId/settings/route.lazy.tsx +4 -4
- package/src/routes/_nav/workspaces/index.lazy.tsx +3 -3
- package/src/server/serverFns/iteration/__tests__/auto-schedule-core.test.ts +62 -0
- package/src/server/serverFns/iteration/__tests__/burndown-core.test.ts +90 -0
- package/src/server/serverFns/iteration/__tests__/iteration-crud.test.ts +211 -0
- package/src/server/serverFns/iteration/__tests__/iteration-stories.test.ts +155 -0
- package/src/server/serverFns/iteration/__tests__/iteration-transfer.test.ts +136 -0
- package/src/server/serverFns/iteration/auto-schedule-core.ts +54 -0
- package/src/server/serverFns/iteration/auto-schedule-internal.ts +88 -0
- package/src/server/serverFns/iteration/burndown-core.ts +60 -0
- package/src/server/serverFns/iteration/index.ts +22 -0
- package/src/server/serverFns/iteration/iteration-auto-schedule.ts +66 -0
- package/src/server/serverFns/iteration/iteration-burndown.ts +70 -0
- package/src/server/serverFns/iteration/iteration-crud.ts +208 -0
- package/src/server/serverFns/iteration/iteration-stories.ts +111 -0
- package/src/server/serverFns/iteration/iteration-transfer.ts +42 -0
- package/src/server/serverFns/iteration/schemas.ts +58 -0
- package/src/server/serverFns/iteration/transfer-internal.ts +62 -0
- package/src/server/serverFns/project/module-overview.ts +109 -0
- package/src/server/serverFns/project.ts +2 -2
- package/src/server/serverFns/story/__tests__/label-suggest-fn.test.ts +100 -0
- package/src/server/serverFns/story/__tests__/labels.test.ts +67 -0
- package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +106 -2
- package/src/server/serverFns/story/index.ts +3 -0
- package/src/server/serverFns/story/story-assoc.ts +36 -4
- package/src/server/serverFns/story/story-attachments.ts +20 -2
- package/src/{components/project/project-icon.tsx → server/serverFns/story/story-crud/label-palette.ts} +16 -19
- package/src/server/serverFns/story/story-crud/label-suggest-core.ts +88 -0
- package/src/server/serverFns/story/story-crud/label-suggest.ts +38 -0
- package/src/{components/knowledge/knowledge-icon.tsx → server/serverFns/story/story-crud/label-upsert.ts} +13 -19
- package/src/server/serverFns/story/story-crud/labels.ts +3 -5
- package/src/server/serverFns/story/story-crud.ts +32 -2
- package/src/server/serverFns/story/story-dependencies.ts +123 -2
- package/src/server/serverFns/story/story-links.ts +30 -6
- package/src/server/serverFns/story/story-transfer.ts +5 -4
- package/src/server/serverFns/story/types.ts +1 -0
- package/src/types/project.ts +5 -5
- package/src/components/project/project-animated-icon.tsx +0 -73
- package/src/components/shared/icon-packs/index.ts +0 -1
- package/src/components/shared/icon-packs/knowledge.ts +0 -1
- package/src/components/shared/icon-packs/prd.ts +0 -1
- package/src/components/shared/icon-packs/project.ts +0 -1
- package/src/components/shared/icon-picker.tsx +0 -1
- package/src/lib/project-mock.ts +0 -156
|
@@ -0,0 +1,111 @@
|
|
|
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 { createServerFn } from "@tanstack/react-start";
|
|
19
|
+
import { getRequest } from "@tanstack/react-start/server";
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { prisma } from "@allbluecn/database";
|
|
22
|
+
import { requireAuth } from "@/server/auth-helpers";
|
|
23
|
+
import { deriveIterationStatus, toDatePart } from "@/lib/iteration/status";
|
|
24
|
+
|
|
25
|
+
/** 目标迭代必须可加入:存在、未归档、非 completed(规格 §3.2) */
|
|
26
|
+
async function assertTargetJoinable(iterationId: string) {
|
|
27
|
+
const it = await prisma.iteration.findUnique({
|
|
28
|
+
where: { id: iterationId },
|
|
29
|
+
select: { startDate: true, endDate: true, archived: true, name: true },
|
|
30
|
+
});
|
|
31
|
+
if (!it || it.archived) throw new Error("迭代不存在");
|
|
32
|
+
if (deriveIterationStatus(toDatePart(it.startDate), toDatePart(it.endDate)) === "completed") {
|
|
33
|
+
throw new Error("不能将需求加入已完成的迭代");
|
|
34
|
+
}
|
|
35
|
+
return it.name;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const setStoryIterationFn = createServerFn({ method: "POST" })
|
|
39
|
+
.validator(z.object({ storyId: z.string(), iterationId: z.string().nullable() }))
|
|
40
|
+
.handler(async ({ data }) => {
|
|
41
|
+
const request = getRequest();
|
|
42
|
+
const session = await requireAuth(request);
|
|
43
|
+
const story = await prisma.story.findUnique({
|
|
44
|
+
where: { id: data.storyId },
|
|
45
|
+
select: { iterationId: true, iteration: { select: { name: true } } },
|
|
46
|
+
});
|
|
47
|
+
if (!story) throw new Error("需求不存在");
|
|
48
|
+
let newName: string | null = null;
|
|
49
|
+
if (data.iterationId) newName = await assertTargetJoinable(data.iterationId);
|
|
50
|
+
await prisma.story.update({ where: { id: data.storyId }, data: { iterationId: data.iterationId } });
|
|
51
|
+
await prisma.storyActivity.create({
|
|
52
|
+
data: {
|
|
53
|
+
storyId: data.storyId,
|
|
54
|
+
userId: session.user!.id!,
|
|
55
|
+
action: "iteration_changed",
|
|
56
|
+
field: "iterationId",
|
|
57
|
+
oldValue: story.iteration?.name ?? null,
|
|
58
|
+
newValue: newName,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
return { ok: true };
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export const addStoriesToIterationFn = createServerFn({ method: "POST" })
|
|
65
|
+
.validator(z.object({ iterationId: z.string(), storyIds: z.array(z.string()).min(1).max(200) }))
|
|
66
|
+
.handler(async ({ data }) => {
|
|
67
|
+
const request = getRequest();
|
|
68
|
+
const session = await requireAuth(request);
|
|
69
|
+
const newName = await assertTargetJoinable(data.iterationId);
|
|
70
|
+
const { count } = await prisma.story.updateMany({
|
|
71
|
+
where: { id: { in: data.storyIds } },
|
|
72
|
+
data: { iterationId: data.iterationId },
|
|
73
|
+
});
|
|
74
|
+
await prisma.storyActivity.createMany({
|
|
75
|
+
data: data.storyIds.map((storyId) => ({
|
|
76
|
+
storyId,
|
|
77
|
+
userId: session.user!.id!,
|
|
78
|
+
action: "iteration_changed",
|
|
79
|
+
field: "iterationId",
|
|
80
|
+
newValue: newName,
|
|
81
|
+
})),
|
|
82
|
+
});
|
|
83
|
+
return { count };
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
export const removeStoryFromIterationFn = createServerFn({ method: "POST" })
|
|
87
|
+
.validator(z.object({ storyId: z.string() }))
|
|
88
|
+
.handler(async ({ data }) => {
|
|
89
|
+
const request = getRequest();
|
|
90
|
+
const session = await requireAuth(request);
|
|
91
|
+
const story = await prisma.story.findUnique({
|
|
92
|
+
where: { id: data.storyId },
|
|
93
|
+
select: { iterationId: true, iteration: { select: { name: true } } },
|
|
94
|
+
});
|
|
95
|
+
if (!story?.iterationId) return { ok: true };
|
|
96
|
+
await prisma.story.updateMany({
|
|
97
|
+
where: { id: data.storyId, iterationId: story.iterationId },
|
|
98
|
+
data: { iterationId: null },
|
|
99
|
+
});
|
|
100
|
+
await prisma.storyActivity.create({
|
|
101
|
+
data: {
|
|
102
|
+
storyId: data.storyId,
|
|
103
|
+
userId: session.user!.id!,
|
|
104
|
+
action: "iteration_changed",
|
|
105
|
+
field: "iterationId",
|
|
106
|
+
oldValue: story.iteration?.name ?? null,
|
|
107
|
+
newValue: null,
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
return { ok: true };
|
|
111
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
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 { createServerFn } from "@tanstack/react-start";
|
|
19
|
+
import { getRequest } from "@tanstack/react-start/server";
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { requireAuth } from "@/server/auth-helpers";
|
|
22
|
+
import { transferStoriesInternal } from "./transfer-internal";
|
|
23
|
+
|
|
24
|
+
export const transferUnfinishedSchema = z.object({
|
|
25
|
+
iterationId: z.string(),
|
|
26
|
+
targetIterationId: z.string(),
|
|
27
|
+
storyIds: z.array(z.string()).optional(),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export const transferUnfinishedFn = createServerFn({ method: "POST" })
|
|
31
|
+
.validator(transferUnfinishedSchema)
|
|
32
|
+
.handler(async ({ data }) => {
|
|
33
|
+
const request = getRequest();
|
|
34
|
+
const session = await requireAuth(request);
|
|
35
|
+
const count = await transferStoriesInternal(
|
|
36
|
+
data.iterationId,
|
|
37
|
+
data.targetIterationId,
|
|
38
|
+
data.storyIds,
|
|
39
|
+
session.user!.id!,
|
|
40
|
+
);
|
|
41
|
+
return { count };
|
|
42
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
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 { z } from "zod";
|
|
19
|
+
|
|
20
|
+
/** yyyy-MM-dd 校验 */
|
|
21
|
+
export const dateOnly = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "日期格式应为 yyyy-MM-dd");
|
|
22
|
+
|
|
23
|
+
export const dateRange = z
|
|
24
|
+
.object({ startDate: dateOnly, endDate: dateOnly })
|
|
25
|
+
.refine((v) => v.startDate <= v.endDate, { message: "结束日期不能早于开始日期" });
|
|
26
|
+
|
|
27
|
+
export const listIterationsSchema = z.object({
|
|
28
|
+
projectId: z.string(),
|
|
29
|
+
includeArchived: z.boolean().default(false),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export const createIterationSchema = dateRange.extend({
|
|
33
|
+
projectId: z.string(),
|
|
34
|
+
name: z.string().min(1).max(100),
|
|
35
|
+
description: z.string().max(20000).optional(),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export const updateIterationSchema = z.object({
|
|
39
|
+
id: z.string(),
|
|
40
|
+
name: z.string().min(1).max(100).optional(),
|
|
41
|
+
description: z.string().max(20000).optional(),
|
|
42
|
+
startDate: dateOnly.optional(),
|
|
43
|
+
endDate: dateOnly.optional(),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
export const iterationIdSchema = z.object({ id: z.string() });
|
|
47
|
+
|
|
48
|
+
export type IterationSummary = {
|
|
49
|
+
id: string;
|
|
50
|
+
name: string;
|
|
51
|
+
description: string | null;
|
|
52
|
+
startDate: string;
|
|
53
|
+
endDate: string;
|
|
54
|
+
archived: boolean;
|
|
55
|
+
status: "upcoming" | "active" | "completed";
|
|
56
|
+
storyCount: number;
|
|
57
|
+
stateCounts: Record<string, number>;
|
|
58
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
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 { prisma } from "@allbluecn/database";
|
|
19
|
+
import { deriveIterationStatus, toDatePart } from "@/lib/iteration/status";
|
|
20
|
+
|
|
21
|
+
/** 内部复用(用户勾选路径 + autoRollover):目标校验 + 幂等 where 批量转移(规格 §3.5) */
|
|
22
|
+
export async function transferStoriesInternal(
|
|
23
|
+
sourceIterationId: string,
|
|
24
|
+
targetIterationId: string,
|
|
25
|
+
storyIds?: string[],
|
|
26
|
+
userId?: string,
|
|
27
|
+
): Promise<number> {
|
|
28
|
+
const target = await prisma.iteration.findUnique({
|
|
29
|
+
where: { id: targetIterationId },
|
|
30
|
+
select: { startDate: true, endDate: true, archived: true, name: true, projectId: true },
|
|
31
|
+
});
|
|
32
|
+
if (!target || target.archived) throw new Error("迭代不存在");
|
|
33
|
+
if (deriveIterationStatus(toDatePart(target.startDate), toDatePart(target.endDate)) === "completed") {
|
|
34
|
+
throw new Error("不能将需求加入已完成的迭代");
|
|
35
|
+
}
|
|
36
|
+
const unfinished = await prisma.story.findMany({
|
|
37
|
+
where: {
|
|
38
|
+
iterationId: sourceIterationId,
|
|
39
|
+
state: { notIn: ["done", "cancelled"] },
|
|
40
|
+
...(storyIds ? { id: { in: storyIds } } : {}),
|
|
41
|
+
},
|
|
42
|
+
select: { id: true },
|
|
43
|
+
});
|
|
44
|
+
if (unfinished.length === 0) return 0;
|
|
45
|
+
const ids = unfinished.map((s) => s.id);
|
|
46
|
+
let actorId = userId;
|
|
47
|
+
if (!actorId) {
|
|
48
|
+
const owner = await prisma.project.findUnique({ where: { id: target.projectId }, select: { ownerId: true } });
|
|
49
|
+
actorId = owner!.ownerId!;
|
|
50
|
+
}
|
|
51
|
+
await prisma.story.updateMany({ where: { id: { in: ids } }, data: { iterationId: targetIterationId } });
|
|
52
|
+
await prisma.storyActivity.createMany({
|
|
53
|
+
data: ids.map((storyId) => ({
|
|
54
|
+
storyId,
|
|
55
|
+
userId: actorId,
|
|
56
|
+
action: "iteration_changed",
|
|
57
|
+
field: "iterationId",
|
|
58
|
+
newValue: target.name,
|
|
59
|
+
})),
|
|
60
|
+
});
|
|
61
|
+
return ids.length;
|
|
62
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
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 { createServerFn } from "@tanstack/react-start";
|
|
19
|
+
import { getRequest } from "@tanstack/react-start/server";
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { prisma } from "@allbluecn/database";
|
|
22
|
+
import { requireAuth } from "@/server/auth-helpers";
|
|
23
|
+
|
|
24
|
+
const projectIdSchema = z.object({ projectId: z.string().min(1) });
|
|
25
|
+
|
|
26
|
+
const moduleStorySchema = z.object({
|
|
27
|
+
id: z.string(),
|
|
28
|
+
title: z.string(),
|
|
29
|
+
state: z.string(),
|
|
30
|
+
priority: z.string(),
|
|
31
|
+
assignee: z.string().nullable().optional(),
|
|
32
|
+
updatedAt: z.string(),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export type ModuleStoryItem = z.infer<typeof moduleStorySchema>;
|
|
36
|
+
|
|
37
|
+
export const getModuleOverviewFn = createServerFn({ method: "GET" })
|
|
38
|
+
.validator(projectIdSchema)
|
|
39
|
+
.handler(async ({ data }) => {
|
|
40
|
+
const request = getRequest();
|
|
41
|
+
await requireAuth(request);
|
|
42
|
+
|
|
43
|
+
const [storyAgg, prdLinks] = await Promise.all([
|
|
44
|
+
prisma.story.aggregate({
|
|
45
|
+
where: { projectId: data.projectId, archived: false },
|
|
46
|
+
_count: { _all: true },
|
|
47
|
+
_max: { updatedAt: true },
|
|
48
|
+
}),
|
|
49
|
+
prisma.storyPrdLink.findMany({
|
|
50
|
+
where: { story: { projectId: data.projectId } },
|
|
51
|
+
select: { prdId: true, createdAt: true },
|
|
52
|
+
}),
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
const prdCount = new Set(prdLinks.map((l) => l.prdId)).size;
|
|
56
|
+
const prdLastUpdated =
|
|
57
|
+
prdLinks.length > 0
|
|
58
|
+
? prdLinks.reduce<Date>(
|
|
59
|
+
(max, l) => (l.createdAt > max ? l.createdAt : max),
|
|
60
|
+
prdLinks[0]!.createdAt,
|
|
61
|
+
)
|
|
62
|
+
: null;
|
|
63
|
+
|
|
64
|
+
// testcase / bug / acceptance 暂无独立数据模型,真实空值
|
|
65
|
+
return {
|
|
66
|
+
requirement: {
|
|
67
|
+
count: storyAgg._count._all,
|
|
68
|
+
lastUpdatedAt: storyAgg._max.updatedAt?.toISOString() ?? null,
|
|
69
|
+
},
|
|
70
|
+
prd: {
|
|
71
|
+
count: prdCount,
|
|
72
|
+
lastUpdatedAt: prdLastUpdated?.toISOString() ?? null,
|
|
73
|
+
},
|
|
74
|
+
design: { count: 0, lastUpdatedAt: null },
|
|
75
|
+
testcase: { count: 0, lastUpdatedAt: null },
|
|
76
|
+
bug: { count: 0, lastUpdatedAt: null },
|
|
77
|
+
acceptance: { count: 0, lastUpdatedAt: null },
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
export const listModuleStoriesFn = createServerFn({ method: "GET" })
|
|
82
|
+
.validator(projectIdSchema)
|
|
83
|
+
.handler(async ({ data }) => {
|
|
84
|
+
const request = getRequest();
|
|
85
|
+
await requireAuth(request);
|
|
86
|
+
const stories = await prisma.story.findMany({
|
|
87
|
+
where: { projectId: data.projectId, archived: false },
|
|
88
|
+
orderBy: { updatedAt: "desc" },
|
|
89
|
+
take: 100,
|
|
90
|
+
select: {
|
|
91
|
+
id: true,
|
|
92
|
+
name: true,
|
|
93
|
+
state: true,
|
|
94
|
+
priority: true,
|
|
95
|
+
updatedAt: true,
|
|
96
|
+
assignee: { select: { username: true } },
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
return {
|
|
100
|
+
items: stories.map((s) => ({
|
|
101
|
+
id: s.id,
|
|
102
|
+
title: s.name,
|
|
103
|
+
state: s.state,
|
|
104
|
+
priority: s.priority,
|
|
105
|
+
assignee: s.assignee?.username ?? null,
|
|
106
|
+
updatedAt: s.updatedAt.toISOString(),
|
|
107
|
+
})),
|
|
108
|
+
};
|
|
109
|
+
});
|
|
@@ -328,8 +328,8 @@ export const deleteProjectFn = createServerFn({ method: "POST" })
|
|
|
328
328
|
await prisma.$transaction(async (tx) => {
|
|
329
329
|
for (const id of storyIds) {
|
|
330
330
|
const counter = await tx.sequenceCounter.upsert({
|
|
331
|
-
where: { scope:
|
|
332
|
-
create: { scope:
|
|
331
|
+
where: { scope: independentId },
|
|
332
|
+
create: { scope: independentId, seq: 1 },
|
|
333
333
|
update: { seq: { increment: 1 } },
|
|
334
334
|
});
|
|
335
335
|
await tx.story.update({
|
|
@@ -0,0 +1,100 @@
|
|
|
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
|
+
const mocks = vi.hoisted(() => ({
|
|
21
|
+
labelFindMany: vi.fn(),
|
|
22
|
+
storyLabelGroupBy: vi.fn(),
|
|
23
|
+
runChat: vi.fn(),
|
|
24
|
+
hasAiKey: vi.fn(),
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
vi.mock("@allbluecn/database", () => ({
|
|
28
|
+
prisma: {
|
|
29
|
+
label: { findMany: mocks.labelFindMany },
|
|
30
|
+
storyLabel: { groupBy: mocks.storyLabelGroupBy },
|
|
31
|
+
},
|
|
32
|
+
}))
|
|
33
|
+
|
|
34
|
+
vi.mock("@allbluecn/ai-gateway", async (importOriginal) => {
|
|
35
|
+
const actual = await importOriginal<typeof import("@allbluecn/ai-gateway")>()
|
|
36
|
+
return {
|
|
37
|
+
...actual,
|
|
38
|
+
runChat: mocks.runChat,
|
|
39
|
+
hasAiKey: mocks.hasAiKey,
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
import { suggestLabelsCore } from "../story-crud/label-suggest-core"
|
|
44
|
+
|
|
45
|
+
const ALL_LABELS = [
|
|
46
|
+
{ id: "l1", name: "性能", color: "#ef4444" },
|
|
47
|
+
{ id: "l2", name: "安全", color: "#3b82f6" },
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
vi.clearAllMocks()
|
|
52
|
+
mocks.hasAiKey.mockResolvedValue(true)
|
|
53
|
+
mocks.labelFindMany.mockResolvedValue(ALL_LABELS)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe("suggestLabelsCore", () => {
|
|
57
|
+
it("正常链路:查标签→runChat→解析→返回 existing 实体与 newNames", async () => {
|
|
58
|
+
mocks.runChat.mockResolvedValue('{"existing":["性能"],"new":["支付"]}')
|
|
59
|
+
const r = await suggestLabelsCore({ name: "优化登录" }, "u1")
|
|
60
|
+
expect(mocks.runChat).toHaveBeenCalledTimes(1)
|
|
61
|
+
const call = mocks.runChat.mock.calls[0] as any[]
|
|
62
|
+
expect(call[0][0].role).toBe("user")
|
|
63
|
+
expect(call[1].systemPrompts[0]).toContain("性能、安全")
|
|
64
|
+
expect(call[1].modelOptions).toEqual({ temperature: 0.2 })
|
|
65
|
+
expect(r.existingLabels).toEqual([{ id: "l1", name: "性能", color: "#ef4444" }])
|
|
66
|
+
expect(r.newNames).toEqual(["支付"])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("AI 未配置抛错", async () => {
|
|
70
|
+
mocks.hasAiKey.mockResolvedValue(false)
|
|
71
|
+
await expect(suggestLabelsCore({ name: "x" }, "u1")).rejects.toMatchObject({
|
|
72
|
+
message: expect.stringContaining("AI"),
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("标签超 100 个时按使用频次截断", async () => {
|
|
77
|
+
const many = Array.from({ length: 120 }, (_, i) => ({
|
|
78
|
+
id: `l${i}`,
|
|
79
|
+
name: `标签${i}`,
|
|
80
|
+
color: "#6b7280",
|
|
81
|
+
}))
|
|
82
|
+
mocks.labelFindMany.mockResolvedValue(many)
|
|
83
|
+
mocks.storyLabelGroupBy.mockResolvedValue(
|
|
84
|
+
Array.from({ length: 100 }, (_, i) => ({ labelId: `l${i}`, _count: { _all: 100 - i } })),
|
|
85
|
+
)
|
|
86
|
+
mocks.runChat.mockResolvedValue('{"existing":[],"new":[]}')
|
|
87
|
+
await suggestLabelsCore({ name: "x" }, "u1")
|
|
88
|
+
expect(mocks.storyLabelGroupBy).toHaveBeenCalledTimes(1)
|
|
89
|
+
const prompt = (mocks.runChat.mock.calls[0] as any[])[1].systemPrompts[0] as string
|
|
90
|
+
const listed = prompt.split("已有标签:")[1]
|
|
91
|
+
expect(listed.split("、")).toHaveLength(100)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it("AI 输出解析失败抛错", async () => {
|
|
95
|
+
mocks.runChat.mockResolvedValue("垃圾输出")
|
|
96
|
+
await expect(suggestLabelsCore({ name: "x" }, "u1")).rejects.toMatchObject({
|
|
97
|
+
message: expect.stringContaining("解析失败"),
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
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
|
+
const mocks = vi.hoisted(() => ({
|
|
21
|
+
labelUpsert: vi.fn(),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
vi.mock("@allbluecn/database", () => ({
|
|
25
|
+
prisma: { label: { upsert: mocks.labelUpsert } },
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
import { upsertLabel } from "../story-crud/label-upsert";
|
|
29
|
+
import { pickLabelColor, LABEL_PALETTE } from "../story-crud/label-palette";
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
vi.clearAllMocks();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("pickLabelColor", () => {
|
|
36
|
+
it("同名稳定同色且落在色板内", () => {
|
|
37
|
+
const a = pickLabelColor("性能");
|
|
38
|
+
expect(pickLabelColor("性能")).toBe(a);
|
|
39
|
+
expect(LABEL_PALETTE).toContain(a);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("不同标签名颜色分布均匀(6 个标签至少 4 色)", () => {
|
|
43
|
+
const colors = new Set(["性能", "安全", "登录", "支付", "导入", "导出"].map(pickLabelColor));
|
|
44
|
+
expect(colors.size).toBeGreaterThanOrEqual(4);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("upsertLabel", () => {
|
|
49
|
+
it("走 upsert 且 create 分支使用色板颜色", async () => {
|
|
50
|
+
mocks.labelUpsert.mockResolvedValue({ id: "l1", name: "性能", color: "#ef4444" });
|
|
51
|
+
const r = await upsertLabel("性能", "u1");
|
|
52
|
+
expect(mocks.labelUpsert).toHaveBeenCalledTimes(1);
|
|
53
|
+
const arg = mocks.labelUpsert.mock.calls[0][0] as any;
|
|
54
|
+
expect(arg.where).toEqual({ name: "性能" });
|
|
55
|
+
expect(arg.update).toEqual({});
|
|
56
|
+
expect(LABEL_PALETTE).toContain(arg.create.color);
|
|
57
|
+
expect(arg.create.createdById).toBe("u1");
|
|
58
|
+
expect(r).toEqual({ id: "l1", name: "性能", color: "#ef4444" });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("传自定义颜色时优先使用自定义颜色", async () => {
|
|
62
|
+
mocks.labelUpsert.mockResolvedValue({ id: "l1", name: "性能", color: "#ff0000" });
|
|
63
|
+
await upsertLabel("性能", "u1", "#ff0000");
|
|
64
|
+
const arg = mocks.labelUpsert.mock.calls[0][0] as any;
|
|
65
|
+
expect(arg.create.color).toBe("#ff0000");
|
|
66
|
+
});
|
|
67
|
+
});
|