@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.
- package/package.json +11 -7
- package/src/components/knowledge/knowledge-toolbar.tsx +124 -1
- package/src/components/story/constants.ts +11 -1
- package/src/components/story/create-story-dialog.tsx +12 -6
- package/src/components/story/detail/__tests__/associated-attachments-panel.test.tsx +114 -0
- package/src/components/story/detail/activity-timeline.tsx +15 -0
- package/src/components/story/detail/story-detail-body.tsx +1 -0
- package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +748 -0
- package/src/components/story/detail/story-detail-toolbar/categories.tsx +23 -18
- package/src/components/story/detail/story-detail-toolbar/children.tsx +2 -0
- package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +29 -21
- package/src/components/story/detail/story-detail-toolbar.tsx +154 -8
- package/src/components/story/inline-editors/story-select-dialog.tsx +19 -4
- package/src/components/story/relations/__tests__/attachment-manage-dialog.test.tsx +171 -0
- package/src/components/story/relations/__tests__/knowledge-select-dialog.test.tsx +174 -0
- package/src/components/story/relations/__tests__/link-manage-dialog.test.tsx +93 -0
- package/src/components/story/relations/attachment-manage-dialog.tsx +490 -0
- package/src/components/story/relations/file-preview.tsx +173 -0
- package/src/components/story/relations/format.ts +120 -0
- package/src/components/story/relations/general-groups.ts +32 -0
- package/src/components/story/relations/knowledge-select-dialog.tsx +403 -0
- package/src/components/story/relations/link-manage-dialog.tsx +191 -0
- package/src/components/story/types.ts +13 -0
- package/src/lib/changelog/sdk-versions.ts +19 -19
- package/src/lib/story/__tests__/video-embed.test.ts +77 -0
- package/src/lib/story/video-embed.ts +59 -0
- package/src/plugins/.gen-manifest.json +13 -0
- package/src/plugins/modules.gen.ts +20 -0
- package/src/plugins/plugins.gen.ts +20 -0
- package/src/plugins/ui.gen.ts +8 -0
- package/src/routeTree.gen.ts +42 -0
- package/src/routes/_nav/inspiration/route.tsx +8 -0
- package/src/routes/_nav/prd/$id/route.tsx +9 -0
- package/src/routes/_nav/prd/index.tsx +9 -0
- package/src/routes/_nav/prd/route.tsx +8 -0
- package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +74 -3
- package/src/routes/_nav/projects/$projectId/settings/route.tsx +6 -2
- package/src/routes/_nav/tags/$id/route.tsx +8 -0
- package/src/routes/_nav/tags/index.tsx +8 -0
- package/src/routes/_nav/tags/route.tsx +8 -0
- package/src/routes/_nav/tags/settings/route.tsx +9 -0
- package/src/routes/api/attachments/$id/content.ts +60 -0
- package/src/routes/api/attachments/upload.ts +148 -0
- package/src/routes/share/prd/$prdId/route.tsx +10 -0
- package/src/server/serverFns/__tests__/project-kb.test.ts +147 -0
- package/src/server/serverFns/knowledge.ts +50 -4
- package/src/server/serverFns/project.ts +43 -0
- package/src/server/serverFns/story/__tests__/kb-scope.test.ts +56 -0
- package/src/server/serverFns/story/__tests__/story-assoc.test.ts +222 -0
- package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +26 -0
- package/src/server/serverFns/story/index.ts +6 -4
- package/src/server/serverFns/story/kb-general.ts +39 -0
- package/src/server/serverFns/story/kb-scope.ts +52 -0
- package/src/server/serverFns/story/link-preview.ts +115 -0
- package/src/server/serverFns/story/story-assoc.ts +339 -50
- package/src/server/serverFns/story/story-attachments.ts +12 -43
- package/src/server/serverFns/story/story-crud.ts +21 -4
- package/src/server/serverFns/story/story-dependencies.ts +35 -3
- package/src/server/serverFns/story/story-links.ts +13 -1
- package/src/server/serverFns/story/story-tree.ts +28 -1
- package/src/server/serverFns/story/types.ts +37 -0
- package/src/styles/globals.css +25 -0
- package/vite.shared.ts +2 -0
- package/src/components/story/detail/story-detail-toolbar/attachments.tsx +0 -73
- package/src/components/story/detail/story-detail-toolbar/knowledge.tsx +0 -73
- package/src/components/story/detail/story-detail-toolbar/links.tsx +0 -73
- package/src/components/story/relations/attachments-panel.tsx +0 -179
- package/src/components/story/relations/knowledge-panel.tsx +0 -284
- 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
|
-
|
|
107
|
-
|
|
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
|
|
113
|
-
where: {
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
});
|