@alicloud/appflow-chat 0.0.1-beta.2 → 0.0.1-beta.4
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/dist/appflow-chat.cjs.js +252 -133
- package/dist/appflow-chat.esm.js +11261 -10184
- package/dist/types/index.d.ts +86 -51
- package/package.json +1 -1
- package/src/components/HumanVerify/CustomParamsRenderer/ArrayField.tsx +162 -7
- package/src/components/HumanVerify/CustomParamsRenderer/EnumField.tsx +199 -0
- package/src/components/HumanVerify/CustomParamsRenderer/FieldRenderer.tsx +152 -8
- package/src/components/HumanVerify/CustomParamsRenderer/FileField.tsx +509 -0
- package/src/components/HumanVerify/CustomParamsRenderer/ObjectField.tsx +10 -3
- package/src/components/HumanVerify/CustomParamsRenderer/TimeField.tsx +104 -0
- package/src/components/HumanVerify/CustomParamsRenderer/index.tsx +16 -2
- package/src/components/HumanVerify/CustomParamsRenderer/types.ts +231 -5
- package/src/components/HumanVerify/HistoryCard.tsx +39 -21
- package/src/components/HumanVerify/HumanVerify.tsx +23 -45
- package/src/components/MessageBubble.tsx +4 -10
- package/src/components/RichMessageBubble.tsx +2 -2
- package/src/index.ts +1 -0
- package/src/markdown/components/Mermaid.tsx +265 -0
- package/src/markdown/index.tsx +7 -0
- package/src/utils/loadMermaid.ts +40 -0
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
import React, { useCallback, useState, useMemo, useEffect } from 'react';
|
|
2
|
+
import { Upload, Button, message } from 'antd';
|
|
3
|
+
import { UploadOutlined, PlusOutlined, LoadingOutlined } from '@ant-design/icons';
|
|
4
|
+
import type { UploadFile, UploadChangeParam } from 'antd/es/upload/interface';
|
|
5
|
+
import type { RcFile } from 'antd/es/upload';
|
|
6
|
+
import {
|
|
7
|
+
FileFieldProps,
|
|
8
|
+
FileSubType,
|
|
9
|
+
UploadTokenResponse,
|
|
10
|
+
UploadFileResponse
|
|
11
|
+
} from './types';
|
|
12
|
+
import styled from 'styled-components';
|
|
13
|
+
|
|
14
|
+
// ==================== Styled Components ====================
|
|
15
|
+
|
|
16
|
+
// 文件上传容器
|
|
17
|
+
const FileFieldContainer = styled.div`
|
|
18
|
+
width: 100%;
|
|
19
|
+
|
|
20
|
+
.ant-upload-wrapper {
|
|
21
|
+
width: 100%;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.ant-upload-list-item {
|
|
25
|
+
margin-top: 8px;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.ant-upload-list-picture-card {
|
|
29
|
+
.ant-upload-list-item-container {
|
|
30
|
+
width: 104px;
|
|
31
|
+
height: 104px;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.ant-upload-select-picture-card {
|
|
36
|
+
width: 104px;
|
|
37
|
+
height: 104px;
|
|
38
|
+
margin: 0;
|
|
39
|
+
background-color: #fafafa;
|
|
40
|
+
border: 1px dashed #d9d9d9;
|
|
41
|
+
border-radius: 8px;
|
|
42
|
+
cursor: pointer;
|
|
43
|
+
transition: border-color 0.3s;
|
|
44
|
+
|
|
45
|
+
&:hover {
|
|
46
|
+
border-color: #1890ff;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
`;
|
|
50
|
+
|
|
51
|
+
// 文件上传提示
|
|
52
|
+
const FileHint = styled.div`
|
|
53
|
+
font-size: 12px;
|
|
54
|
+
color: #8f959e;
|
|
55
|
+
margin-top: 8px;
|
|
56
|
+
`;
|
|
57
|
+
|
|
58
|
+
// 图片类型列表
|
|
59
|
+
const IMAGE_TYPES: FileSubType[] = ['jpg', 'png', 'svg'];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 根据单个文件子类型获取 accept 属性
|
|
63
|
+
*/
|
|
64
|
+
const getAcceptBySingleSubType = (subType: FileSubType): string => {
|
|
65
|
+
switch (subType) {
|
|
66
|
+
case 'jpg':
|
|
67
|
+
return '.jpg,.jpeg,image/jpeg';
|
|
68
|
+
case 'png':
|
|
69
|
+
return '.png,image/png';
|
|
70
|
+
case 'svg':
|
|
71
|
+
return '.svg,image/svg+xml';
|
|
72
|
+
case 'doc':
|
|
73
|
+
return '.doc,.docx,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
|
74
|
+
case 'ppt':
|
|
75
|
+
return '.ppt,.pptx,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation';
|
|
76
|
+
case 'excel':
|
|
77
|
+
return '.xls,.xlsx,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
|
78
|
+
case 'txt':
|
|
79
|
+
return '.txt,text/plain';
|
|
80
|
+
case 'markdown':
|
|
81
|
+
return '.md,.markdown,text/markdown';
|
|
82
|
+
case 'zip':
|
|
83
|
+
return '.zip,.rar,.7z,.tar,.gz,.bz2,application/zip,application/x-rar-compressed,application/x-7z-compressed';
|
|
84
|
+
case 'default':
|
|
85
|
+
default:
|
|
86
|
+
return '*';
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 根据文件子类型数组获取合并的 accept 属性
|
|
92
|
+
*/
|
|
93
|
+
const getAcceptBySubTypes = (subTypes?: FileSubType[]): string => {
|
|
94
|
+
if (!subTypes || subTypes.length === 0) {
|
|
95
|
+
return '*';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 如果包含 default,则接受所有类型
|
|
99
|
+
if (subTypes.includes('default')) {
|
|
100
|
+
return '*';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 合并所有类型的 accept 值,去重
|
|
104
|
+
const acceptSet = new Set<string>();
|
|
105
|
+
subTypes.forEach(subType => {
|
|
106
|
+
const accept = getAcceptBySingleSubType(subType);
|
|
107
|
+
if (accept !== '*') {
|
|
108
|
+
accept.split(',').forEach(item => acceptSet.add(item.trim()));
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return acceptSet.size > 0 ? Array.from(acceptSet).join(',') : '*';
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 根据文件子类型数组获取上传提示文本
|
|
117
|
+
*/
|
|
118
|
+
const getUploadHintBySubTypes = (subTypes?: FileSubType[]): string => {
|
|
119
|
+
if (!subTypes || subTypes.length === 0) {
|
|
120
|
+
return '支持所有文件格式';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (subTypes.includes('default')) {
|
|
124
|
+
return '支持所有文件格式';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const hintMap: Record<FileSubType, string> = {
|
|
128
|
+
'jpg': 'JPG',
|
|
129
|
+
'png': 'PNG',
|
|
130
|
+
'svg': 'SVG',
|
|
131
|
+
'doc': 'DOC/DOCX',
|
|
132
|
+
'ppt': 'PPT/PPTX',
|
|
133
|
+
'excel': 'XLS/XLSX',
|
|
134
|
+
'txt': 'TXT',
|
|
135
|
+
'markdown': 'Markdown',
|
|
136
|
+
'zip': 'ZIP/RAR/7Z',
|
|
137
|
+
'default': '所有格式',
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const hints = subTypes.map(subType => hintMap[subType] || subType).filter(Boolean);
|
|
141
|
+
return hints.length > 0 ? `支持 ${hints.join('、')} 格式` : '支持所有文件格式';
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 判断是否包含图片类型
|
|
146
|
+
*/
|
|
147
|
+
const hasImageType = (subTypes?: FileSubType[]): boolean => {
|
|
148
|
+
if (!subTypes || subTypes.length === 0) {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
return subTypes.some(subType => IMAGE_TYPES.includes(subType));
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 文件字段组件
|
|
156
|
+
* 根据 SubType 渲染不同的文件上传器
|
|
157
|
+
* 优先从 AssociationPropertyMetadata.SubType 读取,兼容旧的 FileSubType 字段
|
|
158
|
+
*/
|
|
159
|
+
export const FileField: React.FC<FileFieldProps> = ({
|
|
160
|
+
schema,
|
|
161
|
+
value,
|
|
162
|
+
onChange,
|
|
163
|
+
disabled = false,
|
|
164
|
+
uploadSender,
|
|
165
|
+
fileUploader,
|
|
166
|
+
}) => {
|
|
167
|
+
// 优先从 AssociationPropertyMetadata.SubType 读取,兼容旧的 FileSubType 字段
|
|
168
|
+
const subTypes = useMemo((): FileSubType[] => {
|
|
169
|
+
const subTypeArray = schema.AssociationPropertyMetadata?.SubType;
|
|
170
|
+
if (Array.isArray(subTypeArray) && subTypeArray.length > 0) {
|
|
171
|
+
return subTypeArray as FileSubType[];
|
|
172
|
+
}
|
|
173
|
+
// 兼容旧的 FileSubType 字段(单个值转为数组)
|
|
174
|
+
if (schema.FileSubType) {
|
|
175
|
+
return [schema.FileSubType];
|
|
176
|
+
}
|
|
177
|
+
return [];
|
|
178
|
+
}, [schema]);
|
|
179
|
+
|
|
180
|
+
const accept = getAcceptBySubTypes(subTypes);
|
|
181
|
+
const hint = getUploadHintBySubTypes(subTypes);
|
|
182
|
+
const isImage = hasImageType(subTypes);
|
|
183
|
+
|
|
184
|
+
// 上传状态
|
|
185
|
+
const [uploading, setUploading] = useState(false);
|
|
186
|
+
|
|
187
|
+
// 将值转换为 UploadFile 数组
|
|
188
|
+
const getFileList = (): UploadFile[] => {
|
|
189
|
+
if (!value) return [];
|
|
190
|
+
if (Array.isArray(value)) {
|
|
191
|
+
return value.map((file: any, index: number) => ({
|
|
192
|
+
uid: file.uid || `${index}`,
|
|
193
|
+
name: file.name || `文件${index + 1}`,
|
|
194
|
+
status: 'done' as const,
|
|
195
|
+
url: file.url,
|
|
196
|
+
...file,
|
|
197
|
+
}));
|
|
198
|
+
}
|
|
199
|
+
if (typeof value === 'object') {
|
|
200
|
+
return [{
|
|
201
|
+
uid: value.uid || '0',
|
|
202
|
+
name: value.name || '文件',
|
|
203
|
+
status: 'done' as const,
|
|
204
|
+
url: value.url,
|
|
205
|
+
...value,
|
|
206
|
+
}];
|
|
207
|
+
}
|
|
208
|
+
return [];
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const [fileList, setFileList] = useState<UploadFile[]>(getFileList());
|
|
212
|
+
|
|
213
|
+
// 当外部 value 变化时,同步更新内部 fileList
|
|
214
|
+
// 这对于数组项删除等场景非常重要,确保组件状态与外部数据保持同步
|
|
215
|
+
useEffect(() => {
|
|
216
|
+
const newFileList = getFileList();
|
|
217
|
+
// 比较 URL 来判断是否需要更新,避免不必要的重渲染
|
|
218
|
+
const currentUrls = fileList.map(f => f.url).sort().join(',');
|
|
219
|
+
const newUrls = newFileList.map(f => f.url).sort().join(',');
|
|
220
|
+
if (currentUrls !== newUrls) {
|
|
221
|
+
setFileList(newFileList);
|
|
222
|
+
}
|
|
223
|
+
}, [value]);
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 获取上传凭证
|
|
227
|
+
*/
|
|
228
|
+
const getUploadToken = useCallback(async (fileName: string): Promise<UploadTokenResponse | null> => {
|
|
229
|
+
if (!uploadSender) {
|
|
230
|
+
message.error('上传功能未配置');
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
const response = await uploadSender({
|
|
236
|
+
eventType: 'uploadToken',
|
|
237
|
+
fileName,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
if (!response) {
|
|
241
|
+
message.error('获取上传凭证失败');
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 解析外层响应
|
|
246
|
+
const parsedResponse = JSON.parse(response);
|
|
247
|
+
|
|
248
|
+
// 解析 content 字段中的上传凭证
|
|
249
|
+
if (parsedResponse.content) {
|
|
250
|
+
return JSON.parse(parsedResponse.content) as UploadTokenResponse;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// 兼容直接返回 uploadUrl 和 downloadUrl 的情况
|
|
254
|
+
if (parsedResponse.uploadUrl && parsedResponse.downloadUrl) {
|
|
255
|
+
return parsedResponse as UploadTokenResponse;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
message.error('获取上传凭证失败');
|
|
259
|
+
return null;
|
|
260
|
+
} catch (error) {
|
|
261
|
+
console.error('获取上传凭证失败:', error);
|
|
262
|
+
message.error('获取上传凭证失败');
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
}, [uploadSender]);
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* 获取文件 ID(文件上传专用)
|
|
269
|
+
*/
|
|
270
|
+
const getFileId = useCallback(async (fileName: string, downloadUrl: string): Promise<UploadFileResponse | null> => {
|
|
271
|
+
if (!uploadSender) {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
const response = await uploadSender({
|
|
277
|
+
eventType: 'uploadFile',
|
|
278
|
+
fileName,
|
|
279
|
+
content: downloadUrl,
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
if (!response) {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// 解析外层响应
|
|
287
|
+
const parsedResponse = JSON.parse(response);
|
|
288
|
+
|
|
289
|
+
// 解析 content 字段中的文件信息
|
|
290
|
+
if (parsedResponse.content) {
|
|
291
|
+
return JSON.parse(parsedResponse.content) as UploadFileResponse;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// 兼容直接返回 fileId 的情况
|
|
295
|
+
if (parsedResponse.fileId) {
|
|
296
|
+
return parsedResponse as UploadFileResponse;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return null;
|
|
300
|
+
} catch (error) {
|
|
301
|
+
console.error('获取文件ID失败:', error);
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
}, [uploadSender]);
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* 图片上传处理
|
|
308
|
+
* 流程:获取预签名URL -> 上传到OSS -> 返回下载URL
|
|
309
|
+
*/
|
|
310
|
+
const handleImageUpload = useCallback(async (file: RcFile): Promise<any> => {
|
|
311
|
+
// 1. 获取预签名 URL
|
|
312
|
+
const tokenResponse = await getUploadToken(file.name);
|
|
313
|
+
if (!tokenResponse) {
|
|
314
|
+
throw new Error('获取上传凭证失败');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// 2. 上传文件到 OSS
|
|
318
|
+
if (!fileUploader) {
|
|
319
|
+
throw new Error('文件上传方法未配置');
|
|
320
|
+
}
|
|
321
|
+
const blob = new Blob([file]);
|
|
322
|
+
await fileUploader(blob, tokenResponse.uploadUrl);
|
|
323
|
+
|
|
324
|
+
// 3. 返回文件信息
|
|
325
|
+
return {
|
|
326
|
+
uid: Date.now().toString(),
|
|
327
|
+
name: file.name,
|
|
328
|
+
url: tokenResponse.downloadUrl,
|
|
329
|
+
type: file.type,
|
|
330
|
+
size: file.size,
|
|
331
|
+
};
|
|
332
|
+
}, [getUploadToken, fileUploader]);
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* 文件上传处理
|
|
336
|
+
* 流程:获取预签名URL -> 上传到OSS -> 获取fileId -> 返回完整信息
|
|
337
|
+
*/
|
|
338
|
+
const handleFileUpload = useCallback(async (file: RcFile): Promise<any> => {
|
|
339
|
+
// 1. 获取预签名 URL
|
|
340
|
+
const tokenResponse = await getUploadToken(file.name);
|
|
341
|
+
if (!tokenResponse) {
|
|
342
|
+
throw new Error('获取上传凭证失败');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// 2. 上传文件到 OSS
|
|
346
|
+
if (!fileUploader) {
|
|
347
|
+
throw new Error('文件上传方法未配置');
|
|
348
|
+
}
|
|
349
|
+
const blob = new Blob([file]);
|
|
350
|
+
await fileUploader(blob, tokenResponse.uploadUrl);
|
|
351
|
+
|
|
352
|
+
// 3. 获取 fileId
|
|
353
|
+
const fileResponse = await getFileId(file.name, tokenResponse.downloadUrl);
|
|
354
|
+
|
|
355
|
+
// 4. 返回完整文件信息
|
|
356
|
+
return {
|
|
357
|
+
uid: Date.now().toString(),
|
|
358
|
+
name: file.name,
|
|
359
|
+
url: tokenResponse.downloadUrl,
|
|
360
|
+
fileId: fileResponse?.fileId,
|
|
361
|
+
type: file.type,
|
|
362
|
+
size: file.size,
|
|
363
|
+
fileType: file.name.split('.').pop(),
|
|
364
|
+
};
|
|
365
|
+
}, [getUploadToken, fileUploader, getFileId]);
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* 自定义上传逻辑
|
|
369
|
+
*/
|
|
370
|
+
const customRequest = useCallback(
|
|
371
|
+
async (options: any) => {
|
|
372
|
+
const { file, onSuccess, onError } = options;
|
|
373
|
+
|
|
374
|
+
// 检查上传配置
|
|
375
|
+
if (!uploadSender || !fileUploader) {
|
|
376
|
+
// 如果没有配置上传方法,使用本地预览(降级处理)
|
|
377
|
+
try {
|
|
378
|
+
const url = URL.createObjectURL(file as Blob);
|
|
379
|
+
onSuccess?.({ url }, new XMLHttpRequest());
|
|
380
|
+
} catch (error) {
|
|
381
|
+
onError?.(error as Error);
|
|
382
|
+
message.error('文件上传失败');
|
|
383
|
+
}
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
setUploading(true);
|
|
388
|
+
|
|
389
|
+
try {
|
|
390
|
+
let result;
|
|
391
|
+
if (isImage) {
|
|
392
|
+
// 图片上传
|
|
393
|
+
result = await handleImageUpload(file as RcFile);
|
|
394
|
+
} else {
|
|
395
|
+
// 文件上传(需要获取 fileId)
|
|
396
|
+
result = await handleFileUpload(file as RcFile);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
onSuccess?.(result, new XMLHttpRequest());
|
|
400
|
+
} catch (error) {
|
|
401
|
+
onError?.(error as Error);
|
|
402
|
+
message.error('文件上传失败');
|
|
403
|
+
} finally {
|
|
404
|
+
setUploading(false);
|
|
405
|
+
}
|
|
406
|
+
},
|
|
407
|
+
[uploadSender, fileUploader, isImage, handleImageUpload, handleFileUpload]
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
// 处理文件变化
|
|
411
|
+
const handleChange = useCallback(
|
|
412
|
+
(info: UploadChangeParam<UploadFile>) => {
|
|
413
|
+
const { fileList: newFileList } = info;
|
|
414
|
+
setFileList(newFileList);
|
|
415
|
+
|
|
416
|
+
// 转换为简化的文件信息
|
|
417
|
+
const files = newFileList
|
|
418
|
+
.filter((file: UploadFile) => file.status === 'done')
|
|
419
|
+
.map((file: UploadFile) => {
|
|
420
|
+
const response = file.response as any;
|
|
421
|
+
return {
|
|
422
|
+
name: file.name,
|
|
423
|
+
url: response?.url || file.url,
|
|
424
|
+
// fileId: response?.fileId,
|
|
425
|
+
// type: file.type || response?.type,
|
|
426
|
+
// size: file.size || response?.size,
|
|
427
|
+
// fileType: response?.fileType,
|
|
428
|
+
// status: file.status,
|
|
429
|
+
};
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
onChange?.(files.length === 1 ? files[0] : files.length > 0 ? files : undefined);
|
|
433
|
+
},
|
|
434
|
+
[onChange]
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
// 上传前的校验
|
|
438
|
+
const beforeUpload = useCallback(
|
|
439
|
+
(file: File) => {
|
|
440
|
+
// 文件大小限制(默认 10MB)
|
|
441
|
+
const maxSize = 10 * 1024 * 1024;
|
|
442
|
+
if (file.size > maxSize) {
|
|
443
|
+
message.error('文件大小不能超过 10MB');
|
|
444
|
+
return Upload.LIST_IGNORE;
|
|
445
|
+
}
|
|
446
|
+
return true;
|
|
447
|
+
},
|
|
448
|
+
[]
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
// 上传按钮的加载指示器
|
|
452
|
+
const uploadButton = (
|
|
453
|
+
<div>
|
|
454
|
+
{uploading ? <LoadingOutlined /> : <PlusOutlined />}
|
|
455
|
+
<div style={{ marginTop: 8 }}>{uploading ? '上传中' : '上传'}</div>
|
|
456
|
+
</div>
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
// 图片类型使用卡片式上传
|
|
460
|
+
if (isImage) {
|
|
461
|
+
return (
|
|
462
|
+
<FileFieldContainer>
|
|
463
|
+
<Upload
|
|
464
|
+
listType="picture-card"
|
|
465
|
+
fileList={fileList}
|
|
466
|
+
onChange={handleChange}
|
|
467
|
+
customRequest={customRequest}
|
|
468
|
+
beforeUpload={beforeUpload}
|
|
469
|
+
accept={accept}
|
|
470
|
+
disabled={disabled || uploading}
|
|
471
|
+
maxCount={1}
|
|
472
|
+
showUploadList={{
|
|
473
|
+
showPreviewIcon: false,
|
|
474
|
+
showRemoveIcon: true,
|
|
475
|
+
}}
|
|
476
|
+
>
|
|
477
|
+
{fileList.length < 1 && uploadButton}
|
|
478
|
+
</Upload>
|
|
479
|
+
<FileHint>{hint}</FileHint>
|
|
480
|
+
</FileFieldContainer>
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// 其他类型使用按钮式上传
|
|
485
|
+
return (
|
|
486
|
+
<FileFieldContainer>
|
|
487
|
+
<Upload
|
|
488
|
+
fileList={fileList}
|
|
489
|
+
onChange={handleChange}
|
|
490
|
+
customRequest={customRequest}
|
|
491
|
+
beforeUpload={beforeUpload}
|
|
492
|
+
accept={accept}
|
|
493
|
+
disabled={disabled || uploading}
|
|
494
|
+
maxCount={1}
|
|
495
|
+
>
|
|
496
|
+
<Button
|
|
497
|
+
icon={uploading ? <LoadingOutlined /> : <UploadOutlined />}
|
|
498
|
+
disabled={disabled || uploading}
|
|
499
|
+
loading={uploading}
|
|
500
|
+
>
|
|
501
|
+
{uploading ? '上传中' : '选择文件'}
|
|
502
|
+
</Button>
|
|
503
|
+
</Upload>
|
|
504
|
+
<FileHint>{hint}</FileHint>
|
|
505
|
+
</FileFieldContainer>
|
|
506
|
+
);
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
export default FileField;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React, { useCallback } from 'react';
|
|
2
2
|
import styled from 'styled-components';
|
|
3
|
-
import { ObjectFieldProps } from './types';
|
|
3
|
+
import { ObjectFieldProps, sortPropertiesByOrder } from './types';
|
|
4
4
|
|
|
5
5
|
// 前向声明,避免循环依赖
|
|
6
6
|
const FieldRenderer = React.lazy(() => import('./FieldRenderer'));
|
|
@@ -66,6 +66,8 @@ export const ObjectField: React.FC<ObjectFieldProps> = ({
|
|
|
66
66
|
level = 0,
|
|
67
67
|
errors = {},
|
|
68
68
|
fieldPath = '',
|
|
69
|
+
uploadSender,
|
|
70
|
+
fileUploader,
|
|
69
71
|
}) => {
|
|
70
72
|
const { Title, Description, Properties, Required: RequiredFields = [] } = schema;
|
|
71
73
|
const displayTitle = Title || name;
|
|
@@ -88,6 +90,9 @@ export const ObjectField: React.FC<ObjectFieldProps> = ({
|
|
|
88
90
|
return null;
|
|
89
91
|
}
|
|
90
92
|
|
|
93
|
+
// 使用排序后的属性列表进行渲染
|
|
94
|
+
const sortedProperties = sortPropertiesByOrder(Properties);
|
|
95
|
+
|
|
91
96
|
return (
|
|
92
97
|
<ObjectFieldContainer>
|
|
93
98
|
<ObjectTitle>
|
|
@@ -100,7 +105,7 @@ export const ObjectField: React.FC<ObjectFieldProps> = ({
|
|
|
100
105
|
)}
|
|
101
106
|
|
|
102
107
|
<ObjectContent>
|
|
103
|
-
{
|
|
108
|
+
{sortedProperties.map(([propertyName, propertySchema]) => {
|
|
104
109
|
const isRequired = RequiredFields.includes(propertyName);
|
|
105
110
|
return (
|
|
106
111
|
<React.Suspense key={propertyName} fallback={<div>加载中...</div>}>
|
|
@@ -114,6 +119,8 @@ export const ObjectField: React.FC<ObjectFieldProps> = ({
|
|
|
114
119
|
level={level + 1}
|
|
115
120
|
errors={errors}
|
|
116
121
|
fieldPath={currentPath}
|
|
122
|
+
uploadSender={uploadSender}
|
|
123
|
+
fileUploader={fileUploader}
|
|
117
124
|
/>
|
|
118
125
|
</React.Suspense>
|
|
119
126
|
);
|
|
@@ -123,4 +130,4 @@ export const ObjectField: React.FC<ObjectFieldProps> = ({
|
|
|
123
130
|
);
|
|
124
131
|
};
|
|
125
132
|
|
|
126
|
-
export default ObjectField;
|
|
133
|
+
export default ObjectField;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import React, { useCallback, useMemo } from 'react';
|
|
2
|
+
import { DatePicker } from 'antd';
|
|
3
|
+
import dayjs, { Dayjs } from 'dayjs';
|
|
4
|
+
import { TimeFieldProps, TimeSubType } from './types';
|
|
5
|
+
import styled from 'styled-components';
|
|
6
|
+
|
|
7
|
+
// ==================== Styled Components ====================
|
|
8
|
+
|
|
9
|
+
// 时间选择器容器
|
|
10
|
+
const TimeFieldContainer = styled.div`
|
|
11
|
+
width: 100%;
|
|
12
|
+
|
|
13
|
+
.ant-picker {
|
|
14
|
+
width: 100%;
|
|
15
|
+
}
|
|
16
|
+
`;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 根据时间子类型获取日期格式
|
|
20
|
+
*/
|
|
21
|
+
const getDateFormat = (subType?: TimeSubType): string => {
|
|
22
|
+
switch (subType) {
|
|
23
|
+
case 'year-month':
|
|
24
|
+
return 'YYYY-MM';
|
|
25
|
+
case 'year-month-day':
|
|
26
|
+
return 'YYYY-MM-DD';
|
|
27
|
+
case 'datetime':
|
|
28
|
+
return 'YYYY-MM-DD HH:mm:ss';
|
|
29
|
+
default:
|
|
30
|
+
return 'YYYY-MM-DD';
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 根据时间子类型获取 picker 类型
|
|
36
|
+
*/
|
|
37
|
+
const getPickerType = (subType?: TimeSubType): 'date' | 'month' | undefined => {
|
|
38
|
+
switch (subType) {
|
|
39
|
+
case 'year-month':
|
|
40
|
+
return 'month';
|
|
41
|
+
case 'year-month-day':
|
|
42
|
+
case 'datetime':
|
|
43
|
+
default:
|
|
44
|
+
return 'date';
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 时间字段组件
|
|
50
|
+
* 根据 SubType 渲染不同的时间选择器
|
|
51
|
+
* 优先从 AssociationPropertyMetadata.SubType 读取,兼容旧的 TimeSubType 字段
|
|
52
|
+
*/
|
|
53
|
+
export const TimeField: React.FC<TimeFieldProps> = ({
|
|
54
|
+
schema,
|
|
55
|
+
value,
|
|
56
|
+
onChange,
|
|
57
|
+
disabled = false,
|
|
58
|
+
}) => {
|
|
59
|
+
// 优先从 AssociationPropertyMetadata.SubType 读取,取数组第一个元素
|
|
60
|
+
// 兼容旧的 TimeSubType 字段
|
|
61
|
+
const subType = useMemo((): TimeSubType | undefined => {
|
|
62
|
+
const subTypeArray = schema.AssociationPropertyMetadata?.SubType;
|
|
63
|
+
if (Array.isArray(subTypeArray) && subTypeArray.length > 0) {
|
|
64
|
+
return subTypeArray[0] as TimeSubType;
|
|
65
|
+
}
|
|
66
|
+
return schema.TimeSubType;
|
|
67
|
+
}, [schema]);
|
|
68
|
+
|
|
69
|
+
const format = getDateFormat(subType);
|
|
70
|
+
const picker = getPickerType(subType);
|
|
71
|
+
const showTime = subType === 'datetime';
|
|
72
|
+
|
|
73
|
+
// 处理值变化
|
|
74
|
+
const handleChange = useCallback(
|
|
75
|
+
(date: Dayjs | null) => {
|
|
76
|
+
if (date) {
|
|
77
|
+
onChange?.(date.format(format));
|
|
78
|
+
} else {
|
|
79
|
+
onChange?.(null);
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
[onChange, format]
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
// 将字符串值转换为 dayjs 对象
|
|
86
|
+
const dayjsValue = value ? dayjs(value, format) : null;
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<TimeFieldContainer>
|
|
90
|
+
<DatePicker
|
|
91
|
+
value={dayjsValue}
|
|
92
|
+
onChange={handleChange}
|
|
93
|
+
format={format}
|
|
94
|
+
picker={picker}
|
|
95
|
+
showTime={showTime ? { format: 'HH:mm:ss' } : false}
|
|
96
|
+
disabled={disabled}
|
|
97
|
+
style={{ width: '100%' }}
|
|
98
|
+
placeholder={`请选择`}
|
|
99
|
+
/>
|
|
100
|
+
</TimeFieldContainer>
|
|
101
|
+
);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export default TimeField;
|