@allbluecn/web-app 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +11 -7
- package/src/components/knowledge/knowledge-toolbar.tsx +124 -1
- package/src/components/story/constants.ts +11 -1
- package/src/components/story/create-story-dialog.tsx +12 -6
- package/src/components/story/detail/__tests__/associated-attachments-panel.test.tsx +114 -0
- package/src/components/story/detail/activity-timeline.tsx +15 -0
- package/src/components/story/detail/story-detail-body.tsx +1 -0
- package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +748 -0
- package/src/components/story/detail/story-detail-toolbar/categories.tsx +23 -18
- package/src/components/story/detail/story-detail-toolbar/children.tsx +2 -0
- package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +29 -21
- package/src/components/story/detail/story-detail-toolbar.tsx +154 -8
- package/src/components/story/inline-editors/story-select-dialog.tsx +19 -4
- package/src/components/story/relations/__tests__/attachment-manage-dialog.test.tsx +171 -0
- package/src/components/story/relations/__tests__/knowledge-select-dialog.test.tsx +174 -0
- package/src/components/story/relations/__tests__/link-manage-dialog.test.tsx +93 -0
- package/src/components/story/relations/attachment-manage-dialog.tsx +490 -0
- package/src/components/story/relations/file-preview.tsx +173 -0
- package/src/components/story/relations/format.ts +120 -0
- package/src/components/story/relations/general-groups.ts +32 -0
- package/src/components/story/relations/knowledge-select-dialog.tsx +403 -0
- package/src/components/story/relations/link-manage-dialog.tsx +191 -0
- package/src/components/story/types.ts +13 -0
- package/src/lib/changelog/sdk-versions.ts +19 -19
- package/src/lib/story/__tests__/video-embed.test.ts +77 -0
- package/src/lib/story/video-embed.ts +59 -0
- package/src/plugins/.gen-manifest.json +13 -0
- package/src/plugins/modules.gen.ts +20 -0
- package/src/plugins/plugins.gen.ts +20 -0
- package/src/plugins/ui.gen.ts +8 -0
- package/src/routeTree.gen.ts +42 -0
- package/src/routes/_nav/inspiration/route.tsx +8 -0
- package/src/routes/_nav/prd/$id/route.tsx +9 -0
- package/src/routes/_nav/prd/index.tsx +9 -0
- package/src/routes/_nav/prd/route.tsx +8 -0
- package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +74 -3
- package/src/routes/_nav/projects/$projectId/settings/route.tsx +6 -2
- package/src/routes/_nav/tags/$id/route.tsx +8 -0
- package/src/routes/_nav/tags/index.tsx +8 -0
- package/src/routes/_nav/tags/route.tsx +8 -0
- package/src/routes/_nav/tags/settings/route.tsx +9 -0
- package/src/routes/api/attachments/$id/content.ts +60 -0
- package/src/routes/api/attachments/upload.ts +148 -0
- package/src/routes/share/prd/$prdId/route.tsx +10 -0
- package/src/server/serverFns/__tests__/project-kb.test.ts +147 -0
- package/src/server/serverFns/knowledge.ts +50 -4
- package/src/server/serverFns/project.ts +43 -0
- package/src/server/serverFns/story/__tests__/kb-scope.test.ts +56 -0
- package/src/server/serverFns/story/__tests__/story-assoc.test.ts +222 -0
- package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +26 -0
- package/src/server/serverFns/story/index.ts +6 -4
- package/src/server/serverFns/story/kb-general.ts +39 -0
- package/src/server/serverFns/story/kb-scope.ts +52 -0
- package/src/server/serverFns/story/link-preview.ts +115 -0
- package/src/server/serverFns/story/story-assoc.ts +339 -50
- package/src/server/serverFns/story/story-attachments.ts +12 -43
- package/src/server/serverFns/story/story-crud.ts +21 -4
- package/src/server/serverFns/story/story-dependencies.ts +35 -3
- package/src/server/serverFns/story/story-links.ts +13 -1
- package/src/server/serverFns/story/story-tree.ts +28 -1
- package/src/server/serverFns/story/types.ts +37 -0
- package/src/styles/globals.css +25 -0
- package/vite.shared.ts +2 -0
- package/src/components/story/detail/story-detail-toolbar/attachments.tsx +0 -73
- package/src/components/story/detail/story-detail-toolbar/knowledge.tsx +0 -73
- package/src/components/story/detail/story-detail-toolbar/links.tsx +0 -73
- package/src/components/story/relations/attachments-panel.tsx +0 -179
- package/src/components/story/relations/knowledge-panel.tsx +0 -284
- package/src/components/story/relations/links-panel.tsx +0 -240
|
@@ -0,0 +1,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
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
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 { useTheme } from "next-themes";
|
|
20
|
+
import { FileViewer } from "@open-file-viewer/react";
|
|
21
|
+
import { imagePlugin, textPlugin, pdfPlugin, officePlugin, archivePlugin, audioPlugin, videoPlugin, epubPlugin } from "@open-file-viewer/core";
|
|
22
|
+
import type { PreviewToolbarCustomAction } from "@open-file-viewer/core";
|
|
23
|
+
import "@open-file-viewer/core/style.css";
|
|
24
|
+
import pdfWorkerSrc from "pdfjs-dist/build/pdf.worker.mjs?url";
|
|
25
|
+
|
|
26
|
+
export const fileViewerPlugins = [
|
|
27
|
+
imagePlugin(),
|
|
28
|
+
textPlugin(),
|
|
29
|
+
pdfPlugin({ workerSrc: pdfWorkerSrc }),
|
|
30
|
+
officePlugin(),
|
|
31
|
+
archivePlugin(),
|
|
32
|
+
audioPlugin(),
|
|
33
|
+
videoPlugin(),
|
|
34
|
+
epubPlugin(),
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
// 全屏工具栏图标(16×16 viewBox)
|
|
38
|
+
const FULLSCREEN_TOOLBAR_ICON =
|
|
39
|
+
'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M6 2.9H4.2A1.3 1.3 0 0 0 2.9 4.2V6"/><path d="M10 2.9h1.8a1.3 1.3 0 0 1 1.3 1.3V6"/><path d="M6 13.1H4.2a1.3 1.3 0 0 1-1.3-1.3V10"/><path d="M10 13.1h1.8a1.3 1.3 0 0 0 1.3-1.3V10"/></svg>';
|
|
40
|
+
const EXIT_FULLSCREEN_TOOLBAR_ICON =
|
|
41
|
+
'<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M2.9 6h1.8A1.3 1.3 0 0 0 6 4.7V2.9"/><path d="M13.1 6h-1.8A1.3 1.3 0 0 1 10 4.7V2.9"/><path d="M2.9 10h1.8A1.3 1.3 0 0 1 6 11.3v1.8"/><path d="M13.1 10h-1.8a1.3 1.3 0 0 0-1.3 1.3v1.8"/></svg>';
|
|
42
|
+
|
|
43
|
+
// 仅指 text/code 预览顶部的标题栏(文件名 · 类型 · 行数 + Wrap/Copy/Download),
|
|
44
|
+
// 音频等类型的标题不属于遮挡,不隐藏
|
|
45
|
+
const TITLE_SELECTORS = ".ofv-code-header";
|
|
46
|
+
|
|
47
|
+
export interface FilePreviewProps {
|
|
48
|
+
file: File;
|
|
49
|
+
fileName: string;
|
|
50
|
+
/** 工具栏按钮文案 */
|
|
51
|
+
fullscreenLabel?: string;
|
|
52
|
+
exitFullscreenLabel?: string;
|
|
53
|
+
/** 显示标题栏切换按钮 */
|
|
54
|
+
showTitleToggle?: boolean;
|
|
55
|
+
titleToggleLabel?: string;
|
|
56
|
+
titleToggleHideLabel?: string;
|
|
57
|
+
/** 全屏回调 */
|
|
58
|
+
onFullscreen?: () => void;
|
|
59
|
+
onExitFullscreen?: () => void;
|
|
60
|
+
/** 预览容器高度 */
|
|
61
|
+
height?: string;
|
|
62
|
+
className?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function FilePreview({
|
|
66
|
+
file,
|
|
67
|
+
fileName,
|
|
68
|
+
fullscreenLabel = "全屏预览",
|
|
69
|
+
exitFullscreenLabel = "退出全屏",
|
|
70
|
+
showTitleToggle = false,
|
|
71
|
+
titleToggleLabel = "显示标题",
|
|
72
|
+
titleToggleHideLabel = "隐藏标题",
|
|
73
|
+
onFullscreen,
|
|
74
|
+
onExitFullscreen,
|
|
75
|
+
height = "var(--attachment-preview-h, 16rem)",
|
|
76
|
+
className,
|
|
77
|
+
}: FilePreviewProps) {
|
|
78
|
+
const { resolvedTheme } = useTheme();
|
|
79
|
+
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
80
|
+
const [showTitle, setShowTitle] = React.useState(false);
|
|
81
|
+
const [isFullscreen, setIsFullscreen] = React.useState(false);
|
|
82
|
+
|
|
83
|
+
// 锁定传给 FileViewer 的 theme:wrapper 的重建依赖含 options.theme,
|
|
84
|
+
// 若随 resolvedTheme 变化会导致整个 viewer 销毁重建(预览重新加载)
|
|
85
|
+
const themePropRef = React.useRef<"light" | "dark" | null>(null);
|
|
86
|
+
if (themePropRef.current === null && (resolvedTheme === "light" || resolvedTheme === "dark")) {
|
|
87
|
+
themePropRef.current = resolvedTheme;
|
|
88
|
+
}
|
|
89
|
+
const themeProp = themePropRef.current ?? "light";
|
|
90
|
+
|
|
91
|
+
// 主题切换后仅同步 ofv-root 的 theme class(core 的 applyTheme 本质就是切 class),
|
|
92
|
+
// 不触发 viewer 重建;file/toolbar 变化导致 viewer 重建后也会重跑本 effect 修正主题
|
|
93
|
+
React.useEffect(() => {
|
|
94
|
+
if (resolvedTheme !== "light" && resolvedTheme !== "dark") return;
|
|
95
|
+
const root = containerRef.current?.querySelector(".ofv-root");
|
|
96
|
+
if (!root) return;
|
|
97
|
+
const expected = `ofv-theme-${resolvedTheme}`;
|
|
98
|
+
if (!root.classList.contains(expected)) {
|
|
99
|
+
root.classList.remove("ofv-theme-light", "ofv-theme-dark");
|
|
100
|
+
root.classList.add(expected);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const toolbarActions = React.useMemo<PreviewToolbarCustomAction[]>(() => {
|
|
105
|
+
const actions: PreviewToolbarCustomAction[] = [];
|
|
106
|
+
if (isFullscreen) {
|
|
107
|
+
actions.push({
|
|
108
|
+
id: "exit-fullscreen",
|
|
109
|
+
label: exitFullscreenLabel,
|
|
110
|
+
title: exitFullscreenLabel,
|
|
111
|
+
icon: EXIT_FULLSCREEN_TOOLBAR_ICON,
|
|
112
|
+
className: "ofv-toolbar-icon-button",
|
|
113
|
+
onClick: () => {
|
|
114
|
+
setIsFullscreen(false);
|
|
115
|
+
onExitFullscreen?.();
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
} else {
|
|
119
|
+
actions.push({
|
|
120
|
+
id: "fullscreen",
|
|
121
|
+
label: fullscreenLabel,
|
|
122
|
+
title: fullscreenLabel,
|
|
123
|
+
icon: FULLSCREEN_TOOLBAR_ICON,
|
|
124
|
+
className: "ofv-toolbar-icon-button",
|
|
125
|
+
onClick: () => {
|
|
126
|
+
setIsFullscreen(true);
|
|
127
|
+
onFullscreen?.();
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
if (showTitleToggle) {
|
|
132
|
+
actions.push({
|
|
133
|
+
id: "toggle-title",
|
|
134
|
+
label: showTitle ? titleToggleHideLabel : titleToggleLabel,
|
|
135
|
+
title: showTitle ? titleToggleHideLabel : titleToggleLabel,
|
|
136
|
+
icon: '<svg viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M1 3h14v2H1V3zm0 4h14v2H1V7zm0 4h14v2H1v-2z"/></svg>',
|
|
137
|
+
className: "ofv-toolbar-icon-button",
|
|
138
|
+
// 标题栏存在时才显示按钮:open-file-viewer 在插件渲染完成(setCommandSupport)
|
|
139
|
+
// 与翻页/缩放时会重新求值 hidden 函数
|
|
140
|
+
hidden: (ctx) => !ctx.viewport.querySelector(TITLE_SELECTORS),
|
|
141
|
+
onClick: () => setShowTitle((v) => !v),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return actions;
|
|
145
|
+
}, [isFullscreen, showTitle, showTitleToggle, fullscreenLabel, exitFullscreenLabel, titleToggleLabel, titleToggleHideLabel, onFullscreen, onExitFullscreen]);
|
|
146
|
+
|
|
147
|
+
// rotate 恒为 true:插件不支持时按钮保持 disabled 态,由 CSS(.ofv-preview-scope)隐藏
|
|
148
|
+
const toolbar = React.useMemo(() => {
|
|
149
|
+
const order = ["zoom-out", "zoom-in", "rotate-left", "rotate-right"];
|
|
150
|
+
order.push(isFullscreen ? "exit-fullscreen" : "fullscreen");
|
|
151
|
+
if (showTitleToggle) order.push("toggle-title");
|
|
152
|
+
return { zoom: true, rotate: true, order, actions: toolbarActions };
|
|
153
|
+
}, [toolbarActions, isFullscreen, showTitleToggle]);
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<div
|
|
157
|
+
ref={containerRef}
|
|
158
|
+
className={`ofv-preview-scope relative w-full overflow-hidden bg-background ${showTitleToggle && !showTitle ? "ofv-preview-title-hidden" : ""} ${className ?? ""}`}
|
|
159
|
+
style={{ height }}
|
|
160
|
+
>
|
|
161
|
+
<FileViewer
|
|
162
|
+
file={file}
|
|
163
|
+
fileName={fileName}
|
|
164
|
+
width="100%"
|
|
165
|
+
height="100%"
|
|
166
|
+
fit="contain"
|
|
167
|
+
toolbar={toolbar}
|
|
168
|
+
theme={themeProp}
|
|
169
|
+
plugins={fileViewerPlugins}
|
|
170
|
+
/>
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
}
|