@allbluecn/web-app 0.5.0 → 0.5.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 (69) hide show
  1. package/package.json +11 -7
  2. package/src/components/knowledge/knowledge-toolbar.tsx +124 -1
  3. package/src/components/story/constants.ts +11 -1
  4. package/src/components/story/create-story-dialog.tsx +12 -6
  5. package/src/components/story/detail/__tests__/associated-attachments-panel.test.tsx +114 -0
  6. package/src/components/story/detail/activity-timeline.tsx +15 -0
  7. package/src/components/story/detail/story-detail-body.tsx +1 -0
  8. package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +748 -0
  9. package/src/components/story/detail/story-detail-toolbar/categories.tsx +23 -18
  10. package/src/components/story/detail/story-detail-toolbar/children.tsx +2 -0
  11. package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +29 -21
  12. package/src/components/story/detail/story-detail-toolbar.tsx +154 -8
  13. package/src/components/story/inline-editors/story-select-dialog.tsx +19 -4
  14. package/src/components/story/relations/__tests__/attachment-manage-dialog.test.tsx +171 -0
  15. package/src/components/story/relations/__tests__/knowledge-select-dialog.test.tsx +174 -0
  16. package/src/components/story/relations/__tests__/link-manage-dialog.test.tsx +93 -0
  17. package/src/components/story/relations/attachment-manage-dialog.tsx +490 -0
  18. package/src/components/story/relations/file-preview.tsx +173 -0
  19. package/src/components/story/relations/format.ts +120 -0
  20. package/src/components/story/relations/general-groups.ts +32 -0
  21. package/src/components/story/relations/knowledge-select-dialog.tsx +403 -0
  22. package/src/components/story/relations/link-manage-dialog.tsx +191 -0
  23. package/src/components/story/types.ts +13 -0
  24. package/src/lib/changelog/sdk-versions.ts +19 -19
  25. package/src/lib/story/__tests__/video-embed.test.ts +77 -0
  26. package/src/lib/story/video-embed.ts +59 -0
  27. package/src/plugins/.gen-manifest.json +13 -0
  28. package/src/plugins/modules.gen.ts +20 -0
  29. package/src/plugins/plugins.gen.ts +20 -0
  30. package/src/plugins/ui.gen.ts +8 -0
  31. package/src/routeTree.gen.ts +42 -0
  32. package/src/routes/_nav/inspiration/route.tsx +8 -0
  33. package/src/routes/_nav/prd/$id/route.tsx +9 -0
  34. package/src/routes/_nav/prd/index.tsx +9 -0
  35. package/src/routes/_nav/prd/route.tsx +8 -0
  36. package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +74 -3
  37. package/src/routes/_nav/projects/$projectId/settings/route.tsx +6 -2
  38. package/src/routes/_nav/tags/$id/route.tsx +8 -0
  39. package/src/routes/_nav/tags/index.tsx +8 -0
  40. package/src/routes/_nav/tags/route.tsx +8 -0
  41. package/src/routes/_nav/tags/settings/route.tsx +9 -0
  42. package/src/routes/api/attachments/$id/content.ts +60 -0
  43. package/src/routes/api/attachments/upload.ts +148 -0
  44. package/src/routes/share/prd/$prdId/route.tsx +10 -0
  45. package/src/server/serverFns/__tests__/project-kb.test.ts +147 -0
  46. package/src/server/serverFns/knowledge.ts +50 -4
  47. package/src/server/serverFns/project.ts +43 -0
  48. package/src/server/serverFns/story/__tests__/kb-scope.test.ts +56 -0
  49. package/src/server/serverFns/story/__tests__/story-assoc.test.ts +222 -0
  50. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +26 -0
  51. package/src/server/serverFns/story/index.ts +6 -4
  52. package/src/server/serverFns/story/kb-general.ts +39 -0
  53. package/src/server/serverFns/story/kb-scope.ts +52 -0
  54. package/src/server/serverFns/story/link-preview.ts +115 -0
  55. package/src/server/serverFns/story/story-assoc.ts +339 -50
  56. package/src/server/serverFns/story/story-attachments.ts +12 -43
  57. package/src/server/serverFns/story/story-crud.ts +21 -4
  58. package/src/server/serverFns/story/story-dependencies.ts +35 -3
  59. package/src/server/serverFns/story/story-links.ts +13 -1
  60. package/src/server/serverFns/story/story-tree.ts +28 -1
  61. package/src/server/serverFns/story/types.ts +37 -0
  62. package/src/styles/globals.css +25 -0
  63. package/vite.shared.ts +2 -0
  64. package/src/components/story/detail/story-detail-toolbar/attachments.tsx +0 -73
  65. package/src/components/story/detail/story-detail-toolbar/knowledge.tsx +0 -73
  66. package/src/components/story/detail/story-detail-toolbar/links.tsx +0 -73
  67. package/src/components/story/relations/attachments-panel.tsx +0 -179
  68. package/src/components/story/relations/knowledge-panel.tsx +0 -284
  69. package/src/components/story/relations/links-panel.tsx +0 -240
@@ -0,0 +1,52 @@
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 type { Prisma } from "@allbluecn/database";
19
+ import { BUILTIN_INDEPENDENT_DISPLAY_ID } from "@/lib/builtin-project";
20
+
21
+ export interface KbScopeInput {
22
+ projectId: string;
23
+ projectDisplayId: string | null;
24
+ userId: string;
25
+ }
26
+
27
+ /** 是否内置独立需求项目(displayId=STORY) */
28
+ export function isIndependentProject(displayId: string | null): boolean {
29
+ return displayId === BUILTIN_INDEPENDENT_DISPLAY_ID;
30
+ }
31
+
32
+ /**
33
+ * 需求可选知识库范围(Prisma where,用于 KnowledgeBase 查询):
34
+ * - 普通项目:visibility=general(跨用户)∪ 项目关联 KB(关联仅允许 team)
35
+ * - 独立需求(STORY):自己的全部 ∪ 他人 team/general
36
+ */
37
+ export function buildKbScopeWhere(input: KbScopeInput): Prisma.KnowledgeBaseWhereInput {
38
+ if (isIndependentProject(input.projectDisplayId)) {
39
+ return {
40
+ OR: [
41
+ { userId: input.userId },
42
+ { visibility: { in: ["team", "general"] } },
43
+ ],
44
+ };
45
+ }
46
+ return {
47
+ OR: [
48
+ { visibility: "general" },
49
+ { projectLinks: { some: { projectId: input.projectId } } },
50
+ ],
51
+ };
52
+ }
@@ -0,0 +1,115 @@
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
+
23
+ const checkSchema = z.object({
24
+ url: z.string().url(),
25
+ origin: z.string().url().optional(),
26
+ });
27
+
28
+ /** SSRF 防护:仅允许公网 http/https 地址(拒绝内网 IP、localhost、本地域名) */
29
+ function isPublicHttpUrl(raw: string): boolean {
30
+ let u: URL;
31
+ try {
32
+ u = new URL(raw);
33
+ } catch {
34
+ return false;
35
+ }
36
+ if (u.protocol !== "http:" && u.protocol !== "https:") return false;
37
+ const host = u.hostname.toLowerCase().replace(/^\[|\]$/g, "");
38
+ if (host === "localhost" || host === "::1" || host === "::" || host.endsWith(".local") || host.endsWith(".internal")) {
39
+ return false;
40
+ }
41
+ const ipv4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
42
+ if (ipv4) {
43
+ const [a, b] = [Number(ipv4[1]), Number(ipv4[2])];
44
+ if (a === 0 || a === 10 || a === 127 || a >= 224) return false;
45
+ if (a === 169 && b === 254) return false;
46
+ if (a === 172 && b >= 16 && b <= 31) return false;
47
+ if (a === 192 && b === 168) return false;
48
+ }
49
+ return true;
50
+ }
51
+
52
+ /** 解析 CSP frame-ancestors 指令,判断目标页面是否允许被应用嵌入 */
53
+ function isFrameAncestorsAllowed(csp: string, targetUrl: string, appOrigin?: string): boolean {
54
+ const directive = csp
55
+ .split(";")
56
+ .map((d) => d.trim().toLowerCase())
57
+ .find((d) => d.startsWith("frame-ancestors"));
58
+ if (!directive) return true;
59
+ const value = directive.slice("frame-ancestors".length).trim();
60
+ if (!value || value.includes("'none'")) return false;
61
+ if (value.includes("*")) return true;
62
+ if (!appOrigin) return false;
63
+ const sources = value.split(/\s+/);
64
+ try {
65
+ // 目标与链接同源时 'self' 视为允许
66
+ const target = new URL(targetUrl);
67
+ const app = new URL(appOrigin);
68
+ if (target.host === app.host) return true;
69
+ } catch {
70
+ return false;
71
+ }
72
+ const appHost = appOrigin.replace(/^https?:\/\//, "").toLowerCase();
73
+ return sources.some((src) => src.replace(/^https?:\/\//, "") === appHost);
74
+ }
75
+
76
+ export const checkLinkEmbeddableFn = createServerFn({ method: "POST" })
77
+ .validator(checkSchema)
78
+ .handler(async ({ data }) => {
79
+ const request = getRequest();
80
+ await requireAuth(request);
81
+
82
+ if (!isPublicHttpUrl(data.url)) {
83
+ return { embeddable: false as const, reason: "invalid" as const };
84
+ }
85
+ try {
86
+ const res = await fetch(data.url, {
87
+ method: "GET",
88
+ redirect: "follow",
89
+ headers: {
90
+ "user-agent": "Mozilla/5.0 (compatible; AllBlueEmbedCheck/1.0)",
91
+ accept: "text/html,application/xhtml+xml,*/*;q=0.8",
92
+ },
93
+ signal: AbortSignal.timeout(8000),
94
+ });
95
+ res.body?.cancel().catch(() => {});
96
+ const contentType = (res.headers.get("content-type") || "").toLowerCase();
97
+ const isHtml = !contentType || contentType.includes("text/html") || contentType.includes("xhtml");
98
+ const isPdf = contentType.includes("application/pdf");
99
+ if (!isHtml && !isPdf) {
100
+ return { embeddable: false as const, reason: "not-html" as const };
101
+ }
102
+ const xfo = (res.headers.get("x-frame-options") || "").toLowerCase();
103
+ if (xfo.includes("deny") || xfo.includes("sameorigin")) {
104
+ return { embeddable: false as const, reason: "blocked" as const };
105
+ }
106
+ const csp = res.headers.get("content-security-policy") || "";
107
+ if (csp && !isFrameAncestorsAllowed(csp, data.url, data.origin)) {
108
+ return { embeddable: false as const, reason: "blocked" as const };
109
+ }
110
+ return { embeddable: true as const };
111
+ } catch {
112
+ // 预检失败(超时/网络异常)不阻塞,交给 iframe 实际尝试
113
+ return { embeddable: true as const };
114
+ }
115
+ });
@@ -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() }))
@@ -103,27 +167,247 @@ export const searchPrdsFn = createServerFn({ method: "GET" })
103
167
  }));
104
168
  });
105
169
 
106
- export const linkDocFn = createServerFn({ method: "POST" })
107
- .validator(z.object({ storyId: z.string(), docId: z.string() }))
170
+ const DOC_SELECT = {
171
+ id: true,
172
+ title: true,
173
+ parentId: true,
174
+ icon: true,
175
+ type: true,
176
+ kb: { select: { id: true, name: true, icon: true } },
177
+ } as const;
178
+
179
+ /** 搜索/列表行 = DocBrief + 树形父引用(KnowledgeSelectDialog 树形渲染用) */
180
+ export type SearchDocRow = {
181
+ id: string;
182
+ title: string;
183
+ kbId: string;
184
+ kbName: string;
185
+ kbIcon: string;
186
+ docIcon: string | null;
187
+ parentId: string | null;
188
+ };
189
+
190
+ function toDocBrief(doc: {
191
+ id: string;
192
+ title: string;
193
+ parentId: string | null;
194
+ icon: string | null;
195
+ kb: { id: string; name: string; icon: string };
196
+ }): SearchDocRow {
197
+ return {
198
+ id: doc.id,
199
+ title: doc.title,
200
+ kbId: doc.kb.id,
201
+ kbName: doc.kb.name,
202
+ kbIcon: doc.kb.icon,
203
+ docIcon: doc.icon,
204
+ parentId: doc.parentId,
205
+ };
206
+ }
207
+
208
+ /** 查需求 → 组装 scope 输入(内部复用) */
209
+ async function resolveStoryScope(storyId: string, userId: string) {
210
+ const story = await prisma.story.findUnique({
211
+ where: { id: storyId },
212
+ select: { project: { select: { id: true, displayId: true } } },
213
+ });
214
+ if (!story) throw new Error("需求不存在");
215
+ return buildKbScopeWhere({
216
+ projectId: story.project.id,
217
+ projectDisplayId: story.project.displayId,
218
+ userId,
219
+ });
220
+ }
221
+
222
+ export const listStoryAvailableKbsFn = createServerFn({ method: "GET" })
223
+ .validator(z.object({ storyId: z.string() }))
108
224
  .handler(async ({ data }) => {
109
225
  const request = getRequest();
110
226
  const session = await requireAuth(request);
111
-
112
- const existing = await prisma.storyDocLink.findUnique({
113
- where: { storyId_docId: { storyId: data.storyId, docId: data.docId } },
227
+ const scope = await resolveStoryScope(data.storyId, session.user!.id!);
228
+ const kbs = await prisma.knowledgeBase.findMany({
229
+ where: { AND: [scope, { isDeleted: false }] },
230
+ select: { id: true, name: true, icon: true, visibility: true },
231
+ orderBy: { name: "asc" },
114
232
  });
115
- if (existing) {
116
- throw new Error("已关联此文档");
233
+ return kbs;
234
+ });
235
+
236
+ export const searchKbDocsFn = createServerFn({ method: "GET" })
237
+ .validator(
238
+ z.object({
239
+ storyId: z.string(),
240
+ kbId: z.string().optional(),
241
+ generalGroup: z.enum(GENERAL_GROUPS).optional(),
242
+ query: z.string().default(""),
243
+ cursor: z.string().optional(),
244
+ }),
245
+ )
246
+ .handler(async ({ data }) => {
247
+ const request = getRequest();
248
+ const session = await requireAuth(request);
249
+ const scope = await resolveStoryScope(data.storyId, session.user!.id!);
250
+
251
+ if (data.generalGroup) {
252
+ // 态③:通用知识库分组 → 平铺列表(周报月报暂无实体内容)
253
+ const items = await searchGeneralGroupDocs(data.generalGroup, session.user!.id!, data.query);
254
+ return { items, nextCursor: null };
117
255
  }
118
256
 
119
- const link = await prisma.storyDocLink.create({
120
- data: {
121
- storyId: data.storyId,
122
- docId: data.docId,
257
+ if (data.kbId) {
258
+ // 态①:指定 KB(须在可用范围内)→ 全量树
259
+ const kb = await prisma.knowledgeBase.findFirst({
260
+ where: { AND: [scope, { id: data.kbId, isDeleted: false }] },
261
+ select: { id: true },
262
+ });
263
+ if (!kb) throw new Error("知识库不在可用范围内");
264
+ const docs = await prisma.knowledgeDocument.findMany({
265
+ where: {
266
+ kbId: data.kbId,
267
+ isDeleted: false,
268
+ ...(data.query ? { title: { contains: data.query, mode: "insensitive" as const } } : {}),
269
+ },
270
+ select: DOC_SELECT,
271
+ orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
272
+ take: 500,
273
+ });
274
+ return { items: docs.map(toDocBrief), nextCursor: null };
275
+ }
276
+
277
+ // 态②:全部可用 KB → 平铺分页
278
+ const rows = await prisma.knowledgeDocument.findMany({
279
+ where: {
280
+ isDeleted: false,
281
+ kb: { isDeleted: false, AND: [scope] },
282
+ ...(data.query ? { title: { contains: data.query, mode: "insensitive" as const } } : {}),
123
283
  },
284
+ select: { ...DOC_SELECT, createdAt: true },
285
+ orderBy: { createdAt: "desc" },
286
+ take: 21,
287
+ ...(data.cursor ? { cursor: { id: data.cursor }, skip: 1 } : {}),
124
288
  });
289
+ const hasMore = rows.length > 20;
290
+ const items = (hasMore ? rows.slice(0, 20) : rows).map(toDocBrief);
291
+ return {
292
+ items,
293
+ nextCursor: hasMore ? rows[rows.length - 1].id : null,
294
+ };
295
+ });
125
296
 
126
- return link;
297
+ export const linkDocsFn = createServerFn({ method: "POST" })
298
+ .validator(z.object({ storyId: z.string(), docIds: z.array(z.string()).min(1) }))
299
+ .handler(async ({ data }) => {
300
+ const request = getRequest();
301
+ const session = await requireAuth(request);
302
+ const userId = session.user!.id!;
303
+ const scope = await resolveStoryScope(data.storyId, userId);
304
+
305
+ const failed: Array<{ docId: string; reason: "outOfScope" }> = [];
306
+
307
+ // 通用知识库条目(ext:<group>:<externalId>)
308
+ const extIds = data.docIds.filter((id) => id.startsWith(EXTERNAL_ID_PREFIX));
309
+ const realIds = data.docIds.filter((id) => !id.startsWith(EXTERNAL_ID_PREFIX));
310
+
311
+ const extParsed = extIds
312
+ .map((id) => ({ id, parsed: parseExternalDocId(id) }))
313
+ .filter((x): x is { id: string; parsed: { group: GeneralGroup; externalId: string } } => x.parsed !== null);
314
+ for (const x of extIds) {
315
+ if (!extParsed.some((p) => p.id === x)) failed.push({ docId: x, reason: "outOfScope" });
316
+ }
317
+
318
+ // 校验外部条目真实存在(按分组表批量查询)
319
+ const existingExt = new Set(
320
+ await prisma.storyExternalLink
321
+ .findMany({ where: { storyId: data.storyId }, select: { group: true, externalId: true } })
322
+ .then((rows) => rows.map((r) => externalDocId(r.group as GeneralGroup, r.externalId))),
323
+ );
324
+ const extToLink: Array<{ group: GeneralGroup; externalId: string }> = [];
325
+ for (const { id, parsed } of extParsed) {
326
+ if (existingExt.has(id)) continue; // skipped
327
+ const table = GENERAL_GROUP_TABLES[parsed.group as Exclude<GeneralGroup, "summary">];
328
+ if (!table) {
329
+ failed.push({ docId: id, reason: "outOfScope" });
330
+ continue;
331
+ }
332
+ const found = await (
333
+ prisma[table] as unknown as {
334
+ findFirst: (args: Record<string, unknown>) => Promise<{ id: string } | null>;
335
+ }
336
+ ).findFirst({ where: { id: parsed.externalId, isDeleted: false }, select: { id: true } });
337
+ if (found) extToLink.push(parsed);
338
+ else failed.push({ docId: id, reason: "outOfScope" });
339
+ }
340
+ if (extToLink.length > 0) {
341
+ await prisma.storyExternalLink.createMany({
342
+ data: extToLink.map(({ group, externalId }) => ({ storyId: data.storyId, group, externalId })),
343
+ skipDuplicates: true,
344
+ });
345
+ }
346
+ const extSkipped = extParsed
347
+ .map((p) => p.id)
348
+ .filter((id) => !extToLink.some((x) => externalDocId(x.group, x.externalId) === id));
349
+
350
+ // 知识库文档(原有逻辑)
351
+ const docs = await prisma.knowledgeDocument.findMany({
352
+ where: { id: { in: realIds }, isDeleted: false },
353
+ select: { id: true, kbId: true },
354
+ });
355
+ const validKbs = await prisma.knowledgeBase.findMany({
356
+ where: { AND: [scope, { isDeleted: false }] },
357
+ select: { id: true },
358
+ });
359
+ const validKbIds = new Set(validKbs.map((k) => k.id));
360
+
361
+ for (const d of docs) {
362
+ if (!validKbIds.has(d.kbId)) failed.push({ docId: d.id, reason: "outOfScope" });
363
+ }
364
+ // 请求中不存在的真实文档 id 视为越界
365
+ const foundIds = new Set(docs.map((d) => d.id));
366
+ for (const id of realIds) {
367
+ if (!foundIds.has(id)) failed.push({ docId: id, reason: "outOfScope" });
368
+ }
369
+ const candidateIds = docs.filter((d) => validKbIds.has(d.kbId)).map((d) => d.id);
370
+
371
+ const existing = await prisma.storyDocLink.findMany({
372
+ where: { storyId: data.storyId, docId: { in: candidateIds } },
373
+ select: { docId: true },
374
+ });
375
+ const existingIds = new Set(existing.map((l) => l.docId));
376
+ const toLink = candidateIds.filter((id) => !existingIds.has(id));
377
+ const skipped = [
378
+ ...candidateIds.filter((id) => existingIds.has(id)),
379
+ ...extSkipped,
380
+ ];
381
+
382
+ if (toLink.length > 0) {
383
+ await prisma.storyDocLink.createMany({
384
+ data: toLink.map((docId) => ({ storyId: data.storyId, docId })),
385
+ skipDuplicates: true,
386
+ });
387
+ }
388
+
389
+ // 活动记录:成功关联知识库文档时记一条(newValue 为文档数量)
390
+ const linkedCount = toLink.length + extToLink.length;
391
+ let activity: ReturnType<typeof toActivityItem> | null = null;
392
+ if (linkedCount > 0) {
393
+ const created = await prisma.storyActivity.create({
394
+ data: {
395
+ storyId: data.storyId,
396
+ userId,
397
+ action: "kb_linked",
398
+ newValue: String(linkedCount),
399
+ },
400
+ select: ACTIVITY_SELECT,
401
+ });
402
+ activity = toActivityItem(created);
403
+ }
404
+
405
+ return {
406
+ linked: [...toLink, ...extToLink.map(({ group, externalId }) => externalDocId(group, externalId))],
407
+ skipped,
408
+ failed,
409
+ activity,
410
+ };
127
411
  });
128
412
 
129
413
  export const unlinkDocFn = createServerFn({ method: "POST" })
@@ -131,12 +415,17 @@ export const unlinkDocFn = createServerFn({ method: "POST" })
131
415
  .handler(async ({ data }) => {
132
416
  const request = getRequest();
133
417
  const session = await requireAuth(request);
134
-
135
- const link = await prisma.storyDocLink.delete({
418
+ const parsed = parseExternalDocId(data.docId);
419
+ if (parsed) {
420
+ await prisma.storyExternalLink.deleteMany({
421
+ where: { storyId: data.storyId, group: parsed.group, externalId: parsed.externalId },
422
+ });
423
+ return { ok: true };
424
+ }
425
+ await prisma.storyDocLink.delete({
136
426
  where: { storyId_docId: { storyId: data.storyId, docId: data.docId } },
137
427
  });
138
-
139
- return link;
428
+ return { ok: true };
140
429
  });
141
430
 
142
431
  export const getLinkedDocsFn = createServerFn({ method: "GET" })
@@ -144,43 +433,43 @@ export const getLinkedDocsFn = createServerFn({ method: "GET" })
144
433
  .handler(async ({ data }) => {
145
434
  const request = getRequest();
146
435
  const session = await requireAuth(request);
147
-
148
436
  const links = await prisma.storyDocLink.findMany({
149
437
  where: { storyId: data.storyId },
150
- include: {
151
- doc: {
152
- select: {
153
- id: true,
154
- title: true,
155
- },
156
- },
157
- },
438
+ include: { doc: { select: DOC_SELECT } },
158
439
  });
440
+ const docRows = links.map((link) => toDocBrief(link.doc));
159
441
 
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,
442
+ const extLinks = await prisma.storyExternalLink.findMany({
443
+ where: { storyId: data.storyId },
444
+ select: { group: true, externalId: true },
180
445
  });
181
-
182
- return docs.map((doc) => ({
183
- id: doc.id,
184
- title: doc.title,
185
- }));
446
+ const byGroup = new Map<GeneralGroup, string[]>();
447
+ for (const l of extLinks) {
448
+ const group = l.group as GeneralGroup;
449
+ const arr = byGroup.get(group) ?? [];
450
+ arr.push(l.externalId);
451
+ byGroup.set(group, arr);
452
+ }
453
+ const extRows: SearchDocRow[] = [];
454
+ for (const [group, ids] of byGroup) {
455
+ const table = GENERAL_GROUP_TABLES[group as Exclude<GeneralGroup, "summary">];
456
+ if (!table) continue;
457
+ const rows = await (
458
+ prisma[table] as unknown as {
459
+ findMany: (args: Record<string, unknown>) => Promise<Array<{ id: string; title: string }>>;
460
+ }
461
+ ).findMany({ where: { id: { in: ids } }, select: GENERAL_ROW_SELECT });
462
+ for (const row of rows) {
463
+ extRows.push({
464
+ id: externalDocId(group, row.id),
465
+ title: row.title,
466
+ kbId: `${EXTERNAL_ID_PREFIX}${group}`,
467
+ kbName: group,
468
+ kbIcon: "book",
469
+ docIcon: null,
470
+ parentId: null,
471
+ });
472
+ }
473
+ }
474
+ return [...docRows, ...extRows];
186
475
  });
@@ -21,36 +21,6 @@ import { z } from "zod";
21
21
  import { prisma } from "@allbluecn/database";
22
22
  import { requireAuth } from "@/server/auth-helpers";
23
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
- });
53
-
54
24
  export const removeAttachmentFn = createServerFn({ method: "POST" })
55
25
  .validator(z.object({ attachmentId: z.string() }))
56
26
  .handler(async ({ data }) => {
@@ -73,6 +43,14 @@ export const getAttachmentsFn = createServerFn({ method: "GET" })
73
43
  const attachments = await prisma.storyAttachment.findMany({
74
44
  where: { storyId: data.storyId },
75
45
  orderBy: { createdAt: "desc" },
46
+ select: {
47
+ id: true,
48
+ fileName: true,
49
+ fileSize: true,
50
+ mimeType: true,
51
+ description: true,
52
+ fileHash: true,
53
+ },
76
54
  });
77
55
 
78
56
  return attachments.map((att) => ({
@@ -80,18 +58,9 @@ export const getAttachmentsFn = createServerFn({ method: "GET" })
80
58
  fileName: att.fileName,
81
59
  fileSize: Number(att.fileSize),
82
60
  mimeType: att.mimeType,
83
- storageUrl: att.storageUrl,
61
+ description: att.description,
62
+ fileHash: att.fileHash,
63
+ // 读取入口统一为内容路由(历史 mock 行无文件体,预览会 404)
64
+ storageUrl: `/api/attachments/${att.id}/content`,
84
65
  }));
85
66
  });
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
- });