openxiangda-cli 2.0.0-alpha.60 → 2.0.0-alpha.62
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/dist/create-workspace.js +1 -0
- package/dist/create-workspace.js.map +1 -1
- package/package.json +4 -4
- package/template/AGENTS.md +1 -1
- package/template/README.md +7 -7
- package/template/apps/server/package.json +1 -1
- package/template/apps/server/src/app.module.ts +1 -3
- package/template/apps/server/test/smoke.test.ts +6 -23
- package/template/apps/web/e2e/resources.spec.ts +20 -0
- package/template/apps/web/index.html +1 -1
- package/template/apps/web/package.json +1 -1
- package/template/apps/web/scripts/check.mjs +5 -2
- package/template/apps/web/src/AuthoritativeSelector.tsx +38 -29
- package/template/apps/web/src/Shell.tsx +39 -211
- package/template/apps/web/src/components/platform-fields/AttachmentFileList.tsx +1 -1
- package/template/apps/web/src/components/resource/GeneratedResourceCrud.tsx +306 -0
- package/template/apps/web/src/components/resource/StandardResourcePages.tsx +431 -0
- package/template/apps/web/src/components/resource/SurfaceFields.tsx +500 -0
- package/template/apps/web/src/data-provider.ts +36 -31
- package/template/apps/web/src/main.tsx +24 -54
- package/template/apps/web/src/platform-client.ts +131 -382
- package/template/apps/web/src/runtime-meta.ts +1 -1
- package/template/apps/web/src/styles.css +100 -3
- package/template/apps/web/test/contracts.test.ts +31 -693
- package/template/openxiangda.config.ts +14 -81
- package/template/package.json +3 -3
- package/template/packages/contracts/src/generated.ts +7 -130
- package/template/apps/server/src/instrument-context.controller.ts +0 -25
- package/template/apps/web/e2e/instruments.spec.ts +0 -1338
- package/template/apps/web/src/CollegePage.tsx +0 -181
- package/template/apps/web/src/InstrumentDetailPage.tsx +0 -134
- package/template/apps/web/src/InstrumentForm.tsx +0 -575
- package/template/apps/web/src/InstrumentFormPage.tsx +0 -117
- package/template/apps/web/src/InstrumentListPage.tsx +0 -385
- package/template/apps/web/src/fields.ts +0 -83
- package/template/apps/web/src/instrument.ts +0 -55
- package/template/platform/data/colleges.ts +0 -21
- package/template/platform/data/instruments.ts +0 -81
|
@@ -0,0 +1,500 @@
|
|
|
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 { AuthoritativeSelector, 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
|
+
case 'resource':
|
|
128
|
+
return <AuthoritativeSelector source="resource" resourceCode={field.reference?.kind === 'resource' ? field.reference.resourceCode : undefined} labelField={field.reference?.kind === 'resource' ? field.reference.labelField : undefined} multiple={field.multiple} onChange={onChange as (value: string | string[] | undefined) => void} placeholder={placeholder} value={value as string | string[] | undefined} operation="update" />;
|
|
129
|
+
default:
|
|
130
|
+
return <Input allowClear value={value as string | undefined} onChange={event => onChange(event.target.value)} placeholder={placeholder} />;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function SurfaceFieldControl({
|
|
135
|
+
field,
|
|
136
|
+
disabled,
|
|
137
|
+
operation,
|
|
138
|
+
recordId,
|
|
139
|
+
renderers,
|
|
140
|
+
}: SurfaceFieldEditContext & { renderers?: SurfaceFieldRenderers }) {
|
|
141
|
+
const rules = [
|
|
142
|
+
...(field.requiredHint
|
|
143
|
+
? [{ required: true, message: `请填写或选择${field.label}` }]
|
|
144
|
+
: []),
|
|
145
|
+
...(field.widget === 'email'
|
|
146
|
+
? [{ type: 'email' as const, message: '邮箱格式不正确' }]
|
|
147
|
+
: []),
|
|
148
|
+
...(field.widget === 'phone'
|
|
149
|
+
? [{ pattern: /^1\d{10}$/, message: '请输入 11 位手机号' }]
|
|
150
|
+
: []),
|
|
151
|
+
];
|
|
152
|
+
const common = { disabled, placeholder: `请输入${field.label}` };
|
|
153
|
+
let control: ReactNode = <Input {...common} />;
|
|
154
|
+
switch (field.widget) {
|
|
155
|
+
case 'textarea':
|
|
156
|
+
control = <Input.TextArea {...common} rows={3} />;
|
|
157
|
+
break;
|
|
158
|
+
case 'number':
|
|
159
|
+
case 'money':
|
|
160
|
+
case 'percent':
|
|
161
|
+
control = <InputNumber {...common} min={0} style={{ width: '100%' }} />;
|
|
162
|
+
break;
|
|
163
|
+
case 'date':
|
|
164
|
+
case 'datetime':
|
|
165
|
+
control = <DatePicker disabled={disabled} showTime={field.widget === 'datetime'} style={{ width: '100%' }} />;
|
|
166
|
+
break;
|
|
167
|
+
case 'boolean':
|
|
168
|
+
control = <Switch checkedChildren="是" disabled={disabled} unCheckedChildren="否" />;
|
|
169
|
+
break;
|
|
170
|
+
case 'select':
|
|
171
|
+
control = <Select disabled={disabled} options={field.options} placeholder={`请选择${field.label}`} />;
|
|
172
|
+
break;
|
|
173
|
+
case 'multi':
|
|
174
|
+
control = <Select disabled={disabled} mode="multiple" options={field.options} placeholder={`请选择${field.label}`} />;
|
|
175
|
+
break;
|
|
176
|
+
case 'email':
|
|
177
|
+
control = <Input {...common} type="email" />;
|
|
178
|
+
break;
|
|
179
|
+
case 'phone':
|
|
180
|
+
control = <Input {...common} type="tel" />;
|
|
181
|
+
break;
|
|
182
|
+
case 'scope':
|
|
183
|
+
control = renderers?.renderScope?.({ field, disabled, operation, recordId }) || (
|
|
184
|
+
<Input {...common} />
|
|
185
|
+
);
|
|
186
|
+
break;
|
|
187
|
+
case 'directory-user':
|
|
188
|
+
case 'directory-department':
|
|
189
|
+
control = (
|
|
190
|
+
<PlatformDirectoryPicker
|
|
191
|
+
disabled={disabled}
|
|
192
|
+
kind={field.widget === 'directory-user' ? 'user' : 'department'}
|
|
193
|
+
multiple={field.multiple}
|
|
194
|
+
placeholder={`搜索并选择${field.label}`}
|
|
195
|
+
/>
|
|
196
|
+
);
|
|
197
|
+
break;
|
|
198
|
+
case 'resource':
|
|
199
|
+
control = (
|
|
200
|
+
<AuthoritativeSelector
|
|
201
|
+
disabled={disabled}
|
|
202
|
+
labelField={field.reference?.kind === 'resource' ? field.reference.labelField : undefined}
|
|
203
|
+
multiple={field.multiple}
|
|
204
|
+
operation={operation}
|
|
205
|
+
placeholder={`搜索并选择${field.label}`}
|
|
206
|
+
resourceCode={field.reference?.kind === 'resource' ? field.reference.resourceCode : undefined}
|
|
207
|
+
source="resource"
|
|
208
|
+
/>
|
|
209
|
+
);
|
|
210
|
+
break;
|
|
211
|
+
case 'file':
|
|
212
|
+
control = (
|
|
213
|
+
<ManagedFileField
|
|
214
|
+
accept={field.accept}
|
|
215
|
+
disabled={disabled}
|
|
216
|
+
field={field}
|
|
217
|
+
maxCount={field.maxCount}
|
|
218
|
+
multiple={field.multiple}
|
|
219
|
+
onUpload={renderers?.upload}
|
|
220
|
+
recordId={recordId}
|
|
221
|
+
/>
|
|
222
|
+
);
|
|
223
|
+
break;
|
|
224
|
+
case 'json':
|
|
225
|
+
control = <Input.TextArea {...common} rows={4} />;
|
|
226
|
+
break;
|
|
227
|
+
case 'readonly':
|
|
228
|
+
control = <Input {...common} readOnly />;
|
|
229
|
+
break;
|
|
230
|
+
case 'text':
|
|
231
|
+
default:
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
const extra =
|
|
235
|
+
renderers?.renderExtra?.({ field, disabled, operation, recordId }) ||
|
|
236
|
+
(disabled ? '当前角色不可修改此字段' : undefined);
|
|
237
|
+
const wide = field.widget === 'textarea' || field.widget === 'file' || field.widget === 'json';
|
|
238
|
+
return (
|
|
239
|
+
<Form.Item
|
|
240
|
+
className={wide ? 'oxa-field-wide' : undefined}
|
|
241
|
+
extra={extra}
|
|
242
|
+
label={field.label}
|
|
243
|
+
name={field.key}
|
|
244
|
+
rules={rules}
|
|
245
|
+
valuePropName={field.widget === 'boolean' ? 'checked' : 'value'}
|
|
246
|
+
>
|
|
247
|
+
{control}
|
|
248
|
+
</Form.Item>
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Mobile keeps the same field values and validation but uses native controls. */
|
|
253
|
+
export function MobileSurfaceFieldControl({
|
|
254
|
+
field,
|
|
255
|
+
disabled,
|
|
256
|
+
operation,
|
|
257
|
+
recordId,
|
|
258
|
+
renderers,
|
|
259
|
+
}: SurfaceFieldEditContext & { renderers?: SurfaceFieldRenderers }) {
|
|
260
|
+
const rules = field.requiredHint
|
|
261
|
+
? [{ required: true, message: `请填写或选择${field.label}` }]
|
|
262
|
+
: [];
|
|
263
|
+
const inputProps = { disabled, className: 'oxa-mobile-input', placeholder: `请输入${field.label}` };
|
|
264
|
+
let control: ReactNode = <input {...inputProps} />;
|
|
265
|
+
switch (field.widget) {
|
|
266
|
+
case 'textarea':
|
|
267
|
+
case 'json':
|
|
268
|
+
control = <textarea {...inputProps} rows={4} />;
|
|
269
|
+
break;
|
|
270
|
+
case 'number':
|
|
271
|
+
case 'money':
|
|
272
|
+
case 'percent':
|
|
273
|
+
control = <input {...inputProps} min={0} type="number" />;
|
|
274
|
+
break;
|
|
275
|
+
case 'date':
|
|
276
|
+
control = <input {...inputProps} type="date" />;
|
|
277
|
+
break;
|
|
278
|
+
case 'datetime':
|
|
279
|
+
control = <input {...inputProps} type="datetime-local" />;
|
|
280
|
+
break;
|
|
281
|
+
case 'boolean':
|
|
282
|
+
control = <input disabled={disabled} type="checkbox" />;
|
|
283
|
+
break;
|
|
284
|
+
case 'select':
|
|
285
|
+
case 'multi':
|
|
286
|
+
control = (
|
|
287
|
+
<select
|
|
288
|
+
className="oxa-mobile-input"
|
|
289
|
+
disabled={disabled}
|
|
290
|
+
multiple={field.widget === 'multi'}
|
|
291
|
+
>
|
|
292
|
+
{field.options?.map(option => (
|
|
293
|
+
<option key={option.value} value={option.value}>
|
|
294
|
+
{option.label}
|
|
295
|
+
</option>
|
|
296
|
+
))}
|
|
297
|
+
</select>
|
|
298
|
+
);
|
|
299
|
+
break;
|
|
300
|
+
case 'email':
|
|
301
|
+
control = <input {...inputProps} type="email" />;
|
|
302
|
+
break;
|
|
303
|
+
case 'phone':
|
|
304
|
+
control = <input {...inputProps} type="tel" />;
|
|
305
|
+
break;
|
|
306
|
+
case 'scope':
|
|
307
|
+
control = renderers?.renderScope?.({ field, disabled, operation, recordId }) || (
|
|
308
|
+
<input {...inputProps} />
|
|
309
|
+
);
|
|
310
|
+
break;
|
|
311
|
+
case 'directory-user':
|
|
312
|
+
case 'directory-department':
|
|
313
|
+
control = (
|
|
314
|
+
<PlatformDirectoryPicker
|
|
315
|
+
disabled={disabled}
|
|
316
|
+
kind={field.widget === 'directory-user' ? 'user' : 'department'}
|
|
317
|
+
multiple={field.multiple}
|
|
318
|
+
placeholder={`搜索并选择${field.label}`}
|
|
319
|
+
/>
|
|
320
|
+
);
|
|
321
|
+
break;
|
|
322
|
+
case 'resource':
|
|
323
|
+
control = (
|
|
324
|
+
<AuthoritativeSelector
|
|
325
|
+
disabled={disabled}
|
|
326
|
+
labelField={field.reference?.kind === 'resource' ? field.reference.labelField : undefined}
|
|
327
|
+
multiple={field.multiple}
|
|
328
|
+
operation={operation}
|
|
329
|
+
placeholder={`搜索并选择${field.label}`}
|
|
330
|
+
resourceCode={field.reference?.kind === 'resource' ? field.reference.resourceCode : undefined}
|
|
331
|
+
source="resource"
|
|
332
|
+
/>
|
|
333
|
+
);
|
|
334
|
+
break;
|
|
335
|
+
case 'file':
|
|
336
|
+
control = (
|
|
337
|
+
<ManagedFileField
|
|
338
|
+
accept={field.accept}
|
|
339
|
+
disabled={disabled}
|
|
340
|
+
field={field}
|
|
341
|
+
maxCount={field.maxCount}
|
|
342
|
+
multiple={field.multiple}
|
|
343
|
+
onUpload={renderers?.upload}
|
|
344
|
+
recordId={recordId}
|
|
345
|
+
/>
|
|
346
|
+
);
|
|
347
|
+
break;
|
|
348
|
+
case 'readonly':
|
|
349
|
+
control = <input {...inputProps} readOnly />;
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
return (
|
|
353
|
+
<Form.Item
|
|
354
|
+
className="oxa-mobile-field"
|
|
355
|
+
extra={renderers?.renderExtra?.({ field, disabled, operation, recordId })}
|
|
356
|
+
label={field.label}
|
|
357
|
+
name={field.key}
|
|
358
|
+
rules={rules}
|
|
359
|
+
valuePropName={field.widget === 'boolean' ? 'checked' : 'value'}
|
|
360
|
+
>
|
|
361
|
+
{control}
|
|
362
|
+
</Form.Item>
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function SurfaceFieldValue({
|
|
367
|
+
field,
|
|
368
|
+
value,
|
|
369
|
+
renderers,
|
|
370
|
+
}: SurfaceFieldValueContext & { renderers?: SurfaceFieldRenderers }) {
|
|
371
|
+
if (value === undefined || value === null || value === '') return <>-</>;
|
|
372
|
+
const custom = renderers?.renderValue?.({ field, value });
|
|
373
|
+
if (custom !== undefined) return <>{custom}</>;
|
|
374
|
+
if (field.widget === 'directory-user' || field.widget === 'directory-department') {
|
|
375
|
+
return (
|
|
376
|
+
<ResolvedValueText
|
|
377
|
+
source={field.widget === 'directory-user' ? 'user' : 'department'}
|
|
378
|
+
value={Array.isArray(value) ? value.map(String) : String(value)}
|
|
379
|
+
/>
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
if (field.widget === 'resource' && field.reference?.kind === 'resource') {
|
|
383
|
+
return <ResolvedValueText labelField={field.reference.labelField} resourceCode={field.reference.resourceCode} source="resource" value={Array.isArray(value) ? value.map(String) : String(value)} />;
|
|
384
|
+
}
|
|
385
|
+
if (field.widget === 'file') {
|
|
386
|
+
const values = (Array.isArray(value) ? value : [value]).filter(isFileRef);
|
|
387
|
+
return values.length ? <AttachmentFileList files={values} /> : <>-</>;
|
|
388
|
+
}
|
|
389
|
+
if (field.widget === 'boolean') {
|
|
390
|
+
return <Tag color={value ? 'green' : 'default'}>{value ? '是' : '否'}</Tag>;
|
|
391
|
+
}
|
|
392
|
+
if (Array.isArray(value)) {
|
|
393
|
+
return <>{value.map(item => optionLabel(field, item)).join('、') || '-'}</>;
|
|
394
|
+
}
|
|
395
|
+
if (field.widget === 'date' || field.widget === 'datetime') {
|
|
396
|
+
const parsed = new Date(String(value));
|
|
397
|
+
return <>{Number.isNaN(parsed.getTime()) ? String(value) : parsed.toLocaleString('zh-CN', { hour12: false })}</>;
|
|
398
|
+
}
|
|
399
|
+
return <>{optionLabel(field, value)}</>;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function optionLabel(field: SurfaceField, value: unknown) {
|
|
403
|
+
return field.options?.find(option => option.value === value)?.label || String(value);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function isFileRef(value: unknown): value is DataFileRef {
|
|
407
|
+
return Boolean(value && typeof value === 'object' && 'id' in value);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function ManagedFileField({
|
|
411
|
+
field,
|
|
412
|
+
value,
|
|
413
|
+
onChange,
|
|
414
|
+
recordId,
|
|
415
|
+
disabled,
|
|
416
|
+
multiple,
|
|
417
|
+
maxCount = 1,
|
|
418
|
+
accept,
|
|
419
|
+
onUpload,
|
|
420
|
+
}: {
|
|
421
|
+
field: SurfaceField;
|
|
422
|
+
value?: DataFileRef | DataFileRef[];
|
|
423
|
+
onChange?: (value: DataFileRef | DataFileRef[] | undefined) => void;
|
|
424
|
+
recordId?: string;
|
|
425
|
+
disabled?: boolean;
|
|
426
|
+
multiple?: boolean;
|
|
427
|
+
maxCount?: number;
|
|
428
|
+
accept?: string | string[];
|
|
429
|
+
onUpload?: SurfaceFieldRenderers['upload'];
|
|
430
|
+
}) {
|
|
431
|
+
const [uploading, setUploading] = useState(false);
|
|
432
|
+
const [uploadError, setUploadError] = useState('');
|
|
433
|
+
const refs = useMemo(() => (Array.isArray(value) ? value : value ? [value] : []), [value]);
|
|
434
|
+
const refsRef = useRef(refs);
|
|
435
|
+
useEffect(() => {
|
|
436
|
+
refsRef.current = refs;
|
|
437
|
+
}, [refs]);
|
|
438
|
+
if (!onUpload) {
|
|
439
|
+
return <Alert type="info" showIcon message="该文件字段尚未配置上传能力" />;
|
|
440
|
+
}
|
|
441
|
+
const uploadProps = {
|
|
442
|
+
accept: Array.isArray(accept) ? accept.join(',') : accept,
|
|
443
|
+
disabled,
|
|
444
|
+
maxCount,
|
|
445
|
+
multiple,
|
|
446
|
+
customRequest: async (options: {
|
|
447
|
+
file: string | Blob;
|
|
448
|
+
onError?: (error: Error) => void;
|
|
449
|
+
onProgress?: (event: { percent: number }) => void;
|
|
450
|
+
onSuccess?: (body: unknown) => void;
|
|
451
|
+
}) => {
|
|
452
|
+
setUploading(true);
|
|
453
|
+
setUploadError('');
|
|
454
|
+
try {
|
|
455
|
+
options.onProgress?.({ percent: 15 });
|
|
456
|
+
const uploaded = await onUpload(field, options.file as File, recordId);
|
|
457
|
+
options.onProgress?.({ percent: 100 });
|
|
458
|
+
const next = multiple ? [...refsRef.current, uploaded] : uploaded;
|
|
459
|
+
refsRef.current = Array.isArray(next) ? next : [next];
|
|
460
|
+
onChange?.(next);
|
|
461
|
+
options.onSuccess?.(uploaded);
|
|
462
|
+
} catch (error) {
|
|
463
|
+
const failure = error instanceof Error ? error : new Error(String(error));
|
|
464
|
+
setUploadError(failure.message);
|
|
465
|
+
options.onError?.(failure);
|
|
466
|
+
} finally {
|
|
467
|
+
setUploading(false);
|
|
468
|
+
}
|
|
469
|
+
},
|
|
470
|
+
showUploadList: false,
|
|
471
|
+
};
|
|
472
|
+
return (
|
|
473
|
+
<div className="oxa-file-field">
|
|
474
|
+
<Upload {...uploadProps} pastable>
|
|
475
|
+
<Button disabled={disabled || uploading || refs.length >= maxCount} icon={field.accept?.toString().startsWith('image/') ? <PictureOutlined /> : <UploadOutlined />} loading={uploading}>
|
|
476
|
+
{field.accept?.toString().startsWith('image/') ? '上传图片' : '上传文件'}
|
|
477
|
+
</Button>
|
|
478
|
+
</Upload>
|
|
479
|
+
<Upload.Dragger {...uploadProps} className="oxa-file-dropzone" openFileDialogOnClick={false} pastable>
|
|
480
|
+
<span>可将文件拖到这里,或在此区域按 <kbd>⌘ V</kbd> 粘贴</span>
|
|
481
|
+
<small>{multiple ? `最多 ${maxCount} 个文件,单个不超过 50MB` : '仅保留一个文件,新上传会替换当前文件'}</small>
|
|
482
|
+
</Upload.Dragger>
|
|
483
|
+
{uploadError && (
|
|
484
|
+
<div className="oxa-file-error">
|
|
485
|
+
<span>{uploadError}</span>
|
|
486
|
+
<Button icon={<ReloadOutlined />} onClick={() => setUploadError('')} size="small" type="text">关闭</Button>
|
|
487
|
+
</div>
|
|
488
|
+
)}
|
|
489
|
+
{refs.length ? (
|
|
490
|
+
<AttachmentFileList files={refs} onRemove={file => {
|
|
491
|
+
const next = refs.filter(item => item.id !== file.id);
|
|
492
|
+
refsRef.current = next;
|
|
493
|
+
onChange?.(multiple ? next : undefined);
|
|
494
|
+
}} removable={!disabled} />
|
|
495
|
+
) : (
|
|
496
|
+
<div className="oxa-file-empty">尚未上传文件</div>
|
|
497
|
+
)}
|
|
498
|
+
</div>
|
|
499
|
+
);
|
|
500
|
+
}
|
|
@@ -1,42 +1,47 @@
|
|
|
1
1
|
import type { DataProvider } from '@refinedev/core';
|
|
2
|
-
import
|
|
3
|
-
import { instrumentDataApi } from './platform-client';
|
|
2
|
+
import { createNativeResourceClient, type GenericResourceQuery } from './platform-client';
|
|
4
3
|
import { applicationCode } from './runtime-meta';
|
|
4
|
+
import { resourceDefinitions } from '../../../packages/contracts/src/generated.js';
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
type GeneratedDefinition = {
|
|
7
|
+
code: string;
|
|
8
|
+
name: string;
|
|
9
|
+
capabilities: { read: string; create: string; update: string; delete: string };
|
|
10
|
+
surface: import('openxiangda-contracts/browser').DataResourceSurface;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const definitions = resourceDefinitions as unknown as Record<string, GeneratedDefinition>;
|
|
14
|
+
type ListParams = Parameters<NonNullable<DataProvider['getList']>>[0];
|
|
15
|
+
type OneParams = Parameters<NonNullable<DataProvider['getOne']>>[0];
|
|
16
|
+
type CreateParams = Parameters<NonNullable<DataProvider['create']>>[0];
|
|
17
|
+
type UpdateParams = Parameters<NonNullable<DataProvider['update']>>[0];
|
|
18
|
+
type DeleteParams = Parameters<NonNullable<DataProvider['deleteOne']>>[0];
|
|
19
|
+
|
|
20
|
+
function clientFor(resource: string) {
|
|
21
|
+
const definition = definitions[resource];
|
|
22
|
+
if (!definition) throw new Error(`OPENXIANGDA_GENERATED_RESOURCE_NOT_DECLARED:${resource}`);
|
|
23
|
+
return createNativeResourceClient(resource, definition.surface);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const applicationProvider = {
|
|
27
|
+
getApiUrl: () => `/service/openxiangda-api/v2/applications/${applicationCode()}/native/data`,
|
|
28
|
+
getList: async ({ resource, pagination, sorters, meta }: ListParams) => {
|
|
29
|
+
const query = (meta?.query || {}) as Partial<GenericResourceQuery>;
|
|
11
30
|
const sorter = sorters?.[0];
|
|
12
|
-
const result = await
|
|
13
|
-
...filters,
|
|
31
|
+
const result = await clientFor(resource).list({
|
|
14
32
|
page: pagination?.currentPage || 1,
|
|
15
33
|
pageSize: pagination?.pageSize || 20,
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
field: String(sorter.field),
|
|
19
|
-
order: sorter.order,
|
|
20
|
-
}
|
|
21
|
-
: { field: 'instrumentCode', order: 'asc' },
|
|
34
|
+
...query,
|
|
35
|
+
...(sorter ? { sort: { field: String(sorter.field), order: (sorter.order || 'asc') as 'asc' | 'desc' } } : {}),
|
|
22
36
|
});
|
|
23
37
|
return { data: result.rows, total: result.total };
|
|
24
38
|
},
|
|
25
|
-
getOne: async ({ id }) => ({ data: await
|
|
26
|
-
create: async ({ variables }) => ({
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
update: async ({ id, variables, meta }) => ({
|
|
30
|
-
data: await instrumentDataApi.update(
|
|
31
|
-
String(id),
|
|
32
|
-
Number(meta?.expectedRevision),
|
|
33
|
-
variables as Partial<InstrumentRecord>
|
|
34
|
-
),
|
|
39
|
+
getOne: async ({ resource, id }: OneParams) => ({ data: await clientFor(resource).get(String(id)) }),
|
|
40
|
+
create: async ({ resource, variables }: CreateParams) => ({ data: await clientFor(resource).create(variables as Record<string, unknown>) }),
|
|
41
|
+
update: async ({ resource, id, variables, meta }: UpdateParams) => ({
|
|
42
|
+
data: await clientFor(resource).update(String(id), Number(meta?.expectedRevision), variables as Record<string, unknown>),
|
|
35
43
|
}),
|
|
36
|
-
deleteOne: async ({ id, meta }) => ({
|
|
37
|
-
data: await
|
|
38
|
-
String(id),
|
|
39
|
-
Number(meta?.expectedRevision)
|
|
40
|
-
),
|
|
44
|
+
deleteOne: async ({ resource, id, meta }: DeleteParams) => ({
|
|
45
|
+
data: await clientFor(resource).remove(String(id), Number(meta?.expectedRevision)),
|
|
41
46
|
}),
|
|
42
|
-
} as DataProvider;
|
|
47
|
+
} as unknown as DataProvider;
|
|
@@ -4,75 +4,45 @@ import zhCN from 'antd/locale/zh_CN';
|
|
|
4
4
|
import React from 'react';
|
|
5
5
|
import ReactDOM from 'react-dom/client';
|
|
6
6
|
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { resourceDefinitions } from '../../../packages/contracts/src/generated.js';
|
|
8
|
+
import { applicationProvider } from './data-provider';
|
|
9
|
+
import { GeneratedResourcePage } from './components/resource/GeneratedResourceCrud';
|
|
9
10
|
import { FilePreviewPage } from './FilePreviewPage';
|
|
10
|
-
import {
|
|
11
|
-
import { InstrumentFormPage } from './InstrumentFormPage';
|
|
12
|
-
import { InstrumentListPage } from './InstrumentListPage';
|
|
11
|
+
import { EmptyApplicationPage } from './Shell';
|
|
13
12
|
import { RuntimeBoundary } from './runtime';
|
|
14
13
|
import { applicationBasename } from './runtime-meta';
|
|
15
14
|
import './styles.css';
|
|
16
15
|
|
|
16
|
+
const codes = Object.keys(resourceDefinitions);
|
|
17
|
+
const firstPath = codes[0] ? `/${codes[0]}` : '/';
|
|
18
|
+
|
|
19
|
+
const resourceRoutes = codes.flatMap(resourceCode => [
|
|
20
|
+
<Route key={`${resourceCode}-list`} path={`/${resourceCode}`} element={<GeneratedResourcePage resourceCode={resourceCode} />} />,
|
|
21
|
+
<Route key={`${resourceCode}-new`} path={`/${resourceCode}/new`} element={<GeneratedResourcePage mode="create" resourceCode={resourceCode} />} />,
|
|
22
|
+
<Route key={`${resourceCode}-detail`} path={`/${resourceCode}/:id`} element={<GeneratedResourcePage mode="detail" resourceCode={resourceCode} />} />,
|
|
23
|
+
<Route key={`${resourceCode}-edit`} path={`/${resourceCode}/:id/edit`} element={<GeneratedResourcePage mode="edit" resourceCode={resourceCode} />} />,
|
|
24
|
+
<Route key={`${resourceCode}-mobile-list`} path={`/m/${resourceCode}`} element={<GeneratedResourcePage resourceCode={resourceCode} variant="mobile" />} />,
|
|
25
|
+
<Route key={`${resourceCode}-mobile-new`} path={`/m/${resourceCode}/new`} element={<GeneratedResourcePage mode="create" resourceCode={resourceCode} variant="mobile" />} />,
|
|
26
|
+
<Route key={`${resourceCode}-mobile-detail`} path={`/m/${resourceCode}/:id`} element={<GeneratedResourcePage mode="detail" resourceCode={resourceCode} variant="mobile" />} />,
|
|
27
|
+
<Route key={`${resourceCode}-mobile-edit`} path={`/m/${resourceCode}/:id/edit`} element={<GeneratedResourcePage mode="edit" resourceCode={resourceCode} variant="mobile" />} />,
|
|
28
|
+
]);
|
|
29
|
+
|
|
17
30
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
18
31
|
<React.StrictMode>
|
|
19
|
-
<ConfigProvider
|
|
20
|
-
locale={zhCN}
|
|
21
|
-
theme={{
|
|
22
|
-
token: {
|
|
23
|
-
borderRadius: 8,
|
|
24
|
-
colorBgLayout: '#f3f7fd',
|
|
25
|
-
colorPrimary: '#1677ff',
|
|
26
|
-
colorText: '#172033',
|
|
27
|
-
fontSize: 14,
|
|
28
|
-
},
|
|
29
|
-
components: {
|
|
30
|
-
Card: { headerFontSize: 16 },
|
|
31
|
-
Layout: { bodyBg: '#f3f7fd', headerBg: '#ffffff', siderBg: '#ffffff' },
|
|
32
|
-
Menu: { itemBorderRadius: 8, itemSelectedBg: '#eaf3ff' },
|
|
33
|
-
},
|
|
34
|
-
}}
|
|
35
|
-
>
|
|
32
|
+
<ConfigProvider locale={zhCN} theme={{ token: { borderRadius: 8, colorBgLayout: '#f3f7fd', colorPrimary: '#1677ff', colorText: '#172033', fontSize: 14 }, components: { Card: { headerFontSize: 16 }, Layout: { bodyBg: '#f3f7fd', headerBg: '#ffffff', siderBg: '#ffffff' }, Menu: { itemBorderRadius: 8, itemSelectedBg: '#eaf3ff' } } }}>
|
|
36
33
|
<AntdApp>
|
|
37
34
|
<RuntimeBoundary>
|
|
38
35
|
<BrowserRouter basename={applicationBasename()}>
|
|
39
|
-
<Refine
|
|
40
|
-
dataProvider={instrumentProvider}
|
|
41
|
-
resources={[
|
|
42
|
-
{
|
|
43
|
-
name: 'instruments',
|
|
44
|
-
list: '/instruments',
|
|
45
|
-
create: '/instruments/new',
|
|
46
|
-
edit: '/instruments/:id/edit',
|
|
47
|
-
show: '/instruments/:id',
|
|
48
|
-
},
|
|
49
|
-
]}
|
|
50
|
-
>
|
|
36
|
+
<Refine dataProvider={applicationProvider} resources={codes.map(code => ({ name: code, list: `/${code}`, create: `/${code}/new`, edit: `/${code}/:id/edit`, show: `/${code}/:id` }))}>
|
|
51
37
|
<Routes>
|
|
52
|
-
<Route path="/" element={<Navigate replace to=
|
|
53
|
-
<Route path="/
|
|
54
|
-
|
|
55
|
-
<Route
|
|
56
|
-
path="/files/:resourceCode/:fileId/preview"
|
|
57
|
-
element={<FilePreviewPage />}
|
|
58
|
-
/>
|
|
59
|
-
<Route
|
|
60
|
-
path="/instruments/new"
|
|
61
|
-
element={<InstrumentFormPage mode="create" />}
|
|
62
|
-
/>
|
|
63
|
-
<Route
|
|
64
|
-
path="/instruments/:id/edit"
|
|
65
|
-
element={<InstrumentFormPage mode="edit" />}
|
|
66
|
-
/>
|
|
67
|
-
<Route
|
|
68
|
-
path="/instruments/:id"
|
|
69
|
-
element={<InstrumentDetailPage />}
|
|
70
|
-
/>
|
|
38
|
+
<Route path="/" element={codes.length ? <Navigate replace to={firstPath} /> : <EmptyApplicationPage />} />
|
|
39
|
+
<Route path="/files/:resourceCode/:fileId/preview" element={<FilePreviewPage />} />
|
|
40
|
+
{resourceRoutes}
|
|
71
41
|
</Routes>
|
|
72
42
|
</Refine>
|
|
73
43
|
</BrowserRouter>
|
|
74
44
|
</RuntimeBoundary>
|
|
75
45
|
</AntdApp>
|
|
76
46
|
</ConfigProvider>
|
|
77
|
-
</React.StrictMode
|
|
47
|
+
</React.StrictMode>,
|
|
78
48
|
);
|