feat: manually mirror opencoze's code from bytedance
Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
@@ -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]}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user