@allbluecn/web-app 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/package.json +230 -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/layout/sidebar-03/__tests__/nav-workspace.test.tsx +2 -2
  12. package/src/components/layout/sidebar-03/nav-workspace.tsx +2 -2
  13. package/src/components/project/create-project-dialog.tsx +44 -57
  14. package/src/components/shared/__tests__/lucide-curated-icons.test.ts +83 -0
  15. package/src/components/shared/__tests__/lucide-icon-data.test.ts +131 -0
  16. package/src/components/shared/__tests__/lucide-icon-picker.test.tsx +197 -0
  17. package/src/components/shared/lucide-icon-picker/curated-icons.ts +478 -0
  18. package/src/components/shared/lucide-icon-picker/icon-data.ts +109 -0
  19. package/src/components/shared/lucide-icon-picker/index.tsx +82 -0
  20. package/src/components/shared/lucide-icon-picker/lucide-icon-by-name.tsx +78 -0
  21. package/src/components/shared/lucide-icon-picker/panel.tsx +163 -0
  22. package/src/components/story/__tests__/constants.test.ts +255 -0
  23. package/src/components/story/ai/story-ai-panel.tsx +23 -15
  24. package/src/components/story/ai/story-ai-preview-list.tsx +19 -0
  25. package/src/components/story/constants.ts +33 -1
  26. package/src/components/story/create-story-dialog.tsx +30 -10
  27. package/src/components/story/detail/__tests__/categories.test.tsx +62 -0
  28. package/src/components/story/detail/__tests__/dependencies-button.test.tsx +49 -0
  29. package/src/components/story/detail/activity-timeline.tsx +11 -4
  30. package/src/components/story/detail/story-detail-body.tsx +89 -38
  31. package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +118 -9
  32. package/src/components/story/detail/story-detail-toolbar/children.tsx +7 -4
  33. package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +67 -7
  34. package/src/components/story/detail/story-detail-toolbar.tsx +6 -1
  35. package/src/components/story/detail/story-properties.tsx +21 -4
  36. package/src/components/story/display/__tests__/group-fields.test.ts +107 -0
  37. package/src/components/story/display/group-resolver.tsx +2 -2
  38. package/src/components/story/inline-editors/__tests__/dependency-type-select.test.tsx +155 -0
  39. package/src/components/story/inline-editors/__tests__/labels-editor.test.tsx +155 -0
  40. package/src/components/story/inline-editors/__tests__/priority-editor.test.tsx +173 -0
  41. package/src/components/story/inline-editors/__tests__/state-editor.test.tsx +174 -0
  42. package/src/components/story/inline-editors/dependency-type-select.tsx +180 -0
  43. package/src/components/story/inline-editors/index.ts +2 -1
  44. package/src/components/story/inline-editors/labels-editor.tsx +159 -3
  45. package/src/components/story/inline-editors/story-select-dialog.tsx +22 -59
  46. package/src/components/story/peek-panel.tsx +121 -21
  47. package/src/components/story/relations/link-manage-dialog.tsx +2 -1
  48. package/src/components/story/relations/story-ref-row.tsx +17 -27
  49. package/src/components/story/story-panel-header.tsx +25 -19
  50. package/src/components/story/story-toolbar.tsx +3 -3
  51. package/src/components/story/transfer-story-popover.tsx +2 -2
  52. package/src/components/story/types.ts +1 -0
  53. package/src/components/story/views/create-view-dialog.tsx +3 -3
  54. package/src/components/story/views/story-list/__tests__/group-header.test.tsx +219 -0
  55. package/src/components/story/views/story-list/group.tsx +1 -1
  56. package/src/components/story/views/story-list/inline-story-creator.tsx +2 -2
  57. package/src/components/story/views/story-list/row.tsx +1 -1
  58. package/src/components/workspace/create-workspace-dialog.tsx +41 -49
  59. package/src/lib/__tests__/project-display-id.test.ts +8 -5
  60. package/src/lib/changelog/sdk-versions.ts +20 -20
  61. package/src/lib/iteration/__tests__/status.test.ts +41 -0
  62. package/src/lib/iteration/status.ts +28 -0
  63. package/src/lib/project-display-id.ts +14 -5
  64. package/src/lib/project-module-meta.ts +77 -0
  65. package/src/lib/utils.d.ts +1 -1
  66. package/src/providers/index.tsx +6 -0
  67. package/src/routeTree.gen.ts +54 -0
  68. package/src/routes/__root.tsx +0 -8
  69. package/src/routes/_nav/knowledge/index.lazy.tsx +2 -2
  70. package/src/routes/_nav/projects/$projectId/$storyId/route.lazy.tsx +52 -25
  71. package/src/routes/_nav/projects/$projectId/$storyId/route.tsx +6 -3
  72. package/src/routes/_nav/projects/$projectId/index.lazy.tsx +32 -15
  73. package/src/routes/_nav/projects/$projectId/iterations/$iterationId/route.lazy.tsx +289 -0
  74. package/src/routes/_nav/projects/$projectId/iterations/$iterationId/route.tsx +27 -0
  75. package/src/routes/_nav/projects/$projectId/iterations/index.lazy.tsx +111 -0
  76. package/src/routes/_nav/projects/$projectId/iterations/index.tsx +24 -0
  77. package/src/routes/_nav/projects/$projectId/route.tsx +4 -2
  78. package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +4 -4
  79. package/src/routes/_nav/stories/$storyId/route.lazy.tsx +5 -1
  80. package/src/routes/_nav/stories/$storyId/route.tsx +4 -0
  81. package/src/routes/_nav/workspaces/$workspaceId/route.lazy.tsx +3 -3
  82. package/src/routes/_nav/workspaces/$workspaceId/settings/route.lazy.tsx +4 -4
  83. package/src/routes/_nav/workspaces/index.lazy.tsx +3 -3
  84. package/src/server/serverFns/iteration/__tests__/auto-schedule-core.test.ts +62 -0
  85. package/src/server/serverFns/iteration/__tests__/burndown-core.test.ts +90 -0
  86. package/src/server/serverFns/iteration/__tests__/iteration-crud.test.ts +211 -0
  87. package/src/server/serverFns/iteration/__tests__/iteration-stories.test.ts +155 -0
  88. package/src/server/serverFns/iteration/__tests__/iteration-transfer.test.ts +136 -0
  89. package/src/server/serverFns/iteration/auto-schedule-core.ts +54 -0
  90. package/src/server/serverFns/iteration/auto-schedule-internal.ts +88 -0
  91. package/src/server/serverFns/iteration/burndown-core.ts +60 -0
  92. package/src/server/serverFns/iteration/index.ts +22 -0
  93. package/src/server/serverFns/iteration/iteration-auto-schedule.ts +66 -0
  94. package/src/server/serverFns/iteration/iteration-burndown.ts +70 -0
  95. package/src/server/serverFns/iteration/iteration-crud.ts +208 -0
  96. package/src/server/serverFns/iteration/iteration-stories.ts +111 -0
  97. package/src/server/serverFns/iteration/iteration-transfer.ts +42 -0
  98. package/src/server/serverFns/iteration/schemas.ts +58 -0
  99. package/src/server/serverFns/iteration/transfer-internal.ts +62 -0
  100. package/src/server/serverFns/project/module-overview.ts +109 -0
  101. package/src/server/serverFns/project.ts +2 -2
  102. package/src/server/serverFns/story/__tests__/label-suggest-fn.test.ts +100 -0
  103. package/src/server/serverFns/story/__tests__/labels.test.ts +67 -0
  104. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +106 -2
  105. package/src/server/serverFns/story/index.ts +3 -0
  106. package/src/server/serverFns/story/story-assoc.ts +36 -4
  107. package/src/server/serverFns/story/story-attachments.ts +20 -2
  108. package/src/{components/project/project-icon.tsx → server/serverFns/story/story-crud/label-palette.ts} +16 -19
  109. package/src/server/serverFns/story/story-crud/label-suggest-core.ts +88 -0
  110. package/src/server/serverFns/story/story-crud/label-suggest.ts +38 -0
  111. package/src/{components/knowledge/knowledge-icon.tsx → server/serverFns/story/story-crud/label-upsert.ts} +13 -19
  112. package/src/server/serverFns/story/story-crud/labels.ts +3 -5
  113. package/src/server/serverFns/story/story-crud.ts +32 -2
  114. package/src/server/serverFns/story/story-dependencies.ts +123 -2
  115. package/src/server/serverFns/story/story-links.ts +30 -6
  116. package/src/server/serverFns/story/story-transfer.ts +5 -4
  117. package/src/server/serverFns/story/types.ts +1 -0
  118. package/src/types/project.ts +5 -5
  119. package/src/components/project/project-animated-icon.tsx +0 -73
  120. package/src/components/shared/icon-packs/index.ts +0 -1
  121. package/src/components/shared/icon-packs/knowledge.ts +0 -1
  122. package/src/components/shared/icon-packs/prd.ts +0 -1
  123. package/src/components/shared/icon-packs/project.ts +0 -1
  124. package/src/components/shared/icon-picker.tsx +0 -1
  125. package/src/lib/project-mock.ts +0 -156
@@ -47,9 +47,12 @@ vi.mock("@tanstack/react-start/server", () => ({
47
47
  const mocks = vi.hoisted(() => ({
48
48
  depFindMany: vi.fn(),
49
49
  depFindFirst: vi.fn(),
50
+ depFindUnique: vi.fn(),
50
51
  depCreate: vi.fn(),
52
+ depUpdate: vi.fn(),
51
53
  depDelete: vi.fn(),
52
54
  storyFindMany: vi.fn(),
55
+ storyFindUnique: vi.fn(),
53
56
  activityCreate: vi.fn(),
54
57
  currentSession: vi.fn(),
55
58
  }));
@@ -59,10 +62,12 @@ vi.mock("@allbluecn/database", () => {
59
62
  storyDependency: {
60
63
  findMany: mocks.depFindMany,
61
64
  findFirst: mocks.depFindFirst,
65
+ findUnique: mocks.depFindUnique,
62
66
  create: mocks.depCreate,
67
+ update: mocks.depUpdate,
63
68
  delete: mocks.depDelete,
64
69
  },
65
- story: { findMany: mocks.storyFindMany },
70
+ story: { findMany: mocks.storyFindMany, findUnique: mocks.storyFindUnique },
66
71
  storyActivity: { create: mocks.activityCreate },
67
72
  };
68
73
  return { prisma, Prisma: {} };
@@ -74,6 +79,7 @@ vi.mock("@/server/auth-helpers", () => ({
74
79
 
75
80
  import {
76
81
  addDependenciesFn,
82
+ updateDependencyFn,
77
83
  removeDependencyFn,
78
84
  getDependenciesFn,
79
85
  } from "../story-dependencies";
@@ -225,12 +231,110 @@ describe("addDependenciesFn", () => {
225
231
  });
226
232
  });
227
233
 
234
+ describe("updateDependencyFn", () => {
235
+ const EXISTING = { id: "d1", sourceId: "a", targetId: "b", type: "BLOCKS" };
236
+ const UPDATE_ACTIVITY = {
237
+ id: "act1",
238
+ action: "dependency_updated",
239
+ field: null,
240
+ oldValue: null,
241
+ newValue: "B-1 story b",
242
+ isAI: false,
243
+ createdAt: new Date("2026-08-31T00:00:00Z"),
244
+ storyId: "a",
245
+ user: { id: "u1", username: "u1", email: "e", avatarConfig: null, bgShape: null, bgColor: null },
246
+ };
247
+
248
+ it("同向改类型:仅更新 type,两端各记一条活动", async () => {
249
+ mocks.depFindUnique.mockResolvedValue(EXISTING);
250
+ mocks.depFindMany.mockResolvedValue([]); // 排除自身后无其他边
251
+ mocks.depUpdate.mockResolvedValue({ ...EXISTING, type: "STARTS_BEFORE" });
252
+ mocks.activityCreate.mockResolvedValue(UPDATE_ACTIVITY);
253
+ const res = await updateDependencyFn({
254
+ data: { depId: "d1", sourceId: "a", targetId: "b", type: "STARTS_BEFORE" },
255
+ });
256
+ expect(res.ok).toBe(true);
257
+ expect(mocks.depUpdate).toHaveBeenCalledWith({
258
+ where: { id: "d1" },
259
+ data: { sourceId: "a", targetId: "b", type: "STARTS_BEFORE" },
260
+ });
261
+ expect(res.activities).toHaveLength(2);
262
+ });
263
+
264
+ it("方向反转合法时更新为新的 source/target", async () => {
265
+ mocks.depFindUnique.mockResolvedValue(EXISTING);
266
+ mocks.depFindMany.mockResolvedValue([]);
267
+ mocks.depUpdate.mockResolvedValue({ ...EXISTING, sourceId: "b", targetId: "a" });
268
+ mocks.activityCreate.mockResolvedValue(UPDATE_ACTIVITY);
269
+ const res = await updateDependencyFn({
270
+ data: { depId: "d1", sourceId: "b", targetId: "a", type: "BLOCKS" },
271
+ });
272
+ expect(res.ok).toBe(true);
273
+ expect(mocks.depUpdate).toHaveBeenCalledWith({
274
+ where: { id: "d1" },
275
+ data: { sourceId: "b", targetId: "a", type: "BLOCKS" },
276
+ });
277
+ });
278
+
279
+ it("方向反转成环时拒绝写入", async () => {
280
+ mocks.depFindUnique.mockResolvedValue(EXISTING);
281
+ // 现边 a -> b;另已存在路径 a -> c -> b。请求反转成 b -> a 后,
282
+ // 从新 target(a) 出发可直达新 source(b) ⇒ 成环,必须拒绝
283
+ mocks.depFindMany.mockResolvedValue([
284
+ { sourceId: "a", targetId: "c" },
285
+ { sourceId: "c", targetId: "b" },
286
+ ]);
287
+ const res = await updateDependencyFn({
288
+ data: { depId: "d1", sourceId: "b", targetId: "a", type: "BLOCKS" },
289
+ });
290
+ expect(res).toEqual({ ok: false, reason: "CYCLE" });
291
+ expect(mocks.depUpdate).not.toHaveBeenCalled();
292
+ });
293
+
294
+ it("三元组与现状一致时短路返回且不写库", async () => {
295
+ mocks.depFindUnique.mockResolvedValue(EXISTING);
296
+ const res = await updateDependencyFn({
297
+ data: { depId: "d1", sourceId: "a", targetId: "b", type: "BLOCKS" },
298
+ });
299
+ expect(res).toEqual({ ok: true, activities: [] });
300
+ expect(mocks.depUpdate).not.toHaveBeenCalled();
301
+ expect(mocks.depFindMany).not.toHaveBeenCalled();
302
+ });
303
+
304
+ it("两端不属于原边时抛错", async () => {
305
+ mocks.depFindUnique.mockResolvedValue(EXISTING);
306
+ await expect(
307
+ updateDependencyFn({
308
+ data: { depId: "d1", sourceId: "a", targetId: "c", type: "BLOCKS" },
309
+ }),
310
+ ).rejects.toMatchObject({ message: expect.stringContaining("依赖关系两端不匹配") });
311
+ expect(mocks.depUpdate).not.toHaveBeenCalled();
312
+ });
313
+
314
+ it("depId 不存在时抛错", async () => {
315
+ mocks.depFindUnique.mockResolvedValue(null);
316
+ await expect(
317
+ updateDependencyFn({
318
+ data: { depId: "missing", sourceId: "a", targetId: "b", type: "BLOCKS" },
319
+ }),
320
+ ).rejects.toMatchObject({ message: expect.stringContaining("依赖关系不存在") });
321
+ });
322
+ });
323
+
228
324
  describe("removeDependencyFn", () => {
229
325
  it("按 depId 删除", async () => {
326
+ mocks.depFindUnique.mockResolvedValue({ id: "d1", sourceId: "s1", targetId: "t1" });
327
+ mocks.storyFindUnique.mockImplementation(({ where }: any) => {
328
+ if (where.id === "s1") return Promise.resolve({ identifier: "A-1", name: "story a" });
329
+ if (where.id === "t1") return Promise.resolve({ identifier: "B-1", name: "story b" });
330
+ return Promise.resolve(null);
331
+ });
230
332
  mocks.depDelete.mockResolvedValue({ id: "d1" });
333
+ mocks.activityCreate.mockResolvedValue({ id: "act1", action: "dependency_removed", field: null, oldValue: null, newValue: "B-1", isAI: false, createdAt: new Date(), user: { id: "u1", username: "u1", email: "e", avatarConfig: null, bgShape: null, bgColor: null } });
231
334
  const res = await removeDependencyFn({ data: { depId: "d1" } });
232
- expect(res.id).toBe("d1");
335
+ expect(res?.dep.id).toBe("d1");
233
336
  expect(mocks.depDelete).toHaveBeenCalledWith({ where: { id: "d1" } });
337
+ expect(mocks.activityCreate).toHaveBeenCalledTimes(2);
234
338
  });
235
339
  });
236
340
 
@@ -21,6 +21,8 @@ export {
21
21
  createLabelFn,
22
22
  } from "./story-crud/labels";
23
23
 
24
+ export { suggestStoryLabelsFn } from "./story-crud/label-suggest";
25
+
24
26
  export {
25
27
  listActivitiesFn,
26
28
  } from "./story-crud/activities";
@@ -82,6 +84,7 @@ export {
82
84
 
83
85
  export {
84
86
  addDependenciesFn,
87
+ updateDependencyFn,
85
88
  removeDependencyFn,
86
89
  getDependenciesFn,
87
90
  } from "./story-dependencies";
@@ -113,12 +113,29 @@ export const unlinkPrdFn = createServerFn({ method: "POST" })
113
113
  .handler(async ({ data }) => {
114
114
  const request = getRequest();
115
115
  const session = await requireAuth(request);
116
+ const userId = session.user!.id!;
117
+
118
+ const prd = await prisma.pRD.findUnique({ where: { id: data.prdId }, select: { name: true } });
116
119
 
117
- const link = await prisma.storyPrdLink.delete({
120
+ await prisma.storyPrdLink.delete({
118
121
  where: { storyId_prdId: { storyId: data.storyId, prdId: data.prdId } },
119
122
  });
120
123
 
121
- return link;
124
+ let activity = null;
125
+ if (prd) {
126
+ const created = await prisma.storyActivity.create({
127
+ data: {
128
+ storyId: data.storyId,
129
+ userId,
130
+ action: "prd_unlinked",
131
+ newValue: prd.name,
132
+ },
133
+ select: ACTIVITY_SELECT,
134
+ });
135
+ activity = toActivityItem(created);
136
+ }
137
+
138
+ return { storyId: data.storyId, prdId: data.prdId, activity };
122
139
  });
123
140
 
124
141
  export const getLinkedPrdsFn = createServerFn({ method: "GET" })
@@ -415,17 +432,32 @@ export const unlinkDocFn = createServerFn({ method: "POST" })
415
432
  .handler(async ({ data }) => {
416
433
  const request = getRequest();
417
434
  const session = await requireAuth(request);
435
+ const userId = session.user!.id!;
436
+
418
437
  const parsed = parseExternalDocId(data.docId);
419
438
  if (parsed) {
420
439
  await prisma.storyExternalLink.deleteMany({
421
440
  where: { storyId: data.storyId, group: parsed.group, externalId: parsed.externalId },
422
441
  });
423
- return { ok: true };
442
+ const created = await prisma.storyActivity.create({
443
+ data: { storyId: data.storyId, userId, action: "kb_unlinked", newValue: `${parsed.group}:${parsed.externalId}` },
444
+ select: ACTIVITY_SELECT,
445
+ });
446
+ return { ok: true, activity: toActivityItem(created) };
424
447
  }
448
+ const doc = await prisma.knowledgeDocument.findUnique({ where: { id: data.docId }, select: { title: true } });
425
449
  await prisma.storyDocLink.delete({
426
450
  where: { storyId_docId: { storyId: data.storyId, docId: data.docId } },
427
451
  });
428
- return { ok: true };
452
+ let activity: ReturnType<typeof toActivityItem> | null = null;
453
+ if (doc) {
454
+ const created = await prisma.storyActivity.create({
455
+ data: { storyId: data.storyId, userId, action: "kb_unlinked", newValue: doc.title },
456
+ select: ACTIVITY_SELECT,
457
+ });
458
+ activity = toActivityItem(created);
459
+ }
460
+ return { ok: true, activity };
429
461
  });
430
462
 
431
463
  export const getLinkedDocsFn = createServerFn({ method: "GET" })
@@ -20,18 +20,36 @@ import { getRequest } from "@tanstack/react-start/server";
20
20
  import { z } from "zod";
21
21
  import { prisma } from "@allbluecn/database";
22
22
  import { requireAuth } from "@/server/auth-helpers";
23
+ import { ACTIVITY_SELECT, toActivityItem } from "./types";
23
24
 
24
25
  export const removeAttachmentFn = createServerFn({ method: "POST" })
25
26
  .validator(z.object({ attachmentId: z.string() }))
26
27
  .handler(async ({ data }) => {
27
28
  const request = getRequest();
28
29
  const session = await requireAuth(request);
30
+ const userId = session.user!.id!;
29
31
 
30
- const attachment = await prisma.storyAttachment.delete({
32
+ const attachment = await prisma.storyAttachment.findUnique({ where: { id: data.attachmentId } });
33
+ await prisma.storyAttachment.delete({
31
34
  where: { id: data.attachmentId },
32
35
  });
33
36
 
34
- return attachment;
37
+ // 结果含 BigInt fileSize 不可序列化,只返回活动记录(Seroval 安全的纯对象)
38
+ let activity = null;
39
+ if (attachment) {
40
+ const created = await prisma.storyActivity.create({
41
+ data: {
42
+ storyId: attachment.storyId,
43
+ userId,
44
+ action: "attachment_removed",
45
+ newValue: attachment.fileName,
46
+ },
47
+ select: ACTIVITY_SELECT,
48
+ });
49
+ activity = toActivityItem(created);
50
+ }
51
+
52
+ return { ok: true, activity };
35
53
  });
36
54
 
37
55
  export const getAttachmentsFn = createServerFn({ method: "GET" })
@@ -15,24 +15,21 @@
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 { PROJECT_ICONS } from "@/components/shared/icon-packs/project";
19
- import { cn } from "@/lib/utils";
18
+ export const LABEL_PALETTE = [
19
+ "#ef4444",
20
+ "#f97316",
21
+ "#f59e0b",
22
+ "#10b981",
23
+ "#06b6d4",
24
+ "#3b82f6",
25
+ "#8b5cf6",
26
+ "#ec4899",
27
+ ] as const;
20
28
 
21
- const ICON_MAP = new Map(
22
- PROJECT_ICONS.map((i) => [i.value, i.icon]),
23
- );
24
-
25
- interface ProjectIconDisplayProps {
26
- name?: string;
27
- size?: number;
28
- className?: string;
29
- }
30
-
31
- export function ProjectIconDisplay({
32
- name,
33
- size = 24,
34
- className,
35
- }: ProjectIconDisplayProps) {
36
- const Icon = ICON_MAP.get(name ?? "") ?? PROJECT_ICONS[0].icon;
37
- return <Icon size={size} className={cn("text-current", className)} />;
29
+ export function pickLabelColor(name: string): string {
30
+ let hash = 0;
31
+ for (const ch of name) {
32
+ hash = (hash * 31 + (ch.codePointAt(0) ?? 0)) | 0;
33
+ }
34
+ return LABEL_PALETTE[Math.abs(hash) % LABEL_PALETTE.length];
38
35
  }
@@ -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
  });
@@ -133,8 +133,8 @@ export const createStoryFn = createServerFn({ method: "POST" })
133
133
  const isIndependent = project.displayId === BUILTIN_INDEPENDENT_DISPLAY_ID;
134
134
  if (isIndependent) {
135
135
  const counter = await tx.sequenceCounter.upsert({
136
- where: { scope: "global" },
137
- create: { scope: "global", seq: 1 },
136
+ where: { scope: projectId },
137
+ create: { scope: projectId, seq: 1 },
138
138
  update: { seq: { increment: 1 } },
139
139
  });
140
140
  sequenceId = counter.seq;
@@ -222,6 +222,7 @@ const updateSchema = z.object({
222
222
  targetDate: z.string().nullable().optional(),
223
223
  labelIds: z.array(z.string()).optional(),
224
224
  parentId: z.string().nullable().optional(),
225
+ iterationId: z.string().nullable().optional(),
225
226
  isAI: z.boolean().default(false),
226
227
  });
227
228
 
@@ -352,6 +353,35 @@ export const updateStoryFn = createServerFn({ method: "POST" })
352
353
  }
353
354
  }
354
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
+
355
385
  updates.updatedBy = { connect: { id: userId } };
356
386
 
357
387
  const updated = await tx.story.update({