openxiangda-cli 2.0.0-alpha.85 → 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 +341 -136
  46. package/template/apps/web/src/runtime.tsx +156 -17
  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
@@ -4,10 +4,15 @@ import {
4
4
  UserOutlined,
5
5
  } from '@ant-design/icons';
6
6
  import { Avatar, Select, Space, Spin, Tag, Typography } from 'antd';
7
+ import type {
8
+ DepartmentReferenceValue,
9
+ DirectoryEntry,
10
+ ResourceReferenceValue,
11
+ UserReferenceValue,
12
+ } from 'openxiangda-contracts/browser';
7
13
  import { useEffect, useMemo, useRef, useState } from 'react';
8
14
  import {
9
15
  resolveDirectory,
10
- resolveResource,
11
16
  searchDirectory,
12
17
  searchResource,
13
18
  type DirectoryKind,
@@ -21,48 +26,105 @@ type Option = {
21
26
  description?: string;
22
27
  path?: string[];
23
28
  source: Source;
29
+ reference?: StoredValue;
24
30
  };
25
31
 
32
+ type StoredValue =
33
+ | UserReferenceValue
34
+ | DepartmentReferenceValue
35
+ | ResourceReferenceValue;
36
+
26
37
  function mergeOptions(current: Option[], incoming: Option[]) {
27
38
  const merged = new Map(current.map(option => [option.value, option]));
28
39
  for (const option of incoming) merged.set(option.value, option);
29
40
  return [...merged.values()];
30
41
  }
31
42
 
32
- function directoryOption(
33
- source: DirectoryKind,
34
- item: {
35
- id: string;
36
- label: string;
37
- selectable: boolean;
38
- description?: string;
39
- path?: Array<{ label: string }>;
40
- }
41
- ): Option {
43
+ function directoryOption(source: DirectoryKind, item: DirectoryEntry): Option {
42
44
  return {
43
45
  source,
44
46
  value: item.id,
45
47
  label: item.label,
46
- selectable: item.selectable,
48
+ selectable: item.selectable !== false,
47
49
  ...(item.description ? { description: item.description } : {}),
48
50
  ...(item.path?.length
49
51
  ? { path: item.path.map(entry => entry.label) }
50
52
  : {}),
53
+ reference: item.snapshot,
54
+ };
55
+ }
56
+
57
+ function resourceOption(item: ResourceReferenceValue): Option {
58
+ return {
59
+ source: 'resource',
60
+ value: item.value,
61
+ label: item.label,
62
+ selectable: true,
63
+ ...(item.description ? { description: item.description } : {}),
64
+ reference: item,
51
65
  };
52
66
  }
53
67
 
68
+ function storedValues(value: StoredValue | StoredValue[] | undefined) {
69
+ return (Array.isArray(value) ? value : value ? [value] : []).map(item => item.value);
70
+ }
71
+
72
+ function storedReferenceOptions(
73
+ source: Source,
74
+ value: StoredValue | StoredValue[] | undefined
75
+ ) {
76
+ return (Array.isArray(value) ? value : value ? [value] : []).map(item =>
77
+ source === 'resource'
78
+ ? resourceOption(item as ResourceReferenceValue)
79
+ : {
80
+ source,
81
+ value: item.value,
82
+ label: item.label,
83
+ selectable: true,
84
+ ...(source === 'user' && (item as UserReferenceValue).employeeNo
85
+ ? { description: (item as UserReferenceValue).employeeNo }
86
+ : {}),
87
+ ...(source === 'department' && (item as DepartmentReferenceValue).fullPath
88
+ ? { description: (item as DepartmentReferenceValue).fullPath }
89
+ : {}),
90
+ reference: item,
91
+ }
92
+ );
93
+ }
94
+
95
+ function selectedValue(
96
+ source: Source,
97
+ ids: string | string[] | undefined,
98
+ options: Option[],
99
+ multiple: boolean
100
+ ) {
101
+ const values = (Array.isArray(ids) ? ids : ids ? [ids] : [])
102
+ .map(id => options.find(option => option.value === id)?.reference)
103
+ .filter((item): item is StoredValue => Boolean(item));
104
+ return multiple ? values : values[0];
105
+ }
106
+
54
107
  async function searchSource(
55
108
  source: Source,
56
109
  operation: 'create' | 'update',
57
110
  keyword: string,
58
111
  cursor?: string,
59
112
  resourceCode?: string,
60
- labelField?: string,
113
+ fieldCode?: string,
114
+ bindings?: Record<string, unknown>,
61
115
  ) {
62
116
  if (source === 'resource') {
63
- if (!resourceCode || !labelField) throw new Error('OPENXIANGDA_RESOURCE_REFERENCE_CONFIG_INVALID');
64
- const page = await searchResource(resourceCode, labelField, { keyword, cursor });
65
- return { items: page.items.map(item => ({ ...item, value: item.id, source }) as Option), nextCursor: page.nextCursor };
117
+ if (!resourceCode || !fieldCode) throw new Error('OPENXIANGDA_RESOURCE_REFERENCE_CONFIG_INVALID');
118
+ const page = await searchResource(resourceCode, fieldCode, {
119
+ operation,
120
+ keyword,
121
+ cursor,
122
+ bindings,
123
+ });
124
+ return {
125
+ items: page.items.map(resourceOption),
126
+ nextCursor: page.nextCursor,
127
+ };
66
128
  }
67
129
  const page = await searchDirectory(source, { keyword, cursor });
68
130
  return {
@@ -71,22 +133,6 @@ async function searchSource(
71
133
  };
72
134
  }
73
135
 
74
- async function resolveSource(
75
- source: Source,
76
- operation: 'create' | 'update',
77
- values: string[],
78
- resourceCode?: string,
79
- labelField?: string,
80
- ) {
81
- if (source === 'resource') {
82
- if (!resourceCode || !labelField) throw new Error('OPENXIANGDA_RESOURCE_REFERENCE_CONFIG_INVALID');
83
- return (await resolveResource(resourceCode, values, labelField)).items.map(item => ({ ...item, value: item.id, source }) as Option);
84
- }
85
- return (await resolveDirectory(source, values)).items.map(item =>
86
- directoryOption(source, item)
87
- );
88
- }
89
-
90
136
  function optionIcon(option: Pick<Option, 'source' | 'label'>) {
91
137
  if (option.source === 'user') {
92
138
  return (
@@ -108,16 +154,18 @@ function optionDescription(option: Option) {
108
154
  }
109
155
 
110
156
  export type AuthoritativeSelectorProps = {
111
- value?: string | string[];
112
- onChange?: (value: string | string[] | undefined) => void;
157
+ value?: StoredValue | StoredValue[];
158
+ onChange?: (value: StoredValue | StoredValue[] | undefined) => void;
113
159
  source: Source;
114
160
  operation: 'create' | 'update';
115
161
  multiple?: boolean;
116
162
  disabled?: boolean;
117
163
  id?: string;
118
164
  placeholder: string;
165
+ /** Host resource and field; the platform resolves the target source declaration. */
119
166
  resourceCode?: string;
120
- labelField?: string;
167
+ fieldCode?: string;
168
+ bindings?: Record<string, unknown>;
121
169
  };
122
170
 
123
171
  export function AuthoritativeSelector({
@@ -130,42 +178,23 @@ export function AuthoritativeSelector({
130
178
  id,
131
179
  placeholder,
132
180
  resourceCode,
133
- labelField,
181
+ fieldCode,
182
+ bindings,
134
183
  }: AuthoritativeSelectorProps) {
135
- const [options, setOptions] = useState<Option[]>([]);
184
+ const [options, setOptions] = useState<Option[]>(() =>
185
+ storedReferenceOptions(source, value)
186
+ );
136
187
  const [keyword, setKeyword] = useState('');
137
188
  const [cursor, setCursor] = useState<string | null>(null);
138
189
  const [loading, setLoading] = useState(false);
139
190
  const [error, setError] = useState('');
140
191
  const [open, setOpen] = useState(false);
141
192
  const requestSequence = useRef(0);
142
- const values = useMemo(
143
- () => (Array.isArray(value) ? value : value ? [value] : []),
144
- [value]
145
- );
193
+ const values = useMemo(() => storedValues(value), [value]);
146
194
 
147
195
  useEffect(() => {
148
- const missing = values.filter(
149
- selected => !options.some(option => option.value === selected)
150
- );
151
- if (missing.length === 0) return;
152
- let active = true;
153
- setLoading(true);
154
- void resolveSource(source, operation, missing, resourceCode, labelField)
155
- .then(items => {
156
- if (active) setOptions(current => mergeOptions(current, items));
157
- })
158
- .catch(reason => {
159
- if (active)
160
- setError(reason instanceof Error ? reason.message : String(reason));
161
- })
162
- .finally(() => {
163
- if (active) setLoading(false);
164
- });
165
- return () => {
166
- active = false;
167
- };
168
- }, [labelField, operation, options, resourceCode, source, values]);
196
+ setOptions(current => mergeOptions(current, storedReferenceOptions(source, value)));
197
+ }, [source, value]);
169
198
 
170
199
  useEffect(() => {
171
200
  if (disabled) return;
@@ -177,7 +206,7 @@ export function AuthoritativeSelector({
177
206
  const timer = window.setTimeout(() => {
178
207
  setLoading(true);
179
208
  setError('');
180
- void searchSource(source, operation, keyword, undefined, resourceCode, labelField)
209
+ void searchSource(source, operation, keyword, undefined, resourceCode, fieldCode, bindings)
181
210
  .then(page => {
182
211
  if (sequence !== requestSequence.current) return;
183
212
  setOptions(current => {
@@ -199,14 +228,14 @@ export function AuthoritativeSelector({
199
228
  });
200
229
  }, 300);
201
230
  return () => window.clearTimeout(timer);
202
- }, [disabled, keyword, labelField, operation, resourceCode, source, values]);
231
+ }, [bindings, disabled, fieldCode, keyword, operation, resourceCode, source, values]);
203
232
 
204
233
  useEffect(() => {
205
234
  if (disabled || !open || keyword.trim().length >= 2) return;
206
235
  const sequence = ++requestSequence.current;
207
236
  setLoading(true);
208
237
  setError('');
209
- void searchSource(source, operation, '', undefined, resourceCode, labelField)
238
+ void searchSource(source, operation, '', undefined, resourceCode, fieldCode, bindings)
210
239
  .then(page => {
211
240
  if (sequence !== requestSequence.current) return;
212
241
  setOptions(current => {
@@ -223,14 +252,14 @@ export function AuthoritativeSelector({
223
252
  .finally(() => {
224
253
  if (sequence === requestSequence.current) setLoading(false);
225
254
  });
226
- }, [disabled, keyword, labelField, open, operation, resourceCode, source, values]);
255
+ }, [bindings, disabled, fieldCode, keyword, open, operation, resourceCode, source, values]);
227
256
 
228
257
  const loadNext = () => {
229
258
  if (!cursor || loading) return;
230
259
  const nextCursor = cursor;
231
260
  setLoading(true);
232
261
  setError('');
233
- void searchSource(source, operation, keyword, nextCursor, resourceCode, labelField)
262
+ void searchSource(source, operation, keyword, nextCursor, resourceCode, fieldCode, bindings)
234
263
  .then(page => {
235
264
  setOptions(current => mergeOptions(current, page.items));
236
265
  setCursor(page.nextCursor);
@@ -268,7 +297,14 @@ export function AuthoritativeSelector({
268
297
  )
269
298
  }
270
299
  onChange={next => {
271
- onChange?.(next || undefined);
300
+ onChange?.(
301
+ selectedValue(
302
+ source,
303
+ (next || undefined) as string | string[] | undefined,
304
+ options,
305
+ multiple
306
+ )
307
+ );
272
308
  if (!multiple) setOpen(false);
273
309
  }}
274
310
  onOpenChange={next => {
@@ -343,7 +379,7 @@ export function AuthoritativeSelector({
343
379
  </Tag>
344
380
  );
345
381
  }}
346
- value={value}
382
+ value={multiple ? values : values[0]}
347
383
  />
348
384
  );
349
385
  }
@@ -364,9 +400,12 @@ export function MobileAuthoritativeSelector({
364
400
  id,
365
401
  placeholder,
366
402
  resourceCode,
367
- labelField,
403
+ fieldCode,
404
+ bindings,
368
405
  }: AuthoritativeSelectorProps) {
369
- const [options, setOptions] = useState<Option[]>([]);
406
+ const [options, setOptions] = useState<Option[]>(() =>
407
+ storedReferenceOptions(source, value)
408
+ );
370
409
  const [keyword, setKeyword] = useState('');
371
410
  const [cursor, setCursor] = useState<string | null>(null);
372
411
  const [loading, setLoading] = useState(false);
@@ -374,36 +413,15 @@ export function MobileAuthoritativeSelector({
374
413
  const [open, setOpen] = useState(false);
375
414
  const [draftValues, setDraftValues] = useState<string[]>([]);
376
415
  const requestSequence = useRef(0);
377
- const values = useMemo(
378
- () => (Array.isArray(value) ? value : value ? [value] : []),
379
- [value]
380
- );
416
+ const values = useMemo(() => storedValues(value), [value]);
381
417
 
382
418
  useEffect(() => {
383
- setDraftValues(values);
384
- }, [open, values]);
419
+ setOptions(current => mergeOptions(current, storedReferenceOptions(source, value)));
420
+ }, [source, value]);
385
421
 
386
422
  useEffect(() => {
387
- const missing = values.filter(
388
- selected => !options.some(option => option.value === selected)
389
- );
390
- if (missing.length === 0) return;
391
- let active = true;
392
- setLoading(true);
393
- void resolveSource(source, operation, missing, resourceCode, labelField)
394
- .then(items => {
395
- if (active) setOptions(current => mergeOptions(current, items));
396
- })
397
- .catch(reason => {
398
- if (active) setError(reason instanceof Error ? reason.message : String(reason));
399
- })
400
- .finally(() => {
401
- if (active) setLoading(false);
402
- });
403
- return () => {
404
- active = false;
405
- };
406
- }, [labelField, operation, options, resourceCode, source, values]);
423
+ setDraftValues(values);
424
+ }, [open, values]);
407
425
 
408
426
  useEffect(() => {
409
427
  if (disabled || !open) return;
@@ -413,7 +431,7 @@ export function MobileAuthoritativeSelector({
413
431
  const timer = window.setTimeout(() => {
414
432
  setLoading(true);
415
433
  setError('');
416
- void searchSource(source, operation, trimmed, undefined, resourceCode, labelField)
434
+ void searchSource(source, operation, trimmed, undefined, resourceCode, fieldCode, bindings)
417
435
  .then(page => {
418
436
  if (sequence !== requestSequence.current) return;
419
437
  setOptions(current => {
@@ -432,14 +450,14 @@ export function MobileAuthoritativeSelector({
432
450
  });
433
451
  }, trimmed ? 300 : 0);
434
452
  return () => window.clearTimeout(timer);
435
- }, [disabled, keyword, labelField, open, operation, resourceCode, source, values]);
453
+ }, [bindings, disabled, fieldCode, keyword, open, operation, resourceCode, source, values]);
436
454
 
437
455
  const loadNext = () => {
438
456
  if (!cursor || loading) return;
439
457
  const nextCursor = cursor;
440
458
  setLoading(true);
441
459
  setError('');
442
- void searchSource(source, operation, keyword.trim(), nextCursor, resourceCode, labelField)
460
+ void searchSource(source, operation, keyword.trim(), nextCursor, resourceCode, fieldCode, bindings)
443
461
  .then(page => {
444
462
  setOptions(current => mergeOptions(current, page.items));
445
463
  setCursor(page.nextCursor);
@@ -461,7 +479,11 @@ export function MobileAuthoritativeSelector({
461
479
  const item = options.find(option => option.value === nextValue);
462
480
  if (!item?.selectable) return;
463
481
  if (!multiple) {
464
- onChange?.(nextValue);
482
+ onChange?.(
483
+ selectedValue(source, nextValue, options, false) as
484
+ | StoredValue
485
+ | undefined
486
+ );
465
487
  setOpen(false);
466
488
  return;
467
489
  }
@@ -552,7 +574,14 @@ export function MobileAuthoritativeSelector({
552
574
  <button
553
575
  className="oxa-mobile-reference-confirm"
554
576
  onClick={() => {
555
- onChange?.(draftValues.length ? draftValues : undefined);
577
+ onChange?.(
578
+ selectedValue(
579
+ source,
580
+ draftValues.length ? draftValues : undefined,
581
+ options,
582
+ true
583
+ ) as StoredValue[] | undefined
584
+ );
556
585
  setOpen(false);
557
586
  }}
558
587
  type="button"
@@ -570,41 +599,16 @@ export function MobileAuthoritativeSelector({
570
599
  export function ResolvedValueText({
571
600
  source,
572
601
  value,
573
- resourceCode,
574
- labelField,
575
602
  }: {
576
603
  source: Source;
577
- value?: string | string[];
578
- resourceCode?: string;
579
- labelField?: string;
604
+ value?: StoredValue | StoredValue[];
580
605
  }) {
581
- const values = useMemo(
582
- () => (Array.isArray(value) ? value : value ? [value] : []),
583
- [value]
606
+ const values = useMemo(() => storedValues(value), [value]);
607
+ const items = useMemo(
608
+ () => storedReferenceOptions(source, value),
609
+ [source, value]
584
610
  );
585
- const [items, setItems] = useState<Option[]>([]);
586
- const [failed, setFailed] = useState(false);
587
- useEffect(() => {
588
- if (values.length === 0) {
589
- setItems([]);
590
- return;
591
- }
592
- let active = true;
593
- setFailed(false);
594
- void resolveSource(source, 'update', values, resourceCode, labelField)
595
- .then(next => {
596
- if (active) setItems(next);
597
- })
598
- .catch(() => {
599
- if (active) setFailed(true);
600
- });
601
- return () => {
602
- active = false;
603
- };
604
- }, [labelField, resourceCode, source, values]);
605
611
  if (values.length === 0) return <>-</>;
606
- if (failed) return <Typography.Text type="danger">目录解析失败</Typography.Text>;
607
- if (!items.length) return <>读取中…</>;
608
612
  const ordered = values.map(value => items.find(item => item.value === value));
609
613
  return (
610
614
  <span className={`oxa-resolved-values oxa-resolved-${source}`}>
@@ -626,3 +630,32 @@ export function ResolvedValueText({
626
630
  </span>
627
631
  );
628
632
  }
633
+
634
+ /** Resolve operational identities such as audit actors; business fields never use this path. */
635
+ export function DirectoryIdentityText({
636
+ source,
637
+ id,
638
+ }: {
639
+ source: DirectoryKind;
640
+ id: string;
641
+ }) {
642
+ const [value, setValue] = useState<UserReferenceValue | DepartmentReferenceValue>();
643
+ const [failed, setFailed] = useState(false);
644
+ useEffect(() => {
645
+ let active = true;
646
+ setFailed(false);
647
+ void resolveDirectory(source, [id])
648
+ .then(page => {
649
+ if (active) setValue(page.items[0]?.snapshot);
650
+ })
651
+ .catch(() => {
652
+ if (active) setFailed(true);
653
+ });
654
+ return () => {
655
+ active = false;
656
+ };
657
+ }, [id, source]);
658
+ if (failed) return <Typography.Text type="secondary">{id}</Typography.Text>;
659
+ if (!value) return <>读取中…</>;
660
+ return <ResolvedValueText source={source} value={value} />;
661
+ }
@@ -12,7 +12,7 @@ import type { DataFilePreview } from 'openxiangda-contracts/browser';
12
12
  import { useEffect, useMemo, useRef, useState } from 'react';
13
13
  import { useParams } from 'react-router-dom';
14
14
  import {
15
- dataFileContentUrl,
15
+ downloadDataFile,
16
16
  fetchDataFileBlob,
17
17
  loadDataFilePreview,
18
18
  } from './platform-client';
@@ -220,6 +220,7 @@ export function FilePreviewPage() {
220
220
  const [preview, setPreview] = useState<DataFilePreview>();
221
221
  const [blob, setBlob] = useState<Blob>();
222
222
  const [error, setError] = useState('');
223
+ const [downloading, setDownloading] = useState(false);
223
224
  useEffect(() => {
224
225
  let active = true;
225
226
  setPreview(undefined);
@@ -249,10 +250,21 @@ export function FilePreviewPage() {
249
250
  };
250
251
  }, [fileId, resourceCode]);
251
252
 
252
- const downloadUrl =
253
- resourceCode && fileId
254
- ? dataFileContentUrl(resourceCode, fileId, 'attachment')
255
- : '';
253
+ const download = async () => {
254
+ if (!resourceCode || !fileId) return;
255
+ setDownloading(true);
256
+ try {
257
+ await downloadDataFile(
258
+ resourceCode,
259
+ fileId,
260
+ preview?.file.name || 'attachment'
261
+ );
262
+ } catch (reason) {
263
+ setError(reason instanceof Error ? reason.message : String(reason));
264
+ } finally {
265
+ setDownloading(false);
266
+ }
267
+ };
256
268
  return (
257
269
  <div className="oxa-file-preview-page">
258
270
  <header className="oxa-file-preview-toolbar">
@@ -268,8 +280,12 @@ export function FilePreviewPage() {
268
280
  </div>
269
281
  </div>
270
282
  <div>
271
- {downloadUrl && (
272
- <Button href={downloadUrl} icon={<DownloadOutlined />}>
283
+ {resourceCode && fileId && (
284
+ <Button
285
+ icon={<DownloadOutlined />}
286
+ loading={downloading}
287
+ onClick={() => void download()}
288
+ >
273
289
  下载文件
274
290
  </Button>
275
291
  )}
@@ -285,8 +301,13 @@ export function FilePreviewPage() {
285
301
  title="文件预览加载失败"
286
302
  subTitle={error}
287
303
  extra={
288
- downloadUrl ? (
289
- <Button href={downloadUrl} icon={<DownloadOutlined />} type="primary">
304
+ resourceCode && fileId ? (
305
+ <Button
306
+ icon={<DownloadOutlined />}
307
+ loading={downloading}
308
+ onClick={() => void download()}
309
+ type="primary"
310
+ >
290
311
  下载文件
291
312
  </Button>
292
313
  ) : undefined
@@ -0,0 +1,128 @@
1
+ import { Select, Typography } from 'antd';
2
+ import { useEffect, useState } from 'react';
3
+ import type {
4
+ RoleSubjectChoice,
5
+ RoleSubjectChoicePage,
6
+ } from 'openxiangda-contracts/browser';
7
+ import { loadRoleSubjects } from './platform-client';
8
+
9
+ function mergeSubjects(
10
+ current: RoleSubjectChoice[],
11
+ incoming: RoleSubjectChoice[]
12
+ ) {
13
+ const merged = new Map(current.map(item => [item.subjectKey, item]));
14
+ for (const item of incoming) merged.set(item.subjectKey, item);
15
+ return [...merged.values()];
16
+ }
17
+
18
+ export function RoleSubjectSelect({
19
+ disabled,
20
+ initialPage,
21
+ onChange,
22
+ value,
23
+ }: {
24
+ disabled?: boolean;
25
+ initialPage: RoleSubjectChoicePage;
26
+ onChange: (value: string) => void;
27
+ value?: string;
28
+ }) {
29
+ const [keyword, setKeyword] = useState('');
30
+ const [options, setOptions] = useState(initialPage.items);
31
+ const [cursor, setCursor] = useState(initialPage.nextCursor);
32
+ const [loading, setLoading] = useState(false);
33
+ const [loadError, setLoadError] = useState('');
34
+
35
+ useEffect(() => {
36
+ let active = true;
37
+ const timer = window.setTimeout(() => {
38
+ setLoading(true);
39
+ setLoadError('');
40
+ void loadRoleSubjects({ keyword, limit: 20 })
41
+ .then(page => {
42
+ if (!active) return;
43
+ setOptions(current =>
44
+ mergeSubjects(
45
+ current.filter(item => item.subjectKey === value),
46
+ page.items
47
+ )
48
+ );
49
+ setCursor(page.nextCursor);
50
+ })
51
+ .catch(reason => {
52
+ if (!active) return;
53
+ setLoadError(
54
+ reason instanceof Error ? reason.message : '应用角色读取失败'
55
+ );
56
+ })
57
+ .finally(() => {
58
+ if (active) setLoading(false);
59
+ });
60
+ }, keyword ? 250 : 0);
61
+ return () => {
62
+ active = false;
63
+ window.clearTimeout(timer);
64
+ };
65
+ }, [keyword, value]);
66
+
67
+ const loadMore = async () => {
68
+ if (!cursor || loading) return;
69
+ setLoading(true);
70
+ setLoadError('');
71
+ try {
72
+ const page = await loadRoleSubjects({ keyword, cursor, limit: 20 });
73
+ setOptions(current => mergeSubjects(current, page.items));
74
+ setCursor(page.nextCursor);
75
+ } catch (reason) {
76
+ setLoadError(
77
+ reason instanceof Error ? reason.message : '更多应用角色读取失败'
78
+ );
79
+ } finally {
80
+ setLoading(false);
81
+ }
82
+ };
83
+
84
+ return (
85
+ <Select
86
+ aria-label="选择应用角色"
87
+ className="oxa-role-switch-select"
88
+ disabled={disabled}
89
+ filterOption={false}
90
+ loading={loading}
91
+ notFoundContent={
92
+ loadError ? (
93
+ <Typography.Text type="danger">应用角色读取失败,请重试</Typography.Text>
94
+ ) : undefined
95
+ }
96
+ onChange={onChange}
97
+ onPopupScroll={event => {
98
+ const target = event.currentTarget;
99
+ if (
100
+ target.scrollTop + target.clientHeight >=
101
+ target.scrollHeight - 12
102
+ ) {
103
+ void loadMore();
104
+ }
105
+ }}
106
+ onSearch={setKeyword}
107
+ optionRender={option => {
108
+ const subject = options.find(item => item.subjectKey === option.value);
109
+ return (
110
+ <div className="oxa-role-switch-option">
111
+ <strong>{subject?.role.name || option.label}</strong>
112
+ {subject?.role.description && (
113
+ <span>{subject.role.description}</span>
114
+ )}
115
+ </div>
116
+ );
117
+ }}
118
+ options={options.map(subject => ({
119
+ label: subject.role.name,
120
+ value: subject.subjectKey,
121
+ }))}
122
+ placeholder="选择当前应用角色"
123
+ showSearch
124
+ status={loadError ? 'error' : undefined}
125
+ value={value}
126
+ />
127
+ );
128
+ }