feat: manually mirror opencoze's code from bytedance
Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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}</>;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
.button {
|
||||
//
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 }>
|
||||
>;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
Reference in New Issue
Block a user