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.
Files changed (40) hide show
  1. package/dist/create-workspace.d.ts +1 -0
  2. package/dist/create-workspace.d.ts.map +1 -1
  3. package/dist/create-workspace.js +1 -0
  4. package/dist/create-workspace.js.map +1 -1
  5. package/package.json +4 -4
  6. package/template/AGENTS.md +1 -1
  7. package/template/README.md +7 -7
  8. package/template/apps/server/package.json +1 -1
  9. package/template/apps/server/src/app.module.ts +1 -3
  10. package/template/apps/server/test/smoke.test.ts +6 -23
  11. package/template/apps/web/e2e/resources.spec.ts +20 -0
  12. package/template/apps/web/index.html +1 -1
  13. package/template/apps/web/package.json +1 -1
  14. package/template/apps/web/scripts/check.mjs +5 -2
  15. package/template/apps/web/src/AuthoritativeSelector.tsx +38 -29
  16. package/template/apps/web/src/Shell.tsx +39 -211
  17. package/template/apps/web/src/components/platform-fields/AttachmentFileList.tsx +1 -1
  18. package/template/apps/web/src/components/resource/GeneratedResourceCrud.tsx +306 -0
  19. package/template/apps/web/src/components/resource/StandardResourcePages.tsx +431 -0
  20. package/template/apps/web/src/components/resource/SurfaceFields.tsx +500 -0
  21. package/template/apps/web/src/data-provider.ts +36 -31
  22. package/template/apps/web/src/main.tsx +24 -54
  23. package/template/apps/web/src/platform-client.ts +131 -382
  24. package/template/apps/web/src/runtime-meta.ts +1 -1
  25. package/template/apps/web/src/styles.css +100 -3
  26. package/template/apps/web/test/contracts.test.ts +31 -693
  27. package/template/openxiangda.config.ts +14 -81
  28. package/template/package.json +3 -3
  29. package/template/packages/contracts/src/generated.ts +7 -130
  30. package/template/apps/server/src/instrument-context.controller.ts +0 -25
  31. package/template/apps/web/e2e/instruments.spec.ts +0 -1338
  32. package/template/apps/web/src/CollegePage.tsx +0 -181
  33. package/template/apps/web/src/InstrumentDetailPage.tsx +0 -134
  34. package/template/apps/web/src/InstrumentForm.tsx +0 -575
  35. package/template/apps/web/src/InstrumentFormPage.tsx +0 -117
  36. package/template/apps/web/src/InstrumentListPage.tsx +0 -385
  37. package/template/apps/web/src/fields.ts +0 -83
  38. package/template/apps/web/src/instrument.ts +0 -55
  39. package/template/platform/data/colleges.ts +0 -21
  40. 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 type { InstrumentListQuery, InstrumentRecord } from './instrument';
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
- export const instrumentProvider = {
7
- getApiUrl: () =>
8
- `/service/openxiangda-api/v2/applications/${applicationCode()}/native/data`,
9
- getList: async ({ pagination, sorters, meta }) => {
10
- const filters = (meta?.query || {}) as Partial<InstrumentListQuery>;
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 instrumentDataApi.list({
13
- ...filters,
31
+ const result = await clientFor(resource).list({
14
32
  page: pagination?.currentPage || 1,
15
33
  pageSize: pagination?.pageSize || 20,
16
- sort: sorter
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 instrumentDataApi.get(String(id)) }),
26
- create: async ({ variables }) => ({
27
- data: await instrumentDataApi.create(variables as Partial<InstrumentRecord>),
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 instrumentDataApi.remove(
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 { instrumentProvider } from './data-provider';
8
- import { CollegePage } from './CollegePage';
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 { InstrumentDetailPage } from './InstrumentDetailPage';
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="/instruments" />} />
53
- <Route path="/instruments" element={<InstrumentListPage />} />
54
- <Route path="/colleges" element={<CollegePage />} />
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
  );