openxiangda-cli 2.0.0-alpha.57 → 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.
Files changed (39) hide show
  1. package/dist/commands/create.d.ts.map +1 -1
  2. package/dist/commands/create.js +13 -5
  3. package/dist/commands/create.js.map +1 -1
  4. package/dist/create-workspace.d.ts +6 -1
  5. package/dist/create-workspace.d.ts.map +1 -1
  6. package/dist/create-workspace.js +12 -9
  7. package/dist/create-workspace.js.map +1 -1
  8. package/package.json +4 -4
  9. package/template/README.md +3 -1
  10. package/template/apps/server/package.json +1 -1
  11. package/template/apps/web/e2e/instruments.spec.ts +1197 -9
  12. package/template/apps/web/package.json +4 -2
  13. package/template/apps/web/scripts/check.mjs +2 -2
  14. package/template/apps/web/scripts/verify-build.mjs +14 -2
  15. package/template/apps/web/src/AuthoritativeSelector.tsx +371 -0
  16. package/template/apps/web/src/CollegePage.tsx +181 -0
  17. package/template/apps/web/src/FilePreviewPage.tsx +303 -0
  18. package/template/apps/web/src/InstrumentDetailPage.tsx +94 -20
  19. package/template/apps/web/src/InstrumentForm.tsx +347 -94
  20. package/template/apps/web/src/InstrumentFormPage.tsx +49 -9
  21. package/template/apps/web/src/InstrumentListPage.tsx +30 -35
  22. package/template/apps/web/src/Shell.tsx +229 -41
  23. package/template/apps/web/src/components/platform-fields/AttachmentFileList.tsx +226 -0
  24. package/template/apps/web/src/components/platform-fields/PlatformDirectoryPicker.tsx +698 -0
  25. package/template/apps/web/src/data-provider.ts +1 -1
  26. package/template/apps/web/src/fields.ts +17 -6
  27. package/template/apps/web/src/instrument.ts +1 -1
  28. package/template/apps/web/src/main.tsx +21 -1
  29. package/template/apps/web/src/platform-client.ts +248 -2
  30. package/template/apps/web/src/runtime-meta.ts +15 -0
  31. package/template/apps/web/src/runtime.tsx +4 -2
  32. package/template/apps/web/src/styles.css +1007 -28
  33. package/template/apps/web/test/contracts.test.ts +473 -10
  34. package/template/openxiangda.config.ts +24 -5
  35. package/template/package.json +3 -3
  36. package/template/packages/contracts/src/generated.ts +22 -0
  37. package/template/platform/data/colleges.ts +21 -0
  38. package/template/platform/data/instruments.ts +1 -1
  39. package/template/scripts/verify-template-budget.mjs +2 -2
@@ -26,6 +26,11 @@ import {
26
26
  } from 'antd';
27
27
  import { useMemo, useState } from 'react';
28
28
  import { useNavigate } from 'react-router-dom';
29
+ import {
30
+ AuthoritativeSelector,
31
+ ResolvedValueText,
32
+ } from './AuthoritativeSelector';
33
+ import { PlatformDirectoryPicker } from './components/platform-fields/PlatformDirectoryPicker';
29
34
  import { Shell } from './Shell';
30
35
  import {
31
36
  INSTRUMENT_CAPABILITIES,
@@ -34,16 +39,6 @@ import {
34
39
  } from './instrument';
35
40
  import { useRuntime } from './runtime';
36
41
 
37
- const colleges = [
38
- { label: '先进材料学院', value: 'college-am' },
39
- { label: '生命健康学院', value: 'college-lh' },
40
- { label: '公共测试中心', value: 'college-pt' },
41
- ];
42
- const admins = [
43
- { label: '王老师', value: 'user-wang' },
44
- { label: '李老师', value: 'user-li' },
45
- { label: '陈老师', value: 'user-chen' },
46
- ];
47
42
  const statuses = [
48
43
  { label: '正常', value: 'normal' },
49
44
  { label: '维修', value: 'maintenance' },
@@ -62,7 +57,7 @@ export function InstrumentListPage() {
62
57
  const [sort, setSort] = useState<{
63
58
  field: string;
64
59
  order: 'asc' | 'desc';
65
- }>({ field: 'updatedAt', order: 'desc' });
60
+ }>({ field: 'instrumentCode', order: 'asc' });
66
61
  const list = useList<InstrumentRecord>({
67
62
  resource: 'instruments',
68
63
  pagination: { currentPage: page, pageSize },
@@ -100,20 +95,15 @@ export function InstrumentListPage() {
100
95
  title: '所属学院',
101
96
  dataIndex: 'collegeId',
102
97
  width: 140,
103
- render: value =>
104
- colleges.find(item => item.value === value)?.label || value,
98
+ render: value => <ResolvedValueText source="college" value={value} />,
105
99
  },
106
100
  {
107
101
  title: '仪器管理员',
108
102
  dataIndex: 'instrumentAdminIds',
109
103
  width: 130,
110
- render: values =>
111
- (values as string[])
112
- .map(
113
- value =>
114
- admins.find(item => item.value === value)?.label || value
115
- )
116
- .join('、'),
104
+ render: values => (
105
+ <ResolvedValueText source="user" value={values as string[]} />
106
+ ),
117
107
  },
118
108
  {
119
109
  title: '仪器状态',
@@ -143,9 +133,8 @@ export function InstrumentListPage() {
143
133
  },
144
134
  {
145
135
  title: '更新时间',
146
- dataIndex: 'updatedAt',
136
+ dataIndex: 'updated_at',
147
137
  width: 160,
148
- sorter: true,
149
138
  render: value => (value ? new Date(value).toLocaleString() : '-'),
150
139
  },
151
140
  {
@@ -211,15 +200,15 @@ export function InstrumentListPage() {
211
200
  }
212
201
  return (
213
202
  <Shell>
214
- <Card>
215
- <Space direction="vertical" size={16} style={{ width: '100%' }}>
203
+ <Card className="oxa-list-card">
204
+ <Space orientation="vertical" size={16} style={{ width: '100%' }}>
216
205
  <div className="oxa-page-heading">
217
206
  <div>
218
- <h1 className="oxa-title" data-testid="instrument-title">
207
+ <h2 className="oxa-title">
219
208
  仪器资源
220
- </h1>
209
+ </h2>
221
210
  <div className="oxa-muted">
222
- Refine 管理查询状态;CRUD 只经过平台 Data API adapter
211
+ 统一管理仪器档案、归属、管理员与开放状态
223
212
  </div>
224
213
  </div>
225
214
  <Space>
@@ -255,22 +244,28 @@ export function InstrumentListPage() {
255
244
  setDraft(value => ({ ...value, keyword: event.target.value }))
256
245
  }
257
246
  />
258
- <Select
259
- allowClear
260
- options={colleges}
247
+ <AuthoritativeSelector
248
+ operation="update"
261
249
  placeholder="所属学院"
250
+ source="college"
262
251
  value={draft.collegeId}
263
252
  onChange={value =>
264
- setDraft(item => ({ ...item, collegeId: value }))
253
+ setDraft(item => ({
254
+ ...item,
255
+ collegeId: typeof value === 'string' ? value : undefined,
256
+ }))
265
257
  }
266
258
  />
267
- <Select
268
- allowClear
269
- options={admins}
259
+ <PlatformDirectoryPicker
260
+ kind="user"
270
261
  placeholder="仪器管理员"
271
262
  value={draft.instrumentAdminId}
272
263
  onChange={value =>
273
- setDraft(item => ({ ...item, instrumentAdminId: value }))
264
+ setDraft(item => ({
265
+ ...item,
266
+ instrumentAdminId:
267
+ typeof value === 'string' ? value : undefined,
268
+ }))
274
269
  }
275
270
  />
276
271
  <Select
@@ -1,52 +1,240 @@
1
- import { DatabaseOutlined, SafetyCertificateOutlined } from '@ant-design/icons';
2
- import { App, Button, Space, Tag } from 'antd';
1
+ import {
2
+ ApartmentOutlined,
3
+ AppstoreOutlined,
4
+ BookOutlined,
5
+ DownOutlined,
6
+ ExperimentOutlined,
7
+ HomeOutlined,
8
+ LeftOutlined,
9
+ MenuUnfoldOutlined,
10
+ SettingOutlined,
11
+ TeamOutlined,
12
+ } from '@ant-design/icons';
13
+ import {
14
+ Avatar,
15
+ Breadcrumb,
16
+ Button,
17
+ Layout,
18
+ Menu,
19
+ Space,
20
+ Typography,
21
+ type MenuProps,
22
+ } from 'antd';
3
23
  import type { ReactNode } from 'react';
24
+ import { useEffect, useMemo, useState } from 'react';
25
+ import { useLocation, useNavigate } from 'react-router-dom';
26
+ import { resolveDirectory } from './platform-client';
4
27
  import { useRuntime } from './runtime';
5
- import { applicationApiPath } from './runtime-meta';
6
28
 
7
- export function Shell({ children }: { children: ReactNode }) {
8
- const { identity } = useRuntime();
9
- const { message } = App.useApp();
10
- const inspectContext = async () => {
11
- const response = await fetch(applicationApiPath('api/instruments/context'), {
12
- credentials: 'include',
13
- headers: { accept: 'application/json' },
14
- });
15
- if (!response.ok) throw new Error(`HTTP_${response.status}`);
16
- const body = (await response.json()) as {
17
- userId?: string;
18
- data?: { userId?: string };
29
+ const { Content, Header, Sider } = Layout;
30
+
31
+ type PageMeta = { title: string; breadcrumbs: string[]; selectedKey: string };
32
+
33
+ function pageMeta(pathname: string): PageMeta {
34
+ if (pathname === '/colleges') {
35
+ return {
36
+ title: '学院字典',
37
+ breadcrumbs: ['首页', '基础数据', '学院字典'],
38
+ selectedKey: '/colleges',
39
+ };
40
+ }
41
+ if (pathname.endsWith('/new')) {
42
+ return {
43
+ title: '新增仪器',
44
+ breadcrumbs: ['首页', '仪器管理', '仪器资源', '新增仪器'],
45
+ selectedKey: '/instruments',
19
46
  };
20
- message.success(
21
- `本地 Nest 已读取平台用户:${body.data?.userId || body.userId || identity.userId}`
22
- );
47
+ }
48
+ if (pathname.endsWith('/edit')) {
49
+ return {
50
+ title: '编辑仪器',
51
+ breadcrumbs: ['首页', '仪器管理', '仪器资源', '编辑仪器'],
52
+ selectedKey: '/instruments',
53
+ };
54
+ }
55
+ if (/^\/instruments\/[^/]+$/.test(pathname)) {
56
+ return {
57
+ title: '仪器详情',
58
+ breadcrumbs: ['首页', '仪器管理', '仪器资源', '仪器详情'],
59
+ selectedKey: '/instruments',
60
+ };
61
+ }
62
+ return {
63
+ title: '仪器资源',
64
+ breadcrumbs: ['首页', '仪器管理', '仪器资源'],
65
+ selectedKey: '/instruments',
23
66
  };
67
+ }
68
+
69
+ export function Shell({ children }: { children: ReactNode }) {
70
+ const { identity, hasCapability } = useRuntime();
71
+ const location = useLocation();
72
+ const navigate = useNavigate();
73
+ const [collapsed, setCollapsed] = useState(false);
74
+ const [user, setUser] = useState({ label: '当前用户', description: '平台用户' });
75
+ const meta = pageMeta(location.pathname);
76
+
77
+ useEffect(() => {
78
+ let active = true;
79
+ void resolveDirectory('user', [identity.userId])
80
+ .then(page => {
81
+ const entry = page.items[0];
82
+ if (active && entry) {
83
+ setUser({
84
+ label: entry.label || '当前用户',
85
+ description: entry.description || '平台用户',
86
+ });
87
+ }
88
+ })
89
+ .catch(() => {
90
+ if (active) setUser({ label: '当前用户', description: '平台用户' });
91
+ });
92
+ return () => {
93
+ active = false;
94
+ };
95
+ }, [identity.userId]);
96
+
97
+ const menuItems = useMemo<MenuProps['items']>(
98
+ () => [
99
+ {
100
+ key: 'overview',
101
+ icon: <AppstoreOutlined />,
102
+ label: '总览',
103
+ children: [
104
+ { key: 'workbench', icon: <HomeOutlined />, label: '工作台' },
105
+ ],
106
+ },
107
+ {
108
+ key: 'instrument-management',
109
+ icon: <ExperimentOutlined />,
110
+ label: '仪器管理',
111
+ children: [
112
+ {
113
+ key: '/instruments',
114
+ icon: <ExperimentOutlined />,
115
+ label: '仪器资源',
116
+ },
117
+ {
118
+ key: 'open-records',
119
+ icon: <BookOutlined />,
120
+ label: '开放记录',
121
+ disabled: true,
122
+ },
123
+ ],
124
+ },
125
+ {
126
+ key: 'base-data',
127
+ icon: <ApartmentOutlined />,
128
+ label: '基础数据',
129
+ children: hasCapability('app:instrument-center:data:colleges:read')
130
+ ? [
131
+ {
132
+ key: '/colleges',
133
+ icon: <ApartmentOutlined />,
134
+ label: '学院字典',
135
+ },
136
+ ]
137
+ : [],
138
+ },
139
+ {
140
+ key: 'system-settings',
141
+ icon: <SettingOutlined />,
142
+ label: '系统设置',
143
+ children: [
144
+ {
145
+ key: 'application-admins',
146
+ icon: <TeamOutlined />,
147
+ label: '应用管理员',
148
+ disabled: true,
149
+ },
150
+ ],
151
+ },
152
+ ],
153
+ [hasCapability]
154
+ );
155
+
24
156
  return (
25
- <div className="oxa-shell">
26
- <header className="oxa-header">
27
- <Space>
28
- <DatabaseOutlined />
29
- <strong>仪器资源管理</strong>
30
- <Tag color="blue">{identity.environment.key}</Tag>
31
- </Space>
32
- <Space wrap>
33
- <span>{identity.userId}</span>
34
- <span className="oxa-header-roles">
35
- {identity.roleCodes.join('、') || '无应用角色'}
36
- </span>
37
- <Button
38
- ghost
39
- size="small"
40
- icon={<SafetyCertificateOutlined />}
41
- onClick={() =>
42
- void inspectContext().catch(error => message.error(error.message))
157
+ <Layout className="oxa-app-layout">
158
+ <Sider
159
+ className="oxa-sider"
160
+ breakpoint="lg"
161
+ collapsed={collapsed}
162
+ collapsedWidth={76}
163
+ collapsible
164
+ onBreakpoint={setCollapsed}
165
+ theme="light"
166
+ trigger={null}
167
+ width={240}
168
+ >
169
+ <div className="oxa-brand">
170
+ <div aria-hidden className="oxa-brand-mark">
171
+ <span />
172
+ </div>
173
+ {!collapsed && (
174
+ <div className="oxa-brand-copy">
175
+ <strong>仪器资源管理</strong>
176
+ <span>仪器、学院与开放管理</span>
177
+ </div>
178
+ )}
179
+ </div>
180
+ <Menu
181
+ className="oxa-menu"
182
+ defaultOpenKeys={[
183
+ 'overview',
184
+ 'instrument-management',
185
+ 'base-data',
186
+ 'system-settings',
187
+ ]}
188
+ inlineCollapsed={collapsed}
189
+ items={menuItems}
190
+ mode="inline"
191
+ onClick={({ key }) => {
192
+ if (key === 'workbench' || key === '/instruments') {
193
+ navigate('/instruments');
194
+ } else if (key === '/colleges') {
195
+ navigate('/colleges');
43
196
  }
197
+ }}
198
+ selectedKeys={[meta.selectedKey]}
199
+ />
200
+ <div className="oxa-sider-footer">
201
+ <Button
202
+ aria-label={collapsed ? '展开侧边栏' : '收起侧边栏'}
203
+ block
204
+ icon={collapsed ? <MenuUnfoldOutlined /> : <LeftOutlined />}
205
+ onClick={() => setCollapsed(value => !value)}
206
+ type="text"
44
207
  >
45
- 验证本地 Nest
208
+ {!collapsed && '收起侧边栏'}
46
209
  </Button>
47
- </Space>
48
- </header>
49
- <main className="oxa-main">{children}</main>
50
- </div>
210
+ </div>
211
+ </Sider>
212
+ <Layout className="oxa-workspace">
213
+ <Header className="oxa-topbar">
214
+ <div className="oxa-topbar-page">
215
+ <Breadcrumb items={meta.breadcrumbs.map(title => ({ title }))} />
216
+ <Typography.Title
217
+ data-testid={meta.title === '仪器资源' ? 'instrument-title' : undefined}
218
+ level={3}
219
+ >
220
+ {meta.title}
221
+ </Typography.Title>
222
+ </div>
223
+ <Space className="oxa-current-user" size={12}>
224
+ <Avatar className="oxa-current-user-avatar" size={42}>
225
+ {user.label.slice(0, 1)}
226
+ </Avatar>
227
+ <div className="oxa-current-user-copy">
228
+ <strong>{user.label}</strong>
229
+ <span>{user.description}</span>
230
+ </div>
231
+ <DownOutlined className="oxa-current-user-chevron" />
232
+ </Space>
233
+ </Header>
234
+ <Content className="oxa-content">
235
+ <main className="oxa-main">{children}</main>
236
+ </Content>
237
+ </Layout>
238
+ </Layout>
51
239
  );
52
240
  }
@@ -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
+ }