openxiangda-cli 2.0.0-alpha.59 → 2.0.0-alpha.61
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/create-workspace.d.ts +1 -0
- package/dist/create-workspace.d.ts.map +1 -1
- package/package.json +4 -4
- package/template/apps/server/package.json +1 -1
- package/template/apps/web/e2e/instruments.spec.ts +1 -1
- package/template/apps/web/package.json +1 -1
- package/template/apps/web/scripts/check.mjs +5 -2
- package/template/apps/web/src/InstrumentDetailPage.tsx +119 -77
- package/template/apps/web/src/InstrumentForm.tsx +119 -314
- package/template/apps/web/src/InstrumentFormPage.tsx +40 -75
- package/template/apps/web/src/InstrumentListPage.tsx +264 -192
- package/template/apps/web/src/components/resource/GeneratedResourceCrud.tsx +285 -0
- package/template/apps/web/src/components/resource/StandardResourcePages.tsx +431 -0
- package/template/apps/web/src/components/resource/SurfaceFields.tsx +469 -0
- package/template/apps/web/src/data-provider.ts +85 -1
- package/template/apps/web/src/fields.ts +9 -74
- package/template/apps/web/src/instrument.ts +1 -0
- package/template/apps/web/src/main.tsx +32 -2
- package/template/apps/web/src/platform-client.ts +95 -2
- package/template/apps/web/src/styles.css +97 -0
- package/template/apps/web/test/contracts.test.ts +80 -4
- package/template/package.json +3 -3
- package/template/packages/contracts/src/generated.ts +674 -0
- package/template/platform/data/instrument-surface.ts +125 -0
- package/template/platform/data/instruments.ts +2 -0
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PictureOutlined,
|
|
3
|
+
ReloadOutlined,
|
|
4
|
+
UploadOutlined,
|
|
5
|
+
} from '@ant-design/icons';
|
|
6
|
+
import {
|
|
7
|
+
Alert,
|
|
8
|
+
Button,
|
|
9
|
+
DatePicker,
|
|
10
|
+
Form,
|
|
11
|
+
Input,
|
|
12
|
+
InputNumber,
|
|
13
|
+
Select,
|
|
14
|
+
Space,
|
|
15
|
+
Switch,
|
|
16
|
+
Tag,
|
|
17
|
+
Upload,
|
|
18
|
+
} from 'antd';
|
|
19
|
+
import type {
|
|
20
|
+
DataFieldSurface,
|
|
21
|
+
DataFileRef,
|
|
22
|
+
} from 'openxiangda-contracts/browser';
|
|
23
|
+
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
|
24
|
+
import { ResolvedValueText } from '../../AuthoritativeSelector';
|
|
25
|
+
import { AttachmentFileList } from '../platform-fields/AttachmentFileList';
|
|
26
|
+
import { PlatformDirectoryPicker } from '../platform-fields/PlatformDirectoryPicker';
|
|
27
|
+
|
|
28
|
+
export type SurfaceField = DataFieldSurface & { key: string };
|
|
29
|
+
|
|
30
|
+
export function listSurfaceFields(fields: Record<string, DataFieldSurface>) {
|
|
31
|
+
return Object.entries(fields)
|
|
32
|
+
.filter(([, field]) => field.list)
|
|
33
|
+
.map(([key, field]) => ({ key, ...field }));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SurfaceFieldEditContext {
|
|
37
|
+
field: SurfaceField;
|
|
38
|
+
disabled: boolean;
|
|
39
|
+
operation: 'create' | 'update';
|
|
40
|
+
recordId?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface SurfaceFieldValueContext {
|
|
44
|
+
field: SurfaceField;
|
|
45
|
+
value: unknown;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SurfaceFieldRenderers {
|
|
49
|
+
renderExtra?: (context: SurfaceFieldEditContext) => ReactNode;
|
|
50
|
+
renderScope?: (context: SurfaceFieldEditContext) => ReactNode;
|
|
51
|
+
renderFilter?: (context: SurfaceFilterContext) => ReactNode;
|
|
52
|
+
upload?: (
|
|
53
|
+
field: SurfaceField,
|
|
54
|
+
file: File,
|
|
55
|
+
recordId?: string
|
|
56
|
+
) => Promise<DataFileRef>;
|
|
57
|
+
renderValue?: (context: SurfaceFieldValueContext) => ReactNode | undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface SurfaceFilterContext {
|
|
61
|
+
field: SurfaceField;
|
|
62
|
+
value: unknown;
|
|
63
|
+
onChange: (value: unknown) => void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Render a standard filter control from the same field surface used by forms. */
|
|
67
|
+
export function SurfaceFilterControl({
|
|
68
|
+
field,
|
|
69
|
+
value,
|
|
70
|
+
onChange,
|
|
71
|
+
range = false,
|
|
72
|
+
renderers,
|
|
73
|
+
}: SurfaceFilterContext & { range?: boolean; renderers?: Pick<SurfaceFieldRenderers, 'renderScope' | 'renderFilter'> }) {
|
|
74
|
+
const custom = renderers?.renderFilter?.({ field, value, onChange });
|
|
75
|
+
if (custom !== undefined) return custom;
|
|
76
|
+
const placeholder = `筛选${field.label}`;
|
|
77
|
+
switch (field.widget) {
|
|
78
|
+
case 'boolean':
|
|
79
|
+
return (
|
|
80
|
+
<Select
|
|
81
|
+
allowClear
|
|
82
|
+
options={[{ label: '是', value: true }, { label: '否', value: false }]}
|
|
83
|
+
placeholder={placeholder}
|
|
84
|
+
value={value}
|
|
85
|
+
onChange={onChange}
|
|
86
|
+
/>
|
|
87
|
+
);
|
|
88
|
+
case 'select':
|
|
89
|
+
case 'multi':
|
|
90
|
+
return (
|
|
91
|
+
<Select
|
|
92
|
+
allowClear
|
|
93
|
+
mode={field.widget === 'multi' ? 'multiple' : undefined}
|
|
94
|
+
options={field.options}
|
|
95
|
+
placeholder={placeholder}
|
|
96
|
+
value={value}
|
|
97
|
+
onChange={onChange}
|
|
98
|
+
/>
|
|
99
|
+
);
|
|
100
|
+
case 'date':
|
|
101
|
+
case 'datetime':
|
|
102
|
+
return range ? (
|
|
103
|
+
<DatePicker.RangePicker
|
|
104
|
+
placeholder={[`起始${field.label}`, `结束${field.label}`]}
|
|
105
|
+
showTime={field.widget === 'datetime'}
|
|
106
|
+
onChange={(_, values) => onChange(values[0] && values[1] ? [values[0], values[1]] : undefined)}
|
|
107
|
+
/>
|
|
108
|
+
) : <DatePicker showTime={field.widget === 'datetime'} onChange={onChange} placeholder={placeholder} />;
|
|
109
|
+
case 'number':
|
|
110
|
+
case 'money':
|
|
111
|
+
case 'percent':
|
|
112
|
+
if (range) {
|
|
113
|
+
const values = Array.isArray(value) ? value as [number | undefined, number | undefined] : [];
|
|
114
|
+
return (
|
|
115
|
+
<Space.Compact>
|
|
116
|
+
<InputNumber min={0} onChange={next => onChange([next === null ? undefined : Number(next), values[1]])} placeholder={`最低${field.label}`} value={values[0]} />
|
|
117
|
+
<InputNumber min={0} onChange={next => onChange([values[0], next === null ? undefined : Number(next)])} placeholder={`最高${field.label}`} value={values[1]} />
|
|
118
|
+
</Space.Compact>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return <InputNumber min={0} onChange={onChange} placeholder={placeholder} value={value as number | null | undefined} />;
|
|
122
|
+
case 'scope':
|
|
123
|
+
return renderers?.renderScope?.({ field, disabled: false, operation: 'update' }) || <Input allowClear value={value as string | undefined} onChange={event => onChange(event.target.value)} placeholder={placeholder} />;
|
|
124
|
+
case 'directory-user':
|
|
125
|
+
case 'directory-department':
|
|
126
|
+
return <PlatformDirectoryPicker kind={field.widget === 'directory-user' ? 'user' : 'department'} multiple={field.multiple} onChange={onChange as (value: string | string[] | undefined) => void} placeholder={placeholder} value={value as string | string[] | undefined} />;
|
|
127
|
+
default:
|
|
128
|
+
return <Input allowClear value={value as string | undefined} onChange={event => onChange(event.target.value)} placeholder={placeholder} />;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function SurfaceFieldControl({
|
|
133
|
+
field,
|
|
134
|
+
disabled,
|
|
135
|
+
operation,
|
|
136
|
+
recordId,
|
|
137
|
+
renderers,
|
|
138
|
+
}: SurfaceFieldEditContext & { renderers?: SurfaceFieldRenderers }) {
|
|
139
|
+
const rules = [
|
|
140
|
+
...(field.requiredHint
|
|
141
|
+
? [{ required: true, message: `请填写或选择${field.label}` }]
|
|
142
|
+
: []),
|
|
143
|
+
...(field.widget === 'email'
|
|
144
|
+
? [{ type: 'email' as const, message: '邮箱格式不正确' }]
|
|
145
|
+
: []),
|
|
146
|
+
...(field.widget === 'phone'
|
|
147
|
+
? [{ pattern: /^1\d{10}$/, message: '请输入 11 位手机号' }]
|
|
148
|
+
: []),
|
|
149
|
+
];
|
|
150
|
+
const common = { disabled, placeholder: `请输入${field.label}` };
|
|
151
|
+
let control: ReactNode = <Input {...common} />;
|
|
152
|
+
switch (field.widget) {
|
|
153
|
+
case 'textarea':
|
|
154
|
+
control = <Input.TextArea {...common} rows={3} />;
|
|
155
|
+
break;
|
|
156
|
+
case 'number':
|
|
157
|
+
case 'money':
|
|
158
|
+
case 'percent':
|
|
159
|
+
control = <InputNumber {...common} min={0} style={{ width: '100%' }} />;
|
|
160
|
+
break;
|
|
161
|
+
case 'date':
|
|
162
|
+
case 'datetime':
|
|
163
|
+
control = <DatePicker disabled={disabled} showTime={field.widget === 'datetime'} style={{ width: '100%' }} />;
|
|
164
|
+
break;
|
|
165
|
+
case 'boolean':
|
|
166
|
+
control = <Switch checkedChildren="是" disabled={disabled} unCheckedChildren="否" />;
|
|
167
|
+
break;
|
|
168
|
+
case 'select':
|
|
169
|
+
control = <Select disabled={disabled} options={field.options} placeholder={`请选择${field.label}`} />;
|
|
170
|
+
break;
|
|
171
|
+
case 'multi':
|
|
172
|
+
control = <Select disabled={disabled} mode="multiple" options={field.options} placeholder={`请选择${field.label}`} />;
|
|
173
|
+
break;
|
|
174
|
+
case 'email':
|
|
175
|
+
control = <Input {...common} type="email" />;
|
|
176
|
+
break;
|
|
177
|
+
case 'phone':
|
|
178
|
+
control = <Input {...common} type="tel" />;
|
|
179
|
+
break;
|
|
180
|
+
case 'scope':
|
|
181
|
+
control = renderers?.renderScope?.({ field, disabled, operation, recordId }) || (
|
|
182
|
+
<Input {...common} />
|
|
183
|
+
);
|
|
184
|
+
break;
|
|
185
|
+
case 'directory-user':
|
|
186
|
+
case 'directory-department':
|
|
187
|
+
control = (
|
|
188
|
+
<PlatformDirectoryPicker
|
|
189
|
+
disabled={disabled}
|
|
190
|
+
kind={field.widget === 'directory-user' ? 'user' : 'department'}
|
|
191
|
+
multiple={field.multiple}
|
|
192
|
+
placeholder={`搜索并选择${field.label}`}
|
|
193
|
+
/>
|
|
194
|
+
);
|
|
195
|
+
break;
|
|
196
|
+
case 'file':
|
|
197
|
+
control = (
|
|
198
|
+
<ManagedFileField
|
|
199
|
+
accept={field.accept}
|
|
200
|
+
disabled={disabled}
|
|
201
|
+
field={field}
|
|
202
|
+
maxCount={field.maxCount}
|
|
203
|
+
multiple={field.multiple}
|
|
204
|
+
onUpload={renderers?.upload}
|
|
205
|
+
recordId={recordId}
|
|
206
|
+
/>
|
|
207
|
+
);
|
|
208
|
+
break;
|
|
209
|
+
case 'json':
|
|
210
|
+
control = <Input.TextArea {...common} rows={4} />;
|
|
211
|
+
break;
|
|
212
|
+
case 'readonly':
|
|
213
|
+
control = <Input {...common} readOnly />;
|
|
214
|
+
break;
|
|
215
|
+
case 'text':
|
|
216
|
+
default:
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
const extra =
|
|
220
|
+
renderers?.renderExtra?.({ field, disabled, operation, recordId }) ||
|
|
221
|
+
(disabled ? '当前角色不可修改此字段' : undefined);
|
|
222
|
+
const wide = field.widget === 'textarea' || field.widget === 'file' || field.widget === 'json';
|
|
223
|
+
return (
|
|
224
|
+
<Form.Item
|
|
225
|
+
className={wide ? 'oxa-field-wide' : undefined}
|
|
226
|
+
extra={extra}
|
|
227
|
+
label={field.label}
|
|
228
|
+
name={field.key}
|
|
229
|
+
rules={rules}
|
|
230
|
+
valuePropName={field.widget === 'boolean' ? 'checked' : 'value'}
|
|
231
|
+
>
|
|
232
|
+
{control}
|
|
233
|
+
</Form.Item>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Mobile keeps the same field values and validation but uses native controls. */
|
|
238
|
+
export function MobileSurfaceFieldControl({
|
|
239
|
+
field,
|
|
240
|
+
disabled,
|
|
241
|
+
operation,
|
|
242
|
+
recordId,
|
|
243
|
+
renderers,
|
|
244
|
+
}: SurfaceFieldEditContext & { renderers?: SurfaceFieldRenderers }) {
|
|
245
|
+
const rules = field.requiredHint
|
|
246
|
+
? [{ required: true, message: `请填写或选择${field.label}` }]
|
|
247
|
+
: [];
|
|
248
|
+
const inputProps = { disabled, className: 'oxa-mobile-input', placeholder: `请输入${field.label}` };
|
|
249
|
+
let control: ReactNode = <input {...inputProps} />;
|
|
250
|
+
switch (field.widget) {
|
|
251
|
+
case 'textarea':
|
|
252
|
+
case 'json':
|
|
253
|
+
control = <textarea {...inputProps} rows={4} />;
|
|
254
|
+
break;
|
|
255
|
+
case 'number':
|
|
256
|
+
case 'money':
|
|
257
|
+
case 'percent':
|
|
258
|
+
control = <input {...inputProps} min={0} type="number" />;
|
|
259
|
+
break;
|
|
260
|
+
case 'date':
|
|
261
|
+
control = <input {...inputProps} type="date" />;
|
|
262
|
+
break;
|
|
263
|
+
case 'datetime':
|
|
264
|
+
control = <input {...inputProps} type="datetime-local" />;
|
|
265
|
+
break;
|
|
266
|
+
case 'boolean':
|
|
267
|
+
control = <input disabled={disabled} type="checkbox" />;
|
|
268
|
+
break;
|
|
269
|
+
case 'select':
|
|
270
|
+
case 'multi':
|
|
271
|
+
control = (
|
|
272
|
+
<select
|
|
273
|
+
className="oxa-mobile-input"
|
|
274
|
+
disabled={disabled}
|
|
275
|
+
multiple={field.widget === 'multi'}
|
|
276
|
+
>
|
|
277
|
+
{field.options?.map(option => (
|
|
278
|
+
<option key={option.value} value={option.value}>
|
|
279
|
+
{option.label}
|
|
280
|
+
</option>
|
|
281
|
+
))}
|
|
282
|
+
</select>
|
|
283
|
+
);
|
|
284
|
+
break;
|
|
285
|
+
case 'email':
|
|
286
|
+
control = <input {...inputProps} type="email" />;
|
|
287
|
+
break;
|
|
288
|
+
case 'phone':
|
|
289
|
+
control = <input {...inputProps} type="tel" />;
|
|
290
|
+
break;
|
|
291
|
+
case 'scope':
|
|
292
|
+
control = renderers?.renderScope?.({ field, disabled, operation, recordId }) || (
|
|
293
|
+
<input {...inputProps} />
|
|
294
|
+
);
|
|
295
|
+
break;
|
|
296
|
+
case 'directory-user':
|
|
297
|
+
case 'directory-department':
|
|
298
|
+
control = (
|
|
299
|
+
<PlatformDirectoryPicker
|
|
300
|
+
disabled={disabled}
|
|
301
|
+
kind={field.widget === 'directory-user' ? 'user' : 'department'}
|
|
302
|
+
multiple={field.multiple}
|
|
303
|
+
placeholder={`搜索并选择${field.label}`}
|
|
304
|
+
/>
|
|
305
|
+
);
|
|
306
|
+
break;
|
|
307
|
+
case 'file':
|
|
308
|
+
control = (
|
|
309
|
+
<ManagedFileField
|
|
310
|
+
accept={field.accept}
|
|
311
|
+
disabled={disabled}
|
|
312
|
+
field={field}
|
|
313
|
+
maxCount={field.maxCount}
|
|
314
|
+
multiple={field.multiple}
|
|
315
|
+
onUpload={renderers?.upload}
|
|
316
|
+
recordId={recordId}
|
|
317
|
+
/>
|
|
318
|
+
);
|
|
319
|
+
break;
|
|
320
|
+
case 'readonly':
|
|
321
|
+
control = <input {...inputProps} readOnly />;
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
return (
|
|
325
|
+
<Form.Item
|
|
326
|
+
className="oxa-mobile-field"
|
|
327
|
+
extra={renderers?.renderExtra?.({ field, disabled, operation, recordId })}
|
|
328
|
+
label={field.label}
|
|
329
|
+
name={field.key}
|
|
330
|
+
rules={rules}
|
|
331
|
+
valuePropName={field.widget === 'boolean' ? 'checked' : 'value'}
|
|
332
|
+
>
|
|
333
|
+
{control}
|
|
334
|
+
</Form.Item>
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function SurfaceFieldValue({
|
|
339
|
+
field,
|
|
340
|
+
value,
|
|
341
|
+
renderers,
|
|
342
|
+
}: SurfaceFieldValueContext & { renderers?: SurfaceFieldRenderers }) {
|
|
343
|
+
if (value === undefined || value === null || value === '') return <>-</>;
|
|
344
|
+
const custom = renderers?.renderValue?.({ field, value });
|
|
345
|
+
if (custom !== undefined) return <>{custom}</>;
|
|
346
|
+
if (field.widget === 'directory-user' || field.widget === 'directory-department') {
|
|
347
|
+
return (
|
|
348
|
+
<ResolvedValueText
|
|
349
|
+
source={field.widget === 'directory-user' ? 'user' : 'department'}
|
|
350
|
+
value={Array.isArray(value) ? value.map(String) : String(value)}
|
|
351
|
+
/>
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
if (field.widget === 'file') {
|
|
355
|
+
const values = (Array.isArray(value) ? value : [value]).filter(isFileRef);
|
|
356
|
+
return values.length ? <AttachmentFileList files={values} /> : <>-</>;
|
|
357
|
+
}
|
|
358
|
+
if (field.widget === 'boolean') {
|
|
359
|
+
return <Tag color={value ? 'green' : 'default'}>{value ? '是' : '否'}</Tag>;
|
|
360
|
+
}
|
|
361
|
+
if (Array.isArray(value)) {
|
|
362
|
+
return <>{value.map(item => optionLabel(field, item)).join('、') || '-'}</>;
|
|
363
|
+
}
|
|
364
|
+
if (field.widget === 'date' || field.widget === 'datetime') {
|
|
365
|
+
const parsed = new Date(String(value));
|
|
366
|
+
return <>{Number.isNaN(parsed.getTime()) ? String(value) : parsed.toLocaleString('zh-CN', { hour12: false })}</>;
|
|
367
|
+
}
|
|
368
|
+
return <>{optionLabel(field, value)}</>;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function optionLabel(field: SurfaceField, value: unknown) {
|
|
372
|
+
return field.options?.find(option => option.value === value)?.label || String(value);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function isFileRef(value: unknown): value is DataFileRef {
|
|
376
|
+
return Boolean(value && typeof value === 'object' && 'id' in value);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function ManagedFileField({
|
|
380
|
+
field,
|
|
381
|
+
value,
|
|
382
|
+
onChange,
|
|
383
|
+
recordId,
|
|
384
|
+
disabled,
|
|
385
|
+
multiple,
|
|
386
|
+
maxCount = 1,
|
|
387
|
+
accept,
|
|
388
|
+
onUpload,
|
|
389
|
+
}: {
|
|
390
|
+
field: SurfaceField;
|
|
391
|
+
value?: DataFileRef | DataFileRef[];
|
|
392
|
+
onChange?: (value: DataFileRef | DataFileRef[] | undefined) => void;
|
|
393
|
+
recordId?: string;
|
|
394
|
+
disabled?: boolean;
|
|
395
|
+
multiple?: boolean;
|
|
396
|
+
maxCount?: number;
|
|
397
|
+
accept?: string | string[];
|
|
398
|
+
onUpload?: SurfaceFieldRenderers['upload'];
|
|
399
|
+
}) {
|
|
400
|
+
const [uploading, setUploading] = useState(false);
|
|
401
|
+
const [uploadError, setUploadError] = useState('');
|
|
402
|
+
const refs = useMemo(() => (Array.isArray(value) ? value : value ? [value] : []), [value]);
|
|
403
|
+
const refsRef = useRef(refs);
|
|
404
|
+
useEffect(() => {
|
|
405
|
+
refsRef.current = refs;
|
|
406
|
+
}, [refs]);
|
|
407
|
+
if (!onUpload) {
|
|
408
|
+
return <Alert type="info" showIcon message="该文件字段尚未配置上传能力" />;
|
|
409
|
+
}
|
|
410
|
+
const uploadProps = {
|
|
411
|
+
accept: Array.isArray(accept) ? accept.join(',') : accept,
|
|
412
|
+
disabled,
|
|
413
|
+
maxCount,
|
|
414
|
+
multiple,
|
|
415
|
+
customRequest: async (options: {
|
|
416
|
+
file: string | Blob;
|
|
417
|
+
onError?: (error: Error) => void;
|
|
418
|
+
onProgress?: (event: { percent: number }) => void;
|
|
419
|
+
onSuccess?: (body: unknown) => void;
|
|
420
|
+
}) => {
|
|
421
|
+
setUploading(true);
|
|
422
|
+
setUploadError('');
|
|
423
|
+
try {
|
|
424
|
+
options.onProgress?.({ percent: 15 });
|
|
425
|
+
const uploaded = await onUpload(field, options.file as File, recordId);
|
|
426
|
+
options.onProgress?.({ percent: 100 });
|
|
427
|
+
const next = multiple ? [...refsRef.current, uploaded] : uploaded;
|
|
428
|
+
refsRef.current = Array.isArray(next) ? next : [next];
|
|
429
|
+
onChange?.(next);
|
|
430
|
+
options.onSuccess?.(uploaded);
|
|
431
|
+
} catch (error) {
|
|
432
|
+
const failure = error instanceof Error ? error : new Error(String(error));
|
|
433
|
+
setUploadError(failure.message);
|
|
434
|
+
options.onError?.(failure);
|
|
435
|
+
} finally {
|
|
436
|
+
setUploading(false);
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
showUploadList: false,
|
|
440
|
+
};
|
|
441
|
+
return (
|
|
442
|
+
<div className="oxa-file-field">
|
|
443
|
+
<Upload {...uploadProps} pastable>
|
|
444
|
+
<Button disabled={disabled || uploading || refs.length >= maxCount} icon={field.accept?.toString().startsWith('image/') ? <PictureOutlined /> : <UploadOutlined />} loading={uploading}>
|
|
445
|
+
{field.accept?.toString().startsWith('image/') ? '上传图片' : '上传文件'}
|
|
446
|
+
</Button>
|
|
447
|
+
</Upload>
|
|
448
|
+
<Upload.Dragger {...uploadProps} className="oxa-file-dropzone" openFileDialogOnClick={false} pastable>
|
|
449
|
+
<span>可将文件拖到这里,或在此区域按 <kbd>⌘ V</kbd> 粘贴</span>
|
|
450
|
+
<small>{multiple ? `最多 ${maxCount} 个文件,单个不超过 50MB` : '仅保留一个文件,新上传会替换当前文件'}</small>
|
|
451
|
+
</Upload.Dragger>
|
|
452
|
+
{uploadError && (
|
|
453
|
+
<div className="oxa-file-error">
|
|
454
|
+
<span>{uploadError}</span>
|
|
455
|
+
<Button icon={<ReloadOutlined />} onClick={() => setUploadError('')} size="small" type="text">关闭</Button>
|
|
456
|
+
</div>
|
|
457
|
+
)}
|
|
458
|
+
{refs.length ? (
|
|
459
|
+
<AttachmentFileList files={refs} onRemove={file => {
|
|
460
|
+
const next = refs.filter(item => item.id !== file.id);
|
|
461
|
+
refsRef.current = next;
|
|
462
|
+
onChange?.(multiple ? next : undefined);
|
|
463
|
+
}} removable={!disabled} />
|
|
464
|
+
) : (
|
|
465
|
+
<div className="oxa-file-empty">尚未上传文件</div>
|
|
466
|
+
)}
|
|
467
|
+
</div>
|
|
468
|
+
);
|
|
469
|
+
}
|
|
@@ -1,7 +1,29 @@
|
|
|
1
1
|
import type { DataProvider } from '@refinedev/core';
|
|
2
2
|
import type { InstrumentListQuery, InstrumentRecord } from './instrument';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
createNativeResourceClient,
|
|
5
|
+
instrumentDataApi,
|
|
6
|
+
type GenericResourceQuery,
|
|
7
|
+
} from './platform-client';
|
|
4
8
|
import { applicationCode } from './runtime-meta';
|
|
9
|
+
import { resourceDefinitions } from '../../../packages/contracts/src/generated.js';
|
|
10
|
+
|
|
11
|
+
type GeneratedDefinition = {
|
|
12
|
+
code: string;
|
|
13
|
+
name: string;
|
|
14
|
+
capabilities: { read: string; create: string; update: string; delete: string };
|
|
15
|
+
surface: import('openxiangda-contracts/browser').DataResourceSurface;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const generatedDefinitions = resourceDefinitions as unknown as Record<string, GeneratedDefinition>;
|
|
19
|
+
|
|
20
|
+
function generatedClient(resource: string) {
|
|
21
|
+
const definition = generatedDefinitions[resource];
|
|
22
|
+
if (!definition || definition.surface.generated === false) {
|
|
23
|
+
throw new Error(`OPENXIANGDA_GENERATED_RESOURCE_NOT_DECLARED:${resource}`);
|
|
24
|
+
}
|
|
25
|
+
return createNativeResourceClient(resource, definition.surface);
|
|
26
|
+
}
|
|
5
27
|
|
|
6
28
|
export const instrumentProvider = {
|
|
7
29
|
getApiUrl: () =>
|
|
@@ -40,3 +62,65 @@ export const instrumentProvider = {
|
|
|
40
62
|
),
|
|
41
63
|
}),
|
|
42
64
|
} as DataProvider;
|
|
65
|
+
|
|
66
|
+
const generatedProvider = {
|
|
67
|
+
getApiUrl: () =>
|
|
68
|
+
`/service/openxiangda-api/v2/applications/${applicationCode()}/native/data`,
|
|
69
|
+
getList: async ({ resource, pagination, sorters, meta }) => {
|
|
70
|
+
const query = (meta?.query || {}) as Partial<GenericResourceQuery>;
|
|
71
|
+
const sorter = sorters?.[0];
|
|
72
|
+
const result = await generatedClient(resource).list({
|
|
73
|
+
...query,
|
|
74
|
+
page: pagination?.currentPage || 1,
|
|
75
|
+
pageSize: pagination?.pageSize || 20,
|
|
76
|
+
sort: sorter
|
|
77
|
+
? { field: String(sorter.field), order: sorter.order || 'asc' }
|
|
78
|
+
: query.sort,
|
|
79
|
+
});
|
|
80
|
+
return { data: result.rows, total: result.total };
|
|
81
|
+
},
|
|
82
|
+
getOne: async ({ resource, id }) => ({
|
|
83
|
+
data: await generatedClient(resource).get(String(id)),
|
|
84
|
+
}),
|
|
85
|
+
create: async ({ resource, variables }) => ({
|
|
86
|
+
data: await generatedClient(resource).create(variables as Record<string, unknown>),
|
|
87
|
+
}),
|
|
88
|
+
update: async ({ resource, id, variables, meta }) => ({
|
|
89
|
+
data: await generatedClient(resource).update(
|
|
90
|
+
String(id),
|
|
91
|
+
Number(meta?.expectedRevision),
|
|
92
|
+
variables as Record<string, unknown>
|
|
93
|
+
),
|
|
94
|
+
}),
|
|
95
|
+
deleteOne: async ({ resource, id, meta }) => ({
|
|
96
|
+
data: await generatedClient(resource).remove(
|
|
97
|
+
String(id),
|
|
98
|
+
Number(meta?.expectedRevision)
|
|
99
|
+
),
|
|
100
|
+
}),
|
|
101
|
+
} as DataProvider;
|
|
102
|
+
|
|
103
|
+
/** One Refine provider: golden modules may override a resource, simple resources use generated CRUD. */
|
|
104
|
+
export const applicationProvider = {
|
|
105
|
+
getApiUrl: instrumentProvider.getApiUrl,
|
|
106
|
+
getList: (args: Parameters<NonNullable<DataProvider['getList']>>[0]) =>
|
|
107
|
+
args.resource === 'instruments'
|
|
108
|
+
? instrumentProvider.getList!(args)
|
|
109
|
+
: generatedProvider.getList!(args),
|
|
110
|
+
getOne: (args: Parameters<NonNullable<DataProvider['getOne']>>[0]) =>
|
|
111
|
+
args.resource === 'instruments'
|
|
112
|
+
? instrumentProvider.getOne!(args)
|
|
113
|
+
: generatedProvider.getOne!(args),
|
|
114
|
+
create: (args: Parameters<NonNullable<DataProvider['create']>>[0]) =>
|
|
115
|
+
args.resource === 'instruments'
|
|
116
|
+
? instrumentProvider.create!(args)
|
|
117
|
+
: generatedProvider.create!(args),
|
|
118
|
+
update: (args: Parameters<NonNullable<DataProvider['update']>>[0]) =>
|
|
119
|
+
args.resource === 'instruments'
|
|
120
|
+
? instrumentProvider.update!(args)
|
|
121
|
+
: generatedProvider.update!(args),
|
|
122
|
+
deleteOne: (args: Parameters<NonNullable<DataProvider['deleteOne']>>[0]) =>
|
|
123
|
+
args.resource === 'instruments'
|
|
124
|
+
? instrumentProvider.deleteOne!(args)
|
|
125
|
+
: generatedProvider.deleteOne!(args),
|
|
126
|
+
} as DataProvider;
|
|
@@ -1,78 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
| 'multi'
|
|
9
|
-
| 'email'
|
|
10
|
-
| 'phone'
|
|
11
|
-
| 'file'
|
|
12
|
-
| 'scope'
|
|
13
|
-
| 'directory-user'
|
|
14
|
-
| 'directory-department';
|
|
1
|
+
import {
|
|
2
|
+
instrumentFields,
|
|
3
|
+
instrumentSurface,
|
|
4
|
+
sectionTitles,
|
|
5
|
+
type FieldKind,
|
|
6
|
+
type InstrumentField,
|
|
7
|
+
} from '../../../platform/data/instrument-surface.js';
|
|
15
8
|
|
|
16
|
-
export
|
|
17
|
-
|
|
18
|
-
label: string;
|
|
19
|
-
kind: FieldKind;
|
|
20
|
-
section: keyof typeof sectionTitles;
|
|
21
|
-
required?: boolean;
|
|
22
|
-
system?: boolean;
|
|
23
|
-
createCapability?: string;
|
|
24
|
-
updateCapability?: string;
|
|
25
|
-
multiple?: boolean;
|
|
26
|
-
maxCount?: number;
|
|
27
|
-
accept?: string;
|
|
28
|
-
options?: Array<{ label: string; value: string }>;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const options = (pairs: Array<[string, string]>) =>
|
|
32
|
-
pairs.map(([label, value]) => ({ label, value }));
|
|
33
|
-
|
|
34
|
-
export const sectionTitles = {
|
|
35
|
-
basic: '基本信息',
|
|
36
|
-
asset: '资产信息',
|
|
37
|
-
usage: '使用与归属',
|
|
38
|
-
management: '管理与联系',
|
|
39
|
-
materials: '地址与资料',
|
|
40
|
-
} as const;
|
|
41
|
-
|
|
42
|
-
// 30 fields: id/revision are Data API system fields; the remaining 28 are
|
|
43
|
-
// declared by platform/data/instruments.ts.
|
|
44
|
-
export const instrumentFields: InstrumentField[] = [
|
|
45
|
-
{ key: 'id', label: '记录 ID', kind: 'text', section: 'basic', system: true },
|
|
46
|
-
{ key: 'revision', label: '修订版本', kind: 'number', section: 'basic', system: true },
|
|
47
|
-
{ key: 'instrumentCode', label: '仪器编码', kind: 'text', section: 'basic', required: true, createCapability: 'app:instrument-center:instrument:field:instrument-code:create', updateCapability: 'app:instrument-center:instrument:field:instrument-code:update' },
|
|
48
|
-
{ key: 'chineseName', label: '仪器中文名', kind: 'text', section: 'basic', required: true },
|
|
49
|
-
{ key: 'englishName', label: '仪器英文名', kind: 'text', section: 'basic' },
|
|
50
|
-
{ key: 'aliasName', label: '别名', kind: 'text', section: 'basic' },
|
|
51
|
-
{ key: 'image', label: '仪器图片', kind: 'file', section: 'basic', maxCount: 1, accept: 'image/*' },
|
|
52
|
-
{ key: 'specModel', label: '规格型号', kind: 'text', section: 'basic', required: true },
|
|
53
|
-
{ key: 'categoryId', label: '仪器分类', kind: 'select', section: 'basic', required: true, options: options([['显微成像', 'analysis_microscope'], ['光谱分析', 'analysis_spectrum'], ['色谱质谱', 'analysis_ms']]) },
|
|
54
|
-
{ key: 'instrumentSource', label: '仪器来源', kind: 'select', section: 'basic', options: options([['购置', 'purchase'], ['捐赠', 'donation'], ['调拨', 'transfer']]) },
|
|
55
|
-
{ key: 'manufacturer', label: '制造商', kind: 'text', section: 'basic' },
|
|
56
|
-
{ key: 'assetCode', label: '资产编码', kind: 'text', section: 'asset', required: true, createCapability: 'app:instrument-center:instrument:field:asset-code:create', updateCapability: 'app:instrument-center:instrument:field:asset-code:update' },
|
|
57
|
-
{ key: 'assetValue', label: '资产价值(元)', kind: 'number', section: 'asset', createCapability: 'app:instrument-center:instrument:field:asset-value:create', updateCapability: 'app:instrument-center:instrument:field:asset-value:update' },
|
|
58
|
-
{ key: 'enabledDate', label: '启用日期', kind: 'date', section: 'asset', required: true },
|
|
59
|
-
{ key: 'usageStatus', label: '使用状况', kind: 'select', section: 'usage', required: true, options: options([['在用', 'active'], ['闲置', 'idle'], ['借出', 'lent']]) },
|
|
60
|
-
{ key: 'instrumentStatus', label: '仪器状态', kind: 'select', section: 'usage', required: true, options: options([['正常', 'normal'], ['维修', 'maintenance'], ['停用', 'disabled'], ['报废', 'scrapped']]) },
|
|
61
|
-
{ key: 'openToOutside', label: '是否对外开放', kind: 'boolean', section: 'usage', required: true },
|
|
62
|
-
{ key: 'subjectAreas', label: '学科领域', kind: 'multi', section: 'usage', options: options([['材料', 'material'], ['生命科学', 'life'], ['化学', 'chemistry'], ['物理', 'physics']]) },
|
|
63
|
-
{ key: 'platformProperty', label: '平台属性', kind: 'select', section: 'usage', options: options([['共享平台设备', 'shared'], ['课题组设备', 'research']]) },
|
|
64
|
-
{ key: 'collegeId', label: '所属学院', kind: 'scope', section: 'usage', required: true, createCapability: 'app:instrument-center:instrument:field:college-id:create', updateCapability: 'app:instrument-center:instrument:field:college-id:update' },
|
|
65
|
-
{ key: 'manageDepartmentId', label: '管理部门', kind: 'directory-department', section: 'management', required: true },
|
|
66
|
-
{ key: 'useDepartmentId', label: '使用部门', kind: 'directory-department', section: 'management', required: true },
|
|
67
|
-
{ key: 'instrumentAdminIds', label: '仪器管理员', kind: 'directory-user', section: 'management', required: true, multiple: true, createCapability: 'app:instrument-center:instrument:field:instrument-admin-ids:create', updateCapability: 'app:instrument-center:instrument:field:instrument-admin-ids:update' },
|
|
68
|
-
{ key: 'contactUserIds', label: '联系人', kind: 'directory-user', section: 'management', required: true, multiple: true },
|
|
69
|
-
{ key: 'contactPhone', label: '联系电话', kind: 'phone', section: 'management', required: true },
|
|
70
|
-
{ key: 'contactEmail', label: '联系邮箱', kind: 'email', section: 'management' },
|
|
71
|
-
{ key: 'locationId', label: '存放点', kind: 'select', section: 'materials', options: options([['A 区 101', 'A-101'], ['B 区 203', 'B-203']]) },
|
|
72
|
-
{ key: 'address', label: '详细地址', kind: 'textarea', section: 'materials' },
|
|
73
|
-
{ key: 'serviceContent', label: '服务内容与主要功能', kind: 'textarea', section: 'materials' },
|
|
74
|
-
{ key: 'attachments', label: '仪器资料', kind: 'file', section: 'materials', multiple: true, maxCount: 8 },
|
|
75
|
-
];
|
|
9
|
+
export { instrumentFields, instrumentSurface, sectionTitles };
|
|
10
|
+
export type { FieldKind, InstrumentField };
|
|
76
11
|
|
|
77
12
|
const instrumentResourceFieldCodes = new Set(
|
|
78
13
|
instrumentFields.filter(field => !field.system).map(field => field.key)
|