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.
Files changed (35) 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 +912 -9
  12. package/template/apps/web/package.json +1 -1
  13. package/template/apps/web/scripts/check.mjs +2 -2
  14. package/template/apps/web/src/AuthoritativeSelector.tsx +371 -0
  15. package/template/apps/web/src/CollegePage.tsx +181 -0
  16. package/template/apps/web/src/InstrumentDetailPage.tsx +94 -20
  17. package/template/apps/web/src/InstrumentForm.tsx +450 -88
  18. package/template/apps/web/src/InstrumentFormPage.tsx +49 -9
  19. package/template/apps/web/src/InstrumentListPage.tsx +30 -35
  20. package/template/apps/web/src/Shell.tsx +229 -41
  21. package/template/apps/web/src/components/platform-fields/PlatformDirectoryPicker.tsx +698 -0
  22. package/template/apps/web/src/data-provider.ts +1 -1
  23. package/template/apps/web/src/fields.ts +17 -6
  24. package/template/apps/web/src/instrument.ts +1 -1
  25. package/template/apps/web/src/main.tsx +16 -1
  26. package/template/apps/web/src/platform-client.ts +213 -2
  27. package/template/apps/web/src/runtime.tsx +4 -2
  28. package/template/apps/web/src/styles.css +869 -29
  29. package/template/apps/web/test/contracts.test.ts +369 -10
  30. package/template/openxiangda.config.ts +24 -5
  31. package/template/package.json +3 -3
  32. package/template/packages/contracts/src/generated.ts +22 -0
  33. package/template/platform/data/colleges.ts +21 -0
  34. package/template/platform/data/instruments.ts +1 -1
  35. 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 { instrumentDataApi } from './platform-client';
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) return <Detail record={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
- const values = await form.validateFields();
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
- await onSubmit?.(
89
- sanitizeInstrumentValues(normalized, writeMode, hasCapability)
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
- initialValues={
97
- isCreate
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
- {Object.entries(sectionTitles).map(([section, title]) => (
108
- <Card key={section} title={title}>
157
+ {groups.map(group => (
158
+ <Card className="oxa-section-card" key={group.key} title={group.title}>
109
159
  <div className="oxa-grid">
110
- {instrumentFields
111
- .filter(field => field.section === section && !field.system)
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}>取消</Button>
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
- if (field.kind === 'number')
221
+ }
222
+ if (field.kind === 'number') {
165
223
  control = <InputNumber {...common} min={0} style={{ width: '100%' }} />;
166
- if (field.kind === 'date')
224
+ }
225
+ if (field.kind === 'date') {
167
226
  control = <DatePicker disabled={disabled} style={{ width: '100%' }} />;
168
- if (field.kind === 'boolean')
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
- if (field.kind === 'select')
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
- if (field.kind === 'multi')
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
- if (field.kind === 'file')
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
- <Upload
243
- accept={accept}
244
- disabled={disabled}
245
- fileList={files}
246
- maxCount={maxCount}
247
- multiple={multiple}
248
- customRequest={async options => {
249
- try {
250
- const uploaded = await instrumentDataApi.upload(
251
- fieldCode,
252
- options.file as File,
253
- recordId
254
- );
255
- onChange?.(multiple ? [...refs, uploaded] : uploaded);
256
- options.onSuccess?.(uploaded);
257
- } catch (error) {
258
- options.onError?.(
259
- error instanceof Error ? error : new Error(String(error))
260
- );
261
- }
262
- }}
263
- onRemove={file => {
264
- const next = refs.filter(item => item.id !== file.uid);
265
- onChange?.(multiple ? next : undefined);
266
- return true;
267
- }}
268
- >
269
- <Button icon={<UploadOutlined />}>上传</Button>
270
- </Upload>
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({ record }: { record: InstrumentRecord }) {
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-sections">
277
- {Object.entries(sectionTitles).map(([section, title]) => (
278
- <Card key={section} title={title}>
279
- <Descriptions
280
- column={2}
281
- items={instrumentFields
282
- .filter(field => field.section === section)
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 === 'boolean') return value ? '是' : '否';
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
- return values
301
- .map(item =>
302
- item && typeof item === 'object' && 'name' in item
303
- ? String(item.name)
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
- return (
319
- field.options?.find(option => option.value === value)?.label ||
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
  }