npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@kne/react-modal

v0.1.13

Published

基于 antd 的 React 弹层组件,提供 Modal/Drawer、useModal/useDrawer/useConfirmModal、共用 TabsLayout/ScrollRegion 布局与 createModalRender/createDrawerRender支持 SimpleBar 与移动端适配。

Readme

react-modal

描述

基于 antd 的 React 弹层组件,提供 Modal/Drawer、useModal/useDrawer/useConfirmModal、共用 TabsLayout/ScrollRegion 布局与 createModalRender/createDrawerRender支持 SimpleBar 与移动端适配。

关键词

react, modal, drawer, antd, dialog, simplebar, use-modal, use-drawer, use-confirm-modal, footer-buttons, tabs-layout, mobile, form, component, i18n

安装

npm i --save @kne/react-modal

概述

概述

@kne/react-modal 是基于 Ant Design 的弹层组件库,提供声明式 / 命令式 Modal 与 Drawer,兼容 footerButtons 体系,内置 SimpleBar 滚动与移动端适配。

主要特性

  • 声明式 / 命令式同一套 props 与 UI(Modal useModal + ModalContextHolder,多层就近 LayerHolder;Drawer useDrawer + DrawerContextHolder;确认框 useConfirmModal)
  • footer + footerButtons(ButtonComponent / display / autoClose)
  • title / footer 固定在滚动外,body 默认 SimpleBar
  • --kne-modal-* / --kne-drawer-* CSS 变量管理高度链
  • Modal 移动端全屏;Drawer 移动端侧滑全宽
  • 示例:各场景示例顶部提供 Modal / Drawer 切换,同一套 props 与内容对比两种弹层
  • 共用布局:TabsLayout、ColumnsLayout、ScrollRegion;createModalRender / createDrawerRender 对接 renderModal 宿主

使用场景

列表快览、表单确认、侧滑详情、Tabs / 分栏复杂内容区均可通过布局组件组合,无需业务侧手写 SCSS。

示例

示例样式

/* 示例业务 UI(布局滚动等仍用 @kne/react-modal/dist/index.css)
 * Modal/Drawer 挂到 body,demo 类名须 @at-root 逃出 md-doc scope 包裹 */
@at-root {
.candidate-list-toolbar {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.candidate-list-item {
  padding: 12px 16px;
  cursor: pointer;
  border-bottom: 1px solid #f5f5f5;
  transition: background 0.15s ease;

  &:hover:not(.is-active) {
    background: #fafafa;
  }

  &.is-active {
    background: #e6f4ff;
    box-shadow: inset 3px 0 0 #1677ff;
  }
}

.candidate-list-item-main {
  display: flex;
  align-items: flex-start;
  gap: 12px;
}

.candidate-list-item-body {
  flex: 1;
  min-width: 0;
}

.candidate-list-item-title {
  font-weight: 600;
  color: rgba(0, 0, 0, 0.88);
  line-height: 1.4;
}

.candidate-list-item-meta {
  margin-top: 2px;
  font-size: 12px;
  color: rgba(0, 0, 0, 0.45);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.candidate-detail-header {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 16px;
  margin-bottom: 20px;
}

.candidate-detail-name {
  margin: 0 0 8px;
  font-size: 18px;
  font-weight: 600;
  color: rgba(0, 0, 0, 0.88);
}

.candidate-detail-sub {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
}

.candidate-score-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 12px 24px;
  margin-bottom: 20px;
}

.candidate-score-item-label {
  display: flex;
  justify-content: space-between;
  margin-bottom: 4px;
  font-size: 13px;
  color: rgba(0, 0, 0, 0.65);
}

.candidate-note-block {
  padding: 12px 14px;
  background: #fafafa;
  border-radius: 8px;
  border: 1px solid #f0f0f0;
  margin-bottom: 12px;

  &:last-child {
    margin-bottom: 0;
  }
}

.candidate-note-block-title {
  font-weight: 600;
  margin-bottom: 6px;
  color: rgba(0, 0, 0, 0.88);
}

.candidate-overview-stats {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 12px;
  padding: 16px 20px;
  border-bottom: 1px solid #f0f0f0;
}

.candidate-stat-card {
  padding: 12px 14px;
  background: #fafafa;
  border-radius: 8px;
  border: 1px solid #f0f0f0;
}

.candidate-stat-value {
  font-size: 22px;
  font-weight: 600;
  line-height: 1.2;
  color: rgba(0, 0, 0, 0.88);
}

.candidate-stat-label {
  margin-top: 4px;
  font-size: 12px;
  color: rgba(0, 0, 0, 0.45);
}

.candidate-overview-section {
  padding: 16px 20px 20px;
}

.candidate-schedule-row {
  display: grid;
  grid-template-columns: 72px 1fr 88px 72px;
  gap: 12px;
  align-items: center;
  padding: 10px 0;
  border-bottom: 1px solid #f5f5f5;
  font-size: 13px;

  &:last-child {
    border-bottom: none;
  }
}

.candidate-schedule-time {
  color: rgba(0, 0, 0, 0.45);
}

.demo-panel-hint {
  margin: 0;
  padding: 10px 16px;
  font-size: 12px;
  color: rgba(0, 0, 0, 0.45);
  background: #fffbe6;
  border-bottom: 1px solid #ffe58f;
}

.demo-job-preview {
  border-radius: 8px;
  overflow: hidden;
  border: 1px solid #f0f0f0;
  background: #fff;
}

.demo-job-preview-header {
  padding: 16px 20px;
  background: linear-gradient(135deg, #f6ffed 0%, #fff 100%);
  border-bottom: 1px solid #f0f0f0;
}

.demo-job-preview-body {
  padding: 16px 20px;
  color: rgba(0, 0, 0, 0.65);
  line-height: 1.7;
}

.demo-modal-height-limited-panel {
  min-height: 100%;
  box-sizing: border-box;
  padding: 12px 16px;
  background: linear-gradient(180deg, #fff7e6 0%, #fff 48px);
  border: 2px dashed #fa8c16;
  border-radius: 6px;
}

.demo-modal-height-limited-panel-title {
  margin: 0 0 8px;
  font-weight: 600;
  color: rgba(0, 0, 0, 0.88);
}

.demo-modal-height-limited-line {
  margin: 6px 0;
  font-size: 13px;
  color: rgba(0, 0, 0, 0.65);
}
}

示例代码

  • 基础弹层
  • Modal / Drawer 切换:受控打开与异步 onConfirm(loading / 成功提示)
  • _ReactModal(@kne/current-lib_react-modal)[import * as _ReactModal from "@kne/react-modal"],(@kne/current-lib_react-modal/dist/index.css),antd(antd)
const { default: Modal, Drawer, DrawerContextHolder, ModalContextHolder } = _ReactModal;
const { Button, Space, message, Typography, Radio, App } = antd;
const { useState, useEffect } = React;

const { Text, Paragraph } = Typography;

const BasicExample = () => {
  const [open, setOpen] = useState(false);
  const [mode, setMode] = useState('modal');
  const isDrawer = mode === 'drawer';

  useEffect(() => {
    if (open) {
      setOpen(false);
    }
  }, [mode]);

  const overlayProps = {
    title: '保存评估备注',
    open,
    onClose: () => setOpen(false),
    onConfirm: async () => {
      await new Promise(resolve => setTimeout(resolve, 800));
      message.success('备注已保存至候选人档案');
    },
    confirmText: '保存'
  };

  const content = (
    <>
      <Paragraph style={{ marginBottom: 8 }}>
        将把当前页面的筛选条件与评估摘要一并写入 <Text strong>陈思远</Text> 的档案备注。
      </Paragraph>
      <Paragraph type="secondary" style={{ marginBottom: 0 }}>
        保存后可在「候选人详情 → 操作记录」中查看历史版本。
      </Paragraph>
    </>
  );

  return (
    <Space direction="vertical" style={{ width: '100%' }} size="middle">
      <Space align="center" wrap>
        <Text type="secondary">打开方式</Text>
        <Radio.Group
          value={mode}
          optionType="button"
          size="small"
          options={[
            { label: 'Modal', value: 'modal' },
            { label: 'Drawer', value: 'drawer' }
          ]}
          onChange={e => setMode(e.target.value)}
        />
      </Space>
      <Button type="primary" onClick={() => setOpen(true)}>
        保存评估备注
      </Button>
      <Text type="secondary">最简受控弹层:切换 Modal / Drawer 对比同一套 props;异步 onConfirm 带 loading。</Text>
      {isDrawer ? (
        <Drawer {...overlayProps} size="default">
          {content}
        </Drawer>
      ) : (
        <Modal {...overlayProps}>{content}</Modal>
      )}
    </Space>
  );
};

render(
  <App>
    <ModalContextHolder />
    <DrawerContextHolder />
    <BasicExample />
  </App>
);
  • footerButtons 与尺寸
  • Modal / Drawer 切换:size / footerButtons / 左侧 footer 插槽 / noPadding 预览
  • _ReactModal(@kne/current-lib_react-modal)[import * as _ReactModal from "@kne/react-modal"],(@kne/current-lib_react-modal/dist/index.css),antd(antd)
const { default: Modal, Drawer, DrawerContextHolder, ModalContextHolder } = _ReactModal;
const { Button, Space, Radio, Tag, message, Typography, App } = antd;
const { useState, useEffect } = React;

const { Text, Title, Paragraph } = Typography;

const FooterButtonsExample = () => {
  const [open, setOpen] = useState(false);
  const [size, setSize] = useState('default');
  const [noPadding, setNoPadding] = useState(false);
  const [mode, setMode] = useState('modal');
  const isDrawer = mode === 'drawer';

  useEffect(() => {
    if (open) {
      setOpen(false);
    }
  }, [mode]);

  const overlayProps = {
    title: '确认发布岗位',
    size,
    noPadding,
    open,
    onClose: () => setOpen(false),
    footer: <Text type="secondary">发布后将同步至招聘官网与内推渠道</Text>,
    footerButtons: [
      {
        children: '存草稿',
        onClick: () => message.info('已保存草稿')
      },
      {
        children: '预览',
        display: () => size !== 'small',
        onClick: () => message.info('打开预览页')
      },
      {
        type: 'primary',
        children: '立即发布',
        onClick: async () => {
          await new Promise(resolve => setTimeout(resolve, 600));
          message.success('岗位已发布');
        }
      }
    ]
  };

  const content = (
    <div className="demo-job-preview">
      <div className="demo-job-preview-header">
        <Title level={5} style={{ margin: 0 }}>
          高级前端工程师
        </Title>
        <Space size={8} style={{ marginTop: 8 }}>
          <Tag color="blue">上海</Tag>
          <Tag>25K–40K · 15 薪</Tag>
          <Tag color="green">急招</Tag>
        </Space>
      </div>
      <div className="demo-job-preview-body">
        <Paragraph style={{ marginTop: 0 }}>负责招聘中台、候选人评估等 B 端产品的前端交付;要求熟悉 React、工程化与组件库协作。</Paragraph>
        <Paragraph style={{ marginBottom: 0 }}>{noPadding ? 'noPadding=true:预览卡片应贴齐内容区边缘。' : 'noPadding=false:预览卡片四周保留默认内边距。'}</Paragraph>
      </div>
    </div>
  );

  return (
    <Space direction="vertical" style={{ width: '100%' }} size="large">
      <Space align="center" wrap>
        <Text type="secondary">打开方式</Text>
        <Radio.Group
          value={mode}
          optionType="button"
          size="small"
          options={[
            { label: 'Modal', value: 'modal' },
            { label: 'Drawer', value: 'drawer' }
          ]}
          onChange={e => setMode(e.target.value)}
        />
      </Space>
      <div>
        <Text type="secondary">切换 Modal / Drawer、尺寸与 noPadding,观察预览卡片与 footer 按钮区布局。</Text>
      </div>
      <Radio.Group
        value={size}
        optionType="button"
        options={[
          { label: '小号', value: 'small' },
          { label: '默认', value: 'default' },
          { label: '大号', value: 'large' }
        ]}
        onChange={e => setSize(e.target.value)}
      />
      <Space wrap>
        <Button type="primary" onClick={() => setOpen(true)}>
          发布岗位确认
        </Button>
        <Button type={noPadding ? 'primary' : 'default'} onClick={() => setNoPadding(v => !v)}>
          noPadding={String(noPadding)}
        </Button>
      </Space>
      {isDrawer ? <Drawer {...overlayProps}>{content}</Drawer> : <Modal {...overlayProps}>{content}</Modal>}
    </Space>
  );
};

render(
  <App>
    <ModalContextHolder />
    <DrawerContextHolder />
    <FooterButtonsExample />
  </App>
);
  • 命令式打开(useModal / useDrawer)
  • 切换 Modal / Drawer:命令式快览,children 函数内可 close()
  • _ReactModal(@kne/current-lib_react-modal)[import * as _ReactModal from "@kne/react-modal"],(@kne/current-lib_react-modal/dist/index.css),antd(antd)
const { useModal, useDrawer, DrawerContextHolder, ModalContextHolder } = _ReactModal;
const { Button, Space, message, Descriptions, Tag, Typography, Radio, App } = antd;
const { useState } = React;

const { Text, Paragraph } = Typography;

const CommandExample = () => {
  const modal = useModal();
  const drawer = useDrawer();
  const [mode, setMode] = useState('modal');
  const isDrawer = mode === 'drawer';

  const openDetail = () => {
    const api = isDrawer ? drawer : modal;
    api({
      title: isDrawer ? '候选人快览(侧滑)' : '候选人快览',
      size: 'small',
      confirmText: '加入待评估',
      children: ({ close }) => (
        <div>
          <Descriptions column={1} size="small" bordered style={{ marginBottom: 12 }}>
            <Descriptions.Item label="姓名">李雨桐</Descriptions.Item>
            <Descriptions.Item label="岗位">前端工程师 · 4 年</Descriptions.Item>
            <Descriptions.Item label="来源">内推 · 张三</Descriptions.Item>
            <Descriptions.Item label="状态">
              <Tag color="processing">简历通过</Tag>
            </Descriptions.Item>
          </Descriptions>
          <Paragraph type="secondary" style={{ marginBottom: 12 }}>
            命令式 {isDrawer ? 'Drawer' : 'Modal'} 适用于列表页「快速查看」;children 为函数时可调用 <Text code>close()</Text> 主动关闭。
          </Paragraph>
          <Button size="small" onClick={() => close()}>
            关闭
          </Button>
        </div>
      ),
      onConfirm: () => {
        message.success('已加入待评估队列');
      }
    });
  };

  return (
    <Space direction="vertical">
      <Space align="center" wrap>
        <Text type="secondary">打开方式</Text>
        <Radio.Group
          value={mode}
          optionType="button"
          size="small"
          options={[
            { label: 'Modal', value: 'modal' },
            { label: 'Drawer', value: 'drawer' }
          ]}
          onChange={e => setMode(e.target.value)}
        />
      </Space>
      <Button type="primary" onClick={openDetail}>
        从列表打开候选人快览
      </Button>
      <Text type="secondary">须挂载 ModalContextHolder(内容弹窗 useModal)与 DrawerContextHolder(useDrawer);确认框仍用 useConfirmModal。</Text>
    </Space>
  );
};

render(
  <App>
    <ModalContextHolder />
    <DrawerContextHolder />
    <CommandExample />
  </App>
);
  • useConfirmModal 确认框
  • confirm / info / success / warning / error 命令式确认与提示(仅 Modal)
  • _ReactModal(@kne/current-lib_react-modal)[import * as _ReactModal from "@kne/react-modal"],(@kne/current-lib_react-modal/dist/index.css),antd(antd)
const { useConfirmModal } = _ReactModal;
const { Button, Space, App } = antd;

const CommandExample = () => {
  const confirmModal = useConfirmModal();

  return (
    <Space wrap>
      <Button
        onClick={() => {
          confirmModal({
            danger: true,
            type: 'confirm',
            title: '确定要删除该记录吗?',
            message: '此操作将永久删除该记录,相关数据将无法恢复。请确认是否继续删除操作?'
          });
        }}
      >
        confirm
      </Button>
      <Button
        onClick={() => {
          confirmModal({
            type: 'confirm',
            confirmType: 'warning',
            title: '确定要编辑此内容吗?',
            message: '编辑后需要重新提交审核,未保存的修改将丢失。请确认是否继续编辑?'
          });
        }}
      >
        confirm 警告
      </Button>
      <Button
        onClick={() => {
          confirmModal({
            type: 'info',
            title: '操作提示',
            message: '该操作将更新系统配置,可能影响其他用户的使用。建议在非工作时间进行此操作。'
          });
        }}
      >
        info
      </Button>
      <Button
        onClick={() => {
          confirmModal({
            type: 'info',
            message: '数据已保存成功,系统将在后台进行同步处理,请稍候查看处理结果。'
          });
        }}
      >
        info 无标题
      </Button>
      <Button
        onClick={() => {
          confirmModal({
            type: 'success',
            title: '操作成功',
            message: '恭喜!您的操作已成功完成。系统已发送通知邮件给相关团队成员。'
          });
        }}
      >
        success
      </Button>
      <Button
        onClick={() => {
          confirmModal({
            type: 'warning',
            title: '操作警告',
            message: '检测到数据异常,继续操作可能导致数据不一致。建议先备份数据或联系技术支持。'
          });
        }}
      >
        warning
      </Button>
      <Button
        onClick={() => {
          confirmModal({
            type: 'error',
            title: '操作失败',
            message: '系统处理出错,请检查网络连接或联系系统管理员。错误代码:ERR-500'
          });
        }}
      >
        error
      </Button>
    </Space>
  );
};

const BaseExample = () => (
  <App>
    <CommandExample />
  </App>
);

render(<BaseExample />);
  • 长内容滚动
  • Modal / Drawer 切换:SimpleBar vs bodyScroll=false 自管滚动
  • _ReactModal(@kne/current-lib_react-modal)[import * as _ReactModal from "@kne/react-modal"],(@kne/current-lib_react-modal/dist/index.css),antd(antd)
const { default: Modal, Drawer, DrawerContextHolder, ModalContextHolder } = _ReactModal;
const { Button, Space, Typography, Divider, Radio, App } = antd;
const { useState, useEffect } = React;

const { Title, Paragraph, Text } = Typography;

const EVAL_SECTIONS = [
  {
    title: '沟通表达',
    items: ['表达结构清晰,能准确复述业务目标与技术约束。', '对追问能够给出有层次的回答,而非堆砌名词。']
  },
  {
    title: '专业深度',
    items: ['熟悉 React 渲染机制,能说明列表虚拟化方案选型理由。', '了解前端监控与错误边界在生产环境的实践。']
  },
  {
    title: '项目复杂度',
    items: ['参与过多团队协同的中台项目,承担核心模块 Owner。', '能描述需求变更下的架构演进与风险控制。']
  },
  {
    title: '协作推进',
    items: ['主动同步风险,推动联调与验收节点按时完成。']
  }
];

const EvaluationContent = () => (
  <>
    {EVAL_SECTIONS.map(section => (
      <div key={section.title} style={{ marginBottom: 20 }}>
        <Title level={5} style={{ marginTop: 0 }}>
          {section.title}
        </Title>
        {section.items.map(item => (
          <Paragraph key={item} style={{ marginBottom: 8, color: 'rgba(0,0,0,0.65)' }}>
            · {item}
          </Paragraph>
        ))}
      </div>
    ))}
    <Divider />
    <Text type="secondary">以下为填充内容,用于验证长文滚动。</Text>
    {Array.from({ length: 12 }, (_, i) => (
      <Paragraph key={i} style={{ color: 'rgba(0,0,0,0.45)' }}>
        补充记录 {i + 1}:候选人对团队文化、工作方式与交付节奏均表示认可。
      </Paragraph>
    ))}
  </>
);

const LongContentExample = () => {
  const [open, setOpen] = useState(false);
  const [openSelfScroll, setOpenSelfScroll] = useState(false);
  const [mode, setMode] = useState('modal');
  const isDrawer = mode === 'drawer';

  useEffect(() => {
    if (open) {
      setOpen(false);
    }
    if (openSelfScroll) {
      setOpenSelfScroll(false);
    }
  }, [mode]);

  const contentHeightVar = isDrawer ? '--kne-drawer-content-height' : '--kne-modal-content-height';
  const Overlay = isDrawer ? Drawer : Modal;

  return (
    <Space direction="vertical" style={{ width: '100%' }} size="middle">
      <Space align="center" wrap>
        <Text type="secondary">打开方式</Text>
        <Radio.Group
          value={mode}
          optionType="button"
          size="small"
          options={[
            { label: 'Modal', value: 'modal' },
            { label: 'Drawer', value: 'drawer' }
          ]}
          onChange={e => setMode(e.target.value)}
        />
      </Space>
      <Paragraph type="secondary" style={{ margin: 0 }}>
        对比默认 SimpleBar 与 bodyScroll=false 自管滚动;切换 Modal / Drawer 观察高度链一致。
      </Paragraph>
      <Space wrap>
        <Button type="primary" onClick={() => setOpen(true)}>
          打开评估详情(SimpleBar)
        </Button>
        <Button onClick={() => setOpenSelfScroll(true)}>自管滚动(bodyScroll=false)</Button>
      </Space>
      <Overlay title="陈思远 · 面试评估纪要" open={open} onClose={() => setOpen(false)} onConfirm={() => {}} confirmText="保存纪要" size={isDrawer ? 'default' : undefined}>
        <EvaluationContent />
      </Overlay>
      <Overlay title="自管滚动示例" open={openSelfScroll} onClose={() => setOpenSelfScroll(false)} bodyScroll={false} footer={null} size={isDrawer ? 'large' : undefined}>
        <div
          style={{
            height: &#96;var(${contentHeightVar})&#96;,
            minHeight: 0,
            overflow: 'auto',
            boxSizing: 'border-box',
            background: '#fafafa'
          }}
        >
          <div style={{ padding: 20 }}>
            <Paragraph style={{ marginTop: 0 }}>
              内容区高度绑定 <Text code>{contentHeightVar}</Text>,Tabs / 分栏场景同样适用。
            </Paragraph>
            <EvaluationContent />
          </div>
        </div>
      </Overlay>
    </Space>
  );
};

render(
  <App>
    <ModalContextHolder />
    <DrawerContextHolder />
    <LongContentExample />
  </App>
);
  • 高度 CSS 变量
  • Modal / Drawer 切换:默认/自定义 CSS 变量;色块绑定 content-height,探针显示变量与 clientHeight
  • _ReactModal(@kne/current-lib_react-modal)[import * as _ReactModal from "@kne/react-modal"],(@kne/current-lib_react-modal/dist/index.css),antd(antd)
const { default: Modal, Drawer, DrawerContextHolder, ModalContextHolder } = _ReactModal;
const { Button, Space, Switch, Tag, Descriptions, Radio, Typography, App } = antd;
const { useState, useEffect } = React;

const { Text } = Typography;

const readCssVar = (el, name) => {
  if (!el) {
    return '-';
  }
  return getComputedStyle(el).getPropertyValue(name).trim() || '-';
};

const getOverlayChrome = mode => {
  const isDrawer = mode === 'drawer';
  return {
    isDrawer,
    testId: isDrawer ? 'react-drawer' : 'react-modal',
    bodyClass: isDrawer ? 'drawer-body' : 'modal-body',
    innerClass: isDrawer ? 'drawer-body-inner' : 'modal-body-inner',
    contentHeightVar: isDrawer ? '--kne-drawer-content-height' : '--kne-modal-content-height',
    bodyHeightVar: isDrawer ? '--kne-drawer-body-height' : '--kne-modal-body-height',
    varPrefix: isDrawer ? '--kne-drawer' : '--kne-modal'
  };
};

const HeightProbe = ({ open, revision, mode }) => {
  const [metrics, setMetrics] = useState(null);
  const chrome = getOverlayChrome(mode);

  useEffect(() => {
    if (!open) {
      setMetrics(null);
      return undefined;
    }
    const timer = setTimeout(() => {
      const outer = document.querySelector(&#96;[data-testid="${chrome.testId}"]&#96;);
      const body = outer && outer.querySelector(&#96;.${chrome.bodyClass}&#96;);
      const inner = outer && outer.querySelector(&#96;.${chrome.innerClass}&#96;);
      const fill = outer && outer.querySelector('[data-vars-fill]');
      setMetrics({
        gutter: readCssVar(outer, &#96;${chrome.varPrefix}-viewport-gutter&#96;),
        bodyHeightVar: readCssVar(body || outer, chrome.bodyHeightVar),
        contentHeightVar: readCssVar(inner || body || outer, chrome.contentHeightVar),
        bodyMin: readCssVar(outer, &#96;${chrome.varPrefix}-body-min-height&#96;),
        paddingV: readCssVar(outer, &#96;${chrome.varPrefix}-body-padding-vertical&#96;),
        bodyClient: body ? &#96;${body.clientHeight}px&#96; : '-',
        fillClient: fill ? &#96;${fill.clientHeight}px&#96; : '-'
      });
    }, 80);
    return () => clearTimeout(timer);
  }, [open, revision, mode, chrome]);

  if (!metrics) {
    return <Tag>打开弹层后显示变量与实测高度</Tag>;
  }

  return (
    <Descriptions size="small" bordered column={1} style={{ maxWidth: 640 }}>
      <Descriptions.Item label={&#96;${chrome.varPrefix}-viewport-gutter&#96;}>{metrics.gutter}</Descriptions.Item>
      <Descriptions.Item label={&#96;${chrome.varPrefix}-body-min-height&#96;}>{metrics.bodyMin}</Descriptions.Item>
      <Descriptions.Item label={&#96;${chrome.varPrefix}-body-padding-vertical&#96;}>{metrics.paddingV}</Descriptions.Item>
      <Descriptions.Item label={chrome.bodyHeightVar}>{metrics.bodyHeightVar}</Descriptions.Item>
      <Descriptions.Item label={chrome.contentHeightVar}>{metrics.contentHeightVar}</Descriptions.Item>
      <Descriptions.Item label="body.clientHeight">{metrics.bodyClient}</Descriptions.Item>
      <Descriptions.Item label="色块 clientHeight(应≈ content-height)">{metrics.fillClient}</Descriptions.Item>
    </Descriptions>
  );
};

const fillLines = Array.from({ length: 18 }, (_, i) => &#96;填充行 ${i + 1} · 用于验证 content-height 滚动&#96;);

const HeightVarsExample = () => {
  const [open, setOpen] = useState(false);
  const [customVars, setCustomVars] = useState(true);
  const [mode, setMode] = useState('modal');
  const isDrawer = mode === 'drawer';

  useEffect(() => {
    if (open) {
      setOpen(false);
    }
  }, [mode]);

  const chrome = getOverlayChrome(mode);
  const revision = &#96;${mode}|${customVars}|${open}&#96;;
  const Overlay = isDrawer ? Drawer : Modal;

  const overlayStyle = customVars
    ? isDrawer
      ? {
          '--kne-drawer-body-min-height': '180px',
          '--kne-drawer-body-padding-vertical': '64px'
        }
      : {
          '--kne-modal-viewport-gutter': '240px',
          '--kne-modal-body-min-height': '180px',
          '--kne-modal-body-padding-vertical': '64px'
        }
    : undefined;

  return (
    <Space direction="vertical" style={{ width: '100%' }} size="middle">
      <Space align="center" wrap>
        <Text type="secondary">打开方式</Text>
        <Radio.Group
          value={mode}
          optionType="button"
          size="small"
          options={[
            { label: 'Modal', value: 'modal' },
            { label: 'Drawer', value: 'drawer' }
          ]}
          onChange={e => setMode(e.target.value)}
        />
      </Space>
      <Space wrap align="center">
        <Switch checked={customVars} onChange={setCustomVars} checkedChildren="自定义变量" unCheckedChildren="默认变量" />
        <Button type="primary" onClick={() => setOpen(true)}>
          打开弹层对比高度
        </Button>
        <Tag color={customVars ? 'blue' : 'default'}>{customVars ? '已覆盖 CSS 变量' : '库内默认值'}</Tag>
      </Space>
      <div style={{ color: 'rgba(0,0,0,0.45)', maxWidth: 640 }}>
        通过 <code>style</code> 覆盖 {chrome.varPrefix}-* 变量。Drawer 无 viewport-gutter;Modal 可加大 gutter 使 body 明显变矮。色块绑定 <code>{chrome.contentHeightVar}</code>。
      </div>
      <HeightProbe open={open} revision={revision} mode={mode} />
      <Overlay
        title={customVars ? '自定义高度 CSS 变量' : '默认高度 CSS 变量'}
        open={open}
        onClose={() => setOpen(false)}
        onConfirm={() => {}}
        bodyScroll={false}
        noPadding={false}
        style={overlayStyle}
        size={isDrawer ? 'default' : undefined}
      >
        <div
          data-vars-fill
          style={{
            height: &#96;var(${chrome.contentHeightVar})&#96;,
            minHeight: 0,
            overflow: 'auto',
            boxSizing: 'border-box',
            background: customVars ? '#fff1f0' : '#e6f4ff',
            border: customVars ? '2px solid #ff4d4f' : '2px solid #1677ff'
          }}
        >
          <div style={{ padding: 12 }}>
            <p style={{ marginTop: 0, fontWeight: 600 }}>色块 height = var({chrome.contentHeightVar})。切换「自定义/默认」与 Modal/Drawer 后重新打开对比。</p>
            {fillLines.map(text => (
              <p key={text} style={{ margin: '4px 0' }}>
                {text}
              </p>
            ))}
          </div>
        </div>
      </Overlay>
    </Space>
  );
};

render(
  <App>
    <ModalContextHolder />
    <DrawerContextHolder />
    <HeightVarsExample />
  </App>
);
  • title / footer / noPadding 高度探针
  • Modal / Drawer 切换:title、footer、noPadding、bodyScroll 组合实测高度变量
  • _ReactModal(@kne/current-lib_react-modal)[import * as _ReactModal from "@kne/react-modal"],(@kne/current-lib_react-modal/dist/index.css),antd(antd)
const { default: Modal, Drawer, DrawerContextHolder, ModalContextHolder } = _ReactModal;
const { Button, Space, Switch, Radio, Tag, Descriptions, Typography, App } = antd;
const { useState, useEffect } = React;

const { Text } = Typography;

const readCssVar = (el, name) => {
  if (!el) {
    return '-';
  }
  return getComputedStyle(el).getPropertyValue(name).trim() || '-';
};

const getOverlayChrome = mode => {
  const isDrawer = mode === 'drawer';
  return {
    isDrawer,
    testId: isDrawer ? 'react-drawer' : 'react-modal',
    bodyClass: isDrawer ? 'drawer-body' : 'modal-body',
    innerClass: isDrawer ? 'drawer-body-inner' : 'modal-body-inner',
    contentHeightVar: isDrawer ? '--kne-drawer-content-height' : '--kne-modal-content-height',
    bodyHeightVar: isDrawer ? '--kne-drawer-body-height' : '--kne-modal-body-height',
    varPrefix: isDrawer ? '--kne-drawer' : '--kne-modal'
  };
};

const HeightProbe = ({ open, revision, mode }) => {
  const [metrics, setMetrics] = useState(null);
  const chrome = getOverlayChrome(mode);

  useEffect(() => {
    if (!open) {
      setMetrics(null);
      return undefined;
    }
    const timer = setTimeout(() => {
      const outer = document.querySelector(&#96;[data-testid="${chrome.testId}"]&#96;);
      const body = outer && outer.querySelector(&#96;.${chrome.bodyClass}&#96;);
      const inner = outer && outer.querySelector(&#96;.${chrome.innerClass}&#96;);
      const scrollHost = outer && outer.querySelector('[data-height-scroll-host]');
      setMetrics({
        outerClass: outer ? outer.className : '-',
        bodyHeightVar: readCssVar(body || outer, chrome.bodyHeightVar),
        contentHeightVar: readCssVar(inner || body || outer, chrome.contentHeightVar),
        paddingV: readCssVar(outer, &#96;${chrome.varPrefix}-body-padding-vertical&#96;),
        paddingH: readCssVar(outer, &#96;${chrome.varPrefix}-body-padding-horizontal&#96;),
        titleH: readCssVar(outer, &#96;${chrome.varPrefix}-title-height&#96;),
        footerH: readCssVar(outer, &#96;${chrome.varPrefix}-footer-height&#96;),
        bodyClient: body ? &#96;${body.clientHeight}px&#96; : '-',
        innerClient: inner ? &#96;${inner.clientHeight}px&#96; : '-',
        scrollClient: scrollHost ? &#96;${scrollHost.clientHeight}px&#96; : '-'
      });
    }, 80);
    return () => clearTimeout(timer);
  }, [open, revision, mode, chrome]);

  if (!metrics) {
    return <Tag>打开弹层后显示实测高度</Tag>;
  }

  return (
    <Descriptions size="small" bordered column={1} style={{ maxWidth: 640 }}>
      <Descriptions.Item label="outer class">{metrics.outerClass}</Descriptions.Item>
      <Descriptions.Item label={&#96;${chrome.varPrefix}-title-height&#96;}>{metrics.titleH}</Descriptions.Item>
      <Descriptions.Item label={&#96;${chrome.varPrefix}-footer-height&#96;}>{metrics.footerH}</Descriptions.Item>
      <Descriptions.Item label={&#96;${chrome.varPrefix}-body-padding-vertical&#96;}>{metrics.paddingV}</Descriptions.Item>
      <Descriptions.Item label={&#96;${chrome.varPrefix}-body-padding-horizontal&#96;}>{metrics.paddingH}</Descriptions.Item>
      <Descriptions.Item label={chrome.bodyHeightVar}>{metrics.bodyHeightVar}</Descriptions.Item>
      <Descriptions.Item label={chrome.contentHeightVar}>{metrics.contentHeightVar}</Descriptions.Item>
      <Descriptions.Item label="body.clientHeight">{metrics.bodyClient}</Descriptions.Item>
      <Descriptions.Item label="body-inner.clientHeight">{metrics.innerClient}</Descriptions.Item>
      <Descriptions.Item label="scrollHost.clientHeight">{metrics.scrollClient}</Descriptions.Item>
    </Descriptions>
  );
};

const ChromeHeightExample = () => {
  const [open, setOpen] = useState(false);
  const [hasTitle, setHasTitle] = useState(true);
  const [footerMode, setFooterMode] = useState('default');
  const [bodyScroll, setBodyScroll] = useState(false);
  const [noPaddingMode, setNoPaddingMode] = useState('auto');
  const [mode, setMode] = useState('modal');
  const isDrawer = mode === 'drawer';

  useEffect(() => {
    if (open) {
      setOpen(false);
    }
  }, [mode]);

  const chrome = getOverlayChrome(mode);
  const Overlay = isDrawer ? Drawer : Modal;
  const revision = [mode, hasTitle, footerMode, bodyScroll, noPaddingMode, open].join('|');

  const noPaddingProp = noPaddingMode === 'auto' ? undefined : noPaddingMode === 'true';

  const overlayProps = {
    open,
    onClose: () => setOpen(false),
    bodyScroll,
    ...(hasTitle ? { title: '候选人评估 · 高度调试' } : {}),
    ...(noPaddingProp === undefined ? {} : { noPadding: noPaddingProp }),
    size: isDrawer ? 'default' : undefined
  };

  if (footerMode === 'null') {
    overlayProps.footer = null;
  } else if (footerMode === 'emptyButtons') {
    overlayProps.footer = <span>仅左侧 footer,按钮为空数组</span>;
    overlayProps.footerButtons = [];
  } else {
    overlayProps.onConfirm = () => {};
  }

  const lines = Array.from({ length: 24 }, (_, i) => &#96;行 ${i + 1} · 用于观察滚动与高度是否贴齐&#96;);

  return (
    <Space direction="vertical" style={{ width: '100%' }} size="middle">
      <Space align="center" wrap>
        <Text type="secondary">打开方式</Text>
        <Radio.Group
          value={mode}
          optionType="button"
          size="small"
          options={[
            { label: 'Modal', value: 'modal' },
            { label: 'Drawer', value: 'drawer' }
          ]}
          onChange={e => setMode(e.target.value)}
        />
      </Space>
      <Space wrap align="center">
        <span>title</span>
        <Switch checked={hasTitle} onChange={setHasTitle} checkedChildren="有" unCheckedChildren="空" />
        <span>bodyScroll</span>
        <Switch checked={bodyScroll} onChange={setBodyScroll} checkedChildren="true" unCheckedChildren="false" />
      </Space>
      <div>
        <div style={{ marginBottom: 8 }}>footer</div>
        <Radio.Group
          value={footerMode}
          optionType="button"
          options={[
            { label: '默认按钮', value: 'default' },
            { label: 'footer=null(无 footer 区)', value: 'null' },
            { label: 'footerButtons=[]', value: 'emptyButtons' }
          ]}
          onChange={e => setFooterMode(e.target.value)}
        />
      </div>
      <div>
        <div style={{ marginBottom: 8 }}>noPadding</div>
        <Radio.Group
          value={noPaddingMode}
          optionType="button"
          options={[
            { label: '未传(auto)', value: 'auto' },
            { label: 'true', value: 'true' },
            { label: 'false', value: 'false' }
          ]}
          onChange={e => setNoPaddingMode(e.target.value)}
        />
      </div>
      <Space wrap>
        <Button type="primary" onClick={() => setOpen(true)}>
          打开并测量
        </Button>
        <Tag>mode={mode}</Tag>
        <Tag>footerMode={footerMode}</Tag>
      </Space>
      <HeightProbe open={open} revision={revision} mode={mode} />
      <Overlay {...overlayProps}>
        {bodyScroll === false ? (
          <div
            data-height-scroll-host
            style={{
              height: &#96;var(${chrome.contentHeightVar})&#96;,
              minHeight: 0,
              overflow: 'auto',
              boxSizing: 'border-box',
              background: noPaddingProp === false ? '#fff7e6' : '#e6f4ff',
              border: noPaddingProp === false ? '2px dashed #fa8c16' : '2px dashed #1677ff'
            }}
          >
            <div style={{ padding: 12 }}>
              <p style={{ marginTop: 0, fontWeight: 600 }}>滚动宿主 height: var({chrome.contentHeightVar})。footer=null 时 footer 变量应为 0。</p>
              {lines.map(text => (
                <p key={text}>{text}</p>
              ))}
            </div>
          </div>
        ) : (
          <div
            data-height-scroll-host
            style={{
              background: noPaddingProp ? '#e6f4ff' : '#fff7e6',
              border: noPaddingProp ? '2px dashed #1677ff' : '2px dashed #fa8c16',
              minHeight: 120
            }}
          >
            <div style={{ padding: 12 }}>
              <p style={{ marginTop: 0, fontWeight: 600 }}>SimpleBar 模式:看色块是否贴边判断 noPadding。</p>
              {lines.slice(0, 8).map(text => (
                <p key={text}>{text}</p>
              ))}
            </div>
          </div>
        )}
      </Overlay>
    </Space>
  );
};

render(
  <App>
    <ModalContextHolder />
    <DrawerContextHolder />
    <ChromeHeightExample />
  </App>
);
  • 高度受限 · 居中弹窗
  • kne-modal-height-limited 压低 body 高度;声明式 / useModal 对比居中与 confirm-paragraph 宽度
  • _ReactModal(@kne/current-lib_react-modal)[import * as _ReactModal from "@kne/react-modal"],(@kne/current-lib_react-modal/dist/index.css),antd(antd)
const { default: Modal, useModal, DrawerContextHolder, ModalContextHolder } = _ReactModal;
const { Button, Space, Radio, Typography, App } = antd;
const { useState } = React;

const { Text, Paragraph } = Typography;

const fillLines = Array.from({ length: 20 }, (_, i) => &#96;填充行 ${i + 1} · 用于观察高度受限时 body 内滚动&#96;);

const LimitedHeightExample = () => {
  const [open, setOpen] = useState(false);
  const [mode, setMode] = useState('declarative');
  const modal = useModal();

  const content = (
    <div className="demo-modal-height-limited-panel">
      <p className="demo-modal-height-limited-panel-title">高度受限弹窗(kne-modal-height-limited)</p>
      <Paragraph type="secondary" style={{ marginBottom: 12 }}>
        默认从视口垂直居中弹出;body 高度被 CSS 变量压到约 420px,长内容在 SimpleBar 内滚动。
      </Paragraph>
      {fillLines.map(text => (
        <p key={text} className="demo-modal-height-limited-line">
          {text}
        </p>
      ))}
    </div>
  );

  const overlayProps = {
    title: '高度受限 · 居中弹窗',
    className: 'kne-modal-height-limited',
    onClose: () => setOpen(false),
    onConfirm: () => setOpen(false),
    confirmText: '知道了'
  };

  return (
    <Space direction="vertical" style={{ width: '100%' }} size="middle">
      <Space align="center" wrap>
        <Text type="secondary">打开方式</Text>
        <Radio.Group
          value={mode}
          optionType="button"
          size="small"
          options={[
            { label: '声明式 Modal', value: 'declarative' },
            { label: 'useModal(命令式)', value: 'imperative' }
          ]}
          onChange={e => setMode(e.target.value)}
        />
      </Space>
      <Button
        type="primary"
        onClick={() => {
          if (mode === 'imperative') {
            modal({
              ...overlayProps,
              children: content
            });
            return;
          }
          setOpen(true);
        }}
      >
        打开高度受限弹窗
      </Button>
      <Text type="secondary">useModal 走声明式 Modal(经 ModalContextHolder 就近 patch),与声明式同一套 chrome。</Text>
      {mode === 'declarative' ? (
        <Modal {...overlayProps} open={open}>
          {content}
        </Modal>
      ) : null}
    </Space>
  );
};

render(
  <App>
    <ModalContextHolder />
    <DrawerContextHolder />
    <LimitedHeightExample />
  </App>
);
  • 批量候选人评估(Tabs + 分栏)
  • Modal / Drawer 切换:Tabs 分栏 / Splitter / 批次概览
  • _ReactModal(@kne/current-lib_react-modal)[import * as _ReactModal from "@kne/react-modal"],(@kne/current-lib_react-modal/dist/index.css),antd(antd)
const { default: Modal, Drawer, DrawerContextHolder, ModalContextHolder, TabsLayout, ColumnsLayout, ScrollRegion, modalClassNames } = _ReactModal;
const { Button, Space, Splitter, Input, Avatar, Tag, Descriptions, Progress, Typography, Divider, App, message, Radio } = antd;
const { useState, useMemo, useEffect } = React;

const { Text, Title, Paragraph } = Typography;

const STATUS_MAP = {
  pending: { label: '待评估', color: 'orange' },
  passed: { label: '已通过', color: 'success' },
  hold: { label: '待定', color: 'processing' }
};

const CANDIDATES = [
  {
    key: '1',
    name: '陈思远',
    role: '高级前端工程师',
    years: 6,
    city: '上海',
    edu: '浙江大学 · 本科',
    status: 'pending',
    interviewer: '王面试官',
    scores: { comm: 4, tech: 5, project: 4, collab: 4 },
    summary: '主导过设计系统与性能治理,对 React 生态和工程化有较深实践。',
    notes: [
      { title: '技术深度', body: '能清晰描述虚拟列表、并发特性在项目中的落地方式,并给出可量化的性能收益。' },
      { title: '协作推进', body: '跨端联调经验充足,曾推动组件库在 3 条业务线统一接入。' },
      { title: '待确认', body: '对 Node 中间层经验相对薄弱,需二面补充后端协作场景。' }
    ]
  },
  {
    key: '2',
    name: '李雨桐',
    role: '前端工程师',
    years: 4,
    city: '上海',
    edu: '同济大学 · 硕士',
    status: 'passed',
    interviewer: '赵面试官',
    scores: { comm: 5, tech: 4, project: 4, collab: 5 },
    summary: '表达结构清晰,B 端复杂表单与权限场景经验丰富。',
    notes: [
      { title: '项目经验', body: '负责过审批流配置平台,熟悉动态表单、状态机与低代码接入。' },
      { title: '综合评价', body: '建议通过,可安排 HR 谈薪。' }
    ]
  },
  {
    key: '3',
    name: '周亦凡',
    role: '资深前端',
    years: 8,
    city: '杭州',
    edu: '华中科技大学 · 本科',
    status: 'hold',
    interviewer: '王面试官',
    scores: { comm: 3, tech: 5, project: 5, collab: 3 },
    summary: '技术栈匹配度高,但管理岗预期与当前编制不完全一致。',
    notes: [
      { title: '优势', body: '架构视野好,有微前端与监控体系从 0 到 1 经验。' },
      { title: '风险', body: '期望职级偏高,需与部门负责人对齐编制与职责范围。' }
    ]
  },
  {
    key: '4',
    name: '张可欣',
    role: 'React 开发',
    years: 3,
    city: '上海',
    edu: '华东师范大学 · 本科',
    status: 'pending',
    interviewer: '待分配',
    scores: { comm: 4, tech: 3, project: 3, collab: 4 },
    summary: '基础扎实,学习意愿强,适合参与组件库与文档建设。',
    notes: [{ title: '初面印象', body: 'Coding 完成度较好,需进一步考察复杂状态管理与性能排查。' }]
  },
  {
    key: '5',
    name: '刘浩然',
    role: '全栈工程师',
    years: 5,
    city: '深圳',
    edu: '中山大学 · 本科',
    status: 'passed',
    interviewer: '陈面试官',
    scores: { comm: 4, tech: 4, project: 4, collab: 4 },
    summary: '前后端均可独立交付,适合业务闭环小团队。',
    notes: [{ title: '备注', body: '可优先推进 offer,期望 4 月到岗。' }]
  },
  {
    key: '6',
    name: '赵一鸣',
    role: '前端工程师',
    years: 2,
    city: '上海',
    edu: '上海大学 · 本科',
    status: 'pending',
    interviewer: '待分配',
    scores: { comm: 3, tech: 3, project: 2, collab: 3 },
    summary: '初级候选人,项目复杂度一般,建议放入人才池观察。',
    notes: [{ title: '初筛', body: '基础题通过,项目细节描述不够深入。' }]
  },
  {
    key: '7',
    name: '孙雅琪',
    role: '高级前端',
    years: 7,
    city: '北京',
    edu: '北京邮电大学 · 硕士',
    status: 'hold',
    interviewer: '王面试官',
    scores: { comm: 4, tech: 5, project: 4, collab: 4 },
    summary: '技术匹配,但地域需确认是否接受 base 上海。',
    notes: [{ title: '跟进', body: '已发送 relocate 意愿确认邮件。' }]
  },
  {
    key: '8',
    name: '吴承泽',
    role: '前端架构师',
    years: 10,
    city: '上海',
    edu: '上海交通大学 · 硕士',
    status: 'passed',
    interviewer: '总监面',
    scores: { comm: 5, tech: 5, project: 5, collab: 5 },
    summary: '综合表现优秀,建议进入终面委员会评审。',
    notes: [
      { title: '架构能力', body: '对团队工程规范、发布流程、质量门禁有体系化方法论。' },
      { title: '下一步', body: '安排与研发负责人终面。' }
    ]
  }
];

const EXTRA_CANDIDATES = Array.from({ length: 12 }, (_, i) => ({
  key: String(i + 9),
  name: &#96;候选人 ${i + 9}&#96;,
  role: i % 2 === 0 ? '前端工程师' : '高级前端',
  years: 2 + (i % 6),
  city: ['上海', '杭州', '深圳'][i % 3],
  edu: '本科',
  status: ['pending', 'passed', 'hold'][i % 3],
  interviewer: '待分配',
  scores: { comm: 3 + (i % 3), tech: 3 + (i % 2), project: 3, collab: 3 },
  summary: '批量导入的待评估候选人,用于验证左侧列表滚动与选中切换。',
  notes: [{ title: '系统备注', body: '暂无详细评估记录,请安排初面后补充。' }]
}));

const ALL_CANDIDATES = [...CANDIDATES, ...EXTRA_CANDIDATES];

const SCORE_LABELS = {
  comm: '沟通表达',
  tech: '专业深度',
  project: '项目复杂度',
  collab: '协作推进'
};

const CandidateList = ({ items, activeKey, onSelect, search, onSearchChange }) => (
  <>
    <div className="modal-scroll-region-sticky candidate-list-toolbar">
      <Input allowClear placeholder="搜索姓名、岗位、城市…" value={search} onChange={e => onSearchChange(e.target.value)} />
      <Text type="secondary">本批 {items.length} 人 · 点击切换右侧详情</Text>
    </div>
    {items.map(item => {
      const status = STATUS_MAP[item.status] || STATUS_MAP.pending;
      return (
        <div key={item.key} className={&#96;candidate-list-item${item.key === activeKey ? ' is-active' : ''}&#96;} onClick={() => onSelect(item.key)}>
          <div className="candidate-list-item-main">
            <Avatar style={{ backgroundColor: item.key === activeKey ? '#1677ff' : '#87d068' }}>{item.name.slice(-2)}</Avatar>
            <div className="candidate-list-item-body">
              <div className="candidate-list-item-title">{item.name}</div>
              <div className="candidate-list-item-meta">
                {item.role} · {item.years} 年 · {item.city}
              </div>
            </div>
            <Tag color={status.color} style={{ margin: 0 }}>
              {status.label}
            </Tag>
          </div>
        </div>
      );
    })}
  </>
);

const CandidateDetail = ({ candidate }) => {
  if (!candidate) {
    return null;
  }
  const status = STATUS_MAP[candidate.status] || STATUS_MAP.pending;
  const avgScore = Object.values(candidate.scores).reduce((a, b) => a + b, 0) / Object.values(candidate.scores).length;

  return (
    <div className="candidate-detail">
      <div className="candidate-detail-header">
        <div>
          <Title level={4} className="candidate-detail-name">
            {candidate.name}
          </Title>
          <div className="candidate-detail-sub">
            <Tag>{candidate.role}</Tag>
            <Tag>{candidate.years} 年经验</Tag>
            <Tag>{candidate.city}</Tag>
            <Tag color={status.color}>{status.label}</Tag>
          </div>
        </div>
        <div style={{ textAlign: 'right' }}>
          <Text type="secondary">综合</Text>
          <div style={{ fontSize: 28, fontWeight: 600, color: '#1677ff', lineHeight: 1.2 }}>{avgScore.toFixed(1)}</div>
        </div>
      </div>

      <Descriptions size="small" bordered column={2} style={{ marginBottom: 20 }}>
        <Descriptions.Item label="学历">{candidate.edu}</Descriptions.Item>
        <Descriptions.Item label="面试官">{candidate.interviewer}</Descriptions.Item>
        <Descriptions.Item label="摘要" span={2}>
          {candidate.summary}
        </Descriptions.Item>
      </Descriptions>

      <Divider orientation="left" plain style={{ margin: '0 0 12px' }}>
        维度评分
      </Divider>
      <div className="candidate-score-grid">
        {Object.entries(candidate.scores).map(([key, value]) => (
          <div key={key}>
            <div className="candidate-score-item-label">
              <span>{SCORE_LABELS[key]}</span>
              <span>{value} / 5</span>
            </div>
            <Progress percent={value * 20} showInfo={false} strokeColor="#1677ff" size="small" />
          </div>
        ))}
      </div>

      <Divider orientation="left" plain style={{ margin: '0 0 12px' }}>
        面试记录
      </Divider>
      {candidate.notes.map(note => (
        <div key={note.title} className="candidate-note-block">
          <div className="candidate-note-block-title">{note.title}</div>
          <Paragraph style={{ margin: 0, color: 'rgba(0,0,0,0.65)' }}>{note.body}</Paragraph>
        </div>
      ))}
    </div>
  );
};

const useCandidatePanel = () => {
  const [active, setActive] = useState(ALL_CANDIDATES[0].key);
  const [search, setSearch] = useState('');

  const filtered = useMemo(() => {
    const q = search.trim().toLowerCase();
    if (!q) {
      return ALL_CANDIDATES;
    }
    return ALL_CANDIDATES.filter(c => c.name.toLowerCase().includes(q) || c.role.toLowerCase().includes(q) || c.city.toLowerCase().includes(q));
  }, [search]);

  const current = filtered.find(item => item.key === active) || filtered[0];

  return { active, setActive, search, setSearch, filtered, current };
};

const ColumnsPane = () => {
  const { setActive, search, setSearch, filtered, current } = useCandidatePanel();

  return (
    <ColumnsLayout widths={['34%', '1fr']}>
      <ScrollRegion>
        <CandidateList items={filtered} activeKey={current?.key} onSelect={setActive} search={search} onSearchChange={setSearch} />
      </ScrollRegion>
      <ScrollRegion inset>
        <CandidateDetail candidate={current} />
      </ScrollRegion>
    </ColumnsLayout>
  );
};

const SplitterPane = () => {
  const { setActive, search, setSearch, filtered, current } = useCandidatePanel();

  return (
    <div style={{ height: '100%', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
      <p className="demo-panel-hint">可拖拽中间分隔条调整列表宽度,左右列仍各自 SimpleBar 滚动。</p>
      <Splitter className={modalClassNames.splitter} style={{ flex: 1, minHeight: 0 }} defaultSize="34%" min="240" max="52%">
        <Splitter.Panel>
          <ScrollRegion>
            <CandidateList items={filtered} activeKey={current?.key} onSelect={setActive} search={search} onSearchChange={setSearch} />
          </ScrollRegion>
        </Splitter.Panel>
        <Splitter.Panel>
          <ScrollRegion inset>
            <CandidateDetail candidate={current} />
          </ScrollRegion>
        </Splitter.Panel>
      </Splitter>
    </div>
  );
};

const OverviewPane = () => {
  const stats = useMemo(() => {
    const pending = ALL_CANDIDATES.filter(c => c.status === 'pending').length;
    const passed = ALL_CANDIDATES.filter(c => c.status === 'passed').length;
    const hold = ALL_CANDIDATES.filter(c => c.status === 'hold').length;
    return { total: ALL_CANDIDATES.length, pending, passed, hold };
  }, []);

  const schedule = [
    { time: '09:30', name: '陈思远', role: '高级前端', room: 'A301', status: 'pending' },
    { time: '10:30', name: '李雨桐', role: '前端', room: 'A301', status: 'passed' },
    { time: '14:00', name: '周亦凡', role: '资深前端', room: 'B208', status: 'hold' },
    { time: '15:30', name: '张可欣', role: 'React', room: 'B208', status: 'pending' },
    { time: '16:30', name: '吴承泽', role: '架构师', room: '总监室', status: 'passed' }
  ];

  return (
    <ScrollRegion>
      <div className="candidate-overview-stats">
        {[
          { label: '本批人数', value: stats.total },
          { label: '待评估', value: stats.pending },
          { label: '已通过', value: stats.passed },
          { label: '待定', value: stats.hold }
        ].map(item => (
          <div key={item.label} className="candidate-stat-card">
            <div className="candidate-stat-value">{item.value}</div>
            <div className="candidate-stat-label">{item.label}</div>
          </div>
        ))}
      </div>
      <div className="candidate-overview-section">
        <Title level={5} style={{ marginTop: 0 }}>
          今日面试安排
        </Title>
        <div className="candidate-schedule-row" style={{ fontWeight: 600, color: 'rgba(0,0,0,0.45)' }}>
          <span>时间</span>
          <span>候选人</span>
          <span>会议室</span>
          <span>状态</span>
        </div>
        {schedule.map(row => {
          const status = STATUS_MAP[row.status];
          return (
            <div key={row.time + row.name} className="candidate-schedule-row">
              <span className="candidate-schedule-time">{row.time}</span>
              <span>
                {row.name}
                <Text type="secondary"> · {row.role}</Text>
              </span>
              <span>{row.room}</span>
              <Tag color={status.color} style={{ margin: 0, justifySelf: 'start' }}>
                {status.label}
              </Tag>
            </div>
          );
        })}
        <Divider />
        <Paragraph type="secondary" style={{ marginBottom: 0 }}>
          概览 Tab 同样使用 ScrollRegion:批次统计与日程较长时在本面板内滚动,不影响 Tabs 顶栏与底部操作区。
        </Paragraph>
        {Array.from({ length: 6 }, (_, i) => (
          <Paragraph key={i} style={{ marginBottom: 8, color: 'rgba(0,0,0,0.45)' }}>
            备注 {i + 1}:终面委员会会议安排在周五 15:00,需提前汇总已通过候选人材料。
          </Paragraph>
        ))}
      </div>
    </ScrollRegion>
  );
};

const ExtendLayoutExample = () => {
  const [open, setOpen] = useState(false);
  const [tab, setTab] = useState('columns');
  const [mode, setMode] = useState('modal');
  const isDrawer = mode === 'drawer';

  useEffect(() => {
    if (open) {
      setOpen(false);
    }
  }, [mode]);

  const Overlay = isDrawer ? Drawer : Modal;

  const layout = (
    <TabsLayout
      activeKey={tab}
      onChange={setTab}
      items={[
        { key: 'columns', label: '候选人', children: <ColumnsPane /> },
        { key: 'split', label: '可调分栏', children: <SplitterPane /> },
        { key: 'overview', label: '批次概览', children: <OverviewPane /> }
      ]}
    />
  );

  return (
    <Space direction="vertical" style={{ width: '100%' }} size="middle">
      <Space align="center" wrap>
        <Text type="secondary">打开方式</Text>
        <Radio.Group
          value={mode}
          optionType="button"
          size="small"
          options={[
            { label: 'Modal', value: 'modal' },
            { label: 'Drawer', value: 'drawer' }
          ]}
          onChange={e => setMode(e.target.value)}
        />
      </Space>
      <div>
        <Button type="primary" onClick={() => setOpen(true)}>
          打开批量评估弹层
        </Button>
        <Paragraph type="secondary" style={{ margin: '8px 0 0' }}>
          模拟 HR 批量面试评估:Tabs 占 title 位,左列表右详情;切换 Modal / Drawer 对比布局高度链。
        </Paragraph>
      </div>
      <Overlay
        open={open}
        onClose={() => setOpen(false)}
        bodyScroll={false}
        size="large"
        onConfirm={async () => {
          await new Promise(r => setTimeout(r, 400));
          message.success('本批评估已保存');
        }}
        confirmText="保存本批评估"
        cancelText="稍后处理"
      >
        {layout}
      </Overlay>
    </Space>
  );
};

render(
  <App>
    <ModalContextHolder />
    <DrawerContextHolder />
    <ExtendLayoutExample />
  </App>
);
  • 嵌套弹层(外层命令式 + 内层 Modal)
  • Modal / Drawer 切换外层;内层声明式 Modal 嵌套 hoist
  • _ReactModal(@kne/current-lib_react-modal)[import * as _ReactModal from "@kne/react-modal"],(@kne/current-lib_react-modal/dist/index.css),antd(antd)
const { default: Modal, useModal, useDrawer, DrawerContextHolder, ModalContextHolder } = _ReactModal;
const { Button, Space, Typography, Tag, message, Checkbox, Radio, App } = antd;
const { useState, useEffect } = React;
const { Text, Paragraph } = Typography;

const EXPORT_FIELDS = [
  { label: '基本信息', value: 'basic' },
  { label: '维度评分', value: 'scores' },
  { label: '面试记录', value: 'notes' },
  { label: '附件简历', value: 'resume' }
];

const NestProbe = ({ open, isDrawer }) => {
  const [info, setInfo] = useState(null);

  useEffect(() => {
    if (!open) {
      setInfo(null);
      return;
    }
    const id = window.setTimeout(() => {
      const outerRootClass = isDrawer ? '.ant-drawer-root' : '.ant-modal-root';
      const titleClass = isDrawer ? '.drawer-title' : '.modal-title';
      const titles = Array.from(document.querySelectorAll(&#96;${outerRootClass} ${titleClass}&#96;));
      const outerTitle = titles.find(el => el.textContent === (isDrawer ? '导出评估报告(侧滑)' : '导出评估报告'));
      const innerTitle = titles.find(el => el.textContent === '选择导出字段');
      const outerRoot = outerTitle?.closest(outerRootClass.slice(1));
      const innerRoot = innerTitle?.closest('.ant-modal-root');
      const outerBody = outerRoot?.querySelector(isDrawer ? '.ant-drawer-body' : '.ant-modal-body');
      const hoisted = !!(innerRoot && outerBody && !outerBody.contains(innerRoot));
      setInfo({ hoisted });
    }, 50);
    return () => window.clearTimeout(id);
  }, [open, isDrawer]);

  if (!info) {
    return <Text type="secondary">打开内层后显示挂载探针</Text>;
  }

  return <Tag color={info.hoisted ? 'success' : 'error'}>内层 Modal {info.hoisted ? '已 hoist 到外层外侧' : '挂载异常'}</Tag>;
};

const ExportFieldPicker = ({ isDrawer }) => {
  const [innerOpen, setInnerOpen] = useState(false);
  const [checked, setChecked] = useState(['basic', 'scores', 'notes']);

  return (
    <div>
      <Paragraph>
        已选择本批 <Text strong>8</Text> 位候选人。内层始终为声明式 Modal;外层当前为 <Text code>{isDrawer ? 'Drawer' : 'Modal'}</Text>。
      </Paragraph>
      <Space>
        <Button type="primary" onClick={() => setInnerOpen(true)}>
          配置导出字段
        </Button>
        <Text type="secondary">已选 {checked.length} 项</Text>
      </Space>
      <div style={{ marginTop: 12 }}>
        <NestProbe open={innerOpen} isDrawer={isDrawer} />
      </div>
      <Modal
        title="选择导出字段"
        open={innerOpen}
        size="small"
        onClose={() => setInnerOpen(false)}
        onConfirm={() => {
          message.success(&#96;将导出:${checked.join('、')}&#96;);
          setInnerOpen(false);
        }}
        confirmText="确认字段"
      >
        <Checkbox.Group style={{ display: 'flex', flexDirection: 'column', gap: 10 }} options={EXPORT_FIELDS} value={checked} onChange={setChecked} />
      </Modal>
    </div>
  );
};

const NestedModalExample = () => {
  const modal = useModal();
  const drawer = useDrawer();
  const [mode, setMode] = useState('modal');
  const isDrawer = mode === 'drawer';

  return (
    <Space direction="vertical" style={{ width: '100%' }} size="middle">
      <Space align="center" wrap>
        <Text type="secondary">打开方式</Text>
        <Radio.Group
          value={mode}
          optionType="button"
          size="small"
          options={[
            { label: 'Modal', value: 'modal' },
            { label: 'Drawer', value: 'drawer' }
          ]}
          onChange={e => setMode(e.target.value)}
        />
      </Space>
      <Button
        type="primary"
        onClick={() => {
          const api = isDrawer ? drawer : modal;
          api({
            title: isDrawer ? '导出评估报告(侧滑)' : '导出评估报告',
            size: 'default',
            children: <ExportFieldPicker isDrawer={isDrawer} />,
            onConfirm: () => message.success('报告已进入生成队列'),
            confirmText: '开始导出'
          });
        }}
      >
        导出本批评估(嵌套弹层)
      </Button>
      <Text type="secondary">外层 Modal / Drawer 命令式 + 内层声明式 Modal 嵌套 hoist。</Text>
    </Space>
  );
};

render(
  <App>
    <ModalContextHolder />
    <DrawerContextHolder />
    <NestedModalExample />
  </App>
);
  • FormModal + renderModal
  • Modal / Drawer 切换:超长表单,createModalRender / createDrawerRender
  • _ReactModal(@kne/current-lib_react-modal)[import * as _ReactModal from "@kne/react-modal"],(@kne/current-lib_react-modal/dist/index.css),_FormInfo(@kne/form-info)[import * as _FormInfo from "@kne/form-info"],(@kne/form-info/dist/index.css),antd(antd)
const { createModalRender, createDrawerRender, DrawerContextHolder, ModalContextHolder } = _ReactModal;
const { default: FormInfo, FormModal, Input, TextArea } = _FormInfo;
const { Button, Space, Typography, App, message, Switch, Flex, Radio } = antd;
const { useState, useMemo, useEffect } = React;
const { Text, Paragraph } = Typography;

const SECTION_DEFS = [
  {
    key: 'basic',
    title: '基本信息',
    fields: [
      ['name', '姓名', 'REQ'],
      ['employeeNo', '工号', 'REQ'],
      ['department', '部门', 'REQ'],
      ['position', '职位', 'REQ'],
      ['phone', '手机号', 'REQ TEL'],
      ['email', '邮箱', 'EMAIL'],
      ['city', '工作城市', 'REQ'],
      ['manager', '直属上级', '']
    ]
  },
  {
    key: 'interview',
    title: '面试评估',
    fields: [
      ['commScore', '沟通表达', 'REQ'],
      ['techScore', '专业深度', 'REQ'],
      ['projectScore', '项目复杂度', 'REQ'],
      ['collabScore', '协作推进', 'REQ'],
      ['cultureScore', '文化匹配', 'REQ'],
      ['overallScore', '综合评分', 'REQ']
    ]
  },
  {
    key: 'experience',
    title: '经历与补充',
    fields: Array.from({ length: 16 }, (_, i) => [&#96;expField${i + 1}&#96;, &#96;经历补充项 ${i + 1}&#96;, i % 4 === 0 ? 'REQ' : ''])
  }
];

const buildInitialData = () => {
  const data = {
    name: '张三',
    employeeNo: 'E20240018',
    department: '研发中心',
    position: '高级前端',
    phone: '13800138000',
    email: '[email protected]',
    city: '上海',
    manager: '李四',
    commScore: '4',
    techScore: '5',
    projectScore: '4',
    collabScore: '4',
    cultureScore: '5',
    overallScore: '4.5',
    summary: '沟通清晰,项目推进稳定。以下为加长评估说明,用于验证弹窗 body 在超长表单下的 SimpleBar 滚动:标题与底部按钮应固定,仅中间表单区域滚动。'.repeat(3)
  };
  SECTION_DEFS[2].fields.forEach(([name], i) => {
    data[name] = &#96;补充说明内容 ${i + 1}:用于拉长表单高度。&#96;;
  });
  return data;
};

const LongFormFields = () => (
  <Flex vertical gap={16}>
    {SECTION_DEFS.map(section => (
      <FormInfo
        key={section.key}
        bordered
        title={section.title}
        column={2}
        gap={20}
        list={section.fields.map(([name, label, rule]) => (
          <Input key={name} name={name} label={label} rule={rule || undefined} />
        ))}
      />
    ))}
    <FormInfo
      bordered
      title="评估摘要(长文本)"
      column={1}
      gap={20}
      list={[
        <TextArea key="summary" name="summary" label="综合评语" rule="REQ" block />,
        <TextArea key="risk" name="risk" label="风险与待跟进" block placeholder="列出风险点、待确认事项等" />,
        <TextArea key="plan" name="plan" label="入职 / 下轮计划" block placeholder="试用期目标、面试官建议等" />
      ]}
    />
    {Array.from({ length: 8 }, (_, block) => (
      <FormInfo
        key={&#96;extra-${block}&#96;}
        bordered
        title={&#96;附加问卷 ${block + 1}&#96;}
        column={2}
        gap={20}
        list={Array.from({ length: 6 }, (_, i) => {
          const name = &#96;q${block + 1}_${i + 1}&#96;;
          return <Input key={name} name={name} label={&#96;问题 ${block + 1}.${i + 1}&#96;} />;
        })}
      />
    ))}
  </Flex>
);

const FormInfoModalExample = () => {
  const [open, setOpen] = useState(false);
  const [bodyScroll, setBodyScroll] = useState(true);
  const [mode, setMode] = useState('modal');
  const isDrawer = mode === 'drawer';
  const initialData = useMemo(() => buildInitialData(), []);

  useEffect(() => {
    if (open) {
      setOpen(false);
    }
  }, [mode]);

  const renderModalBase = isDrawer ? createDrawerRender({ placement: 'right', footerButtons: [], bodyScroll: true, size: 'large' }) : createModalRender({ footerButtons: [], bodyScroll: true, size: 'large' });

  const renderModal = ({ formProps, saveText, autoClose, onCancel, footer, modalRender, children, ...props }) =>
    renderModalBase({
      ...props,
      bodyScroll,
      onClose: onCancel,
      footer: typeof footer === 'function' ? footer() : footer,
      modalRender,
      children
    });

  return (
    <Space direction="vertical" style={{ width: '100%' }}>
      <Space align="center" wrap>
        <Text type="secondary">打开方式</Text>
        <Radio.Group
          value={mode}
          optionType="button"
          size="small"
          options={[
            { label: 'Modal', value: 'modal' },
            { label: 'Drawer', value: 'drawer' }
          ]}
          onChange={e => setMode(e.target.value)}
        />
      </Space>
      <Space wrap>
        <Button type="primary" onClick={() => setOpen(true)}>
          打开深度评估表单(FormModal)
        </Button>
        <Space>
          <Text type="secondary">bodyScroll</Text>
          <Switch checked={bodyScroll} onChange={setBodyScroll} checkedChildren="开" unCheckedChildren="关" />
        </Space>
      </Space>
      <Text type="secondary">createModalRender / createDrawerRender 注入默认 props;切换 Modal / Drawer 对比 form-info 宿主集成。</Text>

      <FormModal
        title={isDrawer ? '候选人深度评估(侧滑)' : '候选人深度评估(超长表单)'}
        open={open}
        onCancel={() => setOpen(false)}
        renderModal={renderModal}
        okText="保存评估"
        cancelText="取消"
        width={1000}
        formProps={{
          data: initialData,
          onSubmit: async data => {
            await new Promise(resolve => setTimeout(resolve, 500));
            message.success(&#96;已保存:${data.name}(共 ${Object.keys(data).length} 个字段)&#96;);
          }
        }}
      >
        <LongFormFields />
      </FormModal>
    </Space>
  );
};

render(
  <App>
    <ModalContextHolder />
    <DrawerContextHolder />
    <FormInfoModalExample />
  </App>
);
  • FormStepsModal + renderModal
  • Modal / Drawer 切换:分步表单,步骤内 FormInfo gap + Flex 垂直间距
  • _ReactModal(@kne/current-lib_react-modal)[import * as _ReactModal from "@kne/react-modal"],(@kne/current-lib_react-modal/dist/index.css),_FormInfo(@kne/form-info)[import * as _FormInfo from "@kne/form-info"],(@kne/form-info/dist/index.css),antd(antd)
const { createModalRender, createDrawerRender, modalClassNames, DrawerContextHolder, ModalContextHolder } = _ReactModal;
const { default: FormInfo, FormStepsModal, List, Input, TextArea } = _FormInfo;
const { Button, Space, Typography, App, message, Flex, Radio } = antd;
const { useState, useEffect } = React;
const { Text } = Typography;

const renderStepsModalBase = isDrawer =>
  isDrawer
    ? createDrawerRender({
        placement: 'right',
        footerButtons: [],
        bodyScroll: true,
        size: 'default',
        className: modalClassNames.stepsForm
      })
    : createModalRender({
        footerButtons: [],
        bodyScroll: true,
        size: 'default',
        className: modalClassNames.stepsForm
      });

const renderStepsModal =
  isDrawer =>
  ({ formProps, saveText, autoClose, onCancel, footer, modalRender, children, className, ...props }) =>
    renderStepsModalBase(isDrawer)({
      ...props,
      className,
      onClose: onCancel,
      footer: typeof footer === 'function' ? footer() : footer,
      modalRender,
      children
    });

const STEP_DATA = {
  name: '李四',
  employeeNo: 'E20240023',
  department: '产品部',
  phone: '13900139000',
  email: '[email protected]',
  years: '6',
  position: '高级产品经理',
  commScore: '4',
  techScore: '4',
  projectScore: '5',
  summary: '产品规划清晰,跨团队推进能力强。',
  workExperience: [
    {
      companyName: '某互联网公司',
      role: '产品经理',
      years: '3年'
    }
  ],
  objectives: '1. 独立负责核心模块\n2. 推动跨部门协作\n3. 三个月内熟悉业务',
  risks: '对行业业务理解仍需加深'
};

const FormInfoStepsModalExample = () => {
  const [open, setOpen] = useState(false);
  const [mode, setMode] = useState('modal');
  const isDrawer = mode === 'drawer';

  useEffect(() => {
    if (open) {
      setOpen(false);
    }
  }, [mode]);

  return (
    <Space direction="vertical" style={{ width: '100%' }}>
      <Space align="center" wrap>
        <Text type="secondary">打开方式</Text>
        <Radio.Group
          value={mode}
          optionType="button"
          size="small"
          options={[
            { label: 'Modal', value: 'modal' },
            { label: 'Drawer', value: 'drawer' }
          ]}
          onChange={e => setMode(e.target.value)}
        />
      </Space>
      <Button type="primary" onClick={() => setOpen(true)}>
        打开分步评估(FormStepsModal)
      </Button>
      <Text type="secondary">三步完成候选人评估;切换 Modal / Drawer 对比分步表单高度链。</Text>

      <FormStepsModal
        autoStep
        completeText="提交评估"
        nextText="下一步"
        onComplete={async allData => {
          await new Promise(resolve => setTimeout(resolve, 600));
          message.success(&#96;已提交 ${allData.length} 步数据&#96;);
        }}
        modalProps={{
          open,
          title: isDrawer ? '候选人评估(侧滑分步)' : '候选人评估(分步)',
          width: 900,
          onCancel: () => setOpen(false),
          renderModal: renderStepsModal(isDrawer)
        }}
        items={[
          {
            title: '基本信息',
            formProps: { data: STEP_DATA },
            children: (
              <Flex vertical gap={16}>
                <FormInfo
                  bordered
                  title="候选人信息"
                  column={2}
                  gap={20}
                  list={[
                    <Input name="name" label="姓名" rule="REQ" />,
                    <Input name="employeeNo" label="工号" rule="REQ" disabled />,