@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.
@@ -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
- {Object.entries(Properties).map(([propertyName, propertySchema]) => {
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;