openxiangda-cli 2.0.0-alpha.57 → 2.0.0-alpha.58
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 +912 -9
- package/template/apps/web/package.json +1 -1
- package/template/apps/web/scripts/check.mjs +2 -2
- package/template/apps/web/src/AuthoritativeSelector.tsx +371 -0
- package/template/apps/web/src/CollegePage.tsx +181 -0
- package/template/apps/web/src/InstrumentDetailPage.tsx +94 -20
- package/template/apps/web/src/InstrumentForm.tsx +450 -88
- 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/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 +16 -1
- package/template/apps/web/src/platform-client.ts +213 -2
- package/template/apps/web/src/runtime.tsx +4 -2
- package/template/apps/web/src/styles.css +869 -29
- package/template/apps/web/test/contracts.test.ts +369 -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 +1 -1
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
import {
|
|
2
|
+
DownloadOutlined,
|
|
3
|
+
EyeOutlined,
|
|
4
|
+
PaperClipOutlined,
|
|
5
|
+
PictureOutlined,
|
|
6
|
+
PlusOutlined,
|
|
7
|
+
ReloadOutlined,
|
|
8
|
+
UploadOutlined,
|
|
9
|
+
} from '@ant-design/icons';
|
|
10
|
+
import {
|
|
11
|
+
App,
|
|
2
12
|
Button,
|
|
3
13
|
Card,
|
|
4
14
|
DatePicker,
|
|
@@ -7,20 +17,36 @@ import {
|
|
|
7
17
|
Input,
|
|
8
18
|
InputNumber,
|
|
9
19
|
Select,
|
|
20
|
+
Space,
|
|
10
21
|
Switch,
|
|
22
|
+
Tag,
|
|
23
|
+
Timeline,
|
|
24
|
+
Typography,
|
|
11
25
|
Upload,
|
|
12
26
|
type UploadFile,
|
|
13
27
|
} from 'antd';
|
|
14
|
-
import { UploadOutlined } from '@ant-design/icons';
|
|
15
28
|
import dayjs from 'dayjs';
|
|
16
|
-
import { useEffect } from 'react';
|
|
17
|
-
import type { DataFileRef } from 'openxiangda-contracts/browser';
|
|
29
|
+
import { useEffect, useState } from 'react';
|
|
30
|
+
import type { DataAuditEntry, DataFileRef } from 'openxiangda-contracts/browser';
|
|
31
|
+
import {
|
|
32
|
+
AuthoritativeSelector,
|
|
33
|
+
ResolvedValueText,
|
|
34
|
+
} from './AuthoritativeSelector';
|
|
35
|
+
import { PlatformDirectoryPicker } from './components/platform-fields/PlatformDirectoryPicker';
|
|
18
36
|
import { instrumentFields, sectionTitles, type InstrumentField } from './fields';
|
|
19
37
|
import type { InstrumentRecord } from './instrument';
|
|
20
|
-
import {
|
|
38
|
+
import {
|
|
39
|
+
instrumentDataApi,
|
|
40
|
+
instrumentFileContentUrl,
|
|
41
|
+
} from './platform-client';
|
|
21
42
|
import { useRuntime } from './runtime';
|
|
22
43
|
|
|
23
44
|
type Values = Record<string, unknown>;
|
|
45
|
+
const createDefaults = {
|
|
46
|
+
openToOutside: false,
|
|
47
|
+
instrumentStatus: 'normal',
|
|
48
|
+
usageStatus: 'active',
|
|
49
|
+
} as const;
|
|
24
50
|
|
|
25
51
|
export function instrumentFieldAllowed(
|
|
26
52
|
field: InstrumentField,
|
|
@@ -53,14 +79,20 @@ export function InstrumentForm({
|
|
|
53
79
|
record,
|
|
54
80
|
onCancel,
|
|
55
81
|
onSubmit,
|
|
82
|
+
auditEntries = [],
|
|
83
|
+
auditError,
|
|
56
84
|
}: {
|
|
57
85
|
mode: 'create' | 'edit' | 'detail';
|
|
58
86
|
record?: InstrumentRecord;
|
|
59
87
|
onCancel: () => void;
|
|
60
88
|
onSubmit?: (values: Values) => Promise<void>;
|
|
89
|
+
auditEntries?: DataAuditEntry[];
|
|
90
|
+
auditError?: string;
|
|
61
91
|
}) {
|
|
62
92
|
const [form] = Form.useForm();
|
|
93
|
+
const { message } = App.useApp();
|
|
63
94
|
const { hasCapability } = useRuntime();
|
|
95
|
+
const [submitting, setSubmitting] = useState(false);
|
|
64
96
|
useEffect(() => {
|
|
65
97
|
if (record) {
|
|
66
98
|
form.setFieldsValue({
|
|
@@ -69,12 +101,21 @@ export function InstrumentForm({
|
|
|
69
101
|
});
|
|
70
102
|
}
|
|
71
103
|
}, [form, record]);
|
|
72
|
-
if (mode === 'detail' && record)
|
|
104
|
+
if (mode === 'detail' && record) {
|
|
105
|
+
return (
|
|
106
|
+
<Detail
|
|
107
|
+
auditEntries={auditEntries}
|
|
108
|
+
auditError={auditError}
|
|
109
|
+
record={record}
|
|
110
|
+
/>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
73
113
|
const isCreate = mode === 'create';
|
|
74
114
|
const writeMode = isCreate ? 'create' : 'edit';
|
|
75
|
-
const submit = async () => {
|
|
76
|
-
|
|
115
|
+
const submit = async (values: Values) => {
|
|
116
|
+
if (submitting) return;
|
|
77
117
|
const normalized = {
|
|
118
|
+
...(isCreate ? createDefaults : {}),
|
|
78
119
|
...values,
|
|
79
120
|
enabledDate:
|
|
80
121
|
values.enabledDate &&
|
|
@@ -85,37 +126,48 @@ export function InstrumentForm({
|
|
|
85
126
|
)
|
|
86
127
|
: values.enabledDate,
|
|
87
128
|
};
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
129
|
+
setSubmitting(true);
|
|
130
|
+
try {
|
|
131
|
+
await onSubmit?.(
|
|
132
|
+
sanitizeInstrumentValues(normalized, writeMode, hasCapability)
|
|
133
|
+
);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
message.error(error instanceof Error ? error.message : String(error));
|
|
136
|
+
} finally {
|
|
137
|
+
setSubmitting(false);
|
|
138
|
+
}
|
|
91
139
|
};
|
|
140
|
+
const groups = Object.entries(sectionTitles).map(([key, title]) => ({
|
|
141
|
+
key,
|
|
142
|
+
title,
|
|
143
|
+
fields: instrumentFields
|
|
144
|
+
.filter(field => field.section === key && !field.system)
|
|
145
|
+
.map(field => field.key),
|
|
146
|
+
}));
|
|
92
147
|
return (
|
|
93
148
|
<Form
|
|
149
|
+
className="oxa-instrument-form oxa-form-full"
|
|
94
150
|
form={form}
|
|
151
|
+
initialValues={isCreate ? createDefaults : undefined}
|
|
95
152
|
layout="vertical"
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
? {
|
|
99
|
-
openToOutside: false,
|
|
100
|
-
instrumentStatus: 'normal',
|
|
101
|
-
usageStatus: 'active',
|
|
102
|
-
}
|
|
103
|
-
: undefined
|
|
104
|
-
}
|
|
153
|
+
onFinish={values => void submit(values)}
|
|
154
|
+
scrollToFirstError={{ block: 'center' }}
|
|
105
155
|
>
|
|
106
156
|
<div className="oxa-sections">
|
|
107
|
-
{
|
|
108
|
-
<Card key={
|
|
157
|
+
{groups.map(group => (
|
|
158
|
+
<Card className="oxa-section-card" key={group.key} title={group.title}>
|
|
109
159
|
<div className="oxa-grid">
|
|
110
|
-
{
|
|
111
|
-
.
|
|
160
|
+
{group.fields
|
|
161
|
+
.map(key => instrumentFields.find(field => field.key === key))
|
|
162
|
+
.filter((field): field is InstrumentField => Boolean(field))
|
|
112
163
|
.map(field => (
|
|
113
164
|
<FieldControl
|
|
114
|
-
field={field}
|
|
115
|
-
key={field.key}
|
|
116
165
|
disabled={
|
|
117
166
|
!instrumentFieldAllowed(field, writeMode, hasCapability)
|
|
118
167
|
}
|
|
168
|
+
field={field}
|
|
169
|
+
key={field.key}
|
|
170
|
+
operation={isCreate ? 'create' : 'update'}
|
|
119
171
|
recordId={record?.id}
|
|
120
172
|
/>
|
|
121
173
|
))}
|
|
@@ -124,13 +176,16 @@ export function InstrumentForm({
|
|
|
124
176
|
))}
|
|
125
177
|
</div>
|
|
126
178
|
<div className="oxa-actions">
|
|
127
|
-
<Button onClick={onCancel}
|
|
179
|
+
<Button disabled={submitting} onClick={onCancel}>
|
|
180
|
+
取消
|
|
181
|
+
</Button>
|
|
128
182
|
<Button
|
|
129
183
|
data-testid="save-instrument"
|
|
184
|
+
htmlType="submit"
|
|
185
|
+
loading={submitting}
|
|
130
186
|
type="primary"
|
|
131
|
-
onClick={() => void submit()}
|
|
132
187
|
>
|
|
133
|
-
{isCreate ? '
|
|
188
|
+
{isCreate ? '保存' : '保存修改'}
|
|
134
189
|
</Button>
|
|
135
190
|
</div>
|
|
136
191
|
</Form>
|
|
@@ -140,10 +195,12 @@ export function InstrumentForm({
|
|
|
140
195
|
function FieldControl({
|
|
141
196
|
field,
|
|
142
197
|
disabled,
|
|
198
|
+
operation,
|
|
143
199
|
recordId,
|
|
144
200
|
}: {
|
|
145
201
|
field: InstrumentField;
|
|
146
202
|
disabled: boolean;
|
|
203
|
+
operation: 'create' | 'update';
|
|
147
204
|
recordId?: string;
|
|
148
205
|
}) {
|
|
149
206
|
const rules = [
|
|
@@ -159,21 +216,25 @@ function FieldControl({
|
|
|
159
216
|
];
|
|
160
217
|
const common = { disabled, placeholder: `请输入${field.label}` };
|
|
161
218
|
let control = <Input {...common} />;
|
|
162
|
-
if (field.kind === 'textarea')
|
|
219
|
+
if (field.kind === 'textarea') {
|
|
163
220
|
control = <Input.TextArea {...common} rows={3} />;
|
|
164
|
-
|
|
221
|
+
}
|
|
222
|
+
if (field.kind === 'number') {
|
|
165
223
|
control = <InputNumber {...common} min={0} style={{ width: '100%' }} />;
|
|
166
|
-
|
|
224
|
+
}
|
|
225
|
+
if (field.kind === 'date') {
|
|
167
226
|
control = <DatePicker disabled={disabled} style={{ width: '100%' }} />;
|
|
168
|
-
|
|
227
|
+
}
|
|
228
|
+
if (field.kind === 'boolean') {
|
|
169
229
|
control = (
|
|
170
230
|
<Switch
|
|
171
|
-
disabled={disabled}
|
|
172
231
|
checkedChildren="是"
|
|
232
|
+
disabled={disabled}
|
|
173
233
|
unCheckedChildren="否"
|
|
174
234
|
/>
|
|
175
235
|
);
|
|
176
|
-
|
|
236
|
+
}
|
|
237
|
+
if (field.kind === 'select') {
|
|
177
238
|
control = (
|
|
178
239
|
<Select
|
|
179
240
|
disabled={disabled}
|
|
@@ -181,7 +242,8 @@ function FieldControl({
|
|
|
181
242
|
placeholder={`请选择${field.label}`}
|
|
182
243
|
/>
|
|
183
244
|
);
|
|
184
|
-
|
|
245
|
+
}
|
|
246
|
+
if (field.kind === 'multi') {
|
|
185
247
|
control = (
|
|
186
248
|
<Select
|
|
187
249
|
disabled={disabled}
|
|
@@ -190,7 +252,31 @@ function FieldControl({
|
|
|
190
252
|
placeholder={`请选择${field.label}`}
|
|
191
253
|
/>
|
|
192
254
|
);
|
|
193
|
-
|
|
255
|
+
}
|
|
256
|
+
if (field.kind === 'scope') {
|
|
257
|
+
control = (
|
|
258
|
+
<AuthoritativeSelector
|
|
259
|
+
disabled={disabled}
|
|
260
|
+
operation={operation}
|
|
261
|
+
placeholder={`请选择${field.label}`}
|
|
262
|
+
source="college"
|
|
263
|
+
/>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
if (
|
|
267
|
+
field.kind === 'directory-user' ||
|
|
268
|
+
field.kind === 'directory-department'
|
|
269
|
+
) {
|
|
270
|
+
control = (
|
|
271
|
+
<PlatformDirectoryPicker
|
|
272
|
+
disabled={disabled}
|
|
273
|
+
kind={field.kind === 'directory-user' ? 'user' : 'department'}
|
|
274
|
+
multiple={field.multiple}
|
|
275
|
+
placeholder={`搜索并选择${field.label}`}
|
|
276
|
+
/>
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
if (field.kind === 'file') {
|
|
194
280
|
control = (
|
|
195
281
|
<FileValue
|
|
196
282
|
accept={field.accept}
|
|
@@ -201,8 +287,22 @@ function FieldControl({
|
|
|
201
287
|
recordId={recordId}
|
|
202
288
|
/>
|
|
203
289
|
);
|
|
290
|
+
}
|
|
291
|
+
const extra = disabled
|
|
292
|
+
? '当前角色不可修改此字段'
|
|
293
|
+
: field.kind === 'scope'
|
|
294
|
+
? '仅显示当前角色可管理的学院'
|
|
295
|
+
: field.key === 'instrumentAdminIds'
|
|
296
|
+
? '管理员选择结果将参与仪器数据权限判断'
|
|
297
|
+
: undefined;
|
|
204
298
|
return (
|
|
205
299
|
<Form.Item
|
|
300
|
+
className={
|
|
301
|
+
field.kind === 'textarea' || field.kind === 'file'
|
|
302
|
+
? 'oxa-field-wide'
|
|
303
|
+
: undefined
|
|
304
|
+
}
|
|
305
|
+
extra={extra}
|
|
206
306
|
label={field.label}
|
|
207
307
|
name={field.key}
|
|
208
308
|
rules={rules}
|
|
@@ -232,61 +332,289 @@ function FileValue({
|
|
|
232
332
|
maxCount?: number;
|
|
233
333
|
accept?: string;
|
|
234
334
|
}) {
|
|
335
|
+
const [uploading, setUploading] = useState(false);
|
|
336
|
+
const [uploadError, setUploadError] = useState('');
|
|
235
337
|
const refs = Array.isArray(value) ? value : value ? [value] : [];
|
|
236
338
|
const files: UploadFile[] = refs.map(file => ({
|
|
237
339
|
uid: file.id,
|
|
238
340
|
name: file.name,
|
|
341
|
+
size: file.size,
|
|
342
|
+
type: file.contentType,
|
|
239
343
|
status: 'done',
|
|
344
|
+
url: instrumentFileContentUrl(file.id),
|
|
240
345
|
}));
|
|
346
|
+
const uploadProps = {
|
|
347
|
+
accept,
|
|
348
|
+
disabled,
|
|
349
|
+
fileList: files,
|
|
350
|
+
maxCount,
|
|
351
|
+
multiple,
|
|
352
|
+
customRequest: async (options: {
|
|
353
|
+
file: string | Blob;
|
|
354
|
+
onError?: (error: Error) => void;
|
|
355
|
+
onProgress?: (event: { percent: number }) => void;
|
|
356
|
+
onSuccess?: (body: unknown) => void;
|
|
357
|
+
}) => {
|
|
358
|
+
setUploading(true);
|
|
359
|
+
setUploadError('');
|
|
360
|
+
try {
|
|
361
|
+
options.onProgress?.({ percent: 15 });
|
|
362
|
+
const uploaded = await instrumentDataApi.upload(
|
|
363
|
+
fieldCode,
|
|
364
|
+
options.file as File,
|
|
365
|
+
recordId
|
|
366
|
+
);
|
|
367
|
+
options.onProgress?.({ percent: 100 });
|
|
368
|
+
onChange?.(multiple ? [...refs, uploaded] : uploaded);
|
|
369
|
+
options.onSuccess?.(uploaded);
|
|
370
|
+
} catch (error) {
|
|
371
|
+
const failure =
|
|
372
|
+
error instanceof Error ? error : new Error(String(error));
|
|
373
|
+
setUploadError(failure.message);
|
|
374
|
+
options.onError?.(failure);
|
|
375
|
+
} finally {
|
|
376
|
+
setUploading(false);
|
|
377
|
+
}
|
|
378
|
+
},
|
|
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
|
+
showUploadList: false,
|
|
400
|
+
};
|
|
241
401
|
return (
|
|
242
|
-
<
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
402
|
+
<div className="oxa-file-field">
|
|
403
|
+
<Upload {...uploadProps} pastable>
|
|
404
|
+
<Button
|
|
405
|
+
disabled={disabled || uploading || refs.length >= maxCount}
|
|
406
|
+
icon={fieldCode === 'image' ? <PictureOutlined /> : <UploadOutlined />}
|
|
407
|
+
loading={uploading}
|
|
408
|
+
>
|
|
409
|
+
{fieldCode === 'image' ? '上传图片' : '上传文件'}
|
|
410
|
+
</Button>
|
|
411
|
+
</Upload>
|
|
412
|
+
<Upload.Dragger
|
|
413
|
+
{...uploadProps}
|
|
414
|
+
className="oxa-file-dropzone"
|
|
415
|
+
openFileDialogOnClick={false}
|
|
416
|
+
pastable
|
|
417
|
+
>
|
|
418
|
+
<span>
|
|
419
|
+
可将文件拖到这里,或在此区域按 <kbd>⌘ V</kbd> 粘贴
|
|
420
|
+
</span>
|
|
421
|
+
<small>
|
|
422
|
+
{multiple
|
|
423
|
+
? `最多 ${maxCount} 个文件,单个不超过 50MB`
|
|
424
|
+
: '仅保留一个文件,新上传会替换当前文件'}
|
|
425
|
+
</small>
|
|
426
|
+
</Upload.Dragger>
|
|
427
|
+
{uploadError && (
|
|
428
|
+
<div className="oxa-file-error">
|
|
429
|
+
<span>{uploadError}</span>
|
|
430
|
+
<Button
|
|
431
|
+
icon={<ReloadOutlined />}
|
|
432
|
+
onClick={() => setUploadError('')}
|
|
433
|
+
size="small"
|
|
434
|
+
type="text"
|
|
435
|
+
>
|
|
436
|
+
关闭
|
|
437
|
+
</Button>
|
|
438
|
+
</div>
|
|
439
|
+
)}
|
|
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>
|
|
480
|
+
) : (
|
|
481
|
+
<div className="oxa-file-empty">尚未上传文件</div>
|
|
482
|
+
)}
|
|
483
|
+
</div>
|
|
271
484
|
);
|
|
272
485
|
}
|
|
273
486
|
|
|
274
|
-
function Detail({
|
|
487
|
+
function Detail({
|
|
488
|
+
record,
|
|
489
|
+
auditEntries,
|
|
490
|
+
auditError,
|
|
491
|
+
}: {
|
|
492
|
+
record: InstrumentRecord;
|
|
493
|
+
auditEntries: DataAuditEntry[];
|
|
494
|
+
auditError?: string;
|
|
495
|
+
}) {
|
|
496
|
+
const groups = Object.entries(sectionTitles)
|
|
497
|
+
.map(([section, title]) => ({
|
|
498
|
+
section,
|
|
499
|
+
title,
|
|
500
|
+
fields: instrumentFields.filter(
|
|
501
|
+
field =>
|
|
502
|
+
field.section === section &&
|
|
503
|
+
!field.system &&
|
|
504
|
+
Object.prototype.hasOwnProperty.call(record, field.key)
|
|
505
|
+
),
|
|
506
|
+
}))
|
|
507
|
+
.filter(group => group.fields.length > 0);
|
|
275
508
|
return (
|
|
276
|
-
<div className="oxa-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
<
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
.
|
|
283
|
-
.map(field => ({
|
|
509
|
+
<div className="oxa-detail-layout">
|
|
510
|
+
<div className="oxa-detail-main">
|
|
511
|
+
{groups.map(group => (
|
|
512
|
+
<Card className="oxa-section-card" key={group.section} title={group.title}>
|
|
513
|
+
<Descriptions
|
|
514
|
+
column={{ xs: 1, sm: 2, lg: 3 }}
|
|
515
|
+
items={group.fields.map(field => ({
|
|
284
516
|
key: field.key,
|
|
285
517
|
label: field.label,
|
|
518
|
+
span:
|
|
519
|
+
field.kind === 'textarea' || field.kind === 'file' ? 3 : 1,
|
|
286
520
|
children: displayValue(field, record[field.key]),
|
|
287
521
|
}))}
|
|
522
|
+
/>
|
|
523
|
+
</Card>
|
|
524
|
+
))}
|
|
525
|
+
</div>
|
|
526
|
+
<aside className="oxa-detail-aside">
|
|
527
|
+
<Card className="oxa-section-card" title="记录信息">
|
|
528
|
+
<Descriptions
|
|
529
|
+
column={1}
|
|
530
|
+
items={[
|
|
531
|
+
{ key: 'revision', label: '数据版本', children: record.revision },
|
|
532
|
+
{
|
|
533
|
+
key: 'updatedAt',
|
|
534
|
+
label: '更新时间',
|
|
535
|
+
children: formatDateTime(record.updated_at),
|
|
536
|
+
},
|
|
537
|
+
{
|
|
538
|
+
key: 'status',
|
|
539
|
+
label: '仪器状态',
|
|
540
|
+
children: displayValue(
|
|
541
|
+
instrumentFields.find(
|
|
542
|
+
field => field.key === 'instrumentStatus'
|
|
543
|
+
)!,
|
|
544
|
+
record.instrumentStatus
|
|
545
|
+
),
|
|
546
|
+
},
|
|
547
|
+
]}
|
|
288
548
|
/>
|
|
289
549
|
</Card>
|
|
550
|
+
<Card className="oxa-section-card" id="instrument-audit" title="最近变更">
|
|
551
|
+
{auditError ? (
|
|
552
|
+
<Typography.Text type="secondary">审计记录暂不可用</Typography.Text>
|
|
553
|
+
) : auditEntries.length ? (
|
|
554
|
+
<Timeline
|
|
555
|
+
items={auditEntries.slice(0, 5).map(entry => ({
|
|
556
|
+
children: (
|
|
557
|
+
<div className="oxa-audit-entry">
|
|
558
|
+
<strong>{auditLabel(entry.operation)}</strong>
|
|
559
|
+
<span>{formatDateTime(entry.occurredAt)}</span>
|
|
560
|
+
{entry.actor.userId && (
|
|
561
|
+
<ResolvedValueText source="user" value={entry.actor.userId} />
|
|
562
|
+
)}
|
|
563
|
+
</div>
|
|
564
|
+
),
|
|
565
|
+
}))}
|
|
566
|
+
/>
|
|
567
|
+
) : (
|
|
568
|
+
<Typography.Text type="secondary">暂无变更记录</Typography.Text>
|
|
569
|
+
)}
|
|
570
|
+
</Card>
|
|
571
|
+
</aside>
|
|
572
|
+
</div>
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function AttachmentReadOnly({ values }: { values: DataFileRef[] }) {
|
|
577
|
+
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>
|
|
290
618
|
))}
|
|
291
619
|
</div>
|
|
292
620
|
);
|
|
@@ -294,18 +622,31 @@ function Detail({ record }: { record: InstrumentRecord }) {
|
|
|
294
622
|
|
|
295
623
|
function displayValue(field: InstrumentField, value: unknown) {
|
|
296
624
|
if (value === undefined || value === null || value === '') return '-';
|
|
297
|
-
if (field.kind === '
|
|
625
|
+
if (field.kind === 'scope') {
|
|
626
|
+
return <ResolvedValueText source="college" value={String(value)} />;
|
|
627
|
+
}
|
|
628
|
+
if (field.kind === 'directory-user') {
|
|
629
|
+
return (
|
|
630
|
+
<ResolvedValueText
|
|
631
|
+
source="user"
|
|
632
|
+
value={Array.isArray(value) ? value.map(String) : String(value)}
|
|
633
|
+
/>
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
if (field.kind === 'directory-department') {
|
|
637
|
+
return <ResolvedValueText source="department" value={String(value)} />;
|
|
638
|
+
}
|
|
639
|
+
if (field.kind === 'boolean') {
|
|
640
|
+
return <Tag color={value ? 'green' : 'default'}>{value ? '是' : '否'}</Tag>;
|
|
641
|
+
}
|
|
298
642
|
if (field.kind === 'file') {
|
|
299
|
-
const values = Array.isArray(value) ? value : [value]
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
: String(item)
|
|
305
|
-
)
|
|
306
|
-
.join('、');
|
|
643
|
+
const values = (Array.isArray(value) ? value : [value]).filter(
|
|
644
|
+
(item): item is DataFileRef =>
|
|
645
|
+
Boolean(item && typeof item === 'object' && 'id' in item)
|
|
646
|
+
);
|
|
647
|
+
return <AttachmentReadOnly values={values} />;
|
|
307
648
|
}
|
|
308
|
-
if (Array.isArray(value))
|
|
649
|
+
if (Array.isArray(value)) {
|
|
309
650
|
return (
|
|
310
651
|
value
|
|
311
652
|
.map(
|
|
@@ -315,8 +656,29 @@ function displayValue(field: InstrumentField, value: unknown) {
|
|
|
315
656
|
)
|
|
316
657
|
.join('、') || '-'
|
|
317
658
|
);
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
String(value)
|
|
321
|
-
)
|
|
659
|
+
}
|
|
660
|
+
const label =
|
|
661
|
+
field.options?.find(option => option.value === value)?.label || String(value);
|
|
662
|
+
if (field.key === 'instrumentStatus' || field.key === 'usageStatus') {
|
|
663
|
+
return <Tag color={value === 'normal' || value === 'active' ? 'green' : 'default'}>{label}</Tag>;
|
|
664
|
+
}
|
|
665
|
+
return label;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function auditLabel(operation: DataAuditEntry['operation']) {
|
|
669
|
+
return {
|
|
670
|
+
created: '创建仪器档案',
|
|
671
|
+
updated: '更新仪器信息',
|
|
672
|
+
deleted: '删除仪器档案',
|
|
673
|
+
}[operation];
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function formatDateTime(value?: string) {
|
|
677
|
+
return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '-';
|
|
678
|
+
}
|
|
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`;
|
|
322
684
|
}
|