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
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { DeleteOutlined, EditOutlined, ImportOutlined } from '@ant-design/icons';
|
|
2
|
+
import { Alert, App, Button, Form, Modal, Select, Space, Table, Typography, Upload } from 'antd';
|
|
3
|
+
import dayjs from 'dayjs';
|
|
4
|
+
import type { DataResourceSurface } from 'openxiangda-contracts/browser';
|
|
5
|
+
import { useMemo, useState } from 'react';
|
|
6
|
+
import { transactNativeData } from '../../platform-client';
|
|
7
|
+
import { SurfaceFieldControl, SurfaceFieldValue, type SurfaceField } from './SurfaceFields';
|
|
8
|
+
import { parseResourceImportFile, type ResourceImportPreview } from './resource-import';
|
|
9
|
+
|
|
10
|
+
type ResourceRecord = Record<string, unknown> & {
|
|
11
|
+
id: string;
|
|
12
|
+
revision: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function ResourceImportButton({
|
|
16
|
+
code,
|
|
17
|
+
name,
|
|
18
|
+
surface,
|
|
19
|
+
writableFieldCodes,
|
|
20
|
+
onCompleted,
|
|
21
|
+
}: {
|
|
22
|
+
code: string;
|
|
23
|
+
name: string;
|
|
24
|
+
surface: DataResourceSurface;
|
|
25
|
+
writableFieldCodes: string[];
|
|
26
|
+
onCompleted: () => Promise<unknown> | unknown;
|
|
27
|
+
}) {
|
|
28
|
+
const { message } = App.useApp();
|
|
29
|
+
const [preview, setPreview] = useState<ResourceImportPreview>();
|
|
30
|
+
const [parsing, setParsing] = useState(false);
|
|
31
|
+
const [submitting, setSubmitting] = useState(false);
|
|
32
|
+
const [idempotencyKey, setIdempotencyKey] = useState('');
|
|
33
|
+
const close = () => {
|
|
34
|
+
if (submitting) return;
|
|
35
|
+
setPreview(undefined);
|
|
36
|
+
setIdempotencyKey('');
|
|
37
|
+
};
|
|
38
|
+
const submit = async () => {
|
|
39
|
+
if (!preview?.operations.length || preview.errors.length) return;
|
|
40
|
+
setSubmitting(true);
|
|
41
|
+
try {
|
|
42
|
+
const key = idempotencyKey || crypto.randomUUID();
|
|
43
|
+
if (!idempotencyKey) setIdempotencyKey(key);
|
|
44
|
+
const result = await transactNativeData(preview.operations, key);
|
|
45
|
+
message.success(`已导入 ${result.items.length} 条${name}`);
|
|
46
|
+
await onCompleted();
|
|
47
|
+
setPreview(undefined);
|
|
48
|
+
setIdempotencyKey('');
|
|
49
|
+
} catch (error) {
|
|
50
|
+
message.error(transactionFailureMessage(error, '导入失败'));
|
|
51
|
+
} finally {
|
|
52
|
+
setSubmitting(false);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const previewColumns = useMemo(
|
|
56
|
+
() => [
|
|
57
|
+
{ title: '行号', dataIndex: 'rowNumber', width: 72 },
|
|
58
|
+
...Object.entries(surface.fields)
|
|
59
|
+
.filter(([code]) => writableFieldCodes.includes(code))
|
|
60
|
+
.map(([fieldCode, field]) => ({
|
|
61
|
+
title: field.label,
|
|
62
|
+
key: fieldCode,
|
|
63
|
+
render: (_: unknown, row: ResourceImportPreview['rows'][number]) => (
|
|
64
|
+
<SurfaceFieldValue field={{ key: fieldCode, ...field }} value={row.data[fieldCode]} />
|
|
65
|
+
),
|
|
66
|
+
})),
|
|
67
|
+
],
|
|
68
|
+
[surface.fields, writableFieldCodes]
|
|
69
|
+
);
|
|
70
|
+
return (
|
|
71
|
+
<>
|
|
72
|
+
<Upload
|
|
73
|
+
accept=".csv,.xls,.xlsx"
|
|
74
|
+
beforeUpload={async (file) => {
|
|
75
|
+
setParsing(true);
|
|
76
|
+
try {
|
|
77
|
+
setPreview(await parseResourceImportFile(file, code, surface, writableFieldCodes));
|
|
78
|
+
setIdempotencyKey(crypto.randomUUID());
|
|
79
|
+
} catch (error) {
|
|
80
|
+
message.error(error instanceof Error ? error.message : '文件解析失败');
|
|
81
|
+
} finally {
|
|
82
|
+
setParsing(false);
|
|
83
|
+
}
|
|
84
|
+
return Upload.LIST_IGNORE;
|
|
85
|
+
}}
|
|
86
|
+
maxCount={1}
|
|
87
|
+
showUploadList={false}
|
|
88
|
+
>
|
|
89
|
+
<Button
|
|
90
|
+
disabled={!writableFieldCodes.length}
|
|
91
|
+
icon={<ImportOutlined />}
|
|
92
|
+
loading={parsing}
|
|
93
|
+
title={!writableFieldCodes.length ? '当前角色没有可导入字段' : undefined}
|
|
94
|
+
>
|
|
95
|
+
导入
|
|
96
|
+
</Button>
|
|
97
|
+
</Upload>
|
|
98
|
+
<Modal
|
|
99
|
+
cancelText="取消"
|
|
100
|
+
destroyOnHidden
|
|
101
|
+
okButtonProps={{ disabled: Boolean(preview?.errors.length) }}
|
|
102
|
+
okText={`确认导入${preview?.operations.length ? ` ${preview.operations.length} 条` : ''}`}
|
|
103
|
+
open={Boolean(preview)}
|
|
104
|
+
title={`导入${name}`}
|
|
105
|
+
width={960}
|
|
106
|
+
confirmLoading={submitting}
|
|
107
|
+
onCancel={close}
|
|
108
|
+
onOk={() => void submit()}
|
|
109
|
+
>
|
|
110
|
+
{preview && (
|
|
111
|
+
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
|
112
|
+
<Typography.Text type="secondary">{preview.fileName} · 单次最多 100 行,所有数据将作为一个事务提交</Typography.Text>
|
|
113
|
+
{preview.errors.length > 0 && (
|
|
114
|
+
<Alert
|
|
115
|
+
description={
|
|
116
|
+
<ul className="oxa-import-errors">
|
|
117
|
+
{preview.errors.slice(0, 20).map((error) => (
|
|
118
|
+
<li key={error}>{error}</li>
|
|
119
|
+
))}
|
|
120
|
+
{preview.errors.length > 20 && <li>另有 {preview.errors.length - 20} 个错误</li>}
|
|
121
|
+
</ul>
|
|
122
|
+
}
|
|
123
|
+
message="请先修正导入文件"
|
|
124
|
+
showIcon
|
|
125
|
+
type="error"
|
|
126
|
+
/>
|
|
127
|
+
)}
|
|
128
|
+
<Table
|
|
129
|
+
columns={previewColumns}
|
|
130
|
+
dataSource={preview.rows.slice(0, 20)}
|
|
131
|
+
locale={{ emptyText: '没有可预览的数据' }}
|
|
132
|
+
pagination={false}
|
|
133
|
+
rowKey="rowNumber"
|
|
134
|
+
scroll={{ x: 'max-content', y: 360 }}
|
|
135
|
+
size="small"
|
|
136
|
+
/>
|
|
137
|
+
{preview.rows.length > 20 && <Typography.Text type="secondary">仅预览前 20 行</Typography.Text>}
|
|
138
|
+
</Space>
|
|
139
|
+
)}
|
|
140
|
+
</Modal>
|
|
141
|
+
</>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function ResourceBatchActions({
|
|
146
|
+
code,
|
|
147
|
+
name,
|
|
148
|
+
surface,
|
|
149
|
+
rows,
|
|
150
|
+
writableFields,
|
|
151
|
+
canDelete,
|
|
152
|
+
onCompleted,
|
|
153
|
+
onClear,
|
|
154
|
+
}: {
|
|
155
|
+
code: string;
|
|
156
|
+
name: string;
|
|
157
|
+
surface: DataResourceSurface;
|
|
158
|
+
rows: ResourceRecord[];
|
|
159
|
+
writableFields: SurfaceField[];
|
|
160
|
+
canDelete: boolean;
|
|
161
|
+
onCompleted: () => Promise<unknown> | unknown;
|
|
162
|
+
onClear: () => void;
|
|
163
|
+
}) {
|
|
164
|
+
const { message } = App.useApp();
|
|
165
|
+
const [form] = Form.useForm();
|
|
166
|
+
const [updateOpen, setUpdateOpen] = useState(false);
|
|
167
|
+
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
168
|
+
const [submitting, setSubmitting] = useState(false);
|
|
169
|
+
const [selectedField, setSelectedField] = useState('');
|
|
170
|
+
const [idempotencyKey, setIdempotencyKey] = useState('');
|
|
171
|
+
if (!rows.length) return null;
|
|
172
|
+
const submitUpdate = async (values: Record<string, unknown>) => {
|
|
173
|
+
const field = writableFields.find((item) => item.key === selectedField);
|
|
174
|
+
if (!field) return;
|
|
175
|
+
const value = normalizeValue(field, values[selectedField]);
|
|
176
|
+
const changed = rows.filter((row) => !sameValue(row[selectedField], value));
|
|
177
|
+
if (!changed.length) {
|
|
178
|
+
message.info('所选记录的该字段已经是目标值');
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
setSubmitting(true);
|
|
182
|
+
try {
|
|
183
|
+
const key = idempotencyKey || crypto.randomUUID();
|
|
184
|
+
if (!idempotencyKey) setIdempotencyKey(key);
|
|
185
|
+
await transactNativeData(
|
|
186
|
+
changed.map((row) => ({
|
|
187
|
+
operation: 'update',
|
|
188
|
+
resourceCode: code,
|
|
189
|
+
id: row.id,
|
|
190
|
+
expectedRevision: row.revision,
|
|
191
|
+
data: { [selectedField]: value },
|
|
192
|
+
})),
|
|
193
|
+
key
|
|
194
|
+
);
|
|
195
|
+
message.success(`已更新 ${changed.length} 条${name}`);
|
|
196
|
+
await onCompleted();
|
|
197
|
+
onClear();
|
|
198
|
+
setUpdateOpen(false);
|
|
199
|
+
setIdempotencyKey('');
|
|
200
|
+
form.resetFields();
|
|
201
|
+
setSelectedField('');
|
|
202
|
+
} catch (error) {
|
|
203
|
+
message.error(transactionFailureMessage(error, '批量更新失败'));
|
|
204
|
+
} finally {
|
|
205
|
+
setSubmitting(false);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
const submitDelete = async () => {
|
|
209
|
+
setSubmitting(true);
|
|
210
|
+
try {
|
|
211
|
+
const key = idempotencyKey || crypto.randomUUID();
|
|
212
|
+
if (!idempotencyKey) setIdempotencyKey(key);
|
|
213
|
+
await transactNativeData(
|
|
214
|
+
rows.map((row) => ({
|
|
215
|
+
operation: 'delete',
|
|
216
|
+
resourceCode: code,
|
|
217
|
+
id: row.id,
|
|
218
|
+
expectedRevision: row.revision,
|
|
219
|
+
})),
|
|
220
|
+
key
|
|
221
|
+
);
|
|
222
|
+
message.success(`已删除 ${rows.length} 条${name}`);
|
|
223
|
+
await onCompleted();
|
|
224
|
+
onClear();
|
|
225
|
+
setDeleteOpen(false);
|
|
226
|
+
setIdempotencyKey('');
|
|
227
|
+
} catch (error) {
|
|
228
|
+
message.error(transactionFailureMessage(error, '批量删除失败'));
|
|
229
|
+
} finally {
|
|
230
|
+
setSubmitting(false);
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
const field = writableFields.find((item) => item.key === selectedField);
|
|
234
|
+
return (
|
|
235
|
+
<>
|
|
236
|
+
<div className="oxa-batch-bar">
|
|
237
|
+
<Typography.Text>已选择 {rows.length} 条</Typography.Text>
|
|
238
|
+
<Space>
|
|
239
|
+
{writableFields.length > 0 && (
|
|
240
|
+
<Button icon={<EditOutlined />} onClick={() => setUpdateOpen(true)}>
|
|
241
|
+
批量修改
|
|
242
|
+
</Button>
|
|
243
|
+
)}
|
|
244
|
+
{canDelete && (
|
|
245
|
+
<Button danger icon={<DeleteOutlined />} onClick={() => setDeleteOpen(true)}>
|
|
246
|
+
批量删除
|
|
247
|
+
</Button>
|
|
248
|
+
)}
|
|
249
|
+
<Button onClick={onClear} type="link">
|
|
250
|
+
取消选择
|
|
251
|
+
</Button>
|
|
252
|
+
</Space>
|
|
253
|
+
</div>
|
|
254
|
+
<Modal
|
|
255
|
+
cancelText="取消"
|
|
256
|
+
okText="确认修改"
|
|
257
|
+
open={updateOpen}
|
|
258
|
+
title={`批量修改 ${rows.length} 条${name}`}
|
|
259
|
+
confirmLoading={submitting}
|
|
260
|
+
onCancel={() => !submitting && setUpdateOpen(false)}
|
|
261
|
+
onOk={() => form.submit()}
|
|
262
|
+
>
|
|
263
|
+
<Form
|
|
264
|
+
form={form}
|
|
265
|
+
layout="vertical"
|
|
266
|
+
onFinish={(values) => void submitUpdate(values as Record<string, unknown>)}
|
|
267
|
+
onValuesChange={() => setIdempotencyKey('')}
|
|
268
|
+
>
|
|
269
|
+
<Form.Item label="修改字段" required>
|
|
270
|
+
<Select
|
|
271
|
+
options={writableFields.map((item) => ({
|
|
272
|
+
label: item.label,
|
|
273
|
+
value: item.key,
|
|
274
|
+
}))}
|
|
275
|
+
placeholder="选择要统一修改的字段"
|
|
276
|
+
value={selectedField || undefined}
|
|
277
|
+
onChange={(value) => {
|
|
278
|
+
form.resetFields();
|
|
279
|
+
setSelectedField(value);
|
|
280
|
+
setIdempotencyKey('');
|
|
281
|
+
}}
|
|
282
|
+
/>
|
|
283
|
+
</Form.Item>
|
|
284
|
+
{field && <SurfaceFieldControl disabled={false} field={{ ...field, requiredHint: true }} operation="update" />}
|
|
285
|
+
</Form>
|
|
286
|
+
</Modal>
|
|
287
|
+
<Modal
|
|
288
|
+
cancelText="取消"
|
|
289
|
+
okButtonProps={{ danger: true }}
|
|
290
|
+
okText="确认删除"
|
|
291
|
+
open={deleteOpen}
|
|
292
|
+
title={`删除 ${rows.length} 条${name}`}
|
|
293
|
+
confirmLoading={submitting}
|
|
294
|
+
onCancel={() => !submitting && setDeleteOpen(false)}
|
|
295
|
+
onOk={() => void submitDelete()}
|
|
296
|
+
>
|
|
297
|
+
<Alert description="任意一条记录发生权限或版本冲突时,本次删除将全部回滚。" message="删除后不可恢复" showIcon type="warning" />
|
|
298
|
+
</Modal>
|
|
299
|
+
</>
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function normalizeValue(field: SurfaceField, value: unknown) {
|
|
304
|
+
if (field.widget !== 'date' && field.widget !== 'datetime') return value;
|
|
305
|
+
if (!value) return value;
|
|
306
|
+
return field.widget === 'datetime' ? dayjs(value as never).toISOString() : dayjs(value as never).format('YYYY-MM-DD');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function sameValue(left: unknown, right: unknown) {
|
|
310
|
+
if (left === right) return true;
|
|
311
|
+
if (left === undefined || right === undefined) return false;
|
|
312
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function transactionFailureMessage(error: unknown, fallback: string) {
|
|
316
|
+
const index = Number((error as any)?.data?.operationIndex);
|
|
317
|
+
const message = error instanceof Error ? error.message : fallback;
|
|
318
|
+
return Number.isSafeInteger(index) && index >= 0 ? `第 ${index + 1} 条操作失败:${message}` : message;
|
|
319
|
+
}
|
|
@@ -4,12 +4,10 @@ import {
|
|
|
4
4
|
ExportOutlined,
|
|
5
5
|
EyeOutlined,
|
|
6
6
|
HistoryOutlined,
|
|
7
|
-
ImportOutlined,
|
|
8
7
|
PlusOutlined,
|
|
9
8
|
} from '@ant-design/icons';
|
|
10
9
|
import {
|
|
11
10
|
Alert,
|
|
12
|
-
App,
|
|
13
11
|
Button,
|
|
14
12
|
Card,
|
|
15
13
|
Result,
|
|
@@ -47,17 +45,10 @@ export function ResourceListPage({
|
|
|
47
45
|
resource,
|
|
48
46
|
surface,
|
|
49
47
|
title,
|
|
50
|
-
description,
|
|
51
48
|
readCapability,
|
|
52
|
-
createCapability,
|
|
53
|
-
createPath,
|
|
54
|
-
onExport,
|
|
55
|
-
importDisabledReason = '导入能力将在批量事务合同落地后启用',
|
|
56
49
|
children,
|
|
57
50
|
}: ResourceListPageProps) {
|
|
58
|
-
const navigate = useNavigate();
|
|
59
51
|
const { hasCapability } = useRuntime();
|
|
60
|
-
const { message } = App.useApp();
|
|
61
52
|
if (!hasCapability(readCapability)) {
|
|
62
53
|
return (
|
|
63
54
|
<Shell>
|
|
@@ -65,7 +56,6 @@ export function ResourceListPage({
|
|
|
65
56
|
</Shell>
|
|
66
57
|
);
|
|
67
58
|
}
|
|
68
|
-
const canCreate = createCapability ? hasCapability(createCapability) : false;
|
|
69
59
|
return (
|
|
70
60
|
<Shell>
|
|
71
61
|
<Card
|
|
@@ -73,42 +63,7 @@ export function ResourceListPage({
|
|
|
73
63
|
data-resource={resource}
|
|
74
64
|
data-surface-layout={surface?.list ? 'standard' : undefined}
|
|
75
65
|
>
|
|
76
|
-
<
|
|
77
|
-
<div className="oxa-page-heading">
|
|
78
|
-
<div>
|
|
79
|
-
<h2 className="oxa-title">{title}</h2>
|
|
80
|
-
{description && <div className="oxa-muted">{description}</div>}
|
|
81
|
-
</div>
|
|
82
|
-
<Space>
|
|
83
|
-
<Button
|
|
84
|
-
disabled
|
|
85
|
-
icon={<ImportOutlined />}
|
|
86
|
-
title={importDisabledReason}
|
|
87
|
-
>
|
|
88
|
-
导入
|
|
89
|
-
</Button>
|
|
90
|
-
<Button
|
|
91
|
-
icon={<ExportOutlined />}
|
|
92
|
-
onClick={() => {
|
|
93
|
-
if (onExport) onExport();
|
|
94
|
-
else message.info('导出将使用当前 Data API 查询条件');
|
|
95
|
-
}}
|
|
96
|
-
>
|
|
97
|
-
导出
|
|
98
|
-
</Button>
|
|
99
|
-
{canCreate && createPath && (
|
|
100
|
-
<Button
|
|
101
|
-
icon={<PlusOutlined />}
|
|
102
|
-
onClick={() => navigate(createPath)}
|
|
103
|
-
type="primary"
|
|
104
|
-
>
|
|
105
|
-
新增
|
|
106
|
-
</Button>
|
|
107
|
-
)}
|
|
108
|
-
</Space>
|
|
109
|
-
</div>
|
|
110
|
-
{children}
|
|
111
|
-
</Space>
|
|
66
|
+
<div className="oxa-list-surface">{children}</div>
|
|
112
67
|
</Card>
|
|
113
68
|
</Shell>
|
|
114
69
|
);
|
|
@@ -289,6 +244,7 @@ interface ResourceDetailPageProps extends StandardResourcePageProps {
|
|
|
289
244
|
editCapability?: string;
|
|
290
245
|
editPath?: string;
|
|
291
246
|
auditTargetId?: string;
|
|
247
|
+
onAuditOpen?: () => void;
|
|
292
248
|
hero: ReactNode;
|
|
293
249
|
meta?: ReactNode;
|
|
294
250
|
children: ReactNode;
|
|
@@ -307,6 +263,7 @@ export function ResourceDetailPage({
|
|
|
307
263
|
editCapability,
|
|
308
264
|
editPath,
|
|
309
265
|
auditTargetId,
|
|
266
|
+
onAuditOpen,
|
|
310
267
|
hero,
|
|
311
268
|
meta,
|
|
312
269
|
children,
|
|
@@ -356,11 +313,14 @@ export function ResourceDetailPage({
|
|
|
356
313
|
{auditTargetId && (
|
|
357
314
|
<Button
|
|
358
315
|
icon={<HistoryOutlined />}
|
|
359
|
-
onClick={() =>
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
316
|
+
onClick={() => {
|
|
317
|
+
onAuditOpen?.();
|
|
318
|
+
window.requestAnimationFrame(() =>
|
|
319
|
+
document
|
|
320
|
+
.getElementById(auditTargetId)
|
|
321
|
+
?.scrollIntoView({ behavior: 'smooth', block: 'center' }),
|
|
322
|
+
);
|
|
323
|
+
}}
|
|
364
324
|
>
|
|
365
325
|
操作记录
|
|
366
326
|
</Button>
|
|
@@ -399,6 +359,7 @@ export function MobileResourceDetailPage({
|
|
|
399
359
|
editCapability,
|
|
400
360
|
editPath,
|
|
401
361
|
auditTargetId,
|
|
362
|
+
onAuditOpen,
|
|
402
363
|
hero,
|
|
403
364
|
meta,
|
|
404
365
|
children,
|
|
@@ -417,7 +378,7 @@ export function MobileResourceDetailPage({
|
|
|
417
378
|
{backLabel}
|
|
418
379
|
</Button>
|
|
419
380
|
<Space>
|
|
420
|
-
{auditTargetId && <Button icon={<HistoryOutlined />} onClick={() => document.getElementById(auditTargetId)?.scrollIntoView({ behavior: 'smooth', block: 'center' })}>操作记录</Button>}
|
|
381
|
+
{auditTargetId && <Button icon={<HistoryOutlined />} onClick={() => { onAuditOpen?.(); window.requestAnimationFrame(() => document.getElementById(auditTargetId)?.scrollIntoView({ behavior: 'smooth', block: 'center' })); }}>操作记录</Button>}
|
|
421
382
|
{editCapability && editPath && hasCapability(editCapability) && <Button icon={<EditOutlined />} onClick={() => navigate(editPath)} type="primary">编辑</Button>}
|
|
422
383
|
</Space>
|
|
423
384
|
</header>
|
|
@@ -460,7 +460,7 @@ function ManagedFileField({
|
|
|
460
460
|
refsRef.current = refs;
|
|
461
461
|
}, [refs]);
|
|
462
462
|
if (!onUpload) {
|
|
463
|
-
return <Alert type="info" showIcon
|
|
463
|
+
return <Alert type="info" showIcon title="该文件字段尚未配置上传能力" />;
|
|
464
464
|
}
|
|
465
465
|
if (mobile) {
|
|
466
466
|
const acceptValue = Array.isArray(accept) ? accept.join(',') : accept;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import type { DataResourceSurface, DataTransactionOperation } from 'openxiangda-contracts/browser';
|
|
2
|
+
|
|
3
|
+
const MAX_IMPORT_BYTES = 5 * 1024 * 1024;
|
|
4
|
+
const MAX_IMPORT_ROWS = 100;
|
|
5
|
+
|
|
6
|
+
export interface ResourceImportRow {
|
|
7
|
+
rowNumber: number;
|
|
8
|
+
data: Record<string, unknown>;
|
|
9
|
+
errors: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ResourceImportPreview {
|
|
13
|
+
fileName: string;
|
|
14
|
+
rows: ResourceImportRow[];
|
|
15
|
+
errors: string[];
|
|
16
|
+
operations: DataTransactionOperation[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function parseResourceImportFile(
|
|
20
|
+
file: File,
|
|
21
|
+
resourceCode: string,
|
|
22
|
+
surface: DataResourceSurface,
|
|
23
|
+
writableFieldCodes: string[]
|
|
24
|
+
): Promise<ResourceImportPreview> {
|
|
25
|
+
if (file.size > MAX_IMPORT_BYTES) {
|
|
26
|
+
return failure(file.name, '导入文件不能超过 5MB');
|
|
27
|
+
}
|
|
28
|
+
if (!/\.(?:csv|xls|xlsx)$/i.test(file.name)) {
|
|
29
|
+
return failure(file.name, '仅支持 CSV、XLS 或 XLSX 文件');
|
|
30
|
+
}
|
|
31
|
+
const XLSX = await import('xlsx');
|
|
32
|
+
const workbook = XLSX.read(await file.arrayBuffer(), {
|
|
33
|
+
cellDates: true,
|
|
34
|
+
type: 'array',
|
|
35
|
+
});
|
|
36
|
+
const sheetName = workbook.SheetNames[0];
|
|
37
|
+
if (!sheetName) return failure(file.name, '文件中没有可读取的工作表');
|
|
38
|
+
const sheet = workbook.Sheets[sheetName];
|
|
39
|
+
if (!sheet) return failure(file.name, '文件中的首个工作表不可读取');
|
|
40
|
+
const matrix = XLSX.utils.sheet_to_json<unknown[]>(sheet, {
|
|
41
|
+
defval: '',
|
|
42
|
+
header: 1,
|
|
43
|
+
raw: true,
|
|
44
|
+
});
|
|
45
|
+
return parseResourceImportMatrix(file.name, matrix, resourceCode, surface, writableFieldCodes);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function parseResourceImportMatrix(
|
|
49
|
+
fileName: string,
|
|
50
|
+
matrix: unknown[][],
|
|
51
|
+
resourceCode: string,
|
|
52
|
+
surface: DataResourceSurface,
|
|
53
|
+
writableFieldCodes: string[]
|
|
54
|
+
): ResourceImportPreview {
|
|
55
|
+
if (!matrix.length) return failure(fileName, '导入文件为空');
|
|
56
|
+
const headerRow = matrix[0];
|
|
57
|
+
if (!headerRow) return failure(fileName, '导入文件缺少表头');
|
|
58
|
+
const writable = new Set(writableFieldCodes);
|
|
59
|
+
const fieldEntries = Object.entries(surface.fields).filter(([, field]) => !field.system);
|
|
60
|
+
const aliases = new Map<string, string[]>();
|
|
61
|
+
for (const [code, field] of fieldEntries) {
|
|
62
|
+
for (const alias of [code, field.label]) {
|
|
63
|
+
const key = String(alias || '').trim();
|
|
64
|
+
if (!key) continue;
|
|
65
|
+
aliases.set(key, [...(aliases.get(key) || []), code]);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const headers = headerRow.map((value) => String(value ?? '').trim());
|
|
69
|
+
const errors: string[] = [];
|
|
70
|
+
const mapped = headers.map((header, index) => {
|
|
71
|
+
if (!header) return null;
|
|
72
|
+
const matches = [...new Set(aliases.get(header) || [])];
|
|
73
|
+
if (matches.length === 0) {
|
|
74
|
+
errors.push(`第 ${index + 1} 列“${header}”不是已声明字段`);
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
if (matches.length > 1) {
|
|
78
|
+
errors.push(`第 ${index + 1} 列“${header}”对应多个字段,请改用字段代码`);
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
const code = matches[0];
|
|
82
|
+
if (!code) return null;
|
|
83
|
+
const field = surface.fields[code];
|
|
84
|
+
if (!field) return null;
|
|
85
|
+
if (field.widget === 'file') {
|
|
86
|
+
errors.push(`文件字段“${field.label}”必须通过平台上传组件录入`);
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
if (!writable.has(code)) {
|
|
90
|
+
errors.push(`字段“${field.label}”不可在新增时写入`);
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
return code;
|
|
94
|
+
});
|
|
95
|
+
const duplicateCodes = mapped.filter((code, index): code is string => Boolean(code) && mapped.indexOf(code) !== index);
|
|
96
|
+
for (const code of [...new Set(duplicateCodes)]) {
|
|
97
|
+
const field = surface.fields[code];
|
|
98
|
+
if (field) errors.push(`字段“${field.label}”在表头中重复出现`);
|
|
99
|
+
}
|
|
100
|
+
const sourceRows = matrix
|
|
101
|
+
.slice(1)
|
|
102
|
+
.filter((row) => row.some((value) => value !== undefined && value !== null && String(value).trim() !== ''));
|
|
103
|
+
headers.forEach((header, index) => {
|
|
104
|
+
if (!header && sourceRows.some((row) => row[index] !== undefined && row[index] !== null && String(row[index]).trim() !== '')) {
|
|
105
|
+
errors.push(`第 ${index + 1} 列存在数据但缺少表头`);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
if (!sourceRows.length) errors.push('导入文件没有数据行');
|
|
109
|
+
if (sourceRows.length > MAX_IMPORT_ROWS) {
|
|
110
|
+
errors.push(`单次最多导入 ${MAX_IMPORT_ROWS} 行,当前为 ${sourceRows.length} 行`);
|
|
111
|
+
}
|
|
112
|
+
const rows = sourceRows.slice(0, MAX_IMPORT_ROWS).map((source, rowIndex) => {
|
|
113
|
+
const data: Record<string, unknown> = {};
|
|
114
|
+
const rowErrors: string[] = [];
|
|
115
|
+
mapped.forEach((code, columnIndex) => {
|
|
116
|
+
if (!code) return;
|
|
117
|
+
const field = surface.fields[code];
|
|
118
|
+
if (!field) return;
|
|
119
|
+
try {
|
|
120
|
+
const value = importValue(field, source[columnIndex]);
|
|
121
|
+
if (value !== undefined) data[code] = value;
|
|
122
|
+
} catch (error) {
|
|
123
|
+
rowErrors.push(`${field.label}:${error instanceof Error ? error.message : String(error)}`);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
for (const [code, field] of fieldEntries) {
|
|
127
|
+
if (writable.has(code) && field.requiredHint && (data[code] === undefined || data[code] === null || data[code] === '')) {
|
|
128
|
+
rowErrors.push(`${field.label}为必填项`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return { rowNumber: rowIndex + 2, data, errors: rowErrors };
|
|
132
|
+
});
|
|
133
|
+
const rowErrors = rows.flatMap((row) => row.errors.map((error) => `第 ${row.rowNumber} 行:${error}`));
|
|
134
|
+
return {
|
|
135
|
+
fileName,
|
|
136
|
+
rows,
|
|
137
|
+
errors: [...errors, ...rowErrors],
|
|
138
|
+
operations:
|
|
139
|
+
errors.length || rowErrors.length
|
|
140
|
+
? []
|
|
141
|
+
: rows.map((row) => ({
|
|
142
|
+
operation: 'create' as const,
|
|
143
|
+
resourceCode,
|
|
144
|
+
data: row.data,
|
|
145
|
+
})),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function importValue(field: DataResourceSurface['fields'][string], input: unknown): unknown {
|
|
150
|
+
if (input === undefined || input === null || input === '') return undefined;
|
|
151
|
+
if (field.widget === 'number' || field.widget === 'money' || field.widget === 'percent') {
|
|
152
|
+
const value = Number(input);
|
|
153
|
+
if (!Number.isFinite(value)) throw new Error('必须是数字');
|
|
154
|
+
return value;
|
|
155
|
+
}
|
|
156
|
+
if (field.widget === 'boolean') {
|
|
157
|
+
if (input === true || input === 1 || /^(?:是|true|yes|1)$/i.test(String(input).trim())) return true;
|
|
158
|
+
if (input === false || input === 0 || /^(?:否|false|no|0)$/i.test(String(input).trim())) return false;
|
|
159
|
+
throw new Error('请填写是或否');
|
|
160
|
+
}
|
|
161
|
+
if (field.widget === 'date' || field.widget === 'datetime') {
|
|
162
|
+
const text = String(input).trim();
|
|
163
|
+
if (field.widget === 'date' && /^\d{4}-\d{2}-\d{2}$/.test(text)) {
|
|
164
|
+
const dateParts = text.split('-');
|
|
165
|
+
const year = Number(dateParts[0]);
|
|
166
|
+
const month = Number(dateParts[1]);
|
|
167
|
+
const day = Number(dateParts[2]);
|
|
168
|
+
const exact = new Date(Date.UTC(year, month - 1, day));
|
|
169
|
+
if (exact.getUTCFullYear() !== year || exact.getUTCMonth() !== month - 1 || exact.getUTCDate() !== day) {
|
|
170
|
+
throw new Error('日期格式无效');
|
|
171
|
+
}
|
|
172
|
+
return text;
|
|
173
|
+
}
|
|
174
|
+
const date = input instanceof Date ? input : new Date(String(input));
|
|
175
|
+
if (Number.isNaN(date.getTime())) throw new Error('日期格式无效');
|
|
176
|
+
return field.widget === 'date'
|
|
177
|
+
? `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
|
178
|
+
: date.toISOString();
|
|
179
|
+
}
|
|
180
|
+
if (field.widget === 'json') {
|
|
181
|
+
if (typeof input === 'object') return input;
|
|
182
|
+
try {
|
|
183
|
+
return JSON.parse(String(input));
|
|
184
|
+
} catch {
|
|
185
|
+
throw new Error('必须是有效 JSON');
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const multiple = field.widget === 'multi' || field.multiple;
|
|
189
|
+
const values = multiple
|
|
190
|
+
? (Array.isArray(input) ? input : String(input).split(/[、,,;;\n]+/)).map((value) => String(value).trim()).filter(Boolean)
|
|
191
|
+
: [String(input).trim()];
|
|
192
|
+
if (field.options?.length) {
|
|
193
|
+
const resolved = values.map((value) => {
|
|
194
|
+
const option = field.options?.find((item) => item.value === value || item.label === value);
|
|
195
|
+
if (!option) throw new Error(`“${value}”不在可选项中`);
|
|
196
|
+
return option.value;
|
|
197
|
+
});
|
|
198
|
+
return multiple ? resolved : resolved[0];
|
|
199
|
+
}
|
|
200
|
+
return multiple ? values : values[0];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function failure(fileName: string, message: string): ResourceImportPreview {
|
|
204
|
+
return { fileName, rows: [], errors: [message], operations: [] };
|
|
205
|
+
}
|