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,489 @@
/*
* 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 { workflowApi } from '@coze-workflow/base/api';
import { REPORT_EVENTS } from '@coze-arch/report-events';
import { I18n } from '@coze-arch/i18n';
import { upLoadFile } from '@coze-arch/bot-utils';
import { CustomError } from '@coze-arch/bot-error';
import { FileBizType } from '@coze-arch/bot-api/developer_api';
import { DeveloperApi } from '@coze-arch/bot-api';
/** 图片上传错误码 */
export enum ImgUploadErrNo {
Success = 0,
/** 缺少文件 */
NoFile,
/** 上传失败 */
UploadFail,
/** 上传超时 */
UploadTimeout,
/** 获取 URL 失败 */
GetUrlFail,
/** 校验异常, 但是不明确具体异常 */
ValidateError,
/** 文件尺寸超出限制 */
MaxSizeError,
/** 文件类型不支持 */
SuffixError,
/** 最大宽度限制 */
MaxWidthError,
/** 最大高度限制 */
MaxHeightError,
/** 最小宽度限制 */
MinWidthError,
/** 最小高度限制 */
MinHeightError,
/** 固定宽高比 */
AspectRatioError,
}
export interface ImageRule {
/** 文件大小限制, 单位 b, 1M = 1 * 1024 * 1024 */
maxSize?: number;
/** 文件后缀 */
suffix?: string[];
/** 最大宽度限制 */
maxWidth?: number;
/** 最大高度限制 */
maxHeight?: number;
/** 最小宽度限制 */
minWidth?: number;
/** 最小高度限制 */
minHeight?: number;
/** 固定宽高比 */
aspectRatio?: number;
}
type UploadResult =
| {
isSuccess: false;
errNo: ImgUploadErrNo;
msg: string;
}
| {
isSuccess: true;
errNo: ImgUploadErrNo.Success;
uri: string;
url: string;
};
/**
* Workflow 图片上传
*/
class ImageUploader {
/** 任务 ID, 用于避免 ABA 问题 */
private taskId = 0;
/**
* 上传模式
* - api 直接使用接口上传
* - uploader 上传到视频云服务, 走 workflow 服务. !海外版未经过测试
*/
mode: 'uploader' | 'api' = 'uploader';
/** 校验规则 */
rules?: ImageRule;
/** 上传的文件 */
file?: File;
/** 展示 Url, 添加文件后生成, 用于预览 */
displayUrl?: string;
/** 上传状态 */
isUploading = false;
/** 超时时间 */
timeout?: number;
/** 校验结果 */
validateResult?: {
isSuccess: boolean;
errNo: ImgUploadErrNo;
msg?: string;
};
/** 上传结果 */
uploadResult?: UploadResult;
constructor(config?: {
rules?: ImageRule;
mode?: ImageUploader['mode'];
timeout?: number;
}) {
this.rules = config?.rules ?? this.rules;
this.mode = config?.mode ?? this.mode;
this.timeout = config?.timeout ?? this.timeout;
}
/** 选择待上传文件 */
async select(file: File) {
if (!file) {
throw new CustomError('normal_error', '选择文件为空');
}
this.reset();
this.file = file;
this.displayUrl = URL.createObjectURL(this.file);
await this.validate().catch(() => {
this.validateResult = {
isSuccess: false,
errNo: ImgUploadErrNo.ValidateError,
msg: I18n.t('imageflow_upload_error'),
};
});
}
/** 上传图片 */
async upload() {
// 未选择文件或文件不符合要求
if (!this.file || !this.validateResult?.isSuccess || this.isUploading) {
return;
}
this.isUploading = true;
// 添加任务 ID,避免 ABA 问题
this.taskId += 1;
const currentId = this.taskId;
let uploadResult: UploadResult;
if (this.mode === 'api') {
uploadResult = await this.uploadByApi(this.file);
} else if (this.mode === 'uploader') {
uploadResult = await this.uploadByUploader(this.file);
} else {
throw new CustomError('normal_error', 'ImageUploader mode error');
}
if (currentId !== this.taskId) {
return;
}
this.uploadResult = uploadResult;
this.isUploading = false;
}
private uploadByUploader(file: File): Promise<UploadResult> {
return new Promise(resolve => {
const timer =
this.timeout &&
setTimeout(
() =>
resolve({
isSuccess: false,
errNo: ImgUploadErrNo.UploadTimeout,
msg: I18n.t('imageflow_upload_error7'),
}),
this.timeout,
);
const doUpload = async () => {
const uri = await upLoadFile({
biz: 'workflow',
file,
fileType: 'image',
})
.then(result => {
if (!result) {
throw new CustomError('normal_error', 'no uri');
}
return result;
})
.catch(() => {
resolve({
isSuccess: false,
errNo: ImgUploadErrNo.UploadFail,
msg: I18n.t('imageflow_upload_error'),
});
return '';
});
if (!uri) {
return;
}
// 获取 url
const resp = await workflowApi
.SignImageURL(
{
uri,
},
{
__disableErrorToast: true,
},
)
.catch(() => null);
const url = resp?.url || '';
if (url) {
resolve({
isSuccess: true,
errNo: ImgUploadErrNo.Success,
uri,
url,
});
} else {
resolve({
isSuccess: false,
errNo: ImgUploadErrNo.GetUrlFail,
msg: I18n.t('imageflow_upload_error'),
});
}
};
doUpload().finally(() => {
clearTimeout(timer);
});
});
}
private uploadByApi(file: File): Promise<UploadResult> {
return new Promise(resolve => {
const timer =
this.timeout &&
setTimeout(
() =>
resolve({
isSuccess: false,
errNo: ImgUploadErrNo.UploadTimeout,
msg: I18n.t('imageflow_upload_error7'),
}),
this.timeout,
);
const doUpload = async function () {
const base64 = await getBase64(file).catch(() => '');
if (!base64) {
resolve({
isSuccess: false,
errNo: ImgUploadErrNo.UploadFail,
msg: I18n.t('imageflow_upload_error'),
});
return;
}
await DeveloperApi.UploadFile({
file_head: {
file_type: getFileExtension(file.name),
biz_type: FileBizType.BIZ_BOT_WORKFLOW,
},
data: base64,
})
.then(result => {
resolve({
isSuccess: true,
errNo: ImgUploadErrNo.Success,
uri: result.data?.upload_uri || '',
url: result.data?.upload_url || '',
});
})
.catch(() => {
resolve({
isSuccess: false,
errNo: ImgUploadErrNo.UploadFail,
msg: I18n.t('imageflow_upload_error'),
});
});
};
doUpload().finally(() => {
clearTimeout(timer);
});
});
}
reset() {
this.file = undefined;
if (this.displayUrl) {
// 是内部链接
URL.revokeObjectURL(this.displayUrl);
this.displayUrl = undefined;
}
this.isUploading = false;
this.uploadResult = undefined;
this.validateResult = undefined;
this.taskId += 1;
}
// eslint-disable-next-line complexity
private async validate() {
if (!this.file || !this.displayUrl) {
return;
}
const rules = this.rules || {};
// 文件尺寸
if (rules.maxSize) {
if (this.file.size > rules.maxSize) {
this.validateResult = {
isSuccess: false,
errNo: ImgUploadErrNo.MaxSizeError,
msg: I18n.t('imageflow_upload_exceed', {
size: formatBytes(rules.maxSize),
}),
};
return;
}
}
// 文件后缀
if (Array.isArray(rules.suffix) && rules.suffix.length > 0) {
const fileExtension = getFileExtension(this.file.name);
if (!rules.suffix.includes(fileExtension)) {
this.validateResult = {
isSuccess: false,
errNo: ImgUploadErrNo.SuffixError,
msg: I18n.t('imageflow_upload_error_type', {
type: `${rules.suffix.filter(Boolean).join('/')}`,
}),
};
return;
}
}
// 图片尺寸
const { width, height } = await getImageSize(this.displayUrl);
if (!width || !height) {
this.validateResult = {
isSuccess: false,
errNo: ImgUploadErrNo.ValidateError,
msg: I18n.t('imageflow_upload_error6'),
};
return;
}
if (rules.maxWidth) {
if (width > rules.maxWidth) {
this.validateResult = {
isSuccess: false,
errNo: ImgUploadErrNo.MaxWidthError,
msg: I18n.t('imageflow_upload_error5', {
value: `${rules.maxWidth}px`,
}),
};
return;
}
}
if (rules.maxHeight) {
if (height > rules.maxHeight) {
this.validateResult = {
isSuccess: false,
errNo: ImgUploadErrNo.MaxHeightError,
msg: I18n.t('imageflow_upload_error4', {
value: `${rules.maxHeight}px`,
}),
};
return;
}
}
if (rules.minWidth) {
if (width < rules.minWidth) {
this.validateResult = {
isSuccess: false,
errNo: ImgUploadErrNo.MinWidthError,
msg: I18n.t('imageflow_upload_error3', {
value: `${rules.minWidth}px`,
}),
};
return;
}
}
if (rules.minHeight) {
if (height < rules.minHeight) {
this.validateResult = {
isSuccess: false,
errNo: ImgUploadErrNo.MinHeightError,
msg: I18n.t('imageflow_upload_error2', {
value: `${rules.minHeight}px`,
}),
};
return;
}
}
if (rules.aspectRatio) {
if (width / height - rules.aspectRatio > Number.MIN_VALUE) {
this.validateResult = {
isSuccess: false,
errNo: ImgUploadErrNo.AspectRatioError,
msg: I18n.t('imageflow_upload_error1'),
};
return;
}
}
this.validateResult = {
isSuccess: true,
errNo: ImgUploadErrNo.Success,
msg: 'success',
};
}
}
export default ImageUploader;
function getBase64(file: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = event => {
const result = event.target?.result;
if (!result || typeof result !== 'string') {
reject(
new CustomError(REPORT_EVENTS.parmasValidation, 'file read fail'),
);
return;
}
resolve(result.replace(/^.*?,/, ''));
};
fileReader.readAsDataURL(file);
});
}
/** 获取文件名后缀 */
function getFileExtension(name: string) {
const index = name.lastIndexOf('.');
return name.slice(index + 1).toLowerCase();
}
/**
* @param url 获取图片宽高
*/
function getImageSize(url: string): Promise<{ width: number; height: number }> {
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;
});
}
/**
* 格式化文件大小
* @param bytes 文件大小
* @param decimals 小数位数, 默认 2 位
* @example
* formatBytes(1024); // 1KB
* formatBytes('1024'); // 1KB
* formatBytes(1234); // 1.21KB
* formatBytes(1234, 3); // 1.205KB
*/
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]}`;
}

View File

@@ -0,0 +1,89 @@
.image-uploader {
&:global(.semi-input-wrapper) {
&:hover {
background-color: var(--semi-color-white);
}
}
&.can-action:global(.semi-input-wrapper) {
transition: all 0.1s;
&:hover {
background: rgba(46, 46, 56, 8%);
}
&:active {
background: rgba(46, 46, 56, 12%);
}
}
.action {
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
font-size: 12px;
color: rgba(29, 28, 35, 60%);
border-radius: 4px;
transition: all 0.2s;
&:hover {
background-color: rgba(46, 46, 56, 8%);
}
&:active {
background-color: rgba(46, 46, 56, 12%);
}
&.disabled {
cursor: not-allowed;
background-color: rgba(46, 46, 56, 8%);
}
}
.input-img-thumb {
overflow: hidden;
display: inline-flex;
flex-grow: 0;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 0.125rem;
img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
object-position: center;
border-radius: 0.125rem;
}
:global {
.semi-image-status {
background: none;
svg {
width: 17px;
color: rgba(6, 7, 9, 30%);
}
}
}
}
}
.img-popover-content {
padding: 8px;
}

View File

@@ -0,0 +1,401 @@
/*
* 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 @coze-arch/max-line-per-function */
import {
type CSSProperties,
type FC,
useRef,
useMemo,
useState,
useEffect,
} from 'react';
import classNames from 'classnames';
import { useBoolean } from 'ahooks';
import { I18n } from '@coze-arch/i18n';
import {
IconCozTrashCan,
IconCozRefresh,
IconCozUpload,
IconCozImageBroken,
} from '@coze-arch/coze-design/icons';
import { type SelectProps } from '@coze-arch/bot-semi/Select';
import {
Image,
ImagePreview,
Popover,
Space,
Spin,
} from '@coze-arch/bot-semi';
import useImageUploader from './use-image-uploader';
import { type ImageRule, ImgUploadErrNo } from './image-uploader';
import s from './index.module.less';
interface ImageUploaderProps {
className?: string;
style?: CSSProperties;
readonly?: boolean;
disabled?: boolean;
/** 图片上传限制 */
rules?: ImageRule;
value?: { url: string; uri: string } | undefined;
validateStatus?: SelectProps['validateStatus'];
onChange?: (value?: { uri: string; url: string }) => void;
onBlur?: () => void;
}
interface ImagePopoverWrapperProps {
/** 图片地址 */
url?: string;
maxWidth?: number;
maxHeight?: number;
minWidth?: number;
minHeight?: number;
/** 是否支持预览 */
enablePreview?: boolean;
children?: React.ReactElement;
}
const ImagePopoverWrapper: FC<ImagePopoverWrapperProps> = ({
url,
children,
maxWidth,
maxHeight,
minWidth,
minHeight,
enablePreview,
}) => {
const [visible, { setTrue: showImagePreview, setFalse: closeImagePreview }] =
useBoolean(false);
const [loadError, setLoadError] = useState(false);
useEffect(() => {
setLoadError(false);
}, [url]);
if (!url) {
return children || null;
}
const content = loadError ? (
<div
className="flex flex-col items-center justify-center"
style={{ width: 225, height: 125 }}
>
<IconCozImageBroken className="w-8 coz-fg-dim" />
<div className="mt-1 coz-fg-primary text-sm font-medium">
{I18n.t('inifinit_list_load_fail')}
</div>
</div>
) : (
<div
className={classNames(
'flex flex-col items-center justify-center rounded-lg overflow-hidden',
enablePreview && !loadError ? 'cursor-zoom-in' : 'cursor-default',
)}
style={{
minWidth,
minHeight,
background: 'rgba(46, 46, 56, 0.08)',
}}
onClick={() => {
if (loadError) {
return;
}
showImagePreview();
}}
>
<img
className={classNames('object-contain object-center rounded-sm')}
style={{ maxWidth, maxHeight }}
src={url}
alt=""
onLoad={() => {
setLoadError(false);
}}
onError={() => {
setLoadError(true);
}}
/>
</div>
);
return (
<>
<Popover
className={s['img-popover-content']}
content={content}
showArrow
position="top"
>
{children}
</Popover>
{enablePreview ? (
<ImagePreview
src={url}
visible={visible}
onVisibleChange={closeImagePreview}
getPopupContainer={() => document.body}
/>
) : null}
</>
);
};
const ImageUploaderBtn: FC<{
visible?: boolean;
disabled?: boolean;
children?: React.ReactElement;
onClick?: () => void;
}> = ({ visible = true, disabled = false, onClick, children }) => {
if (!visible) {
return null;
}
return (
<div
className={classNames(s.action, disabled && s.disabled)}
onClick={e => {
if (disabled) {
return;
}
e.stopPropagation();
onClick?.();
}}
>
{children}
</div>
);
};
const ImageUploader: FC<ImageUploaderProps> = ({
className,
style,
value,
rules,
onChange,
onBlur,
disabled = false,
readonly = false,
validateStatus,
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const {
uri,
url,
fileName,
isError,
loading,
setImgValue,
uploadImg,
clearImg,
retryUploadImg,
} = useImageUploader({
rules,
});
const acceptAttr = useMemo(() => {
if ((rules?.suffix || []).length > 0) {
return (rules?.suffix || []).map(item => `.${item}`).join(',');
}
return 'image/*';
}, [rules?.suffix]);
/** 整体区域支持交互 */
const wrapCanAction = useMemo(
() => !uri && !loading && !isError && !disabled && !readonly,
[uri, loading, isError, disabled, readonly],
);
useEffect(() => {
setImgValue({ uri: value?.uri, url: value?.url });
}, [value?.uri, value?.url]);
const selectImage = () => {
if (loading || disabled || !inputRef.current || readonly || isError) {
return;
}
inputRef.current.click();
};
const renderContent = () => {
if (loading) {
return (
<>
<Spin
style={{ width: 20, height: 20, lineHeight: '20px' }}
spinning
/>
<span
className="truncate min-w-0 ml-1"
style={{ color: 'rgba(29, 28, 35, 0.35)' }}
>
{I18n.t('datasets_unit_upload_state')}
</span>
</>
);
}
if (isError) {
return (
<span
className="truncate min-w-0"
style={{ color: 'rgba(29, 28, 35, 0.35)' }}
title={fileName}
>
{fileName || I18n.t('Upload_failed')}
</span>
);
}
if (url) {
return (
<>
{/* <div
className="inline-flex items-center justify-center flex-shrink-0 flex-grow-0 overflow-hidden rounded-sm"
style={{ width: 20, height: 20 }}
>
<img
className="object-contain object-center rounded-sm max-w-full max-h-full"
src={url}
alt="img"
/>
</div> */}
<Image
className={classNames(s['input-img-thumb'])}
src={url}
alt="img"
preview={false}
fallback={<IconCozImageBroken />}
/>
<div className="truncate min-w-0 ml-1" title={fileName}>
{fileName}
</div>
</>
);
}
return (
<span
className="truncate min-w-0"
style={{ color: 'rgba(29, 28, 35, 0.35)' }}
>
{I18n.t('imageflow_input_upload_placeholder')}
</span>
);
};
return (
<div
className={classNames(
s['image-uploader'],
'semi-input-wrapper semi-input-wrapper-default',
'min-w-0 cursor-default',
(isError || validateStatus === 'error') && 'semi-input-wrapper-error',
wrapCanAction && s['can-action'],
className,
)}
style={style}
>
<ImagePopoverWrapper
url={url}
minWidth={100}
minHeight={75}
maxWidth={400}
maxHeight={300}
enablePreview
>
<div
className={classNames(
'semi-input',
'flex items-center h-full',
!uri &&
!loading &&
!isError &&
!disabled &&
!readonly &&
'cursor-pointer',
)}
style={{ paddingRight: 6 }}
onClick={e => {
e.stopPropagation();
if (wrapCanAction) {
selectImage();
}
}}
>
<>{renderContent()}</>
<div className="flex-1" />
{!readonly && (
<Space spacing={4}>
<ImageUploaderBtn
visible={!uri && !loading && !isError}
disabled={disabled}
onClick={selectImage}
>
<IconCozUpload />
</ImageUploaderBtn>
<ImageUploaderBtn
visible={isError}
disabled={disabled}
onClick={async () => {
const result = await retryUploadImg();
if (result?.isSuccess) {
onChange?.({ uri: result.uri, url: result.url });
}
onBlur?.();
}}
>
<IconCozRefresh />
</ImageUploaderBtn>
<ImageUploaderBtn
visible={Boolean(uri || url)}
disabled={disabled}
onClick={() => {
clearImg();
onChange?.();
onBlur?.();
}}
>
<IconCozTrashCan />
</ImageUploaderBtn>
</Space>
)}
</div>
</ImagePopoverWrapper>
<input
ref={inputRef}
className="hidden"
type="file"
accept={acceptAttr}
onChange={async e => {
const file = e.target.files?.[0];
e.target.value = '';
if (file) {
const result = await uploadImg(file);
if (result?.isSuccess) {
onChange?.({ uri: result.uri, url: result.url });
}
}
}}
/>
</div>
);
};
export default ImageUploader;
export { ImgUploadErrNo, ImageRule, useImageUploader, ImageUploader };

View File

@@ -0,0 +1,230 @@
/*
* 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 @coze-arch/max-line-per-function */
import { useCallback, useEffect, useRef, useState } from 'react';
import { useUnmount } from 'ahooks';
import { I18n } from '@coze-arch/i18n';
import { Toast } from '@coze-arch/coze-design';
import ImageUploader, { ImgUploadErrNo } from './image-uploader';
interface UseImageUploaderParams {
/** 图片限制条件 */
rules?: ImageUploader['rules'];
/** 上传模式 */
mode?: ImageUploader['mode'];
/** 上传配置 */
timeout?: ImageUploader['timeout'];
}
interface UseImageUploaderReturn {
/** 图片标识, 用于提交给服务 */
uri: string;
/** 图片展示地址 */
url: string;
/** 文件名 */
fileName: string;
/** 上传中状态 */
loading: boolean;
/** 上传失败状态 */
isError: boolean;
/** 上传图片 */
uploadImg: (file: File) => Promise<ImageUploader['uploadResult']>;
/** 清除已上传图片 */
clearImg: () => void;
/** 上传失败后重试 */
retryUploadImg: () => Promise<ImageUploader['uploadResult']>;
/**
* 设置初始状态, 用于回显服务下发的数据
*
* @param val 对应值
* @param isMerge 是否 merge 模式, merge 模式仅更新传入字段. 默认 false
*/
setImgValue: (
val: { uri?: string; url?: string; fileName?: string },
isMerge?: boolean,
) => void;
}
/** 缓存文件名 */
const fileNameCache: Record<string, string> = Object.create(null);
// eslint-disable-next-line max-lines-per-function
export default function useImageUploader(
params?: UseImageUploaderParams,
): UseImageUploaderReturn {
const { rules, mode, timeout } = params || {};
const uploaderRef = useRef<ImageUploader>(
new ImageUploader({ rules, mode, timeout }),
);
const [loading, setLoading] = useState(false);
const [isError, setIsError] = useState(false);
const [uri, setUri] = useState('');
const [url, setUrl] = useState('');
const [fileName, setFileName] = useState('');
useUnmount(() => {
uploaderRef.current?.reset();
});
useEffect(() => {
uploaderRef.current.rules = rules;
uploaderRef.current.mode = mode ?? uploaderRef.current.mode;
}, [rules, mode]);
const setImgValue: UseImageUploaderReturn['setImgValue'] = useCallback(
(
{ url: targetDisplayUrl, uri: targetUri, fileName: targetFileName },
isMerge = false,
) => {
if (typeof targetUri !== 'undefined') {
setUri(targetUri);
}
if (typeof targetDisplayUrl !== 'undefined') {
setUrl(targetDisplayUrl);
}
if (typeof targetFileName !== 'undefined') {
setFileName(targetFileName);
}
// 非 Merge 模式, 未设置的值清空
if (!isMerge) {
setUrl(targetDisplayUrl ?? '');
setUri(targetUri ?? '');
setFileName(targetFileName ?? '');
}
// 文件名特殊逻辑, 根据 uri 从缓存重映射文件名
if (!targetFileName) {
if (targetUri && fileNameCache[targetUri]) {
setFileName(fileNameCache[targetUri]);
} else if (!targetUri) {
setFileName('');
}
}
if (typeof targetUri !== 'undefined' || !isMerge) {
setLoading(false);
setIsError(false);
uploaderRef.current?.reset();
}
},
[],
);
const uploadImg = useCallback(
async (file: File): Promise<ImageUploader['uploadResult'] | undefined> => {
await uploaderRef.current.select(file);
// 图片校验不通过
if (!uploaderRef.current.validateResult?.isSuccess) {
Toast.error(
uploaderRef.current.validateResult?.msg || '图片不符合要求',
);
// @ts-expect-error 此处 validateResult.isSuccess 为 false
return uploaderRef.current.validateResult;
}
setIsError(false);
setLoading(true);
setUrl(uploaderRef.current.displayUrl || '');
setFileName(file.name || '');
await uploaderRef.current.upload();
setLoading(false);
// 上传结果
const { uploadResult } = uploaderRef.current;
// 无上传结果说明上传取消
if (!uploadResult) {
return;
}
setIsError(!uploadResult.isSuccess);
if (uploadResult.isSuccess) {
Toast.success(I18n.t('file_upload_success'));
setUri(uploadResult.uri);
// FIXME: 合理的设计应该用 uri 进行缓存, 但是 Imageflow 初期只存储了 url, 使用 url 作为临时方案
fileNameCache[uploadResult.url] = `${file.name}`;
} else {
Toast.error(uploadResult.msg);
}
return uploadResult;
},
[],
);
const retryUploadImg = useCallback(async (): Promise<
ImageUploader['uploadResult']
> => {
// 重传前置检查, 有文件且校验通过
if (
!uploaderRef.current?.file ||
!uploaderRef.current?.validateResult?.isSuccess
) {
Toast.error(I18n.t('imageflow_upload_action'));
return {
isSuccess: false,
errNo: ImgUploadErrNo.NoFile,
msg: '请选择文件',
};
}
setLoading(true);
setIsError(false);
await uploaderRef.current.upload();
setLoading(false);
// 上传结果
const uploadResult = uploaderRef.current.uploadResult || {
isSuccess: false,
errNo: ImgUploadErrNo.UploadFail,
msg: '无上传结果',
};
setIsError(!uploadResult.isSuccess);
if (uploadResult.isSuccess) {
Toast.success(I18n.t('file_upload_success'));
setUri(uploadResult.uri);
fileNameCache[uploadResult.url] = uploaderRef.current.file.name;
} else {
Toast.error(uploadResult.msg);
}
return uploadResult;
}, []);
const clearImg = useCallback(() => {
setUri('');
setUrl('');
setFileName('');
setLoading(false);
setIsError(false);
uploaderRef.current?.reset();
}, []);
return {
uri,
url,
fileName,
loading,
isError,
uploadImg,
clearImg,
retryUploadImg,
setImgValue,
};
}