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,35 @@
.popup_container {
display: flex;
flex-direction: column;
position: relative;
.nano {
position: absolute;
top: 0;
right: 0;
width: 100%;
height: 100%;
align-self: stretch;
pointer-events: none;
:global {
.semi-portal-inner {
left: 50% !important;
}
}
}
}
.tooltip {
&.top-level {
word-break: break-word;
border-radius: 6px;
background: var(--light-color-grey-grey-7, #41414C);
max-width: 400px;
color: var(--light-usage-bg-color-bg-0, #FFF);
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 22px;
}
& > svg > path {
fill: var(--light-color-grey-grey-7, #41414C);
}
}

View File

@@ -0,0 +1,75 @@
/*
* 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, { type PropsWithChildren, useRef } from 'react';
import classNames from 'classnames';
import { type TooltipProps } from '@coze-arch/bot-semi/Tooltip';
import { Tooltip } from '@coze-arch/bot-semi';
import styles from './index.module.less';
type AutoSizeTooltipProps = PropsWithChildren<
{
className?: string;
style?: React.CSSProperties;
containerClassName?: string;
containerStyle?: React.CSSProperties;
tooltipClassName?: string;
tooltipStyle?: React.CSSProperties;
} & Omit<TooltipProps, 'className' | 'style'>
>;
export default function AutoSizeTooltip({
children,
className,
style,
tooltipClassName,
tooltipStyle,
containerClassName,
containerStyle,
...rest
}: AutoSizeTooltipProps) {
const nanoRef = useRef<HTMLDivElement | null>(null);
const renderContent = () => (
<>
<div
ref={nanoRef}
className={classNames(styles.nano, containerClassName)}
style={containerStyle}
/>
<Tooltip
{...rest}
className={classNames(
styles.tooltip,
styles['top-level'],
tooltipClassName,
)}
style={{ left: 0, ...tooltipStyle }}
>
{children}
</Tooltip>
</>
);
return (
<div
className={classNames(styles.popup_container, className)}
style={style}
>
{renderContent()}
</div>
);
}

View File

@@ -0,0 +1,234 @@
/** 基本组件样式 */
.dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: gray;
}
.empty-block {
flex: 1;
}
.help-line-block {
flex: 1;
position: relative;
width: 100%;
height: 100%;
.line {
position: absolute;
top: 0;
left: 0;
width: 1px;
height: 100%;
background: gray;
}
}
/** 业务组件样式 */
.half-top-root {
flex: 1;
position: relative;
width: 100%;
height: 100%;
.line {
position: absolute;
top: 0;
left: 0;
border-left: 1px solid gray;
border-bottom: 1px solid gray;
border-radius: 0 0 0 4px;
width: 80%;
height: 22px;
}
.dot {
display: none;
}
&.children {
.dot {
display: block;
position: absolute;
top: 22px;
left: 70%;
transform: translate3d(0, -60%, 0);
}
}
}
.half-bottom-root {
flex: 1;
position: relative;
width: 100%;
height: 100%;
.line {
position: absolute;
top: 22px;
left: 0;
border-left: 1px solid gray;
border-top: 1px solid gray;
border-radius: 4px 0 0;
width: 80%;
height: calc(100% - 12px);
}
.dot {
display: none;
}
&.children {
.dot {
display: block;
position: absolute;
top: 22px;
left: 70%;
transform: translate3d(0, -40%, 0);
}
}
}
.full-root {
flex: 1;
position: relative;
width: 100%;
height: 100%;
.top-line {
position: absolute;
top: 0;
left: 0;
width: 80%;
height: 22px;
border-left: 1px solid gray;
border-bottom: 1px solid gray;
border-radius: 0 0 0 4px;
}
.bottom-line {
position: absolute;
top: 19px;
left: 0;
width: 1px;
height: calc(100% - 19px);
background: gray;
}
.dot {
display: none;
}
&.children {
.dot {
display: block;
position: absolute;
top: 22px;
left: 70%;
transform: translate3d(0, -60%, 0);
}
}
}
.half-top-child {
flex: 1;
position: relative;
width: 100%;
height: 100%;
.line {
position: absolute;
top: 0;
left: 0;
border-left: 1px solid gray;
border-bottom: 1px solid gray;
border-radius: 0 0 0 4px;
width: 80%;
height: 22px;
}
.dot {
display: none;
}
&.children {
.dot {
display: block;
position: absolute;
top: 22px;
left: 70%;
transform: translate3d(0, -60%, 0);
}
}
}
.full-child {
flex: 1;
position: relative;
width: 100%;
height: 100%;
.top-line {
position: absolute;
top: 0;
left: 0;
width: 80%;
height: 22px;
border-left: 1px solid gray;
border-bottom: 1px solid gray;
border-radius: 0 0 0 4px;
}
.bottom-line {
position: absolute;
top: 19px;
left: 0;
width: 1px;
height: calc(100% - 19px);
background: gray;
}
.dot {
display: none;
}
&.children {
.dot {
display: block;
position: absolute;
top: 22px;
left: 70%;
transform: translate3d(0, -60%, 0);
}
}
}
.multiline {
.line {
position: absolute;
top: -51px;
height: 73px;
}
.top-line {
position: absolute;
top: -51px;
height: 73px;
}
&.with-name-error {
.line {
position: absolute;
top: -7px;
height: 29px;
}
.top-line {
position: absolute;
top: -7px;
height: 29px;
}
}
}

View File

@@ -0,0 +1,262 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import { LineShowResult, getLineShowResult } from '@/parameters/utils/utils';
import { type TreeNodeCustomData } from '../../type';
import styles from './index.module.less';
interface IconComponentProps {
className?: string;
style?: React.CSSProperties;
}
export function Dot({ className, style }: IconComponentProps) {
return <div className={classNames(styles.dot, className)} style={style} />;
}
export function EmptyBlock({ className, style }: IconComponentProps) {
return (
<div
className={classNames(styles['empty-block'], className)}
style={style}
/>
);
}
export function HelpLineBlock({ className, style }: IconComponentProps) {
return (
<div
className={classNames(styles['help-line-block'], className)}
style={style}
>
<div className={styles.line} />
</div>
);
}
export function HalfTopRoot({ className, style }: IconComponentProps) {
return (
<div
className={classNames(styles['half-top-root'], className)}
style={style}
>
<div className={styles.line} />
<Dot className={styles.dot} />
</div>
);
}
export function HalfTopRootWithChildren({
className,
style,
}: IconComponentProps) {
return (
<HalfTopRoot
className={classNames(styles.children, className)}
style={style}
/>
);
}
export function HalfBottomRoot({ className, style }: IconComponentProps) {
return (
<div
className={classNames(styles['half-bottom-root'], className)}
style={style}
>
<div className={styles.line} />
<Dot className={styles.dot} />
</div>
);
}
export function HalfBottomRootWithChildren({
className,
style,
}: IconComponentProps) {
return (
<HalfBottomRoot
className={classNames(styles.children, className)}
style={style}
/>
);
}
export function FullRoot({ className, style }: IconComponentProps) {
return (
<div className={classNames(styles['full-root'], className)} style={style}>
<div className={styles['top-line']} />
<div className={styles['bottom-line']} />
<Dot className={styles.dot} />
</div>
);
}
export function FullRootWithChildren({ className, style }: IconComponentProps) {
return (
<FullRoot
className={classNames(styles.children, className)}
style={style}
/>
);
}
export function HalfTopChild({ className, style }: IconComponentProps) {
return (
<div
className={classNames(styles['half-top-child'], className)}
style={style}
>
<div className={styles.line} />
<Dot className={styles.dot} />
</div>
);
}
export function HalfTopChildWithChildren({
className,
style,
}: IconComponentProps) {
return (
<HalfTopChild
className={classNames(styles.children, className)}
style={style}
/>
);
}
export function FullChild({ className, style }: IconComponentProps) {
return (
<div className={classNames(styles['full-child'], className)} style={style}>
<div className={styles['top-line']} />
<div className={styles['bottom-line']} />
<Dot className={styles.dot} />
</div>
);
}
export function FullChildWithChildren({
className,
style,
}: IconComponentProps) {
return (
<FullChild
className={classNames(styles.children, className)}
style={style}
/>
);
}
interface LevelLineProps {
level: number;
data: TreeNodeCustomData;
className?: string;
style?: React.CSSProperties;
multiInfo?: {
multiline: boolean;
withNameError?: boolean;
};
}
export default function LevelLine({
level,
data,
className,
style,
multiInfo = { multiline: false },
}: LevelLineProps) {
// getLineShowResult 返回数据,暂时没涉及到 root 画线
const lineShowResult = getLineShowResult({ level, data });
const showMap: Record<LineShowResult, React.ReactNode> = {
[LineShowResult.HalfTopRoot]: (
<HalfTopRoot className={className} style={style} />
),
[LineShowResult.HalfTopRootWithChildren]: (
<HalfTopRootWithChildren className={className} style={style} />
),
[LineShowResult.HalfBottomRoot]: (
<HalfBottomRoot className={className} style={style} />
),
[LineShowResult.HalfBottomRootWithChildren]: (
<HalfBottomRootWithChildren className={className} style={style} />
),
[LineShowResult.FullRoot]: <FullRoot className={className} style={style} />,
[LineShowResult.FullRootWithChildren]: (
<FullRootWithChildren className={className} style={style} />
),
// 在 output tree 中,暂时没涉及到 root 画线
[LineShowResult.HalfTopChild]: (
<HalfTopChild
className={classNames(
multiInfo?.multiline ? styles.multiline : null,
multiInfo?.withNameError ? styles['with-name-error'] : null,
className,
)}
style={style}
/>
),
[LineShowResult.HalfTopChildWithChildren]: (
<HalfTopChildWithChildren
className={classNames(
multiInfo?.multiline ? styles.multiline : null,
multiInfo?.withNameError ? styles['with-name-error'] : null,
className,
)}
style={style}
/>
),
[LineShowResult.FullChild]: (
<FullChild
className={classNames(
multiInfo?.multiline ? styles.multiline : null,
multiInfo?.withNameError ? styles['with-name-error'] : null,
className,
)}
style={style}
/>
),
[LineShowResult.FullChildWithChildren]: (
<FullChildWithChildren
className={classNames(
multiInfo?.multiline ? styles.multiline : null,
multiInfo?.withNameError ? styles['with-name-error'] : null,
className,
)}
style={style}
/>
),
[LineShowResult.EmptyBlock]: (
<EmptyBlock className={className} style={style} />
),
[LineShowResult.HelpLineBlock]: (
<HelpLineBlock className={className} style={style} />
),
};
return (
<>
{lineShowResult.map((item, index) => (
<React.Fragment key={index}>{showMap[item]}</React.Fragment>
))}
</>
);
}

View File

@@ -0,0 +1,21 @@
.container {
display: flex;
flex: 1;
position: relative;
align-self: stretch;
flex-direction: column;
margin-left: 8px;
.desc-not-focus {
textarea {
padding: 0 12px;
}
}
.desc-not-focus-with-value {
textarea {
padding: 0 12px;
-webkit-line-clamp: 1;
}
}
}

View File

@@ -0,0 +1,90 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useState } from 'react';
import cs from 'classnames';
import { I18n } from '@coze-arch/i18n';
import WorkflowSLTextArea from '../workflow-sl-textarea';
import { type TreeNodeCustomData } from '../../type';
import { DescriptionLine } from '../../constants';
import styles from './index.module.less';
interface ParamNameProps {
data: TreeNodeCustomData;
disabled?: boolean;
onChange: (desc: string) => void;
onLineChange?: (type: DescriptionLine) => void;
hasObjectLike?: boolean;
}
export default function ParamDescription({
data,
disabled,
onChange,
onLineChange,
hasObjectLike,
}: ParamNameProps) {
const [inputFocus, setInputFocus] = useState(false);
return (
<div className={styles.container}>
<WorkflowSLTextArea
className={cs(
inputFocus
? null
: data.description
? styles['desc-not-focus-with-value']
: styles['desc-not-focus'],
styles.desc,
hasObjectLike ? styles['desc-object-like'] : null,
)}
value={data.description}
ellipsis={true}
// 好像不生效
disabled={disabled}
handleBlur={() => {
setInputFocus(false);
onLineChange?.(DescriptionLine.Single);
}}
handleChange={(desc: string) => {
onChange(desc);
}}
handleFocus={() => {
setInputFocus(true);
onLineChange?.(DescriptionLine.Multi);
}}
textAreaProps={
inputFocus
? {
placeholder: I18n.t('workflow_detail_llm_output_decription'),
maxLength: 50,
rows: 2,
autosize: false,
maxCount: 50,
}
: {
placeholder: I18n.t('workflow_detail_llm_output_decription'),
rows: 1,
autosize: false,
}
}
/>
</div>
);
}

View File

@@ -0,0 +1,24 @@
.container {
display: flex;
position: relative;
align-self: stretch;
flex-direction: column;
flex: 1;
&.withDescription {
flex: auto;
flex-grow: 0;
flex-shrink: 0;
}
.name {
display: flex;
align-items: flex-start;
align-self: stretch;
width: 100%;
&>div:first-child {
flex: 1;
}
}
}

View File

@@ -0,0 +1,75 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useState } from 'react';
import type { CSSProperties } from 'react';
import cx from 'classnames';
import { I18n } from '@coze-arch/i18n';
import useErrorMessage from '@/parameters/hooks/use-error-message';
import useConfig from '@/parameters/hooks/use-config';
import WorkflowSLInput from '../workflow-sl-input';
import { type TreeNodeCustomData } from '../../type';
import styles from './index.module.less';
interface ParamNameProps {
data: TreeNodeCustomData;
disabled?: boolean;
style?: CSSProperties;
onChange: (name: string) => void;
}
export default function ParamName({
disabled,
data,
style,
onChange,
}: ParamNameProps) {
const errorMessage = useErrorMessage('name');
const [slient, setSlient] = useState(true);
const showError = slient === false && errorMessage;
const { withDescription } = useConfig();
return (
<div
className={cx(styles.container, {
[styles.withDescription]: withDescription,
})}
style={style}
>
<WorkflowSLInput
className={styles.name}
value={data.name || ''}
disabled={disabled}
handleBlur={() => setSlient(false)}
handleChange={(name: string) => {
setSlient(true);
onChange(name);
}}
inputProps={{
size: 'small',
placeholder: I18n.t('workflow_detail_end_output_entername'),
disabled,
}}
errorMsg={showError ? errorMessage : ''}
validateStatus={showError ? 'error' : 'default'}
/>
</div>
);
}

View File

@@ -0,0 +1,56 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import { UIIconButton } from '@coze-arch/bot-semi';
import { IconAdd } from '@coze-arch/bot-icons';
import styles from './index.module.less';
type AddOperationProps = React.PropsWithChildren<{
readonly?: boolean;
onClick: React.MouseEventHandler<HTMLButtonElement>;
className?: string;
style?: React.CSSProperties;
disabled?: boolean;
}>;
export default function AddOperation({
readonly,
onClick,
className,
style,
disabled,
}: AddOperationProps) {
if (readonly) {
return null;
}
return (
<UIIconButton
onClick={onClick}
className={classNames(
styles.container,
disabled ? styles.disabled : null,
className,
)}
style={style}
icon={<IconAdd className={styles.icon} />}
disabled={disabled}
/>
);
}

View File

@@ -0,0 +1,41 @@
.container {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: flex-start;
align-self: stretch;
width: 24px;
height: 24px;
margin-left: 8px;
.icon-no {
display: flex;
align-items: center;
.icon {
width: 20px;
height: 20px;
cursor: pointer;
color: #888D92;
&.disabled {
cursor: not-allowed;
}
&>svg {
width: 20px;
height: 20px;
}
}
}
.add {
margin-left: 8px;
color: #4D53E8;
cursor: pointer;
font-size: 12px;
font-style: normal;
font-weight: 600;
line-height: 16px;
}
}

View File

@@ -0,0 +1,89 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import classNames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { Tooltip } from '@coze-arch/bot-semi';
import { IconNo } from '@coze-arch/bot-icons';
import { OperatorLargeSize, OperatorSmallSize } from '@/parameters/constants';
import { type TreeNodeCustomData } from '../../type';
import { ObjectLikeTypes } from '../../constants';
import AddOperation from './add-operation';
import styles from './index.module.less';
interface ParamOperatorProps {
data: TreeNodeCustomData;
level: number;
onAppend: () => void;
onDelete: () => void;
disableDelete: boolean;
hasObjectLike?: boolean;
}
export default function ParamOperator({
data,
level,
onDelete,
onAppend,
disableDelete,
hasObjectLike,
}: ParamOperatorProps) {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
const isLimited = level >= 3;
// 是否展示新增子项的按钮
const needRenderAppendChild =
ObjectLikeTypes.includes(data.type) && !isLimited;
const computedOperatorStyle = (): React.CSSProperties => {
if (!hasObjectLike) {
return { width: OperatorSmallSize };
}
return { width: OperatorLargeSize };
};
return (
<div className={styles.container} style={computedOperatorStyle()}>
<div
className={styles['icon-no']}
onClick={() => {
if (disableDelete) {
return;
}
onDelete();
}}
>
<IconNo
className={classNames({
[styles.icon]: true,
[styles.disabled]: disableDelete,
})}
/>
</div>
{needRenderAppendChild && (
<div className={styles.add}>
<Tooltip content={I18n.t('workflow_detail_node_output_add_subitem')}>
<div>
<AddOperation onClick={onAppend} />
</div>
</Tooltip>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,66 @@
.container {
display: flex;
// align-items: center;
position: relative;
align-self: stretch;
flex-direction: column;
width: 155px;
margin-left: 8px;
.pop-container {
align-self: self-start;
width: 100%;
}
.param-type {
width: 100%;
height: 24px;
padding: 0 1px;
}
// 已经无用,可以删除,添加子项按钮已经放在单独组件中了
.param-operator {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: flex-start;
align-self: stretch;
width: 86px;
height: 24px;
margin-left: 8px;
.icon-no {
display: flex;
align-items: center;
.icon {
width: 20px;
height: 20px;
cursor: pointer;
color: #888D92;
&.disabled {
cursor: not-allowed;
}
&>svg {
width: 20px;
height: 20px;
}
}
}
.add {
margin-left: 8px;
color: #4D53E8;
cursor: pointer;
font-size: 12px;
font-style: normal;
font-weight: 600;
line-height: 16px;
}
}
}

View File

@@ -0,0 +1,75 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { I18n } from '@coze-arch/i18n';
import { type SelectProps } from '@coze-arch/bot-semi/Select';
import { Select } from '@coze-arch/bot-semi';
import convertMapToOptions from '@/parameters/utils/convert-map-to-options';
import { PARAM_TYPE_ALIAS_MAP, ParamTypeAlias } from '@/parameters/types';
import PopupContainer from '../popup-container';
import { type TreeNodeCustomData } from '../../type';
import { ObjectLikeTypes } from '../../constants';
import styles from './index.module.less';
interface ParamTypeProps {
data: TreeNodeCustomData;
level: number;
onSelectChange?: SelectProps['onChange'];
disabled?: boolean;
// 不支持使用的类型
disabledTypes?: ParamTypeAlias[];
}
export default function ParamType({
data,
onSelectChange,
level,
disabled,
disabledTypes = [],
}: ParamTypeProps) {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
const isLimited = level >= 3;
return (
<div className={styles.container}>
<PopupContainer className={styles['pop-container']}>
<Select
placeholder={I18n.t('workflow_detail_start_variable_type')}
disabled={disabled}
onChange={val => {
onSelectChange?.(val);
}}
className={styles['param-type']}
optionList={convertMapToOptions(PARAM_TYPE_ALIAS_MAP, {
computedValue: Number,
passItem: item => item === ParamTypeAlias.List.toString(),
}).map(item => ({
...item,
disabled:
disabledTypes?.includes(Number(item.value)) ||
(isLimited && ObjectLikeTypes.includes(Number(item.value))),
}))}
value={data.type}
/>
</PopupContainer>
</div>
);
}

View File

@@ -0,0 +1,14 @@
.popup-container {
display: flex;
flex-direction: column;
}
.popup-container-id {
position: relative;
:global {
.semi-portal-inner {
top: 2px !important;
left: 0 !important;
width: 100%;
}
}
}

View File

@@ -0,0 +1,65 @@
/*
* 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, {
type PropsWithChildren,
type ReactElement,
useMemo,
} from 'react';
import { nanoid } from 'nanoid';
import classNames from 'classnames';
import s from './index.module.less';
export const PopupContainer: React.FC<
PropsWithChildren<{
className?: string;
containerName?: string;
containerClassName?: string;
containerStyle?: React.CSSProperties;
}>
> = ({
className,
children,
containerName,
containerClassName,
containerStyle,
}) => {
const _nanoid = useMemo(
() => `${containerName || 'popup_container'}_${nanoid()}`,
[containerName],
);
const _children = React.cloneElement(children as unknown as ReactElement, {
getPopupContainer: () => document.getElementById(_nanoid) as HTMLElement,
});
return (
<div className={classNames(s['popup-container'], className)}>
{_children}
<div
id={_nanoid}
style={containerStyle}
className={classNames([
'nowheel',
s['popup-container-id'],
containerClassName,
])}
></div>
</div>
);
};
export default PopupContainer;

View File

@@ -0,0 +1,47 @@
.input-wrapper {
display: flex;
flex-direction: column;
width: 100%;
>* {
width: 100%;
}
span {
width: 100%;
}
}
.error-content {
height: 20px;
}
.error-float {
width: 100%;
position: absolute;
}
.error-text {
font-size: 14px;
font-family: "SF Pro Display", -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif;
line-height: 22px;
color: var(--semi-color-danger)
}
.input {
input {
text-overflow: ellipsis;
overflow: hidden;
}
}
.limit-count {
padding-left: 8px;
padding-right: 12px;
overflow: hidden;
color: var(--light-usage-text-color-text-3, rgba(28, 31, 35, 0.35));
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px;
}

View File

@@ -0,0 +1,192 @@
/*
* 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, {
type ComponentProps,
useRef,
useImperativeHandle,
useMemo,
useEffect,
type ForwardedRef,
} from 'react';
import isNumber from 'lodash-es/isNumber';
import cs from 'classnames';
import { useReactive } from 'ahooks';
import { type TooltipProps } from '@coze-arch/bot-semi/Tooltip';
import { type InputProps } from '@coze-arch/bot-semi/Input';
import { UIInput } from '@coze-arch/bot-semi';
import AutoSizeTooltip from '../auto-size-tooltip';
import s from './index.module.less';
export interface WorkflowSLInputRefType {
triggerFocus?: () => void;
}
export type WorkflowSLInputProps = ComponentProps<typeof UIInput> & {
value: string | undefined;
onRef?: ForwardedRef<WorkflowSLInputRefType>;
ellipsis?: boolean;
handleChange?: (v: string) => void;
handleBlur?: (v: string) => void;
handleFocus?: (v: string) => void;
ellipsisTooltipProps?: TooltipProps;
onFocusTooltipProps?: TooltipProps;
tooltipProps?: TooltipProps;
inputProps?: InputProps;
errorMsg?: string;
errorMsgFloat?: boolean;
maxCount?: number;
className?: string;
style?: React.CSSProperties;
};
const SL_INPUT_TIMEOUT = 10;
export default function WorkflowSLInput(props: WorkflowSLInputProps) {
const { ellipsis = true, maxCount } = props;
const showCount = isNumber(maxCount) && maxCount > 0;
useImperativeHandle(props.onRef, () => ({
triggerFocus,
}));
const $state = useReactive({
value: props.value,
inputOnFocus: false,
inputEle: false,
});
const inputRef = useRef<HTMLInputElement>(null);
const triggerFocus = () => {
$state.inputEle = true;
inputRef?.current?.focus();
};
const onFocus = () => {
$state.inputOnFocus = true;
$state.inputEle = true;
props?.handleFocus?.($state.value || '');
};
const onBlur = (e: React.FocusEvent<HTMLInputElement>) => {
$state.inputOnFocus = false;
props?.handleBlur?.($state.value || '');
props?.onBlur?.(e);
$state.inputEle = false;
};
const onChange = (v: string) => {
$state.value = v;
props?.handleChange?.(v);
};
const onclick = () => {
if (!$state.inputEle) {
setTimeout(() => {
inputRef?.current?.focus();
}, SL_INPUT_TIMEOUT);
}
$state.inputEle = true;
};
const hasEllipsis = useMemo(() => {
const clientWidth = inputRef.current?.clientWidth || 0;
const scrollWidth = inputRef.current?.scrollWidth || 0;
return clientWidth < scrollWidth - 1;
}, [
ellipsis,
$state.inputOnFocus,
$state.value,
inputRef.current?.clientWidth,
inputRef.current?.scrollWidth,
$state.inputEle,
]);
useEffect(() => {
$state.value = props.value;
}, [props.value]);
const LimitCountNode = (
<span className={s['limit-count']}>
{$state.value?.length || 0}/{maxCount}
</span>
);
return (
<div
className={cs(s['input-wrapper'], props.className)}
style={props.style}
>
{!$state.inputEle && hasEllipsis ? (
<AutoSizeTooltip
content={$state.value}
position={'top'}
showArrow
mouseEnterDelay={300}
{...props.tooltipProps}
>
<div
className={cs(props?.errorMsg ? s['error-wrapper'] : null)}
onClick={onclick}
>
<UIInput
{...props.inputProps}
validateStatus={props.validateStatus}
ref={inputRef}
value={$state.value}
className={ellipsis ? s.input : ''}
suffix={showCount ? LimitCountNode : undefined}
></UIInput>
</div>
</AutoSizeTooltip>
) : (
<div className={cs(props?.errorMsg ? s['error-wrapper'] : null)}>
<AutoSizeTooltip
{...props.onFocusTooltipProps}
trigger="custom"
visible={
Boolean(props.onFocusTooltipProps?.content) && $state.inputOnFocus
}
showArrow
>
<UIInput
{...props.inputProps}
validateStatus={props.validateStatus}
ref={inputRef}
value={$state.value}
className={ellipsis ? s.input : ''}
onChange={onChange}
onFocus={onFocus}
onBlur={onBlur}
suffix={showCount ? LimitCountNode : undefined}
></UIInput>
</AutoSizeTooltip>
</div>
)}
{props?.errorMsg && (
<div
className={cs(
s['error-content'],
props?.errorMsgFloat ? s['error-float'] : null,
)}
>
<div className={s['error-text']}>{props?.errorMsg}</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,61 @@
// todo: 这里需要修复为正确的值
@error-red: #ddd;
.input-wrapper {
width: 100%;
span {
width: 100%;
}
}
.error-wrapper {
:global {
.semi-input-wrapper {
border: 1px solid @error-red;
}
}
}
.error-content {
height: 20px;
}
.error-float {
position: absolute;
width: 100%;
}
.error-text {
position: absolute;
padding-top: 2px;
padding-left: 12px;
font-size: 12px;
color: @error-red;
}
.textarea-pd {
padding-bottom: 0;
}
.inputting {
textarea {
.textarea-pd;
}
}
.input-blur {
textarea {
.textarea-pd;
overflow: hidden;
display: -webkit-box;
text-overflow: ellipsis;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
}

View File

@@ -0,0 +1,213 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable @coze-arch/max-line-per-function */
import React, {
type ComponentProps,
useRef,
useImperativeHandle,
useMemo,
useEffect,
type ForwardedRef,
} from 'react';
import cs from 'classnames';
import { useReactive } from 'ahooks';
import { type TooltipProps } from '@coze-arch/bot-semi/Tooltip';
import { type TextAreaProps } from '@coze-arch/bot-semi/Input';
import { TextArea } from '@coze-arch/bot-semi';
import AutoSizeTooltip from '../auto-size-tooltip';
import s from './index.module.less';
export interface WorkflowSLTextAreaRefType {
triggerFocus?: () => void;
}
export type WorkflowSLTextAreaProps = ComponentProps<typeof TextArea> & {
value: string | undefined;
onRef?: ForwardedRef<WorkflowSLTextAreaRefType>;
ellipsis?: boolean;
handleChange?: (v: string) => void;
handleBlur?: (v: string) => void;
handleFocus?: (v: string) => void;
ellipsisTooltipProps?: TooltipProps;
onFocusTooltipProps?: TooltipProps;
textAreaProps?: TextAreaProps;
errorMsg?: string;
errorMsgFloat?: boolean;
disabled?: boolean;
};
/**
* @component TextArea 在 Workflow 场景下的二次封装;
* focus(inputting) 的时候提供多行滚动输入能力blur 的时候提供 ellipsis 和 tooltip 提示能力
*/
export default function WorkflowSLTextArea(props: WorkflowSLTextAreaProps) {
const { ellipsis = true } = props;
useImperativeHandle(props.onRef, () => ({
triggerFocus,
}));
const $state = useReactive({
value: props.value,
inputOnFocus: false,
inputHover: false,
});
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const triggerFocus = () => {
$state.inputOnFocus = true;
textAreaRef?.current?.focus();
};
const onFocus = () => {
$state.inputOnFocus = true;
props?.handleFocus?.($state.value || '');
};
const onBlur = (e: React.FocusEvent<HTMLTextAreaElement>) => {
$state.inputOnFocus = false;
props?.handleBlur?.($state.value || '');
props?.onBlur?.(e);
// 失焦的时候,滚动到最顶端
if (textAreaRef?.current) {
textAreaRef.current.scrollTop = 0;
}
};
const onChange = (v: string) => {
$state.value = v;
props?.handleChange?.(v);
};
// 输入法输入结束
const onCompositionEnd = (e: React.CompositionEvent<HTMLTextAreaElement>) => {
const target = e.target as HTMLTextAreaElement;
if (
props.textAreaProps?.maxCount &&
(target.textLength || 0) > props.textAreaProps?.maxCount
) {
const v = target.value?.slice(0, props.textAreaProps?.maxCount);
$state.value = v;
props?.handleChange?.(v);
}
};
const hasEllipsis = useMemo(() => {
const clientHeight = textAreaRef.current?.clientHeight || 0;
const scrollHeight = textAreaRef.current?.scrollHeight || 0;
return clientHeight < scrollHeight - 1;
}, [
ellipsis,
$state.inputOnFocus,
$state.value,
textAreaRef.current?.clientHeight,
textAreaRef.current?.scrollHeight,
props.textAreaProps?.rows,
]);
useEffect(() => {
$state.value = props.value;
}, [props.value]);
/** 是否处于失焦缩略状态 */
const ellipsisWithBlur = useMemo(
() => !$state.inputOnFocus && hasEllipsis,
[hasEllipsis, $state.inputOnFocus],
);
const showTooltip = useMemo(
() =>
ellipsisWithBlur
? Boolean($state.value) && $state.inputHover
: Boolean(props.onFocusTooltipProps?.content) && $state.inputOnFocus,
[
ellipsisWithBlur,
$state.inputHover,
$state.inputOnFocus,
props.onFocusTooltipProps?.content,
],
);
return (
<div className={cs(s['input-wrapper'], props.className)}>
<AutoSizeTooltip
content={
<article
style={{
maxWidth: 200,
wordWrap: 'break-word',
wordBreak: 'normal',
textAlign: 'left',
}}
>
{$state.value}
</article>
}
position={'top'}
showArrow
mouseEnterDelay={300}
trigger="custom"
visible={showTooltip}
{...(ellipsisWithBlur
? props.ellipsisTooltipProps
: props.onFocusTooltipProps)}
>
<div
className={cs(props?.errorMsg ? s['error-wrapper'] : null)}
onMouseEnter={() => {
$state.inputHover = true;
}}
onMouseLeave={() => {
$state.inputHover = false;
}}
>
<TextArea
{...props.textAreaProps}
ref={textAreaRef}
value={$state.value}
className={
ellipsis
? !$state.inputOnFocus
? s['input-blur']
: s.inputting
: ''
}
onChange={onChange}
onFocus={onFocus}
onBlur={onBlur}
disabled={props.disabled}
onCompositionEnd={onCompositionEnd}
></TextArea>
</div>
</AutoSizeTooltip>
{props?.errorMsg && (
<div
className={cs(
s['error-content'],
props?.errorMsgFloat ? s['error-float'] : null,
)}
>
<div className={s['error-text']}>{props?.errorMsg}</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,35 @@
/*
* 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 { ParamTypeAlias } from '../../types';
// eslint-disable-next-line @typescript-eslint/naming-convention
export const ObjectLikeTypes = [
ParamTypeAlias.Object,
ParamTypeAlias.ArrayObject,
];
export enum ChangeMode {
Update,
Delete,
Append,
DeleteChildren,
}
export enum DescriptionLine {
Single = 'singleline',
Multi = 'multiline',
}

View File

@@ -0,0 +1,87 @@
.font-normal {
color: rgba(28, 31, 35, 0.80);
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 20px;
}
.container {
display: flex;
align-items: center !important;
position: relative;
// 下面是兼容线的样式的,不要动
&:first-child {
& > div:first-child {
& > div {
& > div:first-child {
top: 12px;
}
& > div:last-child {
top: 12px;
}
}
}
& > div:last-child {
margin-top: 0;
}
}
:global {
.semi-form-field {
flex: 1;
}
.semi-tree-option-expand-icon {
display: flex;
align-items: center;
height: 24px;
}
}
.level-icon {
display: flex;
align-self: stretch;
}
.wrapper {
display: flex;
align-items: center;
margin-top: 10px;
flex: 1;
}
}
.readonly-icon-container {
margin-top: 10px;
&.more-level {
cursor: default;
& > span,& > div {
cursor: pointer;
}
&:hover, &:active {
background: transparent;
}
}
&:first-child {
margin-top: 0;
}
}
.readonly-container {
display: flex;
align-items: baseline;
.name {
.font-normal();
word-break: keep-all;
}
.tag {
flex-shrink: 0;
display: flex;
align-items: center;
padding: 2px 8px;
border-radius: 3px;
background: rgba(230, 232, 234, 0.76);
margin-left: 8px;
.label {
.font-normal();
color: rgba(28, 31, 35, 0.60);
font-weight: 400;
}
}
}

View File

@@ -0,0 +1,222 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable @coze-arch/max-line-per-function */
import React, { useCallback, useRef } from 'react';
import isNumber from 'lodash-es/isNumber';
import classNames from 'classnames';
import { type RenderFullLabelProps } from '@coze-arch/bot-semi/Tree';
import { PARAM_TYPE_ALIAS_MAP, type ParamTypeAlias } from '../../types';
import useConfig from '../../hooks/use-config';
import NodeContext from '../../context/node-context';
import { type TreeNodeCustomData, type ActiveMultiInfo } from './type';
import { ChangeMode, ObjectLikeTypes, DescriptionLine } from './constants';
import ParamType from './components/param-type';
import ParamOperator from './components/param-operator';
import ParamName from './components/param-name';
import ParamDescription from './components/param-description';
import LevelLine from './components/line-component';
import styles from './index.module.less';
export interface CustomTreeNodeProps extends RenderFullLabelProps {
onChange: (mode: ChangeMode, param: TreeNodeCustomData) => void;
// Description 组件变换为多行时,其下面第一个 child 需被记录
onActiveMultiInfoChange?: (info: ActiveMultiInfo) => void;
activeMultiInfo?: ActiveMultiInfo;
// 不支持使用的类型
disabledTypes?: ParamTypeAlias[];
}
const LEVEL_LINE_STEP_WIDTH = 15;
export default function CustomTreeNode(props: CustomTreeNodeProps) {
const {
data,
onExpand,
expandIcon,
className,
level,
onChange,
onActiveMultiInfoChange,
activeMultiInfo,
disabledTypes = [],
} = props;
const { allowValueEmpty, readonly, hasObjectLike, withDescription } =
useConfig();
// 当前值
const value = data as TreeNodeCustomData;
const isTopLevel = level === 0;
const isOnlyOneData = value.isSingle && isTopLevel;
const IndentationWidth = level * LEVEL_LINE_STEP_WIDTH;
const paramNameWidth = 181;
const treeNodeRef = useRef<HTMLDivElement>(null);
const disableDelete = Boolean(
!allowValueEmpty && isOnlyOneData && isTopLevel,
);
// 删除时
const onDelete = () => {
onChange(ChangeMode.Delete, value);
};
// 新增子项时
const onAppend = () => {
onChange(ChangeMode.Append, value);
};
// 类型切换时
const onSelectChange = (
val?: string | number | Array<unknown> | Record<string, unknown>,
) => {
if (val === undefined) {
return;
}
if (isNumber(val)) {
const isObjectLike = ObjectLikeTypes.includes(val);
if (!isObjectLike) {
// 如果不是类Object判断是否有children如果有删除掉
if (value.children && value.children.length > 0) {
delete value.children;
}
}
onChange(ChangeMode.Update, { ...value, type: val });
}
// 更新type
};
// 更新
const onNameChange = (name: string) => {
onChange(ChangeMode.Update, { ...value, name });
};
// 更新
const onDescriptionChange = useCallback(
(description: string) => {
onChange(ChangeMode.Update, { ...value, description });
},
[onChange, value],
);
/**
* Description 组件单行 / 多行变换时,其下面第一个 child 的竖线需要缩短 / 延长
*/
const onDescriptionLineChange = useCallback(
(type: DescriptionLine) => {
const errorDoms = treeNodeRef.current?.getElementsByClassName(
'output-param-name-error-text',
);
if (type === DescriptionLine.Multi && value.children?.[0]?.field) {
onActiveMultiInfoChange?.({
activeMultiKey: value.children[0].field,
withNameError: Boolean(errorDoms?.length || 0),
// withNameError: Boolean(nameError || ''),
});
} else {
onActiveMultiInfoChange?.({
activeMultiKey: '',
});
}
},
[onActiveMultiInfoChange, value],
);
if (readonly) {
return (
// 提高class的css 权重
<div
className={classNames(
styles['readonly-icon-container'],
styles['more-level'],
className,
)}
>
{expandIcon}
<div className={styles['readonly-container']} onClick={onExpand}>
<span className={styles.name}>{value.name || '-'}</span>
<div className={styles.tag}>
<span className={styles.label}>
{PARAM_TYPE_ALIAS_MAP[value.type] || '-'}
</span>
</div>
</div>
</div>
);
}
return (
<NodeContext.Provider value={{ field: data.field }}>
<div
className={classNames({
[styles.container]: true,
[className]: Boolean(className),
})}
ref={treeNodeRef}
>
{/* 每增加一级多15长度 */}
<div
style={{ width: IndentationWidth }}
className={styles['level-icon']}
>
<LevelLine
level={level}
data={value}
multiInfo={{
multiline: activeMultiInfo?.activeMultiKey === value.field,
withNameError: activeMultiInfo?.withNameError,
}}
/>
</div>
<div className={styles.wrapper}>
<ParamName
style={{ width: paramNameWidth - IndentationWidth }}
data={value}
onChange={onNameChange}
/>
<ParamType
data={value}
onSelectChange={onSelectChange}
level={level}
disabledTypes={disabledTypes}
/>
{/* LLM 节点输出才有 description */}
{withDescription ? (
<ParamDescription
data={value}
onChange={onDescriptionChange}
onLineChange={onDescriptionLineChange}
hasObjectLike={hasObjectLike}
/>
) : (
<></>
)}
<ParamOperator
data={value}
level={level}
onDelete={onDelete}
onAppend={onAppend}
disableDelete={disableDelete}
hasObjectLike={hasObjectLike}
/>
</div>
</div>
</NodeContext.Provider>
);
}

View File

@@ -0,0 +1,75 @@
/*
* 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 TreeNodeData } from '@coze-arch/bot-semi/Tree';
import { type RecursedParamDefinition } from '../../types';
import { type ChangeMode } from './constants';
export type TreeNodeCustomData = TreeNodeData &
Pick<
RecursedParamDefinition,
| 'name'
| 'type'
| 'isQuote'
| 'fixedValue'
| 'quotedValue'
| 'fieldRandomKey'
> & {
// 行唯一值
key: string;
// Form的field
field?: string;
// 是否是第一项
isFirst?: boolean;
// 是否是最后一项
isLast?: boolean;
// 是否只有该项一条数据
isSingle?: boolean;
// 该项的嵌套层级从0开始
level?: number;
// 辅助线展示的字段
helpLineShow?: Array<boolean>;
children?: Array<TreeNodeCustomData>;
// 变量描述,用于作为隐藏的引导
description?: string;
};
export interface CustomTreeNodeFuncRef {
data: TreeNodeCustomData;
level: number;
readonly: boolean;
// 通用change方法
onChange: (mode: ChangeMode, param: TreeNodeCustomData) => void;
// 定制的类型改变的change方法主要用于自定义render使用
// 添加子项
onAppend: () => void;
// 删除该项
onDelete: () => void;
// 删除该项下面的所有子项
onDeleteChildren: () => void;
// 类型改变时内部的调用方法主要用于从类Object类型转为其他类型时需要删除所有子项
onSelectChange: (
val?: string | number | Array<unknown> | Record<string, unknown>,
) => void;
}
export interface ActiveMultiInfo {
// 当前行是否处于多行状态,多行状态竖线需要延长
activeMultiKey: string;
// 当前行paramName数据是否出现错误信息
withNameError?: boolean;
}

View File

@@ -0,0 +1,37 @@
.header {
display: flex;
align-items: center;
.name {
flex: 1;
}
.type {
margin-left: 8px;
width: 155px;
flex-grow: 0;
flex-shrink: 0;
}
.description {
margin-left: 8px;
width: 312px;
}
&.withDescription {
.name {
flex: auto;
width: 181px;
flex-grow: 0;
flex-shrink: 0;
}
}
}
.text {
color: var(--light-usage-text-color-text-3, rgb(28 31 35 / 35%));
font-size: 10px;
font-style: normal;
font-weight: 400;
line-height: 16px;
}

View File

@@ -0,0 +1,82 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import cx from 'classnames';
import { I18n } from '@coze-arch/i18n';
import useConfig from '../../hooks/use-config';
import {
OperatorLargeSize,
OperatorSmallSize,
SpacingSize,
OperatorTypeBaseWidth,
} from '../../constants';
import styles from './index.module.less';
export default function Header() {
const { readonly, withDescription, hasObjectLike } = useConfig();
if (readonly) {
return null;
}
return (
<div
className={cx(styles.header, {
[styles.withDescription]: withDescription,
})}
>
{/* name */}
<div className={styles.name}>
<span className={styles.text}>
{I18n.t('workflow_detail_end_output_name')}
</span>
</div>
{/* type */}
<div
className={styles.type}
style={
withDescription
? {
width: OperatorTypeBaseWidth,
}
: !hasObjectLike
? { width: OperatorSmallSize + SpacingSize + OperatorTypeBaseWidth }
: { width: OperatorLargeSize + SpacingSize + OperatorTypeBaseWidth }
}
>
<span className={styles.text}>
{I18n.t('workflow_detail_start_variable_type')}
</span>
</div>
{/* description 目前只在 LLM 的 output 中存在 */}
{withDescription ? (
<div className={styles.description}>
<span className={styles.text}>
{I18n.t('workflow_detail_llm_output_decription_title')}
</span>
</div>
) : (
<></>
)}
</div>
);
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable @typescript-eslint/naming-convention */
// 类型选择控件基础宽度
export const OperatorTypeBaseWidth = 155;
// 61 = 删除按钮 + 添加按钮 的上层容器宽度
export const OperatorLargeSize = 61;
// 31 = 删除按钮 的上层容器宽度
export const OperatorSmallSize = 31;
// 8 = 删除按钮与变量类型中间的 margin 距离
export const SpacingSize = 8;

View File

@@ -0,0 +1,29 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable @typescript-eslint/naming-convention */
import { createContext } from 'react';
import type { ParametersProps } from '../types';
export type Configs = Omit<
ParametersProps,
'value' | 'onChange' | 'className' | 'style' | 'disabledTypes'
> & { hasObjectLike?: boolean };
const ConfigContext = createContext<Configs>({});
export default ConfigContext;

View File

@@ -0,0 +1,26 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable @typescript-eslint/naming-convention */
import { createContext } from 'react';
export interface Node {
field?: string;
}
const NodeContext = createContext<Node>({});
export default NodeContext;

View File

@@ -0,0 +1,25 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useContext } from 'react';
import ConfigContext from '../context/config-context';
import type { Configs } from '../context/config-context';
export default function useConfig(): Configs {
const config = useContext(ConfigContext);
return config;
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import resolvePath from '../utils/resolve-path';
import useNode from './use-node';
import useParametersConfig from './use-config';
export default function useErrorMessage(key: string): string {
const { errors = [] } = useParametersConfig();
const { field = '' } = useNode();
const pathSearched = resolvePath(field, key);
const error = errors.find(({ path }) => pathSearched === path);
return error?.message || '';
}

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 { useContext } from 'react';
import NodeContext, { type Node } from '../context/node-context';
export default function useNode(): Node {
const node = useContext(NodeContext);
return node;
}

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.
*/
export { Parameters } from './parameters';
export { ParamTypeAlias } from './types';
export type { ParameterValue, ParametersError, ParametersProps } from './types';

View File

@@ -0,0 +1,46 @@
.container {
position: relative;
.content {
overflow-x: auto;
&.readonly {
max-height: 300px;
overflow: auto;
}
.add-hot-area {
height: 16px;
cursor: pointer;
}
:global {
.semi-tree-option-list {
overflow: initial;
&>div:first-child {
margin-top: 0;
}
}
}
}
:global {
.semi-tree-option-list {
overflow: inherit;
.semi-tree-option {
padding-left: 0;
}
}
.semi-tree-option-list-block .semi-tree-option {
cursor: default;
&:hover,
&:active {
background: transparent;
}
}
}
}

View File

@@ -0,0 +1,202 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* eslint-disable @coze-arch/max-line-per-function */
import React, { type PropsWithChildren, useState } from 'react';
import { nanoid } from 'nanoid';
import { cloneDeep } from 'lodash-es';
import classNames from 'classnames';
import { I18n } from '@coze-arch/i18n';
import { type TreeNodeData } from '@coze-arch/bot-semi/Tree';
import { Toast, Tree } from '@coze-arch/bot-semi';
import { findCustomTreeNodeDataResult, formatTreeData } from './utils/utils';
import { traverse } from './utils/traverse';
import { ParamTypeAlias } from './types';
import type { ParametersProps } from './types';
import ConfigContext from './context/config-context';
import Header from './components/header';
import {
type TreeNodeCustomData,
type ActiveMultiInfo,
} from './components/custom-tree-node/type';
import { ChangeMode } from './components/custom-tree-node/constants';
import CustomTreeNode from './components/custom-tree-node';
import styles from './parameters.module.less';
const getDefaultAppendValue = () => ({
fieldRandomKey: nanoid(),
type: ParamTypeAlias.String,
});
export function Parameters(props: PropsWithChildren<ParametersProps>) {
const {
value,
readonly = false,
withDescription = false,
disabledTypes = [],
className = '',
style = {},
errors = [],
allowValueEmpty = true,
onChange,
} = props;
// 监听该值的变化
const isValueEmpty = !value || value.length === 0;
const { data: formattedTreeData, hasObjectLike } = formatTreeData(
cloneDeep(value) as TreeNodeCustomData[],
);
/**
* 表示当前哪一行的父亲节点的 description 处于多行状态(LLM节点)
* 用于渲染树形竖线,处于多行文本的下一行竖线应该延长
* 若 param name 有错误信息,竖线从错误信息下方延展,长度有所变化
*/
const [activeMultiInfo, setActiveMultiInfo] = useState<ActiveMultiInfo>({
activeMultiKey: '',
});
// 该组件的 change 方法
const onValueChange = (freshValue?: Array<TreeNodeCustomData>) => {
if (onChange) {
freshValue = (freshValue || []).concat([]);
// 清理掉无用字段
traverse<TreeNodeCustomData>(freshValue, node => {
const { key, name, type, description, children } = node;
// eslint-disable-next-line guard-for-in
for (const prop in node) {
delete node[prop];
}
node.key = key;
node.name = name;
node.type = type;
node.description = description;
if (children) {
node.children = children;
}
});
onChange(freshValue);
}
};
// 树节点的 change 方法
const onTreeNodeChange = (mode: ChangeMode, param: TreeNodeCustomData) => {
// 先clone一份因为Tree内部会对treeData执行isEqual克隆一份一定是false
const cloneDeepTreeData = cloneDeep(
formattedTreeData,
) as Array<TreeNodeCustomData>;
const findResult = findCustomTreeNodeDataResult(
cloneDeepTreeData,
param.field as string,
);
if (findResult) {
switch (mode) {
case ChangeMode.Append: {
// 新增不可以用 parentData 做标准,要在当前 data 下新增
const { data } = findResult;
const currentChildren = data.children || [];
// @ts-expect-error 有些值不需要此时指定,因为在 rerender 的时候会执行 format
data.children = currentChildren.concat({
...getDefaultAppendValue(),
// 增加 field
field: `${data.field}.children[${currentChildren.length}]`,
});
onValueChange(cloneDeepTreeData);
break;
}
case ChangeMode.Update: {
const targetArray = findResult.isRoot
? cloneDeepTreeData
: findResult.parentData?.children;
const index = targetArray?.findIndex(item => item.key === param.key);
if (index !== undefined) {
targetArray?.splice(index, 1, param);
onValueChange(cloneDeepTreeData);
}
break;
}
case ChangeMode.Delete: {
if (findResult.isRoot) {
const freshValue = (cloneDeepTreeData || []).filter(
item => item.key !== param.key,
);
onValueChange(freshValue);
} else {
const parentData = findResult.parentData as TreeNodeData;
parentData.children = (parentData.children || []).filter(
item => item.key !== param.key,
);
onValueChange(cloneDeepTreeData);
}
break;
}
case ChangeMode.DeleteChildren: {
const { data } = findResult;
data.children = [];
onValueChange(cloneDeepTreeData);
break;
}
default:
}
} else {
Toast.error(I18n.t('workflow_detail_node_output_parsingfailed'));
}
};
if (readonly && isValueEmpty) {
return null;
}
return (
<ConfigContext.Provider
value={{
errors,
allowValueEmpty,
withDescription,
readonly,
hasObjectLike,
}}
>
<div className={`${styles.container} ${className}`} style={style}>
<Header />
<Tree
expandAll={!readonly}
style={readonly ? {} : { overflow: 'inherit' }}
motion={false}
className={classNames({
[styles.content]: true,
[styles.readonly]: readonly,
[styles['content-fix-pop-container']]: !readonly,
})}
renderFullLabel={renderFullLabelProps => (
<CustomTreeNode
{...renderFullLabelProps}
onChange={onTreeNodeChange}
onActiveMultiInfoChange={setActiveMultiInfo}
activeMultiInfo={activeMultiInfo}
disabledTypes={disabledTypes}
/>
)}
treeData={formattedTreeData}
/>
</div>
</ConfigContext.Provider>
);
}

View File

@@ -0,0 +1,97 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export enum ParamTypeAlias {
String = 1,
Integer,
Boolean,
Number,
/** 理论上没有 List 了,此项仅作兼容 */
List = 5,
Object = 6,
// 上面是 api 中定义的 InputType。下面是整合后的。从 99 开始,避免和后端定义撞车
ArrayString = 99,
ArrayInteger,
ArrayBoolean,
ArrayNumber,
ArrayObject,
}
export const PARAM_TYPE_ALIAS_MAP: Record<ParamTypeAlias, string> = {
[ParamTypeAlias.String]: 'String',
[ParamTypeAlias.Integer]: 'Integer',
[ParamTypeAlias.Boolean]: 'Boolean',
[ParamTypeAlias.Number]: 'Number',
[ParamTypeAlias.List]: 'List',
[ParamTypeAlias.Object]: 'Object',
[ParamTypeAlias.ArrayString]: 'Array<String>',
[ParamTypeAlias.ArrayInteger]: 'Array<Integer>',
[ParamTypeAlias.ArrayBoolean]: 'Array<Boolean>',
[ParamTypeAlias.ArrayNumber]: 'Array<Number>',
[ParamTypeAlias.ArrayObject]: 'Array<Object>',
};
export enum ParamValueType {
QUOTE = 'quote',
FIXED = 'fixed',
}
export interface RecursedParamDefinition {
name?: string;
/** Tree 组件要求每一个节点都有 key而 key 不适合用名称(前后缀)等任何方式赋值,最终确定由接口转换层一次性提供随机 key */
fieldRandomKey?: string;
desc?: string;
required?: boolean;
type: ParamTypeAlias;
children?: RecursedParamDefinition[];
// region 参数值定义
// 输入参数的值可以来自上游变量引用,也可以是用户输入的定值(复杂类型则只允许引用)
// 如果是定值,传 fixedValue
// 如果是引用,传 quotedValue
isQuote?: ParamValueType;
/** 参数定值 */
fixedValue?: string;
/** 参数引用 */
quotedValue?: [nodeId: string, ...path: string[]]; // string[]
// endregion
}
export interface ParameterValue {
key: string;
name?: string;
type: ParamTypeAlias;
description?: string;
children?: ParameterValue[];
}
export interface ParametersError {
path: string;
message: string;
}
export interface ParametersProps {
value: Array<ParameterValue>;
onChange?: (value: Array<ParameterValue>) => void;
readonly?: boolean;
className?: string;
style?: React.CSSProperties;
withDescription?: boolean;
// 不支持使用的类型
disabledTypes?: ParamTypeAlias[];
errors?: ParametersError[];
// 支持空值 & 空数组
allowValueEmpty?: boolean;
}

View File

@@ -0,0 +1,59 @@
/*
* 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 { isFunction } from 'lodash-es';
/**
* 将 { value: label } 形式的结构体转成Select需要的options Array<{ label, value }>
* computedValue将value值转化一次作为options的value
* passItem判断当前value值是否需要跳过遍历
*/
export default function convertMaptoOptions<Value = number>(
map: Record<string, unknown>,
convertOptions: {
computedValue?: (val: unknown) => Value;
passItem?: (val: unknown) => boolean;
/**
* 由于 i18n 的实现方式问题,写成常量的文案需要惰性加载
* 因此涉及到 i18n 的 { value: label } 结构一律需要写成 { value: () => label }
* 该属性启用时,会额外进行一次惰性加载
* @default false
* @link
*/
i18n?: boolean;
} = {},
) {
const res: Array<{ label: string; value: Value }> = [];
for (const [value, label] of Object.entries(map)) {
const pass = convertOptions.passItem
? convertOptions.passItem(value)
: false;
if (pass) {
continue;
}
const computedValue = convertOptions.computedValue
? convertOptions.computedValue(value)
: (value as Value);
const finalLabel: string = convertOptions.i18n
? isFunction(label)
? label()
: label
: label;
res.push({ label: finalLabel, value: computedValue });
}
return res;
}

View File

@@ -0,0 +1,23 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export default function resolvePath(path1: string, path2: string): string {
if (path1 && path2) {
return `${path1}.${path2}`;
}
return path2 || '';
}

View File

@@ -0,0 +1,35 @@
/*
* 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.
*/
interface TreeNode<T> {
children?: T[];
[key: string]: unknown;
}
export function traverse<T extends TreeNode<T>>(
nodeOrNodes: T | T[],
action: (node: T) => void,
) {
const nodes = Array.isArray(nodeOrNodes) ? nodeOrNodes : [nodeOrNodes];
nodes.forEach(node => {
action(node);
if (node.children && node.children.length > 0) {
traverse(node.children, action);
}
});
}

View File

@@ -0,0 +1,189 @@
/*
* 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 { nanoid } from 'nanoid';
import { type TreeNodeCustomData } from '../components/custom-tree-node/type';
import { ObjectLikeTypes } from '../components/custom-tree-node/constants';
interface RootFindResult {
isRoot: true;
data: TreeNodeCustomData;
parentData: null;
}
interface ChildrenFindResult {
isRoot: false;
parentData: TreeNodeCustomData;
data: TreeNodeCustomData;
}
export type FindDataResult = RootFindResult | ChildrenFindResult | null;
/**
* 根据target数组找到key在该项的值和位置主要是获取位置方便操作parent的children
*/
export function findCustomTreeNodeDataResult(
target: Array<TreeNodeCustomData>,
findField: string,
): FindDataResult {
const dataInRoot = target.find(item => item.field === findField);
if (dataInRoot) {
// 如果是根节点
return {
isRoot: true,
parentData: null,
data: dataInRoot,
};
}
function findDataInChildrenLoop(
customChildren: Array<TreeNodeCustomData>,
parentData?: TreeNodeCustomData,
): FindDataResult {
function findDataLoop(
customData: TreeNodeCustomData,
_parentData: TreeNodeCustomData,
): FindDataResult {
if (customData.field === findField) {
return {
isRoot: false,
parentData: _parentData,
data: customData,
};
}
if (customData.children && customData.children.length > 0) {
return findDataInChildrenLoop(
customData.children as Array<TreeNodeCustomData>,
customData,
);
}
return null;
}
for (const child of customChildren) {
const childResult = findDataLoop(child, parentData || child);
if (childResult) {
return childResult;
}
}
return null;
}
return findDataInChildrenLoop(target);
}
const MAX_LINE_LEVEL = 2;
export function formatTreeData(data: Array<TreeNodeCustomData>) {
let hasObjectLike = false;
function resolveActionParamList(
list: Array<TreeNodeCustomData>,
field: string,
// 主要是用来辅助展示线的判断的
{
parentData,
level,
}: {
parentData?: TreeNodeCustomData;
level: number;
},
) {
list?.forEach((item, index) => {
const keyField = field ? `${field}.${index}` : `${index}`;
hasObjectLike = hasObjectLike || ObjectLikeTypes.includes(item.type);
// 赋值children
item.key = item.key ?? item.fieldRandomKey ?? nanoid();
item.field = keyField;
item.isFirst = index === 0;
item.isLast = index === list.length - 1;
item.isSingle = item.isFirst && item.isLast;
item.level = level;
// 第一级不展示辅助线需要判断level
// 也就是第二级level = 1只需要自身的层级线
// 在第三级level = 2之后需要辅助线展示上一级的辅助线
item.helpLineShow =
parentData && level >= MAX_LINE_LEVEL
? (parentData.helpLineShow || []).concat(!parentData.isLast)
: [];
if (item.children) {
resolveActionParamList(
item.children as Array<TreeNodeCustomData>,
`${keyField}.children`,
{
parentData: item,
level: level + 1,
},
);
}
});
}
resolveActionParamList(data as TreeNodeCustomData[], '', { level: 0 });
return { data, hasObjectLike };
}
export enum LineShowResult {
HalfTopRoot,
HalfTopRootWithChildren,
HalfBottomRoot,
HalfBottomRootWithChildren,
FullRoot,
FullRootWithChildren,
HalfTopChild,
HalfTopChildWithChildren,
FullChild,
FullChildWithChildren,
EmptyBlock,
HelpLineBlock,
}
// eslint-disable-next-line complexity
export function getLineShowResult({
level,
data,
}: {
level: number;
data: TreeNodeCustomData;
}): Array<LineShowResult> {
const isRootWithChildren = level === 0 && (data.children || []).length > 0;
const isRootWithoutChildren =
level === 0 && (data.children || []).length === 0;
const isChildWithChildren = level > 0 && (data.children || []).length > 0;
const isChildWithoutChildren =
level > 0 && (data.children || []).length === 0;
const res: Array<LineShowResult> =
data.helpLineShow?.map(item =>
item ? LineShowResult.HelpLineBlock : LineShowResult.EmptyBlock,
) || [];
const isRoot = isRootWithoutChildren || isRootWithChildren;
// 根节点不需要展示线,只有非根节点才需要辅助线
if (!isRoot) {
if (isChildWithChildren) {
if (data.isLast) {
res.push(LineShowResult.HalfTopChildWithChildren);
} else if (data.isFirst) {
res.push(LineShowResult.FullChildWithChildren);
} else {
res.push(LineShowResult.FullChildWithChildren);
}
} else if (isChildWithoutChildren) {
if (data.isLast) {
res.push(LineShowResult.HalfTopChild);
} else if (data.isFirst) {
res.push(LineShowResult.FullChild);
} else {
res.push(LineShowResult.FullChild);
}
}
}
return res;
}