openxiangda-cli 2.0.0-alpha.67 → 2.0.0-alpha.69
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 +12 -7
- package/template/README.md +7 -7
- package/template/apps/server/package.json +1 -1
- package/template/apps/web/e2e/resources.spec.ts +11 -0
- package/template/apps/web/package.json +1 -1
- package/template/apps/web/scripts/check.mjs +18 -13
- package/template/apps/web/src/FilePreviewPage.tsx +3 -3
- package/template/apps/web/src/Shell.tsx +323 -33
- package/template/apps/web/src/appearance.tsx +147 -0
- package/template/apps/web/src/components/resource/GeneratedResourceCrud.tsx +645 -109
- package/template/apps/web/src/components/resource/ResourceBatchActions.tsx +319 -0
- package/template/apps/web/src/components/resource/StandardResourcePages.tsx +13 -52
- package/template/apps/web/src/components/resource/SurfaceFields.tsx +1 -1
- package/template/apps/web/src/components/resource/resource-import.ts +205 -0
- package/template/apps/web/src/main.tsx +22 -21
- package/template/apps/web/src/platform-client.ts +195 -40
- package/template/apps/web/src/styles.css +455 -220
- package/template/apps/web/test/contracts.test.ts +52 -5
- package/template/apps/web/test/resource-import.test.ts +142 -0
- package/template/openxiangda.config.ts +2 -1
- package/template/package.json +4 -3
- package/template/scripts/verify-template-budget.mjs +5 -0
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ArrowRightOutlined,
|
|
3
|
+
ColumnHeightOutlined,
|
|
3
4
|
DeleteOutlined,
|
|
4
5
|
EditOutlined,
|
|
5
6
|
EyeOutlined,
|
|
7
|
+
ExportOutlined,
|
|
6
8
|
PlusOutlined,
|
|
9
|
+
ReloadOutlined,
|
|
7
10
|
SearchOutlined,
|
|
11
|
+
SettingOutlined,
|
|
12
|
+
UpOutlined,
|
|
13
|
+
DownOutlined,
|
|
8
14
|
} from '@ant-design/icons';
|
|
9
15
|
import { useCreate, useDelete, useList, useOne, useUpdate } from '@refinedev/core';
|
|
10
16
|
import {
|
|
@@ -12,7 +18,9 @@ import {
|
|
|
12
18
|
Alert,
|
|
13
19
|
Button,
|
|
14
20
|
Card,
|
|
21
|
+
Collapse,
|
|
15
22
|
Descriptions,
|
|
23
|
+
Dropdown,
|
|
16
24
|
Empty,
|
|
17
25
|
Form,
|
|
18
26
|
Input,
|
|
@@ -22,7 +30,9 @@ import {
|
|
|
22
30
|
Space,
|
|
23
31
|
Spin,
|
|
24
32
|
Table,
|
|
33
|
+
Tooltip,
|
|
25
34
|
Typography,
|
|
35
|
+
type MenuProps,
|
|
26
36
|
type TableColumnsType,
|
|
27
37
|
} from 'antd';
|
|
28
38
|
import dayjs from 'dayjs';
|
|
@@ -49,6 +59,7 @@ import {
|
|
|
49
59
|
import { createNativeResourceClient, type GenericResourceQuery } from '../../platform-client';
|
|
50
60
|
import { ResolvedValueText } from '../../AuthoritativeSelector';
|
|
51
61
|
import { useRuntime } from '../../runtime';
|
|
62
|
+
import { ResourceBatchActions, ResourceImportButton } from './ResourceBatchActions';
|
|
52
63
|
|
|
53
64
|
type ResourceRecord = Record<string, unknown> & {
|
|
54
65
|
id: string;
|
|
@@ -59,7 +70,12 @@ type ResourceRecord = Record<string, unknown> & {
|
|
|
59
70
|
type GeneratedDefinition = {
|
|
60
71
|
code: string;
|
|
61
72
|
name: string;
|
|
62
|
-
capabilities: {
|
|
73
|
+
capabilities: {
|
|
74
|
+
read: string;
|
|
75
|
+
create: string;
|
|
76
|
+
update: string;
|
|
77
|
+
delete: string;
|
|
78
|
+
};
|
|
63
79
|
surface: DataResourceSurface;
|
|
64
80
|
};
|
|
65
81
|
|
|
@@ -81,16 +97,17 @@ function fieldsBySection(surface: DataResourceSurface) {
|
|
|
81
97
|
const section = field.section || 'default';
|
|
82
98
|
groups.set(section, [...(groups.get(section) || []), { key, ...field }]);
|
|
83
99
|
}
|
|
84
|
-
return [...groups.entries()].map(([section, fields]) => ({
|
|
100
|
+
return [...groups.entries()].map(([section, fields]) => ({
|
|
101
|
+
section,
|
|
102
|
+
fields,
|
|
103
|
+
}));
|
|
85
104
|
}
|
|
86
105
|
|
|
87
106
|
function normalizeRecordForForm(record: ResourceRecord, surface: DataResourceSurface) {
|
|
88
107
|
return Object.fromEntries(
|
|
89
108
|
Object.entries(record).map(([key, value]) => {
|
|
90
109
|
const widget = surface.fields[key]?.widget;
|
|
91
|
-
return widget === 'date' || widget === 'datetime'
|
|
92
|
-
? [key, value ? dayjs(String(value)) : undefined]
|
|
93
|
-
: [key, value];
|
|
110
|
+
return widget === 'date' || widget === 'datetime' ? [key, value ? dayjs(String(value)) : undefined] : [key, value];
|
|
94
111
|
})
|
|
95
112
|
);
|
|
96
113
|
}
|
|
@@ -100,22 +117,20 @@ function normalizeFormValues(values: Record<string, unknown>, surface: DataResou
|
|
|
100
117
|
Object.entries(values).map(([key, value]) => {
|
|
101
118
|
const widget = surface.fields[key]?.widget;
|
|
102
119
|
return widget === 'date' || widget === 'datetime'
|
|
103
|
-
? [
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
120
|
+
? [
|
|
121
|
+
key,
|
|
122
|
+
value && typeof value === 'object' && 'format' in value
|
|
123
|
+
? widget === 'datetime'
|
|
124
|
+
? dayjs(value as never).toISOString()
|
|
125
|
+
: (value as { format: (pattern: string) => string }).format('YYYY-MM-DD')
|
|
126
|
+
: value,
|
|
127
|
+
]
|
|
108
128
|
: [key, value];
|
|
109
129
|
})
|
|
110
130
|
);
|
|
111
131
|
}
|
|
112
132
|
|
|
113
|
-
function fieldWritable(
|
|
114
|
-
field: SurfaceField,
|
|
115
|
-
mode: 'create' | 'edit',
|
|
116
|
-
hasCapability: (code: string) => boolean,
|
|
117
|
-
isAppSuperAdmin: boolean
|
|
118
|
-
) {
|
|
133
|
+
function fieldWritable(field: SurfaceField, mode: 'create' | 'edit', hasCapability: (code: string) => boolean, isAppSuperAdmin: boolean) {
|
|
119
134
|
const capability = mode === 'create' ? field.createCapability : field.updateCapability;
|
|
120
135
|
if (isAppSuperAdmin) return true;
|
|
121
136
|
if (capability) return hasCapability(capability);
|
|
@@ -123,11 +138,7 @@ function fieldWritable(
|
|
|
123
138
|
}
|
|
124
139
|
|
|
125
140
|
function auditOperationLabel(operation: DataAuditEntry['operation']) {
|
|
126
|
-
return operation === 'created'
|
|
127
|
-
? '创建'
|
|
128
|
-
: operation === 'deleted'
|
|
129
|
-
? '删除'
|
|
130
|
-
: '更新';
|
|
141
|
+
return operation === 'created' ? '创建' : operation === 'deleted' ? '删除' : '更新';
|
|
131
142
|
}
|
|
132
143
|
|
|
133
144
|
function auditValue(field: SurfaceField, value: unknown) {
|
|
@@ -138,19 +149,19 @@ function auditValue(field: SurfaceField, value: unknown) {
|
|
|
138
149
|
}
|
|
139
150
|
|
|
140
151
|
function auditChangeFields(entry: DataAuditEntry, surface: DataResourceSurface) {
|
|
141
|
-
const fields = fieldsBySection(surface).flatMap(group => group.fields);
|
|
152
|
+
const fields = fieldsBySection(surface).flatMap((group) => group.fields);
|
|
142
153
|
const source = entry.changes || entry.record || entry.before || {};
|
|
143
|
-
return fields.filter(field => Object.prototype.hasOwnProperty.call(source, field.key));
|
|
154
|
+
return fields.filter((field) => Object.prototype.hasOwnProperty.call(source, field.key));
|
|
144
155
|
}
|
|
145
156
|
|
|
146
157
|
function exportCellValue(field: SurfaceField, value: unknown) {
|
|
147
158
|
if (value === undefined || value === null || value === '') return '';
|
|
148
159
|
if (field.widget === 'boolean') return value ? '是' : '否';
|
|
149
160
|
if (field.options && !Array.isArray(value)) {
|
|
150
|
-
return field.options.find(option => option.value === value)?.label || String(value);
|
|
161
|
+
return field.options.find((option) => option.value === value)?.label || String(value);
|
|
151
162
|
}
|
|
152
163
|
if (Array.isArray(value)) {
|
|
153
|
-
return value.map(item => field.options?.find(option => option.value === item)?.label || String(item)).join('、');
|
|
164
|
+
return value.map((item) => field.options?.find((option) => option.value === item)?.label || String(item)).join('、');
|
|
154
165
|
}
|
|
155
166
|
if (field.widget === 'date' || field.widget === 'datetime') {
|
|
156
167
|
const parsed = new Date(String(value));
|
|
@@ -169,7 +180,7 @@ async function exportResourceRows(
|
|
|
169
180
|
name: string,
|
|
170
181
|
surface: DataResourceSurface,
|
|
171
182
|
query: Partial<GenericResourceQuery>,
|
|
172
|
-
sort: { field: string; order: 'asc' | 'desc' }
|
|
183
|
+
sort: { field: string; order: 'asc' | 'desc' }
|
|
173
184
|
) {
|
|
174
185
|
const client = createNativeResourceClient(code, surface);
|
|
175
186
|
const fields = listSurfaceFields(surface.fields);
|
|
@@ -190,9 +201,11 @@ async function exportResourceRows(
|
|
|
190
201
|
total = result.total;
|
|
191
202
|
page += 1;
|
|
192
203
|
} while (rows.length < total && rows.length < 10000 && page <= 20);
|
|
193
|
-
const header = fields.map(field => csvCell(field.label)).join(',');
|
|
194
|
-
const body = rows.map(row => fields.map(field => csvCell(exportCellValue(field, row[field.key]))).join(','));
|
|
195
|
-
const blob = new Blob([`\uFEFF${[header, ...body].join('\r\n')}`], {
|
|
204
|
+
const header = fields.map((field) => csvCell(field.label)).join(',');
|
|
205
|
+
const body = rows.map((row) => fields.map((field) => csvCell(exportCellValue(field, row[field.key]))).join(','));
|
|
206
|
+
const blob = new Blob([`\uFEFF${[header, ...body].join('\r\n')}`], {
|
|
207
|
+
type: 'text/csv;charset=utf-8',
|
|
208
|
+
});
|
|
196
209
|
const url = URL.createObjectURL(blob);
|
|
197
210
|
const anchor = document.createElement('a');
|
|
198
211
|
anchor.href = url;
|
|
@@ -214,7 +227,10 @@ function AuditEntry({ entry, surface }: { entry: DataAuditEntry; surface: DataRe
|
|
|
214
227
|
<div className="oxa-audit-meta">
|
|
215
228
|
<span>操作人</span>
|
|
216
229
|
{entry.actor.principalType === 'application' ? (
|
|
217
|
-
<Typography.Text
|
|
230
|
+
<Typography.Text>
|
|
231
|
+
应用服务
|
|
232
|
+
{entry.actor.oauthClientId ? `(${entry.actor.oauthClientId})` : ''}
|
|
233
|
+
</Typography.Text>
|
|
218
234
|
) : (
|
|
219
235
|
<ResolvedValueText source="user" value={entry.actor.userId} />
|
|
220
236
|
)}
|
|
@@ -223,7 +239,7 @@ function AuditEntry({ entry, surface }: { entry: DataAuditEntry; surface: DataRe
|
|
|
223
239
|
</div>
|
|
224
240
|
{fields.length ? (
|
|
225
241
|
<div className="oxa-audit-changes">
|
|
226
|
-
{fields.map(field => {
|
|
242
|
+
{fields.map((field) => {
|
|
227
243
|
const before = entry.operation === 'created' ? undefined : entry.before?.[field.key];
|
|
228
244
|
const after = entry.operation === 'deleted' ? undefined : changes[field.key];
|
|
229
245
|
return (
|
|
@@ -254,13 +270,19 @@ function useGeneratedList(definition: GeneratedDefinition) {
|
|
|
254
270
|
const [pageSize, setPageSize] = useState(defaultPageSize);
|
|
255
271
|
const [draft, setDraft] = useState<Partial<GenericResourceQuery>>({});
|
|
256
272
|
const [query, setQuery] = useState<Partial<GenericResourceQuery>>({});
|
|
257
|
-
const [sort, setSort] = useState({
|
|
273
|
+
const [sort, setSort] = useState({
|
|
274
|
+
field: defaultSortField,
|
|
275
|
+
order: defaultSortOrder as 'asc' | 'desc',
|
|
276
|
+
});
|
|
258
277
|
useEffect(() => {
|
|
259
278
|
setPage(1);
|
|
260
279
|
setPageSize(defaultPageSize);
|
|
261
280
|
setDraft({});
|
|
262
281
|
setQuery({});
|
|
263
|
-
setSort({
|
|
282
|
+
setSort({
|
|
283
|
+
field: defaultSortField,
|
|
284
|
+
order: defaultSortOrder as 'asc' | 'desc',
|
|
285
|
+
});
|
|
264
286
|
}, [code, defaultPageSize, defaultSortField, defaultSortOrder]);
|
|
265
287
|
const list = useList<ResourceRecord>({
|
|
266
288
|
resource: code,
|
|
@@ -268,7 +290,19 @@ function useGeneratedList(definition: GeneratedDefinition) {
|
|
|
268
290
|
sorters: [sort],
|
|
269
291
|
meta: { query },
|
|
270
292
|
});
|
|
271
|
-
return {
|
|
293
|
+
return {
|
|
294
|
+
draft,
|
|
295
|
+
list,
|
|
296
|
+
page,
|
|
297
|
+
pageSize,
|
|
298
|
+
query,
|
|
299
|
+
setDraft,
|
|
300
|
+
setPage,
|
|
301
|
+
setPageSize,
|
|
302
|
+
setQuery,
|
|
303
|
+
setSort,
|
|
304
|
+
sort,
|
|
305
|
+
};
|
|
272
306
|
}
|
|
273
307
|
|
|
274
308
|
export function GeneratedResourcePage({
|
|
@@ -291,9 +325,7 @@ export function GeneratedResourcePage({
|
|
|
291
325
|
}
|
|
292
326
|
|
|
293
327
|
function GeneratedResourceListPage({ definition, variant }: { definition: GeneratedDefinition; variant: 'desktop' | 'mobile' }) {
|
|
294
|
-
return variant === 'mobile'
|
|
295
|
-
? <GeneratedMobileList definition={definition} />
|
|
296
|
-
: <GeneratedDesktopList definition={definition} />;
|
|
328
|
+
return variant === 'mobile' ? <GeneratedMobileList definition={definition} /> : <GeneratedDesktopList definition={definition} />;
|
|
297
329
|
}
|
|
298
330
|
|
|
299
331
|
function GeneratedDesktopList({ definition }: { definition: GeneratedDefinition }) {
|
|
@@ -304,35 +336,114 @@ function GeneratedDesktopList({ definition }: { definition: GeneratedDefinition
|
|
|
304
336
|
const { code, name, surface, capabilities } = definition;
|
|
305
337
|
const fields = listSurfaceFields(surface.fields);
|
|
306
338
|
const [exporting, setExporting] = useState(false);
|
|
339
|
+
const [filtersExpanded, setFiltersExpanded] = useState(false);
|
|
340
|
+
const [density, setDensity] = useState<'small' | 'middle' | 'large'>('middle');
|
|
341
|
+
const [visibleColumnKeys, setVisibleColumnKeys] = useState<string[]>(() => fields.map((field) => field.key));
|
|
342
|
+
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
|
|
307
343
|
const remove = useDelete();
|
|
308
344
|
const rows = (state.list.result.data || []) as unknown as ResourceRecord[];
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
345
|
+
useEffect(() => {
|
|
346
|
+
setVisibleColumnKeys(fields.map((field) => field.key));
|
|
347
|
+
setFiltersExpanded(false);
|
|
348
|
+
setDensity('middle');
|
|
349
|
+
setSelectedRowKeys([]);
|
|
350
|
+
}, [code]);
|
|
351
|
+
useEffect(() => {
|
|
352
|
+
setSelectedRowKeys([]);
|
|
353
|
+
}, [code, state.page, state.pageSize, state.query, state.sort.field, state.sort.order]);
|
|
354
|
+
const formFields = fieldsBySection(surface).flatMap((group) => group.fields);
|
|
355
|
+
const importWritableFieldCodes = formFields
|
|
356
|
+
.filter((field) => field.widget !== 'readonly' && fieldWritable(field, 'create', hasCapability, identity.isAppSuperAdmin))
|
|
357
|
+
.map((field) => field.key);
|
|
358
|
+
const batchWritableFields = formFields.filter(
|
|
359
|
+
(field) => !['file', 'readonly'].includes(field.widget || '') && fieldWritable(field, 'edit', hasCapability, identity.isAppSuperAdmin)
|
|
360
|
+
);
|
|
361
|
+
const selectedRows = rows.filter((row) => selectedRowKeys.includes(row.id));
|
|
362
|
+
const canBatch = hasCapability(capabilities.update) || hasCapability(capabilities.delete);
|
|
363
|
+
const columns = useMemo<TableColumnsType<ResourceRecord>>(
|
|
364
|
+
() => [
|
|
365
|
+
...fields
|
|
366
|
+
.filter((field) => visibleColumnKeys.includes(field.key))
|
|
367
|
+
.map((field) => ({
|
|
368
|
+
title: field.label,
|
|
369
|
+
dataIndex: field.key,
|
|
370
|
+
key: field.key,
|
|
371
|
+
sorter: Boolean(field.sortable),
|
|
372
|
+
render: (value: unknown, record: ResourceRecord) => (
|
|
373
|
+
<SurfaceFieldValue
|
|
374
|
+
field={field}
|
|
375
|
+
value={value}
|
|
376
|
+
renderers={{
|
|
377
|
+
renderValue: ({ value: current }) =>
|
|
378
|
+
field.key === fields[0]?.key ? (
|
|
379
|
+
<a onClick={() => navigate(`/${code}/${record.id}`)}>{String(current || '-')}</a>
|
|
380
|
+
) : undefined,
|
|
381
|
+
}}
|
|
382
|
+
/>
|
|
383
|
+
),
|
|
384
|
+
})),
|
|
385
|
+
{
|
|
386
|
+
title: '更新时间',
|
|
387
|
+
dataIndex: 'updated_at',
|
|
388
|
+
key: 'updated_at',
|
|
389
|
+
render: (value) => (value ? new Date(String(value)).toLocaleString('zh-CN') : '-'),
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
title: '操作',
|
|
393
|
+
key: 'actions',
|
|
394
|
+
fixed: 'right' as const,
|
|
395
|
+
render: (_: unknown, record: ResourceRecord) => (
|
|
396
|
+
<Space size={4}>
|
|
397
|
+
<Button aria-label="查看" icon={<EyeOutlined />} onClick={() => navigate(`/${code}/${record.id}`)} size="small" type="text" />
|
|
398
|
+
{hasCapability(capabilities.update) && (
|
|
399
|
+
<Button
|
|
400
|
+
aria-label="编辑"
|
|
401
|
+
icon={<EditOutlined />}
|
|
402
|
+
onClick={() => navigate(`/${code}/${record.id}/edit`)}
|
|
403
|
+
size="small"
|
|
404
|
+
type="text"
|
|
405
|
+
/>
|
|
406
|
+
)}
|
|
407
|
+
{hasCapability(capabilities.delete) && (
|
|
408
|
+
<Popconfirm
|
|
409
|
+
onConfirm={() =>
|
|
410
|
+
remove.mutate(
|
|
411
|
+
{
|
|
412
|
+
resource: code,
|
|
413
|
+
id: record.id,
|
|
414
|
+
meta: { expectedRevision: record.revision },
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
onSuccess: () => {
|
|
418
|
+
message.success('已删除');
|
|
419
|
+
void state.list.query.refetch();
|
|
420
|
+
},
|
|
421
|
+
}
|
|
422
|
+
)
|
|
423
|
+
}
|
|
424
|
+
title={`确定删除${name}?`}
|
|
425
|
+
>
|
|
426
|
+
<Button aria-label="删除" danger icon={<DeleteOutlined />} size="small" type="text" />
|
|
427
|
+
</Popconfirm>
|
|
428
|
+
)}
|
|
429
|
+
</Space>
|
|
430
|
+
),
|
|
431
|
+
},
|
|
432
|
+
],
|
|
433
|
+
[
|
|
434
|
+
capabilities.delete,
|
|
435
|
+
capabilities.update,
|
|
436
|
+
code,
|
|
437
|
+
fields,
|
|
438
|
+
hasCapability,
|
|
439
|
+
message,
|
|
440
|
+
name,
|
|
441
|
+
navigate,
|
|
442
|
+
remove,
|
|
443
|
+
state.list.query,
|
|
444
|
+
visibleColumnKeys,
|
|
445
|
+
]
|
|
446
|
+
);
|
|
336
447
|
const handleExport = async () => {
|
|
337
448
|
setExporting(true);
|
|
338
449
|
try {
|
|
@@ -344,12 +455,33 @@ function GeneratedDesktopList({ definition }: { definition: GeneratedDefinition
|
|
|
344
455
|
setExporting(false);
|
|
345
456
|
}
|
|
346
457
|
};
|
|
458
|
+
const filterFields = surface.list?.filterFields || [];
|
|
459
|
+
const hasMoreFilters = filterFields.length > 2;
|
|
460
|
+
const columnMenu: MenuProps = {
|
|
461
|
+
items: fields.map((field) => ({ key: field.key, label: field.label })),
|
|
462
|
+
multiple: true,
|
|
463
|
+
selectable: true,
|
|
464
|
+
selectedKeys: visibleColumnKeys,
|
|
465
|
+
onSelect: ({ selectedKeys }) => setVisibleColumnKeys(selectedKeys.map(String)),
|
|
466
|
+
onDeselect: ({ selectedKeys }) => {
|
|
467
|
+
if (selectedKeys.length) setVisibleColumnKeys(selectedKeys.map(String));
|
|
468
|
+
else message.info('至少保留一列');
|
|
469
|
+
},
|
|
470
|
+
};
|
|
471
|
+
const densityMenu: MenuProps = {
|
|
472
|
+
items: [
|
|
473
|
+
{ key: 'large', label: '宽松' },
|
|
474
|
+
{ key: 'middle', label: '标准' },
|
|
475
|
+
{ key: 'small', label: '紧凑' },
|
|
476
|
+
],
|
|
477
|
+
selectable: true,
|
|
478
|
+
selectedKeys: [density],
|
|
479
|
+
onClick: ({ key }) => setDensity(key as typeof density),
|
|
480
|
+
};
|
|
347
481
|
return (
|
|
348
482
|
<ResourceListPage
|
|
349
483
|
createCapability={capabilities.create}
|
|
350
484
|
createPath={`/${code}/new`}
|
|
351
|
-
description={`由 ${code} 资源 Surface 自动生成的标准 CRUD 页面`}
|
|
352
|
-
onExport={() => void handleExport()}
|
|
353
485
|
readCapability={capabilities.read}
|
|
354
486
|
resource={code}
|
|
355
487
|
surface={surface}
|
|
@@ -357,33 +489,189 @@ function GeneratedDesktopList({ definition }: { definition: GeneratedDefinition
|
|
|
357
489
|
>
|
|
358
490
|
{state.list.query.isError && (
|
|
359
491
|
<Alert
|
|
360
|
-
action={
|
|
492
|
+
action={
|
|
493
|
+
<Button onClick={() => void state.list.query.refetch()} size="small">
|
|
494
|
+
重试
|
|
495
|
+
</Button>
|
|
496
|
+
}
|
|
361
497
|
description={state.list.query.error instanceof Error ? state.list.query.error.message : '请稍后重试或联系平台管理员'}
|
|
362
|
-
message="数据读取失败"
|
|
363
498
|
showIcon
|
|
499
|
+
title="数据读取失败"
|
|
364
500
|
type="error"
|
|
365
501
|
/>
|
|
366
502
|
)}
|
|
367
|
-
<
|
|
368
|
-
<
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
503
|
+
<section aria-label="数据筛选" className="oxa-search-panel">
|
|
504
|
+
<div className="oxa-filter-grid">
|
|
505
|
+
<div className="oxa-filter-item">
|
|
506
|
+
<Select
|
|
507
|
+
allowClear
|
|
508
|
+
options={(surface.list?.searchableFields || []).map((key) => ({
|
|
509
|
+
label: fieldFor(surface, key).label,
|
|
510
|
+
value: key,
|
|
511
|
+
}))}
|
|
512
|
+
placeholder="搜索字段"
|
|
513
|
+
value={state.draft.searchField}
|
|
514
|
+
onChange={(value) => state.setDraft((item) => ({ ...item, searchField: value }))}
|
|
515
|
+
/>
|
|
516
|
+
</div>
|
|
517
|
+
<div className="oxa-filter-item">
|
|
518
|
+
<Input
|
|
519
|
+
allowClear
|
|
520
|
+
onChange={(event) =>
|
|
521
|
+
state.setDraft((item) => ({
|
|
522
|
+
...item,
|
|
523
|
+
keyword: event.target.value,
|
|
524
|
+
}))
|
|
525
|
+
}
|
|
526
|
+
onPressEnter={() => {
|
|
527
|
+
state.setPage(1);
|
|
528
|
+
state.setQuery(state.draft);
|
|
529
|
+
}}
|
|
530
|
+
placeholder="关键词"
|
|
531
|
+
prefix={<SearchOutlined />}
|
|
532
|
+
value={state.draft.keyword}
|
|
533
|
+
/>
|
|
534
|
+
</div>
|
|
535
|
+
{filterFields.map((key, index) => {
|
|
536
|
+
const field = fieldFor(surface, key);
|
|
537
|
+
return (
|
|
538
|
+
<div className={`oxa-filter-item${index >= 2 && !filtersExpanded ? ' is-collapsed' : ''}`} key={key}>
|
|
539
|
+
<SurfaceFilterControl
|
|
540
|
+
field={field}
|
|
541
|
+
onChange={(value) =>
|
|
542
|
+
state.setDraft((item) => ({
|
|
543
|
+
...item,
|
|
544
|
+
filters: { ...item.filters, [key]: value },
|
|
545
|
+
}))
|
|
546
|
+
}
|
|
547
|
+
value={state.draft.filters?.[key]}
|
|
548
|
+
/>
|
|
549
|
+
</div>
|
|
550
|
+
);
|
|
551
|
+
})}
|
|
552
|
+
</div>
|
|
553
|
+
<div className="oxa-filter-actions">
|
|
554
|
+
<Button
|
|
555
|
+
icon={<SearchOutlined />}
|
|
556
|
+
loading={state.list.query.isFetching}
|
|
557
|
+
onClick={() => {
|
|
558
|
+
state.setPage(1);
|
|
559
|
+
state.setQuery(state.draft);
|
|
560
|
+
}}
|
|
561
|
+
type="primary"
|
|
562
|
+
>
|
|
563
|
+
查询
|
|
564
|
+
</Button>
|
|
565
|
+
<Button
|
|
566
|
+
onClick={() => {
|
|
567
|
+
state.setDraft({});
|
|
568
|
+
state.setQuery({});
|
|
569
|
+
state.setPage(1);
|
|
570
|
+
}}
|
|
571
|
+
>
|
|
572
|
+
重置
|
|
573
|
+
</Button>
|
|
574
|
+
{hasMoreFilters && (
|
|
575
|
+
<Button
|
|
576
|
+
icon={filtersExpanded ? <UpOutlined /> : <DownOutlined />}
|
|
577
|
+
onClick={() => setFiltersExpanded((value) => !value)}
|
|
578
|
+
type="link"
|
|
579
|
+
>
|
|
580
|
+
{filtersExpanded ? '收起筛选' : `更多筛选(${filterFields.length - 2})`}
|
|
581
|
+
</Button>
|
|
582
|
+
)}
|
|
583
|
+
</div>
|
|
584
|
+
</section>
|
|
585
|
+
<div className="oxa-list-toolbar">
|
|
586
|
+
<Space className="oxa-list-toolbar-primary" wrap>
|
|
587
|
+
{hasCapability(capabilities.create) && (
|
|
588
|
+
<Button icon={<PlusOutlined />} onClick={() => navigate(`/${code}/new`)} type="primary">
|
|
589
|
+
新增{name}
|
|
590
|
+
</Button>
|
|
591
|
+
)}
|
|
592
|
+
{hasCapability(capabilities.create) && (
|
|
593
|
+
<ResourceImportButton
|
|
594
|
+
code={code}
|
|
595
|
+
name={name}
|
|
596
|
+
surface={surface}
|
|
597
|
+
writableFieldCodes={importWritableFieldCodes}
|
|
598
|
+
onCompleted={() => state.list.query.refetch()}
|
|
599
|
+
/>
|
|
600
|
+
)}
|
|
601
|
+
<Button icon={<ExportOutlined />} loading={exporting} onClick={() => void handleExport()}>
|
|
602
|
+
导出
|
|
603
|
+
</Button>
|
|
604
|
+
</Space>
|
|
605
|
+
<Space className="oxa-list-toolbar-secondary" size={4}>
|
|
606
|
+
<Tooltip title="刷新">
|
|
607
|
+
<Button
|
|
608
|
+
aria-label="刷新数据"
|
|
609
|
+
icon={<ReloadOutlined />}
|
|
610
|
+
loading={state.list.query.isFetching}
|
|
611
|
+
onClick={() => void state.list.query.refetch()}
|
|
612
|
+
type="text"
|
|
613
|
+
/>
|
|
614
|
+
</Tooltip>
|
|
615
|
+
<Dropdown menu={columnMenu} trigger={['click']}>
|
|
616
|
+
<Button icon={<SettingOutlined />} type="text">
|
|
617
|
+
列设置
|
|
618
|
+
</Button>
|
|
619
|
+
</Dropdown>
|
|
620
|
+
<Dropdown menu={densityMenu} trigger={['click']}>
|
|
621
|
+
<Button icon={<ColumnHeightOutlined />} type="text">
|
|
622
|
+
密度
|
|
623
|
+
</Button>
|
|
624
|
+
</Dropdown>
|
|
377
625
|
</Space>
|
|
378
626
|
</div>
|
|
627
|
+
<ResourceBatchActions
|
|
628
|
+
canDelete={hasCapability(capabilities.delete)}
|
|
629
|
+
code={code}
|
|
630
|
+
name={name}
|
|
631
|
+
onClear={() => setSelectedRowKeys([])}
|
|
632
|
+
onCompleted={() => state.list.query.refetch()}
|
|
633
|
+
rows={selectedRows}
|
|
634
|
+
surface={surface}
|
|
635
|
+
writableFields={batchWritableFields}
|
|
636
|
+
/>
|
|
379
637
|
<Table<ResourceRecord>
|
|
380
638
|
columns={columns}
|
|
381
639
|
dataSource={rows}
|
|
382
640
|
loading={state.list.query.isFetching}
|
|
383
641
|
locale={{ emptyText: <Empty description={`暂无${name}`} /> }}
|
|
384
642
|
rowKey="id"
|
|
385
|
-
|
|
386
|
-
|
|
643
|
+
rowSelection={
|
|
644
|
+
canBatch
|
|
645
|
+
? {
|
|
646
|
+
selectedRowKeys,
|
|
647
|
+
onChange: (keys) => {
|
|
648
|
+
const next = keys.map(String);
|
|
649
|
+
if (next.length > 100) message.warning('单次最多选择 100 条记录');
|
|
650
|
+
setSelectedRowKeys(next.slice(0, 100));
|
|
651
|
+
},
|
|
652
|
+
}
|
|
653
|
+
: undefined
|
|
654
|
+
}
|
|
655
|
+
scroll={{ x: 'max-content' }}
|
|
656
|
+
size={density}
|
|
657
|
+
onChange={(_, __, sorter) => {
|
|
658
|
+
const item = Array.isArray(sorter) ? sorter[0] : sorter;
|
|
659
|
+
if (item?.field && item.order)
|
|
660
|
+
state.setSort({
|
|
661
|
+
field: String(item.field),
|
|
662
|
+
order: item.order === 'ascend' ? 'asc' : 'desc',
|
|
663
|
+
});
|
|
664
|
+
}}
|
|
665
|
+
pagination={{
|
|
666
|
+
current: state.page,
|
|
667
|
+
pageSize: state.pageSize,
|
|
668
|
+
total: state.list.result.total || 0,
|
|
669
|
+
showSizeChanger: true,
|
|
670
|
+
onChange: (next, size) => {
|
|
671
|
+
state.setPage(next);
|
|
672
|
+
state.setPageSize(size);
|
|
673
|
+
},
|
|
674
|
+
}}
|
|
387
675
|
/>
|
|
388
676
|
</ResourceListPage>
|
|
389
677
|
);
|
|
@@ -410,27 +698,113 @@ function GeneratedMobileList({ definition }: { definition: GeneratedDefinition }
|
|
|
410
698
|
}
|
|
411
699
|
};
|
|
412
700
|
return (
|
|
413
|
-
<MobileResourceListPage
|
|
701
|
+
<MobileResourceListPage
|
|
702
|
+
createCapability={capabilities.create}
|
|
703
|
+
createPath={`/${code}/new`}
|
|
704
|
+
description={`${code} 资源`}
|
|
705
|
+
onExport={() => void handleExport()}
|
|
706
|
+
readCapability={capabilities.read}
|
|
707
|
+
resource={code}
|
|
708
|
+
surface={surface}
|
|
709
|
+
title={name}
|
|
710
|
+
>
|
|
414
711
|
{state.list.query.isError && (
|
|
415
712
|
<Alert
|
|
416
|
-
action={
|
|
713
|
+
action={
|
|
714
|
+
<Button onClick={() => void state.list.query.refetch()} size="small">
|
|
715
|
+
重试
|
|
716
|
+
</Button>
|
|
717
|
+
}
|
|
417
718
|
description={state.list.query.error instanceof Error ? state.list.query.error.message : '请稍后重试或联系平台管理员'}
|
|
418
|
-
message="数据读取失败"
|
|
419
719
|
showIcon
|
|
720
|
+
title="数据读取失败"
|
|
420
721
|
type="error"
|
|
421
722
|
/>
|
|
422
723
|
)}
|
|
423
724
|
<div className="oxa-mobile-filter">
|
|
424
|
-
<Input
|
|
425
|
-
|
|
725
|
+
<Input
|
|
726
|
+
allowClear
|
|
727
|
+
placeholder="关键词"
|
|
728
|
+
value={state.draft.keyword}
|
|
729
|
+
onChange={(event) => state.setDraft((item) => ({ ...item, keyword: event.target.value }))}
|
|
730
|
+
onPressEnter={() => {
|
|
731
|
+
state.setPage(1);
|
|
732
|
+
state.setQuery(state.draft);
|
|
733
|
+
}}
|
|
734
|
+
/>
|
|
735
|
+
<Button
|
|
736
|
+
loading={state.list.query.isFetching}
|
|
737
|
+
icon={<SearchOutlined />}
|
|
738
|
+
onClick={() => {
|
|
739
|
+
state.setPage(1);
|
|
740
|
+
state.setQuery(state.draft);
|
|
741
|
+
}}
|
|
742
|
+
type="primary"
|
|
743
|
+
>
|
|
744
|
+
查询
|
|
745
|
+
</Button>
|
|
746
|
+
</div>
|
|
747
|
+
{state.list.query.isFetching ? (
|
|
748
|
+
<div className="oxa-page-loading">
|
|
749
|
+
<Spin />
|
|
750
|
+
</div>
|
|
751
|
+
) : rows.length ? (
|
|
752
|
+
<div className="oxa-mobile-record-list">
|
|
753
|
+
{rows.map((record) => (
|
|
754
|
+
<Card className="oxa-mobile-record-card" key={record.id} size="small">
|
|
755
|
+
<div className="oxa-mobile-record-heading">
|
|
756
|
+
<a onClick={() => navigate(`/${code}/${record.id}`)}>{String(record[fields[0]?.key || 'id'] || '-')}</a>
|
|
757
|
+
</div>
|
|
758
|
+
<div className="oxa-mobile-record-fields">
|
|
759
|
+
{fields.slice(1).map((field) => (
|
|
760
|
+
<div key={field.key}>
|
|
761
|
+
<Typography.Text type="secondary">{field.label}</Typography.Text>
|
|
762
|
+
<div>
|
|
763
|
+
<SurfaceFieldValue field={field} value={record[field.key]} />
|
|
764
|
+
</div>
|
|
765
|
+
</div>
|
|
766
|
+
))}
|
|
767
|
+
</div>
|
|
768
|
+
<Space>
|
|
769
|
+
<Button onClick={() => navigate(`/${code}/${record.id}`)} size="small">
|
|
770
|
+
查看
|
|
771
|
+
</Button>
|
|
772
|
+
{hasCapability(capabilities.update) && (
|
|
773
|
+
<Button onClick={() => navigate(`/${code}/${record.id}/edit`)} size="small">
|
|
774
|
+
编辑
|
|
775
|
+
</Button>
|
|
776
|
+
)}
|
|
777
|
+
</Space>
|
|
778
|
+
</Card>
|
|
779
|
+
))}
|
|
780
|
+
</div>
|
|
781
|
+
) : (
|
|
782
|
+
<Card>
|
|
783
|
+
<Empty description={`暂无${name}`} />
|
|
784
|
+
</Card>
|
|
785
|
+
)}
|
|
786
|
+
<div className="oxa-mobile-pagination">
|
|
787
|
+
<Button disabled={state.page <= 1} onClick={() => state.setPage((item) => item - 1)}>
|
|
788
|
+
上一页
|
|
789
|
+
</Button>
|
|
790
|
+
<Typography.Text>第 {state.page} 页</Typography.Text>
|
|
791
|
+
<Button disabled={rows.length < (surface.list?.defaultPageSize || 20)} onClick={() => state.setPage((item) => item + 1)}>
|
|
792
|
+
下一页
|
|
793
|
+
</Button>
|
|
426
794
|
</div>
|
|
427
|
-
{state.list.query.isFetching ? <div className="oxa-page-loading"><Spin /></div> : rows.length ? <div className="oxa-mobile-record-list">{rows.map(record => <Card className="oxa-mobile-record-card" key={record.id} size="small"><div className="oxa-mobile-record-heading"><a onClick={() => navigate(`/${code}/${record.id}`)}>{String(record[fields[0]?.key || 'id'] || '-')}</a></div><div className="oxa-mobile-record-fields">{fields.slice(1).map(field => <div key={field.key}><Typography.Text type="secondary">{field.label}</Typography.Text><div><SurfaceFieldValue field={field} value={record[field.key]} /></div></div>)}</div><Space><Button onClick={() => navigate(`/${code}/${record.id}`)} size="small">查看</Button>{hasCapability(capabilities.update) && <Button onClick={() => navigate(`/${code}/${record.id}/edit`)} size="small">编辑</Button>}</Space></Card>)}</div> : <Card><Empty description={`暂无${name}`} /></Card>}
|
|
428
|
-
<div className="oxa-mobile-pagination"><Button disabled={state.page <= 1} onClick={() => state.setPage(item => item - 1)}>上一页</Button><Typography.Text>第 {state.page} 页</Typography.Text><Button disabled={rows.length < (surface.list?.defaultPageSize || 20)} onClick={() => state.setPage(item => item + 1)}>下一页</Button></div>
|
|
429
795
|
</MobileResourceListPage>
|
|
430
796
|
);
|
|
431
797
|
}
|
|
432
798
|
|
|
433
|
-
function GeneratedResourceFormPage({
|
|
799
|
+
function GeneratedResourceFormPage({
|
|
800
|
+
definition,
|
|
801
|
+
mode,
|
|
802
|
+
variant,
|
|
803
|
+
}: {
|
|
804
|
+
definition: GeneratedDefinition;
|
|
805
|
+
mode: 'create' | 'edit';
|
|
806
|
+
variant: 'desktop' | 'mobile';
|
|
807
|
+
}) {
|
|
434
808
|
const { id = '' } = useParams();
|
|
435
809
|
const navigate = useNavigate();
|
|
436
810
|
const { message } = App.useApp();
|
|
@@ -438,32 +812,97 @@ function GeneratedResourceFormPage({ definition, mode, variant }: { definition:
|
|
|
438
812
|
const [form] = Form.useForm();
|
|
439
813
|
const create = useCreate<ResourceRecord>();
|
|
440
814
|
const update = useUpdate<ResourceRecord>();
|
|
441
|
-
const query = useOne<ResourceRecord>({
|
|
815
|
+
const query = useOne<ResourceRecord>({
|
|
816
|
+
resource: definition.code,
|
|
817
|
+
id,
|
|
818
|
+
queryOptions: { enabled: mode === 'edit' },
|
|
819
|
+
});
|
|
442
820
|
const record = query.result;
|
|
443
821
|
const { code, name, surface, capabilities } = definition;
|
|
444
|
-
useEffect(() => {
|
|
822
|
+
useEffect(() => {
|
|
823
|
+
if (record) form.setFieldsValue(normalizeRecordForForm(record, surface));
|
|
824
|
+
}, [form, record, surface]);
|
|
445
825
|
const listPath = `/${code}`;
|
|
446
826
|
const detailPath = `/${code}/${id}`;
|
|
447
827
|
const Page = variant === 'mobile' ? MobileResourceFormPage : ResourceFormPage;
|
|
448
828
|
const FieldControl = variant === 'mobile' ? MobileSurfaceFieldControl : SurfaceFieldControl;
|
|
449
|
-
if (!hasCapability(mode === 'create' ? capabilities.create : capabilities.update))
|
|
829
|
+
if (!hasCapability(mode === 'create' ? capabilities.create : capabilities.update))
|
|
830
|
+
return <Result status="403" title="当前平台用户无此操作权限" />;
|
|
450
831
|
const submit = async (values: Record<string, unknown>) => {
|
|
451
832
|
const next = normalizeFormValues(
|
|
452
833
|
Object.fromEntries(
|
|
453
834
|
Object.entries(values).filter(([key]) =>
|
|
454
|
-
fieldWritable(
|
|
835
|
+
fieldWritable(
|
|
836
|
+
surface.fields[key] ? { key, ...surface.fields[key] } : { key, label: key, widget: 'text' },
|
|
837
|
+
mode,
|
|
838
|
+
hasCapability,
|
|
839
|
+
identity.isAppSuperAdmin
|
|
840
|
+
)
|
|
455
841
|
)
|
|
456
842
|
),
|
|
457
843
|
surface
|
|
458
844
|
);
|
|
459
|
-
if (mode === 'create') {
|
|
460
|
-
|
|
845
|
+
if (mode === 'create') {
|
|
846
|
+
await create.mutateAsync({ resource: code, values: next });
|
|
847
|
+
message.success(`${name}已创建`);
|
|
848
|
+
navigate(listPath);
|
|
849
|
+
} else {
|
|
850
|
+
const result = await update.mutateAsync({
|
|
851
|
+
resource: code,
|
|
852
|
+
id,
|
|
853
|
+
values: next,
|
|
854
|
+
meta: { expectedRevision: record?.revision },
|
|
855
|
+
});
|
|
856
|
+
message.success('修改已保存');
|
|
857
|
+
navigate(`/${code}/${result.data.id}`);
|
|
858
|
+
}
|
|
461
859
|
};
|
|
462
860
|
return (
|
|
463
|
-
<Page
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
861
|
+
<Page
|
|
862
|
+
backLabel={mode === 'edit' ? `返回${name}详情` : `返回${name}`}
|
|
863
|
+
backPath={mode === 'edit' ? detailPath : listPath}
|
|
864
|
+
capability={mode === 'create' ? capabilities.create : capabilities.update}
|
|
865
|
+
detailPath={record ? detailPath : undefined}
|
|
866
|
+
loading={mode === 'edit' && query.query.isFetching}
|
|
867
|
+
mode={mode}
|
|
868
|
+
notFound={mode === 'edit' && !record}
|
|
869
|
+
resource={code}
|
|
870
|
+
surface={surface}
|
|
871
|
+
title={name}
|
|
872
|
+
>
|
|
873
|
+
<Form
|
|
874
|
+
className={variant === 'mobile' ? 'oxa-mobile-form' : 'oxa-form-full'}
|
|
875
|
+
form={form}
|
|
876
|
+
initialValues={undefined}
|
|
877
|
+
layout="vertical"
|
|
878
|
+
onFinish={(values) => void submit(values as Record<string, unknown>)}
|
|
879
|
+
>
|
|
880
|
+
<div className="oxa-sections">
|
|
881
|
+
{fieldsBySection(surface).map((group) => (
|
|
882
|
+
<Card className="oxa-section-card" key={group.section} title={group.section === 'default' ? '基本信息' : group.section}>
|
|
883
|
+
<div className="oxa-grid">
|
|
884
|
+
{group.fields.map((field) => (
|
|
885
|
+
<FieldControl
|
|
886
|
+
key={field.key}
|
|
887
|
+
disabled={!fieldWritable(field, mode, hasCapability, identity.isAppSuperAdmin)}
|
|
888
|
+
field={field}
|
|
889
|
+
operation={mode === 'create' ? 'create' : 'update'}
|
|
890
|
+
recordId={record?.id}
|
|
891
|
+
renderers={{
|
|
892
|
+
upload: (current, file, recordId) => createNativeResourceClient(code, surface).upload(current.key, file, recordId),
|
|
893
|
+
}}
|
|
894
|
+
/>
|
|
895
|
+
))}
|
|
896
|
+
</div>
|
|
897
|
+
</Card>
|
|
898
|
+
))}
|
|
899
|
+
</div>
|
|
900
|
+
<div className="oxa-actions">
|
|
901
|
+
<Button onClick={() => navigate(mode === 'edit' ? detailPath : listPath)}>取消</Button>
|
|
902
|
+
<Button htmlType="submit" loading={create.mutation.isPending || update.mutation.isPending} type="primary">
|
|
903
|
+
{mode === 'create' ? '保存' : '保存修改'}
|
|
904
|
+
</Button>
|
|
905
|
+
</div>
|
|
467
906
|
</Form>
|
|
468
907
|
</Page>
|
|
469
908
|
);
|
|
@@ -472,21 +911,31 @@ function GeneratedResourceFormPage({ definition, mode, variant }: { definition:
|
|
|
472
911
|
function GeneratedResourceDetailPage({ definition, variant }: { definition: GeneratedDefinition; variant: 'desktop' | 'mobile' }) {
|
|
473
912
|
const { id = '' } = useParams();
|
|
474
913
|
const navigate = useNavigate();
|
|
475
|
-
const query = useOne<ResourceRecord>({
|
|
914
|
+
const query = useOne<ResourceRecord>({
|
|
915
|
+
resource: definition.code,
|
|
916
|
+
id,
|
|
917
|
+
queryOptions: { retry: false },
|
|
918
|
+
});
|
|
476
919
|
const record = query.result;
|
|
477
920
|
const [auditEntries, setAuditEntries] = useState<DataAuditEntry[]>([]);
|
|
478
921
|
const [auditLoading, setAuditLoading] = useState(false);
|
|
479
922
|
const [auditError, setAuditError] = useState('');
|
|
923
|
+
const [auditExpanded, setAuditExpanded] = useState(false);
|
|
924
|
+
const [auditLoadedFor, setAuditLoadedFor] = useState('');
|
|
480
925
|
useEffect(() => {
|
|
481
|
-
if (!record) return;
|
|
926
|
+
if (!record || !auditExpanded || auditLoadedFor === record.id) return;
|
|
482
927
|
let active = true;
|
|
483
928
|
setAuditLoading(true);
|
|
484
929
|
setAuditError('');
|
|
485
|
-
void createNativeResourceClient(definition.code, definition.surface)
|
|
486
|
-
.
|
|
487
|
-
|
|
930
|
+
void createNativeResourceClient(definition.code, definition.surface)
|
|
931
|
+
.audit(record.id)
|
|
932
|
+
.then((page) => {
|
|
933
|
+
if (active) {
|
|
934
|
+
setAuditEntries(page.items || []);
|
|
935
|
+
setAuditLoadedFor(record.id);
|
|
936
|
+
}
|
|
488
937
|
})
|
|
489
|
-
.catch(reason => {
|
|
938
|
+
.catch((reason) => {
|
|
490
939
|
if (!active) return;
|
|
491
940
|
setAuditEntries([]);
|
|
492
941
|
setAuditError(reason instanceof Error ? reason.message : String(reason));
|
|
@@ -497,9 +946,96 @@ function GeneratedResourceDetailPage({ definition, variant }: { definition: Gene
|
|
|
497
946
|
return () => {
|
|
498
947
|
active = false;
|
|
499
948
|
};
|
|
500
|
-
}, [definition.code, definition.surface, record
|
|
501
|
-
const
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
949
|
+
}, [auditExpanded, auditLoadedFor, definition.code, definition.surface, record]);
|
|
950
|
+
const auditContent = auditError ? (
|
|
951
|
+
<Alert
|
|
952
|
+
action={
|
|
953
|
+
<Button
|
|
954
|
+
onClick={() => {
|
|
955
|
+
setAuditError('');
|
|
956
|
+
setAuditLoadedFor('');
|
|
957
|
+
}}
|
|
958
|
+
size="small"
|
|
959
|
+
>
|
|
960
|
+
重试
|
|
961
|
+
</Button>
|
|
962
|
+
}
|
|
963
|
+
description={auditError}
|
|
964
|
+
showIcon
|
|
965
|
+
title="变更记录读取失败"
|
|
966
|
+
type="error"
|
|
967
|
+
/>
|
|
968
|
+
) : auditLoading ? (
|
|
969
|
+
<div className="oxa-audit-loading">
|
|
970
|
+
<Spin size="small" /> 正在读取操作记录
|
|
971
|
+
</div>
|
|
972
|
+
) : auditEntries.length ? (
|
|
973
|
+
auditEntries.slice(0, 5).map((entry) => <AuditEntry entry={entry} key={entry.id} surface={definition.surface} />)
|
|
974
|
+
) : (
|
|
975
|
+
<Typography.Text type="secondary">暂无变更记录</Typography.Text>
|
|
976
|
+
);
|
|
977
|
+
const content = record ? (
|
|
978
|
+
<div className="oxa-detail-layout">
|
|
979
|
+
<div className="oxa-detail-main">
|
|
980
|
+
{fieldsBySection(definition.surface).map((group) => (
|
|
981
|
+
<Card className="oxa-section-card" key={group.section} title={group.section === 'default' ? '基本信息' : group.section}>
|
|
982
|
+
<Descriptions
|
|
983
|
+
column={{ xs: 1, sm: 2, lg: 3 }}
|
|
984
|
+
items={group.fields.map((field) => ({
|
|
985
|
+
key: field.key,
|
|
986
|
+
label: field.label,
|
|
987
|
+
children: <SurfaceFieldValue field={field} value={record[field.key]} />,
|
|
988
|
+
}))}
|
|
989
|
+
/>
|
|
990
|
+
</Card>
|
|
991
|
+
))}
|
|
992
|
+
<div id={`${definition.code}-audit`}>
|
|
993
|
+
<Collapse
|
|
994
|
+
activeKey={auditExpanded ? ['audit'] : []}
|
|
995
|
+
className="oxa-audit-collapse"
|
|
996
|
+
expandIconPlacement="end"
|
|
997
|
+
items={[
|
|
998
|
+
{
|
|
999
|
+
key: 'audit',
|
|
1000
|
+
label: (
|
|
1001
|
+
<div className="oxa-audit-collapse-heading">
|
|
1002
|
+
<strong>操作记录</strong>
|
|
1003
|
+
<span>查看创建、更新和删除的字段变化</span>
|
|
1004
|
+
</div>
|
|
1005
|
+
),
|
|
1006
|
+
children: auditContent,
|
|
1007
|
+
},
|
|
1008
|
+
]}
|
|
1009
|
+
onChange={(keys) => setAuditExpanded((Array.isArray(keys) ? keys : [keys]).includes('audit'))}
|
|
1010
|
+
/>
|
|
1011
|
+
</div>
|
|
1012
|
+
</div>
|
|
1013
|
+
</div>
|
|
1014
|
+
) : null;
|
|
1015
|
+
const hero = record ? (
|
|
1016
|
+
<div>
|
|
1017
|
+
<Typography.Title level={variant === 'mobile' ? 3 : 2}>{definition.name}详情</Typography.Title>
|
|
1018
|
+
</div>
|
|
1019
|
+
) : null;
|
|
1020
|
+
const props = {
|
|
1021
|
+
auditTargetId: `${definition.code}-audit`,
|
|
1022
|
+
backLabel: `返回${definition.name}`,
|
|
1023
|
+
backPath: `/${definition.code}`,
|
|
1024
|
+
editCapability: definition.capabilities.update,
|
|
1025
|
+
editPath: `/${definition.code}/${id}/edit`,
|
|
1026
|
+
error: query.query.isError || (!query.query.isFetching && !record) ? query.query.error?.message || '记录不存在' : undefined,
|
|
1027
|
+
hero,
|
|
1028
|
+
loading: query.query.isFetching,
|
|
1029
|
+
meta: record ? <span>最近更新 {record.updated_at ? new Date(record.updated_at).toLocaleString('zh-CN') : '-'}</span> : null,
|
|
1030
|
+
onAuditOpen: () => setAuditExpanded(true),
|
|
1031
|
+
readCapability: definition.capabilities.read,
|
|
1032
|
+
resource: definition.code,
|
|
1033
|
+
surface: definition.surface,
|
|
1034
|
+
title: definition.name,
|
|
1035
|
+
};
|
|
1036
|
+
return variant === 'mobile' ? (
|
|
1037
|
+
<MobileResourceDetailPage {...props}>{content}</MobileResourceDetailPage>
|
|
1038
|
+
) : (
|
|
1039
|
+
<ResourceDetailPage {...props}>{content}</ResourceDetailPage>
|
|
1040
|
+
);
|
|
505
1041
|
}
|