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.
- package/package.json +4 -4
- package/template/AGENTS.md +4 -4
- package/template/README.md +2 -2
- package/template/apps/server/package.json +1 -1
- package/template/apps/web/data-api-live.e2e.html +15 -0
- package/template/apps/web/e2e/data-api-live-fixture.tsx +141 -0
- package/template/apps/web/e2e/data-api-live.spec.ts +138 -0
- package/template/apps/web/e2e/field-protocol-fixture.tsx +280 -0
- package/template/apps/web/e2e/field-protocol.spec.ts +100 -0
- package/template/apps/web/e2e/resources.spec.ts +258 -20
- package/template/apps/web/field-protocol.e2e.html +12 -0
- package/template/apps/web/package.json +1 -1
- package/template/apps/web/playwright.config.ts +3 -1
- package/template/apps/web/scripts/check.mjs +8 -5
- package/template/apps/web/scripts/verify-build.mjs +1 -1
- package/template/apps/web/src/AuthoritativeSelector.tsx +165 -132
- package/template/apps/web/src/FilePreviewPage.tsx +30 -9
- package/template/apps/web/src/RoleSubjectSelect.tsx +128 -0
- package/template/apps/web/src/Shell.tsx +93 -6
- package/template/apps/web/src/components/platform-fields/AddressField.tsx +338 -0
- package/template/apps/web/src/components/platform-fields/AttachmentFileList.tsx +25 -6
- package/template/apps/web/src/components/platform-fields/CascadeField.tsx +49 -0
- package/template/apps/web/src/components/platform-fields/DateTimeField.tsx +148 -0
- package/template/apps/web/src/components/platform-fields/JsonField.tsx +77 -0
- package/template/apps/web/src/components/platform-fields/LocationField.tsx +164 -0
- package/template/apps/web/src/components/platform-fields/PlatformDirectoryPicker.tsx +19 -44
- package/template/apps/web/src/components/platform-fields/ResourceReferenceField.tsx +67 -0
- package/template/apps/web/src/components/platform-fields/RichTextField.tsx +196 -0
- package/template/apps/web/src/components/platform-fields/SignatureField.tsx +315 -0
- package/template/apps/web/src/components/platform-fields/SubtableField.tsx +553 -0
- package/template/apps/web/src/components/platform-fields/address-value.ts +80 -0
- package/template/apps/web/src/components/platform-fields/cascade-value.ts +66 -0
- package/template/apps/web/src/components/platform-fields/directory-value.ts +53 -0
- package/template/apps/web/src/components/platform-fields/field-form-codec.ts +98 -0
- package/template/apps/web/src/components/platform-fields/location-value.ts +87 -0
- package/template/apps/web/src/components/platform-fields/resource-query.ts +131 -0
- package/template/apps/web/src/components/platform-fields/rich-text-value.ts +59 -0
- package/template/apps/web/src/components/platform-fields/subtable-value.ts +187 -0
- package/template/apps/web/src/components/resource/GeneratedResourceCrud.tsx +194 -108
- package/template/apps/web/src/components/resource/ResourceBatchActions.tsx +8 -1
- package/template/apps/web/src/components/resource/SurfaceFields.tsx +385 -79
- package/template/apps/web/src/components/resource/generated-resource-definition.ts +18 -0
- package/template/apps/web/src/components/resource/resource-import.ts +4 -4
- package/template/apps/web/src/data-provider.ts +59 -34
- package/template/apps/web/src/platform-client.ts +385 -111
- package/template/apps/web/src/runtime.tsx +166 -14
- package/template/apps/web/src/styles.css +264 -0
- package/template/apps/web/test/address-field.test.ts +66 -0
- package/template/apps/web/test/cascade-value.test.ts +68 -0
- package/template/apps/web/test/contracts.test.ts +29 -5
- package/template/apps/web/test/directory-value.test.ts +55 -0
- package/template/apps/web/test/field-bounds.test.ts +17 -0
- package/template/apps/web/test/field-form-codec.test.ts +85 -0
- package/template/apps/web/test/location-field.test.ts +83 -0
- package/template/apps/web/test/resource-import.test.ts +8 -7
- package/template/apps/web/test/resource-query.test.ts +144 -0
- package/template/apps/web/test/rich-json-field.test.ts +53 -0
- package/template/apps/web/test/signature-serial-field.test.ts +40 -0
- package/template/apps/web/test/subtable-value.test.ts +144 -0
- package/template/package.json +3 -3
- package/template/packages/contracts/src/generated.ts +69 -0
- 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 {
|
|
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
|
|
350
|
+
<span>当前应用角色</span>
|
|
289
351
|
<Typography.Text ellipsis>
|
|
290
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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">
|
|
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
|
-
|
|
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
|
+
}
|