openxiangda-cli 2.0.0-alpha.58 → 2.0.0-alpha.59

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.
@@ -1,9 +1,5 @@
1
1
  import {
2
- DownloadOutlined,
3
- EyeOutlined,
4
- PaperClipOutlined,
5
2
  PictureOutlined,
6
- PlusOutlined,
7
3
  ReloadOutlined,
8
4
  UploadOutlined,
9
5
  } from '@ant-design/icons';
@@ -17,28 +13,24 @@ import {
17
13
  Input,
18
14
  InputNumber,
19
15
  Select,
20
- Space,
21
16
  Switch,
22
17
  Tag,
23
18
  Timeline,
24
19
  Typography,
25
20
  Upload,
26
- type UploadFile,
27
21
  } from 'antd';
28
22
  import dayjs from 'dayjs';
29
- import { useEffect, useState } from 'react';
23
+ import { useEffect, useRef, useState } from 'react';
30
24
  import type { DataAuditEntry, DataFileRef } from 'openxiangda-contracts/browser';
31
25
  import {
32
26
  AuthoritativeSelector,
33
27
  ResolvedValueText,
34
28
  } from './AuthoritativeSelector';
29
+ import { AttachmentFileList } from './components/platform-fields/AttachmentFileList';
35
30
  import { PlatformDirectoryPicker } from './components/platform-fields/PlatformDirectoryPicker';
36
31
  import { instrumentFields, sectionTitles, type InstrumentField } from './fields';
37
32
  import type { InstrumentRecord } from './instrument';
38
- import {
39
- instrumentDataApi,
40
- instrumentFileContentUrl,
41
- } from './platform-client';
33
+ import { instrumentDataApi } from './platform-client';
42
34
  import { useRuntime } from './runtime';
43
35
 
44
36
  type Values = Record<string, unknown>;
@@ -335,18 +327,13 @@ function FileValue({
335
327
  const [uploading, setUploading] = useState(false);
336
328
  const [uploadError, setUploadError] = useState('');
337
329
  const refs = Array.isArray(value) ? value : value ? [value] : [];
338
- const files: UploadFile[] = refs.map(file => ({
339
- uid: file.id,
340
- name: file.name,
341
- size: file.size,
342
- type: file.contentType,
343
- status: 'done',
344
- url: instrumentFileContentUrl(file.id),
345
- }));
330
+ const refsRef = useRef(refs);
331
+ useEffect(() => {
332
+ refsRef.current = refs;
333
+ }, [refs]);
346
334
  const uploadProps = {
347
335
  accept,
348
336
  disabled,
349
- fileList: files,
350
337
  maxCount,
351
338
  multiple,
352
339
  customRequest: async (options: {
@@ -365,7 +352,9 @@ function FileValue({
365
352
  recordId
366
353
  );
367
354
  options.onProgress?.({ percent: 100 });
368
- onChange?.(multiple ? [...refs, uploaded] : uploaded);
355
+ const next = multiple ? [...refsRef.current, uploaded] : uploaded;
356
+ refsRef.current = Array.isArray(next) ? next : [next];
357
+ onChange?.(next);
369
358
  options.onSuccess?.(uploaded);
370
359
  } catch (error) {
371
360
  const failure =
@@ -376,26 +365,6 @@ function FileValue({
376
365
  setUploading(false);
377
366
  }
378
367
  },
379
- onDownload: (file: UploadFile) => {
380
- window.open(
381
- instrumentFileContentUrl(file.uid),
382
- '_blank',
383
- 'noopener,noreferrer'
384
- );
385
- },
386
- onPreview: (file: UploadFile) => {
387
- window.open(
388
- instrumentFileContentUrl(file.uid),
389
- '_blank',
390
- 'noopener,noreferrer'
391
- );
392
- },
393
- onRemove: (file: UploadFile) => {
394
- if (disabled) return false;
395
- const next = refs.filter(item => item.id !== file.uid);
396
- onChange?.(multiple ? next : undefined);
397
- return true;
398
- },
399
368
  showUploadList: false,
400
369
  };
401
370
  return (
@@ -437,46 +406,16 @@ function FileValue({
437
406
  </Button>
438
407
  </div>
439
408
  )}
440
- {files.length ? (
441
- <div className="oxa-file-list">
442
- {files.map(file => (
443
- <div className="oxa-file-item" key={file.uid}>
444
- <span className="oxa-file-kind">
445
- {fieldCode === 'image' ? <PictureOutlined /> : <PaperClipOutlined />}
446
- </span>
447
- <span className="oxa-file-meta">
448
- <strong>{file.name}</strong>
449
- <small>{formatFileSize(file.size || 0)} · 已上传</small>
450
- </span>
451
- <Space size={2}>
452
- <Button
453
- aria-label={`预览${file.name}`}
454
- icon={<EyeOutlined />}
455
- onClick={() => uploadProps.onPreview(file)}
456
- size="small"
457
- type="text"
458
- />
459
- <Button
460
- aria-label={`下载${file.name}`}
461
- icon={<DownloadOutlined />}
462
- onClick={() => uploadProps.onDownload(file)}
463
- size="small"
464
- type="text"
465
- />
466
- {!disabled && (
467
- <Button
468
- aria-label={`移除${file.name}`}
469
- danger
470
- icon={<PlusOutlined rotate={45} />}
471
- onClick={() => uploadProps.onRemove(file)}
472
- size="small"
473
- type="text"
474
- />
475
- )}
476
- </Space>
477
- </div>
478
- ))}
479
- </div>
409
+ {refs.length ? (
410
+ <AttachmentFileList
411
+ files={refs}
412
+ onRemove={file => {
413
+ const next = refs.filter(item => item.id !== file.id);
414
+ refsRef.current = next;
415
+ onChange?.(multiple ? next : undefined);
416
+ }}
417
+ removable={!disabled}
418
+ />
480
419
  ) : (
481
420
  <div className="oxa-file-empty">尚未上传文件</div>
482
421
  )}
@@ -575,49 +514,7 @@ function Detail({
575
514
 
576
515
  function AttachmentReadOnly({ values }: { values: DataFileRef[] }) {
577
516
  if (!values.length) return <>-</>;
578
- return (
579
- <div className="oxa-attachment-readonly">
580
- {values.map(file => (
581
- <div className="oxa-attachment-row" key={file.id}>
582
- <PaperClipOutlined />
583
- <span className="oxa-attachment-name">{file.name}</span>
584
- <span className="oxa-muted">{formatFileSize(file.size)}</span>
585
- <Space size={4}>
586
- {file.contentType.startsWith('image/') && (
587
- <Button
588
- aria-label={`预览${file.name}`}
589
- icon={<EyeOutlined />}
590
- onClick={() =>
591
- window.open(
592
- instrumentFileContentUrl(file.id),
593
- '_blank',
594
- 'noopener,noreferrer'
595
- )
596
- }
597
- size="small"
598
- type="text"
599
- />
600
- )}
601
- <Button
602
- aria-label={`下载${file.name}`}
603
- icon={<DownloadOutlined />}
604
- onClick={() =>
605
- window.open(
606
- instrumentFileContentUrl(file.id),
607
- '_blank',
608
- 'noopener,noreferrer'
609
- )
610
- }
611
- size="small"
612
- type="text"
613
- >
614
- 下载
615
- </Button>
616
- </Space>
617
- </div>
618
- ))}
619
- </div>
620
- );
517
+ return <AttachmentFileList files={values} />;
621
518
  }
622
519
 
623
520
  function displayValue(field: InstrumentField, value: unknown) {
@@ -676,9 +573,3 @@ function auditLabel(operation: DataAuditEntry['operation']) {
676
573
  function formatDateTime(value?: string) {
677
574
  return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '-';
678
575
  }
679
-
680
- function formatFileSize(value: number) {
681
- if (value < 1024) return `${value} B`;
682
- if (value < 1024 * 1024) return `${Math.ceil(value / 1024)} KB`;
683
- return `${(value / 1024 / 1024).toFixed(1)} MB`;
684
- }
@@ -0,0 +1,226 @@
1
+ import {
2
+ CloseOutlined,
3
+ DownloadOutlined,
4
+ EyeOutlined,
5
+ FileExcelOutlined,
6
+ FileImageOutlined,
7
+ FilePdfOutlined,
8
+ FileTextOutlined,
9
+ FileWordOutlined,
10
+ PaperClipOutlined,
11
+ } from '@ant-design/icons';
12
+ import { App, Button, Image, Space } from 'antd';
13
+ import type { DataFileRef } from 'openxiangda-contracts/browser';
14
+ import { useEffect, useMemo, useState } from 'react';
15
+ import {
16
+ dataFileContentUrl,
17
+ fetchDataFileBlob,
18
+ loadDataFilePreview,
19
+ } from '../../platform-client';
20
+ import { attachmentPreviewPath } from '../../runtime-meta';
21
+
22
+ interface PreviewImage {
23
+ id: string;
24
+ name: string;
25
+ src: string;
26
+ }
27
+
28
+ const IMAGE_EXTENSIONS = new Set([
29
+ 'avif',
30
+ 'bmp',
31
+ 'gif',
32
+ 'ico',
33
+ 'jpeg',
34
+ 'jpg',
35
+ 'png',
36
+ 'svg',
37
+ 'webp',
38
+ ]);
39
+
40
+ function extension(name: string) {
41
+ return name.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] || '';
42
+ }
43
+
44
+ function isImage(file: DataFileRef) {
45
+ return (
46
+ file.contentType.toLowerCase().startsWith('image/') ||
47
+ IMAGE_EXTENSIONS.has(extension(file.name))
48
+ );
49
+ }
50
+
51
+ function fileIcon(file: DataFileRef) {
52
+ const suffix = extension(file.name);
53
+ if (isImage(file)) return <FileImageOutlined />;
54
+ if (suffix === 'pdf') return <FilePdfOutlined />;
55
+ if (suffix === 'doc' || suffix === 'docx') return <FileWordOutlined />;
56
+ if (suffix === 'xls' || suffix === 'xlsx') return <FileExcelOutlined />;
57
+ if (suffix === 'txt' || suffix === 'csv') return <FileTextOutlined />;
58
+ return <PaperClipOutlined />;
59
+ }
60
+
61
+ export function formatManagedFileSize(value: number) {
62
+ if (value < 1024) return `${value} B`;
63
+ if (value < 1024 * 1024) return `${Math.ceil(value / 1024)} KB`;
64
+ return `${(value / 1024 / 1024).toFixed(1)} MB`;
65
+ }
66
+
67
+ function revokeImages(images: PreviewImage[]) {
68
+ images.forEach(image => URL.revokeObjectURL(image.src));
69
+ }
70
+
71
+ function openIsolatedWindow(url: string) {
72
+ const target = window.open('about:blank', '_blank');
73
+ if (!target) return false;
74
+ target.opener = null;
75
+ target.location.replace(url);
76
+ return true;
77
+ }
78
+
79
+ export function AttachmentFileList({
80
+ files,
81
+ resourceCode = 'instruments',
82
+ removable = false,
83
+ onRemove,
84
+ }: {
85
+ files: DataFileRef[];
86
+ resourceCode?: string;
87
+ removable?: boolean;
88
+ onRemove?: (file: DataFileRef) => void;
89
+ }) {
90
+ const { message } = App.useApp();
91
+ const [openingId, setOpeningId] = useState('');
92
+ const [images, setImages] = useState<PreviewImage[]>([]);
93
+ const [imageOpen, setImageOpen] = useState(false);
94
+ const [currentImage, setCurrentImage] = useState(0);
95
+ const imageFiles = useMemo(() => files.filter(isImage), [files]);
96
+
97
+ useEffect(() => () => revokeImages(images), [images]);
98
+
99
+ const closeImages = () => {
100
+ setImageOpen(false);
101
+ setImages(current => {
102
+ revokeImages(current);
103
+ return [];
104
+ });
105
+ };
106
+
107
+ const previewImage = async (file: DataFileRef) => {
108
+ setOpeningId(file.id);
109
+ try {
110
+ const settled = await Promise.allSettled(
111
+ imageFiles.map(async candidate => {
112
+ const preview = await loadDataFilePreview(resourceCode, candidate.id);
113
+ if (!preview.canPreview || preview.previewType !== 'image') {
114
+ throw new Error(
115
+ preview.unsupportedReason || `${candidate.name} 暂不支持图片预览`
116
+ );
117
+ }
118
+ const blob = await fetchDataFileBlob(resourceCode, candidate.id);
119
+ return {
120
+ id: candidate.id,
121
+ name: candidate.name,
122
+ src: URL.createObjectURL(blob),
123
+ };
124
+ })
125
+ );
126
+ const resolved = settled.flatMap(result =>
127
+ result.status === 'fulfilled' ? [result.value] : []
128
+ );
129
+ const current = resolved.findIndex(image => image.id === file.id);
130
+ if (current < 0) {
131
+ const failure = settled.find(
132
+ result => result.status === 'rejected'
133
+ ) as PromiseRejectedResult | undefined;
134
+ throw failure?.reason || new Error('图片预览加载失败');
135
+ }
136
+ setImages(previous => {
137
+ revokeImages(previous);
138
+ return resolved;
139
+ });
140
+ setCurrentImage(current);
141
+ setImageOpen(true);
142
+ } catch (error) {
143
+ message.error(error instanceof Error ? error.message : String(error));
144
+ } finally {
145
+ setOpeningId('');
146
+ }
147
+ };
148
+
149
+ const openPreview = (file: DataFileRef) => {
150
+ if (isImage(file)) {
151
+ void previewImage(file);
152
+ return;
153
+ }
154
+ if (!openIsolatedWindow(attachmentPreviewPath(resourceCode, file.id))) {
155
+ void message.warning('浏览器阻止了预览窗口,请允许弹出窗口后重试');
156
+ }
157
+ };
158
+
159
+ const download = (file: DataFileRef) => {
160
+ if (!openIsolatedWindow(dataFileContentUrl(resourceCode, file.id))) {
161
+ void message.warning('浏览器阻止了下载窗口,请允许弹出窗口后重试');
162
+ }
163
+ };
164
+
165
+ return (
166
+ <>
167
+ <div className="oxa-file-list">
168
+ {files.map(file => (
169
+ <div className="oxa-file-item" key={file.id}>
170
+ <span className="oxa-file-kind">{fileIcon(file)}</span>
171
+ <button
172
+ className="oxa-file-meta oxa-file-name-button"
173
+ disabled={openingId === file.id}
174
+ onClick={() => openPreview(file)}
175
+ type="button"
176
+ >
177
+ <strong>{file.name}</strong>
178
+ <small>{formatManagedFileSize(file.size)} · 已上传</small>
179
+ </button>
180
+ <Space size={2}>
181
+ <Button
182
+ aria-label={`预览${file.name}`}
183
+ icon={<EyeOutlined />}
184
+ loading={openingId === file.id}
185
+ onClick={() => openPreview(file)}
186
+ size="small"
187
+ type="text"
188
+ />
189
+ <Button
190
+ aria-label={`下载${file.name}`}
191
+ icon={<DownloadOutlined />}
192
+ onClick={() => download(file)}
193
+ size="small"
194
+ type="text"
195
+ />
196
+ {removable && (
197
+ <Button
198
+ aria-label={`移除${file.name}`}
199
+ danger
200
+ icon={<CloseOutlined />}
201
+ onClick={() => onRemove?.(file)}
202
+ size="small"
203
+ type="text"
204
+ />
205
+ )}
206
+ </Space>
207
+ </div>
208
+ ))}
209
+ </div>
210
+ <Image.PreviewGroup
211
+ items={images.map(image => ({ src: image.src, alt: image.name }))}
212
+ preview={{
213
+ open: imageOpen,
214
+ current: currentImage,
215
+ countRender: (current, total) => `${current}/${total}`,
216
+ onChange: setCurrentImage,
217
+ onOpenChange: open => {
218
+ if (!open) closeImages();
219
+ },
220
+ }}
221
+ >
222
+ <span aria-hidden="true" style={{ display: 'none' }} />
223
+ </Image.PreviewGroup>
224
+ </>
225
+ );
226
+ }
@@ -6,6 +6,7 @@ import ReactDOM from 'react-dom/client';
6
6
  import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
7
7
  import { instrumentProvider } from './data-provider';
8
8
  import { CollegePage } from './CollegePage';
9
+ import { FilePreviewPage } from './FilePreviewPage';
9
10
  import { InstrumentDetailPage } from './InstrumentDetailPage';
10
11
  import { InstrumentFormPage } from './InstrumentFormPage';
11
12
  import { InstrumentListPage } from './InstrumentListPage';
@@ -51,6 +52,10 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
51
52
  <Route path="/" element={<Navigate replace to="/instruments" />} />
52
53
  <Route path="/instruments" element={<InstrumentListPage />} />
53
54
  <Route path="/colleges" element={<CollegePage />} />
55
+ <Route
56
+ path="/files/:resourceCode/:fileId/preview"
57
+ element={<FilePreviewPage />}
58
+ />
54
59
  <Route
55
60
  path="/instruments/new"
56
61
  element={<InstrumentFormPage mode="create" />}
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  SCHEMA_VERSIONS,
3
+ type DataFilePreview,
3
4
  type DataFileRef,
4
5
  type DataFileUploadPlan,
5
6
  type DataAuditPage,
@@ -377,10 +378,44 @@ export const instrumentDataApi: InstrumentDataApiAdapter = {
377
378
  },
378
379
  };
379
380
 
380
- export function instrumentFileContentUrl(fileId: string) {
381
- return `${dataBase()}/files/${encodeURIComponent(
381
+ export function dataFileContentUrl(
382
+ resource: string,
383
+ fileId: string,
384
+ disposition: 'attachment' | 'inline' = 'attachment'
385
+ ) {
386
+ const query = new URLSearchParams({
387
+ environmentKey: currentEnvironmentKey(),
388
+ disposition,
389
+ });
390
+ return `${dataBase(resource)}/files/${encodeURIComponent(
382
391
  fileId
383
- )}/content?environmentKey=${encodeURIComponent(currentEnvironmentKey())}`;
392
+ )}/content?${query.toString()}`;
393
+ }
394
+
395
+ export async function loadDataFilePreview(
396
+ resource: string,
397
+ fileId: string
398
+ ) {
399
+ return await request<DataFilePreview>(
400
+ `${dataBase(resource)}/files/${encodeURIComponent(
401
+ fileId
402
+ )}/preview?environmentKey=${encodeURIComponent(currentEnvironmentKey())}`
403
+ );
404
+ }
405
+
406
+ export async function fetchDataFileBlob(resource: string, fileId: string) {
407
+ const response = await fetch(dataFileContentUrl(resource, fileId, 'inline'), {
408
+ credentials: 'include',
409
+ headers: { accept: 'application/octet-stream,*/*' },
410
+ });
411
+ if (!response.ok) {
412
+ const payload = (await response.json().catch(() => null)) as
413
+ | PlatformEnvelope<null>
414
+ | null;
415
+ const code = payload?.errorCode || `HTTP_${response.status}`;
416
+ throw new Error(`${code}: ${payload?.message || '文件内容读取失败'}`);
417
+ }
418
+ return await response.blob();
384
419
  }
385
420
 
386
421
  export interface CollegeRecord extends Record<string, unknown> {
@@ -45,6 +45,21 @@ export function applicationBasename() {
45
45
  return resolveApplicationBasename(runtimeMount());
46
46
  }
47
47
 
48
+ export function attachmentPreviewPath(resourceCode: string, fileId: string) {
49
+ return resolveAttachmentPreviewPath(runtimeMount(), resourceCode, fileId);
50
+ }
51
+
52
+ export function resolveAttachmentPreviewPath(
53
+ mount: RuntimeMount | null,
54
+ resourceCode: string,
55
+ fileId: string
56
+ ) {
57
+ const base = resolveApplicationBasename(mount)?.replace(/\/+$/, '') || '';
58
+ return `${base}/files/${encodeURIComponent(resourceCode)}/${encodeURIComponent(
59
+ fileId
60
+ )}/preview`;
61
+ }
62
+
48
63
  export function resolveApplicationApiPath(
49
64
  mount: RuntimeMount | null,
50
65
  path: string