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.
- package/dist/commands/create.d.ts.map +1 -1
- package/dist/commands/create.js +13 -5
- package/dist/commands/create.js.map +1 -1
- package/dist/create-workspace.d.ts +6 -1
- package/dist/create-workspace.d.ts.map +1 -1
- package/dist/create-workspace.js +12 -9
- package/dist/create-workspace.js.map +1 -1
- package/package.json +4 -4
- package/template/README.md +3 -1
- package/template/apps/server/package.json +1 -1
- package/template/apps/web/e2e/instruments.spec.ts +1197 -9
- package/template/apps/web/package.json +4 -2
- package/template/apps/web/scripts/check.mjs +2 -2
- package/template/apps/web/scripts/verify-build.mjs +14 -2
- package/template/apps/web/src/AuthoritativeSelector.tsx +371 -0
- package/template/apps/web/src/CollegePage.tsx +181 -0
- package/template/apps/web/src/FilePreviewPage.tsx +303 -0
- package/template/apps/web/src/InstrumentDetailPage.tsx +94 -20
- package/template/apps/web/src/InstrumentForm.tsx +347 -94
- package/template/apps/web/src/InstrumentFormPage.tsx +49 -9
- package/template/apps/web/src/InstrumentListPage.tsx +30 -35
- package/template/apps/web/src/Shell.tsx +229 -41
- package/template/apps/web/src/components/platform-fields/AttachmentFileList.tsx +226 -0
- package/template/apps/web/src/components/platform-fields/PlatformDirectoryPicker.tsx +698 -0
- package/template/apps/web/src/data-provider.ts +1 -1
- package/template/apps/web/src/fields.ts +17 -6
- package/template/apps/web/src/instrument.ts +1 -1
- package/template/apps/web/src/main.tsx +21 -1
- package/template/apps/web/src/platform-client.ts +248 -2
- package/template/apps/web/src/runtime-meta.ts +15 -0
- package/template/apps/web/src/runtime.tsx +4 -2
- package/template/apps/web/src/styles.css +1007 -28
- package/template/apps/web/test/contracts.test.ts +473 -10
- package/template/openxiangda.config.ts +24 -5
- package/template/package.json +3 -3
- package/template/packages/contracts/src/generated.ts +22 -0
- package/template/platform/data/colleges.ts +21 -0
- package/template/platform/data/instruments.ts +1 -1
- 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: '
|
|
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
|
-
|
|
112
|
-
|
|
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: '
|
|
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
|
|
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
|
-
<
|
|
207
|
+
<h2 className="oxa-title">
|
|
219
208
|
仪器资源
|
|
220
|
-
</
|
|
209
|
+
</h2>
|
|
221
210
|
<div className="oxa-muted">
|
|
222
|
-
|
|
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
|
-
<
|
|
259
|
-
|
|
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 => ({
|
|
253
|
+
setDraft(item => ({
|
|
254
|
+
...item,
|
|
255
|
+
collegeId: typeof value === 'string' ? value : undefined,
|
|
256
|
+
}))
|
|
265
257
|
}
|
|
266
258
|
/>
|
|
267
|
-
<
|
|
268
|
-
|
|
269
|
-
options={admins}
|
|
259
|
+
<PlatformDirectoryPicker
|
|
260
|
+
kind="user"
|
|
270
261
|
placeholder="仪器管理员"
|
|
271
262
|
value={draft.instrumentAdminId}
|
|
272
263
|
onChange={value =>
|
|
273
|
-
setDraft(item => ({
|
|
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 {
|
|
2
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
<
|
|
26
|
-
<
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
208
|
+
{!collapsed && '收起侧边栏'}
|
|
46
209
|
</Button>
|
|
47
|
-
</
|
|
48
|
-
</
|
|
49
|
-
<
|
|
50
|
-
|
|
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
|
+
}
|