@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,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, userId: session.user!.id!, isDeleted: false },
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, userId, isDeleted: false },
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
- linkDocFn,
108
+ listStoryAvailableKbsFn,
109
+ searchKbDocsFn,
110
+ linkDocsFn,
109
111
  unlinkDocFn,
110
112
  getLinkedDocsFn,
111
- searchDocsFn,
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
+ }