plugin-ai-chat-file-preview 1.0.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.
- package/README.md +44 -0
- package/client.d.ts +2 -0
- package/client.js +1 -0
- package/dist/client/0ef9cbd6bccd7aff.js +30 -0
- package/dist/client/7e04271656f34237.js +10 -0
- package/dist/client/index.js +10 -0
- package/dist/externalVersion.js +17 -0
- package/dist/index.js +48 -0
- package/dist/locale/en-US.json +10 -0
- package/dist/locale/vi-VN.json +10 -0
- package/dist/locale/zh-CN.json +10 -0
- package/dist/server/index.js +42 -0
- package/dist/server/plugin.js +42 -0
- package/package.json +30 -0
- package/server.d.ts +2 -0
- package/server.js +1 -0
- package/src/client/ChatFilePreviewProvider.tsx +235 -0
- package/src/client/PreviewModal.tsx +634 -0
- package/src/client/SessionBlobCache.ts +96 -0
- package/src/client/index.tsx +19 -0
- package/src/client/locale.ts +17 -0
- package/src/index.ts +11 -0
- package/src/locale/en-US.json +10 -0
- package/src/locale/vi-VN.json +10 -0
- package/src/locale/zh-CN.json +10 -0
- package/src/server/index.ts +10 -0
- package/src/server/plugin.ts +18 -0
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
11
|
+
import { Modal, Button, Spin, Alert, message } from 'antd';
|
|
12
|
+
import { DownloadOutlined, DeleteOutlined } from '@ant-design/icons';
|
|
13
|
+
import { useAPIClient } from '@nocobase/client';
|
|
14
|
+
import { SessionBlobCache } from './SessionBlobCache';
|
|
15
|
+
import { useTranslation } from './locale';
|
|
16
|
+
|
|
17
|
+
// ─── Supported MIME / extension lists ──────────────────────────────
|
|
18
|
+
|
|
19
|
+
const PDF_MIME = ['application/pdf'];
|
|
20
|
+
const IMAGE_MIME = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml', 'image/bmp'];
|
|
21
|
+
const TEXT_MIME = [
|
|
22
|
+
'text/plain',
|
|
23
|
+
'text/csv',
|
|
24
|
+
'text/html',
|
|
25
|
+
'text/css',
|
|
26
|
+
'text/javascript',
|
|
27
|
+
'application/json',
|
|
28
|
+
'application/xml',
|
|
29
|
+
'text/xml',
|
|
30
|
+
'text/yaml',
|
|
31
|
+
'application/x-yaml',
|
|
32
|
+
];
|
|
33
|
+
const DOCX_MIME = ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'];
|
|
34
|
+
const XLSX_MIME = ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-excel'];
|
|
35
|
+
|
|
36
|
+
const PDF_EXT = ['pdf'];
|
|
37
|
+
const IMAGE_EXT = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp'];
|
|
38
|
+
const TEXT_EXT = ['txt', 'csv', 'html', 'css', 'js', 'json', 'xml', 'log', 'md', 'yaml', 'yml', 'xaml'];
|
|
39
|
+
const DOCX_EXT = ['docx'];
|
|
40
|
+
const XLSX_EXT = ['xlsx', 'xls'];
|
|
41
|
+
|
|
42
|
+
// ─── Utilities ─────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
export function getFileExt(file: any): string {
|
|
45
|
+
const value = typeof file === 'string' ? file : file?.extname || file?.name || file?.filename || file?.url || '';
|
|
46
|
+
const clean = value.split('?')[0].split('#')[0];
|
|
47
|
+
const idx = clean.lastIndexOf('.');
|
|
48
|
+
return idx !== -1
|
|
49
|
+
? clean
|
|
50
|
+
.slice(idx + 1)
|
|
51
|
+
.toLowerCase()
|
|
52
|
+
.replace(/^\./, '')
|
|
53
|
+
: '';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function resolveFileUrl(file: any): string {
|
|
57
|
+
const url = typeof file === 'string' ? file : file?.url;
|
|
58
|
+
if (!url) return '';
|
|
59
|
+
return url.startsWith('http://') || url.startsWith('https://') ? url : `${location.origin}/${url.replace(/^\//, '')}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function matchType(file: any, mimes: string[], exts: string[]): boolean {
|
|
63
|
+
if (file?.mimetype && mimes.includes(file.mimetype)) return true;
|
|
64
|
+
const ext = getFileExt(file);
|
|
65
|
+
return !!ext && exts.includes(ext);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const isPdfFile = (f: any) => matchType(f, PDF_MIME, PDF_EXT);
|
|
69
|
+
export const isImageFile = (f: any) => matchType(f, IMAGE_MIME, IMAGE_EXT);
|
|
70
|
+
export const isTextFile = (f: any) => matchType(f, TEXT_MIME, TEXT_EXT);
|
|
71
|
+
export const isDocxFile = (f: any) => matchType(f, DOCX_MIME, DOCX_EXT);
|
|
72
|
+
export const isXlsxFile = (f: any) => matchType(f, XLSX_MIME, XLSX_EXT);
|
|
73
|
+
export const isPreviewableFile = (f: any) => isPdfFile(f) || isImageFile(f) || isTextFile(f) || isDocxFile(f) || isXlsxFile(f);
|
|
74
|
+
|
|
75
|
+
export function isPreviewableUrl(url: string): boolean {
|
|
76
|
+
return isPreviewableFile({ url });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ─── Fetch helpers ─────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
async function fetchFileAsBlob(url: string, token: string): Promise<Blob> {
|
|
82
|
+
const headers: Record<string, string> = {};
|
|
83
|
+
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
84
|
+
const res = await fetch(url, { method: 'GET', headers, credentials: 'include' });
|
|
85
|
+
if (!res.ok) throw new Error(`Failed to fetch file: ${res.status}`);
|
|
86
|
+
return res.blob();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function fetchFileAsText(url: string, token: string): Promise<string> {
|
|
90
|
+
const headers: Record<string, string> = {};
|
|
91
|
+
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
92
|
+
const res = await fetch(url, { method: 'GET', headers, credentials: 'include' });
|
|
93
|
+
if (!res.ok) throw new Error(`Failed to fetch file: ${res.status}`);
|
|
94
|
+
return res.text();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ─── Download helper ───────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
async function downloadFileWithAuth(file: any, token: string) {
|
|
100
|
+
const url = resolveFileUrl(file);
|
|
101
|
+
if (!url) return;
|
|
102
|
+
const blob = await fetchFileAsBlob(url, token);
|
|
103
|
+
const name = file?.title && file?.extname ? `${file.title}${file.extname}` : file?.filename || file?.name || 'download';
|
|
104
|
+
const a = document.createElement('a');
|
|
105
|
+
const objectUrl = URL.createObjectURL(blob);
|
|
106
|
+
a.href = objectUrl;
|
|
107
|
+
a.download = name;
|
|
108
|
+
document.body.appendChild(a);
|
|
109
|
+
a.click();
|
|
110
|
+
document.body.removeChild(a);
|
|
111
|
+
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ─── Shared components ─────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
function LoadingIndicator({ msg }: { msg: string }) {
|
|
117
|
+
return (
|
|
118
|
+
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%', width: '100%' }}>
|
|
119
|
+
<Spin tip={msg} />
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function ErrorMsg({ msg }: { msg: string }) {
|
|
125
|
+
return <div style={{ padding: 20, textAlign: 'center', color: '#ff4d4f' }}>{msg}</div>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ─── Hook: load blob with session cache ────────────────────────────
|
|
129
|
+
|
|
130
|
+
function useCachedBlobUrl(file: any, token: string, sessionId: string) {
|
|
131
|
+
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
|
132
|
+
const [loading, setLoading] = useState(true);
|
|
133
|
+
const [error, setError] = useState<string | null>(null);
|
|
134
|
+
const blobUrlRef = useRef<string | null>(null);
|
|
135
|
+
|
|
136
|
+
const fileId = file?.id || file?.uid || '';
|
|
137
|
+
const fileUrl = resolveFileUrl(file);
|
|
138
|
+
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
let cancelled = false;
|
|
141
|
+
if (!fileUrl) {
|
|
142
|
+
setLoading(false);
|
|
143
|
+
setError('No file URL');
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
setLoading(true);
|
|
148
|
+
setError(null);
|
|
149
|
+
|
|
150
|
+
(async () => {
|
|
151
|
+
try {
|
|
152
|
+
// Check IndexedDB cache first
|
|
153
|
+
if (sessionId && fileId) {
|
|
154
|
+
const cached = await SessionBlobCache.get(sessionId, String(fileId));
|
|
155
|
+
if (cached && !cancelled) {
|
|
156
|
+
const url = URL.createObjectURL(cached);
|
|
157
|
+
blobUrlRef.current = url;
|
|
158
|
+
setBlobUrl(url);
|
|
159
|
+
setLoading(false);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Fetch from server
|
|
165
|
+
const blob = await fetchFileAsBlob(fileUrl, token);
|
|
166
|
+
if (cancelled) return;
|
|
167
|
+
|
|
168
|
+
// Cache to IndexedDB
|
|
169
|
+
if (sessionId && fileId) {
|
|
170
|
+
SessionBlobCache.put(sessionId, String(fileId), blob).catch(() => {});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const url = URL.createObjectURL(blob);
|
|
174
|
+
blobUrlRef.current = url;
|
|
175
|
+
setBlobUrl(url);
|
|
176
|
+
setLoading(false);
|
|
177
|
+
} catch (err: any) {
|
|
178
|
+
if (cancelled) return;
|
|
179
|
+
setError(err.message || 'Failed to load');
|
|
180
|
+
setLoading(false);
|
|
181
|
+
}
|
|
182
|
+
})();
|
|
183
|
+
|
|
184
|
+
return () => {
|
|
185
|
+
cancelled = true;
|
|
186
|
+
if (blobUrlRef.current) {
|
|
187
|
+
URL.revokeObjectURL(blobUrlRef.current);
|
|
188
|
+
blobUrlRef.current = null;
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
}, [fileUrl, token, sessionId, fileId]);
|
|
192
|
+
|
|
193
|
+
return { blobUrl, loading, error };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function useCachedTextContent(file: any, token: string, sessionId: string) {
|
|
197
|
+
const [text, setText] = useState<string | null>(null);
|
|
198
|
+
const [loading, setLoading] = useState(true);
|
|
199
|
+
const [error, setError] = useState<string | null>(null);
|
|
200
|
+
|
|
201
|
+
const fileId = file?.id || file?.uid || '';
|
|
202
|
+
const fileUrl = resolveFileUrl(file);
|
|
203
|
+
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
let cancelled = false;
|
|
206
|
+
if (!fileUrl) {
|
|
207
|
+
setLoading(false);
|
|
208
|
+
setError('No file URL');
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
setLoading(true);
|
|
213
|
+
setError(null);
|
|
214
|
+
|
|
215
|
+
(async () => {
|
|
216
|
+
try {
|
|
217
|
+
// Check cache
|
|
218
|
+
if (sessionId && fileId) {
|
|
219
|
+
const cached = await SessionBlobCache.get(sessionId, String(fileId));
|
|
220
|
+
if (cached && !cancelled) {
|
|
221
|
+
const content = await cached.text();
|
|
222
|
+
setText(content);
|
|
223
|
+
setLoading(false);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const content = await fetchFileAsText(fileUrl, token);
|
|
229
|
+
if (cancelled) return;
|
|
230
|
+
|
|
231
|
+
// Cache as blob
|
|
232
|
+
if (sessionId && fileId) {
|
|
233
|
+
const blob = new Blob([content], { type: 'text/plain' });
|
|
234
|
+
SessionBlobCache.put(sessionId, String(fileId), blob).catch(() => {});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
setText(content);
|
|
238
|
+
setLoading(false);
|
|
239
|
+
} catch (err: any) {
|
|
240
|
+
if (cancelled) return;
|
|
241
|
+
setError(err.message || 'Failed to load');
|
|
242
|
+
setLoading(false);
|
|
243
|
+
}
|
|
244
|
+
})();
|
|
245
|
+
|
|
246
|
+
return () => {
|
|
247
|
+
cancelled = true;
|
|
248
|
+
};
|
|
249
|
+
}, [fileUrl, token, sessionId, fileId]);
|
|
250
|
+
|
|
251
|
+
return { text, loading, error };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ─── Inline Previewers ─────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
function PdfPreviewer({ file, sessionId }: { file: any; sessionId: string }) {
|
|
257
|
+
const apiClient = useAPIClient();
|
|
258
|
+
const { t } = useTranslation();
|
|
259
|
+
const token = apiClient.auth?.token || '';
|
|
260
|
+
const { blobUrl, loading, error } = useCachedBlobUrl(file, token, sessionId);
|
|
261
|
+
|
|
262
|
+
if (loading) return <LoadingIndicator msg={t('Loading preview...')} />;
|
|
263
|
+
if (error || !blobUrl) return <ErrorMsg msg={t('Failed to load file preview')} />;
|
|
264
|
+
return <iframe src={blobUrl} width="100%" height="100%" style={{ border: 'none' }} />;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function ImagePreviewer({ file, sessionId }: { file: any; sessionId: string }) {
|
|
268
|
+
const apiClient = useAPIClient();
|
|
269
|
+
const { t } = useTranslation();
|
|
270
|
+
const token = apiClient.auth?.token || '';
|
|
271
|
+
const { blobUrl, loading, error } = useCachedBlobUrl(file, token, sessionId);
|
|
272
|
+
|
|
273
|
+
if (loading) return <LoadingIndicator msg={t('Loading preview...')} />;
|
|
274
|
+
if (error || !blobUrl) return <ErrorMsg msg={t('Failed to load file preview')} />;
|
|
275
|
+
return <img src={blobUrl} style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }} alt={file?.filename || ''} />;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function TextPreviewer({ file, sessionId }: { file: any; sessionId: string }) {
|
|
279
|
+
const apiClient = useAPIClient();
|
|
280
|
+
const { t } = useTranslation();
|
|
281
|
+
const token = apiClient.auth?.token || '';
|
|
282
|
+
const { text, loading, error } = useCachedTextContent(file, token, sessionId);
|
|
283
|
+
|
|
284
|
+
if (loading) return <LoadingIndicator msg={t('Loading preview...')} />;
|
|
285
|
+
if (error || text === null) return <ErrorMsg msg={t('Failed to load file preview')} />;
|
|
286
|
+
return (
|
|
287
|
+
<pre
|
|
288
|
+
style={{
|
|
289
|
+
width: '100%',
|
|
290
|
+
height: '100%',
|
|
291
|
+
overflow: 'auto',
|
|
292
|
+
padding: 16,
|
|
293
|
+
margin: 0,
|
|
294
|
+
fontSize: 13,
|
|
295
|
+
lineHeight: 1.6,
|
|
296
|
+
whiteSpace: 'pre-wrap',
|
|
297
|
+
wordWrap: 'break-word',
|
|
298
|
+
background: '#f5f5f5',
|
|
299
|
+
border: 'none',
|
|
300
|
+
}}
|
|
301
|
+
>
|
|
302
|
+
{text}
|
|
303
|
+
</pre>
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function DocxPreviewer({ file, sessionId }: { file: any; sessionId: string }) {
|
|
308
|
+
const apiClient = useAPIClient();
|
|
309
|
+
const { t } = useTranslation();
|
|
310
|
+
const token = apiClient.auth?.token || '';
|
|
311
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
312
|
+
const [loading, setLoading] = useState(true);
|
|
313
|
+
const [error, setError] = useState<string | null>(null);
|
|
314
|
+
const fileUrl = resolveFileUrl(file);
|
|
315
|
+
const fileId = file?.id || file?.uid || '';
|
|
316
|
+
|
|
317
|
+
useEffect(() => {
|
|
318
|
+
let cancelled = false;
|
|
319
|
+
if (!fileUrl || !containerRef.current) {
|
|
320
|
+
setLoading(false);
|
|
321
|
+
setError('No file URL');
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
setLoading(true);
|
|
326
|
+
setError(null);
|
|
327
|
+
|
|
328
|
+
(async () => {
|
|
329
|
+
try {
|
|
330
|
+
let blob: Blob;
|
|
331
|
+
|
|
332
|
+
if (sessionId && fileId) {
|
|
333
|
+
const cached = await SessionBlobCache.get(sessionId, String(fileId));
|
|
334
|
+
if (cached && !cancelled) {
|
|
335
|
+
blob = cached;
|
|
336
|
+
} else {
|
|
337
|
+
blob = await fetchFileAsBlob(fileUrl, token);
|
|
338
|
+
if (cancelled) return;
|
|
339
|
+
SessionBlobCache.put(sessionId, String(fileId), blob).catch(() => {});
|
|
340
|
+
}
|
|
341
|
+
} else {
|
|
342
|
+
blob = await fetchFileAsBlob(fileUrl, token);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (cancelled || !containerRef.current) return;
|
|
346
|
+
const docxPreview = await import('docx-preview');
|
|
347
|
+
if (cancelled || !containerRef.current) return;
|
|
348
|
+
containerRef.current.innerHTML = '';
|
|
349
|
+
await docxPreview.renderAsync(blob, containerRef.current, undefined, {
|
|
350
|
+
className: 'docx-preview-wrapper',
|
|
351
|
+
inWrapper: true,
|
|
352
|
+
ignoreWidth: false,
|
|
353
|
+
ignoreHeight: false,
|
|
354
|
+
ignoreFonts: false,
|
|
355
|
+
breakPages: true,
|
|
356
|
+
ignoreLastRenderedPageBreak: true,
|
|
357
|
+
experimental: false,
|
|
358
|
+
trimXmlDeclaration: true,
|
|
359
|
+
useBase64URL: true,
|
|
360
|
+
renderHeaders: true,
|
|
361
|
+
renderFooters: true,
|
|
362
|
+
renderFootnotes: true,
|
|
363
|
+
renderEndnotes: true,
|
|
364
|
+
});
|
|
365
|
+
setLoading(false);
|
|
366
|
+
} catch (err: any) {
|
|
367
|
+
if (cancelled) return;
|
|
368
|
+
setError(err.message || 'Failed to render DOCX');
|
|
369
|
+
setLoading(false);
|
|
370
|
+
}
|
|
371
|
+
})();
|
|
372
|
+
|
|
373
|
+
return () => {
|
|
374
|
+
cancelled = true;
|
|
375
|
+
};
|
|
376
|
+
}, [fileUrl, token, sessionId, fileId]);
|
|
377
|
+
|
|
378
|
+
return (
|
|
379
|
+
<div style={{ width: '100%', height: '100%', position: 'relative' }}>
|
|
380
|
+
{loading && <LoadingIndicator msg={t('Loading preview...')} />}
|
|
381
|
+
{error && <ErrorMsg msg={t('Failed to load file preview')} />}
|
|
382
|
+
<div
|
|
383
|
+
ref={containerRef}
|
|
384
|
+
style={{
|
|
385
|
+
width: '100%',
|
|
386
|
+
height: '100%',
|
|
387
|
+
overflow: 'auto',
|
|
388
|
+
display: loading || error ? 'none' : 'block',
|
|
389
|
+
}}
|
|
390
|
+
/>
|
|
391
|
+
</div>
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function XlsxPreviewer({ file, sessionId }: { file: any; sessionId: string }) {
|
|
396
|
+
const apiClient = useAPIClient();
|
|
397
|
+
const { t } = useTranslation();
|
|
398
|
+
const token = apiClient.auth?.token || '';
|
|
399
|
+
const [loading, setLoading] = useState(true);
|
|
400
|
+
const [error, setError] = useState<string | null>(null);
|
|
401
|
+
const [sheetNames, setSheetNames] = useState<string[]>([]);
|
|
402
|
+
const [activeSheet, setActiveSheet] = useState('');
|
|
403
|
+
const [sheetsHtml, setSheetsHtml] = useState<Record<string, string>>({});
|
|
404
|
+
const fileUrl = resolveFileUrl(file);
|
|
405
|
+
const fileId = file?.id || file?.uid || '';
|
|
406
|
+
|
|
407
|
+
useEffect(() => {
|
|
408
|
+
let cancelled = false;
|
|
409
|
+
if (!fileUrl) {
|
|
410
|
+
setLoading(false);
|
|
411
|
+
setError('No file URL');
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
setLoading(true);
|
|
416
|
+
setError(null);
|
|
417
|
+
|
|
418
|
+
(async () => {
|
|
419
|
+
try {
|
|
420
|
+
let blob: Blob;
|
|
421
|
+
|
|
422
|
+
if (sessionId && fileId) {
|
|
423
|
+
const cached = await SessionBlobCache.get(sessionId, String(fileId));
|
|
424
|
+
if (cached && !cancelled) {
|
|
425
|
+
blob = cached;
|
|
426
|
+
} else {
|
|
427
|
+
blob = await fetchFileAsBlob(fileUrl, token);
|
|
428
|
+
if (cancelled) return;
|
|
429
|
+
SessionBlobCache.put(sessionId, String(fileId), blob).catch(() => {});
|
|
430
|
+
}
|
|
431
|
+
} else {
|
|
432
|
+
blob = await fetchFileAsBlob(fileUrl, token);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (cancelled) return;
|
|
436
|
+
const XLSX = await import('xlsx');
|
|
437
|
+
if (cancelled) return;
|
|
438
|
+
const arrayBuffer = await blob.arrayBuffer();
|
|
439
|
+
if (cancelled) return;
|
|
440
|
+
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
|
|
441
|
+
const names = workbook.SheetNames as string[];
|
|
442
|
+
const htmlMap: Record<string, string> = {};
|
|
443
|
+
for (const name of names) {
|
|
444
|
+
htmlMap[name] = XLSX.utils.sheet_to_html(workbook.Sheets[name], { id: 'xlsx-preview-table' });
|
|
445
|
+
}
|
|
446
|
+
if (cancelled) return;
|
|
447
|
+
setSheetNames(names);
|
|
448
|
+
setActiveSheet(names[0] || '');
|
|
449
|
+
setSheetsHtml(htmlMap);
|
|
450
|
+
setLoading(false);
|
|
451
|
+
} catch (err: any) {
|
|
452
|
+
if (cancelled) return;
|
|
453
|
+
setError(err.message || 'Failed to render XLSX');
|
|
454
|
+
setLoading(false);
|
|
455
|
+
}
|
|
456
|
+
})();
|
|
457
|
+
|
|
458
|
+
return () => {
|
|
459
|
+
cancelled = true;
|
|
460
|
+
};
|
|
461
|
+
}, [fileUrl, token, sessionId, fileId]);
|
|
462
|
+
|
|
463
|
+
return (
|
|
464
|
+
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
|
|
465
|
+
{loading && <LoadingIndicator msg={t('Loading preview...')} />}
|
|
466
|
+
{error && <ErrorMsg msg={t('Failed to load file preview')} />}
|
|
467
|
+
{!loading && !error && (
|
|
468
|
+
<>
|
|
469
|
+
{sheetNames.length > 1 && (
|
|
470
|
+
<div
|
|
471
|
+
style={{
|
|
472
|
+
display: 'flex',
|
|
473
|
+
gap: 0,
|
|
474
|
+
borderBottom: '1px solid #e8e8e8',
|
|
475
|
+
background: '#fafafa',
|
|
476
|
+
padding: '0 8px',
|
|
477
|
+
flexShrink: 0,
|
|
478
|
+
overflowX: 'auto',
|
|
479
|
+
}}
|
|
480
|
+
>
|
|
481
|
+
{sheetNames.map((name) => (
|
|
482
|
+
<button
|
|
483
|
+
key={name}
|
|
484
|
+
onClick={() => setActiveSheet(name)}
|
|
485
|
+
style={{
|
|
486
|
+
padding: '8px 16px',
|
|
487
|
+
border: 'none',
|
|
488
|
+
borderBottom: activeSheet === name ? '2px solid #1890ff' : '2px solid transparent',
|
|
489
|
+
background: activeSheet === name ? '#fff' : 'transparent',
|
|
490
|
+
color: activeSheet === name ? '#1890ff' : '#666',
|
|
491
|
+
fontWeight: activeSheet === name ? 600 : 400,
|
|
492
|
+
cursor: 'pointer',
|
|
493
|
+
fontSize: 13,
|
|
494
|
+
whiteSpace: 'nowrap',
|
|
495
|
+
transition: 'all 0.2s',
|
|
496
|
+
}}
|
|
497
|
+
>
|
|
498
|
+
{name}
|
|
499
|
+
</button>
|
|
500
|
+
))}
|
|
501
|
+
</div>
|
|
502
|
+
)}
|
|
503
|
+
<div
|
|
504
|
+
style={{ flex: 1, overflow: 'auto', padding: 0 }}
|
|
505
|
+
dangerouslySetInnerHTML={{ __html: sheetsHtml[activeSheet] || '' }}
|
|
506
|
+
/>
|
|
507
|
+
<style>{`
|
|
508
|
+
#xlsx-preview-table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
|
509
|
+
#xlsx-preview-table td, #xlsx-preview-table th {
|
|
510
|
+
border: 1px solid #e8e8e8; padding: 6px 10px; text-align: left;
|
|
511
|
+
max-width: 33vw; white-space: normal; word-break: break-word;
|
|
512
|
+
}
|
|
513
|
+
#xlsx-preview-table tr:first-child td, #xlsx-preview-table tr:first-child th {
|
|
514
|
+
background: #fafafa; font-weight: 600; position: sticky; top: 0; z-index: 1;
|
|
515
|
+
}
|
|
516
|
+
#xlsx-preview-table tr:nth-child(even) { background: #fafafa; }
|
|
517
|
+
#xlsx-preview-table tr:hover { background: #f0f7ff; }
|
|
518
|
+
`}</style>
|
|
519
|
+
</>
|
|
520
|
+
)}
|
|
521
|
+
</div>
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ─── Main Preview Modal ────────────────────────────────────────────
|
|
526
|
+
|
|
527
|
+
export interface PreviewFile {
|
|
528
|
+
id?: string | number;
|
|
529
|
+
uid?: string;
|
|
530
|
+
url?: string;
|
|
531
|
+
filename?: string;
|
|
532
|
+
name?: string;
|
|
533
|
+
title?: string;
|
|
534
|
+
extname?: string;
|
|
535
|
+
mimetype?: string;
|
|
536
|
+
size?: number;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
interface PreviewModalProps {
|
|
540
|
+
open: boolean;
|
|
541
|
+
file: PreviewFile | null;
|
|
542
|
+
sessionId: string;
|
|
543
|
+
onClose: () => void;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export const PreviewModal: React.FC<PreviewModalProps> = ({ open, file, sessionId, onClose }) => {
|
|
547
|
+
const apiClient = useAPIClient();
|
|
548
|
+
const { t } = useTranslation();
|
|
549
|
+
const [downloading, setDownloading] = useState(false);
|
|
550
|
+
|
|
551
|
+
const fileId = file?.id || file?.uid || '';
|
|
552
|
+
|
|
553
|
+
const onDownload = useCallback(async () => {
|
|
554
|
+
if (!file) return;
|
|
555
|
+
const token = apiClient.auth?.token || '';
|
|
556
|
+
setDownloading(true);
|
|
557
|
+
try {
|
|
558
|
+
await downloadFileWithAuth(file, token);
|
|
559
|
+
} catch {
|
|
560
|
+
message.error(t('Failed to download file'));
|
|
561
|
+
} finally {
|
|
562
|
+
setDownloading(false);
|
|
563
|
+
}
|
|
564
|
+
}, [file, apiClient, t]);
|
|
565
|
+
|
|
566
|
+
const onClearCache = useCallback(async () => {
|
|
567
|
+
if (!sessionId || !fileId) return;
|
|
568
|
+
await SessionBlobCache.delete(sessionId, String(fileId));
|
|
569
|
+
message.success(t('Cache cleared'));
|
|
570
|
+
}, [sessionId, fileId, t]);
|
|
571
|
+
|
|
572
|
+
const PreviewerComponent = useMemo(() => {
|
|
573
|
+
if (!file) return null;
|
|
574
|
+
if (isPdfFile(file)) return PdfPreviewer;
|
|
575
|
+
if (isImageFile(file)) return ImagePreviewer;
|
|
576
|
+
if (isTextFile(file)) return TextPreviewer;
|
|
577
|
+
if (isDocxFile(file)) return DocxPreviewer;
|
|
578
|
+
if (isXlsxFile(file)) return XlsxPreviewer;
|
|
579
|
+
return null;
|
|
580
|
+
}, [file]);
|
|
581
|
+
|
|
582
|
+
const canPreview = PreviewerComponent != null;
|
|
583
|
+
const title = file?.title && file?.extname ? `${file.title}${file.extname}` : file?.filename || file?.name || 'File';
|
|
584
|
+
|
|
585
|
+
return (
|
|
586
|
+
<Modal
|
|
587
|
+
open={open}
|
|
588
|
+
title={title}
|
|
589
|
+
onCancel={onClose}
|
|
590
|
+
destroyOnClose
|
|
591
|
+
footer={[
|
|
592
|
+
sessionId && fileId ? (
|
|
593
|
+
<Button key="clear-cache" icon={<DeleteOutlined />} onClick={onClearCache}>
|
|
594
|
+
{t('Clear cache')}
|
|
595
|
+
</Button>
|
|
596
|
+
) : null,
|
|
597
|
+
<Button key="download" icon={<DownloadOutlined />} onClick={onDownload} loading={downloading}>
|
|
598
|
+
{t('Download')}
|
|
599
|
+
</Button>,
|
|
600
|
+
<Button key="close" onClick={onClose}>
|
|
601
|
+
{t('Close')}
|
|
602
|
+
</Button>,
|
|
603
|
+
].filter(Boolean)}
|
|
604
|
+
width={canPreview ? '85vw' : 520}
|
|
605
|
+
centered
|
|
606
|
+
>
|
|
607
|
+
<div
|
|
608
|
+
style={{
|
|
609
|
+
maxWidth: '100%',
|
|
610
|
+
maxHeight: canPreview ? 'calc(100vh - 256px)' : 'auto',
|
|
611
|
+
height: canPreview ? '70vh' : 'auto',
|
|
612
|
+
width: '100%',
|
|
613
|
+
background: 'white',
|
|
614
|
+
display: 'flex',
|
|
615
|
+
flexDirection: 'column',
|
|
616
|
+
justifyContent: 'center',
|
|
617
|
+
alignItems: 'center',
|
|
618
|
+
overflowY: 'auto',
|
|
619
|
+
}}
|
|
620
|
+
>
|
|
621
|
+
{canPreview && file ? (
|
|
622
|
+
<PreviewerComponent file={file} sessionId={sessionId} />
|
|
623
|
+
) : (
|
|
624
|
+
<Alert
|
|
625
|
+
type="info"
|
|
626
|
+
style={{ width: '100%' }}
|
|
627
|
+
description={t('This file type cannot be previewed. Click Download to save the file.')}
|
|
628
|
+
showIcon
|
|
629
|
+
/>
|
|
630
|
+
)}
|
|
631
|
+
</div>
|
|
632
|
+
</Modal>
|
|
633
|
+
);
|
|
634
|
+
};
|