@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,147 @@
|
|
|
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 { describe, it, expect, vi, beforeEach } from "vitest";
|
|
19
|
+
|
|
20
|
+
vi.mock("@tanstack/react-start", () => ({
|
|
21
|
+
createServerFn: (config: any) => {
|
|
22
|
+
const api: any = {
|
|
23
|
+
_validator: undefined,
|
|
24
|
+
validator(schema: any) {
|
|
25
|
+
this._validator = schema;
|
|
26
|
+
return this;
|
|
27
|
+
},
|
|
28
|
+
handler(fn: Function) {
|
|
29
|
+
const wrapped = async (args: any) => {
|
|
30
|
+
let data = args?.data;
|
|
31
|
+
if (this._validator && data !== undefined) {
|
|
32
|
+
data = this._validator.parse ? this._validator.parse(data) : data;
|
|
33
|
+
}
|
|
34
|
+
return fn({ data });
|
|
35
|
+
};
|
|
36
|
+
return wrapped;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
return api;
|
|
40
|
+
},
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
vi.mock("@tanstack/react-start/server", () => ({ getRequest: () => ({}) }));
|
|
44
|
+
|
|
45
|
+
const mocks = vi.hoisted(() => ({
|
|
46
|
+
pkbFindMany: vi.fn(),
|
|
47
|
+
pkbDeleteMany: vi.fn(),
|
|
48
|
+
pkbCreateMany: vi.fn(),
|
|
49
|
+
kbFindMany: vi.fn(),
|
|
50
|
+
projectFindMany: vi.fn(),
|
|
51
|
+
kbFindFirst: vi.fn(),
|
|
52
|
+
transaction: vi.fn(),
|
|
53
|
+
currentSession: vi.fn(),
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
vi.mock("@allbluecn/database", () => {
|
|
57
|
+
const prisma = {
|
|
58
|
+
projectKnowledgeBase: {
|
|
59
|
+
findMany: mocks.pkbFindMany,
|
|
60
|
+
deleteMany: mocks.pkbDeleteMany,
|
|
61
|
+
createMany: mocks.pkbCreateMany,
|
|
62
|
+
},
|
|
63
|
+
knowledgeBase: { findMany: mocks.kbFindMany, findFirst: mocks.kbFindFirst },
|
|
64
|
+
project: { findMany: mocks.projectFindMany },
|
|
65
|
+
$transaction: mocks.transaction,
|
|
66
|
+
};
|
|
67
|
+
return { prisma, Prisma: {} };
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
vi.mock("@/server/auth-helpers", () => ({ requireAuth: mocks.currentSession }));
|
|
71
|
+
vi.mock("@/server/permissions", () => ({ requirePermission: vi.fn().mockResolvedValue(undefined) }));
|
|
72
|
+
vi.mock("@/server/audit", () => ({ auditLog: vi.fn() }));
|
|
73
|
+
|
|
74
|
+
import { setProjectKbsFn, listProjectKbsFn } from "../project";
|
|
75
|
+
import { setKbProjectsFn, listKbProjectsFn } from "../knowledge";
|
|
76
|
+
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
vi.clearAllMocks();
|
|
79
|
+
mocks.currentSession.mockResolvedValue({ user: { id: "u1" } });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("setProjectKbsFn 校验", () => {
|
|
83
|
+
it("仅接受 team 可见性 KB,非 team 抛错且不写库", async () => {
|
|
84
|
+
mocks.kbFindMany.mockResolvedValue([{ id: "kb1" }]); // 请求 [kb1(team), kb2(general)] 但只查回 kb1
|
|
85
|
+
await expect(
|
|
86
|
+
setProjectKbsFn({ data: { projectId: "p1", kbIds: ["kb1", "kb2"] } }),
|
|
87
|
+
).rejects.toMatchObject({ message: expect.stringContaining("仅允许团队可见") });
|
|
88
|
+
expect(mocks.transaction).not.toHaveBeenCalled();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("全量替换:删差集 + 增新增(事务)", async () => {
|
|
92
|
+
mocks.kbFindMany.mockResolvedValue([{ id: "kb1" }, { id: "kb2" }]);
|
|
93
|
+
mocks.transaction.mockImplementation(async (fn: (tx: any) => any) =>
|
|
94
|
+
fn({
|
|
95
|
+
projectKnowledgeBase: {
|
|
96
|
+
deleteMany: mocks.pkbDeleteMany,
|
|
97
|
+
createMany: mocks.pkbCreateMany,
|
|
98
|
+
},
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
await setProjectKbsFn({ data: { projectId: "p1", kbIds: ["kb1", "kb2"] } });
|
|
102
|
+
expect(mocks.pkbDeleteMany).toHaveBeenCalledWith({
|
|
103
|
+
where: { projectId: "p1", kbId: { notIn: ["kb1", "kb2"] } },
|
|
104
|
+
});
|
|
105
|
+
expect(mocks.pkbCreateMany).toHaveBeenCalledWith({
|
|
106
|
+
data: [
|
|
107
|
+
{ projectId: "p1", kbId: "kb1" },
|
|
108
|
+
{ projectId: "p1", kbId: "kb2" },
|
|
109
|
+
],
|
|
110
|
+
skipDuplicates: true,
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("setKbProjectsFn 校验", () => {
|
|
116
|
+
it("general KB 拒绝关联", async () => {
|
|
117
|
+
mocks.kbFindFirst.mockResolvedValue({ id: "kbG", visibility: "general" });
|
|
118
|
+
await expect(
|
|
119
|
+
setKbProjectsFn({ data: { kbId: "kbG", projectIds: ["p1"] } }),
|
|
120
|
+
).rejects.toMatchObject({ message: expect.stringContaining("通用知识库") });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("private KB 拒绝关联", async () => {
|
|
124
|
+
mocks.kbFindFirst.mockResolvedValue({ id: "kbP", visibility: "private" });
|
|
125
|
+
await expect(
|
|
126
|
+
setKbProjectsFn({ data: { kbId: "kbP", projectIds: ["p1"] } }),
|
|
127
|
+
).rejects.toMatchObject({ message: expect.stringContaining("私有知识库") });
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("listProjectKbsFn / listKbProjectsFn", () => {
|
|
132
|
+
it("项目侧返回 KB 概要", async () => {
|
|
133
|
+
mocks.pkbFindMany.mockResolvedValue([
|
|
134
|
+
{ kb: { id: "kb1", name: "团队库", icon: "notebook", visibility: "team" } },
|
|
135
|
+
]);
|
|
136
|
+
const result = await listProjectKbsFn({ data: { projectId: "p1" } });
|
|
137
|
+
expect(result[0]).toMatchObject({ id: "kb1", name: "团队库" });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("KB 侧返回项目概要", async () => {
|
|
141
|
+
mocks.pkbFindMany.mockResolvedValue([
|
|
142
|
+
{ project: { id: "p1", name: "项目A", icon: "folder" } },
|
|
143
|
+
]);
|
|
144
|
+
const result = await listKbProjectsFn({ data: { kbId: "kb1" } });
|
|
145
|
+
expect(result[0]).toMatchObject({ id: "p1", name: "项目A" });
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -25,6 +25,12 @@ import { deleteEmbeddings } from '@/lib/pgvector'
|
|
|
25
25
|
import { requirePermission } from '@/server/permissions'
|
|
26
26
|
import { auditLog } from '@/server/audit'
|
|
27
27
|
|
|
28
|
+
/** 读权限放宽:自己的 KB ∪ 他人 team/general KB */
|
|
29
|
+
const READABLE_KB = (userId: string) => ({
|
|
30
|
+
isDeleted: false,
|
|
31
|
+
OR: [{ userId }, { visibility: { in: ['team', 'general'] } }],
|
|
32
|
+
})
|
|
33
|
+
|
|
28
34
|
export const listKnowledgeFn = createServerFn({ method: 'GET' })
|
|
29
35
|
.validator(z.object({}).optional())
|
|
30
36
|
.handler(async () => {
|
|
@@ -51,7 +57,7 @@ const createSchema = z.object({
|
|
|
51
57
|
description: z.string().max(200).optional(),
|
|
52
58
|
icon: z.string().default('notebook'),
|
|
53
59
|
tags: z.array(z.string()).default([]),
|
|
54
|
-
visibility: z.enum(['private', 'team']).default('private'),
|
|
60
|
+
visibility: z.enum(['private', 'team', 'general']).default('private'),
|
|
55
61
|
})
|
|
56
62
|
|
|
57
63
|
export const createKnowledgeFn = createServerFn({ method: 'POST' })
|
|
@@ -95,7 +101,7 @@ export const getKnowledgeDetailFn = createServerFn({ method: 'GET' })
|
|
|
95
101
|
const request = getRequest()
|
|
96
102
|
const session = await requireAuth(request)
|
|
97
103
|
const kb = await prisma.knowledgeBase.findFirst({
|
|
98
|
-
where: { id: data.id,
|
|
104
|
+
where: { id: data.id, ...READABLE_KB(session.user!.id!) },
|
|
99
105
|
select: { id: true, name: true, description: true, icon: true, tags: true, visibility: true, createdAt: true, updatedAt: true },
|
|
100
106
|
})
|
|
101
107
|
if (!kb) throw new Error('未找到或无权限')
|
|
@@ -130,7 +136,7 @@ const updateSchema = z.object({
|
|
|
130
136
|
description: z.string().max(500).optional(),
|
|
131
137
|
icon: z.string().optional(),
|
|
132
138
|
tags: z.array(z.string()).optional(),
|
|
133
|
-
visibility: z.enum(['private', 'team']).optional(),
|
|
139
|
+
visibility: z.enum(['private', 'team', 'general']).optional(),
|
|
134
140
|
})
|
|
135
141
|
|
|
136
142
|
export const updateKnowledgeFn = createServerFn({ method: 'POST' })
|
|
@@ -202,7 +208,7 @@ export const createDocumentFn = createServerFn({ method: 'POST' })
|
|
|
202
208
|
const userId = session.user!.id!
|
|
203
209
|
await requirePermission(userId, 'knowledge', 'write')
|
|
204
210
|
const kb = await prisma.knowledgeBase.findFirst({
|
|
205
|
-
where: { id: data.kbId,
|
|
211
|
+
where: { id: data.kbId, ...READABLE_KB(session.user!.id!) },
|
|
206
212
|
select: { id: true },
|
|
207
213
|
})
|
|
208
214
|
if (!kb) throw new Error('未找到或无权限')
|
|
@@ -581,3 +587,43 @@ export const getDocumentPreviewFn = createServerFn({ method: 'GET' })
|
|
|
581
587
|
} : null,
|
|
582
588
|
} satisfies DocumentPreviewData
|
|
583
589
|
})
|
|
590
|
+
|
|
591
|
+
export const listKbProjectsFn = createServerFn({ method: 'GET' })
|
|
592
|
+
.validator(z.object({ kbId: z.string() }))
|
|
593
|
+
.handler(async ({ data }) => {
|
|
594
|
+
const request = getRequest()
|
|
595
|
+
const session = await requireAuth(request)
|
|
596
|
+
const rows = await prisma.projectKnowledgeBase.findMany({
|
|
597
|
+
where: { kbId: data.kbId },
|
|
598
|
+
select: { project: { select: { id: true, name: true, icon: true } } },
|
|
599
|
+
orderBy: { createdAt: 'asc' },
|
|
600
|
+
})
|
|
601
|
+
return rows.map((r) => r.project)
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
export const setKbProjectsFn = createServerFn({ method: 'POST' })
|
|
605
|
+
.validator(z.object({ kbId: z.string(), projectIds: z.array(z.string()) }))
|
|
606
|
+
.handler(async ({ data }) => {
|
|
607
|
+
const request = getRequest()
|
|
608
|
+
const session = await requireAuth(request)
|
|
609
|
+
const userId = session.user!.id!
|
|
610
|
+
const kb = await prisma.knowledgeBase.findFirst({
|
|
611
|
+
where: { id: data.kbId, userId, isDeleted: false },
|
|
612
|
+
select: { visibility: true },
|
|
613
|
+
})
|
|
614
|
+
if (!kb) throw new Error('知识库不存在')
|
|
615
|
+
if (kb.visibility === 'general') throw new Error('通用知识库对所有项目可见,无需关联')
|
|
616
|
+
if (kb.visibility === 'private') throw new Error('私有知识库不可关联项目,请先切换为团队可见')
|
|
617
|
+
await prisma.$transaction(async (tx) => {
|
|
618
|
+
await tx.projectKnowledgeBase.deleteMany({
|
|
619
|
+
where: { kbId: data.kbId, projectId: { notIn: data.projectIds } },
|
|
620
|
+
})
|
|
621
|
+
if (data.projectIds.length > 0) {
|
|
622
|
+
await tx.projectKnowledgeBase.createMany({
|
|
623
|
+
data: data.projectIds.map((projectId) => ({ kbId: data.kbId, projectId })),
|
|
624
|
+
skipDuplicates: true,
|
|
625
|
+
})
|
|
626
|
+
}
|
|
627
|
+
})
|
|
628
|
+
return { ok: true }
|
|
629
|
+
})
|
|
@@ -573,3 +573,46 @@ export const ensureBuiltinProjectFn = createServerFn({ method: "GET" }).handler(
|
|
|
573
573
|
const projectId = await ensureBuiltinIndependentProject(userId);
|
|
574
574
|
return { id: projectId, name: BUILTIN_INDEPENDENT_NAME, displayId: BUILTIN_INDEPENDENT_DISPLAY_ID, icon: BUILTIN_INDEPENDENT_ICON };
|
|
575
575
|
});
|
|
576
|
+
|
|
577
|
+
export const listProjectKbsFn = createServerFn({ method: "GET" })
|
|
578
|
+
.validator(z.object({ projectId: z.string() }))
|
|
579
|
+
.handler(async ({ data }) => {
|
|
580
|
+
const request = getRequest();
|
|
581
|
+
await requireAuth(request);
|
|
582
|
+
const rows = await prisma.projectKnowledgeBase.findMany({
|
|
583
|
+
where: { projectId: data.projectId },
|
|
584
|
+
select: {
|
|
585
|
+
kb: { select: { id: true, name: true, icon: true, visibility: true } },
|
|
586
|
+
},
|
|
587
|
+
orderBy: { createdAt: "asc" },
|
|
588
|
+
});
|
|
589
|
+
return rows.map((r) => r.kb);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
export const setProjectKbsFn = createServerFn({ method: "POST" })
|
|
593
|
+
.validator(z.object({ projectId: z.string(), kbIds: z.array(z.string()) }))
|
|
594
|
+
.handler(async ({ data }) => {
|
|
595
|
+
const request = getRequest();
|
|
596
|
+
await requireAuth(request);
|
|
597
|
+
if (data.kbIds.length > 0) {
|
|
598
|
+
const valid = await prisma.knowledgeBase.findMany({
|
|
599
|
+
where: { id: { in: data.kbIds }, visibility: "team", isDeleted: false },
|
|
600
|
+
select: { id: true },
|
|
601
|
+
});
|
|
602
|
+
if (valid.length !== data.kbIds.length) {
|
|
603
|
+
throw new Error("仅允许团队可见的知识库关联项目");
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
await prisma.$transaction(async (tx) => {
|
|
607
|
+
await tx.projectKnowledgeBase.deleteMany({
|
|
608
|
+
where: { projectId: data.projectId, kbId: { notIn: data.kbIds } },
|
|
609
|
+
});
|
|
610
|
+
if (data.kbIds.length > 0) {
|
|
611
|
+
await tx.projectKnowledgeBase.createMany({
|
|
612
|
+
data: data.kbIds.map((kbId) => ({ projectId: data.projectId, kbId })),
|
|
613
|
+
skipDuplicates: true,
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
return { ok: true };
|
|
618
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
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 { describe, it, expect } from "vitest";
|
|
19
|
+
import { isIndependentProject, buildKbScopeWhere } from "../kb-scope";
|
|
20
|
+
|
|
21
|
+
describe("kb-scope 可用范围判定", () => {
|
|
22
|
+
it("独立需求项目(displayId=STORY)识别", () => {
|
|
23
|
+
expect(isIndependentProject("STORY")).toBe(true);
|
|
24
|
+
expect(isIndependentProject("story")).toBe(false);
|
|
25
|
+
expect(isIndependentProject(null)).toBe(false);
|
|
26
|
+
expect(isIndependentProject("PROJ")).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("普通项目:general ∪ 项目关联(不含他人 private/team)", () => {
|
|
30
|
+
const where = buildKbScopeWhere({
|
|
31
|
+
projectId: "p1",
|
|
32
|
+
projectDisplayId: "PROJ",
|
|
33
|
+
userId: "u1",
|
|
34
|
+
});
|
|
35
|
+
expect(where).toEqual({
|
|
36
|
+
OR: [
|
|
37
|
+
{ visibility: "general" },
|
|
38
|
+
{ projectLinks: { some: { projectId: "p1" } } },
|
|
39
|
+
],
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("独立需求项目:自己的全部 ∪ 他人 team/general", () => {
|
|
44
|
+
const where = buildKbScopeWhere({
|
|
45
|
+
projectId: "p2",
|
|
46
|
+
projectDisplayId: "STORY",
|
|
47
|
+
userId: "u1",
|
|
48
|
+
});
|
|
49
|
+
expect(where).toEqual({
|
|
50
|
+
OR: [
|
|
51
|
+
{ userId: "u1" },
|
|
52
|
+
{ visibility: { in: ["team", "general"] } },
|
|
53
|
+
],
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,222 @@
|
|
|
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 { describe, it, expect, vi, beforeEach } from "vitest";
|
|
19
|
+
|
|
20
|
+
vi.mock("@tanstack/react-start", () => ({
|
|
21
|
+
createServerFn: (config: any) => {
|
|
22
|
+
const api: any = {
|
|
23
|
+
_validator: undefined,
|
|
24
|
+
validator(schema: any) {
|
|
25
|
+
this._validator = schema;
|
|
26
|
+
return this;
|
|
27
|
+
},
|
|
28
|
+
handler(fn: Function) {
|
|
29
|
+
const wrapped = async (args: any) => {
|
|
30
|
+
let data = args?.data;
|
|
31
|
+
if (this._validator && data !== undefined) {
|
|
32
|
+
data = this._validator.parse ? this._validator.parse(data) : data;
|
|
33
|
+
}
|
|
34
|
+
return fn({ data });
|
|
35
|
+
};
|
|
36
|
+
return wrapped;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
return api;
|
|
40
|
+
},
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
vi.mock("@tanstack/react-start/server", () => ({
|
|
44
|
+
getRequest: () => ({}),
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
const mocks = vi.hoisted(() => ({
|
|
48
|
+
storyFindUnique: vi.fn(),
|
|
49
|
+
kbFindMany: vi.fn(),
|
|
50
|
+
kbFindFirst: vi.fn(),
|
|
51
|
+
docFindMany: vi.fn(),
|
|
52
|
+
linkFindMany: vi.fn(),
|
|
53
|
+
linkCreateMany: vi.fn(),
|
|
54
|
+
extLinkFindMany: vi.fn(),
|
|
55
|
+
extLinkCreateMany: vi.fn(),
|
|
56
|
+
extLinkDeleteMany: vi.fn(),
|
|
57
|
+
activityCreate: vi.fn(),
|
|
58
|
+
currentSession: vi.fn(),
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
vi.mock("@allbluecn/database", () => {
|
|
62
|
+
const prisma = {
|
|
63
|
+
story: { findUnique: mocks.storyFindUnique },
|
|
64
|
+
knowledgeBase: { findMany: mocks.kbFindMany, findFirst: mocks.kbFindFirst },
|
|
65
|
+
knowledgeDocument: { findMany: mocks.docFindMany },
|
|
66
|
+
storyDocLink: { findMany: mocks.linkFindMany, createMany: mocks.linkCreateMany },
|
|
67
|
+
storyExternalLink: {
|
|
68
|
+
findMany: mocks.extLinkFindMany,
|
|
69
|
+
createMany: mocks.extLinkCreateMany,
|
|
70
|
+
deleteMany: mocks.extLinkDeleteMany,
|
|
71
|
+
},
|
|
72
|
+
storyActivity: { create: mocks.activityCreate },
|
|
73
|
+
};
|
|
74
|
+
return { prisma, Prisma: {} };
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
vi.mock("@/server/auth-helpers", () => ({
|
|
78
|
+
requireAuth: mocks.currentSession,
|
|
79
|
+
}));
|
|
80
|
+
|
|
81
|
+
import {
|
|
82
|
+
listStoryAvailableKbsFn,
|
|
83
|
+
searchKbDocsFn,
|
|
84
|
+
linkDocsFn,
|
|
85
|
+
getLinkedDocsFn,
|
|
86
|
+
} from "../story-assoc";
|
|
87
|
+
|
|
88
|
+
const SESSION = { user: { id: "u1" } };
|
|
89
|
+
const STORY_WITH_PROJECT = {
|
|
90
|
+
project: { id: "p1", displayId: "PROJ" },
|
|
91
|
+
};
|
|
92
|
+
const STORY_INDEPENDENT = {
|
|
93
|
+
project: { id: "p2", displayId: "STORY" },
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
beforeEach(() => {
|
|
97
|
+
vi.clearAllMocks();
|
|
98
|
+
mocks.currentSession.mockResolvedValue(SESSION);
|
|
99
|
+
mocks.storyFindUnique.mockResolvedValue(STORY_WITH_PROJECT);
|
|
100
|
+
mocks.extLinkFindMany.mockResolvedValue([]);
|
|
101
|
+
mocks.extLinkCreateMany.mockResolvedValue({ count: 0 });
|
|
102
|
+
mocks.extLinkDeleteMany.mockResolvedValue({ count: 0 });
|
|
103
|
+
mocks.activityCreate.mockResolvedValue({
|
|
104
|
+
id: "act1",
|
|
105
|
+
action: "kb_linked",
|
|
106
|
+
field: null,
|
|
107
|
+
oldValue: null,
|
|
108
|
+
newValue: "1",
|
|
109
|
+
isAI: false,
|
|
110
|
+
createdAt: new Date("2026-08-31T00:00:00Z"),
|
|
111
|
+
user: { id: "u1", username: "u1", email: "e", avatarConfig: null, bgShape: null, bgColor: null },
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("listStoryAvailableKbsFn", () => {
|
|
116
|
+
it("普通项目:按 scope 查询返回 KB 列表", async () => {
|
|
117
|
+
mocks.kbFindMany.mockResolvedValue([
|
|
118
|
+
{ id: "kb1", name: "团队库", icon: "notebook", visibility: "team" },
|
|
119
|
+
{ id: "kb2", name: "通用库", icon: "book", visibility: "general" },
|
|
120
|
+
]);
|
|
121
|
+
const result = await listStoryAvailableKbsFn({ data: { storyId: "s1" } });
|
|
122
|
+
expect(result).toHaveLength(2);
|
|
123
|
+
expect(result[0]).toMatchObject({ id: "kb1", name: "团队库" });
|
|
124
|
+
const where = mocks.kbFindMany.mock.calls[0][0].where;
|
|
125
|
+
expect(where.AND).toBeDefined(); // AND: [scope, { isDeleted: false }]
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("需求不存在抛错", async () => {
|
|
129
|
+
mocks.storyFindUnique.mockResolvedValue(null);
|
|
130
|
+
await expect(listStoryAvailableKbsFn({ data: { storyId: "nope" } })).rejects.toMatchObject({
|
|
131
|
+
message: expect.stringContaining("需求不存在"),
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("searchKbDocsFn 两态", () => {
|
|
137
|
+
it("指定 kbId:全量树查询(take 500,含 parentId/type/kb 元数据)", async () => {
|
|
138
|
+
mocks.storyFindUnique.mockResolvedValue(STORY_WITH_PROJECT);
|
|
139
|
+
mocks.kbFindFirst.mockResolvedValue({ id: "kb1" }); // 范围内
|
|
140
|
+
mocks.docFindMany.mockResolvedValue([
|
|
141
|
+
{ id: "d1", title: "页1", parentId: null, kb: { id: "kb1", name: "团队库", icon: "notebook" }, icon: "doc" },
|
|
142
|
+
]);
|
|
143
|
+
const result = await searchKbDocsFn({ data: { storyId: "s1", kbId: "kb1", query: "" } });
|
|
144
|
+
expect(result.items[0]).toMatchObject({ id: "d1", kbName: "团队库" });
|
|
145
|
+
expect(result.nextCursor).toBeNull();
|
|
146
|
+
const args = mocks.docFindMany.mock.calls[0][0];
|
|
147
|
+
expect(args.take).toBe(500);
|
|
148
|
+
expect(args.where.kbId).toBe("kb1");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("指定范围外 kbId:抛越界错误", async () => {
|
|
152
|
+
mocks.storyFindUnique.mockResolvedValue(STORY_WITH_PROJECT);
|
|
153
|
+
mocks.kbFindFirst.mockResolvedValue(null); // 不在 scope 内
|
|
154
|
+
await expect(
|
|
155
|
+
searchKbDocsFn({ data: { storyId: "s1", kbId: "kbX", query: "" } }),
|
|
156
|
+
).rejects.toMatchObject({ message: expect.stringContaining("不在可用范围") });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("全部模式:平铺分页(take 21 判 hasMore)", async () => {
|
|
160
|
+
mocks.storyFindUnique.mockResolvedValue(STORY_WITH_PROJECT);
|
|
161
|
+
mocks.docFindMany.mockResolvedValue([]);
|
|
162
|
+
await searchKbDocsFn({ data: { storyId: "s1", query: "关键词" } });
|
|
163
|
+
const args = mocks.docFindMany.mock.calls[0][0];
|
|
164
|
+
expect(args.take).toBe(21);
|
|
165
|
+
expect(args.where.kb).toBeDefined(); // scope 下推到 kb 关系
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("linkDocsFn 批量关联", () => {
|
|
170
|
+
it("合法项批量建链、已存在跳过、越界归 failed", async () => {
|
|
171
|
+
mocks.storyFindUnique.mockResolvedValue(STORY_WITH_PROJECT);
|
|
172
|
+
mocks.docFindMany.mockResolvedValue([
|
|
173
|
+
{ id: "d1", kbId: "kb1" }, // 合法
|
|
174
|
+
{ id: "d2", kbId: "kb9" }, // 越界(kb9 不在 scope)
|
|
175
|
+
]);
|
|
176
|
+
mocks.kbFindMany.mockResolvedValue([{ id: "kb1" }, { id: "kb2" }]); // scope 内 KB 集合
|
|
177
|
+
mocks.linkFindMany.mockResolvedValue([{ docId: "d1" }]); // d1 已关联
|
|
178
|
+
mocks.linkCreateMany.mockResolvedValue({ count: 0 });
|
|
179
|
+
|
|
180
|
+
const result = await linkDocsFn({ data: { storyId: "s1", docIds: ["d1", "d2"] } });
|
|
181
|
+
expect(result.linked).toEqual([]);
|
|
182
|
+
expect(result.skipped).toEqual(["d1"]);
|
|
183
|
+
expect(result.failed).toEqual([{ docId: "d2", reason: "outOfScope" }]);
|
|
184
|
+
expect(mocks.linkCreateMany).not.toHaveBeenCalled(); // 无新增
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("新增项走 createMany skipDuplicates", async () => {
|
|
188
|
+
mocks.docFindMany.mockResolvedValue([{ id: "d3", kbId: "kb1" }]);
|
|
189
|
+
mocks.kbFindMany.mockResolvedValue([{ id: "kb1" }]);
|
|
190
|
+
mocks.linkFindMany.mockResolvedValue([]);
|
|
191
|
+
mocks.linkCreateMany.mockResolvedValue({ count: 1 });
|
|
192
|
+
const result = await linkDocsFn({ data: { storyId: "s1", docIds: ["d3"] } });
|
|
193
|
+
expect(result.linked).toEqual(["d3"]);
|
|
194
|
+
expect(mocks.linkCreateMany.mock.calls[0][0].data).toEqual([{ storyId: "s1", docId: "d3" }]);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("getLinkedDocsFn 扩展返回 KB 元数据", () => {
|
|
199
|
+
it("返回对齐 DocBrief 的字段", async () => {
|
|
200
|
+
mocks.linkFindMany.mockResolvedValue([
|
|
201
|
+
{
|
|
202
|
+
doc: {
|
|
203
|
+
id: "d1",
|
|
204
|
+
title: "页1",
|
|
205
|
+
kb: { id: "kb1", name: "团队库", icon: "notebook" },
|
|
206
|
+
icon: "doc",
|
|
207
|
+
parentId: null,
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
]);
|
|
211
|
+
const result = await getLinkedDocsFn({ data: { storyId: "s1" } });
|
|
212
|
+
expect(result[0]).toEqual({
|
|
213
|
+
id: "d1",
|
|
214
|
+
title: "页1",
|
|
215
|
+
kbId: "kb1",
|
|
216
|
+
kbName: "团队库",
|
|
217
|
+
kbIcon: "notebook",
|
|
218
|
+
docIcon: "doc",
|
|
219
|
+
parentId: null,
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
});
|
|
@@ -50,6 +50,7 @@ const mocks = vi.hoisted(() => ({
|
|
|
50
50
|
depCreate: vi.fn(),
|
|
51
51
|
depDelete: vi.fn(),
|
|
52
52
|
storyFindMany: vi.fn(),
|
|
53
|
+
activityCreate: vi.fn(),
|
|
53
54
|
currentSession: vi.fn(),
|
|
54
55
|
}));
|
|
55
56
|
|
|
@@ -62,6 +63,7 @@ vi.mock("@allbluecn/database", () => {
|
|
|
62
63
|
delete: mocks.depDelete,
|
|
63
64
|
},
|
|
64
65
|
story: { findMany: mocks.storyFindMany },
|
|
66
|
+
storyActivity: { create: mocks.activityCreate },
|
|
65
67
|
};
|
|
66
68
|
return { prisma, Prisma: {} };
|
|
67
69
|
});
|
|
@@ -104,6 +106,24 @@ beforeEach(() => {
|
|
|
104
106
|
mocks.currentSession.mockResolvedValue({ user: { id: "u1" } });
|
|
105
107
|
// 默认 DB 无既有依赖边(handler 开头一次性取全边建邻接表,必须返回数组)
|
|
106
108
|
mocks.depFindMany.mockResolvedValue([]);
|
|
109
|
+
// 活动写入段:端点 label 查询 + create 返回(含 select 字段)
|
|
110
|
+
mocks.storyFindMany.mockResolvedValue([
|
|
111
|
+
{ id: "a", identifier: "A-1", name: "story a" },
|
|
112
|
+
{ id: "b", identifier: "B-1", name: "story b" },
|
|
113
|
+
]);
|
|
114
|
+
mocks.activityCreate.mockImplementation(({ data }: any) =>
|
|
115
|
+
Promise.resolve({
|
|
116
|
+
id: `act-${data.storyId}`,
|
|
117
|
+
action: "dependency_added",
|
|
118
|
+
field: null,
|
|
119
|
+
oldValue: null,
|
|
120
|
+
newValue: data.newValue,
|
|
121
|
+
isAI: false,
|
|
122
|
+
createdAt: new Date("2026-08-31T00:00:00Z"),
|
|
123
|
+
storyId: data.storyId,
|
|
124
|
+
user: { id: "u1", username: "u1", email: "e", avatarConfig: null, bgShape: null, bgColor: null },
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
107
127
|
});
|
|
108
128
|
|
|
109
129
|
describe("addDependenciesFn", () => {
|
|
@@ -187,6 +207,12 @@ describe("addDependenciesFn", () => {
|
|
|
187
207
|
expect(mocks.depFindMany).toHaveBeenCalledWith({
|
|
188
208
|
select: { sourceId: true, targetId: true },
|
|
189
209
|
});
|
|
210
|
+
// 活动契约:依赖两端各记一条「添加依赖关系」,newValue 为对方描述
|
|
211
|
+
expect(res.activities).toHaveLength(2);
|
|
212
|
+
expect(res.activities.map((x) => [x.storyId, x.newValue]).sort()).toEqual([
|
|
213
|
+
["a", "B-1 story b"],
|
|
214
|
+
["b", "A-1 story a"],
|
|
215
|
+
]);
|
|
190
216
|
});
|
|
191
217
|
|
|
192
218
|
it("validator 拒绝旧枚举值:BLOCKED_BY 不再合法", async () => {
|
|
@@ -93,11 +93,11 @@ export {
|
|
|
93
93
|
getLinksFn,
|
|
94
94
|
} from "./story-links";
|
|
95
95
|
|
|
96
|
+
export { checkLinkEmbeddableFn } from "./link-preview";
|
|
97
|
+
|
|
96
98
|
export {
|
|
97
|
-
uploadAttachmentFn,
|
|
98
99
|
removeAttachmentFn,
|
|
99
100
|
getAttachmentsFn,
|
|
100
|
-
mockCloudStorageFn,
|
|
101
101
|
} from "./story-attachments";
|
|
102
102
|
|
|
103
103
|
export {
|
|
@@ -105,10 +105,12 @@ export {
|
|
|
105
105
|
unlinkPrdFn,
|
|
106
106
|
getLinkedPrdsFn,
|
|
107
107
|
searchPrdsFn,
|
|
108
|
-
|
|
108
|
+
listStoryAvailableKbsFn,
|
|
109
|
+
searchKbDocsFn,
|
|
110
|
+
linkDocsFn,
|
|
109
111
|
unlinkDocFn,
|
|
110
112
|
getLinkedDocsFn,
|
|
111
|
-
|
|
113
|
+
type SearchDocRow,
|
|
112
114
|
} from "./story-assoc";
|
|
113
115
|
|
|
114
116
|
export {
|
|
@@ -0,0 +1,39 @@
|
|
|
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
|
+
// 通用知识库(法律法规/行业合规/词典词条/网站收藏/周报月报)的纯工具定义。
|
|
19
|
+
// 独立成文件以保持 story-assoc.ts(serverFn 模块)纯净:客户端可安全导入。
|
|
20
|
+
|
|
21
|
+
export const GENERAL_GROUPS = ["legal", "compliance", "dictionary", "bookmark", "summary"] as const;
|
|
22
|
+
export type GeneralGroup = (typeof GENERAL_GROUPS)[number];
|
|
23
|
+
|
|
24
|
+
export const EXTERNAL_ID_PREFIX = "ext:";
|
|
25
|
+
|
|
26
|
+
export function parseExternalDocId(id: string): { group: GeneralGroup; externalId: string } | null {
|
|
27
|
+
if (!id.startsWith(EXTERNAL_ID_PREFIX)) return null;
|
|
28
|
+
const rest = id.slice(EXTERNAL_ID_PREFIX.length);
|
|
29
|
+
const idx = rest.indexOf(":");
|
|
30
|
+
if (idx <= 0) return null;
|
|
31
|
+
const group = rest.slice(0, idx);
|
|
32
|
+
const externalId = rest.slice(idx + 1);
|
|
33
|
+
if (!(GENERAL_GROUPS as readonly string[]).includes(group) || !externalId) return null;
|
|
34
|
+
return { group: group as GeneralGroup, externalId };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function externalDocId(group: GeneralGroup, externalId: string): string {
|
|
38
|
+
return `${EXTERNAL_ID_PREFIX}${group}:${externalId}`;
|
|
39
|
+
}
|