在线演示

下面 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 只能写自身或祖先,而联动规则的正常方向是父 → 子,子写回父很容易和父 → 子的规则互相拉扯(用户刚把上级切走,下级又把它写回来)。只有当写入的祖先字段不被任何规则读回时(例如给上级挂一个汇总/展示字段)才适合用它,因此不做成示例。

原生 input

零 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

同一组件中混合使用 <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' }} />;
}

联动 select(options 依赖上级)

下级列的 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 数下标,列顺序一调就静默失效。
  • 固定值只在 effects 里写一处,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 层深度嵌套

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} />
    </>
  );
}