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,6 +6,7 @@ import {
6
6
  LogoutOutlined,
7
7
  MenuUnfoldOutlined,
8
8
  SettingOutlined,
9
+ SwapOutlined,
9
10
  } from '@ant-design/icons';
10
11
  import {
11
12
  App,
@@ -16,6 +17,7 @@ import {
16
17
  Empty,
17
18
  Layout,
18
19
  Menu,
20
+ Modal,
19
21
  Segmented,
20
22
  Tabs,
21
23
  Typography,
@@ -27,6 +29,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
27
29
  import { resourceDefinitions } from '../../../packages/contracts/src/generated.js';
28
30
  import { useAppearance, type AppearancePreference } from './appearance';
29
31
  import { logoutCurrentUser, resolveDirectory } from './platform-client';
32
+ import { RoleSubjectSelect } from './RoleSubjectSelect';
30
33
  import { useRuntime } from './runtime';
31
34
 
32
35
  const { Content, Header, Sider } = Layout;
@@ -89,13 +92,22 @@ function storeHistory(items: HistoryItem[]) {
89
92
  }
90
93
 
91
94
  export function Shell({ children }: { children: ReactNode }) {
92
- const { identity, hasCapability } = useRuntime();
95
+ const {
96
+ identity,
97
+ hasCapability,
98
+ switchRole,
99
+ switchingRole,
100
+ } = useRuntime();
93
101
  const { effectiveAppearance, preference, setPreference } = useAppearance();
94
102
  const { message } = App.useApp();
95
103
  const location = useLocation();
96
104
  const navigate = useNavigate();
97
105
  const [collapsed, setCollapsed] = useState(false);
98
106
  const [loggingOut, setLoggingOut] = useState(false);
107
+ const [roleModalOpen, setRoleModalOpen] = useState(false);
108
+ const [selectedRoleSubjectKey, setSelectedRoleSubjectKey] = useState(
109
+ identity.currentRoleSubject?.subjectKey || ''
110
+ );
99
111
  const [history, setHistory] = useState<HistoryItem[]>(readHistory);
100
112
  const [user, setUser] = useState({
101
113
  label: '当前用户',
@@ -117,6 +129,14 @@ export function Shell({ children }: { children: ReactNode }) {
117
129
  : '/';
118
130
 
119
131
  useEffect(() => {
132
+ if (identity.subjectProfile) {
133
+ setUser({
134
+ label: identity.subjectProfile.displayName || '当前用户',
135
+ description:
136
+ identity.subjectProfile.affiliatedDepartment?.name || '平台用户',
137
+ });
138
+ return;
139
+ }
120
140
  let active = true;
121
141
  void resolveDirectory('user', [identity.userId])
122
142
  .then(page => {
@@ -132,7 +152,7 @@ export function Shell({ children }: { children: ReactNode }) {
132
152
  return () => {
133
153
  active = false;
134
154
  };
135
- }, [identity.userId]);
155
+ }, [identity.subjectProfile, identity.userId]);
136
156
 
137
157
  useEffect(() => {
138
158
  if (!definition || location.pathname.startsWith('/m/')) return;
@@ -199,8 +219,44 @@ export function Shell({ children }: { children: ReactNode }) {
199
219
  }
200
220
  };
201
221
 
222
+ const handleRoleSwitch = async () => {
223
+ if (
224
+ !selectedRoleSubjectKey ||
225
+ selectedRoleSubjectKey === identity.currentRoleSubject?.subjectKey
226
+ ) {
227
+ return;
228
+ }
229
+ try {
230
+ await switchRole(selectedRoleSubjectKey);
231
+ setRoleModalOpen(false);
232
+ message.success('应用角色已切换');
233
+ } catch (reason) {
234
+ message.warning(
235
+ reason instanceof Error
236
+ ? `${reason.message};已重新读取当前角色`
237
+ : '应用角色上下文已变化,请重试'
238
+ );
239
+ }
240
+ };
241
+
242
+ const currentRoleLabel =
243
+ identity.currentRoleSubject?.role.name ||
244
+ (identity.roleSwitchingAvailable
245
+ ? identity.roleCodes.join('、') || '普通成员'
246
+ : '开发者联合权限');
247
+
202
248
  const userMenu: MenuProps = {
203
249
  items: [
250
+ ...(identity.roleSwitchingAvailable &&
251
+ (identity.roleSubjectPage?.total || 0) > 1
252
+ ? [
253
+ {
254
+ icon: <SwapOutlined />,
255
+ key: 'switch-role',
256
+ label: '切换应用角色',
257
+ } as NonNullable<MenuProps['items']>[number],
258
+ ]
259
+ : []),
204
260
  { type: 'divider' },
205
261
  {
206
262
  danger: true,
@@ -210,6 +266,12 @@ export function Shell({ children }: { children: ReactNode }) {
210
266
  },
211
267
  ],
212
268
  onClick: ({ key }) => {
269
+ if (key === 'switch-role') {
270
+ setSelectedRoleSubjectKey(
271
+ identity.currentRoleSubject?.subjectKey || ''
272
+ );
273
+ setRoleModalOpen(true);
274
+ }
213
275
  if (key === 'logout' && !loggingOut) void handleLogout();
214
276
  },
215
277
  };
@@ -285,11 +347,9 @@ export function Shell({ children }: { children: ReactNode }) {
285
347
  </div>
286
348
  </div>
287
349
  <div className="oxa-user-dropdown-roles">
288
- <span>当前角色</span>
350
+ <span>当前应用角色</span>
289
351
  <Typography.Text ellipsis>
290
- {identity.isAppSuperAdmin
291
- ? '应用管理员'
292
- : identity.roleCodes.join('、') || '普通成员'}
352
+ {currentRoleLabel}
293
353
  </Typography.Text>
294
354
  </div>
295
355
  <div className="oxa-user-dropdown-theme">
@@ -345,6 +405,33 @@ export function Shell({ children }: { children: ReactNode }) {
345
405
  <main className="oxa-main">{children}</main>
346
406
  </Content>
347
407
  </Layout>
408
+ <Modal
409
+ cancelText="取消"
410
+ centered
411
+ destroyOnHidden
412
+ okButtonProps={{
413
+ disabled:
414
+ !selectedRoleSubjectKey ||
415
+ selectedRoleSubjectKey === identity.currentRoleSubject?.subjectKey,
416
+ }}
417
+ okText="切换角色"
418
+ onCancel={() => setRoleModalOpen(false)}
419
+ onOk={() => void handleRoleSwitch()}
420
+ open={roleModalOpen}
421
+ confirmLoading={switchingRole}
422
+ title="切换当前应用角色"
423
+ >
424
+ {identity.roleSubjectPage && (
425
+ <RoleSubjectSelect
426
+ initialPage={identity.roleSubjectPage}
427
+ onChange={setSelectedRoleSubjectKey}
428
+ value={selectedRoleSubjectKey || undefined}
429
+ />
430
+ )}
431
+ <Typography.Paragraph className="oxa-role-switch-help" type="secondary">
432
+ 切换后页面、数据和操作权限会按目标角色重新加载。
433
+ </Typography.Paragraph>
434
+ </Modal>
348
435
  </Layout>
349
436
  );
350
437
  }
@@ -0,0 +1,338 @@
1
+ import {
2
+ CloseOutlined,
3
+ EnvironmentOutlined,
4
+ } from '@ant-design/icons';
5
+ import {
6
+ Alert,
7
+ Button,
8
+ Cascader,
9
+ Drawer,
10
+ Input,
11
+ Select,
12
+ Space,
13
+ Spin,
14
+ Typography,
15
+ } from 'antd';
16
+ import type {
17
+ LabeledValue,
18
+ StableAddressValue,
19
+ } from 'openxiangda-contracts/browser';
20
+ import { useCallback, useEffect, useMemo, useState } from 'react';
21
+ import {
22
+ loadChinaDivisions,
23
+ type ChinaDivisionListItem,
24
+ } from '../../platform-client';
25
+ import {
26
+ ADDRESS_LEVELS,
27
+ addressControlValue,
28
+ addressDisplay,
29
+ addressPath,
30
+ addressStoredValue,
31
+ type AddressLevel,
32
+ } from './address-value';
33
+
34
+ interface AddressOption extends LabeledValue {
35
+ hasChildren: boolean;
36
+ isLeaf: boolean;
37
+ children?: AddressOption[];
38
+ }
39
+
40
+ const LEVEL_LABELS: Record<AddressLevel, string> = {
41
+ province: '省份',
42
+ city: '城市',
43
+ district: '区县',
44
+ street: '街道',
45
+ };
46
+
47
+ function divisionOption(item: ChinaDivisionListItem): AddressOption {
48
+ return {
49
+ label: item.name,
50
+ value: item.adcode,
51
+ hasChildren: item.hasChildren,
52
+ isLeaf: !item.hasChildren,
53
+ };
54
+ }
55
+
56
+ function snapshotBranch(path: LabeledValue[], index = 0): AddressOption[] {
57
+ const item = path[index];
58
+ if (!item) return [];
59
+ const children = snapshotBranch(path, index + 1);
60
+ return [{
61
+ ...item,
62
+ hasChildren: children.length > 0,
63
+ isLeaf: children.length === 0,
64
+ ...(children.length ? { children } : {}),
65
+ }];
66
+ }
67
+
68
+ function mergeStoredPath(
69
+ options: AddressOption[],
70
+ path: LabeledValue[],
71
+ index = 0
72
+ ): AddressOption[] {
73
+ const stored = path[index];
74
+ if (!stored) return options;
75
+ let matched = false;
76
+ const next = options.map(option => {
77
+ if (option.value !== stored.value) return option;
78
+ matched = true;
79
+ const children = mergeStoredPath(option.children || [], path, index + 1);
80
+ return {
81
+ ...option,
82
+ label: stored.label || option.label,
83
+ ...(children.length ? { children, isLeaf: false } : {}),
84
+ };
85
+ });
86
+ return matched ? next : [...next, ...snapshotBranch(path, index)];
87
+ }
88
+
89
+ function replaceChildren(
90
+ options: AddressOption[],
91
+ path: string[],
92
+ children: AddressOption[],
93
+ index = 0
94
+ ): AddressOption[] {
95
+ const current = path[index];
96
+ return options.map(option => {
97
+ if (option.value !== current) return option;
98
+ if (index === path.length - 1) {
99
+ return {
100
+ ...option,
101
+ children,
102
+ hasChildren: children.length > 0,
103
+ isLeaf: children.length === 0,
104
+ };
105
+ }
106
+ return {
107
+ ...option,
108
+ children: replaceChildren(option.children || [], path, children, index + 1),
109
+ };
110
+ });
111
+ }
112
+
113
+ function optionSnapshot(option: AddressOption): LabeledValue {
114
+ return { label: option.label, value: option.value };
115
+ }
116
+
117
+ export function AddressValueDisplay({ value }: { value: StableAddressValue }) {
118
+ const parts = addressPath(value);
119
+ return (
120
+ <span className="oxa-address-value">
121
+ <Typography.Text>{addressDisplay(value) || '-'}</Typography.Text>
122
+ {parts.length > 0 && (
123
+ <Typography.Text type="secondary">
124
+ {parts.map(item => item.label).join(' / ')}
125
+ </Typography.Text>
126
+ )}
127
+ </span>
128
+ );
129
+ }
130
+
131
+ export function AddressField({
132
+ value,
133
+ onChange,
134
+ disabled = false,
135
+ mobile = false,
136
+ detailEnabled = true,
137
+ }: {
138
+ value?: StableAddressValue;
139
+ onChange?: (value: StableAddressValue | undefined) => void;
140
+ disabled?: boolean;
141
+ mobile?: boolean;
142
+ detailEnabled?: boolean;
143
+ }) {
144
+ const storedPath = useMemo(() => addressPath(value), [value]);
145
+ const [options, setOptions] = useState<AddressOption[]>(() =>
146
+ snapshotBranch(storedPath)
147
+ );
148
+ const [loading, setLoading] = useState(false);
149
+ const [error, setError] = useState('');
150
+ const [mobileOpen, setMobileOpen] = useState(false);
151
+ const [draft, setDraft] = useState<StableAddressValue | undefined>(value);
152
+ const [mobileOptions, setMobileOptions] = useState<AddressOption[][]>([]);
153
+
154
+ const loadOptions = useCallback(async (parentAdcode?: string) =>
155
+ (await loadChinaDivisions(parentAdcode)).map(divisionOption), []);
156
+
157
+ useEffect(() => {
158
+ let active = true;
159
+ setLoading(true);
160
+ loadOptions()
161
+ .then(items => {
162
+ if (active) setOptions(mergeStoredPath(items, storedPath));
163
+ })
164
+ .catch(reason => {
165
+ if (active) setError(reason instanceof Error ? reason.message : String(reason));
166
+ })
167
+ .finally(() => {
168
+ if (active) setLoading(false);
169
+ });
170
+ return () => {
171
+ active = false;
172
+ };
173
+ }, [loadOptions, storedPath]);
174
+
175
+ const loadChildren = async (selected: AddressOption[]) => {
176
+ const target = selected[selected.length - 1];
177
+ if (!target) return;
178
+ setError('');
179
+ try {
180
+ const children = await loadOptions(target.value);
181
+ setOptions(current => replaceChildren(
182
+ current,
183
+ selected.map(item => item.value),
184
+ children
185
+ ));
186
+ } catch (reason) {
187
+ setError(reason instanceof Error ? reason.message : String(reason));
188
+ }
189
+ };
190
+
191
+ const hydrateMobile = async (stored?: StableAddressValue) => {
192
+ setLoading(true);
193
+ setError('');
194
+ try {
195
+ const path = addressPath(stored);
196
+ const lists: AddressOption[][] = [];
197
+ lists[0] = await loadOptions();
198
+ for (let index = 0; index < path.length && index < ADDRESS_LEVELS.length - 1; index += 1) {
199
+ lists[index + 1] = await loadOptions(path[index]?.value);
200
+ }
201
+ setMobileOptions(lists);
202
+ } catch (reason) {
203
+ setError(reason instanceof Error ? reason.message : String(reason));
204
+ } finally {
205
+ setLoading(false);
206
+ }
207
+ };
208
+
209
+ const openMobile = () => {
210
+ setDraft(value);
211
+ setMobileOpen(true);
212
+ void hydrateMobile(value);
213
+ };
214
+
215
+ const selectMobileLevel = async (index: number, selectedValue?: string) => {
216
+ const currentPath = addressPath(draft).slice(0, index);
217
+ const selected = mobileOptions[index]?.find(item => item.value === selectedValue);
218
+ const nextPath = selected ? [...currentPath, optionSnapshot(selected)] : currentPath;
219
+ const next = addressStoredValue(nextPath, draft?.detail);
220
+ setDraft(next);
221
+ setMobileOptions(current => current.slice(0, index + 1));
222
+ if (selected?.hasChildren && index < ADDRESS_LEVELS.length - 1) {
223
+ try {
224
+ const children = await loadOptions(selected.value);
225
+ setMobileOptions(current => {
226
+ const lists = current.slice(0, index + 1);
227
+ lists[index + 1] = children;
228
+ return lists;
229
+ });
230
+ } catch (reason) {
231
+ setError(reason instanceof Error ? reason.message : String(reason));
232
+ }
233
+ }
234
+ };
235
+
236
+ const detailInput = (current?: StableAddressValue, commit?: (next?: StableAddressValue) => void) =>
237
+ detailEnabled ? (
238
+ <Input
239
+ disabled={disabled || !addressPath(current).length}
240
+ maxLength={4000}
241
+ onChange={event => commit?.(
242
+ addressStoredValue(addressPath(current), event.target.value)
243
+ )}
244
+ placeholder="请输入详细门牌地址"
245
+ value={current?.detail || ''}
246
+ />
247
+ ) : null;
248
+
249
+ if (mobile) {
250
+ return (
251
+ <div className="oxa-address-field oxa-mobile-address-field">
252
+ <Button
253
+ block
254
+ disabled={disabled}
255
+ icon={<EnvironmentOutlined />}
256
+ onClick={openMobile}
257
+ >
258
+ {addressDisplay(value) || '请选择行政区地址'}
259
+ </Button>
260
+ {value && !disabled && (
261
+ <Button
262
+ aria-label="清除地址"
263
+ icon={<CloseOutlined />}
264
+ onClick={() => onChange?.(undefined)}
265
+ type="text"
266
+ />
267
+ )}
268
+ <Drawer
269
+ destroyOnHidden
270
+ onClose={() => setMobileOpen(false)}
271
+ open={mobileOpen}
272
+ placement="bottom"
273
+ size="85vh"
274
+ title="选择行政区地址"
275
+ >
276
+ {loading ? <Spin /> : (
277
+ <div className="oxa-address-mobile-levels">
278
+ {ADDRESS_LEVELS.map((level, index) => {
279
+ const selected = addressPath(draft)[index]?.value;
280
+ const choices = mobileOptions[index] || [];
281
+ if (index > 0 && !choices.length && !selected) return null;
282
+ return (
283
+ <Select
284
+ allowClear
285
+ key={level}
286
+ onChange={next => void selectMobileLevel(index, next)}
287
+ options={choices}
288
+ placeholder={`请选择${LEVEL_LABELS[level]}`}
289
+ value={selected}
290
+ />
291
+ );
292
+ })}
293
+ {detailInput(draft, setDraft)}
294
+ </div>
295
+ )}
296
+ {error && <Alert showIcon title={error} type="error" />}
297
+ <Space className="oxa-address-mobile-actions">
298
+ <Button onClick={() => setMobileOpen(false)}>取消</Button>
299
+ <Button
300
+ disabled={!addressPath(draft).length}
301
+ onClick={() => {
302
+ onChange?.(draft);
303
+ setMobileOpen(false);
304
+ }}
305
+ type="primary"
306
+ >
307
+ 确定
308
+ </Button>
309
+ </Space>
310
+ </Drawer>
311
+ </div>
312
+ );
313
+ }
314
+
315
+ return (
316
+ <div className="oxa-address-field">
317
+ <Cascader
318
+ allowClear
319
+ changeOnSelect
320
+ disabled={disabled}
321
+ loadData={selected => void loadChildren(selected as AddressOption[])}
322
+ loading={loading}
323
+ onChange={(_path, selected) => onChange?.(
324
+ addressStoredValue(
325
+ (selected as AddressOption[]).map(optionSnapshot),
326
+ value?.detail
327
+ )
328
+ )}
329
+ options={options}
330
+ placeholder="请选择行政区地址"
331
+ showSearch={false}
332
+ value={addressControlValue(value)}
333
+ />
334
+ {detailInput(value, onChange)}
335
+ {error && <Alert showIcon title={error} type="error" />}
336
+ </div>
337
+ );
338
+ }
@@ -13,7 +13,7 @@ import { App, Button, Image, Space } from 'antd';
13
13
  import type { DataFileRef } from 'openxiangda-contracts/browser';
14
14
  import { useEffect, useMemo, useState } from 'react';
15
15
  import {
16
- dataFileContentUrl,
16
+ downloadDataFile,
17
17
  fetchDataFileBlob,
18
18
  loadDataFilePreview,
19
19
  } from '../../platform-client';
@@ -89,6 +89,7 @@ export function AttachmentFileList({
89
89
  }) {
90
90
  const { message } = App.useApp();
91
91
  const [openingId, setOpeningId] = useState('');
92
+ const [downloadingId, setDownloadingId] = useState('');
92
93
  const [images, setImages] = useState<PreviewImage[]>([]);
93
94
  const [imageOpen, setImageOpen] = useState(false);
94
95
  const [currentImage, setCurrentImage] = useState(0);
@@ -156,9 +157,14 @@ export function AttachmentFileList({
156
157
  }
157
158
  };
158
159
 
159
- const download = (file: DataFileRef) => {
160
- if (!openIsolatedWindow(dataFileContentUrl(resourceCode, file.id))) {
161
- void message.warning('浏览器阻止了下载窗口,请允许弹出窗口后重试');
160
+ const download = async (file: DataFileRef) => {
161
+ setDownloadingId(file.id);
162
+ try {
163
+ await downloadDataFile(resourceCode, file.id, file.name);
164
+ } catch (error) {
165
+ message.error(error instanceof Error ? error.message : String(error));
166
+ } finally {
167
+ setDownloadingId('');
162
168
  }
163
169
  };
164
170
 
@@ -167,7 +173,19 @@ export function AttachmentFileList({
167
173
  <div className="oxa-file-list">
168
174
  {files.map(file => (
169
175
  <div className="oxa-file-item" key={file.id}>
170
- <span className="oxa-file-kind">{fileIcon(file)}</span>
176
+ <span className="oxa-file-kind">
177
+ {file.thumbnailUrl ? (
178
+ <img
179
+ alt=""
180
+ className="oxa-file-thumbnail"
181
+ height={file.height}
182
+ src={file.thumbnailUrl}
183
+ width={file.width}
184
+ />
185
+ ) : (
186
+ fileIcon(file)
187
+ )}
188
+ </span>
171
189
  <button
172
190
  className="oxa-file-meta oxa-file-name-button"
173
191
  disabled={openingId === file.id}
@@ -189,7 +207,8 @@ export function AttachmentFileList({
189
207
  <Button
190
208
  aria-label={`下载${file.name}`}
191
209
  icon={<DownloadOutlined />}
192
- onClick={() => download(file)}
210
+ loading={downloadingId === file.id}
211
+ onClick={() => void download(file)}
193
212
  size="small"
194
213
  type="text"
195
214
  />
@@ -0,0 +1,49 @@
1
+ import { Cascader } from 'antd';
2
+ import type { DataFieldSurface, LabeledValue } from 'openxiangda-contracts/browser';
3
+ import {
4
+ cascadeControlValue,
5
+ cascadeStoredValue,
6
+ type CascadeStoredValue,
7
+ } from './cascade-value';
8
+
9
+ export function CascadeField({
10
+ field,
11
+ value,
12
+ onChange,
13
+ disabled,
14
+ }: {
15
+ field: DataFieldSurface;
16
+ value?: CascadeStoredValue;
17
+ onChange?: (value: LabeledValue[] | LabeledValue[][] | undefined) => void;
18
+ disabled?: boolean;
19
+ }) {
20
+ const multiple = field.type === 'cascade.multiple';
21
+ return (
22
+ <Cascader
23
+ allowClear
24
+ disabled={disabled}
25
+ maxTagCount="responsive"
26
+ multiple={multiple}
27
+ onChange={(next: unknown) => onChange?.(
28
+ cascadeStoredValue(field.options || [], next, multiple)
29
+ )}
30
+ options={field.options}
31
+ placeholder={`请选择${field.label}`}
32
+ showSearch
33
+ value={cascadeControlValue(value, multiple) as never}
34
+ />
35
+ );
36
+ }
37
+
38
+ export function CascadeValueDisplay({
39
+ value,
40
+ multiple,
41
+ }: {
42
+ value: CascadeStoredValue;
43
+ multiple: boolean;
44
+ }) {
45
+ const paths = multiple
46
+ ? value as LabeledValue[][]
47
+ : [value as LabeledValue[]];
48
+ return <>{paths.map(path => path.map(item => item.label).join(' / ')).join(';') || '-'}</>;
49
+ }