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
@@ -0,0 +1,553 @@
1
+ import {
2
+ DeleteOutlined,
3
+ DownOutlined,
4
+ EditOutlined,
5
+ PlusOutlined,
6
+ ReloadOutlined,
7
+ UpOutlined,
8
+ } from '@ant-design/icons';
9
+ import {
10
+ Alert,
11
+ Button,
12
+ Drawer,
13
+ Empty,
14
+ Form,
15
+ Modal,
16
+ Space,
17
+ Spin,
18
+ Table,
19
+ Tooltip,
20
+ Typography,
21
+ } from 'antd';
22
+ import type {
23
+ DataFieldSurface,
24
+ DataResourceSurface,
25
+ } from 'openxiangda-contracts/browser';
26
+ import { useEffect, useMemo, useRef, useState } from 'react';
27
+ import { resourceDefinitions } from '../../../../../packages/contracts/src/generated.js';
28
+ import { createNativeResourceClient } from '../../platform-client';
29
+ import { useRuntime } from '../../runtime';
30
+ import {
31
+ MobileSurfaceFieldControl,
32
+ SurfaceFieldControl,
33
+ SurfaceFieldValue,
34
+ type SurfaceField,
35
+ } from '../resource/SurfaceFields';
36
+ import {
37
+ fieldValueForData,
38
+ fieldValueForForm,
39
+ } from './field-form-codec';
40
+ import type { SubtableDraftRow } from './subtable-value';
41
+
42
+ interface GeneratedDefinition {
43
+ code: string;
44
+ name: string;
45
+ capabilities: {
46
+ read: string;
47
+ create: string;
48
+ update: string;
49
+ delete: string;
50
+ };
51
+ surface: DataResourceSurface;
52
+ }
53
+
54
+ const definitions = resourceDefinitions as unknown as Record<
55
+ string,
56
+ GeneratedDefinition
57
+ >;
58
+
59
+ export interface SubtableFieldProps {
60
+ field: SurfaceField;
61
+ disabled?: boolean;
62
+ mobile?: boolean;
63
+ operation: 'create' | 'update';
64
+ parentRecordId?: string;
65
+ value?: SubtableDraftRow[];
66
+ onChange?: (value: SubtableDraftRow[]) => void;
67
+ }
68
+
69
+ export function SubtableField({
70
+ field,
71
+ disabled,
72
+ mobile,
73
+ operation,
74
+ parentRecordId,
75
+ value,
76
+ onChange,
77
+ }: SubtableFieldProps) {
78
+ const config = field.subtable;
79
+ const definition = config ? definitions[config.resourceCode] : undefined;
80
+ const { hasCapability, identity } = useRuntime();
81
+ const [internalRows, setInternalRows] = useState<SubtableDraftRow[]>([]);
82
+ const [loading, setLoading] = useState(false);
83
+ const [loadError, setLoadError] = useState('');
84
+ const [editorOpen, setEditorOpen] = useState(false);
85
+ const [editingKey, setEditingKey] = useState<string>();
86
+ const [rowForm] = Form.useForm();
87
+ const loadedKey = useRef('');
88
+ const rows = value ?? internalRows;
89
+ const maxRows = config?.maxRows ?? 20;
90
+ const visibleRows = rows.filter(row => row.state !== 'deleted');
91
+ const childFields = useMemo(
92
+ () => childSurfaceFields(definition?.surface, config?.foreignKey, config?.orderField),
93
+ [config?.foreignKey, config?.orderField, definition?.surface]
94
+ );
95
+ const readableFields = childFields.filter(child =>
96
+ fieldReadable(child, hasCapability, identity.isAppSuperAdmin)
97
+ );
98
+ const editableFields = childFields.filter(child =>
99
+ fieldReadable(child, hasCapability, identity.isAppSuperAdmin) ||
100
+ fieldWritable(
101
+ child,
102
+ editingKey ? 'update' : 'create',
103
+ hasCapability,
104
+ identity.isAppSuperAdmin
105
+ )
106
+ );
107
+ const editingRow = rows.find(row => row.key === editingKey);
108
+ const editorOperation = editingRow?.state === 'persisted' ? 'update' : 'create';
109
+ const canCreate = Boolean(
110
+ definition &&
111
+ !disabled &&
112
+ (identity.isAppSuperAdmin || hasCapability(definition.capabilities.create))
113
+ );
114
+ const canUpdate = Boolean(
115
+ definition &&
116
+ !disabled &&
117
+ (identity.isAppSuperAdmin || hasCapability(definition.capabilities.update))
118
+ );
119
+ const canDelete = Boolean(
120
+ definition &&
121
+ !disabled &&
122
+ (identity.isAppSuperAdmin || hasCapability(definition.capabilities.delete))
123
+ );
124
+
125
+ const emit = (next: SubtableDraftRow[]) => {
126
+ setInternalRows(next);
127
+ onChange?.(next);
128
+ };
129
+
130
+ useEffect(() => {
131
+ if (!parentRecordId || !definition || !config) {
132
+ if (operation === 'create' && value === undefined && internalRows.length === 0) {
133
+ onChange?.([]);
134
+ }
135
+ return;
136
+ }
137
+ const key = `${definition.code}:${parentRecordId}`;
138
+ if (value !== undefined || loadedKey.current === key) return;
139
+ loadedKey.current = key;
140
+ let active = true;
141
+ setLoading(true);
142
+ setLoadError('');
143
+ void loadChildRows(definition, config.foreignKey, config.orderField, parentRecordId, maxRows)
144
+ .then(records => {
145
+ if (!active) return;
146
+ const next = records.map(record =>
147
+ draftFromRecord(record, definition.surface, config.foreignKey, config.orderField)
148
+ );
149
+ emit(next);
150
+ })
151
+ .catch(error => {
152
+ if (!active) return;
153
+ setLoadError(error instanceof Error ? error.message : String(error));
154
+ })
155
+ .finally(() => {
156
+ if (active) setLoading(false);
157
+ });
158
+ return () => {
159
+ active = false;
160
+ };
161
+ }, [
162
+ config,
163
+ definition,
164
+ internalRows.length,
165
+ maxRows,
166
+ onChange,
167
+ operation,
168
+ parentRecordId,
169
+ value,
170
+ ]);
171
+
172
+ if (!config || !definition) {
173
+ return (
174
+ <Alert
175
+ message="子表配置不可用"
176
+ showIcon
177
+ type="error"
178
+ />
179
+ );
180
+ }
181
+
182
+ const openEditor = (row?: SubtableDraftRow) => {
183
+ setEditingKey(row?.key);
184
+ rowForm.resetFields();
185
+ if (row) rowForm.setFieldsValue(row.data);
186
+ setEditorOpen(true);
187
+ };
188
+ const saveEditor = async () => {
189
+ const data = await rowForm.validateFields();
190
+ if (editingKey) {
191
+ emit(
192
+ rows.map(row =>
193
+ row.key === editingKey
194
+ ? {
195
+ ...row,
196
+ data,
197
+ snapshot: {
198
+ ...(row.snapshot || {}),
199
+ ...Object.fromEntries(
200
+ Object.entries(data).map(([key, item]) => [
201
+ key,
202
+ fieldValueForData(definition.surface.fields[key], item),
203
+ ])
204
+ ),
205
+ },
206
+ }
207
+ : row
208
+ )
209
+ );
210
+ } else {
211
+ emit([
212
+ ...rows,
213
+ {
214
+ key: crypto.randomUUID(),
215
+ state: 'created',
216
+ data,
217
+ snapshot: Object.fromEntries(
218
+ Object.entries(data).map(([key, item]) => [
219
+ key,
220
+ fieldValueForData(definition.surface.fields[key], item),
221
+ ])
222
+ ),
223
+ },
224
+ ]);
225
+ }
226
+ setEditorOpen(false);
227
+ };
228
+ const removeRow = (target: SubtableDraftRow) => {
229
+ emit(
230
+ target.state === 'created'
231
+ ? rows.filter(row => row.key !== target.key)
232
+ : rows.map(row =>
233
+ row.key === target.key ? { ...row, state: 'deleted' } : row
234
+ )
235
+ );
236
+ };
237
+ const moveRow = (index: number, direction: -1 | 1) => {
238
+ const target = index + direction;
239
+ if (target < 0 || target >= visibleRows.length) return;
240
+ const reordered = [...visibleRows];
241
+ [reordered[index], reordered[target]] = [reordered[target]!, reordered[index]!];
242
+ emit([...reordered, ...rows.filter(row => row.state === 'deleted')]);
243
+ };
244
+ const columns = [
245
+ ...readableFields.map(child => ({
246
+ title: child.label,
247
+ key: child.key,
248
+ render: (_: unknown, row: SubtableDraftRow) => (
249
+ <SurfaceFieldValue
250
+ field={child}
251
+ resourceCode={definition.code}
252
+ value={row.snapshot?.[child.key] ?? fieldValueForData(
253
+ definition.surface.fields[child.key],
254
+ row.data[child.key]
255
+ )}
256
+ />
257
+ ),
258
+ })),
259
+ ...(disabled
260
+ ? []
261
+ : [{
262
+ title: '操作',
263
+ key: 'actions',
264
+ width: 168,
265
+ render: (_: unknown, row: SubtableDraftRow, index: number) => (
266
+ <Space size={4}>
267
+ <Tooltip title="上移">
268
+ <Button
269
+ aria-label="上移"
270
+ disabled={index === 0 || !canUpdate}
271
+ icon={<UpOutlined />}
272
+ onClick={() => moveRow(index, -1)}
273
+ size="small"
274
+ type="text"
275
+ />
276
+ </Tooltip>
277
+ <Tooltip title="下移">
278
+ <Button
279
+ aria-label="下移"
280
+ disabled={index === visibleRows.length - 1 || !canUpdate}
281
+ icon={<DownOutlined />}
282
+ onClick={() => moveRow(index, 1)}
283
+ size="small"
284
+ type="text"
285
+ />
286
+ </Tooltip>
287
+ <Tooltip title="编辑">
288
+ <Button
289
+ aria-label="编辑"
290
+ disabled={row.state === 'persisted' ? !canUpdate : !canCreate}
291
+ icon={<EditOutlined />}
292
+ onClick={() => openEditor(row)}
293
+ size="small"
294
+ type="text"
295
+ />
296
+ </Tooltip>
297
+ <Tooltip title="删除">
298
+ <Button
299
+ aria-label="删除"
300
+ danger
301
+ disabled={row.state === 'persisted' ? !canDelete : !canCreate}
302
+ icon={<DeleteOutlined />}
303
+ onClick={() => removeRow(row)}
304
+ size="small"
305
+ type="text"
306
+ />
307
+ </Tooltip>
308
+ </Space>
309
+ ),
310
+ }]),
311
+ ];
312
+ const editor = (
313
+ <Form component={false} form={rowForm} layout="vertical">
314
+ <div className={mobile ? 'oxa-mobile-subtable-editor' : 'oxa-grid'}>
315
+ {editableFields.map(child => {
316
+ const Control = mobile
317
+ ? MobileSurfaceFieldControl
318
+ : SurfaceFieldControl;
319
+ const childOperation = editorOperation;
320
+ return (
321
+ <Control
322
+ disabled={!fieldWritable(
323
+ child,
324
+ childOperation,
325
+ hasCapability,
326
+ identity.isAppSuperAdmin
327
+ )}
328
+ field={child}
329
+ key={child.key}
330
+ operation={childOperation}
331
+ recordId={rows.find(row => row.key === editingKey)?.id}
332
+ resourceCode={definition.code}
333
+ renderers={{
334
+ upload: (current, file, recordId) =>
335
+ createNativeResourceClient(definition.code, definition.surface)
336
+ .upload(current.key, file, recordId),
337
+ }}
338
+ />
339
+ );
340
+ })}
341
+ </div>
342
+ </Form>
343
+ );
344
+
345
+ return (
346
+ <div className="oxa-subtable-field">
347
+ <div className="oxa-subtable-toolbar">
348
+ <Typography.Text type="secondary">
349
+ {visibleRows.length} / {maxRows}
350
+ </Typography.Text>
351
+ {!disabled && (
352
+ <Button
353
+ disabled={!canCreate || visibleRows.length >= maxRows}
354
+ icon={<PlusOutlined />}
355
+ onClick={() => openEditor()}
356
+ size="small"
357
+ >
358
+ 添加
359
+ </Button>
360
+ )}
361
+ </div>
362
+ {loadError ? (
363
+ <Alert
364
+ action={(
365
+ <Button
366
+ icon={<ReloadOutlined />}
367
+ onClick={() => {
368
+ loadedKey.current = '';
369
+ setLoadError('');
370
+ setInternalRows([]);
371
+ }}
372
+ size="small"
373
+ >
374
+ 重试
375
+ </Button>
376
+ )}
377
+ message={loadError}
378
+ showIcon
379
+ type="error"
380
+ />
381
+ ) : loading ? (
382
+ <div className="oxa-subtable-loading"><Spin size="small" /></div>
383
+ ) : mobile ? (
384
+ visibleRows.length ? (
385
+ <div className="oxa-mobile-subtable-list">
386
+ {visibleRows.map((row, index) => (
387
+ <div className="oxa-mobile-subtable-row" key={row.key}>
388
+ <div className="oxa-mobile-subtable-values">
389
+ {readableFields.map(child => (
390
+ <div key={child.key}>
391
+ <Typography.Text type="secondary">{child.label}</Typography.Text>
392
+ <SurfaceFieldValue
393
+ field={child}
394
+ resourceCode={definition.code}
395
+ value={row.snapshot?.[child.key] ?? row.data[child.key]}
396
+ />
397
+ </div>
398
+ ))}
399
+ </div>
400
+ {!disabled && (columns[columns.length - 1] as any)?.render?.(null, row, index)}
401
+ </div>
402
+ ))}
403
+ </div>
404
+ ) : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
405
+ ) : (
406
+ <Table
407
+ columns={columns}
408
+ dataSource={visibleRows}
409
+ locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
410
+ pagination={false}
411
+ rowKey="key"
412
+ scroll={{ x: true }}
413
+ size="small"
414
+ />
415
+ )}
416
+ {mobile ? (
417
+ <Drawer
418
+ extra={<Button onClick={() => void saveEditor()} type="primary">保存</Button>}
419
+ onClose={() => setEditorOpen(false)}
420
+ open={editorOpen}
421
+ placement="bottom"
422
+ title={editingKey ? '编辑明细' : '添加明细'}
423
+ >
424
+ {editor}
425
+ </Drawer>
426
+ ) : (
427
+ <Modal
428
+ onCancel={() => setEditorOpen(false)}
429
+ onOk={() => void saveEditor()}
430
+ open={editorOpen}
431
+ title={editingKey ? '编辑明细' : '添加明细'}
432
+ >
433
+ {editor}
434
+ </Modal>
435
+ )}
436
+ </div>
437
+ );
438
+ }
439
+
440
+ export function SubtableValueDisplay({
441
+ field,
442
+ parentRecordId,
443
+ mobile,
444
+ }: {
445
+ field: SurfaceField;
446
+ parentRecordId: string;
447
+ mobile?: boolean;
448
+ }) {
449
+ return (
450
+ <SubtableField
451
+ disabled
452
+ field={field}
453
+ mobile={mobile}
454
+ operation="update"
455
+ parentRecordId={parentRecordId}
456
+ />
457
+ );
458
+ }
459
+
460
+ function childSurfaceFields(
461
+ surface: DataResourceSurface | undefined,
462
+ foreignKey?: string,
463
+ orderField?: string
464
+ ) {
465
+ return Object.entries(surface?.fields || {})
466
+ .filter(([key, field]) =>
467
+ key !== foreignKey &&
468
+ key !== orderField &&
469
+ !field.system &&
470
+ field.type !== 'subtable'
471
+ )
472
+ .map(([key, field]) => ({ key, ...field }));
473
+ }
474
+
475
+ function fieldReadable(
476
+ field: SurfaceField,
477
+ hasCapability: (code: string) => boolean,
478
+ isSuperAdmin: boolean
479
+ ) {
480
+ return isSuperAdmin || (
481
+ field.readCapabilities.length > 0 &&
482
+ field.readCapabilities.every(hasCapability)
483
+ );
484
+ }
485
+
486
+ function fieldWritable(
487
+ field: SurfaceField,
488
+ operation: 'create' | 'update',
489
+ hasCapability: (code: string) => boolean,
490
+ isSuperAdmin: boolean
491
+ ) {
492
+ if (field.widget === 'readonly' || field.type === 'subtable') return false;
493
+ if (isSuperAdmin) return true;
494
+ const capabilities = operation === 'create'
495
+ ? field.createCapabilities
496
+ : field.updateCapabilities;
497
+ return capabilities.length > 0 && capabilities.every(hasCapability);
498
+ }
499
+
500
+ async function loadChildRows(
501
+ definition: GeneratedDefinition,
502
+ foreignKey: string,
503
+ orderField: string,
504
+ parentRecordId: string,
505
+ maxRows: number
506
+ ) {
507
+ const client = createNativeResourceClient(definition.code, definition.surface);
508
+ const records: Record<string, unknown>[] = [];
509
+ let page = 1;
510
+ let total = 0;
511
+ do {
512
+ const pageSize = Math.min(200, maxRows - records.length);
513
+ const result = await client.list({
514
+ page,
515
+ pageSize,
516
+ filters: { [foreignKey]: parentRecordId },
517
+ sort: { field: orderField, order: 'asc' },
518
+ });
519
+ records.push(...result.rows);
520
+ total = result.total;
521
+ page += 1;
522
+ } while (records.length < total && records.length < maxRows);
523
+ if (total > maxRows) throw new Error('OPENXIANGDA_SUBTABLE_STORED_ROWS_EXCEEDED');
524
+ return records;
525
+ }
526
+
527
+ function draftFromRecord(
528
+ record: Record<string, unknown>,
529
+ surface: DataResourceSurface,
530
+ foreignKey: string,
531
+ orderField: string
532
+ ): SubtableDraftRow {
533
+ const data = Object.fromEntries(
534
+ Object.entries(record)
535
+ .filter(([key]) => key !== foreignKey && key !== orderField && surface.fields[key])
536
+ .map(([key, item]) => [key, fieldValueForForm(surface.fields[key], item)])
537
+ );
538
+ const originalData = Object.fromEntries(
539
+ Object.entries(record).filter(
540
+ ([key]) => key !== foreignKey && key !== orderField && surface.fields[key]
541
+ )
542
+ );
543
+ return {
544
+ key: String(record.id),
545
+ state: 'persisted',
546
+ id: String(record.id),
547
+ revision: Number(record.revision),
548
+ originalOrder: Number(record[orderField]),
549
+ data,
550
+ originalData,
551
+ snapshot: record,
552
+ };
553
+ }
@@ -0,0 +1,80 @@
1
+ import type {
2
+ LabeledValue,
3
+ StableAddressValue,
4
+ } from 'openxiangda-contracts/browser';
5
+
6
+ export const ADDRESS_LEVELS = [
7
+ 'province',
8
+ 'city',
9
+ 'district',
10
+ 'street',
11
+ ] as const;
12
+
13
+ export type AddressLevel = (typeof ADDRESS_LEVELS)[number];
14
+
15
+ const CHINA: LabeledValue = { label: '中国', value: '100000' };
16
+
17
+ function snapshot(value: LabeledValue): LabeledValue {
18
+ return {
19
+ label: String(value.label || '').trim(),
20
+ value: String(value.value || '').trim(),
21
+ };
22
+ }
23
+
24
+ export function addressPath(value?: StableAddressValue) {
25
+ return ADDRESS_LEVELS
26
+ .map(level => value?.[level])
27
+ .filter((item): item is LabeledValue => Boolean(item?.value));
28
+ }
29
+
30
+ export function addressControlValue(value?: StableAddressValue) {
31
+ return addressPath(value).map(item => item.value);
32
+ }
33
+
34
+ export function addressDisplay(value?: StableAddressValue) {
35
+ if (!value) return '';
36
+ if (value.fullAddress?.trim()) return value.fullAddress.trim();
37
+ return composeFullAddress(addressPath(value), value.detail);
38
+ }
39
+
40
+ export function composeFullAddress(path: LabeledValue[], detail?: string) {
41
+ const labels: string[] = [];
42
+ for (const item of path) {
43
+ const label = String(item.label || '').trim();
44
+ if (label && labels[labels.length - 1] !== label) labels.push(label);
45
+ }
46
+ const normalizedDetail = String(detail || '').trim();
47
+ if (normalizedDetail) labels.push(normalizedDetail);
48
+ return labels.join('');
49
+ }
50
+
51
+ export function addressStoredValue(
52
+ path: LabeledValue[],
53
+ detail?: string
54
+ ): StableAddressValue | undefined {
55
+ const normalizedPath = path
56
+ .slice(0, ADDRESS_LEVELS.length)
57
+ .map(snapshot)
58
+ .filter(item => item.label && item.value);
59
+ if (!normalizedPath.length) return undefined;
60
+ const normalizedDetail = String(detail || '').trim();
61
+ const value: StableAddressValue = { country: { ...CHINA } };
62
+ normalizedPath.forEach((item, index) => {
63
+ const level = ADDRESS_LEVELS[index];
64
+ if (level) value[level] = item;
65
+ });
66
+ if (normalizedDetail) value.detail = normalizedDetail;
67
+ value.fullAddress = composeFullAddress(normalizedPath, normalizedDetail);
68
+ return value;
69
+ }
70
+
71
+ export function deepestAddressPredicate(value?: StableAddressValue) {
72
+ for (let index = ADDRESS_LEVELS.length - 1; index >= 0; index -= 1) {
73
+ const level = ADDRESS_LEVELS[index];
74
+ const part = level ? value?.[level] : undefined;
75
+ if (level && part?.value) {
76
+ return { path: `${level}.value`, value: part.value };
77
+ }
78
+ }
79
+ return undefined;
80
+ }
@@ -0,0 +1,66 @@
1
+ import type { DataFieldOption, LabeledValue } from 'openxiangda-contracts/browser';
2
+
3
+ export type CascadeStoredValue = LabeledValue[] | LabeledValue[][];
4
+
5
+ function optionSnapshot(option: DataFieldOption): LabeledValue {
6
+ return {
7
+ value: option.value,
8
+ label: option.label,
9
+ ...(option.description ? { description: option.description } : {}),
10
+ ...(option.color ? { color: option.color } : {}),
11
+ };
12
+ }
13
+
14
+ function resolvePath(
15
+ options: DataFieldOption[],
16
+ ids: string[],
17
+ index = 0
18
+ ): LabeledValue[] | undefined {
19
+ const option = options.find(item => item.value === ids[index]);
20
+ if (!option) return undefined;
21
+ if (index === ids.length - 1) return [optionSnapshot(option)];
22
+ const child = resolvePath(option.children || [], ids, index + 1);
23
+ return child ? [optionSnapshot(option), ...child] : undefined;
24
+ }
25
+
26
+ export function cascadeControlValue(
27
+ value: CascadeStoredValue | undefined,
28
+ multiple: boolean
29
+ ) {
30
+ if (multiple) {
31
+ return (Array.isArray(value) ? value : [])
32
+ .filter((path): path is LabeledValue[] => Array.isArray(path))
33
+ .map(path => path.map(item => item.value));
34
+ }
35
+ return (Array.isArray(value) ? value : [])
36
+ .filter((item): item is LabeledValue => !Array.isArray(item))
37
+ .map(item => item.value);
38
+ }
39
+
40
+ export function cascadeStoredValue(
41
+ options: DataFieldOption[],
42
+ value: unknown,
43
+ multiple: boolean
44
+ ) {
45
+ const paths = multiple
46
+ ? (Array.isArray(value) ? value : []).filter(Array.isArray) as string[][]
47
+ : Array.isArray(value) && value.length ? [value.map(String)] : [];
48
+ const snapshots = paths
49
+ .map(path => resolvePath(options, path.map(String)))
50
+ .filter((path): path is LabeledValue[] => Boolean(path));
51
+ return multiple ? snapshots : snapshots[0];
52
+ }
53
+
54
+ export function cascadeTerminalValues(
55
+ value: CascadeStoredValue | undefined,
56
+ multiple: boolean
57
+ ) {
58
+ const paths = multiple
59
+ ? (Array.isArray(value) ? value : []).filter(Array.isArray) as LabeledValue[][]
60
+ : [Array.isArray(value)
61
+ ? value.filter(item => !Array.isArray(item)) as LabeledValue[]
62
+ : []];
63
+ return paths
64
+ .map(path => path[path.length - 1]?.value)
65
+ .filter((item): item is string => Boolean(item));
66
+ }