@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
@@ -20,6 +20,70 @@ 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";
24
+ import { buildKbScopeWhere } from "./kb-scope";
25
+ import {
26
+ GENERAL_GROUPS,
27
+ EXTERNAL_ID_PREFIX,
28
+ parseExternalDocId,
29
+ externalDocId,
30
+ type GeneralGroup,
31
+ } from "./kb-general";
32
+
33
+ // ---------- 通用知识库(法律法规/行业合规/词典词条/网站收藏/周报月报) ----------
34
+
35
+ const GENERAL_GROUP_TABLES = {
36
+ legal: "legalRegulation",
37
+ compliance: "industryCompliance",
38
+ dictionary: "dictionaryTerm",
39
+ bookmark: "websiteBookmark",
40
+ } as const satisfies Record<Exclude<GeneralGroup, "summary">, string>;
41
+
42
+ const GENERAL_ROW_SELECT = { id: true, title: true } as const;
43
+
44
+ async function searchGeneralGroupDocs(
45
+ group: GeneralGroup,
46
+ userId: string,
47
+ query: string,
48
+ ): Promise<SearchDocRow[]> {
49
+ const table = GENERAL_GROUP_TABLES[group as Exclude<GeneralGroup, "summary">];
50
+ if (!table) return []; // summary(周报月报)暂无实体表,无可关联内容
51
+
52
+ const favoriteIds = await prisma.articleFavorite
53
+ .findMany({
54
+ where: { entityType: group, userId },
55
+ select: { entityId: true },
56
+ })
57
+ .then((r) => r.map((f) => f.entityId));
58
+
59
+ const rows = await (
60
+ prisma[table] as unknown as {
61
+ findMany: (args: Record<string, unknown>) => Promise<Array<{ id: string; title: string }>>;
62
+ }
63
+ ).findMany({
64
+ where: {
65
+ isDeleted: false,
66
+ ...(query ? { title: { contains: query, mode: "insensitive" as const } } : {}),
67
+ OR: [
68
+ { userId },
69
+ ...(favoriteIds.length > 0 ? [{ id: { in: favoriteIds } }] : []),
70
+ ],
71
+ },
72
+ select: GENERAL_ROW_SELECT,
73
+ orderBy: { updatedAt: "desc" as const },
74
+ take: 100,
75
+ });
76
+
77
+ return rows.map((row) => ({
78
+ id: externalDocId(group, row.id),
79
+ title: row.title,
80
+ kbId: `${EXTERNAL_ID_PREFIX}${group}`,
81
+ kbName: group,
82
+ kbIcon: "book",
83
+ docIcon: null,
84
+ parentId: null,
85
+ }));
86
+ }
23
87
 
24
88
  export const linkPrdFn = createServerFn({ method: "POST" })
25
89
  .validator(z.object({ storyId: z.string(), prdId: z.string() }))
@@ -49,12 +113,29 @@ export const unlinkPrdFn = createServerFn({ method: "POST" })
49
113
  .handler(async ({ data }) => {
50
114
  const request = getRequest();
51
115
  const session = await requireAuth(request);
116
+ const userId = session.user!.id!;
52
117
 
53
- const link = await prisma.storyPrdLink.delete({
118
+ const prd = await prisma.pRD.findUnique({ where: { id: data.prdId }, select: { name: true } });
119
+
120
+ await prisma.storyPrdLink.delete({
54
121
  where: { storyId_prdId: { storyId: data.storyId, prdId: data.prdId } },
55
122
  });
56
123
 
57
- 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 };
58
139
  });
59
140
 
60
141
  export const getLinkedPrdsFn = createServerFn({ method: "GET" })
@@ -103,27 +184,247 @@ export const searchPrdsFn = createServerFn({ method: "GET" })
103
184
  }));
104
185
  });
105
186
 
106
- export const linkDocFn = createServerFn({ method: "POST" })
107
- .validator(z.object({ storyId: z.string(), docId: z.string() }))
187
+ const DOC_SELECT = {
188
+ id: true,
189
+ title: true,
190
+ parentId: true,
191
+ icon: true,
192
+ type: true,
193
+ kb: { select: { id: true, name: true, icon: true } },
194
+ } as const;
195
+
196
+ /** 搜索/列表行 = DocBrief + 树形父引用(KnowledgeSelectDialog 树形渲染用) */
197
+ export type SearchDocRow = {
198
+ id: string;
199
+ title: string;
200
+ kbId: string;
201
+ kbName: string;
202
+ kbIcon: string;
203
+ docIcon: string | null;
204
+ parentId: string | null;
205
+ };
206
+
207
+ function toDocBrief(doc: {
208
+ id: string;
209
+ title: string;
210
+ parentId: string | null;
211
+ icon: string | null;
212
+ kb: { id: string; name: string; icon: string };
213
+ }): SearchDocRow {
214
+ return {
215
+ id: doc.id,
216
+ title: doc.title,
217
+ kbId: doc.kb.id,
218
+ kbName: doc.kb.name,
219
+ kbIcon: doc.kb.icon,
220
+ docIcon: doc.icon,
221
+ parentId: doc.parentId,
222
+ };
223
+ }
224
+
225
+ /** 查需求 → 组装 scope 输入(内部复用) */
226
+ async function resolveStoryScope(storyId: string, userId: string) {
227
+ const story = await prisma.story.findUnique({
228
+ where: { id: storyId },
229
+ select: { project: { select: { id: true, displayId: true } } },
230
+ });
231
+ if (!story) throw new Error("需求不存在");
232
+ return buildKbScopeWhere({
233
+ projectId: story.project.id,
234
+ projectDisplayId: story.project.displayId,
235
+ userId,
236
+ });
237
+ }
238
+
239
+ export const listStoryAvailableKbsFn = createServerFn({ method: "GET" })
240
+ .validator(z.object({ storyId: z.string() }))
108
241
  .handler(async ({ data }) => {
109
242
  const request = getRequest();
110
243
  const session = await requireAuth(request);
111
-
112
- const existing = await prisma.storyDocLink.findUnique({
113
- where: { storyId_docId: { storyId: data.storyId, docId: data.docId } },
244
+ const scope = await resolveStoryScope(data.storyId, session.user!.id!);
245
+ const kbs = await prisma.knowledgeBase.findMany({
246
+ where: { AND: [scope, { isDeleted: false }] },
247
+ select: { id: true, name: true, icon: true, visibility: true },
248
+ orderBy: { name: "asc" },
114
249
  });
115
- if (existing) {
116
- throw new Error("已关联此文档");
250
+ return kbs;
251
+ });
252
+
253
+ export const searchKbDocsFn = createServerFn({ method: "GET" })
254
+ .validator(
255
+ z.object({
256
+ storyId: z.string(),
257
+ kbId: z.string().optional(),
258
+ generalGroup: z.enum(GENERAL_GROUPS).optional(),
259
+ query: z.string().default(""),
260
+ cursor: z.string().optional(),
261
+ }),
262
+ )
263
+ .handler(async ({ data }) => {
264
+ const request = getRequest();
265
+ const session = await requireAuth(request);
266
+ const scope = await resolveStoryScope(data.storyId, session.user!.id!);
267
+
268
+ if (data.generalGroup) {
269
+ // 态③:通用知识库分组 → 平铺列表(周报月报暂无实体内容)
270
+ const items = await searchGeneralGroupDocs(data.generalGroup, session.user!.id!, data.query);
271
+ return { items, nextCursor: null };
117
272
  }
118
273
 
119
- const link = await prisma.storyDocLink.create({
120
- data: {
121
- storyId: data.storyId,
122
- docId: data.docId,
274
+ if (data.kbId) {
275
+ // 态①:指定 KB(须在可用范围内)→ 全量树
276
+ const kb = await prisma.knowledgeBase.findFirst({
277
+ where: { AND: [scope, { id: data.kbId, isDeleted: false }] },
278
+ select: { id: true },
279
+ });
280
+ if (!kb) throw new Error("知识库不在可用范围内");
281
+ const docs = await prisma.knowledgeDocument.findMany({
282
+ where: {
283
+ kbId: data.kbId,
284
+ isDeleted: false,
285
+ ...(data.query ? { title: { contains: data.query, mode: "insensitive" as const } } : {}),
286
+ },
287
+ select: DOC_SELECT,
288
+ orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
289
+ take: 500,
290
+ });
291
+ return { items: docs.map(toDocBrief), nextCursor: null };
292
+ }
293
+
294
+ // 态②:全部可用 KB → 平铺分页
295
+ const rows = await prisma.knowledgeDocument.findMany({
296
+ where: {
297
+ isDeleted: false,
298
+ kb: { isDeleted: false, AND: [scope] },
299
+ ...(data.query ? { title: { contains: data.query, mode: "insensitive" as const } } : {}),
123
300
  },
301
+ select: { ...DOC_SELECT, createdAt: true },
302
+ orderBy: { createdAt: "desc" },
303
+ take: 21,
304
+ ...(data.cursor ? { cursor: { id: data.cursor }, skip: 1 } : {}),
124
305
  });
306
+ const hasMore = rows.length > 20;
307
+ const items = (hasMore ? rows.slice(0, 20) : rows).map(toDocBrief);
308
+ return {
309
+ items,
310
+ nextCursor: hasMore ? rows[rows.length - 1].id : null,
311
+ };
312
+ });
125
313
 
126
- return link;
314
+ export const linkDocsFn = createServerFn({ method: "POST" })
315
+ .validator(z.object({ storyId: z.string(), docIds: z.array(z.string()).min(1) }))
316
+ .handler(async ({ data }) => {
317
+ const request = getRequest();
318
+ const session = await requireAuth(request);
319
+ const userId = session.user!.id!;
320
+ const scope = await resolveStoryScope(data.storyId, userId);
321
+
322
+ const failed: Array<{ docId: string; reason: "outOfScope" }> = [];
323
+
324
+ // 通用知识库条目(ext:<group>:<externalId>)
325
+ const extIds = data.docIds.filter((id) => id.startsWith(EXTERNAL_ID_PREFIX));
326
+ const realIds = data.docIds.filter((id) => !id.startsWith(EXTERNAL_ID_PREFIX));
327
+
328
+ const extParsed = extIds
329
+ .map((id) => ({ id, parsed: parseExternalDocId(id) }))
330
+ .filter((x): x is { id: string; parsed: { group: GeneralGroup; externalId: string } } => x.parsed !== null);
331
+ for (const x of extIds) {
332
+ if (!extParsed.some((p) => p.id === x)) failed.push({ docId: x, reason: "outOfScope" });
333
+ }
334
+
335
+ // 校验外部条目真实存在(按分组表批量查询)
336
+ const existingExt = new Set(
337
+ await prisma.storyExternalLink
338
+ .findMany({ where: { storyId: data.storyId }, select: { group: true, externalId: true } })
339
+ .then((rows) => rows.map((r) => externalDocId(r.group as GeneralGroup, r.externalId))),
340
+ );
341
+ const extToLink: Array<{ group: GeneralGroup; externalId: string }> = [];
342
+ for (const { id, parsed } of extParsed) {
343
+ if (existingExt.has(id)) continue; // skipped
344
+ const table = GENERAL_GROUP_TABLES[parsed.group as Exclude<GeneralGroup, "summary">];
345
+ if (!table) {
346
+ failed.push({ docId: id, reason: "outOfScope" });
347
+ continue;
348
+ }
349
+ const found = await (
350
+ prisma[table] as unknown as {
351
+ findFirst: (args: Record<string, unknown>) => Promise<{ id: string } | null>;
352
+ }
353
+ ).findFirst({ where: { id: parsed.externalId, isDeleted: false }, select: { id: true } });
354
+ if (found) extToLink.push(parsed);
355
+ else failed.push({ docId: id, reason: "outOfScope" });
356
+ }
357
+ if (extToLink.length > 0) {
358
+ await prisma.storyExternalLink.createMany({
359
+ data: extToLink.map(({ group, externalId }) => ({ storyId: data.storyId, group, externalId })),
360
+ skipDuplicates: true,
361
+ });
362
+ }
363
+ const extSkipped = extParsed
364
+ .map((p) => p.id)
365
+ .filter((id) => !extToLink.some((x) => externalDocId(x.group, x.externalId) === id));
366
+
367
+ // 知识库文档(原有逻辑)
368
+ const docs = await prisma.knowledgeDocument.findMany({
369
+ where: { id: { in: realIds }, isDeleted: false },
370
+ select: { id: true, kbId: true },
371
+ });
372
+ const validKbs = await prisma.knowledgeBase.findMany({
373
+ where: { AND: [scope, { isDeleted: false }] },
374
+ select: { id: true },
375
+ });
376
+ const validKbIds = new Set(validKbs.map((k) => k.id));
377
+
378
+ for (const d of docs) {
379
+ if (!validKbIds.has(d.kbId)) failed.push({ docId: d.id, reason: "outOfScope" });
380
+ }
381
+ // 请求中不存在的真实文档 id 视为越界
382
+ const foundIds = new Set(docs.map((d) => d.id));
383
+ for (const id of realIds) {
384
+ if (!foundIds.has(id)) failed.push({ docId: id, reason: "outOfScope" });
385
+ }
386
+ const candidateIds = docs.filter((d) => validKbIds.has(d.kbId)).map((d) => d.id);
387
+
388
+ const existing = await prisma.storyDocLink.findMany({
389
+ where: { storyId: data.storyId, docId: { in: candidateIds } },
390
+ select: { docId: true },
391
+ });
392
+ const existingIds = new Set(existing.map((l) => l.docId));
393
+ const toLink = candidateIds.filter((id) => !existingIds.has(id));
394
+ const skipped = [
395
+ ...candidateIds.filter((id) => existingIds.has(id)),
396
+ ...extSkipped,
397
+ ];
398
+
399
+ if (toLink.length > 0) {
400
+ await prisma.storyDocLink.createMany({
401
+ data: toLink.map((docId) => ({ storyId: data.storyId, docId })),
402
+ skipDuplicates: true,
403
+ });
404
+ }
405
+
406
+ // 活动记录:成功关联知识库文档时记一条(newValue 为文档数量)
407
+ const linkedCount = toLink.length + extToLink.length;
408
+ let activity: ReturnType<typeof toActivityItem> | null = null;
409
+ if (linkedCount > 0) {
410
+ const created = await prisma.storyActivity.create({
411
+ data: {
412
+ storyId: data.storyId,
413
+ userId,
414
+ action: "kb_linked",
415
+ newValue: String(linkedCount),
416
+ },
417
+ select: ACTIVITY_SELECT,
418
+ });
419
+ activity = toActivityItem(created);
420
+ }
421
+
422
+ return {
423
+ linked: [...toLink, ...extToLink.map(({ group, externalId }) => externalDocId(group, externalId))],
424
+ skipped,
425
+ failed,
426
+ activity,
427
+ };
127
428
  });
128
429
 
129
430
  export const unlinkDocFn = createServerFn({ method: "POST" })
@@ -131,12 +432,32 @@ export const unlinkDocFn = createServerFn({ method: "POST" })
131
432
  .handler(async ({ data }) => {
132
433
  const request = getRequest();
133
434
  const session = await requireAuth(request);
435
+ const userId = session.user!.id!;
134
436
 
135
- const link = await prisma.storyDocLink.delete({
437
+ const parsed = parseExternalDocId(data.docId);
438
+ if (parsed) {
439
+ await prisma.storyExternalLink.deleteMany({
440
+ where: { storyId: data.storyId, group: parsed.group, externalId: parsed.externalId },
441
+ });
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) };
447
+ }
448
+ const doc = await prisma.knowledgeDocument.findUnique({ where: { id: data.docId }, select: { title: true } });
449
+ await prisma.storyDocLink.delete({
136
450
  where: { storyId_docId: { storyId: data.storyId, docId: data.docId } },
137
451
  });
138
-
139
- return link;
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 };
140
461
  });
141
462
 
142
463
  export const getLinkedDocsFn = createServerFn({ method: "GET" })
@@ -144,43 +465,43 @@ export const getLinkedDocsFn = createServerFn({ method: "GET" })
144
465
  .handler(async ({ data }) => {
145
466
  const request = getRequest();
146
467
  const session = await requireAuth(request);
147
-
148
468
  const links = await prisma.storyDocLink.findMany({
149
469
  where: { storyId: data.storyId },
150
- include: {
151
- doc: {
152
- select: {
153
- id: true,
154
- title: true,
155
- },
156
- },
157
- },
470
+ include: { doc: { select: DOC_SELECT } },
158
471
  });
472
+ const docRows = links.map((link) => toDocBrief(link.doc));
159
473
 
160
- return links.map((link) => ({
161
- id: link.doc.id,
162
- title: link.doc.title,
163
- }));
164
- });
165
-
166
- export const searchDocsFn = createServerFn({ method: "GET" })
167
- .validator(z.object({ query: z.string() }))
168
- .handler(async ({ data }) => {
169
- const request = getRequest();
170
- const session = await requireAuth(request);
171
-
172
- const docs = await prisma.knowledgeDocument.findMany({
173
- where: {
174
- title: {
175
- contains: data.query,
176
- mode: "insensitive",
177
- },
178
- },
179
- take: 10,
474
+ const extLinks = await prisma.storyExternalLink.findMany({
475
+ where: { storyId: data.storyId },
476
+ select: { group: true, externalId: true },
180
477
  });
181
-
182
- return docs.map((doc) => ({
183
- id: doc.id,
184
- title: doc.title,
185
- }));
478
+ const byGroup = new Map<GeneralGroup, string[]>();
479
+ for (const l of extLinks) {
480
+ const group = l.group as GeneralGroup;
481
+ const arr = byGroup.get(group) ?? [];
482
+ arr.push(l.externalId);
483
+ byGroup.set(group, arr);
484
+ }
485
+ const extRows: SearchDocRow[] = [];
486
+ for (const [group, ids] of byGroup) {
487
+ const table = GENERAL_GROUP_TABLES[group as Exclude<GeneralGroup, "summary">];
488
+ if (!table) continue;
489
+ const rows = await (
490
+ prisma[table] as unknown as {
491
+ findMany: (args: Record<string, unknown>) => Promise<Array<{ id: string; title: string }>>;
492
+ }
493
+ ).findMany({ where: { id: { in: ids } }, select: GENERAL_ROW_SELECT });
494
+ for (const row of rows) {
495
+ extRows.push({
496
+ id: externalDocId(group, row.id),
497
+ title: row.title,
498
+ kbId: `${EXTERNAL_ID_PREFIX}${group}`,
499
+ kbName: group,
500
+ kbIcon: "book",
501
+ docIcon: null,
502
+ parentId: null,
503
+ });
504
+ }
505
+ }
506
+ return [...docRows, ...extRows];
186
507
  });
@@ -20,48 +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
-
24
- export const uploadAttachmentFn = createServerFn({ method: "POST" })
25
- .handler(async () => {
26
- const request = getRequest();
27
- const session = await requireAuth(request);
28
- const formData = await request.formData();
29
-
30
- const storyId = formData.get("storyId") as string;
31
- const file = formData.get("file") as File;
32
-
33
- if (!storyId || !file) {
34
- throw new Error("缺少 storyId 或 file");
35
- }
36
-
37
- const storageUrl = `/mock-storage/${Date.now()}-${file.name}`;
38
- const fileSize = BigInt(file.size);
39
- const mimeType = file.type;
40
-
41
- const attachment = await prisma.storyAttachment.create({
42
- data: {
43
- storyId,
44
- fileName: file.name,
45
- fileSize,
46
- mimeType,
47
- storageUrl,
48
- },
49
- });
50
-
51
- return attachment;
52
- });
23
+ import { ACTIVITY_SELECT, toActivityItem } from "./types";
53
24
 
54
25
  export const removeAttachmentFn = createServerFn({ method: "POST" })
55
26
  .validator(z.object({ attachmentId: z.string() }))
56
27
  .handler(async ({ data }) => {
57
28
  const request = getRequest();
58
29
  const session = await requireAuth(request);
30
+ const userId = session.user!.id!;
59
31
 
60
- const attachment = await prisma.storyAttachment.delete({
32
+ const attachment = await prisma.storyAttachment.findUnique({ where: { id: data.attachmentId } });
33
+ await prisma.storyAttachment.delete({
61
34
  where: { id: data.attachmentId },
62
35
  });
63
36
 
64
- 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 };
65
53
  });
66
54
 
67
55
  export const getAttachmentsFn = createServerFn({ method: "GET" })
@@ -73,6 +61,14 @@ export const getAttachmentsFn = createServerFn({ method: "GET" })
73
61
  const attachments = await prisma.storyAttachment.findMany({
74
62
  where: { storyId: data.storyId },
75
63
  orderBy: { createdAt: "desc" },
64
+ select: {
65
+ id: true,
66
+ fileName: true,
67
+ fileSize: true,
68
+ mimeType: true,
69
+ description: true,
70
+ fileHash: true,
71
+ },
76
72
  });
77
73
 
78
74
  return attachments.map((att) => ({
@@ -80,18 +76,9 @@ export const getAttachmentsFn = createServerFn({ method: "GET" })
80
76
  fileName: att.fileName,
81
77
  fileSize: Number(att.fileSize),
82
78
  mimeType: att.mimeType,
83
- storageUrl: att.storageUrl,
79
+ description: att.description,
80
+ fileHash: att.fileHash,
81
+ // 读取入口统一为内容路由(历史 mock 行无文件体,预览会 404)
82
+ storageUrl: `/api/attachments/${att.id}/content`,
84
83
  }));
85
84
  });
86
-
87
- export const mockCloudStorageFn = createServerFn({ method: "POST" })
88
- .validator(z.object({ file: z.any() }))
89
- .handler(async ({ data }) => {
90
- const { file } = data;
91
- const mockUrl = `/mock-storage/${Date.now()}-${file.name}`;
92
-
93
- return {
94
- url: mockUrl,
95
- provider: "mock",
96
- };
97
- });
@@ -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
  }