openxiangda-cli 2.0.0-alpha.84 → 2.0.0-alpha.86

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 (62) hide show
  1. package/package.json +4 -4
  2. package/template/AGENTS.md +4 -4
  3. package/template/README.md +2 -2
  4. package/template/apps/server/package.json +1 -1
  5. package/template/apps/web/data-api-live.e2e.html +15 -0
  6. package/template/apps/web/e2e/data-api-live-fixture.tsx +141 -0
  7. package/template/apps/web/e2e/data-api-live.spec.ts +138 -0
  8. package/template/apps/web/e2e/field-protocol-fixture.tsx +280 -0
  9. package/template/apps/web/e2e/field-protocol.spec.ts +100 -0
  10. package/template/apps/web/e2e/resources.spec.ts +258 -20
  11. package/template/apps/web/field-protocol.e2e.html +12 -0
  12. package/template/apps/web/package.json +1 -1
  13. package/template/apps/web/playwright.config.ts +3 -1
  14. package/template/apps/web/scripts/check.mjs +8 -5
  15. package/template/apps/web/scripts/verify-build.mjs +1 -1
  16. package/template/apps/web/src/AuthoritativeSelector.tsx +165 -132
  17. package/template/apps/web/src/FilePreviewPage.tsx +30 -9
  18. package/template/apps/web/src/RoleSubjectSelect.tsx +128 -0
  19. package/template/apps/web/src/Shell.tsx +93 -6
  20. package/template/apps/web/src/components/platform-fields/AddressField.tsx +338 -0
  21. package/template/apps/web/src/components/platform-fields/AttachmentFileList.tsx +25 -6
  22. package/template/apps/web/src/components/platform-fields/CascadeField.tsx +49 -0
  23. package/template/apps/web/src/components/platform-fields/DateTimeField.tsx +148 -0
  24. package/template/apps/web/src/components/platform-fields/JsonField.tsx +77 -0
  25. package/template/apps/web/src/components/platform-fields/LocationField.tsx +164 -0
  26. package/template/apps/web/src/components/platform-fields/PlatformDirectoryPicker.tsx +19 -44
  27. package/template/apps/web/src/components/platform-fields/ResourceReferenceField.tsx +67 -0
  28. package/template/apps/web/src/components/platform-fields/RichTextField.tsx +196 -0
  29. package/template/apps/web/src/components/platform-fields/SignatureField.tsx +315 -0
  30. package/template/apps/web/src/components/platform-fields/SubtableField.tsx +553 -0
  31. package/template/apps/web/src/components/platform-fields/address-value.ts +80 -0
  32. package/template/apps/web/src/components/platform-fields/cascade-value.ts +66 -0
  33. package/template/apps/web/src/components/platform-fields/directory-value.ts +53 -0
  34. package/template/apps/web/src/components/platform-fields/field-form-codec.ts +98 -0
  35. package/template/apps/web/src/components/platform-fields/location-value.ts +87 -0
  36. package/template/apps/web/src/components/platform-fields/resource-query.ts +131 -0
  37. package/template/apps/web/src/components/platform-fields/rich-text-value.ts +59 -0
  38. package/template/apps/web/src/components/platform-fields/subtable-value.ts +187 -0
  39. package/template/apps/web/src/components/resource/GeneratedResourceCrud.tsx +194 -108
  40. package/template/apps/web/src/components/resource/ResourceBatchActions.tsx +8 -1
  41. package/template/apps/web/src/components/resource/SurfaceFields.tsx +385 -79
  42. package/template/apps/web/src/components/resource/generated-resource-definition.ts +18 -0
  43. package/template/apps/web/src/components/resource/resource-import.ts +4 -4
  44. package/template/apps/web/src/data-provider.ts +59 -34
  45. package/template/apps/web/src/platform-client.ts +385 -111
  46. package/template/apps/web/src/runtime.tsx +166 -14
  47. package/template/apps/web/src/styles.css +264 -0
  48. package/template/apps/web/test/address-field.test.ts +66 -0
  49. package/template/apps/web/test/cascade-value.test.ts +68 -0
  50. package/template/apps/web/test/contracts.test.ts +29 -5
  51. package/template/apps/web/test/directory-value.test.ts +55 -0
  52. package/template/apps/web/test/field-bounds.test.ts +17 -0
  53. package/template/apps/web/test/field-form-codec.test.ts +85 -0
  54. package/template/apps/web/test/location-field.test.ts +83 -0
  55. package/template/apps/web/test/resource-import.test.ts +8 -7
  56. package/template/apps/web/test/resource-query.test.ts +144 -0
  57. package/template/apps/web/test/rich-json-field.test.ts +53 -0
  58. package/template/apps/web/test/signature-serial-field.test.ts +40 -0
  59. package/template/apps/web/test/subtable-value.test.ts +144 -0
  60. package/template/package.json +3 -3
  61. package/template/packages/contracts/src/generated.ts +69 -0
  62. package/template/scripts/verify-template-budget.mjs +11 -4
@@ -6,10 +6,12 @@ import {
6
6
  import {
7
7
  Alert,
8
8
  Button,
9
+ Checkbox,
9
10
  DatePicker,
10
11
  Form,
11
12
  Input,
12
13
  InputNumber,
14
+ Radio,
13
15
  Select,
14
16
  Space,
15
17
  Switch,
@@ -19,15 +21,30 @@ import {
19
21
  import type {
20
22
  DataFieldSurface,
21
23
  DataFileRef,
24
+ DepartmentReferenceValue,
25
+ LabeledValue,
26
+ ResourceReferenceValue,
27
+ StableAddressValue,
28
+ StableLocationValue,
29
+ StableSignatureValue,
30
+ UserReferenceValue,
22
31
  } from 'openxiangda-contracts/browser';
23
32
  import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
24
33
  import {
25
34
  AuthoritativeSelector,
26
- MobileAuthoritativeSelector,
27
35
  ResolvedValueText,
28
36
  } from '../../AuthoritativeSelector';
37
+ import { AddressField, AddressValueDisplay } from '../platform-fields/AddressField';
29
38
  import { AttachmentFileList, formatManagedFileSize } from '../platform-fields/AttachmentFileList';
39
+ import { CascadeField, CascadeValueDisplay } from '../platform-fields/CascadeField';
40
+ import type { CascadeStoredValue } from '../platform-fields/cascade-value';
41
+ import { DateTimeField, DateTimeFilter, DateTimeValueDisplay } from '../platform-fields/DateTimeField';
42
+ import { JsonField, JsonValueDisplay } from '../platform-fields/JsonField';
43
+ import { LocationField, LocationValueDisplay } from '../platform-fields/LocationField';
30
44
  import { PlatformDirectoryPicker } from '../platform-fields/PlatformDirectoryPicker';
45
+ import { ResourceReferenceField } from '../platform-fields/ResourceReferenceField';
46
+ import { RichTextField, RichTextValueDisplay } from '../platform-fields/RichTextField';
47
+ import { SignatureField, SignatureValueDisplay } from '../platform-fields/SignatureField';
31
48
 
32
49
  export type SurfaceField = DataFieldSurface & { key: string };
33
50
 
@@ -41,35 +58,51 @@ export interface SurfaceFieldEditContext {
41
58
  field: SurfaceField;
42
59
  disabled: boolean;
43
60
  operation: 'create' | 'update';
61
+ resourceCode?: string;
44
62
  recordId?: string;
45
63
  }
46
64
 
47
65
  export interface SurfaceFieldValueContext {
48
66
  field: SurfaceField;
49
67
  value: unknown;
68
+ resourceCode?: string;
50
69
  }
51
70
 
52
71
  export interface SurfaceFieldRenderers {
53
72
  renderExtra?: (context: SurfaceFieldEditContext) => ReactNode;
54
73
  renderScope?: (context: SurfaceFieldEditContext) => ReactNode;
74
+ renderSubtable?: (context: SurfaceFieldEditContext) => ReactNode;
55
75
  renderFilter?: (context: SurfaceFilterContext) => ReactNode;
56
76
  upload?: (
57
77
  field: SurfaceField,
58
78
  file: File,
59
79
  recordId?: string
60
80
  ) => Promise<DataFileRef>;
81
+ signer?: UserReferenceValue;
61
82
  renderValue?: (context: SurfaceFieldValueContext) => ReactNode | undefined;
62
83
  }
63
84
 
64
85
  export interface SurfaceFilterContext {
65
86
  field: SurfaceField;
87
+ resourceCode?: string;
66
88
  value: unknown;
67
89
  onChange: (value: unknown) => void;
68
90
  }
69
91
 
92
+ function numberInputProps(field: SurfaceField) {
93
+ const scale = field.type === 'number.integer' ? 0 : field.scale;
94
+ return {
95
+ ...(scale === undefined ? {} : { precision: scale }),
96
+ step: scale === undefined ? 'any' : scale === 0 ? 1 : 10 ** -scale,
97
+ ...(field.widget === 'money' ? { prefix: '¥' } : {}),
98
+ ...(field.widget === 'percent' ? { suffix: '%' } : {}),
99
+ };
100
+ }
101
+
70
102
  /** Render a standard filter control from the same field surface used by forms. */
71
103
  export function SurfaceFilterControl({
72
104
  field,
105
+ resourceCode,
73
106
  value,
74
107
  onChange,
75
108
  range = false,
@@ -79,7 +112,7 @@ export function SurfaceFilterControl({
79
112
  if (custom !== undefined) return custom;
80
113
  const placeholder = `筛选${field.label}`;
81
114
  switch (field.widget) {
82
- case 'boolean':
115
+ case 'switch':
83
116
  return (
84
117
  <Select
85
118
  allowClear
@@ -90,11 +123,11 @@ export function SurfaceFilterControl({
90
123
  />
91
124
  );
92
125
  case 'select':
93
- case 'multi':
126
+ case 'multi-select':
94
127
  return (
95
128
  <Select
96
129
  allowClear
97
- mode={field.widget === 'multi' ? 'multiple' : undefined}
130
+ mode={field.widget === 'multi-select' ? 'multiple' : undefined}
98
131
  options={field.options}
99
132
  placeholder={placeholder}
100
133
  value={value}
@@ -102,14 +135,11 @@ export function SurfaceFilterControl({
102
135
  />
103
136
  );
104
137
  case 'date':
138
+ case 'time':
105
139
  case 'datetime':
106
- return range ? (
107
- <DatePicker.RangePicker
108
- placeholder={[`起始${field.label}`, `结束${field.label}`]}
109
- showTime={field.widget === 'datetime'}
110
- onChange={(_, values) => onChange(values[0] && values[1] ? [values[0], values[1]] : undefined)}
111
- />
112
- ) : <DatePicker showTime={field.widget === 'datetime'} onChange={onChange} placeholder={placeholder} />;
140
+ case 'date-range':
141
+ case 'datetime-range':
142
+ return <DateTimeFilter field={field} onChange={onChange} value={value} />;
113
143
  case 'number':
114
144
  case 'money':
115
145
  case 'percent':
@@ -117,28 +147,117 @@ export function SurfaceFilterControl({
117
147
  const values = Array.isArray(value) ? value as [number | undefined, number | undefined] : [];
118
148
  return (
119
149
  <Space.Compact>
120
- <InputNumber min={0} onChange={next => onChange([next === null ? undefined : Number(next), values[1]])} placeholder={`最低${field.label}`} value={values[0]} />
121
- <InputNumber min={0} onChange={next => onChange([values[0], next === null ? undefined : Number(next)])} placeholder={`最高${field.label}`} value={values[1]} />
150
+ <InputNumber {...numberInputProps(field)} onChange={next => onChange([next === null ? undefined : Number(next), values[1]])} placeholder={`最低${field.label}`} value={values[0]} />
151
+ <InputNumber {...numberInputProps(field)} onChange={next => onChange([values[0], next === null ? undefined : Number(next)])} placeholder={`最高${field.label}`} value={values[1]} />
122
152
  </Space.Compact>
123
153
  );
124
154
  }
125
- return <InputNumber min={0} onChange={onChange} placeholder={placeholder} value={value as number | null | undefined} />;
155
+ return <InputNumber {...numberInputProps(field)} onChange={onChange} placeholder={placeholder} value={value as number | null | undefined} />;
126
156
  case 'scope':
127
157
  return renderers?.renderScope?.({ field, disabled: false, operation: 'update' }) || <Input allowClear value={value as string | undefined} onChange={event => onChange(event.target.value)} placeholder={placeholder} />;
128
158
  case 'directory-user':
129
159
  case 'directory-department':
130
- 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} />;
160
+ return <PlatformDirectoryPicker kind={field.widget === 'directory-user' ? 'user' : 'department'} multiple={field.type.endsWith('.multiple')} onChange={onChange as (value: UserReferenceValue | DepartmentReferenceValue | Array<UserReferenceValue | DepartmentReferenceValue> | undefined) => void} placeholder={placeholder} value={value as UserReferenceValue | DepartmentReferenceValue | Array<UserReferenceValue | DepartmentReferenceValue> | undefined} />;
131
161
  case 'resource':
132
- 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" />;
162
+ return (
163
+ <AuthoritativeSelector
164
+ fieldCode={field.key}
165
+ multiple={field.type === 'resource-ref.multiple'}
166
+ onChange={onChange}
167
+ operation="update"
168
+ placeholder={placeholder}
169
+ resourceCode={resourceCode}
170
+ source="resource"
171
+ value={value as ResourceReferenceValue | ResourceReferenceValue[] | undefined}
172
+ />
173
+ );
174
+ case 'cascade':
175
+ return (
176
+ <CascadeField
177
+ field={field}
178
+ onChange={onChange}
179
+ value={value as CascadeStoredValue | undefined}
180
+ />
181
+ );
182
+ case 'address':
183
+ return (
184
+ <AddressField
185
+ detailEnabled={false}
186
+ onChange={onChange}
187
+ value={value as StableAddressValue | undefined}
188
+ />
189
+ );
190
+ case 'json':
191
+ return <JsonField onChange={onChange} value={value} />;
133
192
  default:
134
193
  return <Input allowClear value={value as string | undefined} onChange={event => onChange(event.target.value)} placeholder={placeholder} />;
135
194
  }
136
195
  }
137
196
 
197
+ function snapshotIds(value: LabeledValue | LabeledValue[] | undefined) {
198
+ return (Array.isArray(value) ? value : value ? [value] : []).map(
199
+ item => item.value
200
+ );
201
+ }
202
+
203
+ function StaticOptionField({
204
+ field,
205
+ value,
206
+ onChange,
207
+ disabled,
208
+ }: {
209
+ field: SurfaceField;
210
+ value?: LabeledValue | LabeledValue[];
211
+ onChange?: (value: LabeledValue | LabeledValue[] | undefined) => void;
212
+ disabled?: boolean;
213
+ }) {
214
+ const multiple = field.type === 'option.multiple';
215
+ const ids = snapshotIds(value);
216
+ const emit = (next: string | string[] | undefined) => {
217
+ const selected = (Array.isArray(next) ? next : next ? [next] : [])
218
+ .map(id => field.options?.find(option => option.value === id))
219
+ .filter((item): item is LabeledValue => Boolean(item))
220
+ .map(item => ({ ...item }));
221
+ onChange?.(multiple ? selected : selected[0]);
222
+ };
223
+ if (field.widget === 'radio') {
224
+ return (
225
+ <Radio.Group
226
+ disabled={disabled}
227
+ onChange={event => emit(event.target.value)}
228
+ options={field.options}
229
+ value={ids[0]}
230
+ />
231
+ );
232
+ }
233
+ if (field.widget === 'checkbox') {
234
+ return (
235
+ <Checkbox.Group
236
+ disabled={disabled}
237
+ onChange={next => emit(next.map(String))}
238
+ options={field.options}
239
+ value={ids}
240
+ />
241
+ );
242
+ }
243
+ return (
244
+ <Select
245
+ allowClear
246
+ disabled={disabled}
247
+ mode={multiple ? 'multiple' : undefined}
248
+ onChange={next => emit(next || undefined)}
249
+ options={field.options}
250
+ placeholder={`请选择${field.label}`}
251
+ value={multiple ? ids : ids[0]}
252
+ />
253
+ );
254
+ }
255
+
138
256
  export function SurfaceFieldControl({
139
257
  field,
140
258
  disabled,
141
259
  operation,
260
+ resourceCode,
142
261
  recordId,
143
262
  renderers,
144
263
  }: SurfaceFieldEditContext & { renderers?: SurfaceFieldRenderers }) {
@@ -153,7 +272,11 @@ export function SurfaceFieldControl({
153
272
  ? [{ pattern: /^1\d{10}$/, message: '请输入 11 位手机号' }]
154
273
  : []),
155
274
  ];
156
- const common = { disabled, placeholder: `请输入${field.label}` };
275
+ const common = {
276
+ disabled,
277
+ ...(field.maxLength === undefined ? {} : { maxLength: field.maxLength }),
278
+ placeholder: `请输入${field.label}`,
279
+ };
157
280
  let control: ReactNode = <Input {...common} />;
158
281
  switch (field.widget) {
159
282
  case 'textarea':
@@ -162,20 +285,33 @@ export function SurfaceFieldControl({
162
285
  case 'number':
163
286
  case 'money':
164
287
  case 'percent':
165
- control = <InputNumber {...common} min={0} style={{ width: '100%' }} />;
288
+ control = <InputNumber {...common} {...numberInputProps(field)} style={{ width: '100%' }} />;
166
289
  break;
167
290
  case 'date':
291
+ case 'time':
168
292
  case 'datetime':
169
- control = <DatePicker disabled={disabled} showTime={field.widget === 'datetime'} style={{ width: '100%' }} />;
293
+ case 'date-range':
294
+ case 'datetime-range':
295
+ control = <DateTimeField disabled={disabled} field={field} />;
170
296
  break;
171
- case 'boolean':
297
+ case 'switch':
172
298
  control = <Switch checkedChildren="是" disabled={disabled} unCheckedChildren="否" />;
173
299
  break;
174
300
  case 'select':
175
- control = <Select disabled={disabled} options={field.options} placeholder={`请选择${field.label}`} />;
176
- break;
177
- case 'multi':
178
- control = <Select disabled={disabled} mode="multiple" options={field.options} placeholder={`请选择${field.label}`} />;
301
+ case 'multi-select':
302
+ case 'radio':
303
+ case 'checkbox':
304
+ control = field.source ? (
305
+ <ResourceReferenceField
306
+ disabled={disabled}
307
+ field={field}
308
+ fieldCode={field.key}
309
+ operation={operation}
310
+ resourceCode={resourceCode}
311
+ />
312
+ ) : (
313
+ <StaticOptionField disabled={disabled} field={field} />
314
+ );
179
315
  break;
180
316
  case 'email':
181
317
  control = <Input {...common} type="email" />;
@@ -194,42 +330,92 @@ export function SurfaceFieldControl({
194
330
  <PlatformDirectoryPicker
195
331
  disabled={disabled}
196
332
  kind={field.widget === 'directory-user' ? 'user' : 'department'}
197
- multiple={field.multiple}
333
+ multiple={field.type.endsWith('.multiple')}
198
334
  placeholder={`搜索并选择${field.label}`}
199
335
  />
200
336
  );
201
337
  break;
202
338
  case 'resource':
203
339
  control = (
204
- <AuthoritativeSelector
340
+ <ResourceReferenceField
205
341
  disabled={disabled}
206
- labelField={field.reference?.kind === 'resource' ? field.reference.labelField : undefined}
207
- multiple={field.multiple}
342
+ field={field}
343
+ fieldCode={field.key}
208
344
  operation={operation}
209
- placeholder={`搜索并选择${field.label}`}
210
- resourceCode={field.reference?.kind === 'resource' ? field.reference.resourceCode : undefined}
211
- source="resource"
345
+ resourceCode={resourceCode}
212
346
  />
213
347
  );
214
348
  break;
215
- case 'file':
349
+ case 'cascade':
350
+ control = <CascadeField disabled={disabled} field={field} />;
351
+ break;
352
+ case 'attachment':
353
+ case 'image':
216
354
  control = (
217
355
  <ManagedFileField
218
356
  accept={field.accept}
219
357
  disabled={disabled}
220
358
  field={field}
221
359
  maxCount={field.maxCount}
222
- multiple={field.multiple}
360
+ maxSizeMb={field.maxSizeMb}
361
+ multiple={field.type === 'file'}
223
362
  onUpload={renderers?.upload}
224
363
  recordId={recordId}
364
+ resourceCode={resourceCode}
365
+ />
366
+ );
367
+ break;
368
+ case 'location':
369
+ control = <LocationField disabled={disabled} />;
370
+ break;
371
+ case 'signature':
372
+ control = (
373
+ <SignatureField
374
+ disabled={disabled}
375
+ onUpload={renderers?.upload
376
+ ? file => renderers.upload!(field, file, recordId)
377
+ : undefined}
378
+ resourceCode={resourceCode}
379
+ signer={renderers?.signer}
380
+ />
381
+ );
382
+ break;
383
+ case 'address':
384
+ control = <AddressField disabled={disabled} />;
385
+ break;
386
+ case 'rich-text':
387
+ control = (
388
+ <RichTextField
389
+ disabled={disabled}
390
+ onUpload={renderers?.upload
391
+ ? file => renderers.upload!(field, file, recordId)
392
+ : undefined}
393
+ resourceCode={resourceCode}
225
394
  />
226
395
  );
227
396
  break;
228
397
  case 'json':
229
- control = <Input.TextArea {...common} rows={4} />;
398
+ control = <JsonField disabled={disabled} />;
399
+ break;
400
+ case 'subtable':
401
+ control = renderers?.renderSubtable?.({
402
+ field,
403
+ disabled,
404
+ operation,
405
+ resourceCode,
406
+ recordId,
407
+ }) || <Alert message="子表渲染器未配置" showIcon type="error" />;
230
408
  break;
231
409
  case 'readonly':
232
- control = <Input {...common} readOnly />;
410
+ control = (
411
+ <Input
412
+ {...common}
413
+ placeholder={field.type === 'serial-number'
414
+ ? '保存后由系统自动生成'
415
+ : '由系统生成'}
416
+ readOnly
417
+ />
418
+ );
233
419
  break;
234
420
  case 'text':
235
421
  default:
@@ -238,7 +424,7 @@ export function SurfaceFieldControl({
238
424
  const extra =
239
425
  renderers?.renderExtra?.({ field, disabled, operation, recordId }) ||
240
426
  (disabled ? '当前角色不可修改此字段' : undefined);
241
- const wide = field.widget === 'textarea' || field.widget === 'file' || field.widget === 'json';
427
+ const wide = ['textarea', 'attachment', 'image', 'rich-text', 'address', 'location', 'signature', 'json', 'subtable'].includes(field.widget);
242
428
  return (
243
429
  <Form.Item
244
430
  className={wide ? 'oxa-field-wide' : undefined}
@@ -246,7 +432,7 @@ export function SurfaceFieldControl({
246
432
  label={field.label}
247
433
  name={field.key}
248
434
  rules={rules}
249
- valuePropName={field.widget === 'boolean' ? 'checked' : 'value'}
435
+ valuePropName={field.widget === 'switch' ? 'checked' : 'value'}
250
436
  >
251
437
  {control}
252
438
  </Form.Item>
@@ -258,47 +444,64 @@ export function MobileSurfaceFieldControl({
258
444
  field,
259
445
  disabled,
260
446
  operation,
447
+ resourceCode,
261
448
  recordId,
262
449
  renderers,
263
450
  }: SurfaceFieldEditContext & { renderers?: SurfaceFieldRenderers }) {
264
451
  const rules = field.requiredHint
265
452
  ? [{ required: true, message: `请填写或选择${field.label}` }]
266
453
  : [];
267
- const inputProps = { disabled, className: 'oxa-mobile-input', placeholder: `请输入${field.label}` };
454
+ const inputProps = {
455
+ disabled,
456
+ className: 'oxa-mobile-input',
457
+ ...(field.maxLength === undefined ? {} : { maxLength: field.maxLength }),
458
+ placeholder: `请输入${field.label}`,
459
+ };
268
460
  let control: ReactNode = <input {...inputProps} />;
269
461
  switch (field.widget) {
270
462
  case 'textarea':
271
- case 'json':
272
463
  control = <textarea {...inputProps} rows={4} />;
273
464
  break;
274
465
  case 'number':
275
466
  case 'money':
276
467
  case 'percent':
277
- control = <input {...inputProps} min={0} type="number" />;
468
+ control = (
469
+ <div className="oxa-mobile-number-field">
470
+ {field.widget === 'money' && <span>¥</span>}
471
+ <input
472
+ {...inputProps}
473
+ step={numberInputProps(field).step}
474
+ type="number"
475
+ />
476
+ {field.widget === 'percent' && <span>%</span>}
477
+ </div>
478
+ );
278
479
  break;
279
480
  case 'date':
280
- control = <input {...inputProps} type="date" />;
281
- break;
481
+ case 'time':
282
482
  case 'datetime':
283
- control = <input {...inputProps} type="datetime-local" />;
483
+ case 'date-range':
484
+ case 'datetime-range':
485
+ control = <DateTimeField disabled={disabled} field={field} mobile />;
284
486
  break;
285
- case 'boolean':
487
+ case 'switch':
286
488
  control = <input disabled={disabled} type="checkbox" />;
287
489
  break;
288
490
  case 'select':
289
- case 'multi':
290
- control = (
291
- <select
292
- className="oxa-mobile-input"
491
+ case 'multi-select':
492
+ case 'radio':
493
+ case 'checkbox':
494
+ control = field.source ? (
495
+ <ResourceReferenceField
293
496
  disabled={disabled}
294
- multiple={field.widget === 'multi'}
295
- >
296
- {field.options?.map(option => (
297
- <option key={option.value} value={option.value}>
298
- {option.label}
299
- </option>
300
- ))}
301
- </select>
497
+ field={field}
498
+ fieldCode={field.key}
499
+ mobile
500
+ operation={operation}
501
+ resourceCode={resourceCode}
502
+ />
503
+ ) : (
504
+ <StaticOptionField disabled={disabled} field={field} />
302
505
  );
303
506
  break;
304
507
  case 'email':
@@ -318,40 +521,96 @@ export function MobileSurfaceFieldControl({
318
521
  <PlatformDirectoryPicker
319
522
  disabled={disabled}
320
523
  kind={field.widget === 'directory-user' ? 'user' : 'department'}
321
- multiple={field.multiple}
524
+ multiple={field.type.endsWith('.multiple')}
322
525
  placeholder={`搜索并选择${field.label}`}
323
526
  />
324
527
  );
325
528
  break;
326
529
  case 'resource':
327
530
  control = (
328
- <MobileAuthoritativeSelector
531
+ <ResourceReferenceField
329
532
  disabled={disabled}
330
- labelField={field.reference?.kind === 'resource' ? field.reference.labelField : undefined}
331
- multiple={field.multiple}
533
+ field={field}
534
+ fieldCode={field.key}
535
+ mobile
332
536
  operation={operation}
333
- placeholder={`搜索并选择${field.label}`}
334
- resourceCode={field.reference?.kind === 'resource' ? field.reference.resourceCode : undefined}
335
- source="resource"
537
+ resourceCode={resourceCode}
336
538
  />
337
539
  );
338
540
  break;
339
- case 'file':
541
+ case 'cascade':
542
+ control = <CascadeField disabled={disabled} field={field} />;
543
+ break;
544
+ case 'attachment':
545
+ case 'image':
340
546
  control = (
341
547
  <ManagedFileField
342
548
  accept={field.accept}
343
549
  disabled={disabled}
344
550
  field={field}
345
551
  maxCount={field.maxCount}
346
- multiple={field.multiple}
552
+ maxSizeMb={field.maxSizeMb}
553
+ multiple={field.type === 'file'}
347
554
  mobile
348
555
  onUpload={renderers?.upload}
349
556
  recordId={recordId}
557
+ resourceCode={resourceCode}
558
+ />
559
+ );
560
+ break;
561
+ case 'location':
562
+ control = <LocationField disabled={disabled} mobile />;
563
+ break;
564
+ case 'signature':
565
+ control = (
566
+ <SignatureField
567
+ disabled={disabled}
568
+ mobile
569
+ onUpload={renderers?.upload
570
+ ? file => renderers.upload!(field, file, recordId)
571
+ : undefined}
572
+ resourceCode={resourceCode}
573
+ signer={renderers?.signer}
350
574
  />
351
575
  );
352
576
  break;
577
+ case 'address':
578
+ control = <AddressField disabled={disabled} mobile />;
579
+ break;
580
+ case 'rich-text':
581
+ control = (
582
+ <RichTextField
583
+ disabled={disabled}
584
+ mobile
585
+ onUpload={renderers?.upload
586
+ ? file => renderers.upload!(field, file, recordId)
587
+ : undefined}
588
+ resourceCode={resourceCode}
589
+ />
590
+ );
591
+ break;
592
+ case 'json':
593
+ control = <JsonField disabled={disabled} mobile />;
594
+ break;
595
+ case 'subtable':
596
+ control = renderers?.renderSubtable?.({
597
+ field,
598
+ disabled,
599
+ operation,
600
+ resourceCode,
601
+ recordId,
602
+ }) || <Alert message="子表渲染器未配置" showIcon type="error" />;
603
+ break;
353
604
  case 'readonly':
354
- control = <input {...inputProps} readOnly />;
605
+ control = (
606
+ <input
607
+ {...inputProps}
608
+ placeholder={field.type === 'serial-number'
609
+ ? '保存后由系统自动生成'
610
+ : '由系统生成'}
611
+ readOnly
612
+ />
613
+ );
355
614
  break;
356
615
  }
357
616
  return (
@@ -361,7 +620,7 @@ export function MobileSurfaceFieldControl({
361
620
  label={field.label}
362
621
  name={field.key}
363
622
  rules={rules}
364
- valuePropName={field.widget === 'boolean' ? 'checked' : 'value'}
623
+ valuePropName={field.widget === 'switch' ? 'checked' : 'value'}
365
624
  >
366
625
  {control}
367
626
  </Form.Item>
@@ -371,6 +630,7 @@ export function MobileSurfaceFieldControl({
371
630
  export function SurfaceFieldValue({
372
631
  field,
373
632
  value,
633
+ resourceCode,
374
634
  renderers,
375
635
  }: SurfaceFieldValueContext & { renderers?: SurfaceFieldRenderers }) {
376
636
  if (value === undefined || value === null || value === '') return <>-</>;
@@ -380,31 +640,66 @@ export function SurfaceFieldValue({
380
640
  return (
381
641
  <ResolvedValueText
382
642
  source={field.widget === 'directory-user' ? 'user' : 'department'}
383
- value={Array.isArray(value) ? value.map(String) : String(value)}
643
+ value={value as never}
384
644
  />
385
645
  );
386
646
  }
387
- if (field.widget === 'resource' && field.reference?.kind === 'resource') {
388
- return <ResolvedValueText labelField={field.reference.labelField} resourceCode={field.reference.resourceCode} source="resource" value={Array.isArray(value) ? value.map(String) : String(value)} />;
647
+ if (field.type.startsWith('resource-ref.')) {
648
+ return (
649
+ <ResolvedValueText
650
+ source="resource"
651
+ value={value as ResourceReferenceValue | ResourceReferenceValue[]}
652
+ />
653
+ );
389
654
  }
390
- if (field.widget === 'file') {
655
+ if (field.type === 'file' || field.type === 'image') {
391
656
  const values = (Array.isArray(value) ? value : [value]).filter(isFileRef);
392
- return values.length ? <AttachmentFileList files={values} /> : <>-</>;
657
+ return values.length ? <AttachmentFileList files={values} resourceCode={resourceCode} /> : <>-</>;
658
+ }
659
+ if (field.type === 'location') {
660
+ return <LocationValueDisplay value={value as StableLocationValue} />;
661
+ }
662
+ if (field.type === 'address') {
663
+ return <AddressValueDisplay value={value as StableAddressValue} />;
664
+ }
665
+ if (field.type === 'text.rich') {
666
+ return <RichTextValueDisplay value={String(value)} />;
667
+ }
668
+ if (field.type === 'json') {
669
+ return <JsonValueDisplay value={value} />;
670
+ }
671
+ if (field.type === 'signature') {
672
+ return (
673
+ <SignatureValueDisplay
674
+ resourceCode={resourceCode}
675
+ value={value as StableSignatureValue}
676
+ />
677
+ );
678
+ }
679
+ if (field.type === 'cascade.single' || field.type === 'cascade.multiple') {
680
+ return (
681
+ <CascadeValueDisplay
682
+ multiple={field.type === 'cascade.multiple'}
683
+ value={value as CascadeStoredValue}
684
+ />
685
+ );
393
686
  }
394
- if (field.widget === 'boolean') {
687
+ if (field.type === 'boolean') {
395
688
  return <Tag color={value ? 'green' : 'default'}>{value ? '是' : '否'}</Tag>;
396
689
  }
397
690
  if (Array.isArray(value)) {
398
691
  return <>{value.map(item => optionLabel(field, item)).join('、') || '-'}</>;
399
692
  }
400
- if (field.widget === 'date' || field.widget === 'datetime') {
401
- const parsed = new Date(String(value));
402
- return <>{Number.isNaN(parsed.getTime()) ? String(value) : parsed.toLocaleString('zh-CN', { hour12: false })}</>;
693
+ if (['date', 'time', 'datetime', 'date-range', 'datetime-range'].includes(field.type)) {
694
+ return <DateTimeValueDisplay field={field} value={value} />;
403
695
  }
404
696
  return <>{optionLabel(field, value)}</>;
405
697
  }
406
698
 
407
699
  function optionLabel(field: SurfaceField, value: unknown) {
700
+ if (value && typeof value === 'object' && 'label' in value) {
701
+ return String((value as LabeledValue).label);
702
+ }
408
703
  return field.options?.find(option => option.value === value)?.label || String(value);
409
704
  }
410
705
 
@@ -434,8 +729,10 @@ function ManagedFileField({
434
729
  disabled,
435
730
  multiple,
436
731
  maxCount = 1,
732
+ maxSizeMb = 50,
437
733
  accept,
438
734
  onUpload,
735
+ resourceCode,
439
736
  mobile = false,
440
737
  }: {
441
738
  field: SurfaceField;
@@ -445,8 +742,10 @@ function ManagedFileField({
445
742
  disabled?: boolean;
446
743
  multiple?: boolean;
447
744
  maxCount?: number;
745
+ maxSizeMb?: number;
448
746
  accept?: string | string[];
449
747
  onUpload?: SurfaceFieldRenderers['upload'];
748
+ resourceCode?: string;
450
749
  mobile?: boolean;
451
750
  }) {
452
751
  const [uploading, setUploading] = useState(false);
@@ -459,6 +758,11 @@ function ManagedFileField({
459
758
  useEffect(() => {
460
759
  refsRef.current = refs;
461
760
  }, [refs]);
761
+ const assertSize = (file: File) => {
762
+ if (file.size > maxSizeMb * 1024 * 1024) {
763
+ throw new Error(`单个文件不能超过 ${maxSizeMb}MB`);
764
+ }
765
+ };
462
766
  if (!onUpload) {
463
767
  return <Alert type="info" showIcon title="该文件字段尚未配置上传能力" />;
464
768
  }
@@ -469,6 +773,7 @@ function ManagedFileField({
469
773
  mobileActiveUploads.current += 1;
470
774
  setUploading(true);
471
775
  try {
776
+ assertSize(item.file);
472
777
  const uploaded = await onUpload(field, item.file, recordId);
473
778
  const next = multiple ? [...refsRef.current, uploaded].slice(0, maxCount) : uploaded;
474
779
  refsRef.current = Array.isArray(next) ? next : next ? [next] : [];
@@ -536,7 +841,7 @@ function ManagedFileField({
536
841
  </div>
537
842
  )}
538
843
  {refs.length ? (
539
- <AttachmentFileList files={refs} onRemove={file => {
844
+ <AttachmentFileList files={refs} resourceCode={resourceCode} onRemove={file => {
540
845
  const next = refs.filter(item => item.id !== file.id);
541
846
  refsRef.current = next;
542
847
  onChange?.(multiple ? next : undefined);
@@ -561,6 +866,7 @@ function ManagedFileField({
561
866
  setUploading(true);
562
867
  setUploadError('');
563
868
  try {
869
+ assertSize(options.file as File);
564
870
  options.onProgress?.({ percent: 15 });
565
871
  const uploaded = await onUpload(field, options.file as File, recordId);
566
872
  options.onProgress?.({ percent: 100 });
@@ -587,7 +893,7 @@ function ManagedFileField({
587
893
  </Upload>
588
894
  <Upload.Dragger {...uploadProps} className="oxa-file-dropzone" openFileDialogOnClick={false} pastable>
589
895
  <span>可将文件拖到这里,或在此区域按 <kbd>⌘ V</kbd> 粘贴</span>
590
- <small>{multiple ? `最多 ${maxCount} 个文件,单个不超过 50MB` : '仅保留一个文件,新上传会替换当前文件'}</small>
896
+ <small>{multiple ? `最多 ${maxCount} 个文件,单个不超过 ${maxSizeMb}MB` : `仅保留一个文件,单个不超过 ${maxSizeMb}MB`}</small>
591
897
  </Upload.Dragger>
592
898
  {uploadError && (
593
899
  <div className="oxa-file-error">
@@ -596,7 +902,7 @@ function ManagedFileField({
596
902
  </div>
597
903
  )}
598
904
  {refs.length ? (
599
- <AttachmentFileList files={refs} onRemove={file => {
905
+ <AttachmentFileList files={refs} resourceCode={resourceCode} onRemove={file => {
600
906
  const next = refs.filter(item => item.id !== file.id);
601
907
  refsRef.current = next;
602
908
  onChange?.(multiple ? next : undefined);