@allbluecn/web-app 0.5.0 → 0.6.0

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