在线演示

原生 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 层嵌套,训练集群和框架版本使用下拉选择。同时演示了 levelisLeafnode 等 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} />;
}

联动 select(options 依赖上级)

下级列的 options 需要拿上级选中值去请求接口;切换上级时,已选的下级值自动清空并重新拉取。用 effects 声明依赖:$.onValueChange(target, deps, handler) 里按 deps(上级 dataIndex)异步取数,if (!initial) setValue(undefined) 在用户改动时清空自身(详情页初始化回显时保留已有值),派生的 options / loading / disabledfield 交给 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 自行画成红边 + 红字;首次挂载/回显不报,等用户改动后才提示。提交时通过组件 refvalidate() 命令式整树校验——连未改动的字段也会被强制求值、标红,并返回 { 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 层深度嵌套

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