@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,88 @@
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 {
20
+ hasAiKey,
21
+ runChat,
22
+ buildLabelSuggestSystemPrompt,
23
+ buildLabelSuggestUserMessage,
24
+ parseLabelSuggestions,
25
+ } from "@allbluecn/ai-gateway"
26
+
27
+ const MAX_LABELS_IN_PROMPT = 100
28
+
29
+ export interface SuggestLabelsInput {
30
+ name: string
31
+ description?: string
32
+ currentLabelNames?: string[]
33
+ }
34
+
35
+ export interface SuggestLabelsResult {
36
+ existingLabels: { id: string; name: string; color: string }[]
37
+ newNames: string[]
38
+ }
39
+
40
+ export async function suggestLabelsCore(
41
+ input: SuggestLabelsInput,
42
+ userId: string,
43
+ ): Promise<SuggestLabelsResult> {
44
+ if (!(await hasAiKey(userId))) {
45
+ throw new Error("AI 未配置,请先前往 AI 设置页配置 API Key")
46
+ }
47
+
48
+ const allLabels = await prisma.label.findMany({
49
+ select: { id: true, name: true, color: true },
50
+ orderBy: { name: "asc" },
51
+ })
52
+
53
+ let candidates = allLabels
54
+ if (allLabels.length > MAX_LABELS_IN_PROMPT) {
55
+ const usage = await prisma.storyLabel.groupBy({
56
+ by: ["labelId"],
57
+ _count: { _all: true },
58
+ orderBy: { _count: { labelId: "desc" } },
59
+ take: MAX_LABELS_IN_PROMPT,
60
+ })
61
+ const topIds = new Set(usage.map((u) => u.labelId))
62
+ candidates = allLabels.filter((l) => topIds.has(l.id))
63
+ }
64
+
65
+ const names = candidates.map((l) => l.name)
66
+ const raw = await runChat(
67
+ [{ role: "user", content: buildLabelSuggestUserMessage(input) }],
68
+ {
69
+ systemPrompts: [buildLabelSuggestSystemPrompt(names)],
70
+ modelOptions: { temperature: 0.2 },
71
+ userId,
72
+ },
73
+ )
74
+
75
+ const parsed = parseLabelSuggestions(raw, names)
76
+ const byName = new Map(candidates.map((l) => [l.name, l]))
77
+ const existingLabels: { id: string; name: string; color: string }[] = []
78
+ const unmatchedNames: string[] = []
79
+ for (const n of parsed.existing) {
80
+ const l = byName.get(n)
81
+ if (l) existingLabels.push(l)
82
+ else unmatchedNames.push(n)
83
+ }
84
+ return {
85
+ existingLabels,
86
+ newNames: [...parsed.newNames, ...unmatchedNames],
87
+ }
88
+ }
@@ -0,0 +1,38 @@
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 { suggestLabelsCore } from "./label-suggest-core"
23
+
24
+ const suggestSchema = z.object({
25
+ name: z.string().min(1).max(200),
26
+ description: z.string().max(4000).optional(),
27
+ currentLabelNames: z.array(z.string().min(1).max(10)).max(20).optional(),
28
+ })
29
+
30
+ export const suggestStoryLabelsFn = createServerFn({ method: "POST" })
31
+ .validator(suggestSchema)
32
+ .handler(async ({ data }) => {
33
+ const request = getRequest()
34
+ const session = await requireAuth(request)
35
+ const userId = session.user?.id
36
+ if (!userId) throw new Error("用户未登录")
37
+ return suggestLabelsCore(data, userId)
38
+ })
@@ -15,24 +15,18 @@
15
15
  // You should have received a copy of the GNU Affero General Public License
16
16
  // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
 
18
- import { KNOWLEDGE_ICONS } from "@/components/shared/icon-packs/knowledge";
19
- import { cn } from "@/lib/utils";
18
+ import { prisma } from "@allbluecn/database";
19
+ import { pickLabelColor } from "./label-palette";
20
20
 
21
- const ICON_MAP = new Map(
22
- KNOWLEDGE_ICONS.map((i) => [i.value, i.icon]),
23
- );
24
-
25
- interface KnowledgeIconDisplayProps {
26
- name?: string;
27
- size?: number;
28
- className?: string;
29
- }
30
-
31
- export function KnowledgeIconDisplay({
32
- name,
33
- size = 24,
34
- className,
35
- }: KnowledgeIconDisplayProps) {
36
- const Icon = ICON_MAP.get(name ?? "") ?? KNOWLEDGE_ICONS[0].icon;
37
- return <Icon size={size} className={cn("text-primary", className)} />;
21
+ export async function upsertLabel(name: string, createdById: string, color?: string) {
22
+ return prisma.label.upsert({
23
+ where: { name },
24
+ update: {},
25
+ create: {
26
+ name,
27
+ color: color ?? pickLabelColor(name),
28
+ createdById,
29
+ },
30
+ select: { id: true, name: true, color: true },
31
+ });
38
32
  }
@@ -21,10 +21,11 @@ import { z } from "zod";
21
21
  import { prisma } from "@allbluecn/database";
22
22
  import { requireAuth } from "@/server/auth-helpers";
23
23
  import { USER_BRIEF_SELECT } from "../types";
24
+ import { upsertLabel } from "./label-upsert";
24
25
 
25
26
  const labelCreateSchema = z.object({
26
27
  name: z.string().min(1).max(30),
27
- color: z.string().regex(/^#[0-9a-fA-F]{6}$/).default("#6b7280"),
28
+ color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
28
29
  });
29
30
 
30
31
  export const listUsersBriefFn = createServerFn({ method: "GET" })
@@ -98,8 +99,5 @@ export const createLabelFn = createServerFn({ method: "POST" })
98
99
  .handler(async ({ data }) => {
99
100
  const request = getRequest();
100
101
  const session = await requireAuth(request);
101
- return prisma.label.create({
102
- data: { name: data.name, color: data.color, createdById: session.user!.id! },
103
- select: { id: true, name: true, color: true },
104
- });
102
+ return upsertLabel(data.name, session.user!.id!, data.color);
105
103
  });
@@ -25,7 +25,7 @@ import { isUserInTeam } from "@/server/team-access";
25
25
  import { BUILTIN_INDEPENDENT_DISPLAY_ID } from "@/lib/builtin-project";
26
26
  import type { ActivityItem } from "@/components/story/types";
27
27
  import { diffDescriptionBlocks } from "@/lib/story/description-diff";
28
- import { USER_BRIEF_SELECT, STORY_LIST_SELECT, castStoryUsers, castUserBrief, toDateOnly } from "./types";
28
+ import { USER_BRIEF_SELECT, STORY_LIST_SELECT, castStoryUsers, castUserBrief, toDateOnly, ACTIVITY_SELECT, toActivityItem } from "./types";
29
29
  import { buildListWhere } from "./story-list-query";
30
30
 
31
31
  async function validateAssignee(projectId: string, assigneeId: string | null | undefined) {
@@ -116,9 +116,10 @@ export const createStoryFn = createServerFn({ method: "POST" })
116
116
  for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
117
117
  try {
118
118
  return await prisma.$transaction(async (tx) => {
119
+ let parentStory: { id: string; identifier: string; name: string } | null = null;
119
120
  if (data.parentId) {
120
- const parent = await tx.story.findUnique({ where: { id: data.parentId }, select: { id: true } });
121
- if (!parent) throw new Error("父需求不存在");
121
+ parentStory = await tx.story.findUnique({ where: { id: data.parentId }, select: { id: true, identifier: true, name: true } });
122
+ if (!parentStory) throw new Error("父需求不存在");
122
123
  }
123
124
  let sequenceId: number;
124
125
  let identifier: string;
@@ -132,8 +133,8 @@ export const createStoryFn = createServerFn({ method: "POST" })
132
133
  const isIndependent = project.displayId === BUILTIN_INDEPENDENT_DISPLAY_ID;
133
134
  if (isIndependent) {
134
135
  const counter = await tx.sequenceCounter.upsert({
135
- where: { scope: "global" },
136
- create: { scope: "global", seq: 1 },
136
+ where: { scope: projectId },
137
+ create: { scope: projectId, seq: 1 },
137
138
  update: { seq: { increment: 1 } },
138
139
  });
139
140
  sequenceId = counter.seq;
@@ -177,7 +178,23 @@ export const createStoryFn = createServerFn({ method: "POST" })
177
178
  data: { storyId: story.id, userId, action: "created", isAI: data.isAI },
178
179
  });
179
180
 
180
- return castStoryUsers(story);
181
+ // 创建时带父需求:父需求侧补「添加子需求」活动
182
+ let parentActivity: ReturnType<typeof toActivityItem> | null = null;
183
+ if (parentStory) {
184
+ const pa = await tx.storyActivity.create({
185
+ data: {
186
+ storyId: parentStory.id,
187
+ userId,
188
+ action: "child_added",
189
+ newValue: `${story.identifier} ${story.name}`.trim(),
190
+ isAI: data.isAI,
191
+ },
192
+ select: ACTIVITY_SELECT,
193
+ });
194
+ parentActivity = toActivityItem(pa);
195
+ }
196
+
197
+ return { ...castStoryUsers(story), activities: parentActivity ? [parentActivity] : [] };
181
198
  });
182
199
  } catch (e: unknown) {
183
200
  const isUniqueViolation =
@@ -205,6 +222,7 @@ const updateSchema = z.object({
205
222
  targetDate: z.string().nullable().optional(),
206
223
  labelIds: z.array(z.string()).optional(),
207
224
  parentId: z.string().nullable().optional(),
225
+ iterationId: z.string().nullable().optional(),
208
226
  isAI: z.boolean().default(false),
209
227
  });
210
228
 
@@ -335,6 +353,35 @@ export const updateStoryFn = createServerFn({ method: "POST" })
335
353
  }
336
354
  }
337
355
 
356
+ if (data.iterationId !== undefined) {
357
+ const newIterationId = data.iterationId;
358
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
359
+ const oldIterationId = (existing as any).iteration?.id ?? null;
360
+ if (newIterationId !== oldIterationId) {
361
+ let oldName = "";
362
+ let newName = "";
363
+ if (oldIterationId) {
364
+ const oldIt = await tx.iteration.findUnique({ where: { id: oldIterationId }, select: { name: true } });
365
+ oldName = oldIt?.name ?? "";
366
+ }
367
+ if (newIterationId) {
368
+ const newIt = await tx.iteration.findUnique({ where: { id: newIterationId }, select: { name: true } });
369
+ newName = newIt?.name ?? "";
370
+ }
371
+ if (newIterationId) {
372
+ updates.iteration = { connect: { id: newIterationId } };
373
+ } else {
374
+ updates.iteration = { disconnect: true };
375
+ }
376
+ activities.push({
377
+ action: "iteration_changed",
378
+ field: "iterationId",
379
+ oldValue: oldName,
380
+ newValue: newName,
381
+ });
382
+ }
383
+ }
384
+
338
385
  updates.updatedBy = { connect: { id: userId } };
339
386
 
340
387
  const updated = await tx.story.update({
@@ -20,7 +20,7 @@ import { getRequest } from "@tanstack/react-start/server";
20
20
  import { z } from "zod";
21
21
  import { prisma } from "@allbluecn/database";
22
22
  import { requireAuth } from "@/server/auth-helpers";
23
- import { STORY_LIST_SELECT, castStoryUsers } from "./types";
23
+ import { STORY_LIST_SELECT, castStoryUsers, ACTIVITY_SELECT, toActivityItem } from "./types";
24
24
  import type { DependencyRow } from "@/components/story/types";
25
25
 
26
26
  const edgeTypeSchema = z.enum(["BLOCKS", "STARTS_BEFORE", "FINISHES_BEFORE"]);
@@ -41,7 +41,8 @@ export const addDependenciesFn = createServerFn({ method: "POST" })
41
41
  )
42
42
  .handler(async ({ data }) => {
43
43
  const request = getRequest();
44
- await requireAuth(request);
44
+ const session = await requireAuth(request);
45
+ const userId = session.user!.id!;
45
46
 
46
47
  // 一次性取全部出边(3 类型合并;不按 projectId 裁剪——跨项目边同样参与环判定),
47
48
  // 构建内存邻接表供本批循环的环检测复用,避免 O(items × 可达节点) 次串行 RTT
@@ -111,15 +112,167 @@ export const addDependenciesFn = createServerFn({ method: "POST" })
111
112
  batchEdges.push({ sourceId: item.sourceId, targetId: item.targetId });
112
113
  }
113
114
 
114
- return { created, failed };
115
+ // 活动记录:依赖两端各记一条「添加依赖关系」(newValue 为对方需求描述),
116
+ // 返回带 storyId 供 UI 过滤当前需求侧的记录
117
+ let activities: Array<ReturnType<typeof toActivityItem> & { storyId: string }> = [];
118
+ if (created.length > 0) {
119
+ const endpointIds = [...new Set(batchEdges.flatMap((e) => [e.sourceId, e.targetId]))];
120
+ const endpoints = await prisma.story.findMany({
121
+ where: { id: { in: endpointIds } },
122
+ select: { id: true, identifier: true, name: true },
123
+ });
124
+ const labelById = new Map(
125
+ endpoints.map((s) => [s.id, `${s.identifier} ${s.name}`.trim()]),
126
+ );
127
+ const createdActivities = await Promise.all(
128
+ batchEdges.flatMap(({ sourceId, targetId }) => {
129
+ const sourceLabel = labelById.get(sourceId) ?? "";
130
+ const targetLabel = labelById.get(targetId) ?? "";
131
+ return [
132
+ prisma.storyActivity.create({
133
+ data: { storyId: sourceId, userId, action: "dependency_added", newValue: targetLabel },
134
+ select: { ...ACTIVITY_SELECT, storyId: true },
135
+ }),
136
+ prisma.storyActivity.create({
137
+ data: { storyId: targetId, userId, action: "dependency_added", newValue: sourceLabel },
138
+ select: { ...ACTIVITY_SELECT, storyId: true },
139
+ }),
140
+ ];
141
+ }),
142
+ );
143
+ activities = createdActivities.map((a) => ({ ...toActivityItem(a), storyId: a.storyId }));
144
+ }
145
+
146
+ return { created, failed, activities };
147
+ });
148
+
149
+ export const updateDependencyFn = createServerFn({ method: "POST" })
150
+ .validator(
151
+ z.object({
152
+ depId: z.string(),
153
+ sourceId: z.string(),
154
+ targetId: z.string(),
155
+ type: edgeTypeSchema,
156
+ }),
157
+ )
158
+ .handler(async ({ data }) => {
159
+ const request = getRequest();
160
+ const session = await requireAuth(request);
161
+ const userId = session.user!.id!;
162
+
163
+ const dep = await prisma.storyDependency.findUnique({ where: { id: data.depId } });
164
+ if (!dep) throw new Error("依赖关系不存在");
165
+ // 新边两端必须是原边的重定向(允许方向反转),防止越权改到别的需求对
166
+ const sameSet =
167
+ (data.sourceId === dep.sourceId && data.targetId === dep.targetId) ||
168
+ (data.sourceId === dep.targetId && data.targetId === dep.sourceId);
169
+ if (!sameSet) throw new Error("依赖关系两端不匹配");
170
+ if (
171
+ data.sourceId === dep.sourceId &&
172
+ data.targetId === dep.targetId &&
173
+ data.type === dep.type
174
+ ) {
175
+ return { ok: true as const, activities: [] };
176
+ }
177
+
178
+ // 方向反转可能成环:写入 (S,T) 后成环 ⇔ 排除自身边后存在路径 T →…→ S
179
+ const edges = await prisma.storyDependency.findMany({
180
+ where: { id: { not: dep.id } },
181
+ select: { sourceId: true, targetId: true },
182
+ });
183
+ const adjacency = new Map<string, string[]>();
184
+ for (const e of edges) {
185
+ const list = adjacency.get(e.sourceId);
186
+ if (list) list.push(e.targetId);
187
+ else adjacency.set(e.sourceId, [e.targetId]);
188
+ }
189
+ const visited = new Set<string>();
190
+ const stack = [data.targetId];
191
+ let cyclic = false;
192
+ while (stack.length > 0) {
193
+ const current = stack.pop()!;
194
+ if (current === data.sourceId) {
195
+ cyclic = true;
196
+ break;
197
+ }
198
+ if (visited.has(current)) continue;
199
+ visited.add(current);
200
+ for (const next of adjacency.get(current) ?? []) {
201
+ if (!visited.has(next)) stack.push(next);
202
+ }
203
+ }
204
+ if (cyclic) return { ok: false as const, reason: "CYCLE" as const };
205
+
206
+ await prisma.storyDependency.update({
207
+ where: { id: dep.id },
208
+ data: { sourceId: data.sourceId, targetId: data.targetId, type: data.type },
209
+ });
210
+
211
+ // 两端各记一条「更新依赖关系」(newValue 为对方需求描述,与添加/移除一致)
212
+ const endpoints = await prisma.story.findMany({
213
+ where: { id: { in: [data.sourceId, data.targetId] } },
214
+ select: { id: true, identifier: true, name: true },
215
+ });
216
+ const labelById = new Map(
217
+ endpoints.map((s) => [s.id, `${s.identifier} ${s.name}`.trim()]),
218
+ );
219
+ const acts = await Promise.all([
220
+ prisma.storyActivity.create({
221
+ data: {
222
+ storyId: data.sourceId,
223
+ userId,
224
+ action: "dependency_updated",
225
+ newValue: labelById.get(data.targetId) ?? "",
226
+ },
227
+ select: { ...ACTIVITY_SELECT, storyId: true },
228
+ }),
229
+ prisma.storyActivity.create({
230
+ data: {
231
+ storyId: data.targetId,
232
+ userId,
233
+ action: "dependency_updated",
234
+ newValue: labelById.get(data.sourceId) ?? "",
235
+ },
236
+ select: { ...ACTIVITY_SELECT, storyId: true },
237
+ }),
238
+ ]);
239
+ return {
240
+ ok: true as const,
241
+ activities: acts.map((a) => ({ ...toActivityItem(a), storyId: a.storyId })),
242
+ };
115
243
  });
116
244
 
117
245
  export const removeDependencyFn = createServerFn({ method: "POST" })
118
246
  .validator(z.object({ depId: z.string() }))
119
247
  .handler(async ({ data }) => {
120
248
  const request = getRequest();
121
- await requireAuth(request);
122
- return prisma.storyDependency.delete({ where: { id: data.depId } });
249
+ const session = await requireAuth(request);
250
+ const userId = session.user!.id!;
251
+
252
+ const dep = await prisma.storyDependency.findUnique({ where: { id: data.depId } });
253
+ if (!dep) return null;
254
+
255
+ const [sourceStory, targetStory] = await Promise.all([
256
+ prisma.story.findUnique({ where: { id: dep.sourceId }, select: { identifier: true, name: true } }),
257
+ prisma.story.findUnique({ where: { id: dep.targetId }, select: { identifier: true, name: true } }),
258
+ ]);
259
+
260
+ await prisma.storyDependency.delete({ where: { id: data.depId } });
261
+
262
+ // 两端都记活动记录
263
+ const depLabel = targetStory?.identifier || targetStory?.name || dep.targetId;
264
+ const act1 = await prisma.storyActivity.create({
265
+ data: { storyId: dep.sourceId, userId, action: "dependency_removed", newValue: depLabel },
266
+ select: { ...ACTIVITY_SELECT, storyId: true },
267
+ });
268
+ const depLabel2 = sourceStory?.identifier || sourceStory?.name || dep.sourceId;
269
+ const act2 = await prisma.storyActivity.create({
270
+ data: { storyId: dep.targetId, userId, action: "dependency_removed", newValue: depLabel2 },
271
+ select: { ...ACTIVITY_SELECT, storyId: true },
272
+ });
273
+
274
+ // toActivityItem:Date → ISO 字符串,Seroval 可序列化;带 storyId 供客户端过滤当前需求的记录
275
+ return { dep, activities: [act1, act2].map((a) => ({ ...toActivityItem(a), storyId: a.storyId })) };
123
276
  });
124
277
 
125
278
  export const getDependenciesFn = createServerFn({ method: "GET" })
@@ -20,12 +20,13 @@ import { getRequest } from "@tanstack/react-start/server";
20
20
  import { z } from "zod";
21
21
  import { prisma } from "@allbluecn/database";
22
22
  import { requireAuth } from "@/server/auth-helpers";
23
-
23
+ import { ACTIVITY_SELECT, toActivityItem } from "./types";
24
24
  export const addLinkFn = createServerFn({ method: "POST" })
25
25
  .validator(z.object({ storyId: z.string(), url: z.string().url(), title: z.string().optional(), description: z.string().optional() }))
26
26
  .handler(async ({ data }) => {
27
27
  const request = getRequest();
28
28
  const session = await requireAuth(request);
29
+ const userId = session.user!.id!;
29
30
 
30
31
  const link = await prisma.storyLink.create({
31
32
  data: {
@@ -36,7 +37,17 @@ export const addLinkFn = createServerFn({ method: "POST" })
36
37
  },
37
38
  });
38
39
 
39
- return link;
40
+ const activity = await prisma.storyActivity.create({
41
+ data: {
42
+ storyId: data.storyId,
43
+ userId,
44
+ action: "link_added",
45
+ newValue: data.title || data.url,
46
+ },
47
+ select: ACTIVITY_SELECT,
48
+ });
49
+
50
+ return { ...link, activity: toActivityItem(activity) };
40
51
  });
41
52
 
42
53
  export const updateLinkFn = createServerFn({ method: "POST" })
@@ -44,6 +55,7 @@ export const updateLinkFn = createServerFn({ method: "POST" })
44
55
  .handler(async ({ data }) => {
45
56
  const request = getRequest();
46
57
  const session = await requireAuth(request);
58
+ const userId = session.user!.id!;
47
59
 
48
60
  const link = await prisma.storyLink.update({
49
61
  where: { id: data.linkId },
@@ -54,7 +66,17 @@ export const updateLinkFn = createServerFn({ method: "POST" })
54
66
  },
55
67
  });
56
68
 
57
- return link;
69
+ const activity = await prisma.storyActivity.create({
70
+ data: {
71
+ storyId: link.storyId,
72
+ userId,
73
+ action: "link_updated",
74
+ newValue: link.title || link.url,
75
+ },
76
+ select: ACTIVITY_SELECT,
77
+ });
78
+
79
+ return { ...link, activity: toActivityItem(activity) };
58
80
  });
59
81
 
60
82
  export const removeLinkFn = createServerFn({ method: "POST" })
@@ -62,12 +84,26 @@ export const removeLinkFn = createServerFn({ method: "POST" })
62
84
  .handler(async ({ data }) => {
63
85
  const request = getRequest();
64
86
  const session = await requireAuth(request);
87
+ const userId = session.user!.id!;
65
88
 
66
- const link = await prisma.storyLink.delete({
67
- where: { id: data.linkId },
68
- });
89
+ const link = await prisma.storyLink.findUnique({ where: { id: data.linkId } });
90
+ await prisma.storyLink.delete({ where: { id: data.linkId } });
91
+
92
+ let activity = null;
93
+ if (link) {
94
+ const created = await prisma.storyActivity.create({
95
+ data: {
96
+ storyId: link.storyId,
97
+ userId,
98
+ action: "link_removed",
99
+ newValue: link.title || link.url,
100
+ },
101
+ select: ACTIVITY_SELECT,
102
+ });
103
+ activity = toActivityItem(created);
104
+ }
69
105
 
70
- return link;
106
+ return { activity };
71
107
  });
72
108
 
73
109
  export const getLinksFn = createServerFn({ method: "GET" })
@@ -73,8 +73,8 @@ async function allocateStoryIdentifier(
73
73
  const isIndependent = project.displayId === BUILTIN_INDEPENDENT_DISPLAY_ID;
74
74
  if (isIndependent) {
75
75
  const counter = await tx.sequenceCounter.upsert({
76
- where: { scope: "global" },
77
- create: { scope: "global", seq: 1 },
76
+ where: { scope: projectId },
77
+ create: { scope: projectId, seq: 1 },
78
78
  update: { seq: { increment: 1 } },
79
79
  });
80
80
  return { sequenceId: counter.seq, identifier: `STORY-${counter.seq}` };
@@ -350,7 +350,7 @@ const listProjectStoriesSchema = z.object({
350
350
  cursor: z.string().optional(),
351
351
  excludeStoryId: z.string().optional(),
352
352
  excludeIds: z.array(z.string()).optional(),
353
- mode: z.enum(["parent", "child", "dependency"]).default("parent"),
353
+ mode: z.enum(["parent", "child", "dependency", "iteration"]).default("parent"),
354
354
  pageSize: z.number().int().min(1).max(50).default(20),
355
355
  });
356
356
 
@@ -376,7 +376,7 @@ export const listProjectStoriesFn = createServerFn({ method: "GET" })
376
376
  const stories = await prisma.story.findMany({
377
377
  where: {
378
378
  ...(data.projectId ? { projectId: data.projectId } : {}),
379
- ...(data.mode === "dependency" ? {} : { parentId: null }),
379
+ ...(data.mode === "dependency" || data.mode === "iteration" ? {} : { parentId: null }),
380
380
  archived: false,
381
381
  ...(excludeIds.size > 0
382
382
  ? { id: { notIn: Array.from(excludeIds) } }
@@ -400,6 +400,7 @@ export const listProjectStoriesFn = createServerFn({ method: "GET" })
400
400
  state: true,
401
401
  priority: true,
402
402
  projectId: true,
403
+ ...(data.mode === "iteration" ? { iteration: { select: { id: true, name: true } } } : {}),
403
404
  },
404
405
  });
405
406
 
@@ -54,7 +54,8 @@ export const addChildFn = createServerFn({ method: "POST" })
54
54
  .validator(z.object({ parentId: z.string(), childId: z.string() }))
55
55
  .handler(async ({ data }) => {
56
56
  const request = getRequest();
57
- await requireAuth(request);
57
+ const session = await requireAuth(request);
58
+ const userId = session.user!.id!;
58
59
 
59
60
  const existingChild = await prisma.story.findUnique({
60
61
  where: { id: data.childId },
@@ -68,11 +69,37 @@ export const addChildFn = createServerFn({ method: "POST" })
68
69
  throw new Error("不能将子需求设为其父需求的子需求(循环依赖)");
69
70
  }
70
71
 
72
+ const parent = await prisma.story.findUnique({
73
+ where: { id: data.parentId },
74
+ select: { id: true, identifier: true, name: true },
75
+ });
76
+
71
77
  const child = await prisma.story.update({
72
78
  where: { id: data.childId },
73
79
  data: { parentId: data.parentId },
74
80
  });
75
81
 
82
+ // 活动记录:父需求记「添加子需求」,子需求记「变更父需求」(与 updateStoryFn 格式一致)
83
+ if (parent) {
84
+ const childLabel = `${existingChild?.identifier ?? ""} ${existingChild?.name ?? ""}`.trim();
85
+ const parentLabel = `${parent.identifier} ${parent.name}`.trim();
86
+ await prisma.$transaction([
87
+ prisma.storyActivity.create({
88
+ data: { storyId: parent.id, userId, action: "child_added", newValue: childLabel },
89
+ }),
90
+ prisma.storyActivity.create({
91
+ data: {
92
+ storyId: child.id,
93
+ userId,
94
+ action: "parent_changed",
95
+ field: "parentId",
96
+ oldValue: "",
97
+ newValue: parentLabel,
98
+ },
99
+ }),
100
+ ]);
101
+ }
102
+
76
103
  return child;
77
104
  });
78
105