@allbluecn/web-app 0.4.22 → 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 +12 -8
- package/src/components/knowledge/knowledge-toolbar.tsx +124 -1
- package/src/components/layout/sidebar-03/app-sidebar.tsx +3 -4
- package/src/components/layout/sidebar-03/nav-notifications.tsx +135 -52
- package/src/components/layout/sidebar-03/user-menu.tsx +4 -2
- package/src/components/notifications/settings-notifications.tsx +198 -0
- package/src/components/notifications/team-notifications.tsx +343 -0
- package/src/components/settings/personal-team/team/team-client.tsx +51 -12
- package/src/components/settings/personal-team/team/team-invitations.tsx +181 -181
- package/src/components/shared/wave-physics-loader.tsx +155 -0
- package/src/components/story/__tests__/dependency-type-badge.test.ts +89 -0
- package/src/components/story/constants.ts +124 -51
- 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 +14 -3
- 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 +51 -274
- package/src/components/story/detail/story-detail-toolbar/dependencies.tsx +13 -45
- package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +473 -0
- package/src/components/story/detail/story-detail-toolbar.tsx +237 -10
- package/src/components/story/display/__tests__/use-paged-story-groups.test.ts +152 -1
- package/src/components/story/display/use-paged-story-groups.ts +370 -28
- package/src/components/story/inline-editors/story-select-dialog.tsx +107 -16
- package/src/components/story/peek-panel.tsx +8 -1
- 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/{config/sidebar-notifications.ts → components/story/relations/general-groups.ts} +12 -30
- 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/relations/story-ref-row.tsx +319 -0
- package/src/components/story/story-list-view.tsx +3 -1
- package/src/components/story/story-panel-header.tsx +4 -1
- package/src/components/story/types.ts +30 -18
- package/src/components/story/views/story-list/batch-toolbar.tsx +12 -1
- package/src/components/story/views/story-list/index.tsx +16 -5
- package/src/components/story/views/story-list/row.tsx +5 -4
- package/src/components/ui/separator.tsx +1 -1
- package/src/hooks/use-notifications.ts +4 -1
- package/src/lib/__tests__/datetime.test.ts +16 -1
- package/src/lib/changelog/sdk-versions.ts +19 -19
- package/src/lib/datetime.ts +1 -0
- package/src/lib/story/__tests__/video-embed.test.ts +77 -0
- package/src/lib/story/video-embed.ts +59 -0
- package/src/routeTree.gen.ts +65 -0
- package/src/routes/_nav/notifications/route.lazy.tsx +22 -0
- package/src/routes/_nav/notifications/route.tsx +30 -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/settings/notifications/route.lazy.tsx +6 -5
- package/src/routes/api/attachments/$id/content.ts +60 -0
- package/src/routes/api/attachments/upload.ts +148 -0
- package/src/routes/api/notifications/stream.ts +4 -0
- package/src/server/notifications/__tests__/notification-service.test.ts +189 -0
- package/src/server/notifications/notification-service.ts +137 -27
- package/src/server/serverFns/__tests__/project-kb.test.ts +147 -0
- package/src/server/serverFns/__tests__/team-invitation.test.ts +73 -7
- package/src/server/serverFns/__tests__/team-join-request.test.ts +5 -3
- package/src/server/serverFns/knowledge.ts +50 -4
- package/src/server/serverFns/locale.ts +15 -3
- package/src/server/serverFns/notifications.ts +18 -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 +265 -0
- package/src/server/serverFns/story/__tests__/story-groups.test.ts +19 -1
- package/src/server/serverFns/story/index.ts +7 -6
- 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 +138 -162
- package/src/server/serverFns/story/story-groups.ts +23 -3
- package/src/server/serverFns/story/story-links.ts +13 -1
- package/src/server/serverFns/story/story-transfer.ts +5 -3
- package/src/server/serverFns/story/story-tree.ts +28 -1
- package/src/server/serverFns/story/types.ts +37 -0
- package/src/server/serverFns/team.ts +51 -2
- package/src/styles/globals.css +25 -0
- package/vite.shared.ts +2 -0
- package/src/components/notifications/notification-list.tsx +0 -232
- package/src/components/notifications/notification-preferences.tsx +0 -156
- 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/relation-badge.tsx +0 -57
- package/src/components/story/relations/attachments-panel.tsx +0 -179
- package/src/components/story/relations/dependency-panel.tsx +0 -190
- package/src/components/story/relations/hierarchy-panel.tsx +0 -180
- package/src/components/story/relations/knowledge-panel.tsx +0 -284
- package/src/components/story/relations/links-panel.tsx +0 -240
- package/src/components/story/relations/tab.tsx +0 -212
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
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 { Button } from "@/components/ui/button";
|
|
23
|
+
import { Input } from "@/components/ui/input";
|
|
24
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
25
|
+
import {
|
|
26
|
+
Dialog,
|
|
27
|
+
DialogContent,
|
|
28
|
+
DialogHeader,
|
|
29
|
+
DialogTitle,
|
|
30
|
+
} from "@/components/ui/dialog";
|
|
31
|
+
import { addLinkFn, updateLinkFn } from "@/server/serverFns/story";
|
|
32
|
+
import type { StoryLinkItem } from "../types";
|
|
33
|
+
|
|
34
|
+
export interface LinkManageDialogProps {
|
|
35
|
+
open: boolean;
|
|
36
|
+
onOpenChange: (open: boolean) => void;
|
|
37
|
+
storyId: string;
|
|
38
|
+
data: StoryLinkItem[];
|
|
39
|
+
onUpdate: (links: StoryLinkItem[]) => void;
|
|
40
|
+
/** 添加链接产生的活动,供时间线即时追加 */
|
|
41
|
+
onActivity?: (activity: import("../types").ActivityItem) => void;
|
|
42
|
+
/** 非空时进入编辑态(由合并面板「编辑」触发) */
|
|
43
|
+
initialEdit?: StoryLinkItem | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function LinkManageDialog({
|
|
47
|
+
open,
|
|
48
|
+
onOpenChange,
|
|
49
|
+
storyId,
|
|
50
|
+
data,
|
|
51
|
+
onUpdate,
|
|
52
|
+
onActivity,
|
|
53
|
+
initialEdit = null,
|
|
54
|
+
}: LinkManageDialogProps) {
|
|
55
|
+
const { t } = useTranslation("story");
|
|
56
|
+
const addLink = useServerFn(addLinkFn);
|
|
57
|
+
const updateLink = useServerFn(updateLinkFn);
|
|
58
|
+
const [editing, setEditing] = React.useState<StoryLinkItem | null>(null);
|
|
59
|
+
const [form, setForm] = React.useState({ url: "", title: "", description: "" });
|
|
60
|
+
const [submitting, setSubmitting] = React.useState(false);
|
|
61
|
+
|
|
62
|
+
React.useEffect(() => {
|
|
63
|
+
if (open) {
|
|
64
|
+
setEditing(initialEdit);
|
|
65
|
+
setForm(
|
|
66
|
+
initialEdit
|
|
67
|
+
? { url: initialEdit.url, title: initialEdit.title ?? "", description: initialEdit.description ?? "" }
|
|
68
|
+
: { url: "", title: "", description: "" },
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}, [open, initialEdit]);
|
|
72
|
+
|
|
73
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
74
|
+
e.preventDefault();
|
|
75
|
+
if (submitting) return;
|
|
76
|
+
setSubmitting(true);
|
|
77
|
+
try {
|
|
78
|
+
if (editing) {
|
|
79
|
+
await updateLink({
|
|
80
|
+
data: {
|
|
81
|
+
linkId: editing.id,
|
|
82
|
+
url: form.url,
|
|
83
|
+
title: form.title || undefined,
|
|
84
|
+
description: form.description || undefined,
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
toast.success(t("linkDialog.updated"));
|
|
88
|
+
onUpdate(
|
|
89
|
+
data.map((l) =>
|
|
90
|
+
l.id === editing.id
|
|
91
|
+
? { ...l, url: form.url, title: form.title || null, description: form.description || null }
|
|
92
|
+
: l,
|
|
93
|
+
),
|
|
94
|
+
);
|
|
95
|
+
} else {
|
|
96
|
+
const created = await addLink({
|
|
97
|
+
data: {
|
|
98
|
+
storyId,
|
|
99
|
+
url: form.url,
|
|
100
|
+
title: form.title || undefined,
|
|
101
|
+
description: form.description || undefined,
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
toast.success(t("linkDialog.added"));
|
|
105
|
+
if (created.activity) onActivity?.(created.activity);
|
|
106
|
+
onUpdate([
|
|
107
|
+
...data,
|
|
108
|
+
{
|
|
109
|
+
id: created.id,
|
|
110
|
+
url: created.url,
|
|
111
|
+
title: created.title,
|
|
112
|
+
description: created.description,
|
|
113
|
+
},
|
|
114
|
+
]);
|
|
115
|
+
setForm({ url: "", title: "", description: "" });
|
|
116
|
+
}
|
|
117
|
+
setEditing(null);
|
|
118
|
+
onOpenChange(false);
|
|
119
|
+
} catch (err) {
|
|
120
|
+
toast.error((err as Error)?.message || t("linkDialog.saveFailed"));
|
|
121
|
+
} finally {
|
|
122
|
+
setSubmitting(false);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
128
|
+
<DialogContent className="sm:max-w-130">
|
|
129
|
+
<DialogHeader>
|
|
130
|
+
<DialogTitle className="text-xl">
|
|
131
|
+
{t(editing ? "linkDialog.editTitle" : "linkDialog.title")}
|
|
132
|
+
</DialogTitle>
|
|
133
|
+
</DialogHeader>
|
|
134
|
+
<form onSubmit={handleSubmit} className="flex h-96 flex-col gap-4 overflow-y-auto px-1 pb-1">
|
|
135
|
+
<div className="space-y-1.5">
|
|
136
|
+
<label htmlFor="link-url" className="text-xs font-medium text-muted-foreground">
|
|
137
|
+
URL
|
|
138
|
+
</label>
|
|
139
|
+
<Input
|
|
140
|
+
id="link-url"
|
|
141
|
+
type="url"
|
|
142
|
+
required
|
|
143
|
+
placeholder={t("linkDialog.urlPlaceholder")}
|
|
144
|
+
value={form.url}
|
|
145
|
+
onChange={(e) => setForm({ ...form, url: e.target.value })}
|
|
146
|
+
className="h-10 text-base"
|
|
147
|
+
/>
|
|
148
|
+
</div>
|
|
149
|
+
<div className="space-y-1.5">
|
|
150
|
+
<label htmlFor="link-title" className="text-xs font-medium text-muted-foreground">
|
|
151
|
+
{t("linkDialog.titleOptional")}
|
|
152
|
+
</label>
|
|
153
|
+
<Input
|
|
154
|
+
id="link-title"
|
|
155
|
+
placeholder={t("linkDialog.titlePlaceholder")}
|
|
156
|
+
value={form.title}
|
|
157
|
+
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
|
158
|
+
className="h-10"
|
|
159
|
+
/>
|
|
160
|
+
</div>
|
|
161
|
+
<div className="flex min-h-0 flex-1 flex-col gap-1.5">
|
|
162
|
+
<label htmlFor="link-desc" className="text-xs font-medium text-muted-foreground">
|
|
163
|
+
{t("linkDialog.descOptional")}
|
|
164
|
+
</label>
|
|
165
|
+
<Textarea
|
|
166
|
+
id="link-desc"
|
|
167
|
+
placeholder={t("linkDialog.descPlaceholder")}
|
|
168
|
+
value={form.description}
|
|
169
|
+
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
|
170
|
+
className="min-h-20 flex-1 resize-none"
|
|
171
|
+
/>
|
|
172
|
+
</div>
|
|
173
|
+
<div className="flex shrink-0 justify-end gap-2 pt-1">
|
|
174
|
+
{editing && (
|
|
175
|
+
<Button
|
|
176
|
+
type="button"
|
|
177
|
+
variant="outline"
|
|
178
|
+
onClick={() => onOpenChange(false)}
|
|
179
|
+
>
|
|
180
|
+
{t("linkDialog.cancel")}
|
|
181
|
+
</Button>
|
|
182
|
+
)}
|
|
183
|
+
<Button type="submit" disabled={submitting}>
|
|
184
|
+
{t(editing ? "linkDialog.save" : "linkDialog.add")}
|
|
185
|
+
</Button>
|
|
186
|
+
</div>
|
|
187
|
+
</form>
|
|
188
|
+
</DialogContent>
|
|
189
|
+
</Dialog>
|
|
190
|
+
);
|
|
191
|
+
}
|