@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,208 @@
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
+ import { createServerFn } from "@tanstack/react-start";
20
+ import { getRequest } from "@tanstack/react-start/server";
21
+ import { prisma } from "@allbluecn/database";
22
+ import { requireAuth } from "@/server/auth-helpers";
23
+ import { deriveIterationStatus, isDateRangeOverlapping, toDatePart } from "@/lib/iteration/status";
24
+ import {
25
+ listIterationsSchema,
26
+ createIterationSchema,
27
+ updateIterationSchema,
28
+ iterationIdSchema,
29
+ type IterationSummary,
30
+ } from "./schemas";
31
+ import type { StoryState } from "@/components/story/constants";
32
+ import { ensureUpcomingIterations, ensureAutoRollover } from "./auto-schedule-internal";
33
+
34
+ const ITERATION_SELECT = {
35
+ id: true,
36
+ projectId: true,
37
+ name: true,
38
+ description: true,
39
+ startDate: true,
40
+ endDate: true,
41
+ archived: true,
42
+ } as const;
43
+
44
+ /** 与项目内未归档迭代校验日期不重叠(规格 §2.1:约束在 serverFn 层) */
45
+ async function assertNoOverlap(projectId: string, range: { startDate: string; endDate: string }, excludeId?: string) {
46
+ const siblings = await prisma.iteration.findMany({
47
+ where: { projectId, archived: false, ...(excludeId ? { id: { not: excludeId } } : {}) },
48
+ select: { startDate: true, endDate: true },
49
+ });
50
+ if (siblings.some((s) => isDateRangeOverlapping(range, { startDate: s.startDate, endDate: s.endDate }))) {
51
+ throw new Error("迭代日期与现有迭代重叠");
52
+ }
53
+ }
54
+
55
+ function toSummary(
56
+ it: { id: string; name: string; description: string | null; startDate: Date; endDate: Date; archived: boolean },
57
+ stateCounts: Record<string, number>,
58
+ ): IterationSummary {
59
+ const startDate = toDatePart(it.startDate);
60
+ const endDate = toDatePart(it.endDate);
61
+ return {
62
+ id: it.id,
63
+ name: it.name,
64
+ description: it.description,
65
+ startDate,
66
+ endDate,
67
+ archived: it.archived,
68
+ status: deriveIterationStatus(startDate, endDate),
69
+ storyCount: Object.values(stateCounts).reduce((a, b) => a + b, 0),
70
+ stateCounts,
71
+ };
72
+ }
73
+
74
+ export const listIterationsFn = createServerFn({ method: "GET" })
75
+ .validator(listIterationsSchema)
76
+ .handler(async ({ data }) => {
77
+ const request = getRequest();
78
+ await requireAuth(request);
79
+ // 惰性自愈:自动排期补建 + autoRollover(规格 §3.6)
80
+ await ensureUpcomingIterations(data.projectId);
81
+ await ensureAutoRollover(data.projectId);
82
+ const items = await prisma.iteration.findMany({
83
+ where: { projectId: data.projectId, ...(data.includeArchived ? {} : { archived: false }) },
84
+ orderBy: { startDate: "desc" },
85
+ select: ITERATION_SELECT,
86
+ });
87
+ const grouped = await prisma.story.groupBy({
88
+ by: ["iterationId", "state"],
89
+ where: { iterationId: { in: items.map((i) => i.id) } },
90
+ _count: { _all: true },
91
+ });
92
+ const counts = new Map<string, Record<string, number>>();
93
+ for (const g of grouped) {
94
+ if (!g.iterationId) continue;
95
+ const m = counts.get(g.iterationId) ?? {};
96
+ m[g.state] = g._count._all;
97
+ counts.set(g.iterationId, m);
98
+ }
99
+ return { items: items.map((i) => toSummary(i, counts.get(i.id) ?? {})) };
100
+ });
101
+
102
+ export const getIterationFn = createServerFn({ method: "GET" })
103
+ .validator(iterationIdSchema)
104
+ .handler(async ({ data }) => {
105
+ const request = getRequest();
106
+ await requireAuth(request);
107
+ const it = await prisma.iteration.findUnique({ where: { id: data.id }, select: ITERATION_SELECT });
108
+ if (!it) throw new Error("迭代不存在");
109
+ const grouped = await prisma.story.groupBy({
110
+ by: ["state"],
111
+ where: { iterationId: data.id },
112
+ _count: { _all: true },
113
+ });
114
+ const stateCounts = Object.fromEntries(grouped.map((g) => [g.state, g._count._all]));
115
+ return toSummary(it, stateCounts);
116
+ });
117
+
118
+ export const createIterationsFn = createServerFn({ method: "POST" })
119
+ .validator(createIterationSchema)
120
+ .handler(async ({ data }) => {
121
+ const request = getRequest();
122
+ const session = await requireAuth(request);
123
+ await assertNoOverlap(data.projectId, data);
124
+ const created = await prisma.iteration.create({
125
+ data: {
126
+ projectId: data.projectId,
127
+ name: data.name,
128
+ description: data.description,
129
+ startDate: new Date(data.startDate + "T00:00:00.000Z"),
130
+ endDate: new Date(data.endDate + "T00:00:00.000Z"),
131
+ createdById: session.user!.id!,
132
+ },
133
+ select: { id: true },
134
+ });
135
+ return { id: created.id };
136
+ });
137
+
138
+ export const updateIterationsFn = createServerFn({ method: "POST" })
139
+ .validator(updateIterationSchema)
140
+ .handler(async ({ data }) => {
141
+ const request = getRequest();
142
+ await requireAuth(request);
143
+ const existing = await prisma.iteration.findUnique({ where: { id: data.id }, select: ITERATION_SELECT });
144
+ if (!existing) throw new Error("迭代不存在");
145
+ const next = {
146
+ startDate: data.startDate ?? toDatePart(existing.startDate),
147
+ endDate: data.endDate ?? toDatePart(existing.endDate),
148
+ };
149
+ if (next.startDate > next.endDate) throw new Error("结束日期不能早于开始日期");
150
+ // completed 锁定编辑(规格 §3.1)
151
+ if (deriveIterationStatus(next.startDate, next.endDate) === "completed") {
152
+ throw new Error("已完成的迭代不可编辑");
153
+ }
154
+ if (data.startDate || data.endDate) await assertNoOverlap(existing.projectId, next, data.id);
155
+ await prisma.iteration.update({
156
+ where: { id: data.id },
157
+ data: {
158
+ ...(data.name !== undefined ? { name: data.name } : {}),
159
+ ...(data.description !== undefined ? { description: data.description } : {}),
160
+ ...(data.startDate ? { startDate: new Date(next.startDate + "T00:00:00.000Z") } : {}),
161
+ ...(data.endDate ? { endDate: new Date(next.endDate + "T00:00:00.000Z") } : {}),
162
+ },
163
+ });
164
+ return { ok: true };
165
+ });
166
+
167
+ export const archiveIterationsFn = createServerFn({ method: "POST" })
168
+ .validator(z.object({ id: z.string(), archived: z.boolean() }))
169
+ .handler(async ({ data }) => {
170
+ const request = getRequest();
171
+ await requireAuth(request);
172
+ await prisma.iteration.update({
173
+ where: { id: data.id },
174
+ data: { archived: data.archived, archivedAt: data.archived ? new Date() : null },
175
+ });
176
+ return { ok: true };
177
+ });
178
+
179
+ export const deleteIterationsFn = createServerFn({ method: "POST" })
180
+ .validator(iterationIdSchema)
181
+ .handler(async ({ data }) => {
182
+ const request = getRequest();
183
+ await requireAuth(request);
184
+ await prisma.iteration.delete({ where: { id: data.id } }); // Story.iterationId SetNull 自动解绑
185
+ return { ok: true };
186
+ });
187
+
188
+ const USER_BRIEF_SELECT = {
189
+ id: true, username: true, email: true, avatarConfig: true, bgShape: true, bgColor: true,
190
+ } as const;
191
+
192
+ const listIterationStoriesSchema = z.object({ iterationId: z.string() });
193
+
194
+ export const listIterationStoriesFn = createServerFn({ method: "GET" })
195
+ .validator(listIterationStoriesSchema)
196
+ .handler(async ({ data }) => {
197
+ const request = getRequest();
198
+ await requireAuth(request);
199
+ const stories = await prisma.story.findMany({
200
+ where: { iterationId: data.iterationId, archived: false },
201
+ orderBy: [{ state: "asc" }, { updatedAt: "desc" }],
202
+ select: {
203
+ id: true, identifier: true, name: true, state: true, priority: true,
204
+ assignee: { select: USER_BRIEF_SELECT },
205
+ },
206
+ });
207
+ return { items: stories.map((s) => ({ ...s, state: s.state as StoryState })) };
208
+ });
@@ -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
+ }
@@ -25,6 +25,12 @@ import { deleteEmbeddings } from '@/lib/pgvector'
25
25
  import { requirePermission } from '@/server/permissions'
26
26
  import { auditLog } from '@/server/audit'
27
27
 
28
+ /** 读权限放宽:自己的 KB ∪ 他人 team/general KB */
29
+ const READABLE_KB = (userId: string) => ({
30
+ isDeleted: false,
31
+ OR: [{ userId }, { visibility: { in: ['team', 'general'] } }],
32
+ })
33
+
28
34
  export const listKnowledgeFn = createServerFn({ method: 'GET' })
29
35
  .validator(z.object({}).optional())
30
36
  .handler(async () => {
@@ -51,7 +57,7 @@ const createSchema = z.object({
51
57
  description: z.string().max(200).optional(),
52
58
  icon: z.string().default('notebook'),
53
59
  tags: z.array(z.string()).default([]),
54
- visibility: z.enum(['private', 'team']).default('private'),
60
+ visibility: z.enum(['private', 'team', 'general']).default('private'),
55
61
  })
56
62
 
57
63
  export const createKnowledgeFn = createServerFn({ method: 'POST' })
@@ -95,7 +101,7 @@ export const getKnowledgeDetailFn = createServerFn({ method: 'GET' })
95
101
  const request = getRequest()
96
102
  const session = await requireAuth(request)
97
103
  const kb = await prisma.knowledgeBase.findFirst({
98
- where: { id: data.id, userId: session.user!.id!, isDeleted: false },
104
+ where: { id: data.id, ...READABLE_KB(session.user!.id!) },
99
105
  select: { id: true, name: true, description: true, icon: true, tags: true, visibility: true, createdAt: true, updatedAt: true },
100
106
  })
101
107
  if (!kb) throw new Error('未找到或无权限')
@@ -130,7 +136,7 @@ const updateSchema = z.object({
130
136
  description: z.string().max(500).optional(),
131
137
  icon: z.string().optional(),
132
138
  tags: z.array(z.string()).optional(),
133
- visibility: z.enum(['private', 'team']).optional(),
139
+ visibility: z.enum(['private', 'team', 'general']).optional(),
134
140
  })
135
141
 
136
142
  export const updateKnowledgeFn = createServerFn({ method: 'POST' })
@@ -202,7 +208,7 @@ export const createDocumentFn = createServerFn({ method: 'POST' })
202
208
  const userId = session.user!.id!
203
209
  await requirePermission(userId, 'knowledge', 'write')
204
210
  const kb = await prisma.knowledgeBase.findFirst({
205
- where: { id: data.kbId, userId, isDeleted: false },
211
+ where: { id: data.kbId, ...READABLE_KB(session.user!.id!) },
206
212
  select: { id: true },
207
213
  })
208
214
  if (!kb) throw new Error('未找到或无权限')
@@ -581,3 +587,43 @@ export const getDocumentPreviewFn = createServerFn({ method: 'GET' })
581
587
  } : null,
582
588
  } satisfies DocumentPreviewData
583
589
  })
590
+
591
+ export const listKbProjectsFn = createServerFn({ method: 'GET' })
592
+ .validator(z.object({ kbId: z.string() }))
593
+ .handler(async ({ data }) => {
594
+ const request = getRequest()
595
+ const session = await requireAuth(request)
596
+ const rows = await prisma.projectKnowledgeBase.findMany({
597
+ where: { kbId: data.kbId },
598
+ select: { project: { select: { id: true, name: true, icon: true } } },
599
+ orderBy: { createdAt: 'asc' },
600
+ })
601
+ return rows.map((r) => r.project)
602
+ })
603
+
604
+ export const setKbProjectsFn = createServerFn({ method: 'POST' })
605
+ .validator(z.object({ kbId: z.string(), projectIds: z.array(z.string()) }))
606
+ .handler(async ({ data }) => {
607
+ const request = getRequest()
608
+ const session = await requireAuth(request)
609
+ const userId = session.user!.id!
610
+ const kb = await prisma.knowledgeBase.findFirst({
611
+ where: { id: data.kbId, userId, isDeleted: false },
612
+ select: { visibility: true },
613
+ })
614
+ if (!kb) throw new Error('知识库不存在')
615
+ if (kb.visibility === 'general') throw new Error('通用知识库对所有项目可见,无需关联')
616
+ if (kb.visibility === 'private') throw new Error('私有知识库不可关联项目,请先切换为团队可见')
617
+ await prisma.$transaction(async (tx) => {
618
+ await tx.projectKnowledgeBase.deleteMany({
619
+ where: { kbId: data.kbId, projectId: { notIn: data.projectIds } },
620
+ })
621
+ if (data.projectIds.length > 0) {
622
+ await tx.projectKnowledgeBase.createMany({
623
+ data: data.projectIds.map((projectId) => ({ kbId: data.kbId, projectId })),
624
+ skipDuplicates: true,
625
+ })
626
+ }
627
+ })
628
+ return { ok: true }
629
+ })
@@ -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
+ });