feat: manually mirror opencoze's code from bytedance

Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
fanlv
2025-07-20 17:36:12 +08:00
commit 890153324f
14811 changed files with 1923430 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const MAX_IMAGE_SIZE = 1024 * 1024 * 5;
export const MAX_FILE_SIZE = 1024 * 1024 * 20;

View File

@@ -0,0 +1,36 @@
/* stylelint-disable no-descending-specificity */
.container {
&.hide-upload-area {
:global {
.semi-upload-drag-area {
display: none;
}
}
}
:global {
.semi-upload-drag-area {
.semi-upload-drag-area-sub-text {
word-break: break-all;
}
}
.semi-upload-file-card-preview-placeholder {
background-color: unset;
}
.semi-upload.has-error .semi-upload-drag-area {
border: 1px solid var(--Light-usage-danger---color-danger, #FF441E);
}
.semi-upload-file-card-info-main {
max-height: 100%;
}
}
.upload-error-text {
color: var(--Light-usage-danger---color-danger, #FF441E);
}
}

View File

@@ -0,0 +1,231 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import ClickAwayListener from 'react-click-away-listener';
import React, { useEffect, useRef } from 'react';
import { nanoid } from 'nanoid';
import classNames from 'classnames';
import { useUpdateEffect, useMemoizedFn } from 'ahooks';
import { I18n } from '@coze-arch/i18n';
import { Upload, Toast } from '@coze-arch/coze-design';
import { FileIcon, FileItemStatus } from '../file-icon';
import { typeSafeJSONParse } from '../../utils';
import { formatBytes } from './utils';
import { useUpload } from './use-upload';
import { type FileItem } from './types';
import styles from './file-upload.module.less';
export interface FileUploadProps {
value: string;
accept: string;
multiple: boolean;
disabled?: boolean;
fileType: 'object' | 'image';
validateStatus?: string;
onBlur?: () => void;
onFocus?: () => void;
onChange?: (v?: string) => void;
}
const TEST_RUN_FILE_NAME_KEY = 'x-wf-file_name';
const getFormatFileUrl = (curFile: FileItem) => {
const originUrl = curFile?.url ?? '';
const fileName = curFile?.name ?? '';
try {
const urlObj = new URL(originUrl);
const params = new URLSearchParams(urlObj.search);
if (params.has(TEST_RUN_FILE_NAME_KEY)) {
params.set(TEST_RUN_FILE_NAME_KEY, fileName);
} else {
params.append(TEST_RUN_FILE_NAME_KEY, fileName);
}
urlObj.search = params.toString();
return urlObj.toString();
} catch (e) {
return originUrl;
}
};
const getParsedFileInfo = (formatUrl: string) => {
const url = new URL(formatUrl);
const params = new URLSearchParams(url.search);
const fileName =
params.get(TEST_RUN_FILE_NAME_KEY) ?? I18n.t('plugin_file_unknown');
return {
url: formatUrl,
uid: nanoid(),
name: fileName,
};
};
const getInitialValue = (val: string, multiple: boolean): FileItem[] => {
if (multiple) {
const multipleVal = typeSafeJSONParse(val);
if (Array.isArray(multipleVal)) {
return multipleVal.map(url => getParsedFileInfo(url)) as FileItem[];
}
}
if (val) {
return [getParsedFileInfo(val)] as FileItem[];
}
return [];
};
export const FileUpload: React.FC<FileUploadProps> = props => {
const {
validateStatus,
value,
onChange,
onBlur,
onFocus,
accept,
multiple,
disabled,
fileType,
} = props;
const focusRef = useRef(false);
const maxFileCount = multiple ? 20 : 1;
const { upload, fileList, isUploading, deleteFile, setFileList } = useUpload({
multiple,
fileType,
accept,
maxFileCount,
});
const handleFocus = () => {
if (!focusRef.current) {
focusRef.current = true;
onFocus?.();
}
};
const handleBlur = () => {
if (focusRef.current) {
focusRef.current = false;
onBlur?.();
}
};
const handleClickAway = () => {
if (!isUploading) {
handleBlur();
}
};
const handleUpload = async file => {
const { fileInstance } = file;
await upload(fileInstance);
};
const getSubmitValue = useMemoizedFn((): string | undefined => {
let newVal: string | undefined;
if (multiple) {
const next = fileList
.filter(item => item.url)
.map(item => getFormatFileUrl(item));
newVal = next.length ? JSON.stringify(next) : undefined;
} else {
const singleFile = fileList?.[0];
newVal = singleFile ? getFormatFileUrl(singleFile) : undefined;
}
return newVal;
});
const handleChange = useMemoizedFn(val => onChange?.(val));
// 当fileList更新时触发onChange
useUpdateEffect(() => {
const newVal = getSubmitValue();
handleChange?.(newVal);
}, [fileList]);
// 当表单值更新时同步到fileList
useEffect(() => {
const val = getSubmitValue();
if (val !== value) {
setFileList(getInitialValue(value, multiple));
}
}, [value]);
const handleAcceptInvalid = () => {
Toast.error(
I18n.t('imageflow_upload_error_type', {
type: accept,
}),
);
};
const semiFileList: any[] = fileList.map(file => ({
name: file.name,
size: file.size !== undefined ? formatBytes(file.size) : '',
uid: file.uid || nanoid(),
status: file.status || FileItemStatus.Success,
url: file.uil,
validateMessage: file.validateMessage,
percent: file.percent,
preview: true,
}));
return (
<ClickAwayListener onClickAway={handleClickAway}>
<div
className={classNames(styles.container, {
[styles['hide-upload-area']]: fileList.length >= maxFileCount,
})}
>
<Upload
disabled={disabled}
action=""
limit={maxFileCount}
fileList={semiFileList}
data-testid={props['data-testid']}
className={validateStatus === 'error' ? 'has-error' : ''}
customRequest={handleUpload}
draggable={true}
dragMainText={I18n.t('imageflow_upload_action_common')}
dragSubText={I18n.t('imageflow_upload_type', {
type: accept,
})}
multiple={multiple}
accept={accept}
onDrop={handleFocus}
onOpenFileDialog={handleFocus}
onAcceptInvalid={handleAcceptInvalid}
previewFile={file => {
const { uid } = file;
const fileItem = fileList.find(item => item.uid === uid);
if (fileItem) {
return <FileIcon file={fileItem} size={36} />;
}
}}
onRemove={(currentFile, list, currentFileItem) => {
deleteFile(currentFileItem.uid);
}}
onClear={() => setFileList([])}
/>
</div>
</ClickAwayListener>
);
};

View File

@@ -0,0 +1,17 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { FileUpload, type FileUploadProps } from './file-upload';

View File

@@ -0,0 +1,30 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { FileItemStatus } from '../file-icon';
export interface FileItem extends File {
// 唯一标识
uid?: string;
// 文件地址
url?: string;
// 上传进度
percent?: number;
// 校验信息
validateMessage?: string;
status?: FileItemStatus;
[key: string]: any;
}

View File

@@ -0,0 +1,198 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable max-lines-per-function */
import { useState } from 'react';
import { nanoid } from 'nanoid';
import { workflowApi, type ViewVariableType } from '@coze-workflow/base';
import { I18n } from '@coze-arch/i18n';
import { upLoadFile } from '@coze-arch/bot-utils';
import { CustomError } from '@coze-arch/bot-error';
import { Toast } from '@coze-arch/coze-design';
import { FileItemStatus } from '../file-icon';
import { validate } from './utils';
import { type FileItem } from './types';
import { MAX_IMAGE_SIZE, MAX_FILE_SIZE } from './constants';
export interface UploadConfig {
initialValue?: FileItem[];
customValidate?: (file: FileItem) => Promise<string | undefined>;
timeout?: number;
fileType?: 'object' | 'image';
multiple?: boolean;
maxSize?: number;
inputType?: ViewVariableType;
accept?: string;
maxFileCount?: number;
}
export const useUpload = (props?: UploadConfig) => {
const {
initialValue = [],
customValidate,
timeout,
fileType,
multiple = true,
maxSize,
accept,
maxFileCount = 20,
} = props || {};
const [fileList, setFileList] = useState(initialValue);
const isUploading = fileList.some(
file => file.status === FileItemStatus.Uploading,
);
const updateFileItemProps = (uid, fileItemProps) => {
setFileList(prevList => {
const newList = [...prevList];
const index = newList.findIndex(item => item.uid === uid);
if (index !== -1) {
Object.keys(fileItemProps).forEach(key => {
newList[index][key] = fileItemProps[key];
});
}
return newList;
});
};
const uploadFileWithProgress = async file => {
let progressTimer;
try {
const doUpload = async () =>
await upLoadFile({
biz: 'workflow',
fileType,
file,
getProgress: percent => {
updateFileItemProps(file.uid, {
percent,
});
},
});
if (timeout) {
progressTimer = setTimeout(() => {
throw new Error('Upload timed out');
}, timeout);
}
const uri = await doUpload();
if (!uri) {
throw new CustomError('normal_error', 'no uri');
}
// 上传完成,清空超时计时器
clearTimeout(progressTimer);
// 加签uri获得url
const { url } = await workflowApi.SignImageURL(
{
uri,
},
{
__disableErrorToast: true,
},
);
if (!url) {
throw new Error(I18n.t('imageflow_upload_error'));
}
updateFileItemProps(file.uid, {
url,
status: FileItemStatus.Success,
});
return url;
} catch (error) {
updateFileItemProps(file.uid, {
validateMessage: error.message || 'upload failed',
status: FileItemStatus.ValidateFail,
});
clearTimeout(progressTimer);
}
};
const validateFile = async (file: FileItem): Promise<string | undefined> => {
const validateMsg = await validate(file, {
customValidate,
maxSize:
(maxSize ?? fileType === 'image') ? MAX_IMAGE_SIZE : MAX_FILE_SIZE,
accept,
});
if (validateMsg) {
return validateMsg;
}
};
const upload = async (file: FileItem) => {
file.status = FileItemStatus.Uploading;
if (!file.uid) {
file.uid = nanoid();
}
const errorInfo = await validateFile(file);
if (errorInfo) {
Toast.error(errorInfo);
return;
}
if (!multiple && fileList[0]) {
setFileList([]);
}
let canUpload = true;
setFileList(prevList => {
if (prevList.length >= maxFileCount) {
Toast.warning(I18n.t('plugin_file_max'));
canUpload = false;
return prevList;
}
return [...prevList, file];
});
if (canUpload) {
await uploadFileWithProgress(file);
}
};
const deleteFile = (uid?: string) => {
const index = fileList.findIndex(item => uid === item.uid);
if (index !== -1 && uid) {
setFileList(prevList => {
const newList = [...prevList];
newList.splice(index, 1);
return newList;
});
}
};
return {
fileList,
upload,
isUploading,
deleteFile,
setFileList: _fileList => setFileList(_fileList),
};
};

View File

@@ -0,0 +1,178 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import mime from 'mime-types';
import { isNil } from 'lodash-es';
import { I18n } from '@coze-arch/i18n';
import { getFileExtension } from '../file-icon';
import { type FileItem } from './types';
import { MAX_FILE_SIZE } from './constants';
interface UploadValidateRule {
maxSize?: number;
imageSize?: ImageSizeRule;
accept?: string;
customValidate?: (file: FileItem) => Promise<string | undefined>;
}
/**
* 格式化文件大小
* @param bytes 文件大小
* @param decimals 小数位数, 默认 2 位
* @example
* formatBytes(1024); // 1KB
* formatBytes('1024'); // 1KB
* formatBytes(1234); // 1.21KB
* formatBytes(1234, 3); // 1.205KB
*/
export function formatBytes(bytes: number, decimals = 2) {
if (bytes === 0) {
return '0 Bytes';
}
const k = 1024,
dm = decimals,
sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'],
i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))}${sizes[i]}`;
}
/** 文件大小校验 */
export const sizeValidate = (
size: number,
maxSize: number = MAX_FILE_SIZE,
): string | undefined => {
if (maxSize && size > maxSize) {
return I18n.t('imageflow_upload_exceed', {
size: formatBytes(maxSize),
});
}
};
export interface ImageSizeRule {
maxWidth?: number;
minWidth?: number;
maxHeight?: number;
minHeight?: number;
aspectRatio?: number;
}
/**
* 获取图片的宽高
*/
export async function getImageSize(
file: FileItem,
): Promise<{ width: number; height: number }> {
const url = URL.createObjectURL(file);
return new Promise((resolve, reject) => {
const img = new window.Image();
img.onload = () => {
resolve({
width: img.naturalWidth,
height: img.naturalHeight,
});
};
img.onerror = e => {
reject(e);
};
img.src = url;
});
}
/** 图像宽高校验 */
// eslint-disable-next-line complexity
export const imageSizeValidate = async (
file: FileItem,
rule?: ImageSizeRule,
): Promise<string | undefined> => {
const { maxWidth, minWidth, maxHeight, minHeight, aspectRatio } = rule || {};
// 未定义时不校验
if (isNil(maxWidth || minWidth || maxHeight || minHeight || aspectRatio)) {
return;
}
const { width, height } = await getImageSize(file);
if (maxWidth && width > maxWidth) {
return I18n.t('imageflow_upload_error5', {
value: `${maxWidth}px`,
});
}
if (minWidth && width < minWidth) {
return I18n.t('imageflow_upload_error3', {
value: `${minWidth}px`,
});
}
if (maxHeight && height > maxHeight) {
return I18n.t('imageflow_upload_error4', {
value: `${maxHeight}px`,
});
}
if (minHeight && height < minHeight) {
return I18n.t('imageflow_upload_error2', {
value: `${minHeight}px`,
});
}
if (aspectRatio && width / height > aspectRatio) {
return I18n.t('imageflow_upload_error1');
}
};
export const acceptValidate = (fileName: string, accept?: string) => {
if (!accept) {
return;
}
const acceptList = accept.split(',');
const fileExtension = getFileExtension(fileName);
const mimeType = mime.lookup(fileExtension);
// image/* 匹配所有的图片类型
if (acceptList.includes('image/*') && mimeType?.startsWith?.('image/')) {
return undefined;
}
if (!acceptList.includes(`.${fileExtension}`)) {
return I18n.t('imageflow_upload_error_type', {
type: `${acceptList.filter(Boolean).join('/')}`,
});
}
};
export const validate = async (file: FileItem, rules?: UploadValidateRule) => {
const { size, name } = file;
const { maxSize, imageSize, accept, customValidate } = rules || {};
const validators = [
async () => await customValidate?.(file),
() => sizeValidate(size, maxSize),
async () => await imageSizeValidate(file, imageSize),
() => acceptValidate(name, accept),
];
for await (const validator of validators) {
const errorMsg = await validator();
if (errorMsg) {
return errorMsg;
}
}
};