openxiangda-cli 2.0.0-alpha.89 → 2.0.0-alpha.90

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 (77) hide show
  1. package/README.md +2 -2
  2. package/bin/run.js +1 -0
  3. package/dist/commands/skill.d.ts +26 -0
  4. package/dist/commands/skill.d.ts.map +1 -0
  5. package/dist/commands/skill.js +43 -0
  6. package/dist/commands/skill.js.map +1 -0
  7. package/dist/create-workspace.d.ts.map +1 -1
  8. package/dist/create-workspace.js +3 -3
  9. package/dist/create-workspace.js.map +1 -1
  10. package/dist/toolchain-capsule.d.ts.map +1 -1
  11. package/dist/toolchain-capsule.js +4 -8
  12. package/dist/toolchain-capsule.js.map +1 -1
  13. package/package.json +6 -7
  14. package/template/AGENTS.md +2 -1
  15. package/template/README.md +3 -3
  16. package/template/apps/server/package.json +1 -1
  17. package/template/apps/server/src/app.module.ts +1 -1
  18. package/template/apps/server/src/main.ts +1 -1
  19. package/template/apps/web/e2e/client-fixture.ts +5 -0
  20. package/template/apps/web/e2e/data-api-live-fixture.tsx +19 -14
  21. package/template/apps/web/e2e/field-protocol-fixture.tsx +87 -20
  22. package/template/apps/web/e2e/field-protocol.spec.ts +86 -0
  23. package/template/apps/web/e2e/resources.spec.ts +20 -181
  24. package/template/apps/web/field-protocol.e2e.html +3 -0
  25. package/template/apps/web/package.json +2 -5
  26. package/template/apps/web/src/main.tsx +12 -60
  27. package/template/apps/web/test/contracts.test.ts +52 -104
  28. package/template/openxiangda.config.ts +1 -1
  29. package/template/package.json +1 -3
  30. package/template/packages/contracts/src/generated.ts +1 -0
  31. package/template/scripts/verify-template-budget.mjs +2 -2
  32. package/template/apps/web/src/AuthoritativeSelector.tsx +0 -661
  33. package/template/apps/web/src/FilePreviewPage.tsx +0 -324
  34. package/template/apps/web/src/RoleSubjectSelect.tsx +0 -128
  35. package/template/apps/web/src/Shell.tsx +0 -445
  36. package/template/apps/web/src/appearance.tsx +0 -147
  37. package/template/apps/web/src/components/platform-fields/AddressField.tsx +0 -338
  38. package/template/apps/web/src/components/platform-fields/AttachmentFileList.tsx +0 -245
  39. package/template/apps/web/src/components/platform-fields/CascadeField.tsx +0 -49
  40. package/template/apps/web/src/components/platform-fields/DateTimeField.tsx +0 -148
  41. package/template/apps/web/src/components/platform-fields/JsonField.tsx +0 -77
  42. package/template/apps/web/src/components/platform-fields/LocationField.tsx +0 -164
  43. package/template/apps/web/src/components/platform-fields/PlatformDirectoryPicker.tsx +0 -673
  44. package/template/apps/web/src/components/platform-fields/ResourceReferenceField.tsx +0 -67
  45. package/template/apps/web/src/components/platform-fields/RichTextField.tsx +0 -196
  46. package/template/apps/web/src/components/platform-fields/SignatureField.tsx +0 -315
  47. package/template/apps/web/src/components/platform-fields/SubtableField.tsx +0 -553
  48. package/template/apps/web/src/components/platform-fields/address-value.ts +0 -80
  49. package/template/apps/web/src/components/platform-fields/cascade-value.ts +0 -66
  50. package/template/apps/web/src/components/platform-fields/directory-value.ts +0 -53
  51. package/template/apps/web/src/components/platform-fields/field-form-codec.ts +0 -98
  52. package/template/apps/web/src/components/platform-fields/location-value.ts +0 -87
  53. package/template/apps/web/src/components/platform-fields/resource-query.ts +0 -131
  54. package/template/apps/web/src/components/platform-fields/rich-text-value.ts +0 -59
  55. package/template/apps/web/src/components/platform-fields/subtable-value.ts +0 -187
  56. package/template/apps/web/src/components/resource/GeneratedResourceCrud.tsx +0 -1184
  57. package/template/apps/web/src/components/resource/ResourceBatchActions.tsx +0 -326
  58. package/template/apps/web/src/components/resource/StandardResourcePages.tsx +0 -392
  59. package/template/apps/web/src/components/resource/SurfaceFields.tsx +0 -915
  60. package/template/apps/web/src/components/resource/generated-resource-definition.ts +0 -18
  61. package/template/apps/web/src/components/resource/resource-import.ts +0 -205
  62. package/template/apps/web/src/data-provider.ts +0 -72
  63. package/template/apps/web/src/platform-client.ts +0 -708
  64. package/template/apps/web/src/runtime-meta.ts +0 -75
  65. package/template/apps/web/src/runtime.tsx +0 -227
  66. package/template/apps/web/src/styles.css +0 -1941
  67. package/template/apps/web/test/address-field.test.ts +0 -66
  68. package/template/apps/web/test/cascade-value.test.ts +0 -68
  69. package/template/apps/web/test/directory-value.test.ts +0 -55
  70. package/template/apps/web/test/field-bounds.test.ts +0 -17
  71. package/template/apps/web/test/field-form-codec.test.ts +0 -85
  72. package/template/apps/web/test/location-field.test.ts +0 -83
  73. package/template/apps/web/test/resource-import.test.ts +0 -143
  74. package/template/apps/web/test/resource-query.test.ts +0 -144
  75. package/template/apps/web/test/rich-json-field.test.ts +0 -53
  76. package/template/apps/web/test/signature-serial-field.test.ts +0 -40
  77. package/template/apps/web/test/subtable-value.test.ts +0 -144
@@ -1,915 +0,0 @@
1
- import {
2
- PictureOutlined,
3
- ReloadOutlined,
4
- UploadOutlined,
5
- } from '@ant-design/icons';
6
- import {
7
- Alert,
8
- Button,
9
- Checkbox,
10
- DatePicker,
11
- Form,
12
- Input,
13
- InputNumber,
14
- Radio,
15
- Select,
16
- Space,
17
- Switch,
18
- Tag,
19
- Upload,
20
- } from 'antd';
21
- import type {
22
- DataFieldSurface,
23
- DataFileRef,
24
- DepartmentReferenceValue,
25
- LabeledValue,
26
- ResourceReferenceValue,
27
- StableAddressValue,
28
- StableLocationValue,
29
- StableSignatureValue,
30
- UserReferenceValue,
31
- } from 'openxiangda-contracts/browser';
32
- import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
33
- import {
34
- AuthoritativeSelector,
35
- ResolvedValueText,
36
- } from '../../AuthoritativeSelector';
37
- import { AddressField, AddressValueDisplay } from '../platform-fields/AddressField';
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';
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';
48
-
49
- export type SurfaceField = DataFieldSurface & { key: string };
50
-
51
- export function listSurfaceFields(fields: Record<string, DataFieldSurface>) {
52
- return Object.entries(fields)
53
- .filter(([, field]) => field.list)
54
- .map(([key, field]) => ({ key, ...field }));
55
- }
56
-
57
- export interface SurfaceFieldEditContext {
58
- field: SurfaceField;
59
- disabled: boolean;
60
- operation: 'create' | 'update';
61
- resourceCode?: string;
62
- recordId?: string;
63
- }
64
-
65
- export interface SurfaceFieldValueContext {
66
- field: SurfaceField;
67
- value: unknown;
68
- resourceCode?: string;
69
- }
70
-
71
- export interface SurfaceFieldRenderers {
72
- renderExtra?: (context: SurfaceFieldEditContext) => ReactNode;
73
- renderScope?: (context: SurfaceFieldEditContext) => ReactNode;
74
- renderSubtable?: (context: SurfaceFieldEditContext) => ReactNode;
75
- renderFilter?: (context: SurfaceFilterContext) => ReactNode;
76
- upload?: (
77
- field: SurfaceField,
78
- file: File,
79
- recordId?: string
80
- ) => Promise<DataFileRef>;
81
- signer?: UserReferenceValue;
82
- renderValue?: (context: SurfaceFieldValueContext) => ReactNode | undefined;
83
- }
84
-
85
- export interface SurfaceFilterContext {
86
- field: SurfaceField;
87
- resourceCode?: string;
88
- value: unknown;
89
- onChange: (value: unknown) => void;
90
- }
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
-
102
- /** Render a standard filter control from the same field surface used by forms. */
103
- export function SurfaceFilterControl({
104
- field,
105
- resourceCode,
106
- value,
107
- onChange,
108
- range = false,
109
- renderers,
110
- }: SurfaceFilterContext & { range?: boolean; renderers?: Pick<SurfaceFieldRenderers, 'renderScope' | 'renderFilter'> }) {
111
- const custom = renderers?.renderFilter?.({ field, value, onChange });
112
- if (custom !== undefined) return custom;
113
- const placeholder = `筛选${field.label}`;
114
- switch (field.widget) {
115
- case 'switch':
116
- return (
117
- <Select
118
- allowClear
119
- options={[{ label: '是', value: true }, { label: '否', value: false }]}
120
- placeholder={placeholder}
121
- value={value}
122
- onChange={onChange}
123
- />
124
- );
125
- case 'select':
126
- case 'multi-select':
127
- return (
128
- <Select
129
- allowClear
130
- mode={field.widget === 'multi-select' ? 'multiple' : undefined}
131
- options={field.options}
132
- placeholder={placeholder}
133
- value={value}
134
- onChange={onChange}
135
- />
136
- );
137
- case 'date':
138
- case 'time':
139
- case 'datetime':
140
- case 'date-range':
141
- case 'datetime-range':
142
- return <DateTimeFilter field={field} onChange={onChange} value={value} />;
143
- case 'number':
144
- case 'money':
145
- case 'percent':
146
- if (range) {
147
- const values = Array.isArray(value) ? value as [number | undefined, number | undefined] : [];
148
- return (
149
- <Space.Compact>
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]} />
152
- </Space.Compact>
153
- );
154
- }
155
- return <InputNumber {...numberInputProps(field)} onChange={onChange} placeholder={placeholder} value={value as number | null | undefined} />;
156
- case 'scope':
157
- return renderers?.renderScope?.({ field, disabled: false, operation: 'update' }) || <Input allowClear value={value as string | undefined} onChange={event => onChange(event.target.value)} placeholder={placeholder} />;
158
- case 'directory-user':
159
- case 'directory-department':
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} />;
161
- case 'resource':
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} />;
192
- default:
193
- return <Input allowClear value={value as string | undefined} onChange={event => onChange(event.target.value)} placeholder={placeholder} />;
194
- }
195
- }
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
-
256
- export function SurfaceFieldControl({
257
- field,
258
- disabled,
259
- operation,
260
- resourceCode,
261
- recordId,
262
- renderers,
263
- }: SurfaceFieldEditContext & { renderers?: SurfaceFieldRenderers }) {
264
- const rules = [
265
- ...(field.requiredHint
266
- ? [{ required: true, message: `请填写或选择${field.label}` }]
267
- : []),
268
- ...(field.widget === 'email'
269
- ? [{ type: 'email' as const, message: '邮箱格式不正确' }]
270
- : []),
271
- ...(field.widget === 'phone'
272
- ? [{ pattern: /^1\d{10}$/, message: '请输入 11 位手机号' }]
273
- : []),
274
- ];
275
- const common = {
276
- disabled,
277
- ...(field.maxLength === undefined ? {} : { maxLength: field.maxLength }),
278
- placeholder: `请输入${field.label}`,
279
- };
280
- let control: ReactNode = <Input {...common} />;
281
- switch (field.widget) {
282
- case 'textarea':
283
- control = <Input.TextArea {...common} rows={3} />;
284
- break;
285
- case 'number':
286
- case 'money':
287
- case 'percent':
288
- control = <InputNumber {...common} {...numberInputProps(field)} style={{ width: '100%' }} />;
289
- break;
290
- case 'date':
291
- case 'time':
292
- case 'datetime':
293
- case 'date-range':
294
- case 'datetime-range':
295
- control = <DateTimeField disabled={disabled} field={field} />;
296
- break;
297
- case 'switch':
298
- control = <Switch checkedChildren="是" disabled={disabled} unCheckedChildren="否" />;
299
- break;
300
- case 'select':
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
- );
315
- break;
316
- case 'email':
317
- control = <Input {...common} type="email" />;
318
- break;
319
- case 'phone':
320
- control = <Input {...common} type="tel" />;
321
- break;
322
- case 'scope':
323
- control = renderers?.renderScope?.({ field, disabled, operation, recordId }) || (
324
- <Input {...common} />
325
- );
326
- break;
327
- case 'directory-user':
328
- case 'directory-department':
329
- control = (
330
- <PlatformDirectoryPicker
331
- disabled={disabled}
332
- kind={field.widget === 'directory-user' ? 'user' : 'department'}
333
- multiple={field.type.endsWith('.multiple')}
334
- placeholder={`搜索并选择${field.label}`}
335
- />
336
- );
337
- break;
338
- case 'resource':
339
- control = (
340
- <ResourceReferenceField
341
- disabled={disabled}
342
- field={field}
343
- fieldCode={field.key}
344
- operation={operation}
345
- resourceCode={resourceCode}
346
- />
347
- );
348
- break;
349
- case 'cascade':
350
- control = <CascadeField disabled={disabled} field={field} />;
351
- break;
352
- case 'attachment':
353
- case 'image':
354
- control = (
355
- <ManagedFileField
356
- accept={field.accept}
357
- disabled={disabled}
358
- field={field}
359
- maxCount={field.maxCount}
360
- maxSizeMb={field.maxSizeMb}
361
- multiple={field.type === 'file'}
362
- onUpload={renderers?.upload}
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}
394
- />
395
- );
396
- break;
397
- case 'json':
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" />;
408
- break;
409
- case 'readonly':
410
- control = (
411
- <Input
412
- {...common}
413
- placeholder={field.type === 'serial-number'
414
- ? '保存后由系统自动生成'
415
- : '由系统生成'}
416
- readOnly
417
- />
418
- );
419
- break;
420
- case 'text':
421
- default:
422
- break;
423
- }
424
- const extra =
425
- renderers?.renderExtra?.({ field, disabled, operation, recordId }) ||
426
- (disabled ? '当前角色不可修改此字段' : undefined);
427
- const wide = ['textarea', 'attachment', 'image', 'rich-text', 'address', 'location', 'signature', 'json', 'subtable'].includes(field.widget);
428
- return (
429
- <Form.Item
430
- className={wide ? 'oxa-field-wide' : undefined}
431
- extra={extra}
432
- label={field.label}
433
- name={field.key}
434
- rules={rules}
435
- valuePropName={field.widget === 'switch' ? 'checked' : 'value'}
436
- >
437
- {control}
438
- </Form.Item>
439
- );
440
- }
441
-
442
- /** Mobile keeps the same field values and validation but uses native controls. */
443
- export function MobileSurfaceFieldControl({
444
- field,
445
- disabled,
446
- operation,
447
- resourceCode,
448
- recordId,
449
- renderers,
450
- }: SurfaceFieldEditContext & { renderers?: SurfaceFieldRenderers }) {
451
- const rules = field.requiredHint
452
- ? [{ required: true, message: `请填写或选择${field.label}` }]
453
- : [];
454
- const inputProps = {
455
- disabled,
456
- className: 'oxa-mobile-input',
457
- ...(field.maxLength === undefined ? {} : { maxLength: field.maxLength }),
458
- placeholder: `请输入${field.label}`,
459
- };
460
- let control: ReactNode = <input {...inputProps} />;
461
- switch (field.widget) {
462
- case 'textarea':
463
- control = <textarea {...inputProps} rows={4} />;
464
- break;
465
- case 'number':
466
- case 'money':
467
- case 'percent':
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
- );
479
- break;
480
- case 'date':
481
- case 'time':
482
- case 'datetime':
483
- case 'date-range':
484
- case 'datetime-range':
485
- control = <DateTimeField disabled={disabled} field={field} mobile />;
486
- break;
487
- case 'switch':
488
- control = <input disabled={disabled} type="checkbox" />;
489
- break;
490
- case 'select':
491
- case 'multi-select':
492
- case 'radio':
493
- case 'checkbox':
494
- control = field.source ? (
495
- <ResourceReferenceField
496
- disabled={disabled}
497
- field={field}
498
- fieldCode={field.key}
499
- mobile
500
- operation={operation}
501
- resourceCode={resourceCode}
502
- />
503
- ) : (
504
- <StaticOptionField disabled={disabled} field={field} />
505
- );
506
- break;
507
- case 'email':
508
- control = <input {...inputProps} type="email" />;
509
- break;
510
- case 'phone':
511
- control = <input {...inputProps} type="tel" />;
512
- break;
513
- case 'scope':
514
- control = renderers?.renderScope?.({ field, disabled, operation, recordId }) || (
515
- <input {...inputProps} />
516
- );
517
- break;
518
- case 'directory-user':
519
- case 'directory-department':
520
- control = (
521
- <PlatformDirectoryPicker
522
- disabled={disabled}
523
- kind={field.widget === 'directory-user' ? 'user' : 'department'}
524
- multiple={field.type.endsWith('.multiple')}
525
- placeholder={`搜索并选择${field.label}`}
526
- />
527
- );
528
- break;
529
- case 'resource':
530
- control = (
531
- <ResourceReferenceField
532
- disabled={disabled}
533
- field={field}
534
- fieldCode={field.key}
535
- mobile
536
- operation={operation}
537
- resourceCode={resourceCode}
538
- />
539
- );
540
- break;
541
- case 'cascade':
542
- control = <CascadeField disabled={disabled} field={field} />;
543
- break;
544
- case 'attachment':
545
- case 'image':
546
- control = (
547
- <ManagedFileField
548
- accept={field.accept}
549
- disabled={disabled}
550
- field={field}
551
- maxCount={field.maxCount}
552
- maxSizeMb={field.maxSizeMb}
553
- multiple={field.type === 'file'}
554
- mobile
555
- onUpload={renderers?.upload}
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}
574
- />
575
- );
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;
604
- case 'readonly':
605
- control = (
606
- <input
607
- {...inputProps}
608
- placeholder={field.type === 'serial-number'
609
- ? '保存后由系统自动生成'
610
- : '由系统生成'}
611
- readOnly
612
- />
613
- );
614
- break;
615
- }
616
- return (
617
- <Form.Item
618
- className="oxa-mobile-field"
619
- extra={renderers?.renderExtra?.({ field, disabled, operation, recordId })}
620
- label={field.label}
621
- name={field.key}
622
- rules={rules}
623
- valuePropName={field.widget === 'switch' ? 'checked' : 'value'}
624
- >
625
- {control}
626
- </Form.Item>
627
- );
628
- }
629
-
630
- export function SurfaceFieldValue({
631
- field,
632
- value,
633
- resourceCode,
634
- renderers,
635
- }: SurfaceFieldValueContext & { renderers?: SurfaceFieldRenderers }) {
636
- if (value === undefined || value === null || value === '') return <>-</>;
637
- const custom = renderers?.renderValue?.({ field, value });
638
- if (custom !== undefined) return <>{custom}</>;
639
- if (field.widget === 'directory-user' || field.widget === 'directory-department') {
640
- return (
641
- <ResolvedValueText
642
- source={field.widget === 'directory-user' ? 'user' : 'department'}
643
- value={value as never}
644
- />
645
- );
646
- }
647
- if (field.type.startsWith('resource-ref.')) {
648
- return (
649
- <ResolvedValueText
650
- source="resource"
651
- value={value as ResourceReferenceValue | ResourceReferenceValue[]}
652
- />
653
- );
654
- }
655
- if (field.type === 'file' || field.type === 'image') {
656
- const values = (Array.isArray(value) ? value : [value]).filter(isFileRef);
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
- );
686
- }
687
- if (field.type === 'boolean') {
688
- return <Tag color={value ? 'green' : 'default'}>{value ? '是' : '否'}</Tag>;
689
- }
690
- if (Array.isArray(value)) {
691
- return <>{value.map(item => optionLabel(field, item)).join('、') || '-'}</>;
692
- }
693
- if (['date', 'time', 'datetime', 'date-range', 'datetime-range'].includes(field.type)) {
694
- return <DateTimeValueDisplay field={field} value={value} />;
695
- }
696
- return <>{optionLabel(field, value)}</>;
697
- }
698
-
699
- function optionLabel(field: SurfaceField, value: unknown) {
700
- if (value && typeof value === 'object' && 'label' in value) {
701
- return String((value as LabeledValue).label);
702
- }
703
- return field.options?.find(option => option.value === value)?.label || String(value);
704
- }
705
-
706
- function isFileRef(value: unknown): value is DataFileRef {
707
- return Boolean(value && typeof value === 'object' && 'id' in value);
708
- }
709
-
710
- type MobilePendingUpload = {
711
- id: string;
712
- file: File;
713
- status: 'uploading' | 'error';
714
- error?: string;
715
- };
716
-
717
- let mobileUploadSequence = 0;
718
-
719
- function createMobileUploadId() {
720
- mobileUploadSequence += 1;
721
- return `mobile-upload-${Date.now()}-${mobileUploadSequence}`;
722
- }
723
-
724
- function ManagedFileField({
725
- field,
726
- value,
727
- onChange,
728
- recordId,
729
- disabled,
730
- multiple,
731
- maxCount = 1,
732
- maxSizeMb = 50,
733
- accept,
734
- onUpload,
735
- resourceCode,
736
- mobile = false,
737
- }: {
738
- field: SurfaceField;
739
- value?: DataFileRef | DataFileRef[];
740
- onChange?: (value: DataFileRef | DataFileRef[] | undefined) => void;
741
- recordId?: string;
742
- disabled?: boolean;
743
- multiple?: boolean;
744
- maxCount?: number;
745
- maxSizeMb?: number;
746
- accept?: string | string[];
747
- onUpload?: SurfaceFieldRenderers['upload'];
748
- resourceCode?: string;
749
- mobile?: boolean;
750
- }) {
751
- const [uploading, setUploading] = useState(false);
752
- const [uploadError, setUploadError] = useState('');
753
- const [mobilePending, setMobilePending] = useState<MobilePendingUpload[]>([]);
754
- const mobileInputRef = useRef<HTMLInputElement | null>(null);
755
- const mobileActiveUploads = useRef(0);
756
- const refs = useMemo(() => (Array.isArray(value) ? value : value ? [value] : []), [value]);
757
- const refsRef = useRef(refs);
758
- useEffect(() => {
759
- refsRef.current = refs;
760
- }, [refs]);
761
- const assertSize = (file: File) => {
762
- if (file.size > maxSizeMb * 1024 * 1024) {
763
- throw new Error(`单个文件不能超过 ${maxSizeMb}MB`);
764
- }
765
- };
766
- if (!onUpload) {
767
- return <Alert type="info" showIcon title="该文件字段尚未配置上传能力" />;
768
- }
769
- if (mobile) {
770
- const acceptValue = Array.isArray(accept) ? accept.join(',') : accept;
771
- const uploadOne = async (item: MobilePendingUpload) => {
772
- setMobilePending(current => current.map(entry => entry.id === item.id ? { ...entry, status: 'uploading', error: undefined } : entry));
773
- mobileActiveUploads.current += 1;
774
- setUploading(true);
775
- try {
776
- assertSize(item.file);
777
- const uploaded = await onUpload(field, item.file, recordId);
778
- const next = multiple ? [...refsRef.current, uploaded].slice(0, maxCount) : uploaded;
779
- refsRef.current = Array.isArray(next) ? next : next ? [next] : [];
780
- onChange?.(next);
781
- setMobilePending(current => current.filter(entry => entry.id !== item.id));
782
- } catch (error) {
783
- setMobilePending(current => current.map(entry => entry.id === item.id ? { ...entry, status: 'error', error: error instanceof Error ? error.message : String(error) } : entry));
784
- } finally {
785
- mobileActiveUploads.current = Math.max(0, mobileActiveUploads.current - 1);
786
- setUploading(mobileActiveUploads.current > 0);
787
- }
788
- };
789
- const selectFiles = (files: File[]) => {
790
- const pendingCount = mobilePending.length;
791
- const available = Math.max(0, maxCount - refsRef.current.length - pendingCount);
792
- const selected = multiple ? files.slice(0, available) : files.slice(0, 1);
793
- if (!selected.length) return;
794
- const items = selected.map(file => ({ id: createMobileUploadId(), file, status: 'uploading' as const }));
795
- setMobilePending(current => multiple ? [...current, ...items] : items);
796
- items.forEach(item => void uploadOne(item));
797
- };
798
- return (
799
- <div className="oxa-file-field oxa-mobile-file-field">
800
- <input
801
- ref={mobileInputRef}
802
- accept={acceptValue}
803
- disabled={disabled || uploading || mobilePending.some(item => item.status === 'uploading') || refs.length + mobilePending.length >= maxCount}
804
- multiple={multiple}
805
- onChange={event => {
806
- selectFiles(Array.from(event.target.files || []));
807
- event.currentTarget.value = '';
808
- }}
809
- style={{ display: 'none' }}
810
- type="file"
811
- />
812
- {(!maxCount || refs.length + mobilePending.length < maxCount) && (
813
- <Button
814
- className="oxa-mobile-file-button"
815
- disabled={disabled || uploading || mobilePending.some(item => item.status === 'uploading')}
816
- icon={<UploadOutlined />}
817
- onClick={() => mobileInputRef.current?.click()}
818
- >
819
- 上传文件
820
- </Button>
821
- )}
822
- {mobilePending.length > 0 && (
823
- <div className="oxa-mobile-pending-files">
824
- {mobilePending.map(item => (
825
- <div className="oxa-mobile-pending-file" key={item.id}>
826
- <span className="oxa-mobile-pending-file-copy">
827
- <strong className="oxa-mobile-pending-file-name" title={item.file.name}>{item.file.name}</strong>
828
- <small>{formatManagedFileSize(item.file.size)} · {item.status === 'error' ? '上传失败' : '上传中…'}</small>
829
- </span>
830
- {item.status === 'error' ? (
831
- <>
832
- <span className="oxa-mobile-pending-error">{item.error || '上传失败'}</span>
833
- <Button onClick={() => void uploadOne(item)} size="small" type="link">重试</Button>
834
- <Button onClick={() => setMobilePending(current => current.filter(entry => entry.id !== item.id))} size="small" type="link">移除</Button>
835
- </>
836
- ) : (
837
- <span className="oxa-mobile-pending-status">上传中…</span>
838
- )}
839
- </div>
840
- ))}
841
- </div>
842
- )}
843
- {refs.length ? (
844
- <AttachmentFileList files={refs} resourceCode={resourceCode} onRemove={file => {
845
- const next = refs.filter(item => item.id !== file.id);
846
- refsRef.current = next;
847
- onChange?.(multiple ? next : undefined);
848
- }} removable={!disabled} />
849
- ) : (
850
- <div className="oxa-file-empty">尚未上传文件</div>
851
- )}
852
- </div>
853
- );
854
- }
855
- const uploadProps = {
856
- accept: Array.isArray(accept) ? accept.join(',') : accept,
857
- disabled,
858
- maxCount,
859
- multiple,
860
- customRequest: async (options: {
861
- file: string | Blob;
862
- onError?: (error: Error) => void;
863
- onProgress?: (event: { percent: number }) => void;
864
- onSuccess?: (body: unknown) => void;
865
- }) => {
866
- setUploading(true);
867
- setUploadError('');
868
- try {
869
- assertSize(options.file as File);
870
- options.onProgress?.({ percent: 15 });
871
- const uploaded = await onUpload(field, options.file as File, recordId);
872
- options.onProgress?.({ percent: 100 });
873
- const next = multiple ? [...refsRef.current, uploaded] : uploaded;
874
- refsRef.current = Array.isArray(next) ? next : [next];
875
- onChange?.(next);
876
- options.onSuccess?.(uploaded);
877
- } catch (error) {
878
- const failure = error instanceof Error ? error : new Error(String(error));
879
- setUploadError(failure.message);
880
- options.onError?.(failure);
881
- } finally {
882
- setUploading(false);
883
- }
884
- },
885
- showUploadList: false,
886
- };
887
- return (
888
- <div className="oxa-file-field">
889
- <Upload {...uploadProps} pastable>
890
- <Button disabled={disabled || uploading || refs.length >= maxCount} icon={field.accept?.toString().startsWith('image/') ? <PictureOutlined /> : <UploadOutlined />} loading={uploading}>
891
- {field.accept?.toString().startsWith('image/') ? '上传图片' : '上传文件'}
892
- </Button>
893
- </Upload>
894
- <Upload.Dragger {...uploadProps} className="oxa-file-dropzone" openFileDialogOnClick={false} pastable>
895
- <span>可将文件拖到这里,或在此区域按 <kbd>⌘ V</kbd> 粘贴</span>
896
- <small>{multiple ? `最多 ${maxCount} 个文件,单个不超过 ${maxSizeMb}MB` : `仅保留一个文件,单个不超过 ${maxSizeMb}MB`}</small>
897
- </Upload.Dragger>
898
- {uploadError && (
899
- <div className="oxa-file-error">
900
- <span>{uploadError}</span>
901
- <Button icon={<ReloadOutlined />} onClick={() => setUploadError('')} size="small" type="text">关闭</Button>
902
- </div>
903
- )}
904
- {refs.length ? (
905
- <AttachmentFileList files={refs} resourceCode={resourceCode} onRemove={file => {
906
- const next = refs.filter(item => item.id !== file.id);
907
- refsRef.current = next;
908
- onChange?.(multiple ? next : undefined);
909
- }} removable={!disabled} />
910
- ) : (
911
- <div className="oxa-file-empty">尚未上传文件</div>
912
- )}
913
- </div>
914
- );
915
- }