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,40 @@
.collapse-title {
position: relative;
display: flex;
align-items: center;
font-size: 14px;
font-weight: 500;
height: 24px;
margin-bottom: 12px;
&.collapse-title-sticky {
position: sticky;
top: 0;
background: var(--coz-bg-plus, rgba(252, 252, 255, 1));
z-index: 1;
}
}
.collapse-label {
font-size: 14px;
font-weight: 500;
height: 20px;
color: var(--coz-fg-primary);
}
.collapse-icon {
transition: transform 0.3s ease-in-out, opacity 0.2s ease-in-out;
opacity: 0;
color: var(--coz-fg-dim);
font-size: 14px;
margin: 0 1px;
&.is-show {
opacity: 1;
}
&.is-close {
transform: rotate(-90deg);
}
}
.collapse-extra {
position: absolute;
right: 0;
}

View File

@@ -0,0 +1,105 @@
/*
* 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 React, { useRef, useState, forwardRef } from 'react';
import cls from 'classnames';
import { useHover } from 'ahooks';
import { IconCozArrowDownFill } from '@coze-arch/coze-design/icons';
import { Collapsible } from '@coze-arch/coze-design';
import styles from './collapse.module.less';
interface CollapseProps {
label: React.ReactNode;
extra?: React.ReactNode;
titleSticky?: boolean;
contentClassName?: string;
titleClassName?: string;
className?: string;
extraClassName?: string;
fade?: boolean;
duration?: number;
}
export const Collapse = forwardRef<
HTMLDivElement,
React.PropsWithChildren<CollapseProps>
>(
(
{
label,
extra,
children,
contentClassName,
titleClassName,
titleSticky,
className,
extraClassName,
fade,
duration,
},
ref,
) => {
const [isOpen, setIsOpen] = useState(true);
const titleRef = useRef<HTMLDivElement>(null);
const isTitleHover = useHover(() => titleRef.current);
return (
<div ref={ref} className={className}>
<div
onClick={() => setIsOpen(!isOpen)}
ref={titleRef}
className={cls(
'cursor-pointer',
styles['collapse-title'],
{
[styles['collapse-title-sticky']]: titleSticky,
},
titleClassName,
)}
>
<IconCozArrowDownFill
className={cls(
styles['collapse-icon'],
!isOpen && styles['is-close'],
isTitleHover && styles['is-show'],
)}
/>
<span className={styles['collapse-label']}>{label}</span>
{extra ? (
<div
className={cls(styles['collapse-extra'], extraClassName)}
onClick={e => e.stopPropagation()}
>
{extra}
</div>
) : null}
</div>
<Collapsible
className={contentClassName}
isOpen={isOpen}
keepDOM
fade={fade}
duration={duration}
>
{children}
</Collapsible>
</div>
);
},
);

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 { Collapse } from './collapse';

View File

@@ -0,0 +1,27 @@
/*
* 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 enum FileItemStatus {
Success = 'success',
UploadFail = 'uploadFail',
ValidateFail = 'validateFail',
Validating = 'validating',
Uploading = 'uploading',
Wait = 'wait',
}
// 支持预览的图片类型
export const PREVIEW_IMAGE_TYPE = ['jpg', 'jpeg', 'png', 'webp', 'svg'];

View File

@@ -0,0 +1,10 @@
.file-icon-loading {
:global {
.semi-spin-wrapper {
display: flex;
align-items: center;
justify-content: center;
}
}
}

View File

@@ -0,0 +1,66 @@
/*
* 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 { Spin, Image } from '@coze-arch/coze-design';
import { getIconByExtension, getFileExtension } from './utils';
import { FileItemStatus, PREVIEW_IMAGE_TYPE } from './constants';
import styles from './file-icon.module.less';
interface FileIconProps {
file: {
name: string;
url?: string;
status?: string;
};
size?: number;
}
export const FileIcon = (props: FileIconProps) => {
const { size = 20, file } = props;
const { url, name, status } = file;
const extension = getFileExtension(name);
if (status === FileItemStatus.Uploading) {
return (
<Spin
wrapperClassName={styles['file-icon-loading']}
style={{ width: size, height: size, lineHeight: `${size}px` }}
spinning
/>
);
}
if (PREVIEW_IMAGE_TYPE.includes(extension)) {
return (
<Image
preview={false}
className="object-contain object-center rounded-sm border-0"
style={{ width: size, height: size }}
imgStyle={{ width: size, height: size }}
src={url}
alt=""
/>
);
}
const Icon = getIconByExtension(extension);
return <Icon style={{ width: size, height: size, fontSize: size }} />;
};

View File

@@ -0,0 +1,20 @@
/*
* 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 { FileIcon } from './file-icon';
export { FileItemStatus } from './constants';
export { getFileExtension, isImageFile } from './utils';

View File

@@ -0,0 +1,134 @@
/*
* 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 {
IconCozFileAudio,
IconCozFileCode,
IconCozFilePptx,
IconCozFileDocx,
IconCozFileTxt,
IconCozFileZip,
IconCozFileXlsx,
IconCozFileVideo,
IconCozFileOther,
IconCozFilePdf,
IconCozFileCsv,
} from '@coze-arch/coze-design/illustrations';
import { PREVIEW_IMAGE_TYPE } from './constants';
const codeExtensions = [
'js',
'jsx',
'ts',
'tsx',
'html',
'htm',
'css',
'scss',
'sass',
'less',
'py',
'java',
'c',
'cpp',
'h',
'hpp',
'cs',
'go',
'rb',
'php',
'swift',
'kt',
'kts',
'sql',
'pl',
'sh',
'bash',
'rs',
'dart',
'scala',
'yaml',
'yml',
'json',
];
function isCodeFile(extension: string) {
return codeExtensions.includes(extension);
}
function isAudioFile(extension: string) {
const mimeType = mime.lookup(extension);
return mimeType ? mimeType.startsWith('audio/') : false;
}
function isVideoFile(extension: string) {
const mimeType = mime.lookup(extension);
return mimeType ? mimeType.startsWith('video/') : false;
}
const ICON_MAP = {
// ppt
ppt: IconCozFilePptx,
pptx: IconCozFilePptx,
// doc
doc: IconCozFileDocx,
docx: IconCozFileDocx,
pdf: IconCozFilePdf,
// txt
txt: IconCozFileTxt,
// zip
zip: IconCozFileZip,
rar: IconCozFileZip,
// excel
xls: IconCozFileXlsx,
xlsx: IconCozFileXlsx,
csv: IconCozFileCsv,
// code
code: IconCozFileCode,
// video
video: IconCozFileVideo,
// audio
audio: IconCozFileAudio,
};
export const getIconByExtension = (extension: string) => {
let fileIcon = ICON_MAP[extension] ?? IconCozFileOther;
if (isAudioFile(extension)) {
fileIcon = ICON_MAP.audio;
} else if (isVideoFile(extension)) {
fileIcon = ICON_MAP.video;
} else if (isCodeFile(extension)) {
fileIcon = ICON_MAP.code;
}
return fileIcon;
};
/** 获取文件名后缀 */
export function getFileExtension(name?: string) {
if (!name) {
return '';
}
const index = name.lastIndexOf('.');
return name.slice(index + 1).toLowerCase();
}
export const isImageFile = (name: string) => {
const ext = getFileExtension(name);
return PREVIEW_IMAGE_TYPE.includes(ext);
};

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;
}
}
};

View File

@@ -0,0 +1,28 @@
/*
* 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 { I18n } from '@coze-arch/i18n';
import { Typography } from '@coze-arch/coze-design';
import styles from './input-form-empty.module.less';
export const InputFormEmpty = () => (
<div className={styles['input-form-empty']}>
<Typography.Text className={'text-[12px] coz-fg-dim'}>
{I18n.t('workflow_testrun_input_form_empty')}
</Typography.Text>
</div>
);

View File

@@ -0,0 +1,13 @@
.input-form-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 60px;
}
.empty-icon {
font-size: 32px;
color: var(--coz-fg-dim);
margin-bottom: 4px;
}

View File

@@ -0,0 +1,107 @@
/*
* 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 { useMemo, forwardRef, useImperativeHandle } from 'react';
import { useMemoizedFn } from 'ahooks';
import {
createSchemaField,
FormProvider,
type ISchema,
type JSXComponent,
} from '@formily/react';
import {
createForm,
onFormValuesChange as innerOnFormValuesChange,
type Form,
} from '@formily/core';
import {
Input,
FileUpload,
Switch,
InputInteger,
InputNumber,
FormItem,
FormSection,
VoiceSelect,
TextArea,
FullInput,
InputTime,
} from '../form-materials';
const SchemaField = createSchemaField({
components: {
Input,
InputInteger,
InputNumber,
FormItem,
FormSection,
FileUpload,
Switch,
VoiceSelect,
TextArea,
FullInput,
InputTime,
},
});
interface FormCoreProps {
schema: ISchema;
components?: Record<string, JSXComponent>;
disabled?: boolean;
initialValues?: any;
onFormValuesChange?: (form: Form) => void;
}
type FormCoreRef = Form<any>;
const FormCore = forwardRef<FormCoreRef, FormCoreProps>(
(
{ schema, components, initialValues, disabled, onFormValuesChange },
ref,
) => {
const handleFormChange = useMemoizedFn((f: Form) => {
onFormValuesChange?.(f);
});
const form = useMemo(
() =>
createForm({
initialValues,
disabled,
effects() {
innerOnFormValuesChange(handleFormChange);
},
}),
[],
);
useImperativeHandle(ref, () => form, [form]);
return (
<FormProvider form={form}>
<SchemaField components={components} schema={schema} />
</FormProvider>
);
},
);
export type { FormCoreProps, FormCoreRef };
export { FormCore, SchemaField };
export default FormCore;

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 { FormCore } from './form-core';

View File

@@ -0,0 +1,29 @@
/*
* 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 React, { Suspense, lazy, forwardRef } from 'react';
import type { FormCoreProps, FormCoreRef } from './form-core';
const LazyComponent = lazy(async () => await import('./form-core'));
export const LazyFormCore = forwardRef<FormCoreRef, FormCoreProps>(
(props, ref) => (
<Suspense fallback={null}>
<LazyComponent ref={ref} {...props} />
</Suspense>
),
);

View File

@@ -0,0 +1,24 @@
/*
* 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 { connect, mapProps } from '@formily/react';
import { FileUpload as FileUploadAdapter } from '../../file-upload';
export const FileUpload = connect(
FileUploadAdapter,
mapProps({ validateStatus: true }),
);

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 } from './file-upload';

View File

@@ -0,0 +1,49 @@
.form-item {
margin-bottom: 8px;
}
.form-item-label {
margin-bottom: 4px;
font-size: 12px;
overflow-wrap: break-word;
.form-item-label-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 2px;
}
.top-left {
display: flex;
align-items: center;
}
.tag {
flex-shrink: 0;
margin-left: 4px;
}
}
.label-tooltip {
color: var(--coz-fg-secondary);
margin-left: 2px;
}
.form-item-label-text {
font-weight: 500;
color: #1D1C23;
}
.form-item-label-asterisk {
color: var(--coz-fg-hglt-red);
}
.form-item-feedback-wrap {
min-height: 16px;
margin-top: 4px;
line-height: 16px;
overflow-wrap: break-word;
}
.form-item-feedback-text {
color: var(--coz-fg-hglt-red);
}

View File

@@ -0,0 +1,121 @@
/*
* 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.
*/
/**
* test run test form 布局的 FormItem
*/
import React, { type FC, type ReactNode, type PropsWithChildren } from 'react';
import { connect, mapProps } from '@formily/react';
import { isDataField } from '@formily/core';
import { IconCozInfoCircle } from '@coze-arch/coze-design/icons';
import { Tooltip, Typography, Tag } from '@coze-arch/coze-design';
import styles from './index.module.less';
export interface FormItemProps {
required?: boolean;
label?: ReactNode;
description?: ReactNode;
tag?: ReactNode;
tooltip?: ReactNode;
feedbackText?: ReactNode;
action?: ReactNode;
}
export const FormItemAdapter: FC<PropsWithChildren<FormItemProps>> = props => {
const {
required,
label,
feedbackText,
description,
tooltip,
tag,
action,
children,
} = props;
return (
<div className={styles['form-item']}>
<div className={styles['form-item-label']}>
<div className={styles['form-item-label-top']}>
<div className={styles['top-left']}>
<span className={styles['form-item-label-text']}>{label}</span>
{required ? (
<span className={styles['form-item-label-asterisk']}>*</span>
) : null}
{tooltip ? (
<Tooltip content={tooltip}>
<IconCozInfoCircle className={styles['label-tooltip']} />
</Tooltip>
) : null}
{tag ? (
<Tag className={styles.tag} size="mini" color="primary">
{tag}
</Tag>
) : null}
</div>
{action}
</div>
{description ? (
<Typography.Text
size="small"
type="secondary"
ellipsis={{
showTooltip: true,
}}
>
{description}
</Typography.Text>
) : null}
</div>
<div>{children}</div>
{feedbackText ? (
<div className={styles['form-item-feedback-wrap']}>
<Typography.Text
size="small"
className={styles['form-item-feedback-text']}
>
{feedbackText}
</Typography.Text>
</div>
) : null}
</div>
);
};
const FormItem = connect(
FormItemAdapter,
mapProps(
{
title: 'label',
required: true,
tag: true,
description: true,
} as any,
(props, field) => ({
...props,
feedbackText:
isDataField(field) && field.selfErrors?.length
? field.selfErrors
: undefined,
}),
),
);
export { FormItem };

View File

@@ -0,0 +1,41 @@
.form-section :global .coz-icon-button-mini {
line-height: 0px;
}
.form-section {
border-bottom: 1px solid var(--coz-stroke-primary);
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px 10px 2px;
}
.section-title {
display: flex;
align-items: center;
column-gap: 4px;
cursor: pointer;
.title-collapsible {
color: var(--coz-fg-secondary);
font-size: 12px;
margin-right: -2px;
}
.is-close {
transform: rotate(-90deg);
}
.title-tooltip {
font-size: 14px;
color: var(--coz-fg-secondary);
}
}
.section-context {
padding: 0 16px 10px 16px ;
}

View File

@@ -0,0 +1,83 @@
/*
* 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 React, { useState } from 'react';
import cls from 'classnames';
import {
IconCozArrowDownFill,
IconCozInfoCircle,
} from '@coze-arch/coze-design/icons';
import { Collapsible, Tooltip, Typography } from '@coze-arch/coze-design';
import css from './index.module.less';
export interface FormSectionProps {
title?: React.ReactNode;
tooltip?: React.ReactNode;
action?: React.ReactNode;
collapsible?: boolean;
}
export const FormSection: React.FC<
React.PropsWithChildren<FormSectionProps>
> = ({ title, tooltip, action, collapsible, children }) => {
const [isOpen, setIsOpen] = useState(true);
const handleExpand = () => {
setIsOpen(!isOpen);
};
return (
<div className={css['form-section']}>
<div className={css['section-header']}>
<div
className={css['section-title']}
onClick={collapsible ? handleExpand : undefined}
>
{collapsible ? (
<IconCozArrowDownFill
className={cls(css['title-collapsible'], {
[css['is-close']]: !isOpen,
})}
/>
) : null}
<Typography.Text strong>{title}</Typography.Text>
{tooltip ? (
<Tooltip content={tooltip}>
<IconCozInfoCircle className={css['title-tooltip']} />
</Tooltip>
) : null}
</div>
{action ? (
<div
className={css['section-action']}
onClick={e => {
e.stopPropagation();
}}
>
{action}
</div>
) : null}
</div>
<Collapsible keepDOM fade isOpen={isOpen}>
<div className={css['section-context']}>{children}</div>
</Collapsible>
</div>
);
};

View File

@@ -0,0 +1,21 @@
.full-input :global {
.editor-kit-toolbar-v2-wrapper {
padding: 0 2px;
button {
margin: 0 2px;
}
}
.editor-kit-container {
font-size: 12px;
}
}
.full-input {
min-height: 120px;
border: 1px solid var(--coz-stroke-plus);
}
.modal-full-input {
min-height: 570px;
}

View File

@@ -0,0 +1,153 @@
/*
* 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 { useState, useEffect, useRef, Suspense } from 'react';
import { nanoid } from 'nanoid';
import cls from 'classnames';
import { connect, mapProps } from '@formily/react';
import type { Editor } from '@coze-common/md-editor-adapter';
import {
delta2md,
md2html,
LazyEditorFullInputInner,
ToolbarItemEnum,
} from '@coze-common/md-editor-adapter';
import { Modal } from '@coze-arch/coze-design';
import css from './full-input.module.less';
export interface InnerFullInputProps {
value?: string;
disabled?: boolean;
/** 是否可以展开,默认 true */
expand?: boolean;
className?: string;
onChange: (v?: string) => void;
onExpand?: () => void;
}
export type FullInputProps = InnerFullInputProps & {
modalTitle?: string;
};
const InnerFullInputAdapter: React.FC<FullInputProps> = ({
className,
disabled,
expand = true,
value,
onChange,
...props
}) => {
const editorRef = useRef<Editor | null>(null);
const businessKeyRef = useRef(nanoid());
const innerValueRef = useRef<string | undefined>();
const handleChange = (v: string) => {
if (!editorRef.current) {
return;
}
/**
* deltas => md
*/
const content = editorRef.current.getContent();
const { markdown } = delta2md(content.deltas[0], content.deltas);
/**
* change 可能来自用户输入或者初始化,做一下 diff 来保证性能
*/
if (markdown !== innerValueRef.current) {
innerValueRef.current = markdown;
onChange(markdown);
}
};
useEffect(() => {
if (value !== innerValueRef.current) {
innerValueRef.current = value || '';
/**
* md => html
*/
editorRef.current?.setHTML(md2html(value || ''));
}
}, [value]);
return (
<Suspense fallback={null}>
<LazyEditorFullInputInner
field="full-input"
className={cls(css['full-input'], className)}
businessKey={businessKeyRef.current}
noToolbar={disabled}
noExpand={!expand}
getEditor={editor => {
editorRef.current = editor;
}}
disabled={disabled}
onChange={handleChange}
registerToolItem={items =>
items
.filter(i => (i as any)?.type !== ToolbarItemEnum.Image)
.map(i => {
const item = i as any;
if (item?.type && item.extraPropsToBuiltinComp) {
item.extraPropsToBuiltinComp = {
...item.extraPropsToBuiltinComp,
size: 'extra-small',
};
}
return item;
})
}
{...props}
/>
</Suspense>
);
};
const FullInputAdapter: React.FC<FullInputProps> = ({
expand,
modalTitle,
...props
}) => {
const [modalVisible, setModalVisible] = useState(false);
return (
<>
<Modal
visible={modalVisible}
centered
title={modalTitle}
onCancel={() => setModalVisible(false)}
>
<InnerFullInputAdapter
expand={false}
className={css['modal-full-input']}
{...props}
/>
</Modal>
<InnerFullInputAdapter
expand={expand}
onExpand={() => setModalVisible(true)}
{...props}
/>
</>
);
};
export const FullInput = connect(
FullInputAdapter,
mapProps({ validateStatus: true }),
);

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 { FullInput } from './full-input';

View File

@@ -0,0 +1,28 @@
/*
* 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.
*/
// components
export { Input } from './input';
export { FileUpload } from './file-upload';
export { Switch } from './switch';
export { InputInteger, InputNumber } from './input-number';
export { VoiceSelect } from './voice-select';
export { TextArea } from './text-area';
export { FullInput } from './full-input';
// decorators
export { FormItem } from './form-item';
export { FormSection } from './form-section';
export { InputTime } from './input-time';

View File

@@ -0,0 +1,26 @@
.buttons {
display: flex;
flex-direction: column;
width: 24px;
}
.button {
font-size: 12px;
color: rgba(var(--coze-fg-3), var(--coze-fg-3-alpha));
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
&:hover {
background-color: rgba(var(--coze-bg-5), var(--coze-bg-5-alpha));
}
&.up {
border-top-left-radius: var(--coze-5);
border-top-right-radius: var(--coze-5);
}
&.down {
border-bottom-left-radius: var(--coze-5);
border-bottom-right-radius: var(--coze-5);
}
}

View File

@@ -0,0 +1,148 @@
/*
* 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 { useRef, useMemo, useEffect } from 'react';
import cls from 'classnames';
import BigNumber, { type BigNumber as IBigNumber } from 'bignumber.js';
import {
IconCozArrowDown,
IconCozArrowUp,
} from '@coze-arch/coze-design/icons';
import { Input, type InputProps } from '@coze-arch/coze-design';
import css from './base-input-number-v2.module.less';
export interface InputNumberV2Props {
value?: string;
style?: React.CSSProperties;
placeholder?: string;
validateStatus?: InputProps['validateStatus'];
disabled?: boolean;
'data-testid'?: string;
onChange?: (v?: string) => void;
onBlur?: () => void;
onFocus?: () => void;
/** 整型 */
int?: boolean;
}
/** 是否是合法的数字字符串 */
function isValidNumber(str: string) {
try {
const value = new BigNumber(str);
return !value.isNaN();
} catch {
return false;
}
}
function normalizeNumber(str?: string) {
if (!str || !isValidNumber(str)) {
return;
}
return new BigNumber(str);
}
export const InputNumberV2Adapter: React.FC<InputNumberV2Props> = ({
int,
onChange,
onBlur,
...props
}) => {
const isShowButtons = useMemo(() => !props.disabled, [props.disabled]);
const verifiedRef = useRef<undefined | IBigNumber>(
normalizeNumber(props.value),
);
const fixed = (num: IBigNumber, innerInt?: boolean) =>
innerInt ? num.toFixed(0, BigNumber.ROUND_DOWN) : num.toFixed();
const handleBlur = () => {
if (props.value === '' || props.value === undefined) {
/** 失焦时若值为空,则同时清空验证值 */
verifiedRef.current = undefined;
if (props.value === '') {
// 如果是空字符串需要主动转换为 undefined
onChange?.(undefined);
}
} else {
/** 失焦时若值不为空,则需要验证值的合法性 */
/**
* 1. 若值本身合法,则对值做格式化
* 2. 若值不合法,则采纳最近一次的合法值
* 3. 若都没有,则返回 undefined
*/
let next: undefined | string;
const nextBig = normalizeNumber(props.value) || verifiedRef.current;
if (nextBig) {
next = fixed(nextBig, int);
}
if (next !== props.value) {
onChange?.(next);
}
}
onBlur?.();
};
const handlePlus = () => {
let next = '1';
if (verifiedRef.current) {
const nextNum = verifiedRef.current.plus('1');
next = fixed(nextNum, int);
}
onChange?.(next);
};
const handleMinus = () => {
let next = '0';
if (verifiedRef.current) {
const nextNum = verifiedRef.current.minus('1');
next = fixed(nextNum, int);
}
onChange?.(next);
};
/** 当值发生变化,需要把值同步到合法数字 */
useEffect(() => {
if (props.value === '' || props.value === undefined) {
verifiedRef.current = undefined;
}
const next = normalizeNumber(props.value);
if (next) {
verifiedRef.current = normalizeNumber(props.value);
}
}, [props.value]);
return (
<Input
onChange={onChange}
onBlur={handleBlur}
suffix={
<div className={css.buttons}>
<div className={cls(css.button, css.up)} onClick={handlePlus}>
<IconCozArrowUp />
</div>
<div className={cls(css.button, css.down)} onClick={handleMinus}>
<IconCozArrowDown />
</div>
</div>
}
hideSuffix={!isShowButtons}
{...props}
/>
);
};

View File

@@ -0,0 +1,44 @@
/*
* 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 { useCallback } from 'react';
import { CozInputNumber, type InputNumberProps } from '@coze-arch/coze-design';
export type BaseInputNumberAdapterProps = {
value?: number | string;
onChange?: (v?: number | string) => void;
} & Pick<InputNumberProps, 'precision'>;
export const BaseInputNumberAdapter: React.FC<BaseInputNumberAdapterProps> = ({
onChange,
...props
}) => {
const handleChange = useCallback(
(v: number | string) => {
onChange?.(v === '' ? undefined : v);
},
[onChange],
);
return (
<CozInputNumber
onChange={handleChange}
{...props}
size="small"
style={{ width: '100%' }}
/>
);
};

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 { InputInteger } from './input-integer';
export { InputNumber } from './input-number';

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 React from 'react';
import { connect } from '@formily/react';
import {
BaseInputNumberAdapter,
type BaseInputNumberAdapterProps,
} from './base-input-number';
const InputIntegerAdapter: React.FC<BaseInputNumberAdapterProps> = props => (
<BaseInputNumberAdapter {...props} precision={0.1} />
);
export const InputInteger = connect(InputIntegerAdapter);

View File

@@ -0,0 +1,21 @@
/*
* 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 { connect } from '@formily/react';
import { BaseInputNumberAdapter } from './base-input-number';
export const InputNumber = connect(BaseInputNumberAdapter);

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 { InputTime, type InputTimeProps } from './time';

View File

@@ -0,0 +1,6 @@
.input-time {
width: 100%;
:global .semi-select {
width: 100%;
}
}

View File

@@ -0,0 +1,50 @@
/*
* 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 React from 'react';
import cls from 'classnames';
import { DatePicker } from '@coze-arch/coze-design';
import css from './time.module.less';
export interface InputTimeProps {
className?: string;
value?: string;
onChange?: (v?: string) => void;
}
export const InputTime: React.FC<InputTimeProps> = ({
className,
value,
onChange,
...props
}) => (
<DatePicker
className={cls(css['input-time'], className)}
type="dateTime"
size="small"
showClear={false}
showSuffix={false}
value={value}
onChange={(_date, dateString) => {
if (typeof dateString === 'string' || dateString === undefined) {
onChange?.(dateString);
}
}}
{...props}
/>
);

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 { Input } from './input';

View File

@@ -0,0 +1,22 @@
/*
* 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 { connect, mapProps } from '@formily/react';
import { Input as InputCore } from '@coze-arch/coze-design';
const InputAdapter = props => <InputCore size="small" {...props} />;
export const Input = connect(InputAdapter, mapProps({ validateStatus: true }));

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 { Switch } from './switch';

View File

@@ -0,0 +1,31 @@
/*
* 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 React from 'react';
import { connect } from '@formily/react';
import { Switch as CozSwitch } from '@coze-arch/coze-design';
export interface SwitchProps {
value?: boolean;
onChange?: (v: boolean) => void;
}
const SwitchAdapter: React.FC<SwitchProps> = ({ value, ...props }) => (
<CozSwitch checked={value} {...props} size="small" />
);
export const Switch = connect(SwitchAdapter);

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 { TextArea } from './text-area';

View File

@@ -0,0 +1,6 @@
.text-area-small {
textarea {
font-size: 12px;
line-height: 20px;
}
}

View File

@@ -0,0 +1,47 @@
/*
* 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 cls from 'classnames';
import { connect, mapProps } from '@formily/react';
import { TextArea as TextAreaCore } from '@coze-arch/coze-design';
import css from './text-area.module.less';
export interface TextAreaProps {
size?: string;
className?: string;
}
const TextAreaAdapter: React.FC<TextAreaProps> = ({
size,
className,
...props
}) => (
<TextAreaCore
className={cls(
{
[css['text-area-small']]: size === 'small',
},
className,
)}
{...props}
/>
);
export const TextArea = connect(
TextAreaAdapter,
mapProps({ validateStatus: true }),
);

View File

@@ -0,0 +1,25 @@
/*
* 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 { connect, mapProps } from '@formily/react';
import { VoiceSelect as VoiceSelectBase } from '@coze-workflow/components';
const VoiceSelectAdapter = props => <VoiceSelectBase {...props} />;
export const VoiceSelect = connect(
VoiceSelectAdapter,
mapProps({ validateStatus: true }),
);

View File

@@ -0,0 +1,6 @@
.form-panel-layout {
width: 100%;
height: 100%;
border-radius: 8px;
overflow: hidden;
}

View File

@@ -0,0 +1,31 @@
/*
* 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 React from 'react';
import cls from 'classnames';
import styles from './form-panel-layout.module.less';
interface FormPanelLayoutProps {
className?: string;
}
export const FormPanelLayout: React.FC<
React.PropsWithChildren<FormPanelLayoutProps>
> = ({ className, children }) => (
<div className={cls(styles['form-panel-layout'], className)}>{children}</div>
);

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 { FormPanelLayout } from './form-panel-layout';

View File

@@ -0,0 +1,43 @@
/*
* 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.
*/
/**
* base components
*/
export { Collapse } from './collapse';
export { FormPanelLayout } from './form-panel';
export { TraceIconButton, BaseTestButton } from './test-button';
export { ResizablePanel } from './resizable-panel';
export { BasePanel } from './resizable-panel/base-panel';
// 禁止直接导出 form-engine 避免 formily 包被打到首屏
// export { FormCore } from './form-engine';
export { NodeEventInfo } from './node-event-info';
/**
* feature components
*/
export { LogDetail } from './log-detail';
export {
TestsetManageProvider,
TestsetSelect,
TestsetEditPanel,
type TestsetSelectProps,
type TestsetSelectAPI,
useTestsetManageStore,
} from './testset';
export { InputFormEmpty } from './form-empty';
export { FileIcon, FileItemStatus, isImageFile } from './file-icon';

View File

@@ -0,0 +1,115 @@
/*
* 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 { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { type NodeResult, workflowApi } from '@coze-workflow/base/api';
import { typeSafeJSONParse } from '../../../utils';
interface Props {
result: NodeResult;
paging: number;
spaceId: string;
workflowId: string;
}
export default function useGetCurrentResult({
result,
paging,
spaceId,
workflowId,
}: Props) {
const isNodeLogNeedAsync = true;
const { batch, isBatch } = result || {};
// 反序列化获取所有遍历数组
const batchData: NodeResult[] = useMemo(() => {
if (!isBatch) {
return [];
}
const data = typeSafeJSONParse(batch);
return (Array.isArray(data) ? data : []).map(i => {
if (!i) {
return i;
}
return {
...i,
/** batch 数据里面不包含该标记,手动增加 */
isBatch: true,
};
});
}, [isBatch, batch]);
// 当前执行日志(同步获取完整日志)
const current: NodeResult | undefined = useMemo(() => {
if (!isBatch) {
return result;
}
return batchData.find(i => i?.index === paging);
}, [paging, isBatch, batchData, result]);
const isUseAsyncNodeResult = () => {
if (!isNodeLogNeedAsync) {
return false;
}
if (!current) {
return false;
}
if (!current.needAsync) {
return false;
}
return true;
};
const { data: currentAsync } = useQuery({
retry: 1,
queryKey: [
'WorkflowApiGetNodeExecuteHistory',
workflowId,
spaceId,
current?.executeId,
current?.nodeId,
current?.NodeType,
isBatch,
paging,
],
queryFn: () =>
workflowApi
.GetNodeExecuteHistory({
workflow_id: workflowId,
space_id: spaceId,
execute_id: current?.executeId || '',
node_id: current?.nodeId || '',
node_type: current?.NodeType || '',
is_batch: isBatch,
batch_index: isBatch ? paging : undefined,
})
.then(res => res.data)
.catch(() => current),
enabled: isUseAsyncNodeResult(),
});
return {
current: isUseAsyncNodeResult() ? currentAsync : current,
batchData,
};
}

View File

@@ -0,0 +1,20 @@
/*
* 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 { LogDetail } from './log-detail';

View File

@@ -0,0 +1,5 @@
.log-detail {
display: flex;
flex-direction: column;
row-gap: 16px;
}

View File

@@ -0,0 +1,106 @@
/*
* 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 React, { useLayoutEffect, useMemo, useState } from 'react';
import { type FlowNodeEntity } from '@flowgram-adapter/free-layout-editor';
import { type NodeResult } from '@coze-workflow/base/api';
import { type WorkflowLinkLogData } from '../../types';
import { useMarkdownModal } from '../../features/log';
import { LogDetailPagination } from './pagination';
import { type LogImages as LogImagesType } from './log-images';
import { LogFields } from './log-fields';
import useGetCurrentResult from './hooks/use-get-current-result';
import css from './log-detail.module.less';
interface LogDetailProps {
result: NodeResult;
node?: FlowNodeEntity;
paginationFixedCount?: number;
LogImages: LogImagesType;
spaceId: string;
workflowId: string;
onOpenWorkflowLink?: (data: WorkflowLinkLogData) => void;
}
export const LogDetail: React.FC<LogDetailProps> = ({
result,
node,
paginationFixedCount,
LogImages,
spaceId,
workflowId,
onOpenWorkflowLink,
}) => {
const { isBatch, nodeId } = result;
/** 从 0 开始 */
const [paging, setPaging] = useState(0);
/** 只看错误 */
const [onlyShowError, setOnlyShowError] = useState(false);
const { current, batchData } = useGetCurrentResult({
result,
paging,
spaceId,
workflowId,
});
const echoCurrent = useMemo(() => {
if (!isBatch || !onlyShowError) {
return current;
}
return current?.errorInfo ? current : undefined;
}, [isBatch, onlyShowError, current]);
const { modal, open } = useMarkdownModal();
// 当分页数据发生变化,重新选中第一项
useLayoutEffect(() => {
setPaging(0);
}, [batchData]);
return (
<div className={css['log-detail']}>
{/* 分页 */}
{isBatch ? (
<LogDetailPagination
paging={paging}
data={batchData}
fixedCount={paginationFixedCount}
onlyShowError={onlyShowError}
onChange={setPaging}
onShowErrorChange={setOnlyShowError}
/>
) : null}
{echoCurrent ? (
<LogImages testRunResult={echoCurrent} nodeId={nodeId} />
) : null}
<LogFields
data={echoCurrent}
node={node}
onPreview={open}
onOpenWorkflowLink={onOpenWorkflowLink}
/>
{modal}
</div>
);
};

View File

@@ -0,0 +1,9 @@
.log-filed-empty {
padding-top: 20px;
text-align: center;
p {
margin-top: 16px;
font-size: 16px;
font-weight: 600;
}
}

View File

@@ -0,0 +1,27 @@
/*
* 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 { I18n } from '@coze-arch/i18n';
import { IconCozIllusDone } from '@coze-arch/coze-design/illustrations';
import styles from './empty.module.less';
export const EmptyFiled = () => (
<div className={styles['log-filed-empty']}>
<IconCozIllusDone width="120" height="120" />
<p>{I18n.t('workflow_batch_no_failed_entries')}</p>
</div>
);

View File

@@ -0,0 +1,61 @@
/*
* 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 React, { useMemo } from 'react';
import { type FlowNodeEntity } from '@flowgram-adapter/free-layout-editor';
import { type NodeResult } from '@coze-workflow/base/api';
import { type WorkflowLinkLogData } from '../../../types';
import { generateLog } from '../../../features/log';
import { LogField } from './log-field';
import { EmptyFiled } from './empty';
interface LogFieldsProps {
data: NodeResult | undefined;
node?: FlowNodeEntity;
onPreview: (value: string, path: string[]) => void;
onOpenWorkflowLink?: (data: WorkflowLinkLogData) => void;
}
export const LogFields: React.FC<LogFieldsProps> = ({
data,
node,
onPreview,
onOpenWorkflowLink,
}) => {
const { nodeStatus } = data || {};
const { logs } = useMemo(() => generateLog(data, node), [data, node]);
if (!data) {
return <EmptyFiled />;
}
return (
<>
{logs.map((log, idx) => (
<LogField
key={idx}
log={log}
node={node}
nodeStatus={nodeStatus}
onPreview={onPreview}
onOpenWorkflowLink={onOpenWorkflowLink}
/>
))}
</>
);
};

View File

@@ -0,0 +1,67 @@
/*
* 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 NodeExeStatus } from '@coze-arch/bot-api/workflow_api';
import { type FlowNodeEntity } from '@flowgram-adapter/free-layout-editor';
import { type WorkflowLinkLogData } from '../../../types';
import {
ConditionLogParser,
OutputLogParser,
NormalLogParser,
isOutputLog,
isConditionLog,
FunctionCallLogParser,
isFunctionCallLog,
WorkflowLinkParser,
isWorkflowLinkLog,
type Log,
} from '../../../features/log';
export const LogField: React.FC<{
log: Log;
node?: FlowNodeEntity;
nodeStatus?: NodeExeStatus;
onPreview: (value: string, path: string[]) => void;
onOpenWorkflowLink?: (data: WorkflowLinkLogData) => void;
}> = ({ log, node, nodeStatus, onPreview, onOpenWorkflowLink }) => {
if (isConditionLog(log)) {
return <ConditionLogParser log={log} />;
}
if (isFunctionCallLog(log)) {
return <FunctionCallLogParser log={log} />;
}
if (isOutputLog(log)) {
return (
<OutputLogParser
log={log}
node={node}
nodeStatus={nodeStatus}
onPreview={onPreview}
/>
);
}
if (isWorkflowLinkLog(log)) {
return (
<WorkflowLinkParser log={log} onOpenWorkflowLink={onOpenWorkflowLink} />
);
}
return <NormalLogParser log={log} />;
};

View File

@@ -0,0 +1,25 @@
/*
* 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 NodeResult } from '@coze-workflow/base';
/**
* log images 业务逻辑太重了,本期暂不抽
*/
export type LogImages = React.FC<{
testRunResult: NodeResult;
nodeId?: string;
}>;

View File

@@ -0,0 +1,91 @@
/*
* 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 React, { useMemo } from 'react';
import { type NodeResult } from '@coze-workflow/base/api';
import { I18n } from '@coze-arch/i18n';
import { Typography, Checkbox } from '@coze-arch/coze-design';
import { PageSelector } from './page-selector';
import styles from './pagination.module.less';
interface LogDetailPaginationProps {
paging: number;
data: (NodeResult | null)[];
onlyShowError: boolean;
disabled?: boolean;
fixedCount?: number;
onChange: (page: number) => void;
onShowErrorChange: (v: boolean) => void;
}
export const LogDetailPagination: React.FC<LogDetailPaginationProps> = ({
paging,
data,
onlyShowError,
disabled,
fixedCount,
onChange,
onShowErrorChange,
}) => {
const items = useMemo(() => {
if (onlyShowError) {
return data.filter(v => Boolean(v?.errorInfo));
}
return data;
}, [data, onlyShowError]);
const title = useMemo(() => {
if (onlyShowError) {
return `${I18n.t('workflow_batch_error_items')}: ${items.length}/${
data.length
}`;
}
return `${I18n.t('workflow_batch_total_items')}: ${data.length}`;
}, [data, items, onlyShowError]);
return (
<div>
<div className={styles['pagination-header']}>
<Typography.Text className="font-semibold">{title}</Typography.Text>
<Checkbox
checked={onlyShowError}
disabled={disabled}
onChange={e => {
const checked = Boolean(e.target.checked);
onShowErrorChange(checked);
}}
aria-label={I18n.t('workflow_batch_error_only')}
>
{I18n.t('workflow_batch_error_only')}
</Checkbox>
</div>
<PageSelector
data={items}
paging={paging}
onChange={onChange}
fixedCount={fixedCount}
/>
</div>
);
};

View File

@@ -0,0 +1,68 @@
.ui-selector {
position: relative;
border: 1px solid var(--Light-usage-border---color-border, rgba(29, 28, 35, 0.08));
border-radius: 8px;
padding: 3px 8px;
display: flex;
cursor: pointer;
justify-content: space-between;
align-items: center;
width: 100%;
line-height: 1;
background-color: rgba(255, 255, 255, 1);
}
.ui-selector.has-error {
/* color: rgba(255, 68, 30, 1); */
/* color: var(--light-usage-danger-color-danger, #F93920) */
}
.ui-selector.has-value {
background-color: var(--Light-usage-primary-light---color-primary-light-default, #F1F2FD);
border-color: var(--Light-usage-primary---color-primary, #4D53E8);
}
.ui-selector-icon {
width: 16px;
height: 16px;
color: rgba(29, 28, 35, 0.6);
scale: 0.75;
}
.ui-selector-icon.selected {
transform: rotate(180deg);
}
.ui-selector-placeholder {
font-size: 14px;
color: var(--light-usage-text-color-text-3, rgba(29, 28, 35, 0.35));
}
.more-selector-content {
width: 320px;
padding: 16px;
display: flex;
flex-wrap: wrap;
column-gap: 8px;
row-gap: 8px;
}
.ui-selector-icon-common {
position: absolute;
right: -6px;
top: -8px;
transform: scale(0.75);
line-height: 0;
background: #fff;
border-radius: 6px;
}
.ui-selector-error-icon {
.ui-selector-icon-common;
color: var(--light-usage-danger-color-danger, #F93920);
}
.ui-selector-warning-icon {
.ui-selector-icon-common;
color: var(--Light-color-orange---orange-5, #FF8500);
}

View File

@@ -0,0 +1,155 @@
/*
* 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 React, {
useState,
useMemo,
useRef,
useEffect,
useCallback,
} from 'react';
import { isUndefined } from 'lodash-es';
import cls from 'classnames';
import { type NodeResult } from '@coze-workflow/base/api';
import {
IconCozWarningCircleFill,
IconCozArrowDownFill,
} from '@coze-arch/coze-design/icons';
import { Popover, Typography } from '@coze-arch/coze-design';
import { PageItem, checkHasError, checkHasWarning } from './page-item';
import s from './more-selector.module.less';
interface MoreSelectorProps {
/**
* 运行结果数组
*/
data: (NodeResult | null)[];
/** 当前选择索引 */
paging: number;
fixedCount: number;
/** placeholder */
placeholder: string;
/** 选择索引变更事件 */
onChange: (p: number) => void;
}
export const MoreSelector: React.FC<MoreSelectorProps> = ({
placeholder,
paging,
fixedCount,
data,
onChange,
}) => {
const [isOpen, setIsOpen] = useState(false);
const popoverRef = useRef<HTMLDivElement>(null);
const selectRef = useRef<HTMLDivElement>(null);
const currentItem = useMemo(
() => data.find(v => v?.index === paging),
[paging, data],
);
const hasError = useMemo(() => checkHasError(currentItem), [currentItem]);
const hasWarning = useMemo(() => checkHasWarning(currentItem), [currentItem]);
const handleChange = useCallback(
(v: number) => {
onChange(v);
setIsOpen(false);
},
[onChange, setIsOpen],
);
useEffect(() => {
const handle = (e: Event) => {
if (selectRef.current?.contains(e.target as Node)) {
return;
}
if (!popoverRef.current?.contains(e.target as Node)) {
setIsOpen(false);
}
};
window.addEventListener('click', handle);
return () => window.removeEventListener('click', handle);
}, []);
return (
<Popover
keepDOM
trigger="custom"
position="bottomRight"
visible={isOpen}
content={
<div className={s['more-selector-content']} ref={popoverRef}>
{data.map((v, idx) => (
<PageItem
data={v}
idx={idx + fixedCount}
paging={paging}
onChange={handleChange}
/>
))}
</div>
}
getPopupContainer={() => selectRef?.current || document.body}
>
<div
ref={selectRef}
className={cls({
[s['ui-selector']]: true,
[s['has-value']]: !isUndefined(currentItem) || isOpen,
[s['has-error']]: hasError,
[s['has-warning']]: hasWarning,
})}
onClick={() => setIsOpen(p => !p)}
>
{placeholder && isUndefined(currentItem) ? (
<div className={s['ui-selector-placeholder']}>{placeholder}</div>
) : null}
{!isUndefined(currentItem) && (
<div className={s['ui-selector-content']}>
<Typography.Text>{paging + 1}</Typography.Text>
</div>
)}
<div
className={cls({
[s['ui-selector-icon']]: true,
[s.selected]: isOpen,
})}
>
<IconCozArrowDownFill />
</div>
{hasError || hasWarning ? (
<div
className={cls({
[s['ui-selector-error-icon']]: hasError,
[s['ui-selector-warning-icon']]: hasWarning,
})}
>
<IconCozWarningCircleFill />
</div>
) : null}
</div>
</Popover>
);
};

View File

@@ -0,0 +1,41 @@
.page-item {
width: 32px;
min-width: 32px;
height: 32px;
border-radius: 6px;
background: #fff;
border: 1px solid rgba(29, 28, 35, 0.08);
color: #1D1C23;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
.icon {
position: absolute;
top: -4px;
right: -4px;
}
&.error .icon {
color: #FF441E;
}
&.warning .icon {
color: #FF9600;
}
&.active {
border-color: #4d53e8;
font-weight: 500;
background: transparent;
}
&.empty {
color: rgba(29, 28, 35, 0.20);
background: rgba(46, 46, 56, 0.04);
cursor: not-allowed;
border-width: 0px;
}
}

View File

@@ -0,0 +1,98 @@
/*
* 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 { useCallback, useMemo } from 'react';
import { isNumber } from 'lodash-es';
import cls from 'classnames';
import { type NodeResult } from '@coze-workflow/base/api';
import { I18n } from '@coze-arch/i18n';
import { IconCozWarningCircleFill } from '@coze-arch/coze-design/icons';
import { Tooltip } from '@coze-arch/coze-design';
import styles from './page-item.module.less';
interface PageItemProps {
data: NodeResult | null;
idx: number;
paging: number;
onChange: (v: number) => void;
}
type Nilable<T> = T | undefined | null;
export function checkHasError(item: Nilable<NodeResult>) {
return Boolean(item?.errorInfo) && item?.errorLevel === 'Error';
}
export function checkHasWarning(item: Nilable<NodeResult>) {
return Boolean(item?.errorInfo) && item?.errorLevel !== 'Error';
}
const PageItemEmpty: React.FC<React.PropsWithChildren> = ({ children }) => (
<Tooltip
content={I18n.t('workflow_detail_testrun_panel_batch_naviagte_empty')}
>
<div className={cls(styles['page-item'], styles['page-item-empty'])}>
{children}
</div>
</Tooltip>
);
export const PageItem: React.FC<PageItemProps> = ({
data,
idx,
paging,
onChange,
}) => {
const isError = useMemo(() => checkHasError(data), [data]);
const isWarning = useMemo(() => checkHasWarning(data), [data]);
const page = useMemo(() => {
const temp = data?.index;
if (isNumber(temp)) {
return temp;
}
return idx;
}, [data, idx]);
const echoPage = useMemo(() => page + 1, [page]);
const handleChange = useCallback(() => {
onChange(page);
}, [page, onChange]);
if (!data) {
return <PageItemEmpty key={echoPage}>{echoPage}</PageItemEmpty>;
}
return (
<div
key={echoPage}
className={cls(styles['page-item'], {
[styles.error]: isError,
[styles.warning]: isWarning,
[styles.active]: page === paging,
})}
onClick={handleChange}
>
{echoPage}
{isError || isWarning ? (
<IconCozWarningCircleFill className={styles.icon} />
) : null}
</div>
);
};

View File

@@ -0,0 +1,5 @@
.page-selector {
display: flex;
column-gap: 12px;
margin-right: 2px;
}

View File

@@ -0,0 +1,71 @@
/*
* 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 React, { useMemo } from 'react';
import { type NodeResult } from '@coze-workflow/base/api';
import { I18n } from '@coze-arch/i18n';
import { PageItem } from './page-item';
import { MoreSelector } from './more-selector';
import styles from './page-selector.module.less';
interface PageSelectorProps {
paging: number;
fixedCount?: number;
data: (NodeResult | null)[];
onChange: (val: number) => void;
}
const MAX_FIXED_COUNT = 10;
export const PageSelector: React.FC<PageSelectorProps> = ({
paging,
fixedCount = MAX_FIXED_COUNT,
data,
onChange,
}) => {
// 固定展示的条目,最大为 10 条,不到 10 条按实际展示
const fixedItems = useMemo(
() => data.slice(0, fixedCount),
[fixedCount, data],
);
const moreItems = useMemo(() => data.slice(fixedCount), [data]);
// 是否需要通过下拉框展示更多
const hasMore = useMemo(() => data.length > fixedCount, [data, fixedCount]);
return (
<div style={{ display: 'flex' }} className={styles['page-selector']}>
{fixedItems.map((item, idx) => (
<PageItem data={item} idx={idx} paging={paging} onChange={onChange} />
))}
{hasMore ? (
<MoreSelector
paging={paging}
fixedCount={fixedCount}
data={moreItems}
placeholder={I18n.t('drill_down_placeholer_select')}
onChange={page => {
onChange(page);
}}
/>
) : null}
</div>
);
};

View File

@@ -0,0 +1,6 @@
.pagination-header {
margin-bottom: 8px;
display: flex;
justify-content: space-between;
align-items: center;
}

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 { NodeEventInfo } from './node-event-info';

View File

@@ -0,0 +1,5 @@
.node-event-info {
display: flex;
align-items: center;
column-gap: 4px;
}

View File

@@ -0,0 +1,38 @@
/*
* 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 React from 'react';
import { Avatar, Typography } from '@coze-arch/coze-design';
import { type NodeEvent } from '@coze-arch/bot-api/workflow_api';
import styles from './node-event-info.module.less';
interface NodeEventInfoProps {
event: NodeEvent | undefined;
}
export const NodeEventInfo: React.FC<NodeEventInfoProps> = ({ event }) => {
if (!event) {
return null;
}
return (
<div className={styles['node-event-info']}>
<Avatar src={event.node_icon} shape="square" size="extra-extra-small" />
<Typography.Text>{event.node_title}</Typography.Text>
</div>
);
};

View File

@@ -0,0 +1,47 @@
.base-panel {
display: flex;
flex-direction: column;
border: 1px solid var(--coz-stroke-primary);
border-radius: 8px;
background-color: var(--coz-bg-plus);
position: relative;
}
.dragging {
cursor: row-resize;
user-select: none;
pointer-events: none;
}
.panel-header {
height: 48px;
border-bottom: 1px solid var(--coz-stroke-primary);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
flex-shrink: 0;
column-gap: 8px;
}
.panel-content {
height: 100%;
flex-shrink: 1;
overflow-y: auto;
}
.panel-footer {
border-top: 1px solid var(--coz-stroke-primary);;
}
.resize-bar {
width: 100%;
height: 5px;
position: absolute;
top: 0;
left: 0;
cursor: row-resize;
}

View File

@@ -0,0 +1,103 @@
/*
* 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 React from 'react';
import { isObject } from 'lodash-es';
import cls from 'classnames';
import { IconCozCross } from '@coze-arch/coze-design/icons';
import { IconButton } from '@coze-arch/coze-design';
import { useResize } from './use-resize';
import styles from './base-panel.module.less';
interface BasePanelProps {
className?: string;
/**
* 面板头,不传不渲染
*/
header?: React.ReactNode;
/**
* 面板脚,不传不渲染
*/
footer?: React.ReactNode;
/**
* 默认初始高度,不支持响应式
*/
height?: number;
/**
* 是否可拖拽改变高度
*/
resizable?:
| boolean
| {
min?: number;
max?: number;
};
/**
* 点击关闭事件,仅当渲染面板头时可能触发
*/
onClose?: () => void;
}
export const BasePanel: React.FC<React.PropsWithChildren<BasePanelProps>> = ({
className,
header,
footer,
height,
resizable,
onClose,
children,
}) => {
const {
height: innerHeight,
bind,
ref,
dragging,
} = useResize({
default: height,
...(isObject(resizable) ? resizable : {}),
});
return (
<div
className={cls(
styles['base-panel'],
className,
dragging && styles.dragging,
)}
style={{ height: innerHeight }}
ref={ref}
>
{resizable ? (
<div className={styles['resize-bar']} onMouseDown={bind} />
) : null}
{header ? (
<div className={styles['panel-header']}>
{header}
<IconButton
icon={<IconCozCross className={'text-[18px]'} />}
color="secondary"
onClick={onClose}
/>
</div>
) : null}
<div className={styles['panel-content']}>{children}</div>
{footer ? <div className={styles['panel-footer']}>{footer}</div> : null}
</div>
);
};

View File

@@ -0,0 +1,232 @@
/*
* 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 React, {
useState,
useRef,
useCallback,
useEffect,
forwardRef,
useImperativeHandle,
useMemo,
type MutableRefObject,
} from 'react';
import { isNumber } from 'lodash-es';
import cls from 'classnames';
import { IconCozCross } from '@coze-arch/coze-design/icons';
import { IconButton } from '@coze-arch/coze-design';
import styles from './resizable-panel.module.less';
interface ResizablePanelProps {
className?: string;
header?: React.ReactNode;
headerExtra?: React.ReactNode;
footer?: React.ReactNode;
hideClose?: boolean;
onClose: () => void;
onCloseWithoutAnimation?: () => void;
animation?: 'slide' | 'translateY';
translateYHeight?: string;
innerScrollRef?: MutableRefObject<HTMLDivElement | null>;
draggable?: boolean;
}
export interface ResizablePanelRef {
minimize: () => void;
maximize: () => void;
close: () => void;
scrollTo: (options?: ScrollToOptions) => void;
}
const MIN_HEIGHT = 156;
/**
* TODO: 这里的核心伸缩能力后面想换成 semi 的 Resizable这里先临时写一些逻辑适配
*/
export const ResizablePanel = forwardRef<
ResizablePanelRef,
React.PropsWithChildren<ResizablePanelProps>
>(
(
{
className,
hideClose,
header,
footer,
onClose,
onCloseWithoutAnimation,
children,
animation,
translateYHeight = 'calc(100% - 52px)',
innerScrollRef: innerScrollRefFromProps,
draggable = true,
headerExtra,
},
ref,
) => {
const [height, setHeight] = useState<null | number>(0);
const [isOpen, setIsOpen] = useState<null | boolean>(null);
const innerRef = useRef<HTMLDivElement>(null);
const _scrollRef = useRef<HTMLDivElement>(null);
const scrollRef = innerScrollRefFromProps || _scrollRef;
const isResizing = useRef(false);
const startY = useRef(0);
const startHeight = useRef(0);
const [transition, setTransition] = useState(true);
const handleMouseMove = useCallback(
e => {
if (isResizing.current) {
const newHeight = startHeight.current - (e.clientY - startY.current); // 计算新的高度
setHeight(newHeight > MIN_HEIGHT ? newHeight : MIN_HEIGHT);
}
},
[setHeight],
);
const handleMouseUp = useCallback(() => {
isResizing.current = false;
document.removeEventListener('mousemove', handleMouseMove); // 取消监听
document.removeEventListener('mouseup', handleMouseUp); // 取消监听
}, [handleMouseMove]);
const handleMouseDown = useCallback(
e => {
isResizing.current = true;
startY.current = e.clientY; // 记录鼠标开始拖拽时的 Y 轴坐标
startHeight.current = innerRef.current?.offsetHeight || 0;
document.addEventListener('mousemove', handleMouseMove); // 监听鼠标移动事件
document.addEventListener('mouseup', handleMouseUp); // 监听鼠标抬起事件
},
[handleMouseMove, handleMouseUp],
);
const handleClose = () => {
setTransition(true);
onCloseWithoutAnimation?.();
if (animation) {
setIsOpen(false);
} else {
setHeight(0);
}
setTimeout(() => {
onClose();
}, 250);
};
useImperativeHandle(ref, () => ({
minimize: () => {
setTransition(true);
setHeight(MIN_HEIGHT);
setTimeout(() => setTransition(false), 250);
},
maximize: () => {
setTransition(true);
setHeight(null);
setTimeout(() => setTransition(false), 250);
},
close: () => {
handleClose();
},
scrollTo: (options?: ScrollToOptions) =>
scrollRef.current?.scrollTo(options),
}));
useEffect(() => {
setIsOpen(true);
setTimeout(() => {
setHeight(null);
setTimeout(() => {
setTransition(false);
}, 250);
}, 100);
}, []);
const styleMemo = useMemo(() => {
if (animation === 'slide') {
return {
height: '100%',
};
}
if (animation === 'translateY') {
return {
height: isNumber(height) ? `${height}px` : translateYHeight,
maxHeight: translateYHeight,
};
}
return {
height: isNumber(height) ? `${height}px` : '90%',
};
}, [height, animation, translateYHeight]);
return (
<div
ref={innerRef}
style={styleMemo}
className={cls(
styles.container,
{
[styles['resizable-panel']]: !animation,
[styles['need-transition']]: !animation && transition,
[styles['resizable-panel-translateY']]: animation === 'translateY',
[styles.show]: animation === 'translateY' && isOpen,
[styles.hide]: animation === 'translateY' && !isOpen,
[styles['resizable-panel-slide']]: animation === 'slide',
[styles['slide-in']]: animation === 'slide' && isOpen,
[styles['slide-out']]: animation === 'slide' && !isOpen,
},
className,
)}
>
{draggable ? (
<div
onMouseDown={handleMouseDown}
className={styles['panel-dragging']}
></div>
) : null}
{header ? (
<div className={styles['panel-header']}>
{header}
{hideClose ? null : (
<IconButton
icon={<IconCozCross className={'text-[18px]'} />}
color="secondary"
onClick={handleClose}
/>
)}
</div>
) : null}
{headerExtra ? headerExtra : null}
<div ref={scrollRef} className={styles['panel-content']}>
{children}
</div>
{footer ? <div className={styles['panel-footer']}>{footer}</div> : null}
</div>
);
},
);

View File

@@ -0,0 +1,154 @@
.container {
display: flex;
flex-direction: column;
}
.resizable-panel {
position: absolute;
left: 0;
bottom: 0;
overflow: hidden;
width: 100%;
max-height: 90%;
background: rgb(var(--coze-fg-white));
border-radius: 8px 8px 0 0;
box-shadow: 0 -8px 24px 0 rgba(0, 0, 0, 16%), 0 -16px 48px 0 rgba(0, 0, 0, 8%);
z-index: 10;
&.need-transition {
transition: height 0.25s;
}
}
.resizable-panel-translateY {
position: absolute;
left: 0;
bottom: 0;
overflow: hidden;
width: 100%;
max-height: calc(100% - 52px);
background: rgb(var(--coze-fg-white));
border-radius: 8px 8px 0 0;
box-shadow: 0px -4px 20px 0px rgba(0, 0, 0, 0.1);
z-index: 10;
&.show {
animation: showAnimation 0.25s cubic-bezier(0.14, 1, 0.34, 1);
}
&.hide {
animation: hideAnimation 0.25s cubic-bezier(0.34, 1, 0.14, 1);
}
@keyframes showAnimation {
0% {
transform: translateY(20%);
opacity: 0;
}
100% {
transform: translateY(0%);
opacity: 1;
}
}
@keyframes hideAnimation {
0% {
transform: translateY(0%);
opacity: 1;
}
100% {
transform: translateY(20%);
opacity: 0;
}
}
}
.resizable-panel-slide {
position: absolute;
bottom: 0;
right: -100%; /* 初始位置在容器的右外侧 */
overflow: hidden;
width: 100%;
height: 100%;
background: rgb(var(--coze-fg-white));
border-radius: 8px 8px 0 0;
box-shadow: 0 -8px 24px 0 rgba(0, 0, 0, 16%), 0 -16px 48px 0 rgba(0, 0, 0, 8%);
z-index: 10;
transition: transform 0.25s ease;
&.slide-in {
transform: translateX(-100%);
}
&.slide-out {
transform: translateX(100%);
}
}
.panel-dragging {
cursor: row-resize;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 10px;
&::before {
content: "";
position: absolute;
top: 4px;
left: calc((100% - 100px) / 2);
display: block;
width: 100px;
height: 4px;
background-color: var(--coz-stroke-primary);
border-radius: 4px;
}
}
.panel-header {
height: 48px;
min-height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px 0 12px;
border-bottom: 1px solid var(--coz-stroke-primary);
}
.panel-footer {
height: 56px;
min-height: 56px;
max-height: 56px;
border-top: 1px solid var(--coz-stroke-primary);
flex-grow: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 0 8px 0 12px;
}
.panel-content {
//height: calc(100% - 104px);
flex: 1;
//flex-shrink: 1;
//flex-grow: 1;
overflow-y: auto;
}

View File

@@ -0,0 +1,79 @@
/*
* 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 { useState, useRef, useCallback } from 'react';
import { useMemoizedFn } from 'ahooks';
interface Config {
default?: number;
min?: number;
max?: number;
}
/**
* 目前仅支持高度可变
*/
export const useResize = (config: Config) => {
const [dragging, setDragging] = useState(false);
const [height, setHeight] = useState(config.default);
const ref = useRef<HTMLDivElement>(null);
/**
* 拖拽过程中
*/
const resizing = useRef(false);
/**
* y 轴变化
*/
const startY = useRef(0);
/** 开始位置 */
const start = useRef(0);
const handleMouseMove = useMemoizedFn(e => {
if (resizing.current) {
const newHeight = start.current - (e.clientY - startY.current); // 计算新的高度
if (config.max && newHeight > config.max) {
setHeight(config.max);
} else if (config.min && newHeight < config.min) {
setHeight(config.min);
} else {
setHeight(newHeight);
}
}
});
const handleMouseUp = useCallback(() => {
resizing.current = false;
setDragging(false);
document.removeEventListener('mousemove', handleMouseMove); // 取消监听
document.removeEventListener('mouseup', handleMouseUp); // 取消监听
}, [handleMouseMove]);
const handleMouseDown = useMemoizedFn(e => {
resizing.current = true;
setDragging(true);
startY.current = e.clientY; // 记录鼠标开始拖拽时的 Y 轴坐标
start.current = ref.current?.offsetHeight || 0;
document.addEventListener('mousemove', handleMouseMove); // 监听鼠标移动事件
document.addEventListener('mouseup', handleMouseUp); // 监听鼠标抬起事件
});
return {
height,
bind: handleMouseDown,
ref,
dragging,
};
};

View File

@@ -0,0 +1,22 @@
/*
* 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 { IconCozPlayFill } from '@coze-arch/coze-design/icons';
import { Button, type ButtonProps } from '@coze-arch/coze-design';
export const BaseTestButton: React.FC<ButtonProps> = props => (
<Button color="green" icon={<IconCozPlayFill />} {...props} />
);

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 { BaseTestButton } from './base-test-button';
export { TraceIconButton } from './trace-icon-button';

View File

@@ -0,0 +1,31 @@
/*
* 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 { I18n } from '@coze-arch/i18n';
import { IconCozDebug } from '@coze-arch/coze-design/icons';
import { IconButton, Tooltip, type ButtonProps } from '@coze-arch/coze-design';
type TraceIconButtonProps = ButtonProps;
export const TraceIconButton: React.FC<TraceIconButtonProps> = props => (
<Tooltip content={I18n.t('debug_btn')}>
<IconButton
icon={<IconCozDebug className="coz-fg-primary" />}
color="secondary"
{...props}
/>
</Tooltip>
);

View File

@@ -0,0 +1,4 @@
.auto-gen {
display: flex;
column-gap: 8px;
}

View File

@@ -0,0 +1,66 @@
/*
* 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 { useCallback } from 'react';
import { I18n } from '@coze-arch/i18n';
import { IconCozStopCircle } from '@coze-arch/coze-design/icons';
import { Tooltip, AIButton } from '@coze-arch/coze-design';
import { useAutoGen } from './use-auto-gen';
import styles from './auto-gen-button.module.less';
interface AutoGenButtonProps {
onGenerate: (data: any) => void;
}
export const AutoGenButton: React.FC<AutoGenButtonProps> = ({ onGenerate }) => {
const { generate, abort, generating } = useAutoGen();
const handleGenerate = useCallback(async () => {
const data = await generate();
if (data?.length) {
onGenerate(data);
}
}, [onGenerate, generate]);
return (
<div className={styles['auto-gen']}>
{generating ? (
<Tooltip content={I18n.t('workflow_testset_stopgen')}>
<AIButton
icon={<IconCozStopCircle />}
onlyIcon={true}
onClick={abort}
color="aiplus"
size="small"
/>
</Tooltip>
) : null}
<AIButton
loading={generating}
onClick={handleGenerate}
color="aiplus"
size="small"
>
{generating
? I18n.t('workflow_testset_generating')
: I18n.t('workflow_testset_aigenerate')}
</AIButton>
</div>
);
};

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 { AutoGenButton } from './auto-gen-button';

View File

@@ -0,0 +1,64 @@
/*
* 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 { useCallback, useRef } from 'react';
import { debuggerApi } from '@coze-arch/bot-api';
import { useTestsetManageStore } from '../use-testset-manage-store';
import { typeSafeJSONParse } from '../../../utils';
import { type NodeFormSchema } from '../../../types';
export const useAutoGen = () => {
const { bizCtx, bizComponentSubject, generating, patch } =
useTestsetManageStore(store => ({
bizCtx: store.bizCtx,
bizComponentSubject: store.bizComponentSubject,
generating: store.generating,
patch: store.patch,
}));
const abortRef = useRef<AbortController | null>(null);
const generate = useCallback(async () => {
patch({ generating: true });
try {
abortRef.current = new AbortController();
const { genCaseData } = await debuggerApi.AutoGenerateCaseData(
{ bizComponentSubject, bizCtx, count: 1 },
{ signal: abortRef.current.signal },
);
if (!genCaseData?.length) {
return;
}
return (typeSafeJSONParse(genCaseData[0].input) ||
[]) as NodeFormSchema[];
} finally {
patch({ generating: false });
}
}, [bizCtx, bizComponentSubject, patch, abortRef]);
const abort = useCallback(() => {
abortRef.current?.abort();
}, [abortRef]);
return {
generate,
abort,
generating,
};
};

View File

@@ -0,0 +1,65 @@
.edit-form {
position: absolute;
left: 0;
bottom: 0;
overflow: hidden;
width: 100%;
height: 100%;
background: rgb(var(--coze-fg-white));
border-radius: 8px 8px 0 0;
z-index: 10;
}
.edit-form-title {
display: flex;
align-items: center;
justify-content: space-between;
margin: 12px 0 16px;
.title-text {
font-size: 18px;
font-weight: 600;
font-style: normal;
line-height: 24px;
}
}
.panel-header {
height: 48px;
min-height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
border-bottom: 1px solid var(--coz-stroke-primary);
.header-title {
display: flex;
align-items: center;
column-gap: 4px;
}
}
.panel-footer {
height: 56px;
min-height: 56px;
max-height: 56px;
border-top: 1px solid var(--coz-stroke-primary);
flex-grow: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 0 12px;
}
.panel-content {
height: calc(100% - 104px);
flex-shrink: 1;
flex-grow: 1;
overflow-y: auto;
padding: 12px;
}
.edit-form-desc-input {
margin-top: 12px;
}

View File

@@ -0,0 +1,299 @@
/*
* 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 React, {
useMemo,
useCallback,
useRef,
useState,
useEffect,
} from 'react';
import { isNil, cloneDeep } from 'lodash-es';
import { I18n } from '@coze-arch/i18n';
import { IconCozCross, IconCozArrowLeft } from '@coze-arch/coze-design/icons';
import {
Form,
FormTextArea,
Spin,
Button,
type FormApi,
Tooltip,
Toast,
} from '@coze-arch/coze-design';
import { IconButton, Typography } from '@coze-arch/coze-design';
import {
type CaseDataDetail,
type CaseDataBase,
} from '@coze-arch/bot-api/debugger_api';
import { debuggerApi } from '@coze-arch/bot-api';
import { useTestsetManageStore } from '../use-testset-manage-store';
// import { AutoGenButton } from '../auto-gen-button';
import {
traverseTestsetNodeFormSchemas,
getTestsetFormSubFieldName,
transTestsetBoolSelect2Bool,
transTestsetFormItemSchema2Form,
getTestsetNameRules,
} from '../../../utils';
// import { type NodeFormSchema } from '../../../types';
import {
FormItemSchemaType,
type TestsetFormValuesForBoolSelect,
} from '../../../constants';
import { useEditFormSchemas } from './use-edit-form-schemas';
import { TestsetNameInput } from './name-input';
import { EditFormSection } from './edit-form-section';
import styles from './chat-flow-edit-form.module.less';
const TESTSET_NAME_FIELD = '__TESTSET_NAME__';
const TESTSET_DESC_FIELD = '__TESTSET_DESC__';
interface TestsetEditFormProps {
data?: CaseDataDetail | null;
onParentClose?: () => void;
}
export const ChatFlowTestsetEditForm: React.FC<TestsetEditFormProps> = ({
data,
onParentClose,
}) => {
const { bizComponentSubject, bizCtx, editMode, generating, closeEditPanel } =
useTestsetManageStore(store => ({
bizCtx: store.bizCtx,
bizComponentSubject: store.bizComponentSubject,
editMode: store.editMode,
closeEditPanel: store.closeEditPanel,
generating: store.generating,
}));
const { schemas, schemasLoading } = useEditFormSchemas(data);
const [validating, setValidating] = useState(false);
const [submitting, setSubmitting] = useState(false);
const formRef = useRef<FormApi<Record<string, any>>>();
const title = useMemo(
() =>
editMode === 'edit'
? I18n.t('workflow_testset_edit_title')
: I18n.t('workflow_testset_create_title'),
[editMode],
);
const handleClose = useCallback(() => {
closeEditPanel();
}, [closeEditPanel]);
const handleSubmit = async () => {
setValidating(true);
try {
await formRef.current?.validate();
const errors = formRef.current?.getFormState().errors;
if (Object.keys(errors ?? {}).length) {
return;
}
onSubmit();
} finally {
setValidating(false);
}
};
const onSubmit = async () => {
setSubmitting(true);
try {
const testsetFormValues = formRef.current?.getValues();
if (!testsetFormValues) {
return;
}
const inputSchemas = cloneDeep(schemas ?? []);
traverseTestsetNodeFormSchemas(inputSchemas, (schema, ipt) => {
const val = testsetFormValues[getTestsetFormSubFieldName(schema, ipt)];
if (!isNil(val)) {
ipt.value = val;
}
// 清除 object/array的空值包括空字符串
if (
!val &&
(ipt.type === FormItemSchemaType.LIST ||
ipt.type === FormItemSchemaType.OBJECT)
) {
ipt.value = undefined;
}
// bool 类型 需要将枚举转为布尔值
if (ipt.type === FormItemSchemaType.BOOLEAN) {
ipt.value = transTestsetBoolSelect2Bool(
ipt.value as TestsetFormValuesForBoolSelect,
);
}
});
const caseBase: CaseDataBase = {
name: testsetFormValues[TESTSET_NAME_FIELD],
caseID: data?.caseBase?.caseID,
description: testsetFormValues[TESTSET_DESC_FIELD],
input: JSON.stringify(inputSchemas),
};
await debuggerApi.SaveCaseData({
bizComponentSubject,
bizCtx,
caseBase,
});
Toast.success(I18n.t('Save_success'));
handleClose();
} finally {
setSubmitting(false);
}
};
// const handleGenerate = useCallback(
// (nextSchemas: NodeFormSchema[]) => {
// const formValues = formRef.current?.getValues() || {};
// const validateFields: string[] = [];
// traverseTestsetNodeFormSchemas(nextSchemas, (schema, ipt) => {
// const fieldName = getTestsetFormSubFieldName(schema, ipt);
// const value = transTestsetFormItemSchema2Form(ipt)?.value;
// if (!isNil(value)) {
// formValues[fieldName] = value;
// validateFields.push(fieldName);
// }
// });
// formRef.current?.setValues(formValues);
// // 设置值之后再校验一次
// formRef.current?.validate(validateFields);
// },
// [formRef],
// );
useEffect(() => {
formRef.current?.setValues({
[TESTSET_NAME_FIELD]: data?.caseBase?.name ?? '',
[TESTSET_DESC_FIELD]: data?.caseBase?.description,
});
}, [data]);
useEffect(() => {
if (typeof schemas === 'undefined') {
return;
}
const values = formRef.current?.getValues() ?? {};
traverseTestsetNodeFormSchemas(
schemas,
(schema, ipt) =>
(values[getTestsetFormSubFieldName(schema, ipt)] =
transTestsetFormItemSchema2Form(ipt)?.value),
);
formRef.current?.setValues(values);
}, [schemas]);
return (
<div className={styles['edit-form']}>
<div className={styles['panel-header']}>
<div className={styles['header-title']}>
<IconButton
icon={<IconCozArrowLeft />}
color="secondary"
onClick={handleClose}
/>
<Typography.Text strong fontSize="16px">
{title}
</Typography.Text>
</div>
<IconButton
icon={<IconCozCross />}
color="secondary"
onClick={onParentClose}
/>
</div>
<div className={styles['panel-content']}>
<Form<Record<string, unknown>>
showValidateIcon={false}
getFormApi={api => (formRef.current = api)}
>
<TestsetNameInput
field={TESTSET_NAME_FIELD}
trigger="blur"
stopValidateWithError={true}
label={I18n.t('workflow_testset_name')}
placeholder={I18n.t('workflow_testset_name_placeholder')}
rules={getTestsetNameRules({
bizCtx,
bizComponentSubject,
originVal: data?.caseBase?.name,
isOversea: IS_OVERSEA,
})}
/>
<div className={styles['edit-form-desc-input']}></div>
<FormTextArea
field={TESTSET_DESC_FIELD}
label={I18n.t('workflow_testset_desc')}
placeholder={I18n.t('workflow_testset_desc_placeholder')}
autosize={true}
maxCount={200}
maxLength={200}
rows={2}
/>
<div className={styles['edit-form-title']}>
<span className={styles['title-text']}>
{I18n.t('workflow_testset_node_data')}
</span>
{/* {IS_OVERSEA || IS_BOE ? (
<AutoGenButton onGenerate={handleGenerate} />
) : null} */}
</div>
{schemasLoading ? <Spin /> : null}
{!schemasLoading && schemas?.length
? schemas.map(schema => (
<EditFormSection
key={schema.component_id}
schema={schema}
disabled={generating}
/>
))
: null}
</Form>
</div>
<div className={styles['panel-footer']}>
<Tooltip
content={I18n.t('workflow_testset_submit_tooltip_for_expert_mode')}
>
<Button
color="hgltplus"
style={{ width: '100%' }}
disabled={generating}
loading={validating || submitting}
onClick={handleSubmit}
>
{I18n.t('workflow_testset_edit_confirm')}
</Button>
</Tooltip>
</div>
</div>
);
};

View File

@@ -0,0 +1,44 @@
.edit-form-section {
:global .semi-form-section-text {
border-bottom: unset;
}
}
.form-section-title {
display: flex;
align-items: center;
font-size: 14px;
font-weight: 600;
font-style: normal;
line-height: 22px;
column-gap: 8px;
}
.form-item-label {
font-size: 12px;
font-weight: 500;
font-style: normal;
line-height: 16px;
margin-bottom: 4px;
margin-top: 4px;
.label-text {
display: inline-block;
font-weight: 600;
&.required::after {
content: "*";
font-weight: 600;
color: var(--coz-fg-hglt-red);
}
}
.label-type {
display: inline-block;
margin-left: 8px;
color: var(--coz-fg-secondary);
}
}
.form-item-select {
width: 100%;
}

View File

@@ -0,0 +1,294 @@
/*
* 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 { Fragment, useMemo } from 'react';
import cls from 'classnames';
import { I18n } from '@coze-arch/i18n';
import {
Form,
FormSelect,
FormTextArea,
Avatar,
useFieldState,
} from '@coze-arch/coze-design';
import { IntelligenceType } from '@coze-arch/bot-api/intelligence_api';
import { ComponentType } from '@coze-arch/bot-api/debugger_api';
import { useTestsetManageStore } from '../use-testset-manage-store';
import {
getTestsetFormSubFieldType,
getTestsetFormSubFieldName,
getTestsetFormItemPlaceholder,
getTestsetFormItemCustomProps,
} from '../../../utils';
import {
type NodeFormSchema,
type FormItemSchema,
type ArrayFieldSchema,
} from '../../../types';
import {
FormItemSchemaType,
TESTSET_FORM_BOOLEAN_SELECT_OPTIONS,
TESTSET_BOT_NAME,
} from '../../../constants';
import styles from './edit-form-section.module.less';
/** 整数类型表单精度 */
const INTEGER_PRECISION = 0.1;
/** bot field name 固定值 */
const BOT_FIELD_NAME = `${TESTSET_BOT_NAME}_${ComponentType.CozeVariableBot}`;
interface EditFormSectionTitleProps {
schema: NodeFormSchema;
}
const EditFormSectionTitle: React.FC<EditFormSectionTitleProps> = ({
schema,
}) => {
const title = useMemo(() => {
// 目前只有start和variable两种节点
switch (schema.component_type) {
case ComponentType.CozeStartNode:
return I18n.t('workflow_testset_start_node');
case ComponentType.CozeVariableBot:
return I18n.t('workflow_testset_vardatabase_node');
case ComponentType.CozeVariableChat:
return I18n.t('wf_chatflow_72');
default:
return schema.component_name;
}
}, [schema]);
return (
<div className={styles['form-section-title']}>
{schema.component_icon ? (
<Avatar src={schema.component_icon} shape="square" size="extra-small" />
) : null}
{title}
</div>
);
};
interface FormLabelProps {
schema: FormItemSchema;
}
const FormLabel: React.FC<FormLabelProps> = ({ schema }) => {
const label = useMemo(() => {
if (schema.type === FormItemSchemaType.BOT) {
return I18n.t('workflow_testset_vardatabase_tip');
} else if (schema.type === FormItemSchemaType.CHAT) {
return I18n.t('wf_chatflow_74');
}
return schema.name;
}, [schema]);
const typeLabel = useMemo(() => {
switch (schema.type) {
case FormItemSchemaType.STRING:
case FormItemSchemaType.FLOAT:
case FormItemSchemaType.NUMBER:
case FormItemSchemaType.OBJECT:
case FormItemSchemaType.BOOLEAN:
case FormItemSchemaType.INTEGER:
case FormItemSchemaType.TIME:
return getTestsetFormSubFieldType(schema.type);
case FormItemSchemaType.LIST: {
const subType = (schema.schema as ArrayFieldSchema).type;
return subType
? `Array<${getTestsetFormSubFieldType(subType)}>`
: 'Array';
}
case FormItemSchemaType.BOT:
return '';
case FormItemSchemaType.CHAT:
return '';
default:
return schema.type;
}
}, [schema]);
return (
<div className={styles['form-item-label']}>
<div
className={cls(styles['label-text'], {
[styles.required]: schema.required,
})}
>
{label}
</div>
{typeLabel ? (
<div className={styles['label-type']}>{typeLabel}</div>
) : null}
</div>
);
};
interface FormItemProps {
schema: FormItemSchema;
formSchema: NodeFormSchema;
disabled?: boolean;
projectId?: string;
}
const FormItem: React.FC<FormItemProps> = ({
schema,
formSchema,
disabled,
projectId,
}) => {
const { type, name, required } = schema;
const { formRenders } = useTestsetManageStore(store => ({
formRenders: store.formRenders,
}));
const CustomFormItem = formRenders?.[type];
const fieldName = getTestsetFormSubFieldName(formSchema, schema);
const placeholder = getTestsetFormItemPlaceholder(schema);
const requiredMsg = I18n.t('workflow_testset_required_tip', {
param_name:
schema.type === FormItemSchemaType.BOT ||
schema.type === FormItemSchemaType.CHAT
? ''
: name,
});
if (typeof CustomFormItem !== 'undefined') {
return (
<CustomFormItem
field={fieldName}
disabled={disabled}
rules={[{ required, message: requiredMsg }]}
noLabel={true}
placeholder={placeholder}
{...getTestsetFormItemCustomProps(schema, projectId)}
/>
);
}
switch (type) {
case FormItemSchemaType.BOOLEAN:
return (
<FormSelect
className={styles['form-item-select']}
field={fieldName}
disabled={disabled}
rules={[{ required, message: requiredMsg }]}
optionList={TESTSET_FORM_BOOLEAN_SELECT_OPTIONS}
noLabel={true}
placeholder={placeholder}
showClear={!required}
/>
);
case FormItemSchemaType.INTEGER:
case FormItemSchemaType.FLOAT:
case FormItemSchemaType.NUMBER:
return (
<Form.InputNumber
field={fieldName}
trigger={['change', 'blur']}
precision={
type === FormItemSchemaType.INTEGER ? INTEGER_PRECISION : undefined
}
rules={[{ required, message: requiredMsg }]}
disabled={disabled}
noLabel={true}
style={{ width: '100%' }}
placeholder={placeholder}
/>
);
case FormItemSchemaType.OBJECT:
case FormItemSchemaType.LIST:
return (
<FormTextArea
field={fieldName}
trigger={['change', 'blur']}
rules={[{ required, message: requiredMsg }]}
disabled={disabled}
noLabel={true}
placeholder={placeholder}
/>
);
case FormItemSchemaType.TIME:
return <Form.DatePicker type="dateTime" field={fieldName} />;
case FormItemSchemaType.STRING:
default:
return (
<FormTextArea
field={fieldName}
autosize={{ minRows: 2, maxRows: 5 }}
trigger={['change', 'blur']}
rules={[{ required, message: requiredMsg }]}
disabled={disabled}
noLabel={true}
placeholder={placeholder}
/>
);
}
};
interface EditFormSectionProps {
schema: NodeFormSchema;
disabled?: boolean;
}
export const EditFormSection: React.FC<EditFormSectionProps> = ({
schema,
disabled,
}) => {
const { projectId } = useTestsetManageStore(store => ({
projectId: store.projectId,
}));
const botFieldValue = useFieldState(BOT_FIELD_NAME);
// 判断是否选择到了应用,只有选中应用才会回显对话组件。
// 应用内直接默认选中应用,可以回显对话组件。
const isBotSelectProject =
(botFieldValue?.value?.id &&
botFieldValue?.value?.type === IntelligenceType.Project) ||
projectId;
return (
<Form.Section
text={<EditFormSectionTitle schema={schema} />}
className={styles['edit-form-section']}
>
{schema.inputs
.filter(i => {
if (projectId && i.type === FormItemSchemaType.BOT) {
return false;
}
// 对话组件只会在应用内存在
if (!isBotSelectProject && i.type === FormItemSchemaType.CHAT) {
return false;
}
return true;
})
.map((s, idx) => (
<Fragment key={idx}>
<FormLabel schema={s} />
<FormItem
schema={s}
formSchema={schema}
disabled={disabled}
projectId={botFieldValue?.value}
/>
</Fragment>
))}
</Form.Section>
);
};

View File

@@ -0,0 +1,21 @@
.edit-form-resizeable-panel {
box-shadow: none;
}
.edit-form {
padding: 12px;
}
.edit-form-title {
display: flex;
align-items: center;
justify-content: space-between;
margin: 12px 0 16px;
.title-text {
font-size: 18px;
font-weight: 600;
font-style: normal;
line-height: 24px;
}
}
.edit-form-desc-input {
margin-top: 12px;
}

View File

@@ -0,0 +1,307 @@
/*
* 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 React, {
useMemo,
useCallback,
useRef,
useState,
useEffect,
} from 'react';
import { isNil, cloneDeep } from 'lodash-es';
import { I18n } from '@coze-arch/i18n';
import { IconCozArrowLeft } from '@coze-arch/coze-design/icons';
import {
Form,
FormTextArea,
Spin,
Button,
type FormApi,
Tooltip,
Toast,
} from '@coze-arch/coze-design';
import {
type CaseDataDetail,
type CaseDataBase,
} from '@coze-arch/bot-api/debugger_api';
import { debuggerApi } from '@coze-arch/bot-api';
import { useTestsetManageStore } from '../use-testset-manage-store';
import { AutoGenButton } from '../auto-gen-button';
import { ResizablePanel, type ResizablePanelRef } from '../../resizable-panel';
import {
traverseTestsetNodeFormSchemas,
getTestsetFormSubFieldName,
transTestsetBoolSelect2Bool,
transTestsetFormItemSchema2Form,
getTestsetNameRules,
} from '../../../utils';
import { type NodeFormSchema } from '../../../types';
import {
FormItemSchemaType,
type TestsetFormValuesForBoolSelect,
} from '../../../constants';
import { useEditFormSchemas } from './use-edit-form-schemas';
import { TestsetNameInput } from './name-input';
import { EditFormSection } from './edit-form-section';
import styles from './edit-form.module.less';
const TESTSET_NAME_FIELD = '__TESTSET_NAME__';
const TESTSET_DESC_FIELD = '__TESTSET_DESC__';
interface TestsetEditFormProps {
data?: CaseDataDetail | null;
}
export const TestsetEditForm: React.FC<TestsetEditFormProps> = ({ data }) => {
const {
bizComponentSubject,
bizCtx,
editMode,
generating,
closeEditPanel,
updateEditPanelCloseState,
} = useTestsetManageStore(store => ({
bizCtx: store.bizCtx,
bizComponentSubject: store.bizComponentSubject,
editMode: store.editMode,
closeEditPanel: store.closeEditPanel,
generating: store.generating,
updateEditPanelCloseState: store.updateEditPanelCloseState,
}));
const { schemas, schemasLoading } = useEditFormSchemas(data);
const [validating, setValidating] = useState(false);
const [submitting, setSubmitting] = useState(false);
const formRef = useRef<FormApi<Record<string, any>>>();
const resizablePanelRef = useRef<ResizablePanelRef | null>(null);
const title = useMemo(
() => (
<span className={'flex items-center'}>
<IconCozArrowLeft
className={'mr-[7px] cursor-pointer'}
onClick={() => resizablePanelRef.current?.close?.()}
/>
<span className={'font-medium'}>
{editMode === 'edit'
? I18n.t('workflow_testset_edit_title')
: I18n.t('workflow_testset_create_title')}
</span>
</span>
),
[editMode],
);
const handleClose = useCallback(() => {
closeEditPanel();
}, [closeEditPanel]);
const handleUpdateCloseState = useCallback(() => {
updateEditPanelCloseState(false);
}, [updateEditPanelCloseState]);
const handleSubmit = async () => {
setValidating(true);
try {
await formRef.current?.validate();
const errors = formRef.current?.getFormState().errors;
if (Object.keys(errors ?? {}).length) {
return;
}
onSubmit();
} finally {
setValidating(false);
}
};
const onSubmit = async () => {
setSubmitting(true);
try {
const testsetFormValues = formRef.current?.getValues();
if (!testsetFormValues) {
return;
}
const inputSchemas = cloneDeep(schemas ?? []);
traverseTestsetNodeFormSchemas(inputSchemas, (schema, ipt) => {
const val = testsetFormValues[getTestsetFormSubFieldName(schema, ipt)];
if (!isNil(val)) {
ipt.value = val;
}
// 清除 object/array的空值包括空字符串
if (
!val &&
(ipt.type === FormItemSchemaType.LIST ||
ipt.type === FormItemSchemaType.OBJECT)
) {
ipt.value = undefined;
}
// bool 类型 需要将枚举转为布尔值
if (ipt.type === FormItemSchemaType.BOOLEAN) {
ipt.value = transTestsetBoolSelect2Bool(
ipt.value as TestsetFormValuesForBoolSelect,
);
}
});
const caseBase: CaseDataBase = {
name: testsetFormValues[TESTSET_NAME_FIELD],
caseID: data?.caseBase?.caseID,
description: testsetFormValues[TESTSET_DESC_FIELD],
input: JSON.stringify(inputSchemas),
};
await debuggerApi.SaveCaseData({
bizComponentSubject,
bizCtx,
caseBase,
});
Toast.success(I18n.t('Save_success'));
handleClose();
} finally {
setSubmitting(false);
}
};
const handleGenerate = useCallback(
(nextSchemas: NodeFormSchema[]) => {
const formValues = formRef.current?.getValues() || {};
const validateFields: string[] = [];
traverseTestsetNodeFormSchemas(nextSchemas, (schema, ipt) => {
const fieldName = getTestsetFormSubFieldName(schema, ipt);
const value = transTestsetFormItemSchema2Form(ipt)?.value;
if (!isNil(value)) {
formValues[fieldName] = value;
validateFields.push(fieldName);
}
});
formRef.current?.setValues(formValues);
// 设置值之后再校验一次
formRef.current?.validate(validateFields);
},
[formRef],
);
useEffect(() => {
formRef.current?.setValues({
[TESTSET_NAME_FIELD]: data?.caseBase?.name ?? '',
[TESTSET_DESC_FIELD]: data?.caseBase?.description,
});
}, [data]);
useEffect(() => {
if (typeof schemas === 'undefined') {
return;
}
const values = formRef.current?.getValues() ?? {};
traverseTestsetNodeFormSchemas(
schemas,
(schema, ipt) =>
(values[getTestsetFormSubFieldName(schema, ipt)] =
transTestsetFormItemSchema2Form(ipt)?.value),
);
formRef.current?.setValues(values);
}, [schemas]);
return (
<ResizablePanel
className={styles['edit-form-resizeable-panel']}
draggable={false}
animation={'slide'}
ref={resizablePanelRef}
header={title}
onClose={handleClose}
onCloseWithoutAnimation={handleUpdateCloseState}
footer={
<Tooltip
content={I18n.t('workflow_testset_submit_tooltip_for_expert_mode')}
>
<Button
color="hgltplus"
style={{ width: '100%' }}
disabled={generating}
loading={validating || submitting}
onClick={handleSubmit}
>
{I18n.t('workflow_testset_edit_confirm')}
</Button>
</Tooltip>
}
>
<div className={styles['edit-form']}>
<Form<Record<string, unknown>>
showValidateIcon={false}
getFormApi={api => (formRef.current = api)}
>
<TestsetNameInput
field={TESTSET_NAME_FIELD}
trigger="blur"
stopValidateWithError={true}
label={I18n.t('workflow_testset_name')}
placeholder={I18n.t('workflow_testset_name_placeholder')}
rules={getTestsetNameRules({
bizCtx,
bizComponentSubject,
originVal: data?.caseBase?.name,
isOversea: IS_OVERSEA,
})}
/>
<div className={styles['edit-form-desc-input']}></div>
<FormTextArea
field={TESTSET_DESC_FIELD}
label={I18n.t('workflow_testset_desc')}
placeholder={I18n.t('workflow_testset_desc_placeholder')}
autosize={true}
maxCount={200}
maxLength={200}
rows={2}
/>
<div className={styles['edit-form-title']}>
<span className={styles['title-text']}>
{I18n.t('workflow_testset_node_data')}
</span>
{IS_OVERSEA || IS_BOE ? (
<AutoGenButton onGenerate={handleGenerate} />
) : null}
</div>
{schemasLoading ? <Spin /> : null}
{!schemasLoading && schemas?.length
? schemas.map(schema => (
<EditFormSection
key={schema.component_id}
schema={schema}
disabled={generating}
/>
))
: null}
</Form>
</div>
</ResizablePanel>
);
};

View File

@@ -0,0 +1,44 @@
/*
* 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 { useTestsetManageStore } from '../use-testset-manage-store';
import { TestsetEditForm } from './edit-form';
import { ChatFlowTestsetEditForm } from './chat-flow-edit-form';
interface TestsetEditPanelProps {
isChatFlow?: boolean;
onParentClose?: () => void;
}
export const TestsetEditPanel: React.FC<TestsetEditPanelProps> = ({
isChatFlow,
onParentClose,
}) => {
const { editPanelVisible, editData } = useTestsetManageStore(store => ({
editPanelVisible: store.editPanelVisible,
editData: store.editData,
}));
if (!editPanelVisible) {
return null;
}
return isChatFlow ? (
<ChatFlowTestsetEditForm data={editData} onParentClose={onParentClose} />
) : (
<TestsetEditForm data={editData} />
);
};

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 { TestsetEditPanel } from './edit-panel';

View File

@@ -0,0 +1,10 @@
.suffix {
margin-right: 12px;
margin-left: 8px;
font-size: 14px;
font-weight: 400;
font-style: normal;
line-height: 20px;
color: var(--light-usage-text-color-text-3, rgb(29 28 35 / 35%));
}

View File

@@ -0,0 +1,61 @@
/*
* 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 ChangeEvent, type FocusEvent } from 'react';
import {
Input,
withField,
type InputProps,
type CommonFieldProps,
} from '@coze-arch/coze-design';
import styles from './name-input.module.less';
const TESTSET_NAME_MAX_LEN = 50;
function count(val: unknown) {
return val ? `${val}`.length : 0;
}
/** 需要后缀 & blur trim扩展下原始的input */
function InnerInput(props: InputProps) {
const onBlur = (evt: FocusEvent<HTMLInputElement>) => {
props.onChange?.(
`${props.value ?? ''}`.trim(),
{} as unknown as ChangeEvent<HTMLInputElement>,
);
props.onBlur?.(evt);
};
return (
<Input
{...props}
maxLength={props.maxLength ?? TESTSET_NAME_MAX_LEN}
autoComplete="off"
onBlur={onBlur}
suffix={
<div className={styles.suffix}>
{count(props.value)}/{props.maxLength ?? TESTSET_NAME_MAX_LEN}
</div>
}
/>
);
}
export const TestsetNameInput = withField(InnerInput, {}) as (
props: CommonFieldProps & InputProps,
) => JSX.Element;

View File

@@ -0,0 +1,84 @@
/*
* 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 { isNil } from 'lodash-es';
import { useRequest } from 'ahooks';
import { type CaseDataDetail } from '@coze-arch/bot-api/debugger_api';
import { debuggerApi } from '@coze-arch/bot-api';
import { useTestsetManageStore } from '../use-testset-manage-store';
import {
typeSafeJSONParse,
traverseTestsetNodeFormSchemas,
getTestsetFormSubFieldName,
isTestsetFormSameFieldType,
assignTestsetFormDefaultValue,
} from '../../../utils';
import { type NodeFormSchema, type FormItemSchema } from '../../../types';
export const useEditFormSchemas = (testset?: CaseDataDetail | null) => {
const { bizCtx, bizComponentSubject } = useTestsetManageStore(store => ({
bizCtx: store.bizCtx,
bizComponentSubject: store.bizComponentSubject,
}));
const { data: schemas, loading: schemasLoading } = useRequest(
async () => {
const localSchemas = (typeSafeJSONParse(testset?.caseBase?.input) ||
[]) as NodeFormSchema[];
const res = await debuggerApi.GetSchemaByID({
bizComponentSubject,
bizCtx,
});
const remoteSchemas = (typeSafeJSONParse(res.schemaJson) ||
[]) as NodeFormSchema[];
if (localSchemas.length) {
// 编辑模式比对本地和远程schema并尝试赋值
const localSchemaMap: Record<string, FormItemSchema | undefined> = {};
traverseTestsetNodeFormSchemas(
localSchemas,
(schema, ipt) =>
(localSchemaMap[getTestsetFormSubFieldName(schema, ipt)] = ipt),
);
traverseTestsetNodeFormSchemas(remoteSchemas, (schema, ipt) => {
const subName = getTestsetFormSubFieldName(schema, ipt);
const field = localSchemaMap[subName];
if (
isTestsetFormSameFieldType(ipt.type, field?.type) &&
!isNil(field?.value)
) {
ipt.value = field?.value;
}
});
} else {
// 创建模式:赋默认值
traverseTestsetNodeFormSchemas(remoteSchemas, (schema, ipt) => {
assignTestsetFormDefaultValue(ipt);
});
}
return remoteSchemas;
},
{ refreshDeps: [testset] },
);
return {
schemas,
schemasLoading,
};
};

View File

@@ -0,0 +1,24 @@
/*
* 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 { TestsetManageProvider } from './manage-provider';
export {
TestsetSelect,
type TestsetSelectProps,
type TestsetSelectAPI,
} from './select';
export { TestsetEditPanel } from './edit-panel';
export { useTestsetManageStore } from './use-testset-manage-store';

View File

@@ -0,0 +1,188 @@
/*
* 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 { useRef, type PropsWithChildren, createContext } from 'react';
import { create } from 'zustand';
import {
ComponentType,
type BizCtx,
type ComponentSubject,
type CaseDataDetail,
} from '@coze-arch/bot-api/debugger_api';
import { validateTestsetSchema } from '../../utils';
import type {
ValidateSchemaResult,
TestsetEditMode,
NodeFormItem,
} from '../../types';
import { TESTSET_CONNECTOR_ID, type FormItemSchemaType } from '../../constants';
export interface TestsetManageState {
bizCtx?: BizCtx;
bizComponentSubject?: ComponentSubject;
projectId?: string;
/**
* 校验缓存
*/
validateCache: ValidateSchemaResult;
/**
* 编辑面板状态
*/
editPanelVisible: boolean;
editData: CaseDataDetail | null;
editMode: TestsetEditMode;
editPanelCloseState: boolean;
/**
* 自动填充状态
*/
generating: boolean;
/**
* 自定义渲染组件,暂时从外部传入,后续不要了
*/
formRenders: Partial<Record<FormItemSchemaType, NodeFormItem>>;
}
export interface TestsetManageAction {
/** 更新状态 */
patch: (s: Partial<TestsetManageState>) => void;
/**
* 校验 schema
*/
validateSchema: () => Promise<ValidateSchemaResult>;
/**
* 打开编辑面板
*/
openEditPanel: (data?: CaseDataDetail) => void;
/**
* 关闭编辑面板
*/
closeEditPanel: () => void;
updateEditPanelCloseState: (state: boolean) => void;
}
const createTestsetManageState = (initState: Partial<TestsetManageState>) => {
const { bizCtx = {}, bizComponentSubject = {}, ...rest } = initState;
return create<TestsetManageState & TestsetManageAction>((set, get) => ({
bizCtx: {
connectorID: TESTSET_CONNECTOR_ID,
...bizCtx,
},
bizComponentSubject: {
componentType: ComponentType.CozeStartNode,
parentComponentType: ComponentType.CozeWorkflow,
...bizComponentSubject,
},
formRenders: {},
...rest,
validateCache: 'pending',
editPanelVisible: false,
editPanelCloseState: false,
editMode: 'edit',
editData: null,
generating: false,
patch: s => {
set(() => s);
},
validateSchema: async () => {
const store = get();
if (store.validateCache !== 'pending') {
return store.validateCache;
}
const res = await validateTestsetSchema({
bizCtx: store.bizCtx,
bizComponentSubject: store.bizComponentSubject,
});
set(() => ({ validateCache: res }));
return res;
},
openEditPanel: data => {
set(() => ({
editData: data || null,
editMode: data ? 'edit' : 'create',
editPanelVisible: true,
editPanelCloseState: true,
}));
},
closeEditPanel: () => {
set(() => ({
editPanelVisible: false,
editPanelCloseState: false,
}));
},
updateEditPanelCloseState: state => {
set(() => ({
editPanelCloseState: state,
}));
},
}));
};
type TestsetManageStore = ReturnType<typeof createTestsetManageState>;
export const TestsetManageContext = createContext<TestsetManageStore>(
{} as unknown as TestsetManageStore,
);
interface TestsetManageProviderProps {
spaceId: string;
workflowId: string;
userId?: string;
nodeId?: string;
projectId?: string;
formRenders?: Partial<Record<FormItemSchemaType, NodeFormItem>>;
}
export const TestsetManageProvider: React.FC<
PropsWithChildren<TestsetManageProviderProps>
> = ({
spaceId,
workflowId,
userId,
nodeId,
projectId,
formRenders,
children,
}) => {
// 只初始化一次
const storeRef = useRef<TestsetManageStore>(
createTestsetManageState({
bizCtx: {
bizSpaceID: spaceId,
connectorUID: userId,
},
bizComponentSubject: {
componentID: nodeId,
parentComponentID: workflowId,
},
projectId,
formRenders,
}),
);
return (
<TestsetManageContext.Provider value={storeRef.current}>
{children}
</TestsetManageContext.Provider>
);
};

View File

@@ -0,0 +1,64 @@
/*
* 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 { useCallback } from 'react';
import { I18n } from '@coze-arch/i18n';
import { IconCozPlus } from '@coze-arch/coze-design/icons';
import { Button, Toast } from '@coze-arch/coze-design';
import { useTestsetManageStore } from '../use-testset-manage-store';
interface TestsetAddButtonProps {
onOpenEditPanel: () => void;
}
export const TestsetAddButton: React.FC<TestsetAddButtonProps> = ({
onOpenEditPanel,
}) => {
const { validateSchema, openEditPanel } = useTestsetManageStore(store => ({
validateSchema: store.validateSchema,
openEditPanel: store.openEditPanel,
}));
const handleAdd = useCallback(async () => {
const res = await validateSchema();
if (res !== 'ok') {
Toast.error({
content:
res === 'empty'
? I18n.t('workflow_testset_peedit')
: I18n.t('workflow_test_nodeerror'),
showClose: false,
});
return;
}
openEditPanel();
onOpenEditPanel();
}, [onOpenEditPanel, openEditPanel, validateSchema]);
return (
<Button
icon={<IconCozPlus />}
color="highlight"
size="small"
style={{ width: '100%' }}
onClick={handleAdd}
>
{I18n.t('workflow_testset_create_btn')}
</Button>
);
};

View File

@@ -0,0 +1,35 @@
.container {
display: flex;
align-items: center;
justify-content: center;
padding: 8px 12px;
&.no-more {
display: none;
}
}
.spin {
width: 16px;
height: 16px;
:global {
.semi-spin-wrapper {
line-height: 16px;
svg {
width: 16px;
height: 16px;
}
}
}
}
.text {
margin-left: 8px;
font-size: 12px;
font-weight: 500;
font-style: normal;
line-height: 16px;
color: var(--light-usage-primary-color-primary, #4D53E8);
}

View File

@@ -0,0 +1,41 @@
/*
* 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 { forwardRef } from 'react';
import cls from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { Spin } from '@coze-arch/coze-design';
import styles from './auto-load-more.module.less';
interface LoadMoreProps {
noMore?: boolean;
}
export const AutoLoadMore = forwardRef<HTMLDivElement, LoadMoreProps>(
({ noMore }, ref) => (
<div
className={cls(styles.container, {
[styles['no-more']]: noMore,
})}
ref={ref}
>
<Spin spinning={true} wrapperClassName={styles.spin} />
<div className={styles.text}>{I18n.t('loading')}</div>
</div>
),
);

View File

@@ -0,0 +1,21 @@
/*
* 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 {
TestsetSelect,
type TestsetSelectProps,
type TestsetSelectAPI,
} from './select';

Some files were not shown because too many files have changed in this diff Show More