@allbluecn/web-app 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. package/package.json +239 -30
  2. package/src/components/iteration/auto-schedule-dialog.tsx +150 -0
  3. package/src/components/iteration/burndown-chart.tsx +63 -0
  4. package/src/components/iteration/constants.ts +28 -0
  5. package/src/components/iteration/create-iteration-dialog.tsx +140 -0
  6. package/src/components/iteration/iteration-card.tsx +60 -0
  7. package/src/components/iteration/iteration-editor.tsx +116 -0
  8. package/src/components/iteration/iteration-progress-bar.tsx +44 -0
  9. package/src/components/iteration/iteration-status-badge.tsx +33 -0
  10. package/src/components/knowledge/dialog/knowledge-form-dialog.tsx +21 -30
  11. package/src/components/knowledge/knowledge-toolbar.tsx +124 -1
  12. package/src/components/layout/sidebar-03/__tests__/nav-workspace.test.tsx +2 -2
  13. package/src/components/layout/sidebar-03/nav-workspace.tsx +2 -2
  14. package/src/components/project/create-project-dialog.tsx +44 -57
  15. package/src/components/shared/__tests__/lucide-curated-icons.test.ts +83 -0
  16. package/src/components/shared/__tests__/lucide-icon-data.test.ts +131 -0
  17. package/src/components/shared/__tests__/lucide-icon-picker.test.tsx +197 -0
  18. package/src/components/shared/lucide-icon-picker/curated-icons.ts +478 -0
  19. package/src/components/shared/lucide-icon-picker/icon-data.ts +109 -0
  20. package/src/components/shared/lucide-icon-picker/index.tsx +82 -0
  21. package/src/components/shared/lucide-icon-picker/lucide-icon-by-name.tsx +78 -0
  22. package/src/components/shared/lucide-icon-picker/panel.tsx +163 -0
  23. package/src/components/story/__tests__/constants.test.ts +255 -0
  24. package/src/components/story/ai/story-ai-panel.tsx +23 -15
  25. package/src/components/story/ai/story-ai-preview-list.tsx +19 -0
  26. package/src/components/story/constants.ts +43 -1
  27. package/src/components/story/create-story-dialog.tsx +42 -16
  28. package/src/components/story/detail/__tests__/associated-attachments-panel.test.tsx +114 -0
  29. package/src/components/story/detail/__tests__/categories.test.tsx +62 -0
  30. package/src/components/story/detail/__tests__/dependencies-button.test.tsx +49 -0
  31. package/src/components/story/detail/activity-timeline.tsx +26 -4
  32. package/src/components/story/detail/story-detail-body.tsx +90 -38
  33. package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +857 -0
  34. package/src/components/story/detail/story-detail-toolbar/categories.tsx +23 -18
  35. package/src/components/story/detail/story-detail-toolbar/children.tsx +9 -4
  36. package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +95 -27
  37. package/src/components/story/detail/story-detail-toolbar.tsx +159 -8
  38. package/src/components/story/detail/story-properties.tsx +21 -4
  39. package/src/components/story/display/__tests__/group-fields.test.ts +107 -0
  40. package/src/components/story/display/group-resolver.tsx +2 -2
  41. package/src/components/story/inline-editors/__tests__/dependency-type-select.test.tsx +155 -0
  42. package/src/components/story/inline-editors/__tests__/labels-editor.test.tsx +155 -0
  43. package/src/components/story/inline-editors/__tests__/priority-editor.test.tsx +173 -0
  44. package/src/components/story/inline-editors/__tests__/state-editor.test.tsx +174 -0
  45. package/src/components/story/inline-editors/dependency-type-select.tsx +180 -0
  46. package/src/components/story/inline-editors/index.ts +2 -1
  47. package/src/components/story/inline-editors/labels-editor.tsx +159 -3
  48. package/src/components/story/inline-editors/story-select-dialog.tsx +41 -63
  49. package/src/components/story/peek-panel.tsx +121 -21
  50. package/src/components/story/relations/__tests__/attachment-manage-dialog.test.tsx +171 -0
  51. package/src/components/story/relations/__tests__/knowledge-select-dialog.test.tsx +174 -0
  52. package/src/components/story/relations/__tests__/link-manage-dialog.test.tsx +93 -0
  53. package/src/components/story/relations/attachment-manage-dialog.tsx +490 -0
  54. package/src/components/story/relations/file-preview.tsx +173 -0
  55. package/src/components/story/relations/format.ts +120 -0
  56. package/src/components/story/relations/general-groups.ts +32 -0
  57. package/src/components/story/relations/knowledge-select-dialog.tsx +403 -0
  58. package/src/components/story/relations/link-manage-dialog.tsx +192 -0
  59. package/src/components/story/relations/story-ref-row.tsx +17 -27
  60. package/src/components/story/story-panel-header.tsx +25 -19
  61. package/src/components/story/story-toolbar.tsx +3 -3
  62. package/src/components/story/transfer-story-popover.tsx +2 -2
  63. package/src/components/story/types.ts +14 -0
  64. package/src/components/story/views/create-view-dialog.tsx +3 -3
  65. package/src/components/story/views/story-list/__tests__/group-header.test.tsx +219 -0
  66. package/src/components/story/views/story-list/group.tsx +1 -1
  67. package/src/components/story/views/story-list/inline-story-creator.tsx +2 -2
  68. package/src/components/story/views/story-list/row.tsx +1 -1
  69. package/src/components/workspace/create-workspace-dialog.tsx +41 -49
  70. package/src/lib/__tests__/project-display-id.test.ts +8 -5
  71. package/src/lib/changelog/sdk-versions.ts +20 -20
  72. package/src/lib/iteration/__tests__/status.test.ts +41 -0
  73. package/src/lib/iteration/status.ts +28 -0
  74. package/src/lib/project-display-id.ts +14 -5
  75. package/src/lib/project-module-meta.ts +77 -0
  76. package/src/lib/story/__tests__/video-embed.test.ts +77 -0
  77. package/src/lib/story/video-embed.ts +59 -0
  78. package/src/lib/utils.d.ts +1 -1
  79. package/src/plugins/.gen-manifest.json +13 -0
  80. package/src/plugins/modules.gen.ts +20 -0
  81. package/src/plugins/plugins.gen.ts +20 -0
  82. package/src/plugins/ui.gen.ts +8 -0
  83. package/src/providers/index.tsx +6 -0
  84. package/src/routeTree.gen.ts +96 -0
  85. package/src/routes/__root.tsx +0 -8
  86. package/src/routes/_nav/inspiration/route.tsx +8 -0
  87. package/src/routes/_nav/knowledge/index.lazy.tsx +2 -2
  88. package/src/routes/_nav/prd/$id/route.tsx +9 -0
  89. package/src/routes/_nav/prd/index.tsx +9 -0
  90. package/src/routes/_nav/prd/route.tsx +8 -0
  91. package/src/routes/_nav/projects/$projectId/$storyId/route.lazy.tsx +52 -25
  92. package/src/routes/_nav/projects/$projectId/$storyId/route.tsx +6 -3
  93. package/src/routes/_nav/projects/$projectId/index.lazy.tsx +32 -15
  94. package/src/routes/_nav/projects/$projectId/iterations/$iterationId/route.lazy.tsx +289 -0
  95. package/src/routes/_nav/projects/$projectId/iterations/$iterationId/route.tsx +27 -0
  96. package/src/routes/_nav/projects/$projectId/iterations/index.lazy.tsx +111 -0
  97. package/src/routes/_nav/projects/$projectId/iterations/index.tsx +24 -0
  98. package/src/routes/_nav/projects/$projectId/route.tsx +4 -2
  99. package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +78 -7
  100. package/src/routes/_nav/projects/$projectId/settings/route.tsx +6 -2
  101. package/src/routes/_nav/stories/$storyId/route.lazy.tsx +5 -1
  102. package/src/routes/_nav/stories/$storyId/route.tsx +4 -0
  103. package/src/routes/_nav/tags/$id/route.tsx +8 -0
  104. package/src/routes/_nav/tags/index.tsx +8 -0
  105. package/src/routes/_nav/tags/route.tsx +8 -0
  106. package/src/routes/_nav/tags/settings/route.tsx +9 -0
  107. package/src/routes/_nav/workspaces/$workspaceId/route.lazy.tsx +3 -3
  108. package/src/routes/_nav/workspaces/$workspaceId/settings/route.lazy.tsx +4 -4
  109. package/src/routes/_nav/workspaces/index.lazy.tsx +3 -3
  110. package/src/routes/api/attachments/$id/content.ts +60 -0
  111. package/src/routes/api/attachments/upload.ts +148 -0
  112. package/src/routes/share/prd/$prdId/route.tsx +10 -0
  113. package/src/server/serverFns/__tests__/project-kb.test.ts +147 -0
  114. package/src/server/serverFns/iteration/__tests__/auto-schedule-core.test.ts +62 -0
  115. package/src/server/serverFns/iteration/__tests__/burndown-core.test.ts +90 -0
  116. package/src/server/serverFns/iteration/__tests__/iteration-crud.test.ts +211 -0
  117. package/src/server/serverFns/iteration/__tests__/iteration-stories.test.ts +155 -0
  118. package/src/server/serverFns/iteration/__tests__/iteration-transfer.test.ts +136 -0
  119. package/src/server/serverFns/iteration/auto-schedule-core.ts +54 -0
  120. package/src/server/serverFns/iteration/auto-schedule-internal.ts +88 -0
  121. package/src/server/serverFns/iteration/burndown-core.ts +60 -0
  122. package/src/server/serverFns/iteration/index.ts +22 -0
  123. package/src/server/serverFns/iteration/iteration-auto-schedule.ts +66 -0
  124. package/src/server/serverFns/iteration/iteration-burndown.ts +70 -0
  125. package/src/server/serverFns/iteration/iteration-crud.ts +208 -0
  126. package/src/server/serverFns/iteration/iteration-stories.ts +111 -0
  127. package/src/server/serverFns/iteration/iteration-transfer.ts +42 -0
  128. package/src/server/serverFns/iteration/schemas.ts +58 -0
  129. package/src/server/serverFns/iteration/transfer-internal.ts +62 -0
  130. package/src/server/serverFns/knowledge.ts +50 -4
  131. package/src/server/serverFns/project/module-overview.ts +109 -0
  132. package/src/server/serverFns/project.ts +45 -2
  133. package/src/server/serverFns/story/__tests__/kb-scope.test.ts +56 -0
  134. package/src/server/serverFns/story/__tests__/label-suggest-fn.test.ts +100 -0
  135. package/src/server/serverFns/story/__tests__/labels.test.ts +67 -0
  136. package/src/server/serverFns/story/__tests__/story-assoc.test.ts +222 -0
  137. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +132 -2
  138. package/src/server/serverFns/story/index.ts +9 -4
  139. package/src/server/serverFns/story/kb-general.ts +39 -0
  140. package/src/server/serverFns/story/kb-scope.ts +52 -0
  141. package/src/server/serverFns/story/link-preview.ts +115 -0
  142. package/src/server/serverFns/story/story-assoc.ts +372 -51
  143. package/src/server/serverFns/story/story-attachments.ts +32 -45
  144. package/src/{components/project/project-icon.tsx → server/serverFns/story/story-crud/label-palette.ts} +16 -19
  145. package/src/server/serverFns/story/story-crud/label-suggest-core.ts +88 -0
  146. package/src/server/serverFns/story/story-crud/label-suggest.ts +38 -0
  147. package/src/{components/knowledge/knowledge-icon.tsx → server/serverFns/story/story-crud/label-upsert.ts} +13 -19
  148. package/src/server/serverFns/story/story-crud/labels.ts +3 -5
  149. package/src/server/serverFns/story/story-crud.ts +53 -6
  150. package/src/server/serverFns/story/story-dependencies.ts +158 -5
  151. package/src/server/serverFns/story/story-links.ts +43 -7
  152. package/src/server/serverFns/story/story-transfer.ts +5 -4
  153. package/src/server/serverFns/story/story-tree.ts +28 -1
  154. package/src/server/serverFns/story/types.ts +38 -0
  155. package/src/styles/globals.css +25 -0
  156. package/src/types/project.ts +5 -5
  157. package/vite.shared.ts +2 -0
  158. package/src/components/project/project-animated-icon.tsx +0 -73
  159. package/src/components/shared/icon-packs/index.ts +0 -1
  160. package/src/components/shared/icon-packs/knowledge.ts +0 -1
  161. package/src/components/shared/icon-packs/prd.ts +0 -1
  162. package/src/components/shared/icon-packs/project.ts +0 -1
  163. package/src/components/shared/icon-picker.tsx +0 -1
  164. package/src/components/story/detail/story-detail-toolbar/attachments.tsx +0 -73
  165. package/src/components/story/detail/story-detail-toolbar/knowledge.tsx +0 -73
  166. package/src/components/story/detail/story-detail-toolbar/links.tsx +0 -73
  167. package/src/components/story/relations/attachments-panel.tsx +0 -179
  168. package/src/components/story/relations/knowledge-panel.tsx +0 -284
  169. package/src/components/story/relations/links-panel.tsx +0 -240
  170. package/src/lib/project-mock.ts +0 -156
@@ -0,0 +1,148 @@
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 { createFileRoute } from "@tanstack/react-router";
19
+ import { prisma } from "@allbluecn/database";
20
+ import { requireAuth, requireUserInDb } from "@/server/auth-helpers";
21
+ import { ACTIVITY_SELECT, toActivityItem } from "@/server/serverFns/story/types";
22
+ import { inferMimeType } from "@/components/story/relations/format";
23
+
24
+ const MAX_FILE_SIZE = 10 * 1024 * 1024;
25
+
26
+ export const Route = createFileRoute("/api/attachments/upload")({
27
+ server: {
28
+ handlers: {
29
+ POST: async ({ request }) => {
30
+ try {
31
+ const session = await requireAuth(request);
32
+ const userId = session.user!.id!;
33
+ await requireUserInDb(userId);
34
+
35
+ const formData = await request.formData();
36
+ const storyId = formData.get("storyId");
37
+ const file = formData.get("file");
38
+ const rawDescription = formData.get("description");
39
+ const description =
40
+ typeof rawDescription === "string" && rawDescription.trim()
41
+ ? rawDescription.trim().slice(0, 500)
42
+ : null;
43
+ const attachmentId = formData.get("attachmentId") as string | null;
44
+ const fileHash = formData.get("fileHash") as string | null;
45
+ if (typeof storyId !== "string" || !storyId) {
46
+ return Response.json({ error: "缺少 storyId" }, { status: 400 });
47
+ }
48
+ if (!(file instanceof File)) {
49
+ return Response.json({ error: "缺少文件" }, { status: 400 });
50
+ }
51
+ if (file.size <= 0 || file.size > MAX_FILE_SIZE) {
52
+ return Response.json({ error: "文件大小需在 10MB 以内" }, { status: 400 });
53
+ }
54
+
55
+ const story = await prisma.story.findUnique({
56
+ where: { id: storyId },
57
+ select: { id: true },
58
+ });
59
+ if (!story) {
60
+ return Response.json({ error: "需求不存在" }, { status: 404 });
61
+ }
62
+
63
+ const bytes = new Uint8Array(await file.arrayBuffer());
64
+ let attachment;
65
+ let activity;
66
+
67
+ if (attachmentId) {
68
+ // 编辑模式:替换现有附件
69
+ attachment = await prisma.storyAttachment.update({
70
+ where: { id: attachmentId },
71
+ data: {
72
+ fileName: file.name.slice(0, 255),
73
+ fileSize: BigInt(file.size),
74
+ mimeType: inferMimeType(file.name, file.type),
75
+ description,
76
+ bytes,
77
+ fileHash: fileHash ?? null,
78
+ },
79
+ });
80
+ activity = await prisma.storyActivity.create({
81
+ data: {
82
+ storyId,
83
+ userId,
84
+ action: "attachment_updated",
85
+ newValue: file.name.slice(0, 255),
86
+ },
87
+ select: ACTIVITY_SELECT,
88
+ });
89
+ } else {
90
+ // 新增模式:先检查同 story 是否已有相同 hash 的附件
91
+ const existing = fileHash
92
+ ? await prisma.storyAttachment.findFirst({
93
+ where: { storyId, fileHash },
94
+ select: { id: true },
95
+ })
96
+ : null;
97
+ if (existing) {
98
+ return Response.json({ duplicated: true, existingId: existing.id }, { status: 200 });
99
+ }
100
+ attachment = await prisma.storyAttachment.create({
101
+ data: {
102
+ storyId,
103
+ fileName: file.name.slice(0, 255),
104
+ fileSize: BigInt(file.size),
105
+ mimeType: inferMimeType(file.name, file.type),
106
+ description,
107
+ storageUrl: "pending",
108
+ bytes,
109
+ fileHash: fileHash ?? null,
110
+ },
111
+ });
112
+ const storageUrl = `/api/attachments/${attachment.id}/content`;
113
+ await prisma.storyAttachment.update({
114
+ where: { id: attachment.id },
115
+ data: { storageUrl },
116
+ });
117
+ activity = await prisma.storyActivity.create({
118
+ data: {
119
+ storyId,
120
+ userId,
121
+ action: "attachment_added",
122
+ newValue: file.name.slice(0, 255),
123
+ },
124
+ select: ACTIVITY_SELECT,
125
+ });
126
+ }
127
+
128
+ const storageUrl = `/api/attachments/${attachment.id}/content`;
129
+
130
+ return Response.json({
131
+ id: attachment.id,
132
+ fileName: attachment.fileName,
133
+ fileSize: Number(attachment.fileSize),
134
+ mimeType: attachment.mimeType,
135
+ description: attachment.description,
136
+ storageUrl,
137
+ fileHash: attachment.fileHash,
138
+ activity: toActivityItem(activity),
139
+ });
140
+ } catch (e) {
141
+ const message = e instanceof Error ? e.message : "上传失败";
142
+ const status = message === "UNAUTHORIZED" ? 401 : 500;
143
+ return Response.json({ error: message }, { status });
144
+ }
145
+ },
146
+ },
147
+ },
148
+ });
@@ -0,0 +1,10 @@
1
+ // AUTO-GENERATED by @allbluecn/kernel — DO NOT EDIT
2
+ import { createFileRoute } from '@tanstack/react-router'
3
+ import type { PluginRouteContext } from '@allbluecn/kernel'
4
+ import { prd_route_3 } from '@allbluecn/plugins-core/prd/routes'
5
+
6
+ export const Route = createFileRoute('/share/prd/$prdId')({
7
+ beforeLoad: ((ctx: PluginRouteContext) => prd_route_3.beforeLoad(ctx)) as any,
8
+ validateSearch: prd_route_3.validateSearch as any,
9
+ component: prd_route_3.Component as any,
10
+ })
@@ -0,0 +1,147 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ import { describe, it, expect, vi, beforeEach } from "vitest";
19
+
20
+ vi.mock("@tanstack/react-start", () => ({
21
+ createServerFn: (config: any) => {
22
+ const api: any = {
23
+ _validator: undefined,
24
+ validator(schema: any) {
25
+ this._validator = schema;
26
+ return this;
27
+ },
28
+ handler(fn: Function) {
29
+ const wrapped = async (args: any) => {
30
+ let data = args?.data;
31
+ if (this._validator && data !== undefined) {
32
+ data = this._validator.parse ? this._validator.parse(data) : data;
33
+ }
34
+ return fn({ data });
35
+ };
36
+ return wrapped;
37
+ },
38
+ };
39
+ return api;
40
+ },
41
+ }));
42
+
43
+ vi.mock("@tanstack/react-start/server", () => ({ getRequest: () => ({}) }));
44
+
45
+ const mocks = vi.hoisted(() => ({
46
+ pkbFindMany: vi.fn(),
47
+ pkbDeleteMany: vi.fn(),
48
+ pkbCreateMany: vi.fn(),
49
+ kbFindMany: vi.fn(),
50
+ projectFindMany: vi.fn(),
51
+ kbFindFirst: vi.fn(),
52
+ transaction: vi.fn(),
53
+ currentSession: vi.fn(),
54
+ }));
55
+
56
+ vi.mock("@allbluecn/database", () => {
57
+ const prisma = {
58
+ projectKnowledgeBase: {
59
+ findMany: mocks.pkbFindMany,
60
+ deleteMany: mocks.pkbDeleteMany,
61
+ createMany: mocks.pkbCreateMany,
62
+ },
63
+ knowledgeBase: { findMany: mocks.kbFindMany, findFirst: mocks.kbFindFirst },
64
+ project: { findMany: mocks.projectFindMany },
65
+ $transaction: mocks.transaction,
66
+ };
67
+ return { prisma, Prisma: {} };
68
+ });
69
+
70
+ vi.mock("@/server/auth-helpers", () => ({ requireAuth: mocks.currentSession }));
71
+ vi.mock("@/server/permissions", () => ({ requirePermission: vi.fn().mockResolvedValue(undefined) }));
72
+ vi.mock("@/server/audit", () => ({ auditLog: vi.fn() }));
73
+
74
+ import { setProjectKbsFn, listProjectKbsFn } from "../project";
75
+ import { setKbProjectsFn, listKbProjectsFn } from "../knowledge";
76
+
77
+ beforeEach(() => {
78
+ vi.clearAllMocks();
79
+ mocks.currentSession.mockResolvedValue({ user: { id: "u1" } });
80
+ });
81
+
82
+ describe("setProjectKbsFn 校验", () => {
83
+ it("仅接受 team 可见性 KB,非 team 抛错且不写库", async () => {
84
+ mocks.kbFindMany.mockResolvedValue([{ id: "kb1" }]); // 请求 [kb1(team), kb2(general)] 但只查回 kb1
85
+ await expect(
86
+ setProjectKbsFn({ data: { projectId: "p1", kbIds: ["kb1", "kb2"] } }),
87
+ ).rejects.toMatchObject({ message: expect.stringContaining("仅允许团队可见") });
88
+ expect(mocks.transaction).not.toHaveBeenCalled();
89
+ });
90
+
91
+ it("全量替换:删差集 + 增新增(事务)", async () => {
92
+ mocks.kbFindMany.mockResolvedValue([{ id: "kb1" }, { id: "kb2" }]);
93
+ mocks.transaction.mockImplementation(async (fn: (tx: any) => any) =>
94
+ fn({
95
+ projectKnowledgeBase: {
96
+ deleteMany: mocks.pkbDeleteMany,
97
+ createMany: mocks.pkbCreateMany,
98
+ },
99
+ }),
100
+ );
101
+ await setProjectKbsFn({ data: { projectId: "p1", kbIds: ["kb1", "kb2"] } });
102
+ expect(mocks.pkbDeleteMany).toHaveBeenCalledWith({
103
+ where: { projectId: "p1", kbId: { notIn: ["kb1", "kb2"] } },
104
+ });
105
+ expect(mocks.pkbCreateMany).toHaveBeenCalledWith({
106
+ data: [
107
+ { projectId: "p1", kbId: "kb1" },
108
+ { projectId: "p1", kbId: "kb2" },
109
+ ],
110
+ skipDuplicates: true,
111
+ });
112
+ });
113
+ });
114
+
115
+ describe("setKbProjectsFn 校验", () => {
116
+ it("general KB 拒绝关联", async () => {
117
+ mocks.kbFindFirst.mockResolvedValue({ id: "kbG", visibility: "general" });
118
+ await expect(
119
+ setKbProjectsFn({ data: { kbId: "kbG", projectIds: ["p1"] } }),
120
+ ).rejects.toMatchObject({ message: expect.stringContaining("通用知识库") });
121
+ });
122
+
123
+ it("private KB 拒绝关联", async () => {
124
+ mocks.kbFindFirst.mockResolvedValue({ id: "kbP", visibility: "private" });
125
+ await expect(
126
+ setKbProjectsFn({ data: { kbId: "kbP", projectIds: ["p1"] } }),
127
+ ).rejects.toMatchObject({ message: expect.stringContaining("私有知识库") });
128
+ });
129
+ });
130
+
131
+ describe("listProjectKbsFn / listKbProjectsFn", () => {
132
+ it("项目侧返回 KB 概要", async () => {
133
+ mocks.pkbFindMany.mockResolvedValue([
134
+ { kb: { id: "kb1", name: "团队库", icon: "notebook", visibility: "team" } },
135
+ ]);
136
+ const result = await listProjectKbsFn({ data: { projectId: "p1" } });
137
+ expect(result[0]).toMatchObject({ id: "kb1", name: "团队库" });
138
+ });
139
+
140
+ it("KB 侧返回项目概要", async () => {
141
+ mocks.pkbFindMany.mockResolvedValue([
142
+ { project: { id: "p1", name: "项目A", icon: "folder" } },
143
+ ]);
144
+ const result = await listKbProjectsFn({ data: { kbId: "kb1" } });
145
+ expect(result[0]).toMatchObject({ id: "p1", name: "项目A" });
146
+ });
147
+ });
@@ -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 { describe, it, expect } from "vitest";
19
+ import { planMissingIterations } from "../auto-schedule-core";
20
+
21
+ const cfg = { title: "Sprint", durationDays: 14, cooldownDays: 0, startsOn: "2026-09-01", futureCount: 2, lastSeq: 0 };
22
+
23
+ describe("planMissingIterations(规格 §3.6)", () => {
24
+ it("无现有迭代 → 从 max(startsOn, today) 起补 2 个,命名带递增序号", () => {
25
+ const { planned, nextSeq } = planMissingIterations(cfg, [], "2026-09-01");
26
+ expect(planned).toHaveLength(2);
27
+ expect(planned[0]).toMatchObject({ name: "Sprint 1", startDate: "2026-09-01", endDate: "2026-09-14" });
28
+ expect(planned[1]).toMatchObject({ name: "Sprint 2", startDate: "2026-09-15", endDate: "2026-09-28" });
29
+ expect(nextSeq).toBe(2);
30
+ });
31
+ it("upcoming 已够数 → 不补", () => {
32
+ const { planned } = planMissingIterations(cfg, [
33
+ { startDate: "2026-09-15", endDate: "2026-09-28", archived: false },
34
+ { startDate: "2026-09-29", endDate: "2026-10-12", archived: false },
35
+ ], "2026-09-01");
36
+ expect(planned).toHaveLength(0);
37
+ });
38
+ it("anchor 接最晚迭代结束日,cooldown 生效", () => {
39
+ const { planned } = planMissingIterations(
40
+ { ...cfg, cooldownDays: 2, lastSeq: 3 },
41
+ [{ startDate: "2026-09-01", endDate: "2026-09-14", archived: false }],
42
+ "2026-09-01",
43
+ );
44
+ expect(planned[0]).toMatchObject({ name: "Sprint 4", startDate: "2026-09-16" });
45
+ });
46
+ it("与现有迭代重叠时顺延至其后", () => {
47
+ const { planned } = planMissingIterations(cfg, [
48
+ { startDate: "2026-08-19", endDate: "2026-09-05", archived: false },
49
+ ], "2026-09-01");
50
+ expect(planned[0].startDate).toBe("2026-09-06");
51
+ });
52
+ it("归档迭代不占用 anchor/不阻断", () => {
53
+ const { planned } = planMissingIterations(cfg, [
54
+ { startDate: "2026-09-10", endDate: "2026-09-23", archived: true },
55
+ ], "2026-09-01");
56
+ expect(planned[0].startDate).toBe("2026-09-01");
57
+ });
58
+ it("startsOn 在过去时从今天起", () => {
59
+ const { planned } = planMissingIterations({ ...cfg, startsOn: "2026-08-01" }, [], "2026-09-01");
60
+ expect(planned[0].startDate).toBe("2026-09-01");
61
+ });
62
+ });
@@ -0,0 +1,90 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { computeBurndown } from "../burndown-core";
3
+
4
+ // 输入构造器:迭代 09-01~09-05(5 天),时区 Asia/Shanghai
5
+ const base = {
6
+ startDate: "2026-09-01",
7
+ endDate: "2026-09-05",
8
+ timezone: "Asia/Shanghai",
9
+ };
10
+
11
+ describe("computeBurndown(规格 §3.4)", () => {
12
+ it("无需求无事件 → 各日 scope=0 pending=0", () => {
13
+ const r = computeBurndown({
14
+ ...base,
15
+ stories: [],
16
+ stateEvents: [],
17
+ now: new Date("2026-09-03T04:00:00Z"),
18
+ });
19
+ expect(r.points).toHaveLength(3); // 只画到今天
20
+ expect(r.points[0]).toMatchObject({ date: "2026-09-01", scope: 0, pending: 0 });
21
+ });
22
+
23
+ it("三个需求,第二天完成一个 → day1 pending=3, day2 pending=2", () => {
24
+ const r = computeBurndown({
25
+ ...base,
26
+ stories: [
27
+ { id: "s1", addedAt: "2026-09-01T02:00:00Z" },
28
+ { id: "s2", addedAt: "2026-09-01T02:00:00Z" },
29
+ { id: "s3", addedAt: "2026-09-01T02:00:00Z" },
30
+ ],
31
+ stateEvents: [{ storyId: "s1", at: "2026-09-02T06:00:00Z", to: "done" }],
32
+ now: new Date("2026-09-05T20:00:00Z"),
33
+ });
34
+ expect(r.points[0]).toMatchObject({ scope: 3, pending: 3 });
35
+ expect(r.points[1]).toMatchObject({ scope: 3, pending: 2 });
36
+ });
37
+
38
+ it("中途加入(day3)抬高 scope", () => {
39
+ const r = computeBurndown({
40
+ ...base,
41
+ stories: [
42
+ { id: "s1", addedAt: "2026-09-01T02:00:00Z" },
43
+ { id: "s2", addedAt: "2026-09-03T02:00:00Z" },
44
+ ],
45
+ stateEvents: [],
46
+ now: new Date("2026-09-05T20:00:00Z"),
47
+ });
48
+ expect(r.points[1]).toMatchObject({ scope: 1 });
49
+ expect(r.points[2]).toMatchObject({ scope: 2 });
50
+ });
51
+
52
+ it("状态反复变更取最后态(done→in_progress→done)", () => {
53
+ const r = computeBurndown({
54
+ ...base,
55
+ stories: [{ id: "s1", addedAt: "2026-09-01T02:00:00Z" }],
56
+ stateEvents: [
57
+ { storyId: "s1", at: "2026-09-02T02:00:00Z", to: "done" },
58
+ { storyId: "s1", at: "2026-09-03T02:00:00Z", to: "in_progress" },
59
+ { storyId: "s1", at: "2026-09-04T02:00:00Z", to: "done" },
60
+ ],
61
+ now: new Date("2026-09-05T20:00:00Z"),
62
+ });
63
+ expect(r.points[1].pending).toBe(0); // day2 done
64
+ expect(r.points[2].pending).toBe(1); // day3 打回
65
+ expect(r.points[3].pending).toBe(0); // day4 再 done
66
+ });
67
+
68
+ it("cancelled 计入退出(done 口径)", () => {
69
+ const r = computeBurndown({
70
+ ...base,
71
+ stories: [
72
+ { id: "s1", addedAt: "2026-09-01T02:00:00Z" },
73
+ { id: "s2", addedAt: "2026-09-01T02:00:00Z" },
74
+ ],
75
+ stateEvents: [{ storyId: "s2", at: "2026-09-02T02:00:00Z", to: "cancelled" }],
76
+ now: new Date("2026-09-05T20:00:00Z"),
77
+ });
78
+ expect(r.points[1].pending).toBe(1);
79
+ });
80
+
81
+ it("领先/滞后判定:today pending < ideal → leading", () => {
82
+ const r = computeBurndown({
83
+ ...base,
84
+ stories: Array.from({ length: 4 }, (_, i) => ({ id: `s${i}`, addedAt: "2026-09-01T02:00:00Z" })),
85
+ stateEvents: Array.from({ length: 3 }, (_, i) => ({ storyId: `s${i}`, at: "2026-09-01T06:00:00Z", to: "done" })),
86
+ now: new Date("2026-09-03T02:00:00Z"), // 上海时区 09-03 10:00(20:00Z 会被分桶到 09-04)
87
+ });
88
+ expect(r.status).toBe("leading"); // day3: pending=1 < ideal=round(4*3/5)=2
89
+ });
90
+ });
@@ -0,0 +1,211 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ import { describe, it, expect, vi, beforeEach } from "vitest";
19
+
20
+ vi.mock("@tanstack/react-start", () => ({
21
+ createServerFn: (config: any) => {
22
+ const api: any = {
23
+ _validator: undefined,
24
+ validator(schema: any) {
25
+ this._validator = schema;
26
+ return this;
27
+ },
28
+ handler(fn: Function) {
29
+ const wrapped = async (args: any) => {
30
+ let data = args?.data;
31
+ if (this._validator && data !== undefined) {
32
+ data = this._validator.parse ? this._validator.parse(data) : data;
33
+ }
34
+ return fn({ data });
35
+ };
36
+ return wrapped;
37
+ },
38
+ };
39
+ return api;
40
+ },
41
+ }));
42
+
43
+ vi.mock("@tanstack/react-start/server", () => ({
44
+ getRequest: () => ({}),
45
+ }));
46
+
47
+ const mocks = vi.hoisted(() => ({
48
+ findMany: vi.fn(),
49
+ findUnique: vi.fn(),
50
+ create: vi.fn(),
51
+ update: vi.fn(),
52
+ delete: vi.fn(),
53
+ count: vi.fn(),
54
+ groupBy: vi.fn(),
55
+ currentSession: vi.fn(),
56
+ }));
57
+
58
+ vi.mock("@allbluecn/database", () => ({
59
+ prisma: {
60
+ iteration: {
61
+ findMany: mocks.findMany,
62
+ findUnique: mocks.findUnique,
63
+ create: mocks.create,
64
+ update: mocks.update,
65
+ delete: mocks.delete,
66
+ },
67
+ story: { groupBy: mocks.groupBy },
68
+ iterationAutoSchedule: { findMany: mocks.findMany },
69
+ },
70
+ Prisma: {},
71
+ }));
72
+
73
+ vi.mock("@/server/auth-helpers", () => ({
74
+ requireAuth: mocks.currentSession,
75
+ }));
76
+
77
+ import {
78
+ listIterationsFn,
79
+ getIterationFn,
80
+ createIterationsFn,
81
+ updateIterationsFn,
82
+ archiveIterationsFn,
83
+ deleteIterationsFn,
84
+ } from "../iteration-crud";
85
+
86
+ describe("createIterationsFn", () => {
87
+ beforeEach(() => {
88
+ vi.clearAllMocks();
89
+ mocks.currentSession.mockResolvedValue({ user: { id: "u1" } });
90
+ });
91
+
92
+ it("日期与现有迭代重叠时拒绝", async () => {
93
+ mocks.findMany.mockResolvedValue([
94
+ { startDate: new Date("2026-09-01"), endDate: new Date("2026-09-14"), archived: false },
95
+ ]);
96
+ await expect(
97
+ createIterationsFn({ data: { projectId: "p1", name: "S2", startDate: "2026-09-10", endDate: "2026-09-23" } }),
98
+ ).rejects.toMatchObject({ message: expect.stringContaining("重叠") });
99
+ });
100
+
101
+ it("合法日期调用 create 且日期按 UTC 0 点入库", async () => {
102
+ mocks.findMany.mockResolvedValue([]);
103
+ mocks.create.mockResolvedValue({ id: "i1" });
104
+ const result = await createIterationsFn({
105
+ data: { projectId: "p1", name: "S1", startDate: "2026-09-01", endDate: "2026-09-14" },
106
+ });
107
+ expect(result).toEqual({ id: "i1" });
108
+ expect(mocks.create).toHaveBeenCalledWith(
109
+ expect.objectContaining({
110
+ data: expect.objectContaining({
111
+ startDate: new Date("2026-09-01T00:00:00.000Z"),
112
+ endDate: new Date("2026-09-14T00:00:00.000Z"),
113
+ }),
114
+ }),
115
+ );
116
+ });
117
+ });
118
+
119
+ describe("updateIterationsFn", () => {
120
+ beforeEach(() => {
121
+ vi.clearAllMocks();
122
+ mocks.currentSession.mockResolvedValue({ user: { id: "u1" } });
123
+ });
124
+
125
+ it("completed 迭代锁定编辑(startDate 已过)", async () => {
126
+ mocks.findUnique.mockResolvedValue({
127
+ id: "i1",
128
+ startDate: new Date("2026-08-01"),
129
+ endDate: new Date("2026-08-14"),
130
+ archived: false,
131
+ projectId: "p1",
132
+ });
133
+ await expect(updateIterationsFn({ data: { id: "i1", name: "改名" } })).rejects.toMatchObject({
134
+ message: expect.stringContaining("不可编辑"),
135
+ });
136
+ });
137
+
138
+ it("改期时校验与其他迭代不重叠", async () => {
139
+ mocks.findUnique.mockResolvedValue({
140
+ id: "i1",
141
+ startDate: new Date("2026-09-01"),
142
+ endDate: new Date("2026-09-14"),
143
+ archived: false,
144
+ projectId: "p1",
145
+ });
146
+ mocks.findMany.mockResolvedValue([
147
+ { startDate: new Date("2026-09-10"), endDate: new Date("2026-09-20") },
148
+ ]);
149
+ await expect(
150
+ updateIterationsFn({ data: { id: "i1", startDate: "2026-09-18", endDate: "2026-09-25" } }),
151
+ ).rejects.toMatchObject({ message: expect.stringContaining("重叠") });
152
+ });
153
+ });
154
+
155
+ describe("listIterationsFn", () => {
156
+ beforeEach(() => {
157
+ vi.clearAllMocks();
158
+ mocks.currentSession.mockResolvedValue({ user: { id: "u1" } });
159
+ });
160
+
161
+ it("返回带统计与 status 字段", async () => {
162
+ mocks.findMany.mockResolvedValue([
163
+ { id: "i1", name: "Sprint 1", description: null, startDate: new Date("2026-09-01"), endDate: new Date("2026-09-14"), archived: false },
164
+ { id: "i2", name: "Sprint 0", description: null, startDate: new Date("2026-08-19"), endDate: new Date("2026-09-01"), archived: false },
165
+ ]);
166
+ mocks.groupBy.mockResolvedValue([
167
+ { iterationId: "i1", state: "done", _count: { _all: 3 } },
168
+ { iterationId: "i1", state: "in_progress", _count: { _all: 1 } },
169
+ { iterationId: "i2", state: "done", _count: { _all: 5 } },
170
+ ]);
171
+ const result = await listIterationsFn({ data: { projectId: "p1", includeArchived: false } });
172
+ expect(result.items).toHaveLength(2);
173
+ expect(result.items[0].id).toBe("i1");
174
+ expect(result.items[0].stateCounts).toEqual({ done: 3, in_progress: 1 });
175
+ expect(result.items[0].storyCount).toBe(4);
176
+ expect(result.items[0].startDate).toBe("2026-09-01");
177
+ expect(result.items[0].endDate).toBe("2026-09-14");
178
+ expect(result.items[1].storyCount).toBe(5);
179
+ });
180
+ });
181
+
182
+ describe("archiveIterationsFn", () => {
183
+ beforeEach(() => {
184
+ vi.clearAllMocks();
185
+ mocks.currentSession.mockResolvedValue({ user: { id: "u1" } });
186
+ });
187
+
188
+ it("归档/取消归档", async () => {
189
+ mocks.update.mockResolvedValue({ id: "i1" });
190
+ const r1 = await archiveIterationsFn({ data: { id: "i1", archived: true } });
191
+ expect(r1.ok).toBe(true);
192
+ expect(mocks.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ archived: true }) }));
193
+ const r2 = await archiveIterationsFn({ data: { id: "i1", archived: false } });
194
+ expect(r2.ok).toBe(true);
195
+ expect(mocks.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ archived: false }) }));
196
+ });
197
+ });
198
+
199
+ describe("deleteIterationsFn", () => {
200
+ beforeEach(() => {
201
+ vi.clearAllMocks();
202
+ mocks.currentSession.mockResolvedValue({ user: { id: "u1" } });
203
+ });
204
+
205
+ it("删除迭代", async () => {
206
+ mocks.delete.mockResolvedValue({ id: "i1" });
207
+ const r = await deleteIterationsFn({ data: { id: "i1" } });
208
+ expect(r.ok).toBe(true);
209
+ expect(mocks.delete).toHaveBeenCalledWith({ where: { id: "i1" } });
210
+ });
211
+ });