零 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} />;
}下级列的 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} />;
}校验走 effects 的 $.onValidate(target, rule, deps?):值一变就反应式重算,把错误写进 field.error,render 自行画成红边 + 红字;首次挂载/回显不报,等用户改动后才提示。提交时通过组件 ref 调 validate() 命令式整树校验——连未改动的字段也会被强制求值、标红,并返回 { valid, errors },不用再手动遍历 value。同一份规则同时驱动「边改边提示」和「提交拦截」。
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: '框架版本' };
/** 复用同一控件渲染:把 field.error 画成红边 + 红字提示 */
function renderField(placeholder: string) {
return ({ value, onChange, field }: CellRenderProps) => (
<div>
<input
style={{ ...inputStyle, borderColor: field.error ? '#ff4d4f' : '#d9d9d9' }}
placeholder={placeholder}
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('如 PyTorch 2.1') },
{ title: '操作', width: 90, hasAdd: false, render: ({ onDelete }) => <button type="button" onClick={onDelete}>删除</button> },
];
/** 校验走 onValidate:规则返回错误字符串即不通过。反应式 + validate() 命令式共用同一份规则。 */
const effects: Effects = ($) => {
$.onValidate('product', ({ value }) => (value?.trim() ? undefined : '训练任务必填'));
$.onValidate('region', ({ value }) => (value?.trim() ? undefined : '训练集群必填'));
$.onValidate('spec', ({ value }) => {
const s = (value ?? '').trim();
if (!s) return '框架版本必填';
if (!/\d/.test(s)) return '需包含版本号,如 PyTorch 2.1';
});
};
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 列层级嵌套,验证组件在更深层级下的连线表现和数据管理。
[]
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 mkInput = (title: string, dataIndex: string, width: number, hasAdd: boolean): ColumnConfig => ({
title, dataIndex, width, hasAdd,
render: ({ value, onChange }) => (
<input style={inputStyle} value={value} onChange={(e) => onChange(e.target.value)} />
),
});
const columns: ColumnConfig[] = [
mkInput('项目', 'project', 100, true),
mkInput('环境', 'env', 100, true),
mkInput('服务', 'service', 100, true),
mkInput('实例', 'instance', 100, true),
mkInput('配置', 'config', 120, false),
{
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} />;
}通过 addRender 自定义"添加"按钮的样式,按钮位置由组件固定控制(添加在单元格下方)。删除入口统一由"操作"列渲染。
[]
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',
};
const columns: ColumnConfig[] = [
{
title: '训练任务',
dataIndex: 'product',
width: 120,
hasAdd: true,
addRender: ({ onClick }) => (
<button type="button" style={addBtnStyle} onClick={onClick}>+ 添加</button>
),
render: ({ value, onChange, title }) => (
<input
style={{ width: '100%', padding: '4px 8px', border: '1px solid #d9d9d9', borderRadius: 4, fontSize: 14, height: 32 }}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
),
},
{
title: '训练集群',
dataIndex: 'region',
width: 120,
hasAdd: true,
addRender: ({ onClick }) => (
<button type="button" style={addBtnStyle} onClick={onClick}>+ 添加</button>
),
render: ({ value, onChange, title }) => (
<input
style={{ width: '100%', padding: '4px 8px', border: '1px solid #d9d9d9', borderRadius: 4, fontSize: 14, height: 32 }}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
),
},
{
title: '框架版本',
dataIndex: 'spec',
width: 180,
hasAdd: false,
render: ({ value, onChange, title }) => (
<input
style={{ width: '100%', padding: '4px 8px', border: '1px solid #d9d9d9', borderRadius: 4, fontSize: 14, height: 32 }}
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} />;
}实时切换连线样式(贝塞尔曲线 / 折线)、颜色、粗细,以及开启溯源动画(线条上粒子从子节点流向父节点)。
[]
import { useState } from 'react';
import { CascadingInput } from 'react-cascading-input';
import 'react-cascading-input/styles';
import type { ColumnConfig } 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 line = { style: lineStyle, color: lineColor, width: lineWidth, showSource };
return (
<>
<div style={{ marginBottom: 16, display: 'flex', gap: 16, 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}
/>
</>
);
}