@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;DraweruseDrawer+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: `var(${contentHeightVar})`,
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(`[data-testid="${chrome.testId}"]`);
const body = outer && outer.querySelector(`.${chrome.bodyClass}`);
const inner = outer && outer.querySelector(`.${chrome.innerClass}`);
const fill = outer && outer.querySelector('[data-vars-fill]');
setMetrics({
gutter: readCssVar(outer, `${chrome.varPrefix}-viewport-gutter`),
bodyHeightVar: readCssVar(body || outer, chrome.bodyHeightVar),
contentHeightVar: readCssVar(inner || body || outer, chrome.contentHeightVar),
bodyMin: readCssVar(outer, `${chrome.varPrefix}-body-min-height`),
paddingV: readCssVar(outer, `${chrome.varPrefix}-body-padding-vertical`),
bodyClient: body ? `${body.clientHeight}px` : '-',
fillClient: fill ? `${fill.clientHeight}px` : '-'
});
}, 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={`${chrome.varPrefix}-viewport-gutter`}>{metrics.gutter}</Descriptions.Item>
<Descriptions.Item label={`${chrome.varPrefix}-body-min-height`}>{metrics.bodyMin}</Descriptions.Item>
<Descriptions.Item label={`${chrome.varPrefix}-body-padding-vertical`}>{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) => `填充行 ${i + 1} · 用于验证 content-height 滚动`);
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 = `${mode}|${customVars}|${open}`;
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: `var(${chrome.contentHeightVar})`,
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(`[data-testid="${chrome.testId}"]`);
const body = outer && outer.querySelector(`.${chrome.bodyClass}`);
const inner = outer && outer.querySelector(`.${chrome.innerClass}`);
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, `${chrome.varPrefix}-body-padding-vertical`),
paddingH: readCssVar(outer, `${chrome.varPrefix}-body-padding-horizontal`),
titleH: readCssVar(outer, `${chrome.varPrefix}-title-height`),
footerH: readCssVar(outer, `${chrome.varPrefix}-footer-height`),
bodyClient: body ? `${body.clientHeight}px` : '-',
innerClient: inner ? `${inner.clientHeight}px` : '-',
scrollClient: scrollHost ? `${scrollHost.clientHeight}px` : '-'
});
}, 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={`${chrome.varPrefix}-title-height`}>{metrics.titleH}</Descriptions.Item>
<Descriptions.Item label={`${chrome.varPrefix}-footer-height`}>{metrics.footerH}</Descriptions.Item>
<Descriptions.Item label={`${chrome.varPrefix}-body-padding-vertical`}>{metrics.paddingV}</Descriptions.Item>
<Descriptions.Item label={`${chrome.varPrefix}-body-padding-horizontal`}>{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) => `行 ${i + 1} · 用于观察滚动与高度是否贴齐`);
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: `var(${chrome.contentHeightVar})`,
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) => `填充行 ${i + 1} · 用于观察高度受限时 body 内滚动`);
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: `候选人 ${i + 9}`,
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={`candidate-list-item${item.key === activeKey ? ' is-active' : ''}`} 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(`${outerRootClass} ${titleClass}`));
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(`将导出:${checked.join('、')}`);
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) => [`expField${i + 1}`, `经历补充项 ${i + 1}`, 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] = `补充说明内容 ${i + 1}:用于拉长表单高度。`;
});
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={`extra-${block}`}
bordered
title={`附加问卷 ${block + 1}`}
column={2}
gap={20}
list={Array.from({ length: 6 }, (_, i) => {
const name = `q${block + 1}_${i + 1}`;
return <Input key={name} name={name} label={`问题 ${block + 1}.${i + 1}`} />;
})}
/>
))}
</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(`已保存:${data.name}(共 ${Object.keys(data).length} 个字段)`);
}
}}
>
<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(`已提交 ${allData.length} 步数据`);
}}
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 />,
