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,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 { type FC, useState } from 'react';
import { I18n } from '@coze-arch/i18n';
import { InputComponentType } from '@coze-arch/bot-api/connector_api';
import { type InputConfigFe } from '../types';
import {
useConfigAsserted,
useConfigStoreGuarded,
} from '../context/store-context';
import { ERROR_LINE_HEIGHT } from '../constants';
import { type HeaderItem, SortableFieldTable } from './sortable-field-table';
import {
BaseInputFieldLine,
inputFieldColumnWidth,
type InputComponentOption,
InputLineCommonContext,
} from './field-line/input-config-line';
export const BaseInputFieldsTable: FC = () => {
const config = useConfigAsserted();
const inputFields = config.input_config || [];
const updateConfigByImmer = useConfigStoreGuarded()(
state => state.updateConfigByImmer,
);
const [errorLines, setErrorLines] = useState<string[]>([]);
return (
<div className="mt-[4px]">
<InputLineCommonContext.Provider
value={{
inputFieldsSelectorList: config.input_type_list,
onChange: val => {
updateConfigByImmer(cfg => {
const fields = cfg.input_config;
const idx = fields.findIndex(field => field._id === val._id);
fields[idx] = val;
});
},
inputOptions: getInputOptions(),
onToggleError: (id, error) => {
setErrorLines(lines => {
if (!error) {
return lines.filter(lineId => lineId !== id);
}
const inLines = lines.includes(id);
if (!inLines) {
return [...lines, id];
}
return lines;
});
},
}}
>
<SortableFieldTable<InputConfigFe>
enabled={inputFields.length > 1}
headers={getInputFieldsHeaders()}
data={inputFields.map(field => ({
deletable: field.invalid ?? false,
bizComponent: BaseInputFieldLine,
data: field,
getKey: data => data._id,
onDelete: delItem => {
updateConfigByImmer(cfg => {
cfg.input_config = cfg.input_config.filter(
e => e._id !== delItem._id,
);
});
},
deleteButtonStyle: {
width: 32,
},
lineStyle: {
marginTop: 8,
paddingBottom: errorLines.some(id => id.includes(field._id))
? ERROR_LINE_HEIGHT
: 0,
},
}))}
getId={data => data.data._id}
onChange={mix =>
updateConfigByImmer(cfg => {
cfg.input_config = mix.map(x => x.data);
})
}
/>
</InputLineCommonContext.Provider>
</div>
);
};
const getInputFieldsHeaders = (): HeaderItem[] => [
{
name: I18n.t('publish_base_configFields_field'),
required: false,
width: inputFieldColumnWidth.field,
},
{
name: I18n.t('publish_base_configFields_title'),
required: true,
width: inputFieldColumnWidth.title,
},
{
name: I18n.t('publish_base_configFields_placeholder'),
required: false,
width: inputFieldColumnWidth.placeholder,
},
{
name: I18n.t('publish_base_configFields_component'),
required: true,
width: inputFieldColumnWidth.control,
},
{
name: I18n.t('required'),
required: false,
width: inputFieldColumnWidth.required,
},
];
const getInputOptions = (): InputComponentOption[] => [
{
label: I18n.t('publish_base_inputFieldConfig_textInput'),
value: InputComponentType.Text,
},
{
label: I18n.t('publish_base_inputFieldConfig_singleSelect'),
value: InputComponentType.SingleSelect,
},
{
label: I18n.t('publish_base_inputFieldConfig_multiSelect'),
value: InputComponentType.MultiSelect,
},
{
label: I18n.t('publish_base_inputFieldConfig_fieldSelector'),
value: InputComponentType.FieldSelector,
},
];

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 ReactMarkdown from 'react-markdown';
import {
type FC,
type PropsWithChildren,
useEffect,
useRef,
useState,
} from 'react';
import { nanoid } from 'nanoid';
import classNames from 'classnames';
import { useMutationObserver } from 'ahooks';
import { I18n } from '@coze-arch/i18n';
import { IconCozPlus } from '@coze-arch/coze-design/icons';
import { Button, Select } from '@coze-arch/coze-design';
import {
type OutputTypeInfo,
OutputSubComponentType,
} from '@coze-arch/bot-api/connector_api';
import { getIsStructOutput } from '../validate/utils';
import {
validateOutputStructGroupByKey,
validateOutputStructPrimaryKey,
} from '../validate';
import {
type BaseOutputStructLineType,
type FeishuBaseConfigFe,
type OutputSubComponentFe,
} from '../types';
import { default as mdStyles } from '../md-tooltip/index.module.less';
import { useConfigStoreGuarded } from '../context/store-context';
import { ERROR_LINE_HEIGHT } from '../constants';
import { type HeaderItem, SortableFieldTable } from './sortable-field-table';
import { FormSubtitle } from './form-title';
import { useRequireVerifyCenter } from './field-line/require-verify-center';
import {
BaseOutputStructLine,
OutputLineCommonContext,
outputStructColumnWidth,
type OutputStructVerifyRes,
} from './field-line/output-struct-line';
import styles from './index.module.less';
export const BaseOutputFieldsTable: FC<{
config: FeishuBaseConfigFe;
}> = ({ config }) => {
const outputTypeId = config.output_type;
const updateConfigByImmer = useConfigStoreGuarded()(
state => state.updateConfigByImmer,
);
const outputTypeTips = getOutputInfo(
config.output_type_list,
outputTypeId,
)?.tips;
return (
<div className="mt-[6px]">
<Select
style={{
width: '100%',
}}
defaultValue={config.output_type}
optionList={config.output_type_list.map(info => ({
label: info.name,
value: info.id,
}))}
onChange={val => {
updateConfigByImmer(cfg => {
const type = Number(val);
cfg.output_type = type;
if (getIsStructOutput(type)) {
cfg.output_sub_component.type = OutputSubComponentType.Object;
const itemList = cfg.output_sub_component.item_list;
if (!itemList?.length) {
cfg.output_sub_component.item_list = [
getDefaultStructFieldItem(),
];
}
} else {
cfg.output_sub_component.type = OutputSubComponentType.None;
}
});
}}
/>
{getIsStructOutput(outputTypeId) ? (
<OutputStructConfig config={config} />
) : null}
{outputTypeTips ? (
<div
className={classNames(
'rounded-[8px] px-[8px] py-[12px] coz-mg-hglt mt-[8px]',
'text-[12px] leading-[16px] coz-fg-primary',
)}
style={{
// eslint-disable-next-line @typescript-eslint/ban-ts-comment -- .
// @ts-expect-error
'--tooltip-content-max-width': '580px',
}}
>
<ReactMarkdown className={mdStyles.md_wrap}>
{outputTypeTips}
</ReactMarkdown>
</div>
) : null}
</div>
);
};
const getOutputInfo = (
infoList: OutputTypeInfo[],
id: number,
): OutputTypeInfo | undefined => infoList.find(info => info.id === id);
const getDefaultStructFieldItem = (): BaseOutputStructLineType => ({
key: '',
output_type: undefined,
_id: nanoid(),
});
const OutputStructConfig: FC<{
config: FeishuBaseConfigFe;
}> = ({ config }) => {
const structFields = config.output_sub_component.item_list;
const updateConfigByImmer = useConfigStoreGuarded()(
state => state.updateConfigByImmer,
);
const [requiredToCheck, setRequiredToCheck] = useState(false);
const { registerVerifyFn } = useRequireVerifyCenter();
const [errorLines, setErrorLinesRaw] = useState<string[]>([]);
const setErrorLines = (hasError: boolean, id: string) => {
setErrorLinesRaw(lines => {
if (!hasError) {
return lines.filter(lineId => lineId !== id);
}
const inLines = lines.includes(id);
if (!inLines) {
return [...lines, id];
}
return lines;
});
};
useEffect(() => {
const unregister = registerVerifyFn(() => setRequiredToCheck(true));
return unregister;
}, []);
return (
<>
<div className="ml-[8px] mt-[16px]">
<FormSubtitle
title={I18n.t('publish_base_config_structOutputConfig')}
required
tooltip={config.output_sub_component.struct_output_desc}
suffix={
<Button
icon={<IconCozPlus />}
onClick={() => {
updateConfigByImmer(cfg => {
const newList = cfg.output_sub_component.item_list || [];
newList.push(getDefaultStructFieldItem());
cfg.output_sub_component.item_list = newList;
});
}}
color="secondary"
size="small"
className="ml-auto"
>
{I18n.t('Add_1')}
</Button>
}
/>
</div>
<OutputLineCommonContext.Provider
value={{
onChange: val => {
updateConfigByImmer(cfg => {
const fields = cfg.output_sub_component.item_list;
if (!fields) {
return;
}
const idx =
fields?.findIndex(field => field._id === val._id) ?? -1;
if (idx < 0) {
return;
}
fields[idx] = val;
});
},
list: config.object_value_type_list,
getShowRequireWarn: curLine => {
const res = getShowRequireWarnImpl({
curLine,
allFields: config.output_sub_component?.item_list || [],
requiredToCheck,
});
return res;
},
onToggleError: (id, error) => {
setErrorLines(error, id);
},
}}
>
<SortableFieldTable<BaseOutputStructLineType>
linesWrapper={OutputConfigLinesWrapper}
enabled={(structFields?.length || 0) > 1}
headers={getBaseInfoHeaders(config.output_sub_component)}
onChange={mix =>
updateConfigByImmer(cfg => {
cfg.output_sub_component.item_list = mix.map(m => m.data);
})
}
getId={mix => mix.data._id}
style={{
marginTop: 6,
}}
data={(structFields || []).map(field => ({
bizComponent: BaseOutputStructLine,
deletable: (structFields || []).length > 1,
data: field,
getKey: data => data._id,
onDelete: delItem => {
updateConfigByImmer(cfg => {
const list = cfg.output_sub_component.item_list;
if (!list) {
return;
}
cfg.output_sub_component.item_list = list.filter(
item => item._id !== delItem._id,
);
});
},
lineStyle: {
paddingBottom: errorLines.some(id => id.includes(field._id))
? ERROR_LINE_HEIGHT
: 0,
},
deleteButtonStyle: {
width: 24,
minWidth: 0,
height: 24,
padding: 0,
},
}))}
/>
</OutputLineCommonContext.Provider>
</>
);
};
const OutputConfigLinesWrapper: FC<PropsWithChildren> = ({ children }) => {
const wrapRef = useRef<HTMLDivElement | null>(null);
const [withScrollbar, setWithScrollbar] = useState(false);
const detectScrollbar = () => {
if (!wrapRef.current) {
return;
}
const isVerticalScrollbar =
wrapRef.current.scrollHeight > wrapRef.current.clientHeight;
setWithScrollbar(isVerticalScrollbar);
};
useEffect(detectScrollbar, []);
useMutationObserver(detectScrollbar, wrapRef, {
childList: true,
});
return (
<div
ref={wrapRef}
className={classNames(
'overflow-x-hidden',
!withScrollbar && 'pr-[8px]',
styles.output_config,
)}
>
{children}
</div>
);
};
const getBaseInfoHeaders = (
outputComponent: OutputSubComponentFe,
): HeaderItem[] => [
{
name: I18n.t('publish_base_configFields_key'),
required: true,
width: outputStructColumnWidth.key,
},
{
name: I18n.t('publish_base_configStruct_dataType'),
required: true,
width: outputStructColumnWidth.outputType,
},
{
name: I18n.t('publish_base_configStruct_id'),
required: true,
width: outputStructColumnWidth.groupByKey,
tooltip: outputComponent.struct_id_desc,
},
{
name: I18n.t('publish_base_configStruct_primary'),
required: true,
width: outputStructColumnWidth.primary,
tooltip: outputComponent.struct_primary_desc,
style: I18n.language.includes('zh')
? {}
: {
fontSize: 12,
lineHeight: '16px',
display: 'inline-block',
},
},
];
const getShowRequireWarnImpl = ({
curLine,
allFields,
requiredToCheck,
}: {
curLine: BaseOutputStructLineType;
allFields: BaseOutputStructLineType[];
requiredToCheck: boolean;
}): OutputStructVerifyRes => {
const idx = allFields.findIndex(line => line._id === curLine._id);
const res: OutputStructVerifyRes = {
groupByKey: {
warn: false,
},
primary: {
warn: false,
},
};
if (!requiredToCheck || idx > 0) {
return res;
}
const groupByKeyVerifyRes = validateOutputStructGroupByKey(allFields);
const primaryKeyVerifyRes = validateOutputStructPrimaryKey(allFields);
if (!groupByKeyVerifyRes.ok) {
res.groupByKey.tip = groupByKeyVerifyRes.error;
res.groupByKey.warn = true;
}
if (!primaryKeyVerifyRes.ok) {
res.primary.tip = primaryKeyVerifyRes.error;
res.primary.warn = true;
}
return res;
};

View File

@@ -0,0 +1,5 @@
.input_deletable {
:global(.semi-input-suffix) {
margin: 0;
}
}

View File

@@ -0,0 +1,161 @@
/*
* 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 { createContext, type FC, useContext } from 'react';
import { nanoid } from 'nanoid';
import { produce } from 'immer';
import classNames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { IconCozPlus, IconCozTrashCan } from '@coze-arch/coze-design/icons';
import { Button, Input } from '@coze-arch/coze-design';
import { SortableFieldTable } from '../sortable-field-table';
import type { InputComponentSelectOption, InputConfigFe } from '../../types';
import styles from './index.module.less';
const SelectSubEditContext = createContext<{
onChange?: (choice: InputComponentSelectOption) => void;
onDelete?: (data: InputComponentSelectOption) => void;
choiceLength?: number;
}>({});
export const SelectSubEditComponent: FC<{
config: InputConfigFe;
onUpdate: (cfg: InputConfigFe) => void;
}> = ({ config, onUpdate }) => (
<div>
<div className="flex ml-[8px] mt-[20px]">
<span className="coz-fg-secondary text-[12px] font-medium leading-[16px]">
{I18n.t('publish_base_inputFieldConfig_options')}
</span>
<Button
icon={<IconCozPlus />}
onClick={() => {
onUpdate(
produce<InputConfigFe>(cfg => {
cfg.input_component.choice.push({
name: '',
id: nanoid(),
});
})(config),
);
}}
color="secondary"
size="small"
className="ml-auto"
>
{I18n.t('Add_1')}
</Button>
</div>
<SelectSubEditContext.Provider
value={{
onChange: choiceItem => {
onUpdate(
produce<InputConfigFe>(cfg => {
const { choice } = cfg.input_component;
const idx = choice.findIndex(i => i.id === choiceItem.id);
choice.splice(idx, 1, choiceItem);
})(config),
);
},
choiceLength: config.input_component.choice?.length || 0,
onDelete: delData => {
onUpdate(
produce<InputConfigFe>(cfg => {
cfg.input_component.choice = cfg.input_component.choice.filter(
it => it.id !== delData.id,
);
})(config),
);
},
}}
>
<SortableFieldTable<InputComponentSelectOption>
enabled={config.input_component.choice.length > 1}
headless
style={{
padding: 0,
}}
headers={[
{
width: 192,
name: '',
required: false,
},
]}
data={config.input_component.choice.map(data => ({
data,
deletable: false,
lineStyle: {
paddingRight: 0,
paddingTop: 8,
},
getKey: it => it.id,
bizComponent: SelectEditLine,
}))}
getId={data => data.data.id}
onChange={data => {
const choice = data.map(it => it.data);
onUpdate(
produce<InputConfigFe>(cfg => {
cfg.input_component.choice = choice;
})(config),
);
}}
/>
</SelectSubEditContext.Provider>
</div>
);
const SelectEditLine: FC<{
data: InputComponentSelectOption;
}> = ({ data }) => {
const { onChange, onDelete, choiceLength } = useContext(SelectSubEditContext);
if (choiceLength === undefined || !onChange || !onDelete) {
throw new Error('impossible context member miss');
}
return (
<Input
value={data.name}
onChange={str => {
onChange({
id: data.id,
name: str,
});
}}
className={classNames(styles.input_deletable, 'w-full mr-[8px]')}
suffix={
choiceLength <= 1 ? null : (
<Button
color="secondary"
onClick={() => onDelete(data)}
icon={<IconCozTrashCan />}
style={{
width: 24,
height: 24,
minWidth: 0,
padding: 0,
}}
></Button>
)
}
/>
);
};

View File

@@ -0,0 +1,386 @@
/*
* 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 { createContext, type FC, useContext, useState } from 'react';
import { nanoid } from 'nanoid';
import { produce } from 'immer';
import classnames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { IconCozInfoCircle } from '@coze-arch/coze-design/icons';
import {
Button,
Input,
InputNumber,
Popover,
Radio,
RadioGroup,
Select,
Tag,
Typography,
} from '@coze-arch/coze-design';
import {
InputComponentType,
type InputTypeInfo,
} from '@coze-arch/bot-api/connector_api';
import { getIsSelectType } from '../../validate/utils';
import { validateSingleInputFieldControl } from '../../validate';
import { type InputConfigFe } from '../../types';
import { type ConfigStoreState } from '../../store';
import { MdTooltip } from '../../md-tooltip';
import { useConfigStoreGuarded } from '../../context/store-context';
import { INPUT_CONFIG_TEXT_MAX_CHAR } from '../../constants';
import { BigCheckbox } from '../../big-checkbox';
import { useRequireVerify } from './use-require-verify';
import { RequiredWarn } from './required-warn';
import { SelectSubEditComponent } from './input-config-line-select-edit';
export const inputFieldColumnWidth = {
field: 110,
title: 118,
placeholder: 118,
control: 118,
required: 60,
};
export interface InputComponentOption {
label: string;
value: InputComponentType;
}
const USER_QUERY_FIELD_NAME = 'user_query';
const INVALID_LINE_OPACITY = 0.3;
export const InputLineCommonContext = createContext<{
onChange?: (val: InputConfigFe) => void;
inputFieldsSelectorList?: InputTypeInfo[];
inputOptions?: InputComponentOption[];
onToggleError?: (id: string, error: boolean) => void;
}>({});
const getInputConfig = (storeState: ConfigStoreState, id: string) => {
const data = storeState.config?.input_config.find(item => item._id === id);
if (!data) {
throw new Error(`cannot find data of ${id}`);
}
return data;
};
export const BaseInputFieldLine: FC<{
data: InputConfigFe;
// eslint-disable-next-line @coze-arch/max-line-per-function -- 鼠鼠我呀,也很无奈
}> = ({ data: { _id: id } }) => {
const { onChange, inputFieldsSelectorList, inputOptions, onToggleError } =
useContext(InputLineCommonContext);
const store = useConfigStoreGuarded();
const data = useConfigStoreGuarded()(state => getInputConfig(state, id));
if (!data) {
throw new Error(`cannot find data of ${id}`);
}
if (
!inputFieldsSelectorList ||
!onChange ||
!inputOptions ||
!onToggleError
) {
throw new Error('impossible context member miss');
}
const changeByImmer = (updater: (sth: InputConfigFe) => void) => {
onChange(produce<InputConfigFe>(updater)(data));
};
const isUserQuery = data.field === USER_QUERY_FIELD_NAME;
const [showPopover, setShowPopover] = useState(false);
const getVal = () => getInputConfig(store.getState(), id);
const titleRequire = useRequireVerify({
getVal,
verify: config => !!config?.title,
onChange: isError => onToggleError(`${id}#title`, isError),
});
const controlRequire = useRequireVerify({
getVal,
verify: config => !!config?.input_component.type,
onChange: isError => onToggleError(`${id}#control`, isError),
});
return (
<>
<div
className="coz-fg-secondary text-[14px] leading-[20px] flex items-center"
style={{
width: inputFieldColumnWidth.field,
opacity: data.invalid ? INVALID_LINE_OPACITY : 1,
}}
>
<Typography.Text
className={classnames('mr-[3px] coz-fg-secondary')}
ellipsis={{ showTooltip: true }}
>
{data.field}
</Typography.Text>
{data.desc ? (
<MdTooltip content={data.desc} tooltipPosition="right">
<Button
className="!w-[14px] !h-[16px] !min-w-0 !p-0"
theme="borderless"
type="secondary"
color="secondary"
icon={<IconCozInfoCircle className="text-[12px]" />}
/>
</MdTooltip>
) : null}
{data.invalid ? (
<Tag color="primary" size="mini">
{I18n.t('publish_base_configFields_invalid')}
</Tag>
) : null}
</div>
<div
style={{
width: inputFieldColumnWidth.title,
position: 'relative',
}}
>
<Input
error={titleRequire.showWarn}
onBlur={titleRequire.onTrigger}
value={data.title}
onChange={val =>
changeByImmer(origin => {
origin.title = val;
})
}
placeholder={I18n.t('publish_base_configFields_title_placeholder')}
disabled={data.invalid}
maxLength={30}
/>
{titleRequire.showWarn ? <RequiredWarn /> : null}
</div>
<Input
value={data.placeholder}
onChange={val =>
changeByImmer(origin => {
origin.placeholder = val;
})
}
placeholder={I18n.t(
'publish_base_configFields_placeholder_placeholder',
)}
style={{
width: inputFieldColumnWidth.placeholder,
}}
disabled={data.invalid}
maxLength={30}
/>
<Popover
visible={showPopover}
trigger="custom"
position="top"
content={
<InputFieldControlConfig
originConfig={data}
inputOptions={inputOptions}
closePanel={() => {
setShowPopover(false);
controlRequire.onTrigger();
}}
onUpdate={(config: InputConfigFe) => {
onChange(config);
}}
inputFieldsSelectorList={inputFieldsSelectorList}
/>
}
>
<div
style={{
width: inputFieldColumnWidth.control,
position: 'relative',
}}
>
<div
onClick={() => {
if (!data.invalid) {
setShowPopover(true);
}
}}
>
<Select
disabled={data.invalid}
optionList={inputOptions}
value={data.input_component.type}
className="w-full"
dropdownStyle={{
display: 'none',
}}
renderOptionItem={() => null}
placeholder={I18n.t(
'publish_base_configFields_component_placeholder',
)}
hasError={controlRequire.showWarn}
/>
</div>
{controlRequire.showWarn ? <RequiredWarn /> : null}
</div>
</Popover>
{data.invalid ? null : (
<BigCheckbox
style={{
marginLeft: 'auto',
}}
checked={isUserQuery || data.required}
disabled={isUserQuery}
onChange={e => {
const val = Boolean(e.target.checked);
changeByImmer(cur => {
cur.required = val;
});
}}
/>
)}
</>
);
};
const InputFieldControlConfig: FC<{
onUpdate: (config: InputConfigFe) => void;
inputOptions: InputComponentOption[];
inputFieldsSelectorList: InputTypeInfo[];
originConfig: InputConfigFe;
closePanel: () => void;
}> = ({
inputOptions,
onUpdate: submitSubConfig,
originConfig,
closePanel,
inputFieldsSelectorList,
}) => {
const [config, setConfig] = useState(() => originConfig);
const fieldsSelectorOptions = inputFieldsSelectorList.map(opt => ({
value: opt.id,
label: opt.name,
}));
return (
<div className="pl-[12px] pb-[16px]">
<div className="overflow-y-auto max-h-[320px] pt-[12px] pr-[12px]">
<div className="ml-[8px]">
<p className="coz-fg-secondary text-[12px] font-medium leading-[16px]">
{I18n.t('publish_base_configFields_component')}
</p>
</div>
<RadioGroup
className="mx-[8px] mt-[14px] grid grid-cols-2 gap-[12px]"
defaultValue={config.input_component.type}
onChange={val => {
const type = val.target.value as InputComponentType;
setConfig(
produce<InputConfigFe>(curConfig => {
curConfig.input_component.type = type;
if (
getIsSelectType(type) &&
!curConfig.input_component.choice?.length
) {
curConfig.input_component.choice.push({
name: '',
id: nanoid(),
});
}
}),
);
}}
>
{inputOptions.map(option => (
<Radio key={option.value} value={option.value} className="">
{option.label}
</Radio>
))}
</RadioGroup>
{config.input_component.type === InputComponentType.Text ? (
<div className="ml-[8px] mt-[20px]">
<div className="coz-fg-secondary text-[12px] font-medium leading-[16px]">
{I18n.t('publish_base_inputFieldConfig_maxChars')}
</div>
<InputNumber
style={{
marginTop: 6,
}}
defaultValue={config.input_component.max_char}
max={INPUT_CONFIG_TEXT_MAX_CHAR}
min={config.required ? 1 : 0}
onChange={val => {
setConfig(
produce<InputConfigFe>(curConfig => {
curConfig.input_component.max_char = Number(val);
}),
);
}}
/>
</div>
) : null}
{getIsSelectType(config.input_component.type) ? (
<SelectSubEditComponent config={config} onUpdate={setConfig} />
) : null}
{config.input_component.type === InputComponentType.FieldSelector ? (
<div>
<div className="flex ml-[8px] mt-[20px]">
<span className="coz-fg-secondary text-[12px] font-medium leading-[16px]">
{I18n.t('publish_base_inputFieldConfig_supports')}
</span>
</div>
<Select
style={{
width: 256,
marginTop: 6,
}}
optionList={fieldsSelectorOptions}
multiple
defaultValue={config.input_component.supported_type}
maxTagCount={2}
expandRestTagsOnClick
onChange={valRaw => {
const val = valRaw as number[];
setConfig(
produce<InputConfigFe>(curConfig => {
curConfig.input_component.supported_type = val;
}),
);
}}
></Select>
</div>
) : null}
</div>
<div className="flex gap-[8px] items-center mt-[24px] mr-[12px]">
<Button color="primary" onClick={closePanel} className="ml-auto">
{I18n.t('Cancel')}
</Button>
<Button
disabled={!validateSingleInputFieldControl(config)}
onClick={() => {
submitSubConfig(config);
closePanel();
}}
>
{I18n.t('Confirm')}
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,234 @@
/*
* 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, createContext, useContext, useEffect } from 'react';
import { I18n } from '@coze-arch/i18n';
import { Input, Select } from '@coze-arch/coze-design';
import { type OutputTypeInfo } from '@coze-arch/bot-api/connector_api';
import { getIsNumberOutput, getIsTextOutput } from '../../validate/utils';
import { type BaseOutputStructLineType } from '../../types';
import type { ConfigStoreState } from '../../store';
import { useConfigStoreGuarded } from '../../context/store-context';
import { BigCheckbox } from '../../big-checkbox';
import { useRequireVerify } from './use-require-verify';
import { RequiredWarn } from './required-warn';
export const OutputLineCommonContext = createContext<{
onChange?: (val: BaseOutputStructLineType) => void;
list?: OutputTypeInfo[];
getShowRequireWarn?: (val: BaseOutputStructLineType) => OutputStructVerifyRes;
onToggleError?: (id: string, error: boolean) => void;
}>({});
export interface OutputStructVerifyRes {
groupByKey: {
warn: boolean;
tip?: string;
};
primary: {
warn: boolean;
tip?: string;
};
}
const getOutputFieldConfig = (storeState: ConfigStoreState, id: string) => {
const data = storeState.config?.output_sub_component.item_list?.find(
item => item._id === id,
);
if (!data) {
throw new Error(`cannot find data of ${id}`);
}
return data;
};
export const BaseOutputStructLine: FC<{
data: BaseOutputStructLineType;
// eslint-disable-next-line @coze-arch/max-line-per-function -- /
}> = ({ data: { _id: id } }) => {
const { list, onChange, getShowRequireWarn, onToggleError } = useContext(
OutputLineCommonContext,
);
const store = useConfigStoreGuarded();
const data = useConfigStoreGuarded()(state =>
getOutputFieldConfig(state, id),
);
if (!data) {
throw new Error(`cannot find data of ${id}`);
}
if (!list || !onChange || !getShowRequireWarn || !onToggleError) {
throw new Error('impossible context member miss');
}
const { groupByKey: groupByKeyRequire, primary: primaryRequire } =
getShowRequireWarn(data);
const getVal = () => getOutputFieldConfig(store.getState(), id);
const keyRequire = useRequireVerify({
getVal,
verify: config => !!config?.key,
onChange: isError => onToggleError(`${id}#key`, isError),
});
const typeRequire = useRequireVerify({
getVal,
verify: config => Number.isInteger(config.output_type),
onChange: isError => onToggleError(`${id}$type`, isError),
});
useEffect(() => {
const hasError = groupByKeyRequire.warn || primaryRequire.warn;
onToggleError(data._id, hasError);
}, [groupByKeyRequire.warn, primaryRequire.warn]);
return (
<>
<div
style={{
width: outputStructColumnWidth.key,
margin: '6px 0',
position: 'relative',
}}
>
<Input
error={keyRequire.showWarn}
value={data.key}
onBlur={keyRequire.onTrigger}
placeholder={I18n.t('publish_base_configFields_key_placeholder')}
onChange={val => {
onChange({
...data,
key: val,
});
}}
/>
{keyRequire.showWarn ? <RequiredWarn /> : null}
</div>
<div
style={{
width: outputStructColumnWidth.outputType,
position: 'relative',
}}
>
<Select
defaultValue={data.output_type}
optionList={list.map(info => ({
value: info.id,
label: info.name,
}))}
placeholder={I18n.t('publish_base_configFields_dataType_placeholder')}
onBlur={typeRequire.onTrigger}
onChange={val => {
onChange({
...data,
output_type: Number(val),
});
typeRequire.onTrigger();
}}
hasError={typeRequire.showWarn}
style={{
width: '100%',
}}
/>
{typeRequire.showWarn ? <RequiredWarn /> : null}
</div>
<div
style={{
width: outputStructColumnWidth.groupByKey,
position: 'relative',
}}
>
<BigCheckbox
checked={data.is_group_by_key}
/**
* is_group_by_key: 只允许提交 text 类型
* 可以切换场景:
* 1. 已经勾选:任意类型
* 2. 未勾选:仅 text 类型
*/
disabled={
!(data.is_group_by_key || getIsTextOutput(data.output_type))
}
isError={groupByKeyRequire.warn}
onChange={e => {
const val = Boolean(e.target.checked);
onChange({
...data,
is_group_by_key: val,
});
}}
/>
{groupByKeyRequire.warn ? (
<RequiredWarn
text={groupByKeyRequire.tip}
style={{
marginLeft: 0,
}}
/>
) : null}
</div>
<div
style={{
width: outputStructColumnWidth.primary,
position: 'relative',
}}
>
<BigCheckbox
checked={data.is_primary}
isError={primaryRequire.warn}
/**
* is_primary: 只允许提交 text 或 number 类型
* 可以切换场景:
* 1. 已经勾选:任意类型
* 2. 未勾选:仅 text 与 number 类型
*/
disabled={
!(
data.is_primary ||
getIsNumberOutput(data.output_type) ||
getIsTextOutput(data.output_type)
)
}
onChange={e => {
const val = Boolean(e.target.checked);
onChange({
...data,
is_primary: val,
});
}}
/>
{primaryRequire.warn ? (
<RequiredWarn
text={primaryRequire.tip}
style={{
marginLeft: 0,
}}
/>
) : null}
</div>
</>
);
};
const FIRST_TWO_COLUMN_TRANSFER_SPACE = 30;
// 总和为 566滚动条留 8左侧拖拽按钮 16gap 8 * 4 删除按钮 24
// (566 - 8 - 16 - 4 * 8 - 24 - (44 + 96)) / 2 = 173
export const outputStructColumnWidth = {
key: 173 + FIRST_TWO_COLUMN_TRANSFER_SPACE,
outputType: 173 - FIRST_TWO_COLUMN_TRANSFER_SPACE,
groupByKey: 44,
// 给国际化预留一些宽度
primary: 96,
};

View File

@@ -0,0 +1,68 @@
/*
* 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 {
createContext,
type FC,
type PropsWithChildren,
useContext,
useRef,
} from 'react';
interface FieldsRequireCenter {
verifyFns: Set<() => void>;
triggerAllVerify: () => void;
registerVerifyFn: (fn: () => void) => () => void;
}
const FieldsRequireCenterContext = createContext<FieldsRequireCenter>({
verifyFns: new Set(),
triggerAllVerify: () => undefined,
registerVerifyFn: () => () => undefined,
});
export const FieldsRequireCenterWrapper: FC<PropsWithChildren> = ({
children,
}) => {
const fns = useRef(new Set<() => void>());
return (
<FieldsRequireCenterContext.Provider
value={{
verifyFns: fns.current,
triggerAllVerify: () => {
fns.current.forEach(fn => fn());
},
registerVerifyFn: fn => {
fns.current.add(fn);
return () => {
fns.current.delete(fn);
};
},
}}
>
{children}
</FieldsRequireCenterContext.Provider>
);
};
FieldsRequireCenterWrapper.displayName = 'FieldsRequireCenterWrapper';
export const useRequireVerifyCenter = (): Omit<
FieldsRequireCenter,
'verifyFns'
> => useContext(FieldsRequireCenterContext);

View File

@@ -0,0 +1,51 @@
/*
* 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 CSSProperties, type FC } from 'react';
import { merge } from 'lodash-es';
import classNames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { ERROR_LINE_HEIGHT } from '../../constants';
export const RequiredWarn: FC<{
text?: string;
className?: string;
style?: CSSProperties;
absolute?: boolean;
}> = props => {
const { text, style, className, absolute = true } = props;
return (
<div
className={classNames(
className,
'coz-fg-hglt-red text-[10px]',
'ml-[8px]',
'whitespace-nowrap',
absolute ? 'absolute' : '',
)}
style={merge(
{
lineHeight: `${ERROR_LINE_HEIGHT}px`,
},
style,
)}
>
{text || I18n.t('publish_base_configFields_requiredWarn')}
</div>
);
};

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 { useEffect, useState } from 'react';
import { useRequireVerifyCenter } from './require-verify-center';
export const useRequireVerify = <T>({
getVal,
verify,
onChange,
}: {
getVal: () => T;
verify: (val: T) => boolean;
onChange?: (isError: boolean) => void;
}) => {
const [showWarn, setShowWarn] = useState(false);
const { registerVerifyFn } = useRequireVerifyCenter();
const onTrigger = () => {
const val = getVal();
const verified = verify(val);
const isError = !verified;
setShowWarn(isError);
onChange?.(isError);
};
useEffect(() => {
const unregister = registerVerifyFn(onTrigger);
return unregister;
}, []);
return {
showWarn,
onTrigger,
};
};

View File

@@ -0,0 +1,80 @@
/*
* 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 CSSProperties, type FC, type ReactNode } from 'react';
import classNames from 'classnames';
import { IconCozInfoCircle } from '@coze-arch/coze-design/icons';
import { Tooltip } from '@coze-arch/coze-design';
import { MdTooltip } from '../md-tooltip';
export const FormTitle: FC<{
title: string;
tooltip?: string;
style?: CSSProperties;
required?: boolean;
}> = ({ title, style, tooltip, required }) => (
<p
style={style}
className={classNames(
'text-[16px]',
'coz-fg-plus',
'font-medium',
'leading-[22px]',
'flex',
'items-center',
)}
>
<span>{title}</span>
{required ? (
<i className="coz-fg-hglt-red text-[12px] font-medium">*</i>
) : null}
{tooltip ? (
<Tooltip content={tooltip}>
<span className="cursor-pointer ml-[4px] h-[22px] flex items-center">
<IconCozInfoCircle className="text-[14px] coz-fg-secondary" />
</span>
</Tooltip>
) : null}
</p>
);
export const FormSubtitle: FC<{
title: string;
required: boolean;
tooltip?: string;
style?: CSSProperties;
suffix?: ReactNode;
}> = ({ title, required, tooltip, style, suffix }) => (
<p
className={classNames('flex', 'justify-start', 'items-center')}
style={style}
>
<span className="text-[12px] coz-fg-secondary leading-[16px] font-medium">
{title}
</span>
{required ? (
<i className="coz-fg-hglt-red text-[12px] font-medium">*</i>
) : null}
<MdTooltip content={tooltip}>
<span className="cursor-pointer ml-[4px] h-[16px] flex items-center">
<IconCozInfoCircle className="text-[12px] coz-fg-secondary" />
</span>
</MdTooltip>
{suffix}
</p>
);

View File

@@ -0,0 +1,10 @@
.output_config {
&::-webkit-scrollbar-thumb {
background: var(--coz-fg-dim);
border-radius: 3px;
}
&::-webkit-scrollbar {
width: 7px;
}
}

View File

@@ -0,0 +1,403 @@
/*
* 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 ReactMarkdown from 'react-markdown';
import {
forwardRef,
useImperativeHandle,
useRef,
useState,
memo,
type FC,
} from 'react';
import { nanoid } from 'nanoid';
import { cloneDeep, omit } from 'lodash-es';
import { useRequest } from 'ahooks';
import { I18n } from '@coze-arch/i18n';
import {
IconCozCross,
IconCozLongArrowUp,
} from '@coze-arch/coze-design/icons';
import {
Button,
IconButton,
Modal,
Spin,
Tag,
Toast,
} from '@coze-arch/coze-design';
import { useSpaceStore } from '@coze-arch/bot-studio-store';
import { type PublishConnectorInfo } from '@coze-arch/bot-api/developer_api';
import {
type FeishuBaseConfig,
type InputComponent,
type InputConfig,
type OutputSubComponent,
type OutputSubComponentItem,
} from '@coze-arch/bot-api/connector_api';
import { connectorApi, DeveloperApi } from '@coze-arch/bot-api';
import { OUTPUT_TYPE_TEXT } from '../validate/utils';
import { useSubscribeAndUpdateConfig } from '../validate/field-interaction';
import { validateFullConfig } from '../validate';
import {
type FeishuBaseConfigFe,
type InputComponentFe,
type InputConfigFe,
type OutputSubComponentFe,
type SaveConfigPayload,
} from '../types';
import { type ConfigStore, createConfigStore } from '../store';
import { LoadFailedDisplay } from '../expection-display';
import { StoreContext, useConfigAsserted } from '../context/store-context';
import { StepIndicator } from './step-indicator';
import { FormSubtitle, FormTitle } from './form-title';
import {
FieldsRequireCenterWrapper,
useRequireVerifyCenter,
} from './field-line/require-verify-center';
import { BaseOutputFieldsTable } from './base-output-fields-table';
import { BaseInputFieldsTable } from './base-input-fields-table';
export const JumpButton: FC<{
url: string;
completed: boolean;
}> = ({ url, completed }) => (
<Button
color="secondary"
onClick={() => {
window.open(url);
}}
icon={<IconCozLongArrowUp className="rotate-45" />}
iconPosition="right"
size="small"
className={!completed ? '!coz-fg-hglt' : ''}
>
{!completed
? I18n.t('publish_base_configFields_complete_Information_fill_out')
: I18n.t('publish_base_configFields_complete_Information_edit')}
</Button>
);
export const FeishuBaseModal = memo(
forwardRef<
{
openModal: () => void;
},
{
botId: string;
record: PublishConnectorInfo;
onSaved: (id: string) => void;
}
>(({ botId, record, onSaved }, ref) => {
const [showModal, setShowModal] = useState(false);
useImperativeHandle(ref, () => ({
openModal: () => {
setShowModal(true);
run();
},
}));
const storeRef = useRef<ConfigStore | null>(null);
const formRef = useRef<{
configFormSubmit: () => void;
} | null>(null);
if (!storeRef.current) {
storeRef.current = createConfigStore();
}
useSubscribeAndUpdateConfig(storeRef.current);
const { data, loading, run, mutate, cancel, error } = useRequest(
async () => {
const { config } = await connectorApi.GetFeishuBaseConfig({
bot_id: botId,
});
if (!config) {
return undefined;
}
return convertBaseConfig(config);
},
{
manual: true,
onSuccess: res => {
if (!res) {
return;
}
storeRef.current?.getState().setConfig(res);
},
},
);
const hideModalAndClearData = () => {
setShowModal(false);
cancel();
mutate();
};
return (
<Modal
visible={showModal}
onCancel={hideModalAndClearData}
closeOnEsc={false}
maskClosable={false}
footer={
<Button
color="hgltplus"
size="default"
onClick={() => {
formRef.current?.configFormSubmit();
}}
>
{I18n.t('Confirm')}
</Button>
}
size="large"
linearGradientMask
header={
<div className="flex items-center justify-between h-[40px]">
<span className="text-[20px] font-medium leading-[28px] coz-fg-primary">
{I18n.t('publish_base_config_configFeishuBase')}
</span>
<IconButton
onClick={hideModalAndClearData}
icon={<IconCozCross className="text-[18px]" />}
className="w-[40px] !h-[40px] -pr-2"
color="secondary"
/>
</div>
}
>
<Spin spinning={loading}>
{data?.description ? (
<ReactMarkdown
linkTarget="_blank"
className="coz-fg-secondary text-[14px] leading-[20px]"
>
{data.description}
</ReactMarkdown>
) : null}
{data ? (
<StoreContext.Provider value={{ store: storeRef.current }}>
<FieldsRequireCenterWrapper>
<ConfigForm
ref={formRef}
record={record}
botId={botId}
onSaved={() => {
onSaved(record.id);
setShowModal(false);
}}
/>
</FieldsRequireCenterWrapper>
</StoreContext.Provider>
) : (
<div className="h-[60px]" />
)}
{!data && error ? <LoadFailedDisplay /> : null}
</Spin>
</Modal>
);
}),
);
const ConfigForm = forwardRef<
{ configFormSubmit: () => void },
{
botId: string;
record: PublishConnectorInfo;
onSaved: () => void;
}
>(({ botId, record, onSaved }, ref) => {
const config = useConfigAsserted();
const { input_desc, output_desc, to_complete_info } = config;
const { url = '', completed = false } = to_complete_info ?? {};
const couldSubmit = validateFullConfig(config);
const { triggerAllVerify } = useRequireVerifyCenter();
useImperativeHandle(ref, () => ({
configFormSubmit: () => {
triggerAllVerify();
if (!couldSubmit) {
Toast.error({
content: I18n.t('publish_base_configFields_ unfinished_toast'),
});
return;
}
submitConfig();
},
}));
const { run: submitConfig } = useRequest(
() => {
const spaceId = useSpaceStore.getState().getSpaceId();
return DeveloperApi.BindConnector({
space_id: spaceId,
bot_id: botId,
connector_id: record.id,
connector_info: {
config: JSON.stringify(getSubmitPayload(config)),
},
});
},
{
manual: true,
onSuccess: () => {
Toast.success({
content: I18n.t('Save_success'),
});
onSaved();
},
},
);
return (
<div className="mt-[28px] pb-[32px]">
<div>
<div className="flex items-center gap-2">
<StepIndicator number={1} />
<FormTitle title={I18n.t('publish_base_config_configBaseInfo')} />
</div>
<FormSubtitle
required
title={I18n.t('publish_base_config_configOutputType')}
tooltip={output_desc}
style={{
marginTop: 9,
}}
/>
</div>
<BaseOutputFieldsTable config={config} />
<div className="mt-[32px]">
<div className="flex items-center gap-2">
<StepIndicator number={2} />
<FormTitle
title={I18n.t('publish_base_configFields')}
tooltip={input_desc}
/>
</div>
</div>
<BaseInputFieldsTable />
{to_complete_info ? (
<>
<div className="flex items-center gap-2 mt-6">
<StepIndicator number={3} />
<FormTitle
title={I18n.t(
'publish_base_configFields_complete_Information_title',
)}
required
/>
{completed ? (
<>
<Tag color="green">
{I18n.t('publish_base_configFields_status_completed')}
</Tag>
<JumpButton completed={completed} url={url} />
</>
) : null}
</div>
{!completed ? (
<div className="mt-[6px] flex items-center">
{I18n.t(
'publish_base_configFields_complete_Information_describe',
)}
<JumpButton completed={completed} url={url} />
</div>
) : null}
</>
) : null}
</div>
);
});
const getSubmitPayload = (config: FeishuBaseConfigFe): SaveConfigPayload => {
const res: SaveConfigPayload = cloneDeep({
output_type: config.output_type,
input_config: config.input_config.map(cfg => {
const inputConfig: InputConfig = {
...cfg,
input_component: reverseInputComponent(cfg.input_component),
};
return omit(inputConfig, '_id');
}),
output_sub_component: reverseOutputSubComponent(
config.output_sub_component,
),
});
res.output_sub_component.item_list = (
res.output_sub_component.item_list || []
).map(cfg => omit(cfg, '_id'));
return res;
};
const reverseOutputSubComponent = (
output: OutputSubComponentFe,
): OutputSubComponent => ({
...output,
item_list: (output.item_list || []).map(item => {
const res: OutputSubComponentItem = {
...omit(item, '_id'),
output_type: item.output_type ?? OUTPUT_TYPE_TEXT,
};
return res;
}),
});
const convertInputComponent = (cfg: InputComponent): InputComponentFe => {
const { choice } = cfg;
const res: InputComponentFe = {
...cfg,
choice: (choice || []).map(c => ({
name: c,
id: nanoid(),
})),
};
return res;
};
const reverseInputComponent = (cfg: InputComponentFe): InputComponent => {
const { choice } = cfg;
const res: InputComponent = {
...cfg,
choice: choice.map(c => c.name),
};
return res;
};
const convertBaseConfig = (config: FeishuBaseConfig): FeishuBaseConfigFe => {
const configFe: FeishuBaseConfigFe = {
...config,
output_sub_component: {
...config.output_sub_component,
item_list: (config.output_sub_component.item_list || []).map(item => ({
...item,
_id: nanoid(),
})),
},
input_config: config.input_config?.map(cfg => {
const res: InputConfigFe = {
...cfg,
input_component: convertInputComponent(cfg.input_component),
_id: nanoid(),
};
return res;
}),
};
return configFe;
};

View File

@@ -0,0 +1,240 @@
/*
* 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 ComponentType,
type CSSProperties,
type FC,
forwardRef,
type JSX,
type PropsWithChildren,
type ReactElement,
type ReactNode,
useEffect,
useMemo,
useRef,
} from 'react';
import classNames from 'classnames';
import { SortableList } from '@coze-studio/components/sortable-list';
import { type ITemRenderProps, type ConnectDnd } from '@coze-studio/components';
import { IconCozHandle, IconCozTrashCan } from '@coze-arch/coze-design/icons';
import { Button } from '@coze-arch/coze-design';
import { MdTooltip } from '../md-tooltip';
export interface HeaderItem {
name: string;
required: boolean;
width: number;
tooltip?: string;
style?: CSSProperties;
}
export interface SortableFieldTableMethod {
addRow: () => boolean;
}
export interface IData<Data extends object> {
data: Data;
deletable: boolean;
getKey: (data: Data) => string;
onDelete?: (data: Data) => void;
bizComponent: ComponentType<{ data: Data }>;
lineStyle?: CSSProperties;
deleteButtonStyle?: CSSProperties;
}
interface SortableFieldTableProps<Data extends object> {
className?: string;
headers: HeaderItem[];
data: IData<Data>[];
getId: (data: IData<Data>) => string;
onChange: (data: IData<Data>[]) => void;
headless?: boolean;
style?: CSSProperties;
enabled: boolean;
linesWrapper?: ComponentType;
}
const DefaultLinesWrapper: FC<PropsWithChildren> = ({ children }) => (
<>{children}</>
);
export const SortableFieldTable = <T extends object>({
className,
headers,
data,
getId,
onChange,
headless,
enabled,
style,
linesWrapper,
}: SortableFieldTableProps<T>): ReactElement => {
const uniqueSymbol = useMemo(() => Symbol(), []);
const LinesWrapper = linesWrapper || DefaultLinesWrapper;
return (
<div
className={classNames(
headless ? '' : 'coz-bg-primary',
headless ? '' : 'coz-stroke-primary border-solid border-[1px]',
'px-[12px] pt-[12px] pb-[12px]',
'rounded-[8px]',
className,
)}
style={style}
>
{headless ? null : <FieldTableHeader headers={headers} />}
<LinesWrapper>
<SortableList
enabled={enabled}
getId={getId}
list={data}
onChange={onChange}
itemRender={ItemRender as never}
type={uniqueSymbol}
/>
</LinesWrapper>
</div>
);
};
const ItemRender = <Data extends object>(
props: ITemRenderProps<IData<Data>>,
): JSX.Element => {
const { data: bizProps } = props;
const BizComponent = bizProps.bizComponent;
return (
<FieldSortLine
gap={8}
connect={props.connect}
deletable={bizProps.deletable}
onDelete={() => bizProps.onDelete?.(bizProps.data)}
style={bizProps.lineStyle}
deleteButtonStyle={bizProps.deleteButtonStyle}
>
<BizComponent key={bizProps.getKey(bizProps.data)} data={bizProps.data} />
</FieldSortLine>
);
};
const TableFieldLine = forwardRef<
HTMLDivElement,
PropsWithChildren<{
className?: string;
gap?: number;
prefix?: ReactNode;
style?: CSSProperties;
}>
>(({ children, className, gap, prefix, style }, ref) => (
<div
className={classNames(className, 'flex items-center')}
ref={ref}
style={style}
>
{prefix}
<div
className="flex items-center w-full"
style={{
gap,
}}
>
{children}
</div>
</div>
));
TableFieldLine.displayName = 'TableFieldLine';
const FieldSortLine: FC<
PropsWithChildren<{
deletable?: boolean;
connect: ConnectDnd;
gap?: number;
style?: CSSProperties;
deleteButtonStyle?: CSSProperties;
onDelete: () => void;
}>
> = ({
children,
deletable,
connect,
gap,
style,
onDelete,
deleteButtonStyle,
}) => {
const dropRef = useRef<HTMLDivElement | null>(null);
const dragRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
connect(dropRef, dragRef);
}, []);
return (
<TableFieldLine
style={style}
gap={gap}
ref={dropRef}
prefix={
<div className="cursor-grab h-full mr-[4px] w-[12px]" ref={dragRef}>
<IconCozHandle className="text-[12px]" />
</div>
}
>
{children}
{deletable ? (
<Button
color="secondary"
onClick={onDelete}
style={deleteButtonStyle}
icon={<IconCozTrashCan />}
></Button>
) : null}
</TableFieldLine>
);
};
const FieldTableHeader: FC<{ headers: HeaderItem[] }> = ({ headers }) => (
<TableFieldLine
className="border-0 border-b-[1px] coz-stroke-primary border-solid h-[28px] mb-[12px]"
gap={8}
prefix={
<div
style={{
minWidth: 16,
}}
/>
}
>
{headers.map(header => (
<div
key={header.name}
className={classNames(
'text-[14px] coz-fg-secondary font-medium leading-[20px]',
'inline-flex items-center',
)}
style={{
width: header.width,
...header.style,
}}
>
{header.name}
{header.required ? <i className="coz-fg-hglt-red">*</i> : null}
<MdTooltip content={header.tooltip} />
</div>
))}
</TableFieldLine>
);

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 { type CSSProperties, type FC } from 'react';
import classNames from 'classnames';
export const StepIndicator: FC<{
number: number;
className?: string;
style?: CSSProperties;
}> = ({ number, className, style }) => (
<div
style={style}
className={classNames(
className,
'coz-mg-hglt',
'w-[20px]',
'h-[20px]',
'coz-fg-hglt',
'text-[14px]',
'font-medium',
'flex',
'items-center',
'justify-center',
'rounded-[50%]',
)}
>
{number}
</div>
);