@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,120 @@
|
|
|
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 {
|
|
19
|
+
BookOpen,
|
|
20
|
+
File,
|
|
21
|
+
FileArchive,
|
|
22
|
+
FileText,
|
|
23
|
+
Film,
|
|
24
|
+
FolderArchive,
|
|
25
|
+
Image as ImageIcon,
|
|
26
|
+
Music,
|
|
27
|
+
type LucideIcon,
|
|
28
|
+
} from "lucide-react";
|
|
29
|
+
|
|
30
|
+
export function formatFileSize(bytes: number): string {
|
|
31
|
+
if (bytes === 0) return "0 B";
|
|
32
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
33
|
+
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
|
34
|
+
return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** mimeType → 文件类型图标(弹窗列表与合并面板卡片共用) */
|
|
38
|
+
export function getFileTypeIcon(mimeType: string, fileName?: string): LucideIcon {
|
|
39
|
+
const effective = inferMimeType(fileName ?? "", mimeType);
|
|
40
|
+
if (effective.startsWith("image/")) return ImageIcon;
|
|
41
|
+
if (effective.startsWith("video/")) return Film;
|
|
42
|
+
if (effective.startsWith("audio/")) return Music;
|
|
43
|
+
if (effective === "application/pdf") return FileText;
|
|
44
|
+
if (isEbookMimeType(effective)) return BookOpen;
|
|
45
|
+
if (effective.includes("zip") || effective.includes("rar") || effective.includes("7z")) {
|
|
46
|
+
return FileArchive;
|
|
47
|
+
}
|
|
48
|
+
if (effective.includes("tar") || effective.includes("gz")) return FolderArchive;
|
|
49
|
+
if (effective.startsWith("text/") || effective.includes("json")) return FileText;
|
|
50
|
+
return File;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 判断是否为电子书 MIME 类型 */
|
|
54
|
+
export function isEbookMimeType(mimeType: string): boolean {
|
|
55
|
+
return (
|
|
56
|
+
mimeType === "application/epub+zip" ||
|
|
57
|
+
mimeType === "application/epub" ||
|
|
58
|
+
mimeType === "application/x-mobipocket-ebook" ||
|
|
59
|
+
mimeType === "application/vnd.amazon.epub"
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** mimeType → 文件类型标签(中文,用于附件列表展示) */
|
|
64
|
+
export function getMimeTypeLabel(mimeType: string, fileName?: string): string {
|
|
65
|
+
const effective = inferMimeType(fileName ?? "", mimeType);
|
|
66
|
+
if (effective.startsWith("image/")) return "图片";
|
|
67
|
+
if (effective.startsWith("video/")) return "视频";
|
|
68
|
+
if (effective.startsWith("audio/")) return "音频";
|
|
69
|
+
if (effective === "application/pdf") return "PDF";
|
|
70
|
+
if (isEbookMimeType(effective)) return "电子书";
|
|
71
|
+
if (effective.includes("zip") || effective.includes("rar") || effective.includes("7z")) return "压缩包";
|
|
72
|
+
if (effective.includes("tar") || effective.includes("gz")) return "压缩包";
|
|
73
|
+
if (effective.startsWith("text/") || effective.includes("json")) return "文本";
|
|
74
|
+
if (effective.includes("word") || effective.includes("document")) return "Word";
|
|
75
|
+
if (effective.includes("sheet") || effective.includes("excel") || effective.includes("spreadsheet")) return "表格";
|
|
76
|
+
if (effective.includes("presentation") || effective.includes("powerpoint")) return "演示";
|
|
77
|
+
return "未识别文件";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 扩展名 → MIME 类型映射(补充浏览器无法识别的格式) */
|
|
81
|
+
const EXTENSION_MIME_MAP: Record<string, string> = {
|
|
82
|
+
".epub": "application/epub+zip",
|
|
83
|
+
".mobi": "application/x-mobipocket-ebook",
|
|
84
|
+
".azw3": "application/vnd.amazon.epub",
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** 根据文件名推断 MIME 类型(浏览器缺失时兜底) */
|
|
88
|
+
export function inferMimeType(fileName: string, fileMimeType: string): string {
|
|
89
|
+
if (fileMimeType && !fileMimeType.startsWith("application/octet-stream")) {
|
|
90
|
+
return fileMimeType;
|
|
91
|
+
}
|
|
92
|
+
const ext = fileName.slice(fileName.toLowerCase().lastIndexOf(".")).toLowerCase();
|
|
93
|
+
return EXTENSION_MIME_MAP[ext] ?? fileMimeType;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 链接 favicon URL(Google 服务,失败时 UI 回退 Link 图标) */
|
|
97
|
+
export function getFaviconUrl(url: string): string | null {
|
|
98
|
+
try {
|
|
99
|
+
const host = new URL(url).hostname;
|
|
100
|
+
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=32`;
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 从 URL 自动提取标题(用于用户未填写链接标题时的兜底显示) */
|
|
107
|
+
export function extractLinkTitle(url: string): string {
|
|
108
|
+
try {
|
|
109
|
+
const { hostname, pathname } = new URL(url);
|
|
110
|
+
const cleanHost = hostname.replace(/^www\./, "");
|
|
111
|
+
if (!pathname || pathname === "/" || pathname === "") return cleanHost;
|
|
112
|
+
// 取最后一段路径作为标题,去除扩展名
|
|
113
|
+
const segments = pathname.split("/").filter(Boolean);
|
|
114
|
+
const last = segments[segments.length - 1] ?? "";
|
|
115
|
+
const name = last.replace(/\.\w+$/, "").replace(/[-_]/g, " ");
|
|
116
|
+
return name || cleanHost;
|
|
117
|
+
} catch {
|
|
118
|
+
return url;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
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 { TFunction } from "i18next";
|
|
19
|
+
|
|
20
|
+
const GROUP_I18N_KEYS: Record<string, string> = {
|
|
21
|
+
legal: "knowledgeDialog.groupLegal",
|
|
22
|
+
compliance: "knowledgeDialog.groupCompliance",
|
|
23
|
+
dictionary: "knowledgeDialog.groupDictionary",
|
|
24
|
+
bookmark: "knowledgeDialog.groupBookmark",
|
|
25
|
+
summary: "knowledgeDialog.groupSummary",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** 通用知识库分组 key → i18n 标签(未知 key 原样返回) */
|
|
29
|
+
export function generalGroupLabel(t: TFunction, group: string): string {
|
|
30
|
+
const key = GROUP_I18N_KEYS[group];
|
|
31
|
+
return key ? t(key) : group;
|
|
32
|
+
}
|
|
@@ -0,0 +1,403 @@
|
|
|
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 * as React from "react";
|
|
19
|
+
import { useTranslation } from "react-i18next";
|
|
20
|
+
import { useServerFn } from "@tanstack/react-start";
|
|
21
|
+
import { toast } from "sonner";
|
|
22
|
+
import { BookMarked, BookOpenText, Bookmark, Check, ChevronsUpDown, Scale, ShieldCheck, Sprout } from "lucide-react";
|
|
23
|
+
import { Button } from "@/components/ui/button";
|
|
24
|
+
import { Input } from "@/components/ui/input";
|
|
25
|
+
import { Badge } from "@/components/ui/badge";
|
|
26
|
+
import { Checkbox } from "@/components/ui/checkbox";
|
|
27
|
+
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
28
|
+
import {
|
|
29
|
+
Dialog,
|
|
30
|
+
DialogContent,
|
|
31
|
+
DialogFooter,
|
|
32
|
+
DialogHeader,
|
|
33
|
+
DialogTitle,
|
|
34
|
+
} from "@/components/ui/dialog";
|
|
35
|
+
import { useDebouncedCallback, SEARCH_DEBOUNCE_MS } from "@/lib/pacer";
|
|
36
|
+
import {
|
|
37
|
+
listStoryAvailableKbsFn,
|
|
38
|
+
searchKbDocsFn,
|
|
39
|
+
linkDocsFn,
|
|
40
|
+
unlinkDocFn,
|
|
41
|
+
type SearchDocRow,
|
|
42
|
+
} from "@/server/serverFns/story";
|
|
43
|
+
import { generalGroupLabel } from "./general-groups";
|
|
44
|
+
import type { DocBrief } from "../types";
|
|
45
|
+
|
|
46
|
+
interface KbBrief {
|
|
47
|
+
id: string;
|
|
48
|
+
name: string;
|
|
49
|
+
icon: string;
|
|
50
|
+
visibility: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type KbFilter = { type: "kb"; id: string } | { type: "general"; group: string } | null;
|
|
54
|
+
|
|
55
|
+
const GENERAL_GROUP_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
|
56
|
+
legal: Scale,
|
|
57
|
+
compliance: ShieldCheck,
|
|
58
|
+
dictionary: BookMarked,
|
|
59
|
+
bookmark: Bookmark,
|
|
60
|
+
summary: Sprout,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const GENERAL_GROUP_META = [
|
|
64
|
+
{ key: "legal" },
|
|
65
|
+
{ key: "compliance" },
|
|
66
|
+
{ key: "dictionary" },
|
|
67
|
+
{ key: "bookmark" },
|
|
68
|
+
{ key: "summary" },
|
|
69
|
+
] as const;
|
|
70
|
+
|
|
71
|
+
export interface KnowledgeSelectDialogProps {
|
|
72
|
+
open: boolean;
|
|
73
|
+
onOpenChange: (open: boolean) => void;
|
|
74
|
+
storyId: string;
|
|
75
|
+
linkedDocs: DocBrief[];
|
|
76
|
+
onUpdate: (docs: DocBrief[]) => void;
|
|
77
|
+
/** 关联知识库文档产生的活动,供时间线即时追加 */
|
|
78
|
+
onActivity?: (activity: import("../types").ActivityItem) => void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function KnowledgeSelectDialog({
|
|
82
|
+
open,
|
|
83
|
+
onOpenChange,
|
|
84
|
+
storyId,
|
|
85
|
+
linkedDocs,
|
|
86
|
+
onUpdate,
|
|
87
|
+
onActivity,
|
|
88
|
+
}: KnowledgeSelectDialogProps) {
|
|
89
|
+
const { t } = useTranslation("story");
|
|
90
|
+
const listKbs = useServerFn(listStoryAvailableKbsFn);
|
|
91
|
+
const searchDocs = useServerFn(searchKbDocsFn);
|
|
92
|
+
const linkDocs = useServerFn(linkDocsFn);
|
|
93
|
+
const unlinkDoc = useServerFn(unlinkDocFn);
|
|
94
|
+
|
|
95
|
+
const [kbs, setKbs] = React.useState<KbBrief[]>([]);
|
|
96
|
+
const [kbFilter, setKbFilter] = React.useState<KbFilter>(null);
|
|
97
|
+
const [items, setItems] = React.useState<SearchDocRow[]>([]);
|
|
98
|
+
const [search, setSearch] = React.useState("");
|
|
99
|
+
const [selected, setSelected] = React.useState<Set<string>>(new Set());
|
|
100
|
+
const [loading, setLoading] = React.useState(false);
|
|
101
|
+
const [submitting, setSubmitting] = React.useState(false);
|
|
102
|
+
const [kbPopoverOpen, setKbPopoverOpen] = React.useState(false);
|
|
103
|
+
|
|
104
|
+
const linkedIds = React.useMemo(() => new Set(linkedDocs.map((d) => d.id)), [linkedDocs]);
|
|
105
|
+
|
|
106
|
+
const loadDocs = React.useCallback(
|
|
107
|
+
async (filter: KbFilter, query: string) => {
|
|
108
|
+
setLoading(true);
|
|
109
|
+
try {
|
|
110
|
+
const res = await searchDocs({
|
|
111
|
+
data: {
|
|
112
|
+
storyId,
|
|
113
|
+
kbId: filter?.type === "kb" ? filter.id : undefined,
|
|
114
|
+
generalGroup: filter?.type === "general" ? (filter.group as never) : undefined,
|
|
115
|
+
query,
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
setItems(res.items as SearchDocRow[]);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
toast.error((err as Error)?.message || t("knowledgeDialog.linkFailed"));
|
|
121
|
+
setItems([]);
|
|
122
|
+
} finally {
|
|
123
|
+
setLoading(false);
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
[searchDocs, storyId, t],
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
React.useEffect(() => {
|
|
130
|
+
if (!open) return;
|
|
131
|
+
setSelected(new Set(linkedIds));
|
|
132
|
+
setSearch("");
|
|
133
|
+
setKbFilter(null);
|
|
134
|
+
void (async () => {
|
|
135
|
+
try {
|
|
136
|
+
const list = await listKbs({ data: { storyId } });
|
|
137
|
+
setKbs(list);
|
|
138
|
+
} catch {
|
|
139
|
+
setKbs([]);
|
|
140
|
+
}
|
|
141
|
+
})();
|
|
142
|
+
void loadDocs(null, "");
|
|
143
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
144
|
+
}, [open]);
|
|
145
|
+
|
|
146
|
+
const debouncedSearch = useDebouncedCallback(
|
|
147
|
+
(q: string) => {
|
|
148
|
+
void loadDocs(kbFilter, q);
|
|
149
|
+
},
|
|
150
|
+
{ wait: SEARCH_DEBOUNCE_MS },
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
const handleSearch = (q: string) => {
|
|
154
|
+
setSearch(q);
|
|
155
|
+
debouncedSearch(q);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const handleKbSelect = (filter: KbFilter) => {
|
|
159
|
+
setKbFilter(filter);
|
|
160
|
+
setKbPopoverOpen(false);
|
|
161
|
+
void loadDocs(filter, search);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const toggle = (id: string) => {
|
|
165
|
+
setSelected((prev) => {
|
|
166
|
+
const next = new Set(prev);
|
|
167
|
+
if (next.has(id)) next.delete(id);
|
|
168
|
+
else next.add(id);
|
|
169
|
+
return next;
|
|
170
|
+
});
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// 全选/部分选状态(仅针对当前列表内的条目)
|
|
174
|
+
const listedIds = React.useMemo(() => items.map((d) => d.id), [items]);
|
|
175
|
+
const selectedListedCount = React.useMemo(
|
|
176
|
+
() => listedIds.filter((id) => selected.has(id)).length,
|
|
177
|
+
[listedIds, selected],
|
|
178
|
+
);
|
|
179
|
+
const allSelected = listedIds.length > 0 && selectedListedCount === listedIds.length;
|
|
180
|
+
const someSelected = selectedListedCount > 0 && !allSelected;
|
|
181
|
+
const toggleSelectAll = () => {
|
|
182
|
+
setSelected((prev) => {
|
|
183
|
+
const next = new Set(prev);
|
|
184
|
+
if (allSelected) {
|
|
185
|
+
for (const id of listedIds) next.delete(id);
|
|
186
|
+
} else {
|
|
187
|
+
for (const id of listedIds) next.add(id);
|
|
188
|
+
}
|
|
189
|
+
return next;
|
|
190
|
+
});
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const childrenOf = React.useMemo(() => {
|
|
194
|
+
const map = new Map<string | null, SearchDocRow[]>();
|
|
195
|
+
for (const row of items) {
|
|
196
|
+
const key = row.parentId ?? null;
|
|
197
|
+
const arr = map.get(key) ?? [];
|
|
198
|
+
arr.push(row);
|
|
199
|
+
map.set(key, arr);
|
|
200
|
+
}
|
|
201
|
+
return map;
|
|
202
|
+
}, [items]);
|
|
203
|
+
|
|
204
|
+
const renderRows = (parentId: string | null, depth: number): React.ReactNode[] => {
|
|
205
|
+
const rows = childrenOf.get(parentId) ?? [];
|
|
206
|
+
const out: React.ReactNode[] = [];
|
|
207
|
+
for (const row of rows) {
|
|
208
|
+
const isGeneral = row.kbId.startsWith("ext:");
|
|
209
|
+
out.push(
|
|
210
|
+
<div
|
|
211
|
+
key={row.id}
|
|
212
|
+
role="button"
|
|
213
|
+
tabIndex={0}
|
|
214
|
+
aria-pressed={selected.has(row.id)}
|
|
215
|
+
onClick={() => toggle(row.id)}
|
|
216
|
+
onKeyDown={(e) => {
|
|
217
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
218
|
+
e.preventDefault();
|
|
219
|
+
toggle(row.id);
|
|
220
|
+
}
|
|
221
|
+
}}
|
|
222
|
+
className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted/50"
|
|
223
|
+
style={{ paddingLeft: `${8 + depth * 20}px` }}
|
|
224
|
+
>
|
|
225
|
+
<Checkbox
|
|
226
|
+
checked={selected.has(row.id)}
|
|
227
|
+
onCheckedChange={() => toggle(row.id)}
|
|
228
|
+
onClick={(e) => e.stopPropagation()}
|
|
229
|
+
aria-label={row.title}
|
|
230
|
+
className="shrink-0"
|
|
231
|
+
/>
|
|
232
|
+
<BookOpenText className="size-3.5 shrink-0 text-muted-foreground" />
|
|
233
|
+
<span className="min-w-0 flex-1 truncate text-sm">{row.title}</span>
|
|
234
|
+
{kbFilter === null && (
|
|
235
|
+
<span className="shrink-0 text-xs text-muted-foreground">
|
|
236
|
+
{isGeneral ? generalGroupLabel(t, row.kbName) : row.kbName}
|
|
237
|
+
</span>
|
|
238
|
+
)}
|
|
239
|
+
{linkedIds.has(row.id) && (
|
|
240
|
+
<Badge variant="secondary" className="shrink-0 text-[10px]">
|
|
241
|
+
{t("knowledgeDialog.linked")}
|
|
242
|
+
</Badge>
|
|
243
|
+
)}
|
|
244
|
+
</div>,
|
|
245
|
+
);
|
|
246
|
+
out.push(...renderRows(row.id, depth + 1));
|
|
247
|
+
}
|
|
248
|
+
return out;
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const handleConfirm = async () => {
|
|
252
|
+
if (submitting) return;
|
|
253
|
+
setSubmitting(true);
|
|
254
|
+
try {
|
|
255
|
+
const toAdd = [...selected].filter((id) => !linkedIds.has(id));
|
|
256
|
+
const toRemove = [...linkedIds].filter((id) => !selected.has(id));
|
|
257
|
+
|
|
258
|
+
let nextDocs = linkedDocs;
|
|
259
|
+
if (toRemove.length > 0) {
|
|
260
|
+
for (const docId of toRemove) {
|
|
261
|
+
await unlinkDoc({ data: { storyId, docId } });
|
|
262
|
+
}
|
|
263
|
+
nextDocs = nextDocs.filter((d) => !toRemove.includes(d.id));
|
|
264
|
+
}
|
|
265
|
+
if (toAdd.length > 0) {
|
|
266
|
+
const res = await linkDocs({ data: { storyId, docIds: toAdd } });
|
|
267
|
+
if (res.failed.length > 0) {
|
|
268
|
+
toast.warning(t("knowledgeDialog.partialFailed", { count: res.failed.length }));
|
|
269
|
+
}
|
|
270
|
+
if (res.activity) onActivity?.(res.activity);
|
|
271
|
+
const addedIds = new Set(res.linked);
|
|
272
|
+
nextDocs = [
|
|
273
|
+
...nextDocs,
|
|
274
|
+
...items.filter((d) => addedIds.has(d.id)).map((d) => ({ id: d.id, title: d.title, kbId: d.kbId, kbName: d.kbName, kbIcon: d.kbIcon, docIcon: d.docIcon, parentId: d.parentId })),
|
|
275
|
+
];
|
|
276
|
+
} onUpdate(nextDocs);
|
|
277
|
+
onOpenChange(false);
|
|
278
|
+
} catch (err) {
|
|
279
|
+
toast.error((err as Error)?.message || t("knowledgeDialog.linkFailed"));
|
|
280
|
+
} finally {
|
|
281
|
+
setSubmitting(false);
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const activeKb = kbFilter?.type === "kb" ? kbs.find((k) => k.id === kbFilter.id) : undefined;
|
|
286
|
+
|
|
287
|
+
return (
|
|
288
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
289
|
+
<DialogContent className="sm:max-w-260 -mt-50">
|
|
290
|
+
<DialogHeader>
|
|
291
|
+
<DialogTitle>{t("knowledgeDialog.title")}</DialogTitle>
|
|
292
|
+
</DialogHeader>
|
|
293
|
+
|
|
294
|
+
<div className="flex items-center gap-2">
|
|
295
|
+
<Input
|
|
296
|
+
placeholder={t("knowledgeDialog.searchPlaceholder")}
|
|
297
|
+
value={search}
|
|
298
|
+
onChange={(e) => handleSearch(e.target.value)}
|
|
299
|
+
className="h-8"
|
|
300
|
+
/>
|
|
301
|
+
<Popover open={kbPopoverOpen} onOpenChange={setKbPopoverOpen}>
|
|
302
|
+
<PopoverTrigger asChild>
|
|
303
|
+
<Button variant="outline" size="sm" className="h-8 shrink-0 gap-1">
|
|
304
|
+
<BookOpenText className="size-3.5" />
|
|
305
|
+
{activeKb
|
|
306
|
+
? activeKb.name
|
|
307
|
+
: kbFilter?.type === "general"
|
|
308
|
+
? generalGroupLabel(t, kbFilter.group)
|
|
309
|
+
: t("knowledgeDialog.allKbs")}
|
|
310
|
+
<ChevronsUpDown className="size-3.5 opacity-50" />
|
|
311
|
+
</Button>
|
|
312
|
+
</PopoverTrigger>
|
|
313
|
+
<PopoverContent align="end" className="w-60 p-1">
|
|
314
|
+
<button
|
|
315
|
+
type="button"
|
|
316
|
+
className="flex w-full items-center justify-between rounded-sm px-2 py-1.5 mt-1 text-sm hover:bg-muted"
|
|
317
|
+
onClick={() => handleKbSelect(null)}
|
|
318
|
+
>
|
|
319
|
+
{t("knowledgeDialog.allKbs")}
|
|
320
|
+
{kbFilter === null && <Check className="size-3.5" />}
|
|
321
|
+
</button>
|
|
322
|
+
<div className="border-t px-2 pt-3 text-xs font-medium text-muted-foreground">
|
|
323
|
+
{t("knowledgeDialog.userKbs")}
|
|
324
|
+
</div>
|
|
325
|
+
{kbs.map((kb) => (
|
|
326
|
+
<button
|
|
327
|
+
key={kb.id}
|
|
328
|
+
type="button"
|
|
329
|
+
className="flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm hover:bg-muted"
|
|
330
|
+
onClick={() => handleKbSelect({ type: "kb", id: kb.id })}
|
|
331
|
+
>
|
|
332
|
+
<span className="truncate">{kb.name}</span>
|
|
333
|
+
{kbFilter?.type === "kb" && kbFilter.id === kb.id && <Check className="size-3.5" />}
|
|
334
|
+
</button>
|
|
335
|
+
))}
|
|
336
|
+
<div className=" border-t px-2 pt-3 text-xs font-medium text-muted-foreground">
|
|
337
|
+
{t("knowledgeDialog.generalKbs")}
|
|
338
|
+
</div>
|
|
339
|
+
{GENERAL_GROUP_META.map((group) => {
|
|
340
|
+
const Icon = GENERAL_GROUP_ICONS[group.key];
|
|
341
|
+
return (
|
|
342
|
+
<button
|
|
343
|
+
key={group.key}
|
|
344
|
+
type="button"
|
|
345
|
+
className="flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm hover:bg-muted"
|
|
346
|
+
onClick={() => handleKbSelect({ type: "general", group: group.key })}
|
|
347
|
+
>
|
|
348
|
+
<span className="flex min-w-0 items-center gap-2">
|
|
349
|
+
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
|
350
|
+
<span className="truncate">{generalGroupLabel(t, group.key)}</span>
|
|
351
|
+
</span>
|
|
352
|
+
{kbFilter?.type === "general" && kbFilter.group === group.key && <Check className="size-3.5" />}
|
|
353
|
+
</button>
|
|
354
|
+
);
|
|
355
|
+
})}
|
|
356
|
+
</PopoverContent>
|
|
357
|
+
</Popover>
|
|
358
|
+
</div>
|
|
359
|
+
|
|
360
|
+
<div className="h-80 overflow-y-auto rounded-md border p-1">
|
|
361
|
+
{loading ? (
|
|
362
|
+
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">…</div>
|
|
363
|
+
) : items.length > 0 ? (
|
|
364
|
+
renderRows(null, 0)
|
|
365
|
+
) : (
|
|
366
|
+
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
|
367
|
+
{t("knowledgeDialog.empty")}
|
|
368
|
+
</div>
|
|
369
|
+
)}
|
|
370
|
+
</div>
|
|
371
|
+
|
|
372
|
+
<DialogFooter className="items-center justify-between gap-2 sm:justify-between">
|
|
373
|
+
<div className="flex items-center gap-2 ml-3">
|
|
374
|
+
<Checkbox
|
|
375
|
+
id="kb-select-all"
|
|
376
|
+
checked={allSelected ? true : someSelected ? "indeterminate" : false}
|
|
377
|
+
onCheckedChange={toggleSelectAll}
|
|
378
|
+
disabled={listedIds.length === 0}
|
|
379
|
+
aria-label={t("knowledgeDialog.selectAll")}
|
|
380
|
+
/>
|
|
381
|
+
<label
|
|
382
|
+
htmlFor="kb-select-all"
|
|
383
|
+
className="cursor-pointer text-xs text-muted-foreground select-none"
|
|
384
|
+
>
|
|
385
|
+
{t("knowledgeDialog.selectAll")}
|
|
386
|
+
</label>
|
|
387
|
+
<span className="text-xs text-muted-foreground">
|
|
388
|
+
{t("knowledgeDialog.selectedCount", { count: selected.size })}
|
|
389
|
+
</span>
|
|
390
|
+
</div>
|
|
391
|
+
<div className="flex gap-2">
|
|
392
|
+
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
393
|
+
{t("knowledgeDialog.cancel")}
|
|
394
|
+
</Button>
|
|
395
|
+
<Button onClick={handleConfirm} disabled={submitting}>
|
|
396
|
+
{t("knowledgeDialog.confirm")}
|
|
397
|
+
</Button>
|
|
398
|
+
</div>
|
|
399
|
+
</DialogFooter>
|
|
400
|
+
</DialogContent>
|
|
401
|
+
</Dialog>
|
|
402
|
+
);
|
|
403
|
+
}
|