下面 9 个示例合起来覆盖了组件的完整能力,按「想找哪个能力」对照着看:
| 能力 | 涉及 API | 看哪个示例 |
|---|---|---|
基础受控用法、render 自定义控件 |
columns / value / onChange / render |
原生 input |
| 混合控件、按层级差异化渲染 | level / isLeaf / node.children / title |
混合 input + select |
| 高度可变控件的连线对齐 | data-line-anchor |
多选等高控件 |
| 异步取数、上级变化清空下级、防竞态 | effects / onValueChange / field.options / loading / initial / isActive |
联动 select |
| 按分支锁死下级、禁止再分叉 | hasAdd 函数形式 / values / field.disabled / setValue |
分支收敛 |
| 反应式 + 命令式校验、跨列规则 | onValidate(含 deps)/ field.error / ref.validate() / dataIndex |
表单校验 |
| 深层嵌套、定位当前节点在树中的位置 | path / parent / level |
5 层深度嵌套 |
| 自定义添加入口(下方 / 行内两种位置) | addRender / onAdd / width / JSX title |
自定义"添加"按钮 |
| 连线风格与溯源动画全部参数 | line.style / color / width / showSource 对象 |
连线样式定制 |
唯一没有单独示例的是
EffectContext.setTreeValue/EffectsSetupContext.setTreeValue(跨节点写值)。它是逃生舱:setTreeValue只能写自身或祖先,而联动规则的正常方向是父 → 子,子写回父很容易和父 → 子的规则互相拉扯(用户刚把上级切走,下级又把它写回来)。只有当写入的祖先字段不被任何规则读回时(例如给上级挂一个汇总/展示字段)才适合用它,因此不做成示例。
零 UI 库依赖,基础示例,使用原生 <input> 元素渲染每列,末尾通过"操作"列渲染删除按钮。
[]
import { useState } from 'react';
import { CascadingInput } from 'react-cascading-input';
import 'react-cascading-input/styles';
import type { ColumnConfig } from 'react-cascading-input';
const inputStyle: React.CSSProperties = {
width: '100%', boxSizing: 'border-box', padding: '4px 8px',
border: '1px solid #d9d9d9', borderRadius: 4, fontSize: 14, height: 32,
};
const deleteBtnStyle: React.CSSProperties = {
padding: '4px 12px', border: '1px solid #ff4d4f', borderRadius: 4,
background: '#fff1f0', color: '#ff4d4f', fontSize: 13, cursor: 'pointer',
};
const columns: ColumnConfig[] = [
{
title: '训练任务',
dataIndex: 'product',
width: 120,
hasAdd: true,
render: ({ value, onChange, title }) => (
<input style={inputStyle} value={value}
onChange={(e) => onChange(e.target.value)} />
),
},
{
title: '训练集群',
dataIndex: 'region',
width: 120,
hasAdd: true,
render: ({ value, onChange, title }) => (
<input style={inputStyle} value={value}
onChange={(e) => onChange(e.target.value)} />
),
},
{
title: '框架版本',
dataIndex: 'spec',
width: 180,
hasAdd: false,
render: ({ value, onChange, title }) => (
<input style={inputStyle} value={value}
onChange={(e) => onChange(e.target.value)} />
),
},
{
// 删除入口统一放在"操作"列
title: '操作',
width: 100,
hasAdd: false,
render: ({ onDelete }) => (
<button type="button" style={deleteBtnStyle} onClick={onDelete}>删除</button>
),
},
];
export function App() {
const [value, setValue] = useState<any[]>([]);
return <CascadingInput columns={columns} value={value} onChange={setValue} />;
}同一组件中混合使用 <input> 和 <select>,展示 render prop 的灵活性。4 层嵌套,训练集群和框架版本使用下拉选择。同时演示了 level、isLeaf、node 等 CellRenderProps 参数的实际用法:通过 level 给不同层级设置不同边框色,通过 node.children 显示子节点数量,通过 isLeaf 给末列加浅灰背景。
[]
import { useState } from 'react';
import { CascadingInput } from 'react-cascading-input';
import 'react-cascading-input/styles';
import type { ColumnConfig } from 'react-cascading-input';
const selectStyle: React.CSSProperties = {
width: '100%', boxSizing: 'border-box', padding: '4px 8px',
border: '1px solid #d9d9d9', borderRadius: 4, fontSize: 14, height: 32, background: '#fff',
};
const inputStyle: React.CSSProperties = {
width: '100%', boxSizing: 'border-box', padding: '4px 8px',
border: '1px solid #d9d9d9', borderRadius: 4, fontSize: 14, height: 32,
};
const deleteBtnStyle: React.CSSProperties = {
padding: '4px 12px', border: '1px solid #ff4d4f', borderRadius: 4,
background: '#fff1f0', color: '#ff4d4f', fontSize: 13, cursor: 'pointer',
};
/** 不同层级的边框色,通过 level 区分视觉深度 */
const levelColors = ['#d9d9d9', '#91d5ff', '#b7eb8f', '#ffd591'];
const columns: ColumnConfig[] = [
{
title: '训练任务',
dataIndex: 'product',
width: 120,
hasAdd: true,
render: ({ value, onChange, title, level }) => (
<input
style={{ ...inputStyle, borderColor: levelColors[level] || '#d9d9d9' }}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
),
},
{
title: '训练集群',
dataIndex: 'region',
width: 130,
hasAdd: true,
render: ({ value, onChange, title, level, node }) => (
<div style={{ position: 'relative' }}>
<select
style={{ ...selectStyle, borderColor: levelColors[level] || '#d9d9d9' }}
value={value || ''}
onChange={(e) => onChange(e.target.value)}
>
<option value="">请选择{title}</option>
<option value="beijing">北京</option>
<option value="shanghai">上海</option>
<option value="guangzhou">广州</option>
</select>
{node.children && node.children.length > 0 && (
<span style={{ position: 'absolute', right: 24, top: 8, fontSize: 11, color: '#999', pointerEvents: 'none' }}>
{node.children.length}项
</span>
)}
</div>
),
},
{
title: '框架版本',
dataIndex: 'framework',
width: 130,
hasAdd: true,
render: ({ value, onChange, title, level }) => (
<select
style={{ ...selectStyle, borderColor: levelColors[level] || '#d9d9d9' }}
value={value || ''}
onChange={(e) => onChange(e.target.value)}
>
<option value="">请选择{title}</option>
<option value="pytorch">PyTorch</option>
<option value="tensorflow">TensorFlow</option>
<option value="mindspore">MindSpore</option>
</select>
),
},
{
title: '训练阶段',
dataIndex: 'stage',
width: 120,
hasAdd: false,
render: ({ value, onChange, title, level, isLeaf }) => (
<select
style={{
...selectStyle,
borderColor: levelColors[level] || '#d9d9d9',
background: isLeaf ? '#fafafa' : '#fff',
}}
value={value || ''}
onChange={(e) => onChange(e.target.value)}
>
<option value="">请选择{title}</option>
<option value="preprocess">预处理</option>
<option value="training">训练中</option>
<option value="eval">评估</option>
</select>
),
},
{
title: '操作',
width: 100,
hasAdd: false,
render: ({ onDelete }) => (
<button type="button" style={deleteBtnStyle} onClick={onDelete}>删除</button>
),
},
];
export function App() {
const [value, setValue] = useState<any[]>([]);
return <CascadingInput columns={columns} value={value} onChange={setValue} />;
}第二列是标签式多选:选中项越多控件越高。连线锚在控件自身的垂直中心,所以多选变高时连接点仍在框正中。
行内是顶端对齐,父节点钉在它子树的第一行,点"添加"加出兄弟节点时父节点不会上下漂。两列控件高度不一致时,组件内部会自动补一段上外边距,把父控件中心压到第一行子控件的中心 —— 第一段连线保持水平,其余子节点向下发散。
若控件外还包了一层容器(如错误提示放在 .tree-cell-content 内),可在真正的控件上加 data-line-anchor 显式指定锚点元素。
[]
import { useState } from 'react';
import { CascadingInput } from 'react-cascading-input';
import 'react-cascading-input/styles';
import type { CellRenderProps, ColumnConfig, TreeNode } from 'react-cascading-input';
const boxStyle: React.CSSProperties = {
width: '100%', boxSizing: 'border-box', display: 'flex', flexWrap: 'wrap',
alignItems: 'center', gap: 4, minHeight: 32, padding: '3px 4px',
border: '1px solid #d9d9d9', borderRadius: 4, background: '#fff',
};
const SPECS = ['A100 40G', 'A100 80G', 'A800 80G', 'H100 80G', 'V100 32G', 'L20 48G'];
/** 多选控件:选中项以标签渲染,标签换行时整个控件会变高(等同 antd Select mode="multiple") */
function TagMultiSelect({ value, onChange }: Pick<CellRenderProps, 'value' | 'onChange'>) {
const selected = value ? value.split(',').filter(Boolean) : [];
const commit = (next: string[]) => onChange(next.join(','));
return (
<div style={boxStyle}>
{selected.map((tag) => (
<span key={tag} style={tagStyle}>
{tag}
<button type="button" style={tagCloseStyle} aria-label={`移除 ${tag}`}
onClick={() => commit(selected.filter((t) => t !== tag))}>×</button>
</span>
))}
<select
value=""
style={{ flex: 1, minWidth: 56, border: 'none', outline: 'none', fontSize: 12, background: 'none' }}
onChange={(e) => { if (e.target.value) commit([...selected, e.target.value]); }}
>
<option value="">+ 规格</option>
{SPECS.filter((s) => !selected.includes(s)).map((s) => <option key={s} value={s}>{s}</option>)}
</select>
</div>
);
}
const columns: ColumnConfig[] = [
{
title: '训练任务', dataIndex: 'product', width: 140, hasAdd: true,
render: ({ value, onChange }) => (
<input style={inputStyle} placeholder="请输入训练任务" value={value} onChange={(e) => onChange(e.target.value)} />
),
},
{
title: '加速卡规格(多选)', dataIndex: 'spec', width: 180, hasAdd: true,
render: ({ value, onChange }) => <TagMultiSelect value={value} onChange={onChange} />,
},
{
title: '框架版本', dataIndex: 'framework', width: 140, hasAdd: false,
render: ({ value, onChange }) => (
<input style={inputStyle} placeholder="如 PyTorch 2.1" value={value} onChange={(e) => onChange(e.target.value)} />
),
},
{
title: '操作', width: 90, hasAdd: false,
render: ({ onDelete }) => <button type="button" style={deleteBtnStyle} onClick={onDelete}>删除</button>,
},
];
export function App() {
const [value, setValue] = useState<TreeNode[]>([]);
return <CascadingInput columns={columns} value={value} onChange={setValue} line={{ style: 'straight' }} />;
}下级列的 options 需要拿上级选中值去请求接口;切换上级时,已选的下级值自动清空并重新拉取。用 effects 声明依赖:$.onValueChange(target, deps, handler) 里按 deps(上级 dataIndex)异步取数,if (!initial) setValue(undefined) 在用户改动时清空自身(详情页初始化回显时保留已有值),派生的 options / loading / disabled 经 field 交给 render。依赖沿祖先链解析,可跨任意层级。
[]
import { useState } from 'react';
import { CascadingInput } from 'react-cascading-input';
import 'react-cascading-input/styles';
import type { ColumnConfig, Effects } from 'react-cascading-input';
/** 模拟接口:根据父级选中值异步返回下级 options */
const fakeApi = (parentValue: string): Promise<string[]> =>
new Promise((resolve) => {
setTimeout(() => {
const map: Record<string, string[]> = {
'task-a': ['北京集群', '上海集群'],
'task-b': ['广州集群', '深圳集群', '杭州集群'],
};
resolve(map[parentValue] ?? []);
}, 400);
});
const columns: ColumnConfig[] = [
{
title: '训练任务', dataIndex: 'product', width: 160, hasAdd: true,
render: ({ value, onChange }) => (
<select value={value} onChange={(e) => onChange(e.target.value)}>
<option value="">请选择训练任务</option>
<option value="task-a">任务一</option>
<option value="task-b">任务二</option>
</select>
),
},
{
title: '训练集群', dataIndex: 'region', width: 160, hasAdd: true,
// options / disabled / loading 全部来自 effects 计算出的 field
render: ({ value, onChange, field }) => (
<select value={value} disabled={field.disabled || field.loading} onChange={(e) => onChange(e.target.value)}>
<option value="">{field.loading ? '加载中…' : '请选择训练集群'}</option>
{(field.options ?? []).map((opt: string) => <option key={opt} value={opt}>{opt}</option>)}
</select>
),
},
{
title: '框架版本', dataIndex: 'spec', width: 180, hasAdd: false,
render: ({ value, onChange, field }) => (
<select value={value} disabled={field.disabled || field.loading} onChange={(e) => onChange(e.target.value)}>
<option value="">{field.loading ? '加载中…' : '请选择框架版本'}</option>
{(field.options ?? []).map((opt: string) => <option key={opt} value={opt}>{opt}</option>)}
</select>
),
},
{
title: '操作', width: 100, hasAdd: false,
render: ({ onDelete }) => <button type="button" onClick={onDelete}>删除</button>,
},
];
// 声明式联动:集群依赖任务、版本依赖集群
const effects: Effects = ($) => {
$.onValueChange('region', ['product'], async ({ deps, setValue, setState, isActive, initial }) => {
if (!initial) setValue(undefined); // 初始化回显时保留已有值,仅用户改动才清空
// 每条出口都要复位 loading:field 是 patch 合并,残留的 loading 会让格子永久禁用
if (!deps.product) return { options: [], disabled: true, loading: false };
setState({ loading: true, disabled: false });
try {
const options = await fakeApi(deps.product);
if (isActive()) setState({ options, loading: false });
} catch {
if (isActive()) setState({ options: [], loading: false });
}
});
$.onValueChange('spec', ['region'], async ({ deps, setValue, setState, isActive, initial }) => {
if (!initial) setValue(undefined);
if (!deps.region) return { options: [], disabled: true, loading: false };
setState({ loading: true, disabled: false });
try {
const options = await fakeApi(deps.region);
if (isActive()) setState({ options, loading: false });
} catch {
if (isActive()) setState({ options: [], loading: false });
}
});
};
export function App() {
const [value, setValue] = useState<any[]>([]);
return <CascadingInput columns={columns} value={value} onChange={setValue} effects={effects} />;
}某条分支选中后要「锁死」下级:任务类型选「独占整机」后,资源套餐固定为「整机套餐」且不可改、配比自动填 100,套餐列的"添加"按钮也消失(这条分支不允许再分叉)。
两个要点:
hasAdd 的函数形式:hasAdd: ({ values }) => values.taskType !== '独占整机'。values 是沿本节点路径按 dataIndex 解析出的各列值,和 effects 里的 deps 同一口径——别用 ancestors[0]?.taskType 数下标,列顺序一调就静默失效。options / value / disabled 一起给全,render 只读 field、不认识业务分支。少了 disabled,用户还能把「固定值」改掉或清空,表单又回到非法状态。注意 hasAdd 只控制入口,不会裁剪已经添加出来的兄弟节点:先在旧分支加了几条、再把上级切成收敛分支,那几条仍在 value 里,要不要清由业务自己决定。
[]
import { useState } from 'react';
import { CascadingInput } from 'react-cascading-input';
import 'react-cascading-input/styles';
import type { ColumnConfig, Effects, TreeNode } from 'react-cascading-input';
const EXCLUSIVE = '独占整机';
const WHOLE_MACHINE_PKG = '整机套餐';
const SHARED_PKGS = ['基础套餐', '加速套餐', '大内存套餐'];
const columns: ColumnConfig[] = [
{
title: '任务类型', dataIndex: 'taskType', width: 140, hasAdd: true,
render: ({ value, onChange }) => (
<select value={value} onChange={(e) => onChange(e.target.value)}>
<option value="">请选择任务类型</option>
<option value="共享调度">共享调度</option>
<option value={EXCLUSIVE}>{EXCLUSIVE}</option>
</select>
),
},
{
title: '资源套餐', dataIndex: 'pkg', width: 160,
// 按分支动态开关"添加":独占整机只有一个套餐,不允许再分叉
hasAdd: ({ values }) => values.taskType !== EXCLUSIVE,
// render 只读 field,不判断业务分支
render: ({ value, onChange, field }) => (
<select value={value} disabled={field.disabled} onChange={(e) => onChange(e.target.value)}>
<option value="">请选择资源套餐</option>
{(field.options ?? []).map((opt: string) => <option key={opt} value={opt}>{opt}</option>)}
</select>
),
},
{
title: '配比(%)', dataIndex: 'ratio', width: 100, hasAdd: false,
render: ({ value, onChange }) => (
<input type="number" min={0} max={100} value={value} onChange={(e) => onChange(e.target.value)} />
),
},
{
title: '操作', width: 90, hasAdd: false,
render: ({ onDelete }) => <button type="button" onClick={onDelete}>删除</button>,
},
];
/** 固定值只写一处:options + value + disabled 一起给全 */
const effects: Effects = ($) => {
$.onValueChange('pkg', ['taskType'], ({ deps, value, setValue }) => {
if (deps.taskType === EXCLUSIVE) {
setValue(WHOLE_MACHINE_PKG); // 引擎内部比对过值,相同不会重复提交
return { options: [WHOLE_MACHINE_PKG], disabled: true };
}
// 只清掉上一条分支留下的固定值,普通套餐之间切换时保留用户已选
if (value === WHOLE_MACHINE_PKG) setValue('');
return { options: SHARED_PKGS, disabled: false };
});
// 配比给默认值,用户仍可改(所以不写 disabled)
$.onValueChange('ratio', ['taskType'], ({ deps, setValue, initial }) => {
if (!initial && deps.taskType === EXCLUSIVE) setValue('100');
});
};
export function App() {
const [value, setValue] = useState<TreeNode[]>([]);
return <CascadingInput columns={columns} value={value} onChange={setValue} effects={effects} line={{ style: 'straight' }} />;
}校验走 effects 的 $.onValidate(target, rule, deps?):值一变就反应式重算,把错误写进 field.error,render 自行画成红边 + 红字;首次挂载/回显不报,等用户改动后才提示。提交时通过组件 ref 调 validate() 命令式整树校验——连未改动的字段也会被强制求值、标红,并返回 { valid, errors },不用再手动遍历 value。同一份规则同时驱动「边改边提示」和「提交拦截」。
框架版本一列还演示了跨列校验:onValidate 第三个参数声明 deps,规则里就能拿到沿祖先链解析出的上级值(这里判断框架版本不能与训练任务同名)。多重判断(必填 / 格式 / 跨列)都写在同一条 rule 里——同一节点只有一个 error 槽,一列注册多条规则会互相覆盖。
另外三列共用同一个 render,靠 render props 里的 dataIndex 区分字段取 placeholder,这也是 dataIndex 出现在 render props 里的原因。
import { useRef, useState } from 'react';
import { CascadingInput } from 'react-cascading-input';
import 'react-cascading-input/styles';
import type { CascadingInputHandle, CellRenderProps, ColumnConfig, Effects, TreeNode } from 'react-cascading-input';
const inputStyle: React.CSSProperties = {
width: '100%',
boxSizing: 'border-box',
padding: '4px 8px',
border: '1px solid #d9d9d9',
borderRadius: 4,
fontSize: 14,
height: 32,
};
const errorTextStyle: React.CSSProperties = { color: '#ff4d4f', fontSize: 12, lineHeight: '16px', marginTop: 2 };
const TITLES: Record<string, string> = { product: '训练任务', region: '训练集群', spec: '框架版本' };
const PLACEHOLDERS: Record<string, string> = {
product: '请输入训练任务',
region: '请输入训练集群',
spec: '如 PyTorch 2.1',
};
/** 一个 render 服务多列:用 render props 里的 dataIndex 区分字段。
* 错误文案会把单元格撑高,给 input 标上 data-line-anchor,让连线锚在输入框中心不下移。 */
function renderField({ value, onChange, field, dataIndex }: CellRenderProps) {
return (
<div>
<input
data-line-anchor
style={{ ...inputStyle, borderColor: field.error ? '#ff4d4f' : '#d9d9d9' }}
placeholder={dataIndex ? PLACEHOLDERS[dataIndex] : ''}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
{field.error && <div style={errorTextStyle}>{field.error}</div>}
</div>
);
}
const columns: ColumnConfig[] = [
{ title: '训练任务', dataIndex: 'product', width: 160, hasAdd: true, render: renderField },
{ title: '训练集群', dataIndex: 'region', width: 160, hasAdd: true, render: renderField },
{ title: '框架版本', dataIndex: 'spec', width: 200, hasAdd: false, render: renderField },
{ title: '操作', width: 90, hasAdd: false, render: ({ onDelete }) => <button type="button" onClick={onDelete}>删除</button> },
];
/** 每列只注册一条规则,多重判断写在同一个 rule 内——同一节点只有一个 error 槽 */
const effects: Effects = ($) => {
$.onValidate('product', ({ value }) => (value?.trim() ? undefined : '训练任务必填'));
$.onValidate('region', ({ value }) => (value?.trim() ? undefined : '训练集群必填'));
$.onValidate(
'spec',
({ value, deps }) => {
const s = (value ?? '').trim();
if (!s) return '框架版本必填';
if (!/\d/.test(s)) return '需包含版本号,如 PyTorch 2.1';
// 跨列校验:deps 沿祖先链解析,可跨任意层级
if (s === (deps.product ?? '').trim()) return '框架版本不能与训练任务同名';
},
['product'],
);
};
function App() {
const [value, setValue] = useState<TreeNode[]>([]);
const ref = useRef<CascadingInputHandle>(null);
const [result, setResult] = useState<{ valid: boolean; messages: string[] } | null>(null);
const handleSubmit = () => {
const r = ref.current?.validate();
if (!r) return;
// errors 携带 path/dataIndex,可拼成更友好的提示;此处直接用 message
setResult({ valid: r.valid, messages: r.errors.map((e) => `${TITLES[e.dataIndex]}:${e.message}`) });
};
return (
<div>
<CascadingInput ref={ref} columns={columns} value={value} onChange={setValue} effects={effects} line={{ style: 'straight' }} />
<button type="button" onClick={handleSubmit}>校验并提交</button>
{result?.valid && <span>✓ 校验通过,可提交</span>}
{result && !result.valid && <ul>{result.messages.map((m, i) => <li key={i}>{m}</li>)}</ul>}
</div>
);
}5 列层级嵌套,验证组件在更深层级下的连线表现和数据管理。层级一多就容易迷路,这里用 render props 里的定位信息补上下文:path 是根到当前节点的 id 数组(path.length 即深度,鼠标悬浮输入框可看完整路径),parent 是直接父节点,用它把上级填的值带进 placeholder,level 用来给不同层级配不同的左侧色条。
[]
import { useState } from 'react';
import { CascadingInput } from 'react-cascading-input';
import 'react-cascading-input/styles';
import type { ColumnConfig } from 'react-cascading-input';
const inputStyle: React.CSSProperties = {
width: '100%', boxSizing: 'border-box', padding: '4px 8px',
border: '1px solid #d9d9d9', borderRadius: 4, fontSize: 14, height: 32,
};
const deleteBtnStyle: React.CSSProperties = {
padding: '4px 12px', border: '1px solid #ff4d4f', borderRadius: 4,
background: '#fff1f0', color: '#ff4d4f', fontSize: 13, cursor: 'pointer',
};
/** 层级越深,输入框左侧色条越深:用 level 表达深度 */
const levelColors = ['#d9d9d9', '#91d5ff', '#87e8de', '#b7eb8f', '#ffd591'];
/**
* - path 是根到当前节点的 id 数组,path.length 即深度,悬浮可看完整路径;
* - parent 是直接父节点,用它把上级已填的值带进 placeholder;
* - parentDataIndex 传父列字段名(链式结构里就是上一列),避免在 render 里数下标。
*/
const mkInput = (
title: string,
dataIndex: string,
width: number,
hasAdd: boolean,
parentDataIndex?: string,
): ColumnConfig => ({
title, dataIndex, width, hasAdd,
render: ({ value, onChange, path, parent, level }) => {
const parentValue = parentDataIndex ? parent?.[parentDataIndex] : undefined;
return (
<input
style={{ ...inputStyle, borderLeft: `3px solid ${levelColors[level] ?? '#d9d9d9'}` }}
// 原生 title 做轻量提示:不额外占高度,也就不会影响连线锚点
title={`第 ${path.length} 层 · 路径 ${path.join(' / ')}`}
placeholder={parentValue ? `${parentValue} 下的${title}` : `请输入${title}`}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
);
},
});
const columns: ColumnConfig[] = [
mkInput('项目', 'project', 110, true),
mkInput('环境', 'env', 110, true, 'project'),
mkInput('服务', 'service', 110, true, 'env'),
mkInput('实例', 'instance', 110, true, 'service'),
mkInput('配置', 'config', 120, false, 'instance'),
{
title: '操作',
width: 100,
hasAdd: false,
render: ({ onDelete }) => (
<button type="button" style={deleteBtnStyle} onClick={onDelete}>删除</button>
),
},
];
export function App() {
const [value, setValue] = useState<any[]>([]);
return <CascadingInput columns={columns} value={value} onChange={setValue} />;
}添加入口有两种放法,这个示例各演示一列:
hasAdd: true + addRender 自定义按钮样式,位置由组件控制。onAdd 在 render 里同样拿得到,想放哪就放哪;此时把 hasAdd 关掉,避免同一列出现两个入口。行内按钮会占宽度,用 render props 里的 width(当前列宽)减去按钮占位算出输入框宽度。列标题 title 是 ReactNode,可以直接写 JSX——这里放了图标 + 文案。删除入口统一由"操作"列渲染。
[]
import { useState } from 'react';
import { CascadingInput } from 'react-cascading-input';
import 'react-cascading-input/styles';
import type { ColumnConfig } from 'react-cascading-input';
const addBtnStyle: React.CSSProperties = {
padding: '2px 12px', border: '1px solid #1890ff', borderRadius: 4,
background: '#e6f7ff', color: '#1890ff', fontSize: 12, cursor: 'pointer',
};
const deleteBtnStyle: React.CSSProperties = {
padding: '2px 12px', border: '1px solid #ff4d4f', borderRadius: 4,
background: '#fff1f0', color: '#ff4d4f', fontSize: 12, cursor: 'pointer',
};
/** 行内"添加"按钮:和控件并排,尺寸固定 28px(下面用 width 给输入框留出这段空间) */
const INLINE_ADD_SIZE = 28;
const inlineAddStyle: React.CSSProperties = {
flex: `0 0 ${INLINE_ADD_SIZE}px`, height: 32, border: '1px dashed #1890ff',
borderRadius: 4, background: '#fff', color: '#1890ff', fontSize: 16, lineHeight: 1, cursor: 'pointer',
};
/** title 是 ReactNode,可以直接放 JSX:图标 + 文案 */
const iconTitle = (icon: string, text: string) => (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<span aria-hidden="true">{icon}</span>
{text}
</span>
);
const columns: ColumnConfig[] = [
{
title: iconTitle('🎯', '训练任务'),
dataIndex: 'product',
width: 120,
hasAdd: true,
// 添加按钮放在单元格下方,位置由组件控制,这里只定制样式
addRender: ({ onClick }) => (
<button type="button" style={addBtnStyle} onClick={onClick}>+ 添加</button>
),
render: ({ value, onChange }) => (
<input style={inputStyle} value={value} onChange={(e) => onChange(e.target.value)} />
),
},
{
title: iconTitle('🖥️', '训练集群'),
dataIndex: 'region',
width: 150,
// 这一列的添加入口自己画在控件旁边,所以关掉组件默认的下方按钮,避免两个入口
hasAdd: false,
// onAdd 不只能给 addRender 用:render 里同样拿得到,想放哪就放哪。
// width 是当前列宽,减去行内按钮占位后就是输入框该有的宽度
render: ({ value, onChange, onAdd, width }) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<input
style={{ ...inputStyle, width: width - INLINE_ADD_SIZE - 4 }}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
<button type="button" style={inlineAddStyle} title="添加同级集群" onClick={onAdd}>+</button>
</div>
),
},
{
title: '框架版本',
dataIndex: 'spec',
width: 180,
hasAdd: false,
render: ({ value, onChange }) => (
<input style={inputStyle} value={value} onChange={(e) => onChange(e.target.value)} />
),
},
{
title: '操作',
width: 100,
hasAdd: false,
render: ({ onDelete }) => (
<button type="button" style={deleteBtnStyle} onClick={onDelete}>删除</button>
),
},
];
export function App() {
const [value, setValue] = useState<any[]>([]);
return (
<CascadingInput
columns={columns}
value={value}
onChange={setValue}
line={{ style: 'curve', color: '#1890ff', width: 1 }}
/>
);
}实时切换连线样式(贝塞尔曲线 / 折线)、颜色、粗细,以及开启溯源动画(水滴沿连线从子节点流向父节点)。勾上"溯源动画"后会出现 SourceAnimationOptions 的全部参数:水滴颜色、大小、拖尾长度、流速、呼吸幅度与周期——showSource 传 true 用默认值,传对象即可逐项定制。
[]
import { useState } from 'react';
import { CascadingInput } from 'react-cascading-input';
import 'react-cascading-input/styles';
import type { ColumnConfig, LineConfig } from 'react-cascading-input';
const columns: ColumnConfig[] = [
{
title: '训练任务', dataIndex: 'product', width: 120, hasAdd: true,
render: ({ value, onChange }) => (
<input placeholder="请输入训练任务" value={value} onChange={(e) => onChange(e.target.value)} />
),
},
{
title: '训练集群', dataIndex: 'region', width: 120, hasAdd: true,
render: ({ value, onChange }) => (
<input placeholder="请输入训练集群" value={value} onChange={(e) => onChange(e.target.value)} />
),
},
{
title: '框架版本', dataIndex: 'spec', width: 180, hasAdd: false,
render: ({ value, onChange }) => (
<input placeholder="请输入框架版本" value={value} onChange={(e) => onChange(e.target.value)} />
),
},
{
title: '操作', width: 100, hasAdd: false,
render: ({ onDelete }) => (
<button type="button" onClick={onDelete}>删除</button>
),
},
];
export function App() {
const [value, setValue] = useState<any[]>([]);
const [lineStyle, setLineStyle] = useState<'curve' | 'straight'>('curve');
const [lineColor, setLineColor] = useState('#d9d9d9');
const [lineWidth, setLineWidth] = useState(1.5);
const [showSource, setShowSource] = useState(false);
const [dropColor, setDropColor] = useState('#1890ff');
const [dropSize, setDropSize] = useState(3);
const [tailLength, setTailLength] = useState(28);
const [speed, setSpeed] = useState(0.004);
const [breatheAmplitude, setBreatheAmplitude] = useState(1);
const [breatheCycle, setBreatheCycle] = useState(400);
const line: LineConfig = {
style: lineStyle,
color: lineColor,
width: lineWidth,
// showSource 传对象即可逐项定制;传 true 用默认配置,false / 不传则不开启。
// 水滴默认跟随 line.color(偏浅不易看清),所以这里单独给一个较深的颜色
showSource: showSource
? { color: dropColor, size: dropSize, tailLength, speed, breatheAmplitude, breatheCycle }
: false,
};
return (
<>
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap', alignItems: 'center' }}>
<label>
连线样式:
<select value={lineStyle} onChange={(e) => setLineStyle(e.target.value as 'curve' | 'straight')}>
<option value="curve">贝塞尔曲线</option>
<option value="straight">折线</option>
</select>
</label>
<label>
连线颜色:
<input type="color" value={lineColor} onChange={(e) => setLineColor(e.target.value)} />
</label>
<label>
连线粗细:
<input type="range" min={0.5} max={4} step={0.5} value={lineWidth}
onChange={(e) => setLineWidth(Number(e.target.value))} />
</label>
<label>
<input type="checkbox" checked={showSource} onChange={(e) => setShowSource(e.target.checked)} />
溯源动画
</label>
{/* 勾上后再渲染水滴颜色 / 大小 / 拖尾 / 流速 / 呼吸幅度 / 呼吸周期六个滑杆,此处省略 */}
</div>
<CascadingInput columns={columns} value={value} onChange={setValue} line={line} />
</>
);
}