@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.
Files changed (99) hide show
  1. package/package.json +12 -8
  2. package/src/components/knowledge/knowledge-toolbar.tsx +124 -1
  3. package/src/components/layout/sidebar-03/app-sidebar.tsx +3 -4
  4. package/src/components/layout/sidebar-03/nav-notifications.tsx +135 -52
  5. package/src/components/layout/sidebar-03/user-menu.tsx +4 -2
  6. package/src/components/notifications/settings-notifications.tsx +198 -0
  7. package/src/components/notifications/team-notifications.tsx +343 -0
  8. package/src/components/settings/personal-team/team/team-client.tsx +51 -12
  9. package/src/components/settings/personal-team/team/team-invitations.tsx +181 -181
  10. package/src/components/shared/wave-physics-loader.tsx +155 -0
  11. package/src/components/story/__tests__/dependency-type-badge.test.ts +89 -0
  12. package/src/components/story/constants.ts +124 -51
  13. package/src/components/story/create-story-dialog.tsx +12 -6
  14. package/src/components/story/detail/__tests__/associated-attachments-panel.test.tsx +114 -0
  15. package/src/components/story/detail/activity-timeline.tsx +15 -0
  16. package/src/components/story/detail/story-detail-body.tsx +14 -3
  17. package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +748 -0
  18. package/src/components/story/detail/story-detail-toolbar/categories.tsx +23 -18
  19. package/src/components/story/detail/story-detail-toolbar/children.tsx +51 -274
  20. package/src/components/story/detail/story-detail-toolbar/dependencies.tsx +13 -45
  21. package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +473 -0
  22. package/src/components/story/detail/story-detail-toolbar.tsx +237 -10
  23. package/src/components/story/display/__tests__/use-paged-story-groups.test.ts +152 -1
  24. package/src/components/story/display/use-paged-story-groups.ts +370 -28
  25. package/src/components/story/inline-editors/story-select-dialog.tsx +107 -16
  26. package/src/components/story/peek-panel.tsx +8 -1
  27. package/src/components/story/relations/__tests__/attachment-manage-dialog.test.tsx +171 -0
  28. package/src/components/story/relations/__tests__/knowledge-select-dialog.test.tsx +174 -0
  29. package/src/components/story/relations/__tests__/link-manage-dialog.test.tsx +93 -0
  30. package/src/components/story/relations/attachment-manage-dialog.tsx +490 -0
  31. package/src/components/story/relations/file-preview.tsx +173 -0
  32. package/src/components/story/relations/format.ts +120 -0
  33. package/src/{config/sidebar-notifications.ts → components/story/relations/general-groups.ts} +12 -30
  34. package/src/components/story/relations/knowledge-select-dialog.tsx +403 -0
  35. package/src/components/story/relations/link-manage-dialog.tsx +191 -0
  36. package/src/components/story/relations/story-ref-row.tsx +319 -0
  37. package/src/components/story/story-list-view.tsx +3 -1
  38. package/src/components/story/story-panel-header.tsx +4 -1
  39. package/src/components/story/types.ts +30 -18
  40. package/src/components/story/views/story-list/batch-toolbar.tsx +12 -1
  41. package/src/components/story/views/story-list/index.tsx +16 -5
  42. package/src/components/story/views/story-list/row.tsx +5 -4
  43. package/src/components/ui/separator.tsx +1 -1
  44. package/src/hooks/use-notifications.ts +4 -1
  45. package/src/lib/__tests__/datetime.test.ts +16 -1
  46. package/src/lib/changelog/sdk-versions.ts +19 -19
  47. package/src/lib/datetime.ts +1 -0
  48. package/src/lib/story/__tests__/video-embed.test.ts +77 -0
  49. package/src/lib/story/video-embed.ts +59 -0
  50. package/src/routeTree.gen.ts +65 -0
  51. package/src/routes/_nav/notifications/route.lazy.tsx +22 -0
  52. package/src/routes/_nav/notifications/route.tsx +30 -0
  53. package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +74 -3
  54. package/src/routes/_nav/projects/$projectId/settings/route.tsx +6 -2
  55. package/src/routes/_nav/settings/notifications/route.lazy.tsx +6 -5
  56. package/src/routes/api/attachments/$id/content.ts +60 -0
  57. package/src/routes/api/attachments/upload.ts +148 -0
  58. package/src/routes/api/notifications/stream.ts +4 -0
  59. package/src/server/notifications/__tests__/notification-service.test.ts +189 -0
  60. package/src/server/notifications/notification-service.ts +137 -27
  61. package/src/server/serverFns/__tests__/project-kb.test.ts +147 -0
  62. package/src/server/serverFns/__tests__/team-invitation.test.ts +73 -7
  63. package/src/server/serverFns/__tests__/team-join-request.test.ts +5 -3
  64. package/src/server/serverFns/knowledge.ts +50 -4
  65. package/src/server/serverFns/locale.ts +15 -3
  66. package/src/server/serverFns/notifications.ts +18 -4
  67. package/src/server/serverFns/project.ts +43 -0
  68. package/src/server/serverFns/story/__tests__/kb-scope.test.ts +56 -0
  69. package/src/server/serverFns/story/__tests__/story-assoc.test.ts +222 -0
  70. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +265 -0
  71. package/src/server/serverFns/story/__tests__/story-groups.test.ts +19 -1
  72. package/src/server/serverFns/story/index.ts +7 -6
  73. package/src/server/serverFns/story/kb-general.ts +39 -0
  74. package/src/server/serverFns/story/kb-scope.ts +52 -0
  75. package/src/server/serverFns/story/link-preview.ts +115 -0
  76. package/src/server/serverFns/story/story-assoc.ts +339 -50
  77. package/src/server/serverFns/story/story-attachments.ts +12 -43
  78. package/src/server/serverFns/story/story-crud.ts +21 -4
  79. package/src/server/serverFns/story/story-dependencies.ts +138 -162
  80. package/src/server/serverFns/story/story-groups.ts +23 -3
  81. package/src/server/serverFns/story/story-links.ts +13 -1
  82. package/src/server/serverFns/story/story-transfer.ts +5 -3
  83. package/src/server/serverFns/story/story-tree.ts +28 -1
  84. package/src/server/serverFns/story/types.ts +37 -0
  85. package/src/server/serverFns/team.ts +51 -2
  86. package/src/styles/globals.css +25 -0
  87. package/vite.shared.ts +2 -0
  88. package/src/components/notifications/notification-list.tsx +0 -232
  89. package/src/components/notifications/notification-preferences.tsx +0 -156
  90. package/src/components/story/detail/story-detail-toolbar/attachments.tsx +0 -73
  91. package/src/components/story/detail/story-detail-toolbar/knowledge.tsx +0 -73
  92. package/src/components/story/detail/story-detail-toolbar/links.tsx +0 -73
  93. package/src/components/story/relation-badge.tsx +0 -57
  94. package/src/components/story/relations/attachments-panel.tsx +0 -179
  95. package/src/components/story/relations/dependency-panel.tsx +0 -190
  96. package/src/components/story/relations/hierarchy-panel.tsx +0 -180
  97. package/src/components/story/relations/knowledge-panel.tsx +0 -284
  98. package/src/components/story/relations/links-panel.tsx +0 -240
  99. package/src/components/story/relations/tab.tsx +0 -212
@@ -0,0 +1,748 @@
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 { useTheme } from "next-themes";
21
+ import { Link as RouterLink } from "@tanstack/react-router";
22
+ import { useServerFn } from "@tanstack/react-start";
23
+ import { toast } from "sonner";
24
+ import {
25
+ BookOpenText,
26
+ ChevronDown,
27
+ Download,
28
+ Edit2,
29
+ ExternalLink,
30
+ LayoutGrid,
31
+ Link as LinkIcon,
32
+ LayoutList,
33
+ Loader2,
34
+ Plus,
35
+ ShieldOff,
36
+ Trash2,
37
+ Unlink,
38
+ } from "lucide-react";
39
+ import { Button } from "@/components/ui/button";
40
+ import { Badge } from "@/components/ui/badge";
41
+ import { Dialog, DialogContent } from "@/components/ui/dialog";
42
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
43
+ import { FilePreview } from "@/components/story/relations/file-preview";
44
+ import { resolveVideoEmbed } from "@/lib/story/video-embed";
45
+ import { removeLinkFn, removeAttachmentFn, unlinkDocFn, checkLinkEmbeddableFn } from "@/server/serverFns/story";
46
+ import { getDocumentFn } from "@/server/serverFns/knowledge";
47
+ import { sanitize } from "@/lib/sanitize";
48
+ import { formatFileSize, getFaviconUrl, getFileTypeIcon, extractLinkTitle, getMimeTypeLabel } from "../../relations/format";
49
+ import { generalGroupLabel } from "../../relations/general-groups";
50
+ import { KnowledgeIconDisplay } from "@/components/knowledge/knowledge-icon";
51
+ import type { AssociatedItem, RelationsData, StoryLinkItem, StoryAttachmentItem } from "../../types";
52
+
53
+ export type AssociatedDialogKind = "link" | "attachment" | "knowledge";
54
+
55
+ export interface AssociatedAttachmentsPanelProps {
56
+ storyId: string;
57
+ data: RelationsData;
58
+ onRefresh: (next: (prev: RelationsData) => RelationsData) => void;
59
+ onOpenDialog: (kind: AssociatedDialogKind, editLink?: StoryLinkItem, editAttachment?: StoryAttachmentItem) => void;
60
+ }
61
+
62
+ // 记住列表/预览显示方式
63
+ const VIEW_MODE_STORAGE_KEY = "associated-attachments-view-mode";
64
+
65
+ // 全屏浮窗尺寸与 peek-panel 面板的 full-screen 模式一致(四边 inset-4)
66
+ const FULLSCREEN_CONTENT_CLASS = "inset-4 w-auto h-auto sm:max-w-none";
67
+
68
+ function useAttachmentFile(attachment: StoryAttachmentItem) {
69
+ const [file, setFile] = React.useState<File | null>(null);
70
+ const [error, setError] = React.useState<string | null>(null);
71
+
72
+ React.useEffect(() => {
73
+ let cancelled = false;
74
+ fetch(attachment.storageUrl)
75
+ .then((res) => {
76
+ if (!res.ok) throw new Error("加载失败");
77
+ return res.blob();
78
+ })
79
+ .then((blob) => {
80
+ if (cancelled) return;
81
+ setFile(new File([blob], attachment.fileName, { type: attachment.mimeType }));
82
+ })
83
+ .catch((e) => {
84
+ if (cancelled) return;
85
+ setError((e as Error).message);
86
+ });
87
+ return () => { cancelled = true; };
88
+ }, [attachment]);
89
+
90
+ return { file, error };
91
+ }
92
+
93
+ function AttachmentFileView({
94
+ attachment,
95
+ onFullscreen,
96
+ onExitFullscreen,
97
+ fill = false,
98
+ }: {
99
+ attachment: StoryAttachmentItem;
100
+ onFullscreen?: () => void;
101
+ onExitFullscreen?: () => void;
102
+ /** fill: 填充父容器(全屏弹窗内),否则使用网格 CSS 变量高度 */
103
+ fill?: boolean;
104
+ }) {
105
+ const { t } = useTranslation("story");
106
+ const { file, error } = useAttachmentFile(attachment);
107
+
108
+ if (error) {
109
+ return (
110
+ <div
111
+ className={`flex items-center justify-center text-sm text-muted-foreground ${fill ? "h-full w-full" : ""}`}
112
+ style={fill ? undefined : { height: "var(--attachment-preview-h-sub, 12rem)" }}
113
+ >
114
+ 预览失败:{error}
115
+ </div>
116
+ );
117
+ }
118
+ if (!file) {
119
+ return (
120
+ <div
121
+ className={`flex items-center justify-center text-sm text-muted-foreground ${fill ? "h-full w-full" : ""}`}
122
+ style={fill ? undefined : { height: "var(--attachment-preview-h-sub, 12rem)" }}
123
+ >
124
+ 加载中…
125
+ </div>
126
+ );
127
+ }
128
+ return (
129
+ <FilePreview
130
+ file={file}
131
+ fileName={attachment.fileName}
132
+ showTitleToggle
133
+ titleToggleLabel={t("attachmentDialog.toggleTitle")}
134
+ titleToggleHideLabel={t("attachmentDialog.hideTitle")}
135
+ fullscreenLabel={t("associatedAttachments.fullscreen")}
136
+ exitFullscreenLabel={t("associatedAttachments.exitFullscreen")}
137
+ onFullscreen={onFullscreen}
138
+ onExitFullscreen={onExitFullscreen}
139
+ height={fill ? "100%" : "var(--attachment-preview-h, 24rem)"}
140
+ className={fill ? "h-full min-h-0 flex-1" : "rounded-md border"}
141
+ />
142
+ );
143
+ }
144
+
145
+ function LinkPreview({ url }: { url: string }) {
146
+ const checkEmbeddable = useServerFn(checkLinkEmbeddableFn);
147
+ const [state, setState] = React.useState<"checking" | "ok" | "blocked">("checking");
148
+ // 视频链接(平台播放器 / 直链文件)走专用预览,无需服务端嵌入探测
149
+ const video = resolveVideoEmbed(url);
150
+
151
+ React.useEffect(() => {
152
+ if (video) return;
153
+ let cancelled = false;
154
+ setState("checking");
155
+ checkEmbeddable({ data: { url, origin: window.location.origin } })
156
+ .then((res: { embeddable: boolean }) => {
157
+ if (cancelled) return;
158
+ setState(res.embeddable ? "ok" : "blocked");
159
+ })
160
+ .catch(() => {
161
+ if (cancelled) return;
162
+ setState("ok");
163
+ });
164
+ return () => { cancelled = true; };
165
+ }, [url, checkEmbeddable, video]);
166
+
167
+ if (video) {
168
+ return (
169
+ <div className="h-48 w-full overflow-hidden rounded-md border bg-background">
170
+ {video.kind === "file" ? (
171
+ <video src={video.src} controls preload="metadata" className="size-full bg-black" />
172
+ ) : (
173
+ <iframe
174
+ src={video.src}
175
+ className="size-full border-0"
176
+ title="视频预览"
177
+ allow="autoplay; encrypted-media; picture-in-picture; fullscreen"
178
+ allowFullScreen
179
+ sandbox="allow-scripts allow-same-origin allow-popups allow-forms allow-presentation"
180
+ />
181
+ )}
182
+ </div>
183
+ );
184
+ }
185
+ if (state === "checking") {
186
+ return (
187
+ <div className="flex h-48 w-full items-center justify-center rounded-md border bg-background text-sm text-muted-foreground">
188
+ <Loader2 className="mr-2 size-4 animate-spin" />
189
+ 检查嵌入权限…
190
+ </div>
191
+ );
192
+ }
193
+ if (state === "blocked") {
194
+ return (
195
+ <div className="flex h-48 w-full flex-col items-center justify-center gap-2 rounded-md border bg-muted/30 text-sm text-muted-foreground">
196
+ <ShieldOff className="size-6" />
197
+ <span>该网站不允许嵌入预览</span>
198
+ <a href={url} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
199
+ 在新标签页打开
200
+ </a>
201
+ </div>
202
+ );
203
+ }
204
+ return (
205
+ <div className="h-48 w-full overflow-hidden rounded-md border bg-background">
206
+ <iframe
207
+ src={url}
208
+ className="size-full border-0"
209
+ title="链接预览"
210
+ sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
211
+ />
212
+ </div>
213
+ );
214
+ }
215
+
216
+ function DocHtmlPreview({ kbId, docId, title }: { kbId: string; docId: string; title: string }) {
217
+ const getDocument = useServerFn(getDocumentFn);
218
+ const [html, setHtml] = React.useState<string | null>(null);
219
+ const [error, setError] = React.useState<string | null>(null);
220
+
221
+ React.useEffect(() => {
222
+ let cancelled = false;
223
+ getDocument({ data: { id: docId } })
224
+ .then((doc: { content?: unknown }) => {
225
+ if (cancelled) return;
226
+ const content = doc?.content;
227
+ const docHtml = content && typeof content === "object" && "html" in content
228
+ ? (content as { html?: string }).html
229
+ : null;
230
+ setHtml(docHtml || "<p></p>");
231
+ })
232
+ .catch((e: unknown) => {
233
+ if (cancelled) return;
234
+ setError((e as Error)?.message || "加载失败");
235
+ });
236
+ return () => { cancelled = true; };
237
+ }, [docId, getDocument]);
238
+
239
+ if (error) {
240
+ return (
241
+ <div className="flex h-48 w-full flex-col items-center justify-center gap-2 rounded-md border bg-muted/30 text-sm text-muted-foreground">
242
+ <span>文档预览加载失败</span>
243
+ <RouterLink
244
+ to="/knowledge/$kbId/doc/$docId"
245
+ params={{ kbId, docId }}
246
+ target="_blank"
247
+ className="text-primary hover:underline"
248
+ >
249
+ 跳转查看「{title}」
250
+ </RouterLink>
251
+ </div>
252
+ );
253
+ }
254
+ if (html === null) {
255
+ return (
256
+ <div className="flex h-48 w-full items-center justify-center rounded-md border bg-background text-sm text-muted-foreground">
257
+ <Loader2 className="mr-2 size-4 animate-spin" />
258
+ 加载中…
259
+ </div>
260
+ );
261
+ }
262
+ return (
263
+ <div className="prose prose-sm h-48 w-full max-w-none overflow-y-auto rounded-md border bg-background px-3 py-2 dark:prose-invert">
264
+ {/* eslint-disable-next-line react/no-danger -- 内容已经过 DOMPurify sanitize */}
265
+ <div dangerouslySetInnerHTML={{ __html: sanitize(html) }} />
266
+ </div>
267
+ );
268
+ }
269
+
270
+ function ExternalDocPreview({ group, title }: { group: string; title: string }) {
271
+ const { t } = useTranslation("story");
272
+ return (
273
+ <div className="flex h-48 w-full flex-col items-center justify-center gap-2 rounded-md border bg-muted/30 text-sm text-muted-foreground">
274
+ <BookOpenText className="size-6" />
275
+ <span className="max-w-full truncate px-4 font-medium text-foreground">{title}</span>
276
+ <span>{generalGroupLabel(t, group)}</span>
277
+ </div>
278
+ );
279
+ }
280
+
281
+ function ItemPreview({ item, onFullscreen }: { item: AssociatedItem; onFullscreen?: () => void }) {
282
+ if (item.kind === "link") {
283
+ return <LinkPreview url={item.data.url} />;
284
+ }
285
+ if (item.kind === "attachment") {
286
+ return <AttachmentFileView attachment={item.data} onFullscreen={onFullscreen} />;
287
+ }
288
+ if (item.data.kbId.startsWith("ext:")) {
289
+ const group = item.data.kbId.slice("ext:".length);
290
+ return <ExternalDocPreview group={group} title={item.data.title} />;
291
+ }
292
+ return <DocHtmlPreview kbId={item.data.kbId} docId={item.data.id} title={item.data.title} />;
293
+ }
294
+
295
+ export function AssociatedAttachmentsPanel({
296
+ storyId,
297
+ data,
298
+ onRefresh,
299
+ onOpenDialog,
300
+ }: AssociatedAttachmentsPanelProps) {
301
+ const { t } = useTranslation("story");
302
+ const removeLink = useServerFn(removeLinkFn);
303
+ const removeAttachment = useServerFn(removeAttachmentFn);
304
+ const unlinkDoc = useServerFn(unlinkDocFn);
305
+ const [mode, setMode] = React.useState<"list" | "preview">("list");
306
+ const [groupCollapsed, setGroupCollapsed] = React.useState<Record<string, boolean>>({});
307
+ const [fullscreenAttachment, setFullscreenAttachment] = React.useState<StoryAttachmentItem | null>(null);
308
+
309
+ // 下次进入时恢复上次的显示方式(挂载后读取,避免 SSR hydration 不匹配)
310
+ React.useEffect(() => {
311
+ try {
312
+ if (window.localStorage.getItem(VIEW_MODE_STORAGE_KEY) === "preview") setMode("preview");
313
+ } catch { /* localStorage 不可用时忽略 */ }
314
+ }, []);
315
+
316
+ const switchMode = (next: "list" | "preview") => {
317
+ setMode(next);
318
+ try {
319
+ window.localStorage.setItem(VIEW_MODE_STORAGE_KEY, next);
320
+ } catch { /* localStorage 不可用时忽略 */ }
321
+ };
322
+
323
+ const items: AssociatedItem[] = React.useMemo(
324
+ () => [
325
+ ...data.links.map((l) => ({ kind: "link" as const, data: l })),
326
+ ...data.attachments.map((a) => ({ kind: "attachment" as const, data: a })),
327
+ ...data.knowledge.docs.map((d) => ({ kind: "doc" as const, data: d })),
328
+ ],
329
+ [data],
330
+ );
331
+
332
+ const groups = React.useMemo(() => {
333
+ const linkItems = items.filter((i) => i.kind === "link");
334
+ const attachmentItems = items.filter((i) => i.kind === "attachment");
335
+ const docItems = items.filter((i) => i.kind === "doc");
336
+ return [
337
+ { kind: "link" as const, label: t("associatedAttachments.groupLink"), count: linkItems.length, items: linkItems },
338
+ { kind: "attachment" as const, label: t("associatedAttachments.groupAttachment"), count: attachmentItems.length, items: attachmentItems },
339
+ { kind: "doc" as const, label: t("associatedAttachments.groupKb"), count: docItems.length, items: docItems },
340
+ ].filter((g) => g.count > 0);
341
+ }, [items, t]);
342
+
343
+ const handleRemoveLink = async (id: string) => {
344
+ try {
345
+ await removeLink({ data: { linkId: id } });
346
+ onRefresh((prev) => ({ ...prev, links: prev.links.filter((l) => l.id !== id) }));
347
+ } catch (err) {
348
+ toast.error((err as Error)?.message || t("linkDialog.removeFailed"));
349
+ }
350
+ };
351
+
352
+ const handleRemoveAttachment = async (id: string) => {
353
+ try {
354
+ await removeAttachment({ data: { attachmentId: id } });
355
+ onRefresh((prev) => ({
356
+ ...prev,
357
+ attachments: prev.attachments.filter((a) => a.id !== id),
358
+ }));
359
+ } catch (err) {
360
+ toast.error((err as Error)?.message || t("attachmentDialog.removeFailed"));
361
+ }
362
+ };
363
+
364
+ const handleUnlinkDoc = async (docId: string) => {
365
+ try {
366
+ await unlinkDoc({ data: { storyId, docId } });
367
+ onRefresh((prev) => ({
368
+ ...prev,
369
+ knowledge: { ...prev.knowledge, docs: prev.knowledge.docs.filter((d) => d.id !== docId) },
370
+ }));
371
+ } catch (err) {
372
+ toast.error((err as Error)?.message || t("knowledgeDialog.linkFailed"));
373
+ }
374
+ };
375
+
376
+ const getHost = (url: string) => {
377
+ try {
378
+ return new URL(url).hostname;
379
+ } catch {
380
+ return url;
381
+ }
382
+ };
383
+
384
+ if (items.length === 0) return null;
385
+
386
+ const renderActionsInline = (item: AssociatedItem, inPreviewCard = false) => {
387
+ switch (item.kind) {
388
+ case "link":
389
+ return (
390
+ <>
391
+ <Button variant="ghost" size="icon" className="size-7" asChild>
392
+ <a href={item.data.url} target="_blank" rel="noopener noreferrer" title={t("associatedAttachments.open")} onClick={(e) => e.stopPropagation()}>
393
+ <ExternalLink className="size-3.5" />
394
+ </a>
395
+ </Button>
396
+ <Button
397
+ variant="ghost"
398
+ size="icon"
399
+ className="size-7"
400
+ title={t("associatedAttachments.edit")}
401
+ aria-label={t("associatedAttachments.edit")}
402
+ onClick={() => onOpenDialog("link", item.data)}
403
+ >
404
+ <Edit2 className="size-3.5" />
405
+ </Button>
406
+ <Button
407
+ variant="ghost"
408
+ size="icon"
409
+ className="size-7"
410
+ aria-label={t("associatedAttachments.remove")}
411
+ onClick={() => handleRemoveLink(item.data.id)}
412
+ >
413
+ <Trash2 className="size-3.5" />
414
+ </Button>
415
+ </>
416
+ );
417
+ case "attachment":
418
+ return (
419
+ <>
420
+ {inPreviewCard && (
421
+ <Button variant="ghost" size="icon" className="size-7" asChild>
422
+ <a
423
+ href={item.data.storageUrl}
424
+ download={item.data.fileName}
425
+ title={t("associatedAttachments.download")}
426
+ aria-label={t("associatedAttachments.download")}
427
+ onClick={(e) => e.stopPropagation()}
428
+ >
429
+ <Download className="size-3.5" />
430
+ </a>
431
+ </Button>
432
+ )}
433
+ <Button
434
+ variant="ghost"
435
+ size="icon"
436
+ className="size-7"
437
+ title={t("associatedAttachments.edit")}
438
+ aria-label={t("associatedAttachments.edit")}
439
+ onClick={() => onOpenDialog("attachment", undefined, item.data)}
440
+ >
441
+ <Edit2 className="size-3.5" />
442
+ </Button>
443
+ <Button
444
+ variant="ghost"
445
+ size="icon"
446
+ className="size-7"
447
+ aria-label={t("associatedAttachments.remove")}
448
+ onClick={() => handleRemoveAttachment(item.data.id)}
449
+ >
450
+ <Trash2 className="size-3.5" />
451
+ </Button>
452
+ </>
453
+ );
454
+ case "doc": {
455
+ const isExternal = item.data.kbId.startsWith("ext:");
456
+ const externalGroup = isExternal ? item.data.kbId.slice("ext:".length) : null;
457
+ return (
458
+ <>
459
+ <Button variant="ghost" size="icon" className="size-7" asChild>
460
+ {isExternal && externalGroup ? (
461
+ externalGroup === "legal" ? (
462
+ <RouterLink to="/knowledge/laws/$id" params={{ id: item.data.id }} title={t("associatedAttachments.goToDoc")} onClick={(e) => e.stopPropagation()}>
463
+ <ExternalLink className="size-3.5" />
464
+ </RouterLink>
465
+ ) : externalGroup === "compliance" ? (
466
+ <RouterLink to="/knowledge/compliance/$id" params={{ id: item.data.id }} title={t("associatedAttachments.goToDoc")} onClick={(e) => e.stopPropagation()}>
467
+ <ExternalLink className="size-3.5" />
468
+ </RouterLink>
469
+ ) : externalGroup === "dictionary" ? (
470
+ <RouterLink to="/knowledge/dictionaries/$id" params={{ id: item.data.id }} title={t("associatedAttachments.goToDoc")} onClick={(e) => e.stopPropagation()}>
471
+ <ExternalLink className="size-3.5" />
472
+ </RouterLink>
473
+ ) : externalGroup === "bookmark" ? (
474
+ <RouterLink to="/knowledge/bookmarks/$id" params={{ id: item.data.id }} title={t("associatedAttachments.goToDoc")} onClick={(e) => e.stopPropagation()}>
475
+ <ExternalLink className="size-3.5" />
476
+ </RouterLink>
477
+ ) : (
478
+ <span />
479
+ )
480
+ ) : (
481
+ <RouterLink
482
+ to="/knowledge/$kbId/doc/$docId"
483
+ params={{ kbId: item.data.kbId, docId: item.data.id }}
484
+ title={t("associatedAttachments.goToDoc")}
485
+ onClick={(e) => e.stopPropagation()}
486
+ >
487
+ <ExternalLink className="size-3.5" />
488
+ </RouterLink>
489
+ )}
490
+ </Button>
491
+ <Button
492
+ variant="ghost"
493
+ size="icon"
494
+ className="size-7"
495
+ title={t("associatedAttachments.removeLink")}
496
+ aria-label={t("associatedAttachments.removeLink")}
497
+ onClick={() => handleUnlinkDoc(item.data.id)}
498
+ >
499
+ <Unlink className="size-3.5" />
500
+ </Button>
501
+ </>
502
+ );
503
+ }
504
+ }
505
+ };
506
+
507
+ const renderListRow = (item: AssociatedItem) => {
508
+ const key = `${item.kind}-${item.data.id}`;
509
+ if (item.kind === "link") {
510
+ const favicon = getFaviconUrl(item.data.url);
511
+ return (
512
+ <div key={key} className="flex items-center gap-1.5 border-b border-border/40 px-2 py-2 last:border-b-0">
513
+ <span className="w-4 shrink-0" aria-hidden />
514
+ {favicon ? (
515
+ <img src={favicon} alt="" className="size-4 shrink-0" onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }} />
516
+ ) : (
517
+ <LinkIcon className="size-4 shrink-0 text-muted-foreground" />
518
+ )}
519
+ <span className="min-w-0 flex-1 truncate text-sm text-foreground">
520
+ {item.data.title || extractLinkTitle(item.data.url)}
521
+ {item.data.description?.trim() && (
522
+ <span className="ml-2 text-xs text-muted-foreground" title={item.data.description}>
523
+ {item.data.description}
524
+ </span>
525
+ )}
526
+ </span>
527
+ <span className="shrink-0 text-xs text-muted-foreground">{getHost(item.data.url)}</span>
528
+ <div className="flex items-center gap-0.5 shrink-0">{renderActionsInline(item)}</div>
529
+ </div>
530
+ );
531
+ }
532
+ if (item.kind === "attachment") {
533
+ const Icon = getFileTypeIcon(item.data.mimeType, item.data.fileName);
534
+ return (
535
+ <div key={key} className="flex items-center gap-1.5 border-b border-border/40 px-2 py-2 last:border-b-0">
536
+ <span className="w-4 shrink-0" aria-hidden />
537
+ <Icon className="size-4 shrink-0 text-muted-foreground" />
538
+ <span className="min-w-0 flex-1 truncate text-sm text-foreground">
539
+ {item.data.fileName}
540
+ {item.data.description?.trim() && (
541
+ <span className="ml-2 text-xs text-muted-foreground" title={item.data.description}>
542
+ {item.data.description}
543
+ </span>
544
+ )}
545
+ </span>
546
+ <Badge variant="secondary" className="shrink-0 text-[10px] h-4">
547
+ {getMimeTypeLabel(item.data.mimeType, item.data.fileName)}
548
+ </Badge>
549
+ <span className="shrink-0 text-xs text-muted-foreground">
550
+ {formatFileSize(item.data.fileSize)}
551
+ </span>
552
+ <div className="flex items-center gap-0.5 shrink-0">{renderActionsInline(item)}</div>
553
+ </div>
554
+ );
555
+ }
556
+ return (
557
+ <div key={key} className="flex items-center gap-1.5 border-b border-border/40 px-2 py-2 last:border-b-0">
558
+ <span className="w-4 shrink-0" aria-hidden />
559
+ <BookOpenText className="size-4 shrink-0 text-muted-foreground" />
560
+ <span className="min-w-0 flex-1 truncate text-sm text-foreground">{item.data.title}</span>
561
+ <KnowledgeIconDisplay name={item.data.kbIcon} size={14} className="shrink-0 text-muted-foreground" />
562
+ <span className="shrink-0 text-xs text-muted-foreground">{item.data.kbId.startsWith("ext:") ? generalGroupLabel(t, item.data.kbName) : item.data.kbName}</span>
563
+ <div className="flex items-center gap-0.5 shrink-0">{renderActionsInline(item)}</div>
564
+ </div>
565
+ );
566
+ };
567
+
568
+ const renderPreviewCard = (item: AssociatedItem) => {
569
+ const key = `${item.kind}-${item.data.id}`;
570
+ if (item.kind === "link") {
571
+ const favicon = getFaviconUrl(item.data.url);
572
+ return (
573
+ <div key={key} className="group relative flex flex-col gap-2 rounded-lg border p-3">
574
+ <div className="flex items-center gap-2">
575
+ {favicon ? (
576
+ <img src={favicon} alt="" className="size-5 shrink-0" onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }} />
577
+ ) : (
578
+ <LinkIcon className="size-5 shrink-0 text-muted-foreground" />
579
+ )}
580
+ <span className="min-w-0 truncate text-sm font-medium text-foreground">
581
+ {item.data.title || extractLinkTitle(item.data.url)}
582
+ </span>
583
+ </div>
584
+ <span className="truncate text-xs text-muted-foreground">{getHost(item.data.url)}</span>
585
+ <div className="absolute right-2 top-2 hidden gap-0.5 rounded-md border bg-background p-0.5 group-hover:flex">
586
+ {renderActionsInline(item, true)}
587
+ </div>
588
+ <ItemPreview item={item} />
589
+ </div>
590
+ );
591
+ }
592
+ if (item.kind === "attachment") {
593
+ const Icon = getFileTypeIcon(item.data.mimeType, item.data.fileName);
594
+ return (
595
+ <div key={key} className="group relative flex flex-col gap-2 rounded-lg border p-3">
596
+ <div className="flex items-center gap-2">
597
+ <Icon className="size-5 shrink-0 text-muted-foreground" />
598
+ <span className="min-w-0 truncate text-sm font-medium text-foreground">{item.data.fileName}</span>
599
+ </div>
600
+ <div className="flex items-center gap-1.5">
601
+ <Badge variant="secondary" className="shrink-0 text-[10px] h-4">
602
+ {getMimeTypeLabel(item.data.mimeType, item.data.fileName)}
603
+ </Badge>
604
+ <span className="truncate text-xs text-muted-foreground">
605
+ {formatFileSize(item.data.fileSize)}
606
+ </span>
607
+ </div>
608
+ <div className="absolute right-2 top-2 hidden gap-0.5 rounded-md border bg-background p-0.5 group-hover:flex">
609
+ {renderActionsInline(item, true)}
610
+ </div>
611
+ <ItemPreview item={item} onFullscreen={() => setFullscreenAttachment(item.data)} />
612
+ </div>
613
+ );
614
+ }
615
+ return (
616
+ <div key={key} className="group relative flex flex-col gap-2 rounded-lg border p-3">
617
+ <div className="flex items-center gap-2">
618
+ <BookOpenText className="size-5 shrink-0 text-muted-foreground" />
619
+ <span className="min-w-0 truncate text-sm font-medium text-foreground">{item.data.title}</span>
620
+ </div>
621
+ <div className="flex items-center gap-1.5">
622
+ <KnowledgeIconDisplay name={item.data.kbIcon} size={12} className="shrink-0 text-muted-foreground" />
623
+ <span className="truncate text-xs text-muted-foreground">{item.data.kbId.startsWith("ext:") ? generalGroupLabel(t, item.data.kbName) : item.data.kbName}</span>
624
+ </div>
625
+ <div className="absolute right-2 top-2 hidden gap-0.5 rounded-md border bg-background p-0.5 group-hover:flex">
626
+ {renderActionsInline(item, true)}
627
+ </div>
628
+ <ItemPreview item={item} />
629
+ </div>
630
+ );
631
+ };
632
+
633
+ return (
634
+ <>
635
+ <Collapsible defaultOpen data-testid="associated-panel">
636
+ <CollapsibleTrigger
637
+ asChild
638
+ className="group flex w-full cursor-pointer items-center gap-2 pb-3 pt-4 text-xs font-semibold hover:text-foreground transition-colors"
639
+ >
640
+ <div
641
+ role="button"
642
+ tabIndex={0}
643
+ onKeyDown={(e) => {
644
+ if (e.key === "Enter" || e.key === " ") {
645
+ e.preventDefault();
646
+ (e.currentTarget as HTMLDivElement).click();
647
+ }
648
+ }}
649
+ >
650
+ <div className="flex items-center gap-1.5">
651
+ <span>{t("associatedAttachments.title")}</span>
652
+ <span className="inline-flex min-w-[18px] items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-primary-foreground">
653
+ {items.length}
654
+ </span>
655
+ <ChevronDown className="size-3 text-muted-foreground transition-transform group-data-[state=open]:rotate-0 group-data-[state=closed]:-rotate-90" />
656
+ </div>
657
+ <div className="flex items-center gap-1 ml-auto" onClick={(e) => e.stopPropagation()}>
658
+ <Button
659
+ variant="ghost"
660
+ size="sm"
661
+ className={`size-7 p-0 ${mode === "list" ? "bg-muted" : ""}`}
662
+ aria-label={t("associatedAttachments.list")}
663
+ title={t("associatedAttachments.list")}
664
+ onClick={() => switchMode("list")}
665
+ >
666
+ <LayoutList className="size-3.5" />
667
+ </Button>
668
+ <Button
669
+ variant="ghost"
670
+ size="sm"
671
+ className={`size-7 p-0 ${mode === "preview" ? "bg-muted" : ""}`}
672
+ aria-label={t("associatedAttachments.preview")}
673
+ title={t("associatedAttachments.preview")}
674
+ onClick={() => switchMode("preview")}
675
+ >
676
+ <LayoutGrid className="size-3.5" />
677
+ </Button>
678
+ </div>
679
+ </div>
680
+ </CollapsibleTrigger>
681
+ <CollapsibleContent className="pb-2">
682
+ <div
683
+ data-testid="associated-items"
684
+ data-mode={mode}
685
+ className="flex flex-col rounded-lg border overflow-hidden"
686
+ >
687
+ {groups.map((group) => {
688
+ const isCollapsed = groupCollapsed[group.kind] ?? false;
689
+ return (
690
+ <Collapsible key={group.kind} open={!isCollapsed} onOpenChange={(open) => setGroupCollapsed((p) => ({ ...p, [group.kind]: !open }))}>
691
+ <CollapsibleTrigger asChild>
692
+ <div className="flex h-9 cursor-pointer items-center gap-1.5 border-b border-border/40 bg-muted/50 px-3 text-sm font-medium hover:text-foreground transition-colors">
693
+ <ChevronDown className={`size-3 shrink-0 text-muted-foreground transition-transform ${isCollapsed ? "-rotate-90" : "rotate-0"}`} />
694
+ <span>{group.label}</span>
695
+ <span className="ml-1 text-xs text-muted-foreground">{group.count}</span>
696
+ <div className="ml-auto flex items-center" onClick={(e) => e.stopPropagation()}>
697
+ <Button
698
+ variant="ghost"
699
+ size="sm"
700
+ className="size-6 p-0"
701
+ aria-label={t("associatedAttachments.add")}
702
+ title={t("associatedAttachments.add")}
703
+ onClick={() => onOpenDialog(group.kind === "doc" ? "knowledge" : group.kind)}
704
+ >
705
+ <Plus className="size-3.5" />
706
+ </Button>
707
+ </div>
708
+ </div>
709
+ </CollapsibleTrigger>
710
+ <CollapsibleContent className={mode === "preview" ? "p-3" : ""}>
711
+ {mode === "list" ? (
712
+ <div className="flex flex-col">
713
+ {group.items.map((item) => renderListRow(item))}
714
+ </div>
715
+ ) : group.kind === "attachment" ? (
716
+ <div className="grid gap-3 attachment-preview-grid">
717
+ {group.items.map((item) => renderPreviewCard(item))}
718
+ </div>
719
+ ) : (
720
+ <div className="grid grid-cols-5 gap-3">
721
+ {group.items.map((item) => renderPreviewCard(item))}
722
+ </div>
723
+ )}
724
+ </CollapsibleContent>
725
+ </Collapsible>
726
+ );
727
+ })}
728
+ </div>
729
+ </CollapsibleContent>
730
+ </Collapsible>
731
+ <Dialog open={fullscreenAttachment !== null} onOpenChange={(open) => { if (!open) setFullscreenAttachment(null); }}>
732
+ <DialogContent
733
+ showCloseButton={false}
734
+ className={`flex flex-col overflow-hidden shadow-2xl transition-all duration-300 p-0 gap-0 translate-x-0 translate-y-0 bg-popover ${FULLSCREEN_CONTENT_CLASS}`}
735
+ onOpenAutoFocus={(e) => e.preventDefault()}
736
+ >
737
+ {fullscreenAttachment && (
738
+ <AttachmentFileView
739
+ attachment={fullscreenAttachment}
740
+ fill
741
+ onExitFullscreen={() => setFullscreenAttachment(null)}
742
+ />
743
+ )}
744
+ </DialogContent>
745
+ </Dialog>
746
+ </>
747
+ );
748
+ }