@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,93 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ import { describe, it, expect, vi, beforeEach } from "vitest";
19
+ import { render, screen, fireEvent, waitFor } from "@testing-library/react";
20
+
21
+ vi.mock("@tanstack/react-start", () => ({
22
+ useServerFn: (fn: any) => vi.fn().mockImplementation((args: any) => fn(args)),
23
+ }));
24
+
25
+ const fns = vi.hoisted(() => ({
26
+ addLink: vi.fn(),
27
+ updateLink: vi.fn(),
28
+ removeLink: vi.fn(),
29
+ }));
30
+
31
+ vi.mock("@/server/serverFns/story", () => ({
32
+ addLinkFn: fns.addLink,
33
+ updateLinkFn: fns.updateLink,
34
+ removeLinkFn: fns.removeLink,
35
+ }));
36
+
37
+ vi.mock("sonner", () => ({
38
+ toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn() },
39
+ }));
40
+
41
+ import { LinkManageDialog } from "../link-manage-dialog";
42
+ import type { StoryLinkItem } from "../../types";
43
+
44
+ const LINKS: StoryLinkItem[] = [
45
+ { id: "l1", url: "https://a.com", title: "站点A", description: null },
46
+ { id: "l2", url: "https://b.com", title: null, description: "备注" },
47
+ ];
48
+
49
+ function setup(props?: Partial<Parameters<typeof LinkManageDialog>[0]>) {
50
+ const onUpdate = vi.fn();
51
+ render(
52
+ <LinkManageDialog
53
+ open
54
+ onOpenChange={() => {}}
55
+ storyId="s1"
56
+ data={LINKS}
57
+ onUpdate={onUpdate}
58
+ initialEdit={null}
59
+ {...props}
60
+ />,
61
+ );
62
+ return { onUpdate };
63
+ }
64
+
65
+ beforeEach(() => vi.clearAllMocks());
66
+
67
+ describe("LinkManageDialog", () => {
68
+ it("不渲染已有链接列表,仅保留添加表单", () => {
69
+ setup();
70
+ expect(screen.queryByText("站点A")).not.toBeInTheDocument();
71
+ expect(screen.getByLabelText(/URL/i)).toBeInTheDocument();
72
+ });
73
+
74
+ it("填写 URL 提交调用 addLinkFn 并回调 onUpdate", async () => {
75
+ fns.addLink.mockResolvedValue({ id: "l3", url: "https://c.com", title: "C", description: null });
76
+ const { onUpdate } = setup();
77
+ fireEvent.change(screen.getByLabelText(/URL/i), { target: { value: "https://c.com" } });
78
+ fireEvent.click(screen.getByRole("button", { name: "添加" }));
79
+ await waitFor(() => expect(onUpdate).toHaveBeenCalled());
80
+ const call = fns.addLink.mock.calls[0][0];
81
+ expect(call.data.storyId).toBe("s1");
82
+ expect(call.data.url).toBe("https://c.com");
83
+ });
84
+
85
+ it("initialEdit 进入编辑态,保存调 updateLinkFn", async () => {
86
+ fns.updateLink.mockResolvedValue({});
87
+ setup({ initialEdit: LINKS[0] });
88
+ expect(screen.getByRole("button", { name: "保存" })).toBeInTheDocument();
89
+ fireEvent.click(screen.getByRole("button", { name: "保存" }));
90
+ await waitFor(() => expect(fns.updateLink).toHaveBeenCalled());
91
+ expect(fns.updateLink.mock.calls[0][0].data.linkId).toBe("l1");
92
+ });
93
+ });
@@ -0,0 +1,490 @@
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 { toast } from "sonner";
21
+ import { UploadCloud, X, ChevronLeft, ChevronRight } from "lucide-react";
22
+ import { Button } from "@/components/ui/button";
23
+ import {
24
+ Dialog,
25
+ DialogContent,
26
+ DialogHeader,
27
+ DialogTitle,
28
+ } from "@/components/ui/dialog";
29
+ import { Textarea } from "@/components/ui/textarea";
30
+ import { formatFileSize, getFileTypeIcon } from "./format";
31
+ import { FilePreview } from "./file-preview";
32
+ import type { StoryAttachmentItem } from "../types";
33
+
34
+ const MAX_FILE_SIZE = 10 * 1024 * 1024;
35
+
36
+ interface StagedFile {
37
+ file: File;
38
+ /** 图片类文件的本地缩略图 Object URL */
39
+ thumb: string | null;
40
+ /** SHA-256 十六进制哈希;编辑模式或异步计算时为 null */
41
+ hash: string | null;
42
+ }
43
+
44
+ export interface AttachmentManageDialogProps {
45
+ open: boolean;
46
+ onOpenChange: (open: boolean) => void;
47
+ storyId: string;
48
+ data: StoryAttachmentItem[];
49
+ onUpdate: (attachments: StoryAttachmentItem[]) => void;
50
+ /** 上传附件产生的活动,供时间线即时追加 */
51
+ onActivity?: (activity: import("../types").ActivityItem) => void;
52
+ /** 编辑模式:预填充现有附件,隐藏继续上传按钮和上传区 */
53
+ editItem?: StoryAttachmentItem | null;
54
+ }
55
+
56
+ async function computeFileHash(file: File): Promise<string> {
57
+ const buf = await file.arrayBuffer();
58
+ const hashBuf = await crypto.subtle.digest("SHA-256", buf);
59
+ return Array.from(new Uint8Array(hashBuf))
60
+ .map((b) => b.toString(16).padStart(2, "0"))
61
+ .join("");
62
+ }
63
+
64
+ export function AttachmentManageDialog({
65
+ open,
66
+ onOpenChange,
67
+ storyId,
68
+ data,
69
+ onUpdate,
70
+ onActivity,
71
+ editItem,
72
+ }: AttachmentManageDialogProps) {
73
+ const { t } = useTranslation("story");
74
+ const inputRef = React.useRef<HTMLInputElement>(null);
75
+ const [staged, setStaged] = React.useState<StagedFile[]>([]);
76
+ const [description, setDescription] = React.useState("");
77
+ const [uploading, setUploading] = React.useState(false);
78
+ const [dragOver, setDragOver] = React.useState(false);
79
+ const [previewIndex, setPreviewIndex] = React.useState(0);
80
+ // 当前暂存列表中已计算出的哈希集合(用于同批次重复检测)
81
+ const [computedHashes, setComputedHashes] = React.useState<Set<string>>(new Set());
82
+
83
+ const isEdit = !!editItem;
84
+ const dialogTitle = isEdit ? t("attachmentDialog.editTitle") : t("attachmentDialog.title");
85
+
86
+ // 编辑模式:预取现有文件并预填描述,同时计算 hash
87
+ React.useEffect(() => {
88
+ if (!open || !editItem) return;
89
+ let cancelled = false;
90
+ fetch(editItem.storageUrl)
91
+ .then((res) => {
92
+ if (!res.ok) throw new Error("加载失败");
93
+ return res.blob();
94
+ })
95
+ .then((blob) => {
96
+ if (cancelled) return;
97
+ const file = new File([blob], editItem.fileName, { type: editItem.mimeType });
98
+ const thumb = editItem.mimeType.startsWith("image/") ? URL.createObjectURL(file) : null;
99
+ return computeFileHash(file).then((hash) => ({ file, thumb, hash }));
100
+ })
101
+ .then((result) => {
102
+ if (cancelled || !result) return;
103
+ setStaged([{ file: result.file, thumb: result.thumb, hash: result.hash }]);
104
+ setComputedHashes(new Set([result.hash]));
105
+ setDescription(editItem.description ?? "");
106
+ })
107
+ .catch(() => {
108
+ if (cancelled) return;
109
+ });
110
+ return () => { cancelled = true; };
111
+ }, [open, editItem]);
112
+
113
+ const previewFile = staged[previewIndex]?.file ?? null;
114
+ const previewName = staged[previewIndex]?.file.name ?? "";
115
+
116
+ React.useEffect(() => {
117
+ if (staged.length > 0 && previewIndex >= staged.length) {
118
+ setPreviewIndex(0);
119
+ }
120
+ }, [staged, previewIndex]);
121
+
122
+ const addFiles = (files: FileList | File[]) => {
123
+ if (uploading) return;
124
+ const next: StagedFile[] = [];
125
+ for (const file of Array.from(files)) {
126
+ if (file.size > MAX_FILE_SIZE) {
127
+ toast.error(t("attachmentDialog.sizeLimit", { name: file.name }));
128
+ continue;
129
+ }
130
+ next.push({
131
+ file,
132
+ thumb: file.type.startsWith("image/") ? URL.createObjectURL(file) : null,
133
+ hash: null,
134
+ });
135
+ }
136
+ if (next.length === 0) return;
137
+ setStaged((prev) => [...prev, ...next]);
138
+ setPreviewIndex(Math.max(0, previewIndex));
139
+
140
+ // 异步计算新增文件的哈希,并检测与现有已计算哈希的重复
141
+ const prevLength = staged.length;
142
+ const newItems = next.map((item, i) => ({
143
+ index: prevLength + i,
144
+ file: item.file,
145
+ }));
146
+ (async () => {
147
+ const updates: Array<{ index: number; hash: string | null }> = [];
148
+ const existingHashes = new Set(computedHashes);
149
+ // 先用已有暂存项的哈希去重
150
+ for (const s of staged) {
151
+ if (s.hash) existingHashes.add(s.hash);
152
+ }
153
+ for (const { index, file } of newItems) {
154
+ try {
155
+ const hash = await computeFileHash(file);
156
+ updates.push({ index, hash });
157
+ existingHashes.add(hash);
158
+ } catch {
159
+ updates.push({ index, hash: null });
160
+ }
161
+ }
162
+ setStaged((prev) =>
163
+ prev.map((item, i) => {
164
+ const u = updates.find((up) => up.index === i);
165
+ return u ? { ...item, hash: u.hash } : item;
166
+ }),
167
+ );
168
+ setComputedHashes(existingHashes);
169
+ })();
170
+ };
171
+
172
+ const removeStaged = (index: number) => {
173
+ setStaged((prev) => {
174
+ const item = prev[index];
175
+ if (item?.thumb) URL.revokeObjectURL(item.thumb);
176
+ const next = prev.filter((_, i) => i !== index);
177
+ if (next.length === 0) {
178
+ setPreviewIndex(0);
179
+ } else if (index <= previewIndex) {
180
+ setPreviewIndex(Math.max(0, previewIndex - 1));
181
+ }
182
+ return next;
183
+ });
184
+ };
185
+
186
+ const clearStaged = () => {
187
+ for (const item of staged) {
188
+ if (item.thumb) URL.revokeObjectURL(item.thumb);
189
+ }
190
+ setStaged([]);
191
+ setDescription("");
192
+ setPreviewIndex(0);
193
+ setComputedHashes(new Set());
194
+ };
195
+
196
+ const handleClose = (next: boolean) => {
197
+ if (!next) clearStaged();
198
+ onOpenChange(next);
199
+ };
200
+
201
+ // 判断每个暂存项是否为重复(同批次内 hash 重复,或已存在于当前 story 附件)
202
+ const duplicateIndexes = React.useMemo(() => {
203
+ if (isEdit) return new Set<number>();
204
+ const seen = new Map<string, number>(); // hash → 首次出现 index
205
+ const dupSet = new Set<number>();
206
+ for (let i = 0; i < staged.length; i++) {
207
+ const h = staged[i].hash;
208
+ if (!h) continue;
209
+ const first = seen.get(h);
210
+ if (first !== undefined) {
211
+ dupSet.add(i);
212
+ if (first !== undefined) dupSet.add(first);
213
+ } else {
214
+ seen.set(h, i);
215
+ }
216
+ }
217
+ // 同时标记与已有附件重复的(已有附件 hash 匹配但未重新上传)
218
+ for (const att of data) {
219
+ if (att.fileHash) {
220
+ const idx = staged.findIndex((s) => s.hash === att.fileHash && s.hash !== null);
221
+ if (idx !== -1) dupSet.add(idx);
222
+ }
223
+ }
224
+ return dupSet;
225
+ }, [staged, data, isEdit]);
226
+
227
+ const hasDuplicates = duplicateIndexes.size > 0;
228
+
229
+ const handleConfirm = async () => {
230
+ if (staged.length === 0 || uploading) return;
231
+ setUploading(true);
232
+ const desc = description.trim();
233
+ const added: Array<StoryAttachmentItem & { activity?: import("../types").ActivityItem }> = [];
234
+ for (const item of staged) {
235
+ // 跳过重复文件
236
+ if (duplicateIndexes.has(staged.indexOf(item))) continue;
237
+ try {
238
+ const formData = new FormData();
239
+ formData.append("storyId", storyId);
240
+ formData.append("file", item.file);
241
+ if (desc) formData.append("description", desc);
242
+ if (editItem) formData.append("attachmentId", editItem.id);
243
+ if (item.hash) formData.append("fileHash", item.hash);
244
+ const res = await fetch("/api/attachments/upload", {
245
+ method: "POST",
246
+ body: formData,
247
+ });
248
+ if (!res.ok) {
249
+ const err = await res.json().catch(() => ({}));
250
+ throw new Error(err.error || t("attachmentDialog.removeFailed"));
251
+ }
252
+ const json = await res.json() as Record<string, unknown>;
253
+ if ((json.duplicated as boolean | undefined) === true) continue;
254
+ added.push(json as unknown as StoryAttachmentItem & { activity?: import("../types").ActivityItem });
255
+ } catch (err) {
256
+ toast.error((err as Error)?.message || t("attachmentDialog.removeFailed"));
257
+ }
258
+ }
259
+ setUploading(false);
260
+ if (added.length > 0) {
261
+ onUpdate([...added, ...data]);
262
+ for (const item of added) {
263
+ if (item.activity) onActivity?.(item.activity);
264
+ }
265
+ toast.success(t("attachmentDialog.uploaded", { count: added.length }));
266
+ clearStaged();
267
+ onOpenChange(false);
268
+ } else if (!hasDuplicates) {
269
+ // 所有文件都重复,无新增
270
+ toast.info(t("attachmentDialog.duplicateHint"));
271
+ clearStaged();
272
+ onOpenChange(false);
273
+ }
274
+ };
275
+
276
+ const hasFiles = staged.length > 0;
277
+ const isBatch = staged.length > 1;
278
+
279
+ // 画廊列表左右按钮:整页滚动,不逐个切换选中文件
280
+ const listRef = React.useRef<HTMLDivElement>(null);
281
+ const [canScrollLeft, setCanScrollLeft] = React.useState(false);
282
+ const [canScrollRight, setCanScrollRight] = React.useState(false);
283
+
284
+ const updateScrollState = React.useCallback(() => {
285
+ const el = listRef.current;
286
+ if (!el) return;
287
+ setCanScrollLeft(el.scrollLeft > 1);
288
+ setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
289
+ }, []);
290
+
291
+ React.useEffect(() => {
292
+ updateScrollState();
293
+ }, [staged.length, updateScrollState]);
294
+
295
+ const scrollList = (dir: -1 | 1) => {
296
+ const el = listRef.current;
297
+ if (!el) return;
298
+ el.scrollBy({ left: dir * el.clientWidth, behavior: "smooth" });
299
+ };
300
+
301
+ return (
302
+ <Dialog open={open} onOpenChange={handleClose}>
303
+ <DialogContent className="sm:max-w-lg">
304
+ <DialogHeader>
305
+ <DialogTitle className="text-xl">{dialogTitle}</DialogTitle>
306
+ </DialogHeader>
307
+ <div className="flex flex-col overflow-hidden" style={{ height: "calc(100dvh - 10rem)", maxHeight: "33rem" }}>
308
+ {/* 内容滚动区 */}
309
+ <div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-1 pb-1">
310
+ {/* 上传区域 */}
311
+ {!hasFiles && !isEdit ? (
312
+ /* 空态:大居中上传按钮 */
313
+ <div
314
+ role="button"
315
+ tabIndex={0}
316
+ aria-label={t("attachmentDialog.upload")}
317
+ onClick={() => inputRef.current?.click()}
318
+ onKeyDown={(e) => {
319
+ if (e.key === "Enter" || e.key === " ") {
320
+ e.preventDefault();
321
+ inputRef.current?.click();
322
+ }
323
+ }}
324
+ onDragOver={(e) => {
325
+ e.preventDefault();
326
+ setDragOver(true);
327
+ }}
328
+ onDragLeave={() => setDragOver(false)}
329
+ onDrop={(e) => {
330
+ e.preventDefault();
331
+ setDragOver(false);
332
+ if (e.dataTransfer.files.length > 0) addFiles(e.dataTransfer.files);
333
+ }}
334
+ className={`flex cursor-pointer flex-1 flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed px-4 text-sm text-muted-foreground transition-colors ${
335
+ dragOver ? "border-primary bg-primary/5" : "hover:border-muted-foreground/40 hover:bg-muted/50"
336
+ } min-h-48`}
337
+ >
338
+ <UploadCloud className="size-10" />
339
+ <div className="text-center">
340
+ <span className="block text-base font-medium text-foreground">{t("attachmentDialog.upload")}</span>
341
+ <span className="mt-1 block text-xs">{t("attachmentDialog.uploadHint")}</span>
342
+ </div>
343
+ </div>
344
+ ) : (
345
+ /* 有文件:画廊预览模式 */
346
+ <div className="flex flex-col gap-3">
347
+ {/* 大预览框 */}
348
+ <FilePreview
349
+ file={previewFile!}
350
+ fileName={previewName}
351
+ showTitleToggle
352
+ titleToggleLabel={t("attachmentDialog.toggleTitle")}
353
+ titleToggleHideLabel={t("attachmentDialog.hideTitle")}
354
+ fullscreenLabel={t("associatedAttachments.fullscreen")}
355
+ exitFullscreenLabel={t("associatedAttachments.exitFullscreen")}
356
+ />
357
+ {/* 文件列表(水平排列,可滚动) */}
358
+ <div className="flex items-center gap-2">
359
+ <button
360
+ onClick={() => scrollList(-1)}
361
+ className={`shrink-0 rounded-md mt-3 p-1 transition-colors ${
362
+ canScrollLeft ? "hover:bg-muted" : "cursor-not-allowed opacity-50"
363
+ }`}
364
+ aria-label="上一页"
365
+ >
366
+ <ChevronLeft className="size-4" />
367
+ </button>
368
+ <div
369
+ ref={listRef}
370
+ onScroll={updateScrollState}
371
+ className="flex min-w-0 flex-1 gap-4 overflow-x-auto overflow-y-hidden pt-3 pl-4 scrollbar-hide"
372
+ >
373
+ {staged.map((item, index) => {
374
+ const Icon = getFileTypeIcon(item.file.type, item.file.name);
375
+ const isActive = index === previewIndex;
376
+ const isDup = duplicateIndexes.has(index);
377
+ return (
378
+ <div
379
+ key={`${item.file.name}-${item.file.size}-${index}`}
380
+ className={`group relative flex shrink-0 flex-col items-center gap-1.5 rounded-md border p-2 transition-colors ${
381
+ isActive ? "border-primary bg-primary/5" : "hover:bg-muted"
382
+ } ${isDup ? "border-red-500/60" : ""}`}
383
+ style={{ width: "4rem" }}
384
+ >
385
+ {isDup && (
386
+ <span className="absolute top-0 left-1 z-20 -translate-x-1/2 -translate-y-1/2 rounded-full bg-red-500 px-1.5 py-0.5 text-[9px] font-semibold text-white shadow-sm whitespace-nowrap">
387
+ {t("attachmentDialog.duplicateBadge")}
388
+ </span>
389
+ )}
390
+ <button
391
+ onClick={() => setPreviewIndex(index)}
392
+ className="flex w-full flex-col items-center gap-1.5"
393
+ >
394
+ <div className="flex size-8 items-center justify-center rounded-md border bg-muted/50">
395
+ {item.thumb ? (
396
+ <img src={item.thumb} alt="" className="size-6 rounded object-cover" />
397
+ ) : (
398
+ <Icon className="size-4 text-muted-foreground" />
399
+ )}
400
+ </div>
401
+ <span className="w-full truncate text-[10px] text-foreground">{item.file.name}</span>
402
+ </button>
403
+ <Button
404
+ variant="ghost"
405
+ size="icon"
406
+ className="absolute right-0.5 top-0.5 size-4 opacity-0 group-hover:opacity-100 transition-opacity"
407
+ aria-label={t("attachmentDialog.removeFile")}
408
+ disabled={uploading}
409
+ onClick={(e) => {
410
+ e.stopPropagation();
411
+ removeStaged(index);
412
+ }}
413
+ >
414
+ <X className="size-2.5" />
415
+ </Button>
416
+ </div>
417
+ );
418
+ })}
419
+ </div>
420
+ <button
421
+ onClick={() => scrollList(1)}
422
+ className={`shrink-0 rounded-md mt-3 p-1 transition-colors ${
423
+ canScrollRight ? "hover:bg-muted" : "cursor-not-allowed opacity-50"
424
+ }`}
425
+ aria-label="下一页"
426
+ >
427
+ <ChevronRight className="size-4" />
428
+ </button>
429
+ </div>
430
+ </div>
431
+ )}
432
+ </div>
433
+
434
+ {/* 底部固定区 */}
435
+ <div className="shrink-0 space-y-3 pt-3">
436
+ {/* 描述 */}
437
+ <div className="space-y-1.5">
438
+ <label htmlFor="attachment-desc" className="text-xs font-medium text-muted-foreground">
439
+ {t("attachmentDialog.descOptional")}
440
+ </label>
441
+ <Textarea
442
+ id="attachment-desc"
443
+ placeholder={t("attachmentDialog.descPlaceholder")}
444
+ value={description}
445
+ onChange={(e) => setDescription(e.target.value)}
446
+ disabled={isBatch}
447
+ className="min-h-16 resize-none mt-1.5"
448
+ />
449
+ {isBatch && <p className="text-xs text-muted-foreground">{t("attachmentDialog.batchDescDisabled")}</p>}
450
+ </div>
451
+ {/* 操作按钮行 */}
452
+ <div className="flex items-center justify-between gap-2">
453
+ {hasFiles && !isEdit && (
454
+ <Button variant="outline" size="sm" onClick={() => inputRef.current?.click()}>
455
+ <UploadCloud className="size-3.5 mr-1.5" />
456
+ {t("attachmentDialog.continueUpload")}
457
+ </Button>
458
+ )}
459
+ <div className="flex items-center gap-2 ml-auto">
460
+ {hasDuplicates && (
461
+ <span className="text-xs mr-2 text-amber-600 dark:text-amber-400">{t("attachmentDialog.duplicateHint")}</span>
462
+ )}
463
+ <Button variant="outline" disabled={uploading} onClick={() => handleClose(false)}>
464
+ {t("attachmentDialog.cancel")}
465
+ </Button>
466
+ <Button
467
+ disabled={staged.length === 0 || uploading}
468
+ onClick={() => void handleConfirm()}
469
+ >
470
+ {uploading ? t("attachmentDialog.uploading") : t("attachmentDialog.confirmUpload")}
471
+ </Button>
472
+ </div>
473
+ </div>
474
+ </div>
475
+
476
+ <input
477
+ ref={inputRef}
478
+ type="file"
479
+ multiple
480
+ className="hidden"
481
+ onChange={(e) => {
482
+ if (e.target.files && e.target.files.length > 0) addFiles(e.target.files);
483
+ e.target.value = "";
484
+ }}
485
+ />
486
+ </div>
487
+ </DialogContent>
488
+ </Dialog>
489
+ );
490
+ }