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,158 @@
/*
* 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, { useEffect, useRef } from 'react';
import { useUpdateEffect } from 'ahooks';
import { IconSpin } from '@douyinfe/semi-icons';
import { useLoadMore } from '../../hooks/shortcut-bar/use-load-more';
const TIME_TO_CANCEL_MOUSE_MOVE = 50;
export interface LoadMoreListData<TData extends object> {
list: TData[];
hasMore: boolean;
}
export type LoadMoreListProps<TData extends object> = {
className?: string;
getId: (data: TData) => string;
defaultId?: string;
itemRender: (data: TData) => React.ReactNode;
defaultList?: TData[];
listTopSlot?: React.ReactNode;
getMoreListService: (
currentData: LoadMoreListData<TData> | undefined,
) => Promise<LoadMoreListData<TData>>;
onSelect?: (data: TData) => void;
onActiveId?: (id: string) => void;
} & Omit<React.HTMLAttributes<HTMLDivElement>, 'className' | 'onSelect'>;
export const LoadMoreList = <TData extends object>(
props: LoadMoreListProps<TData>,
) => {
const {
className,
onSelect,
getId,
itemRender,
onActiveId,
getMoreListService,
defaultId,
listTopSlot,
defaultList,
...restProps
} = props;
const mouseMovingCancelIdRef = useRef<ReturnType<typeof setTimeout>>();
const mouseMovingRef = useRef(false);
const listRef = useRef<HTMLDivElement>(null);
const activeItemRef = useRef<HTMLDivElement | null>(null);
const {
data,
scrollIntoView,
activeId,
focusTo,
goNext,
goPrev,
loadingMore,
loading,
} = useLoadMore<TData>({
getMoreListService,
getId: (item: TData) => getId(item),
listRef,
defaultList,
});
const list = data?.list ?? [];
useEffect(() => {
onActiveId?.(activeId);
}, [activeId]);
useUpdateEffect(() => {
if (loading) {
return;
}
const defaultItem = list.find(item => getId(item) === defaultId);
if (defaultItem) {
focusTo(defaultItem);
scrollIntoView(defaultItem);
onActiveId?.(defaultId || getId(defaultItem));
}
}, [loading]);
return (
<div
ref={listRef}
tabIndex={1}
className={className}
onMouseLeave={() => {
focusTo(null);
}}
onMouseMove={() => {
clearTimeout(mouseMovingCancelIdRef.current);
mouseMovingRef.current = true;
mouseMovingCancelIdRef.current = setTimeout(() => {
mouseMovingRef.current = false;
}, TIME_TO_CANCEL_MOUSE_MOVE);
}}
onKeyDown={event => {
if (event.key === 'ArrowDown') {
event.preventDefault();
goNext();
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
goPrev();
return;
}
if (event.key === 'Enter') {
const selectItem = list.find(item => getId(item) === activeId);
selectItem && onSelect?.(selectItem);
}
}}
{...restProps}
>
{listTopSlot}
{list.map(item => (
<div
key={getId(item)}
data-id={getId(item)}
ref={getId(item) === activeId ? activeItemRef : null}
onClick={() => {
onSelect?.(item);
}}
onMouseEnter={() => {
// 鼠标位于滚动条中,会触发该事件,设置仅在移动鼠标过程中进行更新
if (mouseMovingRef.current) {
focusTo(item);
listRef.current?.focus();
}
}}
>
{itemRender(item)}
</div>
))}
{loadingMore || loading ? (
<div className="flex justify-center items-center">
<IconSpin style={{ color: '#4D53E8' }} spin />
</div>
) : null}
</div>
);
};

View File

@@ -0,0 +1,74 @@
/*
* 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 FC } from 'react';
import { useIsSendMessageLock } from '@coze-common/chat-area';
import { type DSL } from '../../types';
import { ChatAreaStateContext } from '../../context/chat-area-state/context';
import { type DSLContext } from './widgets/types';
import { DSLWidgetsMap } from './widgets';
const getChildrenIds = (item: DSL['elements'][string]): string[] =>
item.children ??
((item.props?.Columns ?? []) as { children: string[] }[])?.reduce<string[]>(
(res, column) => {
if (column.children) {
res.push(...column.children);
}
return res;
},
[],
);
const DSLRender: FC<
{
elementId: string;
} & IShortCutPanelProps
> = ({ elementId, ...context }) => {
const { dsl } = context;
const item = dsl?.elements[elementId];
const itemType = item?.type || '';
const Component = itemType in DSLWidgetsMap ? DSLWidgetsMap[itemType] : null;
const childrenIds = item && getChildrenIds(item);
if (!Component) {
// TODO slardar report
return null;
}
return (
<Component context={context} props={item?.props}>
{childrenIds?.map(childrenId => (
<div className="flex-1 overflow-hidden">
<DSLRender key={childrenId} elementId={childrenId} {...context} />
</div>
))}
</Component>
);
};
export type IShortCutPanelProps = DSLContext;
export const ShortCutPanel: FC<IShortCutPanelProps> = ({ dsl, ...context }) => {
const isSendMessageLock = useIsSendMessageLock();
return (
<ChatAreaStateContext.Provider value={{ isSendMessageLock }}>
<DSLRender elementId={dsl.rootID} dsl={dsl} {...context} />
</ChatAreaStateContext.Provider>
);
};

View File

@@ -0,0 +1,81 @@
/*
* 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 } from 'react';
import { type FormApi } from '@coze-arch/bot-semi/Form';
import { Form } from '@coze-arch/bot-semi';
import { type DSLComponent, type TValue } from '../types';
import { findInputElementsWithDefault } from '../../../../utils/dsl-template';
type FormValue = Record<string, TValue>;
export const DSLForm: DSLComponent = ({
context: { onChange, onSubmit, dsl },
children,
}) => {
const formRef = useRef<FormApi>();
/**
* text类型组件交互 支持 placeholder 表示默认值
* @param formValues
*/
const onSubmitWrap = (formValues: FormValue) => {
if (!onSubmit) {
return;
}
const inputElementsWithDefault = findInputElementsWithDefault(dsl);
const newValues = Object.entries(formValues).reduce(
(prev: Record<string, TValue>, curr) => {
const [field, value] = curr;
const input = inputElementsWithDefault.find(i => i.id === field);
if (input && !value) {
prev[field] = input.defaultValue;
} else {
prev[field] = value;
}
return prev;
},
{},
);
inputElementsWithDefault.forEach(input => {
const { id, defaultValue } = input;
if (id && !(id in newValues)) {
newValues[id] = defaultValue;
}
});
onSubmit(newValues);
};
return (
<Form<FormValue>
className="w-full"
autoComplete="off"
getFormApi={api => (formRef.current = api)}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
onChange={formState => onChange?.(formState.values!)}
onSubmit={onSubmitWrap}
>
{children}
</Form>
);
};

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 { DSLFormUpload } from './upload';
import { type DSLComponent } from './types';
import { DSLFormInput } from './text-input';
import { DSLSubmitButton } from './submit-button';
import { DSLFormSelect } from './select';
import { DSLRoot } from './root';
import { DSLPlaceholer } from './placeholder';
import { DSLColumnLayout } from './layout';
import { DSLForm } from './form';
// 组件参数是在运行时决定,无法具体做类型约束
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const DSLWidgetsMap: Record<string, DSLComponent<any>> = {
'@flowpd/cici-components/Input': DSLFormInput,
'@flowpd/cici-components/Select': DSLFormSelect,
'@flowpd/cici-components/Upload': DSLFormUpload,
'@flowpd/cici-components/Placeholder': DSLPlaceholer,
'@flowpd/cici-components/ColumnLayout': DSLColumnLayout,
'@flowpd/cici-components/Form': DSLForm,
'@flowpd/cici-components/PageContainer': DSLRoot,
'@flowpd/cici-components/Button': DSLSubmitButton,
} as const;

View File

@@ -0,0 +1,31 @@
.label {
overflow-x: hidden;
width: fit-content;
max-width: calc(100 - 16px);
margin-bottom: 0;
padding-right: 0;
:global(.semi-form-field-label-text) {
display: flex;
flex-wrap: nowrap;
align-items: center;
width: 100%;
}
}
.text {
font-size: 12px;
font-weight: 500;
line-height: 18px;
}
.icon {
margin-left: 2px;
padding: 2px;
svg {
width: 12px;
height: 12px;
}
}

View File

@@ -0,0 +1,48 @@
/*
* 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 FC } from 'react';
import { Form, Tooltip, Typography } from '@coze-arch/bot-semi';
import { IconInfo } from '@coze-arch/bot-icons';
import style from './index.module.less';
export const LabelWithDescription: FC<{
name: string;
description?: string;
required?: boolean;
}> = ({ name, description, required = true }) => (
<div className="w-full flex items-center px-2 mb-[2px]">
<Form.Label
text={
<Typography.Text
ellipsis={{ showTooltip: true }}
className={style.text}
>
{name}
</Typography.Text>
}
required={required}
className={style.label}
/>
{!!description && (
<Tooltip content={description}>
<IconInfo className={style.icon} />
</Tooltip>
)}
</div>
);

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 type { FC, PropsWithChildren } from 'react';
// 本期不需要不支持复布局解析
export const DSLColumnLayout: FC<PropsWithChildren> = ({ children }) => (
<div className="flex items-center justify-between w-full mb-3 gap-2">
{children}
</div>
);

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 type { FC } from 'react';
import { I18n } from '@coze-arch/i18n';
export const DSLPlaceholer: FC = () => (
<div
className="flex items-center justify-center rounded-lg coz-bg-plus text-center text-xs font-medium coz-fg-secondary "
style={{ height: 58 }}
>
{I18n.t('shortcut_modal_components')}
</div>
);

View File

@@ -0,0 +1,19 @@
/*
* 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 { FC, PropsWithChildren } from 'react';
export const DSLRoot: FC<PropsWithChildren> = ({ children }) => <>{children}</>;

View File

@@ -0,0 +1,49 @@
/*
* 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 SelectProps } from '@coze-arch/bot-semi/Select';
import { UIFormSelect } from '@coze-arch/bot-semi';
import { type DSLFormFieldCommonProps, type DSLComponent } from '../types';
import { LabelWithDescription } from '../label-with-desc';
export const DSLFormSelect: DSLComponent<
DSLFormFieldCommonProps & Pick<SelectProps, 'optionList'>
> = ({
context: { readonly },
props: { name, description, defaultValue, ...props },
}) => {
const required = !defaultValue?.value;
return (
<div>
<LabelWithDescription
name={name}
description={description}
required={required}
/>
<UIFormSelect
disabled={readonly}
fieldStyle={{ padding: 0 }}
className="w-full"
field={name}
initValue={defaultValue?.value}
noLabel
{...props}
/>
</div>
);
};

View File

@@ -0,0 +1,77 @@
/*
* 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 { IconButton, useFormState } from '@coze-arch/bot-semi';
import { IconSend } from '@coze-arch/bot-icons';
import {
type DSLContext,
type DSLComponent,
type DSLFormFieldCommonProps,
} from '../types';
import { findInputElementById } from '../../../../utils/dsl-template';
import { useChatAreaState } from '../../../../context/chat-area-state';
import styles from './index.module.less';
interface DSLSubmitButtonProps {
formFields?: string[];
}
const useIsSubmitButtonDisable = ({
context: { readonly, dsl },
props: { formFields = [] },
}: {
context: DSLContext;
props: Pick<DSLSubmitButtonProps, 'formFields'>;
}): boolean => {
const formState = useFormState();
const disabled = formFields.some(field => {
const isEmpty = !formState.values[field];
const isError = !!formState.errors?.[field];
const inputDefaultValue = findInputElementById(dsl, field)?.props
?.defaultValue as DSLFormFieldCommonProps['defaultValue'];
if (inputDefaultValue?.value) {
return isError;
}
return isError || isEmpty;
});
const { isSendMessageLock } = useChatAreaState();
return readonly || disabled || isSendMessageLock;
};
export const DSLSubmitButton: DSLComponent<DSLSubmitButtonProps> = ({
context,
props,
}) => {
const isDisabled = useIsSubmitButtonDisable({ context, props });
return (
<div className="flex justify-end">
<IconButton
theme="borderless"
className={styles.button}
htmlType="submit"
size="small"
disabled={isDisabled}
icon={<IconSend />}
/>
</div>
);
};

View File

@@ -0,0 +1,60 @@
/*
* 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 RuleItem } from '@coze-arch/bot-semi/Form';
import { Form } from '@coze-arch/bot-semi';
import { type DSLFormFieldCommonProps, type DSLComponent } from '../types';
import { LabelWithDescription } from '../label-with-desc';
const parseRules = (rules: RuleItem[]): RuleItem[] =>
rules.map(rule => {
if (rule.required) {
return {
...rule,
// required 情况下,禁止输入空格
validator: (r, v) => !!v?.trim(),
};
}
return rule;
});
export const DSLFormInput: DSLComponent<DSLFormFieldCommonProps> = ({
context: { readonly },
props: { name, description, rules, defaultValue, ...props },
}) => {
const required = !defaultValue?.value;
return (
<div>
<LabelWithDescription
required={required}
name={name}
description={description}
/>
<Form.Input
disabled={readonly}
fieldStyle={{ padding: 0 }}
placeholder={defaultValue?.value}
className="w-full"
field={name}
noLabel
rules={parseRules(rules)}
{...props}
/>
</div>
);
};

View File

@@ -0,0 +1,60 @@
/*
* 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 PropsWithChildren, type FC } from 'react';
import { type RuleItem } from '@coze-arch/bot-semi/Form';
import { type InputType } from '@coze-arch/bot-api/playground_api';
import { type DSL } from '../../../types';
export interface FileValue {
fileInstance?: File;
url?: string;
width?: number;
height?: number;
}
export type TValue = string | FileValue | undefined;
export type TCustomUpload = (uploadParams: {
file: File;
onProgress?: (percent: number) => void;
onSuccess?: (url: string, width?: number, height?: number) => void;
onError?: (e: { status?: number }) => void;
}) => void;
export interface DSLContext {
dsl: DSL;
uploadFile?: TCustomUpload;
onChange?: (value: Record<string, TValue>) => void; // 需要兼容 file
onSubmit?: (value: Record<string, TValue>) => void;
readonly?: boolean; // 支持搭建时的预览模式
}
export interface DSLFormFieldCommonProps {
name: string;
description?: string;
rules: RuleItem[];
defaultValue?: {
type: InputType;
value: string;
};
}
export type DSLComponent<TProps = unknown> = FC<
PropsWithChildren<{ context: DSLContext; props: TProps }>
>;

View File

@@ -0,0 +1,85 @@
.upload-button {
min-width: 0;
padding: 8px;
border-style: dashed
}
button.delete-btn {
height: 20px;
line-height: 1;
border-radius: 6px;
}
.delete-icon {
svg {
width: 12px;
height: 12px;
}
}
.file {
position: relative;
padding: 3px;
* {
z-index: 1;
}
&:focus {
border-color: #4E40E5;
}
}
.file-uploading::after {
content: '';
position: absolute;
top: 0;
left: 0;
display: block;
width: var(--var-percent);
min-width: 15%;
height: 100%;
background-color: #e6e8ff;
}
.container.container-error {
.upload-button {
border-color: #F22435;
}
.input {
border-color: #F22435;
}
.file {
border-color: #F22435;
}
}
.retry {
cursor: pointer;
display: flex;
flex-shrink: 0;
flex-wrap: nowrap;
gap: 8px;
align-items: center;
justify-content: flex-start;
margin-right: 12px;
font-size: 12px;
font-weight: 500;
line-height: 16px;
color: #4E40E5;
svg {
width: 12px;
height: 12px;
}
}

View File

@@ -0,0 +1,365 @@
/*
* 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 FC, useState, useRef, useEffect } from 'react';
import classnames from 'classnames';
import { getFileInfo } from '@coze-common/chat-core';
import { I18n } from '@coze-arch/i18n';
import { type FileItem } from '@coze-arch/bot-semi/Upload';
import {
IconButton,
Toast,
Typography,
UIButton,
UIInput,
Upload,
useFieldApi,
withField,
} from '@coze-arch/bot-semi';
import {
IconAdd,
IconClose,
IconCloseNoCycle,
IconCopyLink,
} from '@coze-arch/bot-icons';
import { type shortcut_command } from '@coze-arch/bot-api/playground_api';
import {
type DSLFormFieldCommonProps,
type DSLComponent,
type TValue,
type TCustomUpload,
} from '../types';
import { LabelWithDescription } from '../label-with-desc';
import { getFileInfoByFileType } from '../../../../utils/file-const';
import style from './index.module.less';
const UploadContent: FC<{
file: FileItem;
disabled?: boolean;
inputType: shortcut_command.InputType;
onRemove: () => void;
onRetry: () => void;
}> = ({ file, disabled, inputType, onRemove, onRetry }) => {
const isFailed = file.status === 'uploadFail';
const isUploading = file.status === 'uploading';
const fileType =
file.fileInstance && getFileInfo(file.fileInstance)?.fileType;
const fileIcon = fileType && getFileInfoByFileType(fileType)?.icon;
return (
<div
className={classnames(
style.file,
'flex border border-solid rounded-lg items-center w-full coz-stroke-primary',
{
[style['file-uploading'] || '']: isUploading,
},
)}
style={{
// @ts-expect-error ts 无法识别自定义变量
'--var-percent': `${file.percent}%`,
}}
>
<img
src={fileIcon ?? file.url}
className={classnames(
'w-6 h-6',
fileType === 'image' &&
'rounded border border-solid coz-stroke-primary',
)}
/>
<Typography.Text ellipsis className="mx-2 flex-1 text-sm">
{file.name}
</Typography.Text>
{isFailed ? (
<div
onClick={e => {
e.stopPropagation();
if (!disabled) {
onRetry();
}
}}
className={style.retry}
>
<IconClose className="coz-fg-hglt-red" />
<div>{I18n.t('Retry')}</div>
</div>
) : null}
<IconButton
className={classnames('close-btn w-5 h-5', style['delete-btn'])}
disabled={disabled}
onClick={e => {
e.stopPropagation();
onRemove();
}}
theme="borderless"
size="small"
icon={<IconCloseNoCycle className={style['delete-icon']} />}
/>
</div>
);
};
interface UploadProps {
value?: unknown;
name: string;
onChange?: (value: TValue) => void;
uploadFile?: TCustomUpload;
maxSize?: number;
accept?: string;
disabled?: boolean;
validateStatus?: 'error' | 'success';
inputType: shortcut_command.InputType;
}
const FileUpload: FC<
UploadProps & {
toggle: () => void;
}
> = ({
value,
name,
uploadFile,
onChange,
inputType,
disabled,
toggle,
...props
}) => {
const [file, setFile] = useState<FileItem | undefined>();
const fieldApi = useFieldApi(name);
const uidRef = useRef<string | undefined>(file?.uid);
const onUpload = (newFile: FileItem) => {
if (newFile.fileInstance) {
setFile({
...newFile,
percent: 0,
status: 'uploading',
});
// 立即清理错误状态
fieldApi.setError(true);
uidRef.current = newFile?.uid;
uploadFile?.({
file: newFile.fileInstance,
onProgress: percent => {
if (uidRef.current !== newFile.uid) {
return;
}
setFile({
...newFile,
percent,
status: 'uploading',
});
},
onSuccess: (url, width = 0, height = 0) => {
if (uidRef.current !== newFile.uid) {
return;
}
onChange?.({
fileInstance: newFile.fileInstance,
url,
width,
height,
});
setFile({
...newFile,
response: url,
percent: 100,
status: 'success',
});
},
onError: () => {
if (uidRef.current !== newFile.uid) {
return;
}
// 上传失败,触发错误状态
fieldApi.setError(false);
setFile({
...newFile,
status: 'uploadFail',
});
},
});
}
};
return (
<>
<Upload
action=""
className="w-full"
draggable
limit={1}
{...props}
disabled={disabled}
onAcceptInvalid={() => {
Toast.error(I18n.t('shortcut_Illegal_file_format'));
}}
onSizeError={() => {
if (props.maxSize) {
Toast.error(
I18n.t('file_too_large', {
max_size: `${props.maxSize / 1024}MB`,
}),
);
}
}}
customRequest={({ onSuccess }) => {
// 即使 action="" ,在不传 customRequest 仍然会触发一次向当前 URL 上传文件的请求
// 这里传一个 mock customRequest 来阻止 semi 默认的上传行为
onSuccess('');
}}
showUploadList={false}
onChange={({ currentFile }) => {
// semi 同一个文件会触发多次 onChange这里只响应首个
if (
uidRef.current !== currentFile.uid &&
(!props.maxSize ||
(currentFile.fileInstance?.size &&
currentFile.fileInstance.size <= props.maxSize * 1024))
) {
onUpload(currentFile);
}
}}
>
{file ? (
<UploadContent
file={file}
inputType={inputType}
onRemove={() => {
uidRef.current = undefined;
setFile(undefined);
onChange?.('');
setTimeout(() => {
// 删除文件,清理错误状态避免立刻飘红
fieldApi.setError(true);
});
}}
onRetry={() => {
if (file) {
onUpload(file);
}
}}
/>
) : (
<UIButton
icon={<IconAdd />}
disabled={disabled}
className={classnames(style['upload-button'], 'w-full')}
>
<span className={style['upload-button-text-short']}>
{I18n.t('shortcut_component_upload_component_placeholder')}
</span>
</UIButton>
)}
</Upload>
{!file && (
<IconButton
disabled={disabled}
icon={<IconCopyLink />}
onClick={toggle}
/>
)}
</>
);
};
const FileInput: FC<
UploadProps & {
toggle: () => void;
}
> = ({ disabled, onChange, toggle }) => (
<>
<UIInput disabled={disabled} onChange={onChange} className={style.input} />
<IconButton
disabled={disabled}
icon={<IconCloseNoCycle />}
onClick={toggle}
/>
</>
);
// 为了方便控制向外传递的 value
const UploadInner = withField((props: UploadProps) => {
const [showInput, setShowInput] = useState(false);
const hasError = props.validateStatus === 'error';
const fieldApi = useFieldApi(props.name);
// 避免清空输入导致的飘红
useEffect(() => {
setTimeout(() => {
props.onChange?.('');
// 避免 onchange 触发校验导致立刻飘红
setTimeout(() => {
fieldApi.setError(true);
});
});
}, [showInput]);
return (
<div
className={classnames(
'flex items-center justify-start gap-2',
style.container,
hasError && style['container-error'],
)}
>
{showInput ? (
<FileInput
toggle={() => {
setShowInput(false);
}}
{...props}
/>
) : (
<FileUpload
toggle={() => {
setShowInput(true);
}}
{...props}
/>
)}
</div>
);
});
export const DSLFormUpload: DSLComponent<
DSLFormFieldCommonProps & {
maxSize?: number;
accept?: string;
inputType: shortcut_command.InputType;
}
> = ({
context: { uploadFile, readonly },
props: { name, description, rules, ...props },
}) => (
<div>
<LabelWithDescription name={name} description={description} />
<UploadInner
field={name}
noLabel
name={name}
fieldStyle={{ padding: 0 }}
uploadFile={uploadFile}
disabled={readonly}
rules={readonly ? undefined : rules}
{...props}
/>
</div>
);