@kne/react-modal 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,2050 @@
1
+ # react-modal
2
+
3
+ ### 描述
4
+
5
+ 基于 antd 的 React 弹窗组件,提供 Modal/useModal、Tabs 分栏布局与 renderModal 适配,支持 SimpleBar 滚动与移动端全屏。
6
+
7
+ ### 关键词
8
+
9
+ react, modal, antd, dialog, simplebar, use-modal, footer-buttons, tabs-layout, mobile, form, component, i18n
10
+
11
+ ### 安装
12
+
13
+ ```shell
14
+ npm i --save @kne/react-modal
15
+ ```
16
+
17
+ ### 概述
18
+
19
+ #### 概述
20
+
21
+ `@kne/react-modal` 是基于 Ant Design Modal 的精简弹窗组件,保留声明式 `Modal` 与命令式 `useModal`,兼容 components-core Modal 常用调用(含 `footerButtons` 系统),并内置 SimpleBar 内容滚动与移动端全屏适配。
22
+
23
+ #### 主要特性
24
+
25
+ - 声明式 / 命令式同一套 props 与 UI
26
+ - `footer` + `footerButtons`(`ButtonComponent` / `display` / `autoClose`)与既有用法对齐
27
+ - title / footer 固定在滚动外,body 默认 SimpleBar
28
+ - `--kne-modal-*` CSS 变量管理 body / content 高度与 padding
29
+ - 移动端全屏(`@kne/responsive-utils`)与嵌套挂载处理
30
+ - 布局组合:`ModalTabsLayout`、`ModalColumnsLayout`、`ModalScrollRegion`;`createModalRender` 对接 `renderModal` 宿主
31
+
32
+ #### 使用场景
33
+
34
+ 需要在不打断当前流程的前提下打开浮层处理事务、表单确认或展示长内容时使用。Tabs 顶栏、N 列分栏、分步表单等可通过布局组件与 `createModalRender` 组合,无需业务侧手写 SCSS。
35
+
36
+
37
+ ### 示例
38
+
39
+ #### 示例样式
40
+
41
+ ```scss
42
+ /* 示例业务 UI(布局滚动等仍用 @kne/react-modal/dist/index.css) */
43
+
44
+ .candidate-list-toolbar {
45
+ display: flex;
46
+ flex-direction: column;
47
+ gap: 8px;
48
+ }
49
+
50
+ .candidate-list-item {
51
+ padding: 12px 16px;
52
+ cursor: pointer;
53
+ border-bottom: 1px solid #f5f5f5;
54
+ transition: background 0.15s ease;
55
+
56
+ &:hover:not(.is-active) {
57
+ background: #fafafa;
58
+ }
59
+
60
+ &.is-active {
61
+ background: #e6f4ff;
62
+ box-shadow: inset 3px 0 0 #1677ff;
63
+ }
64
+ }
65
+
66
+ .candidate-list-item-main {
67
+ display: flex;
68
+ align-items: flex-start;
69
+ gap: 12px;
70
+ }
71
+
72
+ .candidate-list-item-body {
73
+ flex: 1;
74
+ min-width: 0;
75
+ }
76
+
77
+ .candidate-list-item-title {
78
+ font-weight: 600;
79
+ color: rgba(0, 0, 0, 0.88);
80
+ line-height: 1.4;
81
+ }
82
+
83
+ .candidate-list-item-meta {
84
+ margin-top: 2px;
85
+ font-size: 12px;
86
+ color: rgba(0, 0, 0, 0.45);
87
+ white-space: nowrap;
88
+ overflow: hidden;
89
+ text-overflow: ellipsis;
90
+ }
91
+
92
+ .candidate-detail-header {
93
+ display: flex;
94
+ align-items: flex-start;
95
+ justify-content: space-between;
96
+ gap: 16px;
97
+ margin-bottom: 20px;
98
+ }
99
+
100
+ .candidate-detail-name {
101
+ margin: 0 0 8px;
102
+ font-size: 18px;
103
+ font-weight: 600;
104
+ color: rgba(0, 0, 0, 0.88);
105
+ }
106
+
107
+ .candidate-detail-sub {
108
+ display: flex;
109
+ flex-wrap: wrap;
110
+ gap: 8px;
111
+ align-items: center;
112
+ }
113
+
114
+ .candidate-score-grid {
115
+ display: grid;
116
+ grid-template-columns: repeat(2, minmax(0, 1fr));
117
+ gap: 12px 24px;
118
+ margin-bottom: 20px;
119
+ }
120
+
121
+ .candidate-score-item-label {
122
+ display: flex;
123
+ justify-content: space-between;
124
+ margin-bottom: 4px;
125
+ font-size: 13px;
126
+ color: rgba(0, 0, 0, 0.65);
127
+ }
128
+
129
+ .candidate-note-block {
130
+ padding: 12px 14px;
131
+ background: #fafafa;
132
+ border-radius: 8px;
133
+ border: 1px solid #f0f0f0;
134
+ margin-bottom: 12px;
135
+
136
+ &:last-child {
137
+ margin-bottom: 0;
138
+ }
139
+ }
140
+
141
+ .candidate-note-block-title {
142
+ font-weight: 600;
143
+ margin-bottom: 6px;
144
+ color: rgba(0, 0, 0, 0.88);
145
+ }
146
+
147
+ .candidate-overview-stats {
148
+ display: grid;
149
+ grid-template-columns: repeat(4, minmax(0, 1fr));
150
+ gap: 12px;
151
+ padding: 16px 20px;
152
+ border-bottom: 1px solid #f0f0f0;
153
+ }
154
+
155
+ .candidate-stat-card {
156
+ padding: 12px 14px;
157
+ background: #fafafa;
158
+ border-radius: 8px;
159
+ border: 1px solid #f0f0f0;
160
+ }
161
+
162
+ .candidate-stat-value {
163
+ font-size: 22px;
164
+ font-weight: 600;
165
+ line-height: 1.2;
166
+ color: rgba(0, 0, 0, 0.88);
167
+ }
168
+
169
+ .candidate-stat-label {
170
+ margin-top: 4px;
171
+ font-size: 12px;
172
+ color: rgba(0, 0, 0, 0.45);
173
+ }
174
+
175
+ .candidate-overview-section {
176
+ padding: 16px 20px 20px;
177
+ }
178
+
179
+ .candidate-schedule-row {
180
+ display: grid;
181
+ grid-template-columns: 72px 1fr 88px 72px;
182
+ gap: 12px;
183
+ align-items: center;
184
+ padding: 10px 0;
185
+ border-bottom: 1px solid #f5f5f5;
186
+ font-size: 13px;
187
+
188
+ &:last-child {
189
+ border-bottom: none;
190
+ }
191
+ }
192
+
193
+ .candidate-schedule-time {
194
+ color: rgba(0, 0, 0, 0.45);
195
+ }
196
+
197
+ .demo-panel-hint {
198
+ margin: 0;
199
+ padding: 10px 16px;
200
+ font-size: 12px;
201
+ color: rgba(0, 0, 0, 0.45);
202
+ background: #fffbe6;
203
+ border-bottom: 1px solid #ffe58f;
204
+ }
205
+
206
+ .demo-job-preview {
207
+ border-radius: 8px;
208
+ overflow: hidden;
209
+ border: 1px solid #f0f0f0;
210
+ background: #fff;
211
+ }
212
+
213
+ .demo-job-preview-header {
214
+ padding: 16px 20px;
215
+ background: linear-gradient(135deg, #f6ffed 0%, #fff 100%);
216
+ border-bottom: 1px solid #f0f0f0;
217
+ }
218
+
219
+ .demo-job-preview-body {
220
+ padding: 16px 20px;
221
+ color: rgba(0, 0, 0, 0.65);
222
+ line-height: 1.7;
223
+ }
224
+ ```
225
+
226
+ #### 示例代码
227
+
228
+ - 基础弹窗
229
+ - 受控打开与异步 onConfirm(loading / 成功提示)
230
+ - _ReactModal(@kne/current-lib_react-modal)[import * as _ReactModal from "@kne/react-modal"],(@kne/current-lib_react-modal/dist/index.css),antd(antd)
231
+
232
+ ```jsx
233
+ const { default: Modal } = _ReactModal;
234
+ const { Button, Space, message, App, Typography } = antd;
235
+ const { useState } = React;
236
+
237
+ const { Text, Paragraph } = Typography;
238
+
239
+ const BasicExample = () => {
240
+ const [open, setOpen] = useState(false);
241
+
242
+ return (
243
+ <Space direction="vertical" style={{ width: '100%' }} size="middle">
244
+ <Button type="primary" onClick={() => setOpen(true)}>
245
+ 保存评估备注
246
+ </Button>
247
+ <Text type="secondary">最简受控弹窗:异步 onConfirm 带 loading,返回 false 可阻止关闭。</Text>
248
+ <Modal
249
+ title="保存评估备注"
250
+ open={open}
251
+ onClose={() => setOpen(false)}
252
+ onConfirm={async () => {
253
+ await new Promise(resolve => setTimeout(resolve, 800));
254
+ message.success('备注已保存至候选人档案');
255
+ }}
256
+ confirmText="保存"
257
+ >
258
+ <Paragraph style={{ marginBottom: 8 }}>
259
+ 将把当前页面的筛选条件与评估摘要一并写入 <Text strong>陈思远</Text> 的档案备注。
260
+ </Paragraph>
261
+ <Paragraph type="secondary" style={{ marginBottom: 0 }}>
262
+ 保存后可在「候选人详情 → 操作记录」中查看历史版本。
263
+ </Paragraph>
264
+ </Modal>
265
+ </Space>
266
+ );
267
+ };
268
+
269
+ const BaseExample = () => (
270
+ <App>
271
+ <BasicExample />
272
+ </App>
273
+ );
274
+
275
+ render(<BaseExample />);
276
+
277
+ ```
278
+
279
+ - footerButtons 与尺寸
280
+ - 岗位发布确认:size / footerButtons / 左侧 footer 插槽 / noPadding 预览
281
+ - _ReactModal(@kne/current-lib_react-modal)[import * as _ReactModal from "@kne/react-modal"],(@kne/current-lib_react-modal/dist/index.css),antd(antd)
282
+
283
+ ```jsx
284
+ const { default: Modal } = _ReactModal;
285
+ const { Button, Space, Radio, Tag, message, App, Typography } = antd;
286
+ const { useState } = React;
287
+
288
+ const { Text, Title, Paragraph } = Typography;
289
+
290
+ const FooterButtonsExample = () => {
291
+ const [open, setOpen] = useState(false);
292
+ const [size, setSize] = useState('default');
293
+ const [noPadding, setNoPadding] = useState(false);
294
+
295
+ return (
296
+ <Space direction="vertical" style={{ width: '100%' }} size="large">
297
+ <div>
298
+ <Text type="secondary">切换尺寸与 noPadding,观察岗位预览卡片与 footer 按钮区布局。</Text>
299
+ </div>
300
+ <Radio.Group
301
+ value={size}
302
+ optionType="button"
303
+ options={[
304
+ { label: '小号', value: 'small' },
305
+ { label: '默认', value: 'default' },
306
+ { label: '大号', value: 'large' }
307
+ ]}
308
+ onChange={e => setSize(e.target.value)}
309
+ />
310
+ <Space wrap>
311
+ <Button type="primary" onClick={() => setOpen(true)}>
312
+ 发布岗位确认
313
+ </Button>
314
+ <Button type={noPadding ? 'primary' : 'default'} onClick={() => setNoPadding(v => !v)}>
315
+ noPadding={String(noPadding)}
316
+ </Button>
317
+ </Space>
318
+ <Modal
319
+ title="确认发布岗位"
320
+ size={size}
321
+ noPadding={noPadding}
322
+ open={open}
323
+ onClose={() => setOpen(false)}
324
+ footer={<Text type="secondary">发布后将同步至招聘官网与内推渠道</Text>}
325
+ footerButtons={[
326
+ {
327
+ children: '存草稿',
328
+ onClick: () => message.info('已保存草稿')
329
+ },
330
+ {
331
+ children: '预览',
332
+ display: () => size !== 'small',
333
+ onClick: () => message.info('打开预览页')
334
+ },
335
+ {
336
+ type: 'primary',
337
+ children: '立即发布',
338
+ onClick: async () => {
339
+ await new Promise(resolve => setTimeout(resolve, 600));
340
+ message.success('岗位已发布');
341
+ }
342
+ }
343
+ ]}
344
+ >
345
+ <div className="demo-job-preview">
346
+ <div className="demo-job-preview-header">
347
+ <Title level={5} style={{ margin: 0 }}>
348
+ 高级前端工程师
349
+ </Title>
350
+ <Space size={8} style={{ marginTop: 8 }}>
351
+ <Tag color="blue">上海</Tag>
352
+ <Tag>25K–40K · 15 薪</Tag>
353
+ <Tag color="green">急招</Tag>
354
+ </Space>
355
+ </div>
356
+ <div className="demo-job-preview-body">
357
+ <Paragraph style={{ marginTop: 0 }}>
358
+ 负责招聘中台、候选人评估等 B 端产品的前端交付;要求熟悉 React、工程化与组件库协作。
359
+ </Paragraph>
360
+ <Paragraph style={{ marginBottom: 0 }}>
361
+ {noPadding
362
+ ? 'noPadding=true:预览卡片应贴齐弹窗内容区边缘。'
363
+ : 'noPadding=false:预览卡片四周保留默认内边距。'}
364
+ </Paragraph>
365
+ </div>
366
+ </div>
367
+ </Modal>
368
+ </Space>
369
+ );
370
+ };
371
+
372
+ const BaseExample = () => (
373
+ <App>
374
+ <FooterButtonsExample />
375
+ </App>
376
+ );
377
+
378
+ render(<BaseExample />);
379
+
380
+ ```
381
+
382
+ - useModal 命令式
383
+ - 命令式打开候选人快览,children 函数内可 close()
384
+ - _ReactModal(@kne/current-lib_react-modal)[import * as _ReactModal from "@kne/react-modal"],(@kne/current-lib_react-modal/dist/index.css),antd(antd)
385
+
386
+ ```jsx
387
+ const { useModal } = _ReactModal;
388
+ const { Button, Space, message, App, Descriptions, Tag, Typography } = antd;
389
+
390
+ const { Text, Paragraph } = Typography;
391
+
392
+ const CommandExample = () => {
393
+ const modal = useModal();
394
+
395
+ const openDetail = () => {
396
+ modal({
397
+ title: '候选人快览',
398
+ size: 'small',
399
+ confirmText: '加入待评估',
400
+ children: ({ close }) => (
401
+ <div>
402
+ <Descriptions column={1} size="small" bordered style={{ marginBottom: 12 }}>
403
+ <Descriptions.Item label="姓名">李雨桐</Descriptions.Item>
404
+ <Descriptions.Item label="岗位">前端工程师 · 4 年</Descriptions.Item>
405
+ <Descriptions.Item label="来源">内推 · 张三</Descriptions.Item>
406
+ <Descriptions.Item label="状态">
407
+ <Tag color="processing">简历通过</Tag>
408
+ </Descriptions.Item>
409
+ </Descriptions>
410
+ <Paragraph type="secondary" style={{ marginBottom: 12 }}>
411
+ 命令式弹窗适用于列表页「快速查看」场景;children 为函数时可调用 <Text code>close()</Text>{' '}
412
+ 主动关闭。
413
+ </Paragraph>
414
+ <Button size="small" onClick={() => close()}>
415
+ 关闭
416
+ </Button>
417
+ </div>
418
+ ),
419
+ onConfirm: () => {
420
+ message.success('已加入待评估队列');
421
+ }
422
+ });
423
+ };
424
+
425
+ return (
426
+ <Space direction="vertical">
427
+ <Button type="primary" onClick={openDetail}>
428
+ 从列表打开候选人快览
429
+ </Button>
430
+ <Text type="secondary">需在 antd App 上下文中使用 useModal。</Text>
431
+ </Space>
432
+ );
433
+ };
434
+
435
+ const BaseExample = () => (
436
+ <App>
437
+ <CommandExample />
438
+ </App>
439
+ );
440
+
441
+ render(<BaseExample />);
442
+
443
+ ```
444
+
445
+ - 长内容滚动
446
+ - 面试评估纪要:默认 SimpleBar vs bodyScroll=false 自管滚动
447
+ - _ReactModal(@kne/current-lib_react-modal)[import * as _ReactModal from "@kne/react-modal"],(@kne/current-lib_react-modal/dist/index.css),antd(antd)
448
+
449
+ ```jsx
450
+ const { default: Modal } = _ReactModal;
451
+ const { Button, Space, App, Typography, Divider } = antd;
452
+ const { useState } = React;
453
+
454
+ const { Title, Paragraph, Text } = Typography;
455
+
456
+ const EVAL_SECTIONS = [
457
+ {
458
+ title: '沟通表达',
459
+ items: [
460
+ '表达结构清晰,能准确复述业务目标与技术约束。',
461
+ '对追问能够给出有层次的回答,而非堆砌名词。'
462
+ ]
463
+ },
464
+ {
465
+ title: '专业深度',
466
+ items: [
467
+ '熟悉 React 渲染机制,能说明列表虚拟化方案选型理由。',
468
+ '了解前端监控与错误边界在生产环境的实践。'
469
+ ]
470
+ },
471
+ {
472
+ title: '项目复杂度',
473
+ items: [
474
+ '参与过多团队协同的中台项目,承担核心模块 Owner。',
475
+ '能描述需求变更下的架构演进与风险控制。'
476
+ ]
477
+ },
478
+ {
479
+ title: '协作推进',
480
+ items: ['主动同步风险,推动联调与验收节点按时完成。']
481
+ }
482
+ ];
483
+
484
+ const EvaluationContent = () => (
485
+ <>
486
+ {EVAL_SECTIONS.map(section => (
487
+ <div key={section.title} style={{ marginBottom: 20 }}>
488
+ <Title level={5} style={{ marginTop: 0 }}>
489
+ {section.title}
490
+ </Title>
491
+ {section.items.map(item => (
492
+ <Paragraph key={item} style={{ marginBottom: 8, color: 'rgba(0,0,0,0.65)' }}>
493
+ · {item}
494
+ </Paragraph>
495
+ ))}
496
+ </div>
497
+ ))}
498
+ <Divider />
499
+ <Text type="secondary">以下为填充内容,用于验证长文滚动。</Text>
500
+ {Array.from({ length: 12 }, (_, i) => (
501
+ <Paragraph key={i} style={{ color: 'rgba(0,0,0,0.45)' }}>
502
+ 补充记录 {i + 1}:候选人对团队文化、工作方式与交付节奏均表示认可。
503
+ </Paragraph>
504
+ ))}
505
+ </>
506
+ );
507
+
508
+ const LongContentExample = () => {
509
+ const [open, setOpen] = useState(false);
510
+ const [openSelfScroll, setOpenSelfScroll] = useState(false);
511
+
512
+ return (
513
+ <Space direction="vertical" style={{ width: '100%' }} size="middle">
514
+ <Paragraph type="secondary" style={{ margin: 0 }}>
515
+ 对比默认 SimpleBar 与 bodyScroll=false 自管滚动两种长内容场景。
516
+ </Paragraph>
517
+ <Space wrap>
518
+ <Button type="primary" onClick={() => setOpen(true)}>
519
+ 打开评估详情(SimpleBar)
520
+ </Button>
521
+ <Button onClick={() => setOpenSelfScroll(true)}>自管滚动(bodyScroll=false)</Button>
522
+ </Space>
523
+ <Modal
524
+ title="陈思远 · 面试评估纪要"
525
+ open={open}
526
+ onClose={() => setOpen(false)}
527
+ onConfirm={() => {}}
528
+ confirmText="保存纪要"
529
+ >
530
+ <EvaluationContent />
531
+ </Modal>
532
+ <Modal
533
+ title="自管滚动示例"
534
+ open={openSelfScroll}
535
+ onClose={() => setOpenSelfScroll(false)}
536
+ bodyScroll={false}
537
+ footer={null}
538
+ >
539
+ <div
540
+ style={{
541
+ height: 'var(--kne-modal-content-height)',
542
+ minHeight: 0,
543
+ overflow: 'auto',
544
+ boxSizing: 'border-box',
545
+ background: '#fafafa'
546
+ }}
547
+ >
548
+ <div style={{ padding: 20 }}>
549
+ <Paragraph style={{ marginTop: 0 }}>
550
+ 内容区高度绑定 <Text code>--kne-modal-content-height</Text>,在 Tabs / 分栏场景同样适用。
551
+ </Paragraph>
552
+ <EvaluationContent />
553
+ </div>
554
+ </div>
555
+ </Modal>
556
+ </Space>
557
+ );
558
+ };
559
+
560
+ const BaseExample = () => (
561
+ <App>
562
+ <LongContentExample />
563
+ </App>
564
+ );
565
+
566
+ render(<BaseExample />);
567
+
568
+ ```
569
+
570
+ - 高度 CSS 变量
571
+ - Switch 对比默认/自定义 CSS 变量;色块绑定 --kne-modal-content-height,探针显示变量与 clientHeight
572
+ - _ReactModal(@kne/current-lib_react-modal)[import * as _ReactModal from "@kne/react-modal"],(@kne/current-lib_react-modal/dist/index.css),antd(antd)
573
+
574
+ ```jsx
575
+ const { default: Modal } = _ReactModal;
576
+ const { Button, Space, Switch, Tag, Descriptions, App } = antd;
577
+ const { useState, useEffect } = React;
578
+
579
+ const readCssVar = (el, name) => {
580
+ if (!el) {
581
+ return '-';
582
+ }
583
+ return getComputedStyle(el).getPropertyValue(name).trim() || '-';
584
+ };
585
+
586
+ const HeightProbe = ({ open, revision }) => {
587
+ const [metrics, setMetrics] = useState(null);
588
+
589
+ useEffect(() => {
590
+ if (!open) {
591
+ setMetrics(null);
592
+ return undefined;
593
+ }
594
+ const timer = setTimeout(() => {
595
+ const outer = document.querySelector('[data-testid="react-modal"]');
596
+ const body = outer && outer.querySelector('.modal-body');
597
+ const inner = outer && outer.querySelector('.modal-body-inner');
598
+ const fill = outer && outer.querySelector('[data-vars-fill]');
599
+ setMetrics({
600
+ gutter: readCssVar(outer, '--kne-modal-viewport-gutter'),
601
+ bodyHeightVar: readCssVar(body || outer, '--kne-modal-body-height'),
602
+ contentHeightVar: readCssVar(inner || body || outer, '--kne-modal-content-height'),
603
+ bodyMin: readCssVar(outer, '--kne-modal-body-min-height'),
604
+ paddingV: readCssVar(outer, '--kne-modal-body-padding-vertical'),
605
+ bodyClient: body ? &#96;${body.clientHeight}px&#96; : '-',
606
+ fillClient: fill ? &#96;${fill.clientHeight}px&#96; : '-'
607
+ });
608
+ }, 80);
609
+ return () => clearTimeout(timer);
610
+ }, [open, revision]);
611
+
612
+ if (!metrics) {
613
+ return <Tag>打开弹窗后显示变量与实测高度</Tag>;
614
+ }
615
+
616
+ return (
617
+ <Descriptions size="small" bordered column={1} style={{ maxWidth: 640 }}>
618
+ <Descriptions.Item label="--kne-modal-viewport-gutter">{metrics.gutter}</Descriptions.Item>
619
+ <Descriptions.Item label="--kne-modal-body-min-height">{metrics.bodyMin}</Descriptions.Item>
620
+ <Descriptions.Item label="--kne-modal-body-padding-vertical">{metrics.paddingV}</Descriptions.Item>
621
+ <Descriptions.Item label="--kne-modal-body-height">{metrics.bodyHeightVar}</Descriptions.Item>
622
+ <Descriptions.Item label="--kne-modal-content-height">{metrics.contentHeightVar}</Descriptions.Item>
623
+ <Descriptions.Item label="body.clientHeight">{metrics.bodyClient}</Descriptions.Item>
624
+ <Descriptions.Item label="色块 clientHeight(应≈ content-height)">{metrics.fillClient}</Descriptions.Item>
625
+ </Descriptions>
626
+ );
627
+ };
628
+
629
+ const fillLines = Array.from({ length: 18 }, (_, i) => &#96;填充行 ${i + 1} · 用于验证 content-height 滚动&#96;);
630
+
631
+ const HeightVarsExample = () => {
632
+ const [open, setOpen] = useState(false);
633
+ const [customVars, setCustomVars] = useState(true);
634
+ const revision = &#96;${customVars}|${open}&#96;;
635
+
636
+ const modalStyle = customVars
637
+ ? {
638
+ '--kne-modal-viewport-gutter': '240px',
639
+ '--kne-modal-body-min-height': '180px',
640
+ '--kne-modal-body-padding-vertical': '64px'
641
+ }
642
+ : undefined;
643
+
644
+ return (
645
+ <Space direction="vertical" style={{ width: '100%' }} size="middle">
646
+ <Space wrap align="center">
647
+ <Switch
648
+ checked={customVars}
649
+ onChange={setCustomVars}
650
+ checkedChildren="自定义变量"
651
+ unCheckedChildren="默认变量"
652
+ />
653
+ <Button type="primary" onClick={() => setOpen(true)}>
654
+ 打开弹窗对比高度
655
+ </Button>
656
+ <Tag color={customVars ? 'blue' : 'default'}>{customVars ? '已覆盖 CSS 变量' : '库内默认值'}</Tag>
657
+ </Space>
658
+ <div style={{ color: 'rgba(0,0,0,0.45)', maxWidth: 640 }}>
659
+ 通过 Modal <code>style</code> 覆盖变量(挂在 <code>.modal</code> 根上)。自定义时加大{' '}
660
+ <code>viewport-gutter</code>、<code>body-padding-vertical</code>,body 会明显变矮;下方色块高度绑定{' '}
661
+ <code>--kne-modal-content-height</code>,应与实测 content 高度一致。
662
+ </div>
663
+ <HeightProbe open={open} revision={revision} />
664
+ <Modal
665
+ title={customVars ? '自定义高度 CSS 变量' : '默认高度 CSS 变量'}
666
+ open={open}
667
+ onClose={() => setOpen(false)}
668
+ onConfirm={() => {}}
669
+ bodyScroll={false}
670
+ noPadding={false}
671
+ style={modalStyle}
672
+ >
673
+ <div
674
+ data-vars-fill
675
+ style={{
676
+ height: 'var(--kne-modal-content-height)',
677
+ minHeight: 0,
678
+ overflow: 'auto',
679
+ boxSizing: 'border-box',
680
+ background: customVars ? '#fff1f0' : '#e6f4ff',
681
+ border: customVars ? '2px solid #ff4d4f' : '2px solid #1677ff'
682
+ }}
683
+ >
684
+ <div style={{ padding: 12 }}>
685
+ <p style={{ marginTop: 0, fontWeight: 600 }}>
686
+ 色块 height = var(--kne-modal-content-height)。切换「自定义/默认」后重新打开,对比色块高度与左侧探针数值。
687
+ </p>
688
+ {customVars ? (
689
+ <p style={{ margin: '0 0 8px' }}>
690
+ 当前覆盖:gutter 240px(默认 120px)、padding-vertical 64px(默认 48px)、body-min-height
691
+ 180px。
692
+ </p>
693
+ ) : (
694
+ <p style={{ margin: '0 0 8px' }}>未传 style,使用库内 calc 与 size 默认 min-height。</p>
695
+ )}
696
+ {fillLines.map(text => (
697
+ <p key={text} style={{ margin: '4px 0' }}>
698
+ {text}
699
+ </p>
700
+ ))}
701
+ </div>
702
+ </div>
703
+ </Modal>
704
+ </Space>
705
+ );
706
+ };
707
+
708
+ const BaseExample = () => (
709
+ <App>
710
+ <HeightVarsExample />
711
+ </App>
712
+ );
713
+
714
+ render(<BaseExample />);
715
+
716
+ ```
717
+
718
+ - title / footer / noPadding 高度探针
719
+ - 切换 title 空/有、footer=null、footerButtons=[]、noPadding auto/true/false、bodyScroll,实测 body 与 content 高度变量与 clientHeight
720
+ - _ReactModal(@kne/current-lib_react-modal)[import * as _ReactModal from "@kne/react-modal"],(@kne/current-lib_react-modal/dist/index.css),antd(antd)
721
+
722
+ ```jsx
723
+ const { default: Modal } = _ReactModal;
724
+ const { Button, Space, Switch, Radio, Tag, Descriptions, App } = antd;
725
+ const { useState, useEffect } = React;
726
+
727
+ const readCssVar = (el, name) => {
728
+ if (!el) {
729
+ return '-';
730
+ }
731
+ return getComputedStyle(el).getPropertyValue(name).trim() || '-';
732
+ };
733
+
734
+ const HeightProbe = ({ open, revision }) => {
735
+ const [metrics, setMetrics] = useState(null);
736
+
737
+ useEffect(() => {
738
+ if (!open) {
739
+ setMetrics(null);
740
+ return undefined;
741
+ }
742
+ const timer = setTimeout(() => {
743
+ const outer = document.querySelector('[data-testid="react-modal"]');
744
+ const body = outer && outer.querySelector('.modal-body');
745
+ const inner = outer && outer.querySelector('.modal-body-inner');
746
+ const scrollHost = outer && outer.querySelector('[data-height-scroll-host]');
747
+ setMetrics({
748
+ outerClass: outer ? outer.className : '-',
749
+ bodyHeightVar: readCssVar(body || outer, '--kne-modal-body-height'),
750
+ contentHeightVar: readCssVar(inner || body || outer, '--kne-modal-content-height'),
751
+ paddingV: readCssVar(outer, '--kne-modal-body-padding-vertical'),
752
+ paddingH: readCssVar(outer, '--kne-modal-body-padding-horizontal'),
753
+ titleH: readCssVar(outer, '--kne-modal-title-height'),
754
+ footerH: readCssVar(outer, '--kne-modal-footer-height'),
755
+ bodyClient: body ? &#96;${body.clientHeight}px&#96; : '-',
756
+ innerClient: inner ? &#96;${inner.clientHeight}px&#96; : '-',
757
+ scrollClient: scrollHost ? &#96;${scrollHost.clientHeight}px&#96; : '-'
758
+ });
759
+ }, 80);
760
+ return () => clearTimeout(timer);
761
+ }, [open, revision]);
762
+
763
+ if (!metrics) {
764
+ return <Tag>打开弹窗后显示实测高度</Tag>;
765
+ }
766
+
767
+ return (
768
+ <Descriptions size="small" bordered column={1} style={{ maxWidth: 640 }}>
769
+ <Descriptions.Item label="outer class">{metrics.outerClass}</Descriptions.Item>
770
+ <Descriptions.Item label="--kne-modal-title-height">{metrics.titleH}</Descriptions.Item>
771
+ <Descriptions.Item label="--kne-modal-footer-height">{metrics.footerH}</Descriptions.Item>
772
+ <Descriptions.Item label="--kne-modal-body-padding-vertical">{metrics.paddingV}</Descriptions.Item>
773
+ <Descriptions.Item label="--kne-modal-body-padding-horizontal">{metrics.paddingH}</Descriptions.Item>
774
+ <Descriptions.Item label="--kne-modal-body-height">{metrics.bodyHeightVar}</Descriptions.Item>
775
+ <Descriptions.Item label="--kne-modal-content-height">{metrics.contentHeightVar}</Descriptions.Item>
776
+ <Descriptions.Item label="body.clientHeight">{metrics.bodyClient}</Descriptions.Item>
777
+ <Descriptions.Item label="body-inner.clientHeight">{metrics.innerClient}</Descriptions.Item>
778
+ <Descriptions.Item label="scrollHost.clientHeight">{metrics.scrollClient}</Descriptions.Item>
779
+ </Descriptions>
780
+ );
781
+ };
782
+
783
+ const ChromeHeightExample = () => {
784
+ const [open, setOpen] = useState(false);
785
+ const [hasTitle, setHasTitle] = useState(true);
786
+ const [footerMode, setFooterMode] = useState('default'); // default | null | emptyButtons
787
+ const [bodyScroll, setBodyScroll] = useState(false);
788
+ const [noPaddingMode, setNoPaddingMode] = useState('auto'); // auto | true | false
789
+ const revision = [hasTitle, footerMode, bodyScroll, noPaddingMode, open].join('|');
790
+
791
+ const noPaddingProp =
792
+ noPaddingMode === 'auto' ? undefined : noPaddingMode === 'true';
793
+
794
+ const modalProps = {
795
+ open,
796
+ onClose: () => setOpen(false),
797
+ bodyScroll,
798
+ ...(hasTitle ? { title: '候选人评估 · 高度调试' } : {}),
799
+ ...(noPaddingProp === undefined ? {} : { noPadding: noPaddingProp })
800
+ };
801
+
802
+ if (footerMode === 'null') {
803
+ modalProps.footer = null;
804
+ } else if (footerMode === 'emptyButtons') {
805
+ modalProps.footer = <span>仅左侧 footer,按钮为空数组</span>;
806
+ modalProps.footerButtons = [];
807
+ } else {
808
+ modalProps.onConfirm = () => {};
809
+ }
810
+
811
+ const openModal = () => {
812
+ setOpen(true);
813
+ };
814
+
815
+ const lines = Array.from({ length: 24 }, (_, i) => &#96;行 ${i + 1} · 用于观察滚动与高度是否贴齐&#96;);
816
+
817
+ return (
818
+ <Space direction="vertical" style={{ width: '100%' }} size="middle">
819
+ <Space wrap align="center">
820
+ <span>title</span>
821
+ <Switch checked={hasTitle} onChange={setHasTitle} checkedChildren="有" unCheckedChildren="空" />
822
+ <span>bodyScroll</span>
823
+ <Switch
824
+ checked={bodyScroll}
825
+ onChange={setBodyScroll}
826
+ checkedChildren="true"
827
+ unCheckedChildren="false"
828
+ />
829
+ </Space>
830
+ <div>
831
+ <div style={{ marginBottom: 8 }}>footer</div>
832
+ <Radio.Group
833
+ value={footerMode}
834
+ optionType="button"
835
+ options={[
836
+ { label: '默认按钮', value: 'default' },
837
+ { label: 'footer=null(无 footer 区)', value: 'null' },
838
+ { label: 'footerButtons=[]', value: 'emptyButtons' }
839
+ ]}
840
+ onChange={e => setFooterMode(e.target.value)}
841
+ />
842
+ </div>
843
+ <div>
844
+ <div style={{ marginBottom: 8 }}>noPadding</div>
845
+ <Radio.Group
846
+ value={noPaddingMode}
847
+ optionType="button"
848
+ options={[
849
+ { label: '未传(auto)', value: 'auto' },
850
+ { label: 'true', value: 'true' },
851
+ { label: 'false', value: 'false' }
852
+ ]}
853
+ onChange={e => setNoPaddingMode(e.target.value)}
854
+ />
855
+ <div style={{ marginTop: 8, color: 'rgba(0,0,0,0.45)' }}>
856
+ bodyScroll=false 且未传 noPadding 时默认去掉内边距;显式 false 可保留 padding 观察差异。
857
+ </div>
858
+ </div>
859
+ <Space wrap>
860
+ <Button type="primary" onClick={openModal}>
861
+ 打开评估弹窗并测量
862
+ </Button>
863
+ <Tag>hasTitle={String(hasTitle)}</Tag>
864
+ <Tag>footerMode={footerMode}</Tag>
865
+ <Tag>bodyScroll={String(bodyScroll)}</Tag>
866
+ <Tag>
867
+ noPadding=
868
+ {noPaddingProp === undefined ? 'undefined(auto)' : String(noPaddingProp)}
869
+ </Tag>
870
+ </Space>
871
+ <HeightProbe open={open} revision={revision} />
872
+ <Modal {...modalProps}>
873
+ {bodyScroll === false ? (
874
+ <div
875
+ data-height-scroll-host
876
+ style={{
877
+ height: 'var(--kne-modal-content-height)',
878
+ minHeight: 0,
879
+ overflow: 'auto',
880
+ boxSizing: 'border-box',
881
+ background: noPaddingProp === false ? '#fff7e6' : '#e6f4ff',
882
+ border: noPaddingProp === false ? '2px dashed #fa8c16' : '2px dashed #1677ff'
883
+ }}
884
+ >
885
+ <div style={{ padding: 12 }}>
886
+ <p style={{ marginTop: 0, fontWeight: 600 }}>
887
+ 滚动宿主使用 height: var(--kne-modal-content-height)。蓝/橙底应与 body
888
+ 内容区同高;footer=null 时 footer 变量应为 0。
889
+ </p>
890
+ {lines.map(text => (
891
+ <p key={text}>{text}</p>
892
+ ))}
893
+ </div>
894
+ </div>
895
+ ) : (
896
+ <div
897
+ data-height-scroll-host
898
+ style={{
899
+ background: noPaddingProp ? '#e6f4ff' : '#fff7e6',
900
+ border: noPaddingProp ? '2px dashed #1677ff' : '2px dashed #fa8c16',
901
+ minHeight: 120
902
+ }}
903
+ >
904
+ <div style={{ padding: 12 }}>
905
+ <p style={{ marginTop: 0, fontWeight: 600 }}>SimpleBar 模式:看色块是否贴边判断 noPadding。</p>
906
+ {lines.slice(0, 8).map(text => (
907
+ <p key={text}>{text}</p>
908
+ ))}
909
+ </div>
910
+ </div>
911
+ )}
912
+ </Modal>
913
+ </Space>
914
+ );
915
+ };
916
+
917
+ const BaseExample = () => (
918
+ <App>
919
+ <ChromeHeightExample />
920
+ </App>
921
+ );
922
+
923
+ render(<BaseExample />);
924
+
925
+ ```
926
+
927
+ - 批量候选人评估(Tabs + 分栏)
928
+ - HR 批量面试评估:左列表右详情、搜索筛选、维度评分;固定分栏 / Splitter / 批次概览
929
+ - _ReactModal(@kne/current-lib_react-modal)[import * as _ReactModal from "@kne/react-modal"],(@kne/current-lib_react-modal/dist/index.css),antd(antd),(@kne/current-lib_react-modal/doc/style.scss)
930
+
931
+ ```jsx
932
+ const {
933
+ default: Modal,
934
+ ModalTabsLayout,
935
+ ModalColumnsLayout,
936
+ ModalScrollRegion,
937
+ modalClassNames
938
+ } = _ReactModal;
939
+ const {
940
+ Button,
941
+ Space,
942
+ Splitter,
943
+ Input,
944
+ Avatar,
945
+ Tag,
946
+ Descriptions,
947
+ Progress,
948
+ Typography,
949
+ Divider,
950
+ App,
951
+ message
952
+ } = antd;
953
+ const { useState, useMemo } = React;
954
+
955
+ const { Text, Title, Paragraph } = Typography;
956
+
957
+ const STATUS_MAP = {
958
+ pending: { label: '待评估', color: 'orange' },
959
+ passed: { label: '已通过', color: 'success' },
960
+ hold: { label: '待定', color: 'processing' }
961
+ };
962
+
963
+ const CANDIDATES = [
964
+ {
965
+ key: '1',
966
+ name: '陈思远',
967
+ role: '高级前端工程师',
968
+ years: 6,
969
+ city: '上海',
970
+ edu: '浙江大学 · 本科',
971
+ status: 'pending',
972
+ interviewer: '王面试官',
973
+ scores: { comm: 4, tech: 5, project: 4, collab: 4 },
974
+ summary: '主导过设计系统与性能治理,对 React 生态和工程化有较深实践。',
975
+ notes: [
976
+ { title: '技术深度', body: '能清晰描述虚拟列表、并发特性在项目中的落地方式,并给出可量化的性能收益。' },
977
+ { title: '协作推进', body: '跨端联调经验充足,曾推动组件库在 3 条业务线统一接入。' },
978
+ { title: '待确认', body: '对 Node 中间层经验相对薄弱,需二面补充后端协作场景。' }
979
+ ]
980
+ },
981
+ {
982
+ key: '2',
983
+ name: '李雨桐',
984
+ role: '前端工程师',
985
+ years: 4,
986
+ city: '上海',
987
+ edu: '同济大学 · 硕士',
988
+ status: 'passed',
989
+ interviewer: '赵面试官',
990
+ scores: { comm: 5, tech: 4, project: 4, collab: 5 },
991
+ summary: '表达结构清晰,B 端复杂表单与权限场景经验丰富。',
992
+ notes: [
993
+ { title: '项目经验', body: '负责过审批流配置平台,熟悉动态表单、状态机与低代码接入。' },
994
+ { title: '综合评价', body: '建议通过,可安排 HR 谈薪。' }
995
+ ]
996
+ },
997
+ {
998
+ key: '3',
999
+ name: '周亦凡',
1000
+ role: '资深前端',
1001
+ years: 8,
1002
+ city: '杭州',
1003
+ edu: '华中科技大学 · 本科',
1004
+ status: 'hold',
1005
+ interviewer: '王面试官',
1006
+ scores: { comm: 3, tech: 5, project: 5, collab: 3 },
1007
+ summary: '技术栈匹配度高,但管理岗预期与当前编制不完全一致。',
1008
+ notes: [
1009
+ { title: '优势', body: '架构视野好,有微前端与监控体系从 0 到 1 经验。' },
1010
+ { title: '风险', body: '期望职级偏高,需与部门负责人对齐编制与职责范围。' }
1011
+ ]
1012
+ },
1013
+ {
1014
+ key: '4',
1015
+ name: '张可欣',
1016
+ role: 'React 开发',
1017
+ years: 3,
1018
+ city: '上海',
1019
+ edu: '华东师范大学 · 本科',
1020
+ status: 'pending',
1021
+ interviewer: '待分配',
1022
+ scores: { comm: 4, tech: 3, project: 3, collab: 4 },
1023
+ summary: '基础扎实,学习意愿强,适合参与组件库与文档建设。',
1024
+ notes: [{ title: '初面印象', body: 'Coding 完成度较好,需进一步考察复杂状态管理与性能排查。' }]
1025
+ },
1026
+ {
1027
+ key: '5',
1028
+ name: '刘浩然',
1029
+ role: '全栈工程师',
1030
+ years: 5,
1031
+ city: '深圳',
1032
+ edu: '中山大学 · 本科',
1033
+ status: 'passed',
1034
+ interviewer: '陈面试官',
1035
+ scores: { comm: 4, tech: 4, project: 4, collab: 4 },
1036
+ summary: '前后端均可独立交付,适合业务闭环小团队。',
1037
+ notes: [{ title: '备注', body: '可优先推进 offer,期望 4 月到岗。' }]
1038
+ },
1039
+ {
1040
+ key: '6',
1041
+ name: '赵一鸣',
1042
+ role: '前端工程师',
1043
+ years: 2,
1044
+ city: '上海',
1045
+ edu: '上海大学 · 本科',
1046
+ status: 'pending',
1047
+ interviewer: '待分配',
1048
+ scores: { comm: 3, tech: 3, project: 2, collab: 3 },
1049
+ summary: '初级候选人,项目复杂度一般,建议放入人才池观察。',
1050
+ notes: [{ title: '初筛', body: '基础题通过,项目细节描述不够深入。' }]
1051
+ },
1052
+ {
1053
+ key: '7',
1054
+ name: '孙雅琪',
1055
+ role: '高级前端',
1056
+ years: 7,
1057
+ city: '北京',
1058
+ edu: '北京邮电大学 · 硕士',
1059
+ status: 'hold',
1060
+ interviewer: '王面试官',
1061
+ scores: { comm: 4, tech: 5, project: 4, collab: 4 },
1062
+ summary: '技术匹配,但地域需确认是否接受 base 上海。',
1063
+ notes: [{ title: '跟进', body: '已发送 relocate 意愿确认邮件。' }]
1064
+ },
1065
+ {
1066
+ key: '8',
1067
+ name: '吴承泽',
1068
+ role: '前端架构师',
1069
+ years: 10,
1070
+ city: '上海',
1071
+ edu: '上海交通大学 · 硕士',
1072
+ status: 'passed',
1073
+ interviewer: '总监面',
1074
+ scores: { comm: 5, tech: 5, project: 5, collab: 5 },
1075
+ summary: '综合表现优秀,建议进入终面委员会评审。',
1076
+ notes: [
1077
+ { title: '架构能力', body: '对团队工程规范、发布流程、质量门禁有体系化方法论。' },
1078
+ { title: '下一步', body: '安排与研发负责人终面。' }
1079
+ ]
1080
+ }
1081
+ ];
1082
+
1083
+ const EXTRA_CANDIDATES = Array.from({ length: 12 }, (_, i) => ({
1084
+ key: String(i + 9),
1085
+ name: &#96;候选人 ${i + 9}&#96;,
1086
+ role: i % 2 === 0 ? '前端工程师' : '高级前端',
1087
+ years: 2 + (i % 6),
1088
+ city: ['上海', '杭州', '深圳'][i % 3],
1089
+ edu: '本科',
1090
+ status: ['pending', 'passed', 'hold'][i % 3],
1091
+ interviewer: '待分配',
1092
+ scores: { comm: 3 + (i % 3), tech: 3 + (i % 2), project: 3, collab: 3 },
1093
+ summary: '批量导入的待评估候选人,用于验证左侧列表滚动与选中切换。',
1094
+ notes: [{ title: '系统备注', body: '暂无详细评估记录,请安排初面后补充。' }]
1095
+ }));
1096
+
1097
+ const ALL_CANDIDATES = [...CANDIDATES, ...EXTRA_CANDIDATES];
1098
+
1099
+ const SCORE_LABELS = {
1100
+ comm: '沟通表达',
1101
+ tech: '专业深度',
1102
+ project: '项目复杂度',
1103
+ collab: '协作推进'
1104
+ };
1105
+
1106
+ const CandidateList = ({ items, activeKey, onSelect, search, onSearchChange }) => (
1107
+ <>
1108
+ <div className="modal-scroll-region-sticky candidate-list-toolbar">
1109
+ <Input
1110
+ allowClear
1111
+ placeholder="搜索姓名、岗位、城市…"
1112
+ value={search}
1113
+ onChange={e => onSearchChange(e.target.value)}
1114
+ />
1115
+ <Text type="secondary">本批 {items.length} 人 · 点击切换右侧详情</Text>
1116
+ </div>
1117
+ {items.map(item => {
1118
+ const status = STATUS_MAP[item.status] || STATUS_MAP.pending;
1119
+ return (
1120
+ <div
1121
+ key={item.key}
1122
+ className={&#96;candidate-list-item${item.key === activeKey ? ' is-active' : ''}&#96;}
1123
+ onClick={() => onSelect(item.key)}
1124
+ >
1125
+ <div className="candidate-list-item-main">
1126
+ <Avatar style={{ backgroundColor: item.key === activeKey ? '#1677ff' : '#87d068' }}>
1127
+ {item.name.slice(-2)}
1128
+ </Avatar>
1129
+ <div className="candidate-list-item-body">
1130
+ <div className="candidate-list-item-title">{item.name}</div>
1131
+ <div className="candidate-list-item-meta">
1132
+ {item.role} · {item.years} 年 · {item.city}
1133
+ </div>
1134
+ </div>
1135
+ <Tag color={status.color} style={{ margin: 0 }}>
1136
+ {status.label}
1137
+ </Tag>
1138
+ </div>
1139
+ </div>
1140
+ );
1141
+ })}
1142
+ </>
1143
+ );
1144
+
1145
+ const CandidateDetail = ({ candidate }) => {
1146
+ if (!candidate) {
1147
+ return null;
1148
+ }
1149
+ const status = STATUS_MAP[candidate.status] || STATUS_MAP.pending;
1150
+ const avgScore =
1151
+ Object.values(candidate.scores).reduce((a, b) => a + b, 0) / Object.values(candidate.scores).length;
1152
+
1153
+ return (
1154
+ <div className="candidate-detail">
1155
+ <div className="candidate-detail-header">
1156
+ <div>
1157
+ <Title level={4} className="candidate-detail-name">
1158
+ {candidate.name}
1159
+ </Title>
1160
+ <div className="candidate-detail-sub">
1161
+ <Tag>{candidate.role}</Tag>
1162
+ <Tag>{candidate.years} 年经验</Tag>
1163
+ <Tag>{candidate.city}</Tag>
1164
+ <Tag color={status.color}>{status.label}</Tag>
1165
+ </div>
1166
+ </div>
1167
+ <div style={{ textAlign: 'right' }}>
1168
+ <Text type="secondary">综合</Text>
1169
+ <div style={{ fontSize: 28, fontWeight: 600, color: '#1677ff', lineHeight: 1.2 }}>
1170
+ {avgScore.toFixed(1)}
1171
+ </div>
1172
+ </div>
1173
+ </div>
1174
+
1175
+ <Descriptions size="small" bordered column={2} style={{ marginBottom: 20 }}>
1176
+ <Descriptions.Item label="学历">{candidate.edu}</Descriptions.Item>
1177
+ <Descriptions.Item label="面试官">{candidate.interviewer}</Descriptions.Item>
1178
+ <Descriptions.Item label="摘要" span={2}>
1179
+ {candidate.summary}
1180
+ </Descriptions.Item>
1181
+ </Descriptions>
1182
+
1183
+ <Divider orientation="left" plain style={{ margin: '0 0 12px' }}>
1184
+ 维度评分
1185
+ </Divider>
1186
+ <div className="candidate-score-grid">
1187
+ {Object.entries(candidate.scores).map(([key, value]) => (
1188
+ <div key={key}>
1189
+ <div className="candidate-score-item-label">
1190
+ <span>{SCORE_LABELS[key]}</span>
1191
+ <span>{value} / 5</span>
1192
+ </div>
1193
+ <Progress percent={value * 20} showInfo={false} strokeColor="#1677ff" size="small" />
1194
+ </div>
1195
+ ))}
1196
+ </div>
1197
+
1198
+ <Divider orientation="left" plain style={{ margin: '0 0 12px' }}>
1199
+ 面试记录
1200
+ </Divider>
1201
+ {candidate.notes.map(note => (
1202
+ <div key={note.title} className="candidate-note-block">
1203
+ <div className="candidate-note-block-title">{note.title}</div>
1204
+ <Paragraph style={{ margin: 0, color: 'rgba(0,0,0,0.65)' }}>{note.body}</Paragraph>
1205
+ </div>
1206
+ ))}
1207
+ </div>
1208
+ );
1209
+ };
1210
+
1211
+ const useCandidatePanel = () => {
1212
+ const [active, setActive] = useState(ALL_CANDIDATES[0].key);
1213
+ const [search, setSearch] = useState('');
1214
+
1215
+ const filtered = useMemo(() => {
1216
+ const q = search.trim().toLowerCase();
1217
+ if (!q) {
1218
+ return ALL_CANDIDATES;
1219
+ }
1220
+ return ALL_CANDIDATES.filter(
1221
+ c =>
1222
+ c.name.toLowerCase().includes(q) ||
1223
+ c.role.toLowerCase().includes(q) ||
1224
+ c.city.toLowerCase().includes(q)
1225
+ );
1226
+ }, [search]);
1227
+
1228
+ const current = filtered.find(item => item.key === active) || filtered[0];
1229
+
1230
+ return { active, setActive, search, setSearch, filtered, current };
1231
+ };
1232
+
1233
+ const ColumnsPane = () => {
1234
+ const { setActive, search, setSearch, filtered, current } = useCandidatePanel();
1235
+
1236
+ return (
1237
+ <ModalColumnsLayout widths={['34%', '1fr']}>
1238
+ <ModalScrollRegion>
1239
+ <CandidateList
1240
+ items={filtered}
1241
+ activeKey={current?.key}
1242
+ onSelect={setActive}
1243
+ search={search}
1244
+ onSearchChange={setSearch}
1245
+ />
1246
+ </ModalScrollRegion>
1247
+ <ModalScrollRegion inset>
1248
+ <CandidateDetail candidate={current} />
1249
+ </ModalScrollRegion>
1250
+ </ModalColumnsLayout>
1251
+ );
1252
+ };
1253
+
1254
+ const SplitterPane = () => {
1255
+ const { setActive, search, setSearch, filtered, current } = useCandidatePanel();
1256
+
1257
+ return (
1258
+ <div style={{ height: '100%', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
1259
+ <p className="demo-panel-hint">可拖拽中间分隔条调整列表宽度,左右列仍各自 SimpleBar 滚动。</p>
1260
+ <Splitter
1261
+ className={modalClassNames.splitter}
1262
+ style={{ flex: 1, minHeight: 0 }}
1263
+ defaultSize="34%"
1264
+ min="240"
1265
+ max="52%"
1266
+ >
1267
+ <Splitter.Panel>
1268
+ <ModalScrollRegion>
1269
+ <CandidateList
1270
+ items={filtered}
1271
+ activeKey={current?.key}
1272
+ onSelect={setActive}
1273
+ search={search}
1274
+ onSearchChange={setSearch}
1275
+ />
1276
+ </ModalScrollRegion>
1277
+ </Splitter.Panel>
1278
+ <Splitter.Panel>
1279
+ <ModalScrollRegion inset>
1280
+ <CandidateDetail candidate={current} />
1281
+ </ModalScrollRegion>
1282
+ </Splitter.Panel>
1283
+ </Splitter>
1284
+ </div>
1285
+ );
1286
+ };
1287
+
1288
+ const OverviewPane = () => {
1289
+ const stats = useMemo(() => {
1290
+ const pending = ALL_CANDIDATES.filter(c => c.status === 'pending').length;
1291
+ const passed = ALL_CANDIDATES.filter(c => c.status === 'passed').length;
1292
+ const hold = ALL_CANDIDATES.filter(c => c.status === 'hold').length;
1293
+ return { total: ALL_CANDIDATES.length, pending, passed, hold };
1294
+ }, []);
1295
+
1296
+ const schedule = [
1297
+ { time: '09:30', name: '陈思远', role: '高级前端', room: 'A301', status: 'pending' },
1298
+ { time: '10:30', name: '李雨桐', role: '前端', room: 'A301', status: 'passed' },
1299
+ { time: '14:00', name: '周亦凡', role: '资深前端', room: 'B208', status: 'hold' },
1300
+ { time: '15:30', name: '张可欣', role: 'React', room: 'B208', status: 'pending' },
1301
+ { time: '16:30', name: '吴承泽', role: '架构师', room: '总监室', status: 'passed' }
1302
+ ];
1303
+
1304
+ return (
1305
+ <ModalScrollRegion>
1306
+ <div className="candidate-overview-stats">
1307
+ {[
1308
+ { label: '本批人数', value: stats.total },
1309
+ { label: '待评估', value: stats.pending },
1310
+ { label: '已通过', value: stats.passed },
1311
+ { label: '待定', value: stats.hold }
1312
+ ].map(item => (
1313
+ <div key={item.label} className="candidate-stat-card">
1314
+ <div className="candidate-stat-value">{item.value}</div>
1315
+ <div className="candidate-stat-label">{item.label}</div>
1316
+ </div>
1317
+ ))}
1318
+ </div>
1319
+ <div className="candidate-overview-section">
1320
+ <Title level={5} style={{ marginTop: 0 }}>
1321
+ 今日面试安排
1322
+ </Title>
1323
+ <div className="candidate-schedule-row" style={{ fontWeight: 600, color: 'rgba(0,0,0,0.45)' }}>
1324
+ <span>时间</span>
1325
+ <span>候选人</span>
1326
+ <span>会议室</span>
1327
+ <span>状态</span>
1328
+ </div>
1329
+ {schedule.map(row => {
1330
+ const status = STATUS_MAP[row.status];
1331
+ return (
1332
+ <div key={row.time + row.name} className="candidate-schedule-row">
1333
+ <span className="candidate-schedule-time">{row.time}</span>
1334
+ <span>
1335
+ {row.name}
1336
+ <Text type="secondary"> · {row.role}</Text>
1337
+ </span>
1338
+ <span>{row.room}</span>
1339
+ <Tag color={status.color} style={{ margin: 0, justifySelf: 'start' }}>
1340
+ {status.label}
1341
+ </Tag>
1342
+ </div>
1343
+ );
1344
+ })}
1345
+ <Divider />
1346
+ <Paragraph type="secondary" style={{ marginBottom: 0 }}>
1347
+ 概览 Tab 同样使用 ModalScrollRegion:批次统计与日程较长时在本面板内滚动,不影响 Tabs 顶栏与底部操作区。
1348
+ </Paragraph>
1349
+ {Array.from({ length: 6 }, (_, i) => (
1350
+ <Paragraph key={i} style={{ marginBottom: 8, color: 'rgba(0,0,0,0.45)' }}>
1351
+ 备注 {i + 1}:终面委员会会议安排在周五 15:00,需提前汇总已通过候选人材料。
1352
+ </Paragraph>
1353
+ ))}
1354
+ </div>
1355
+ </ModalScrollRegion>
1356
+ );
1357
+ };
1358
+
1359
+ const ExtendLayoutExample = () => {
1360
+ const [open, setOpen] = useState(false);
1361
+ const [tab, setTab] = useState('columns');
1362
+
1363
+ return (
1364
+ <Space direction="vertical" style={{ width: '100%' }} size="middle">
1365
+ <div>
1366
+ <Button type="primary" onClick={() => setOpen(true)}>
1367
+ 打开批量评估弹窗
1368
+ </Button>
1369
+ <Paragraph type="secondary" style={{ margin: '8px 0 0' }}>
1370
+ 模拟 HR 批量面试评估:Tabs 占 title 位,左列表右详情,支持固定分栏 / 可调 Splitter / 批次概览。
1371
+ </Paragraph>
1372
+ </div>
1373
+ <Modal
1374
+ open={open}
1375
+ onClose={() => setOpen(false)}
1376
+ bodyScroll={false}
1377
+ size="large"
1378
+ onConfirm={async () => {
1379
+ await new Promise(r => setTimeout(r, 400));
1380
+ message.success('本批评估已保存');
1381
+ }}
1382
+ confirmText="保存本批评估"
1383
+ cancelText="稍后处理"
1384
+ >
1385
+ <ModalTabsLayout
1386
+ activeKey={tab}
1387
+ onChange={setTab}
1388
+ items={[
1389
+ { key: 'columns', label: '候选人', children: <ColumnsPane /> },
1390
+ { key: 'split', label: '可调分栏', children: <SplitterPane /> },
1391
+ { key: 'overview', label: '批次概览', children: <OverviewPane /> }
1392
+ ]}
1393
+ />
1394
+ </Modal>
1395
+ </Space>
1396
+ );
1397
+ };
1398
+
1399
+ const BaseExample = () => (
1400
+ <App>
1401
+ <ExtendLayoutExample />
1402
+ </App>
1403
+ );
1404
+
1405
+ render(<BaseExample />);
1406
+
1407
+ ```
1408
+
1409
+ - useModal 内再开声明式 Modal
1410
+ - 导出评估报告:外层 useModal + 内层字段选择 Modal(嵌套 hoist)
1411
+ - _ReactModal(@kne/current-lib_react-modal)[import * as _ReactModal from "@kne/react-modal"],(@kne/current-lib_react-modal/dist/index.css),antd(antd)
1412
+
1413
+ ```jsx
1414
+ const { default: Modal, useModal } = _ReactModal;
1415
+ const { Button, Space, Typography, Tag, App, message, Checkbox } = antd;
1416
+ const { useState, useEffect } = React;
1417
+ const { Text, Paragraph, Title } = Typography;
1418
+
1419
+ const EXPORT_FIELDS = [
1420
+ { label: '基本信息', value: 'basic' },
1421
+ { label: '维度评分', value: 'scores' },
1422
+ { label: '面试记录', value: 'notes' },
1423
+ { label: '附件简历', value: 'resume' }
1424
+ ];
1425
+
1426
+ const NestProbe = ({ open }) => {
1427
+ const [info, setInfo] = useState(null);
1428
+
1429
+ useEffect(() => {
1430
+ if (!open) {
1431
+ setInfo(null);
1432
+ return;
1433
+ }
1434
+ const id = window.setTimeout(() => {
1435
+ const titles = Array.from(document.querySelectorAll('.ant-modal-root .modal-title'));
1436
+ const outerTitle = titles.find(el => el.textContent === '导出评估报告');
1437
+ const innerTitle = titles.find(el => el.textContent === '选择导出字段');
1438
+ const outerRoot = outerTitle?.closest('.ant-modal-root');
1439
+ const innerRoot = innerTitle?.closest('.ant-modal-root');
1440
+ const outerBody = outerRoot?.querySelector('.ant-modal-body');
1441
+ const outerZ = Number(outerRoot?.querySelector('.ant-modal-wrap')?.style?.zIndex || 0);
1442
+ const innerZ = Number(innerRoot?.querySelector('.ant-modal-wrap')?.style?.zIndex || 0);
1443
+ const hoisted = !!(innerRoot && outerBody && !outerBody.contains(innerRoot));
1444
+ setInfo({ hoisted, outerZ, innerZ });
1445
+ }, 50);
1446
+ return () => window.clearTimeout(id);
1447
+ }, [open]);
1448
+
1449
+ if (!info) {
1450
+ return <Text type="secondary">打开内层后显示挂载探针</Text>;
1451
+ }
1452
+
1453
+ return (
1454
+ <Space direction="vertical" size={4}>
1455
+ <span>
1456
+ 内层挂载位置:
1457
+ <Tag color={info.hoisted ? 'success' : 'error'} style={{ marginLeft: 8 }}>
1458
+ {info.hoisted ? '外层 modal-root 外侧' : '异常'}
1459
+ </Tag>
1460
+ </span>
1461
+ <Text type="secondary">zIndex 外层 {info.outerZ} / 内层 {info.innerZ}(antd 管理)</Text>
1462
+ </Space>
1463
+ );
1464
+ };
1465
+
1466
+ const ExportFieldPicker = () => {
1467
+ const [innerOpen, setInnerOpen] = useState(false);
1468
+ const [checked, setChecked] = useState(['basic', 'scores', 'notes']);
1469
+
1470
+ return (
1471
+ <div>
1472
+ <Paragraph>
1473
+ 已选择本批 <Text strong>8</Text> 位候选人。导出前可勾选需要写入 PDF 的字段;内层声明式{' '}
1474
+ <Text code>Modal</Text> 会自动 hoist,无需手动指定容器。
1475
+ </Paragraph>
1476
+ <Space>
1477
+ <Button type="primary" onClick={() => setInnerOpen(true)}>
1478
+ 配置导出字段
1479
+ </Button>
1480
+ <Text type="secondary">已选 {checked.length} 项</Text>
1481
+ </Space>
1482
+ <div style={{ marginTop: 12 }}>
1483
+ <NestProbe open={innerOpen} />
1484
+ </div>
1485
+ <Modal
1486
+ title="选择导出字段"
1487
+ open={innerOpen}
1488
+ size="small"
1489
+ onClose={() => setInnerOpen(false)}
1490
+ onConfirm={() => {
1491
+ message.success(&#96;将导出:${checked.join('、')}&#96;);
1492
+ setInnerOpen(false);
1493
+ }}
1494
+ confirmText="确认字段"
1495
+ >
1496
+ <Checkbox.Group
1497
+ style={{ display: 'flex', flexDirection: 'column', gap: 10 }}
1498
+ options={EXPORT_FIELDS}
1499
+ value={checked}
1500
+ onChange={setChecked}
1501
+ />
1502
+ </Modal>
1503
+ </div>
1504
+ );
1505
+ };
1506
+
1507
+ const NestedModalExample = () => {
1508
+ const modal = useModal();
1509
+
1510
+ return (
1511
+ <Space direction="vertical" style={{ width: '100%' }} size="middle">
1512
+ <Button
1513
+ type="primary"
1514
+ onClick={() => {
1515
+ modal({
1516
+ title: '导出评估报告',
1517
+ size: 'default',
1518
+ children: <ExportFieldPicker />,
1519
+ onConfirm: () => message.success('报告已进入生成队列'),
1520
+ confirmText: '开始导出'
1521
+ });
1522
+ }}
1523
+ >
1524
+ 导出本批评估(嵌套弹窗)
1525
+ </Button>
1526
+ <Text type="secondary">外层 useModal + 内层声明式 Modal:关闭内层不影响外层。</Text>
1527
+ </Space>
1528
+ );
1529
+ };
1530
+
1531
+ const BaseExample = () => (
1532
+ <App>
1533
+ <NestedModalExample />
1534
+ </App>
1535
+ );
1536
+
1537
+ render(<BaseExample />);
1538
+
1539
+ ```
1540
+
1541
+ - FormInfo FormModal + renderModal
1542
+ - 超长表单:用 @kne/react-modal 重写 renderModal,验收 title/footer 固定与 body SimpleBar 滚动(可关 bodyScroll 对比)
1543
+ - _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)
1544
+
1545
+ ```jsx
1546
+ const { createModalRender } = _ReactModal;
1547
+ const { default: FormInfo, FormModal, Input, TextArea } = _FormInfo;
1548
+ const { Button, Space, Typography, App, message, Switch, Flex } = antd;
1549
+ const { useState, useMemo } = React;
1550
+ const { Text, Paragraph } = Typography;
1551
+
1552
+ const SECTION_DEFS = [
1553
+ {
1554
+ key: 'basic',
1555
+ title: '基本信息',
1556
+ fields: [
1557
+ ['name', '姓名', 'REQ'],
1558
+ ['employeeNo', '工号', 'REQ'],
1559
+ ['department', '部门', 'REQ'],
1560
+ ['position', '职位', 'REQ'],
1561
+ ['phone', '手机号', 'REQ TEL'],
1562
+ ['email', '邮箱', 'EMAIL'],
1563
+ ['city', '工作城市', 'REQ'],
1564
+ ['manager', '直属上级', '']
1565
+ ]
1566
+ },
1567
+ {
1568
+ key: 'interview',
1569
+ title: '面试评估',
1570
+ fields: [
1571
+ ['commScore', '沟通表达', 'REQ'],
1572
+ ['techScore', '专业深度', 'REQ'],
1573
+ ['projectScore', '项目复杂度', 'REQ'],
1574
+ ['collabScore', '协作推进', 'REQ'],
1575
+ ['cultureScore', '文化匹配', 'REQ'],
1576
+ ['overallScore', '综合评分', 'REQ']
1577
+ ]
1578
+ },
1579
+ {
1580
+ key: 'experience',
1581
+ title: '经历与补充',
1582
+ fields: Array.from({ length: 16 }, (_, i) => [
1583
+ &#96;expField${i + 1}&#96;,
1584
+ &#96;经历补充项 ${i + 1}&#96;,
1585
+ i % 4 === 0 ? 'REQ' : ''
1586
+ ])
1587
+ }
1588
+ ];
1589
+
1590
+ const buildInitialData = () => {
1591
+ const data = {
1592
+ name: '张三',
1593
+ employeeNo: 'E20240018',
1594
+ department: '研发中心',
1595
+ position: '高级前端',
1596
+ phone: '13800138000',
1597
+ email: 'zhangsan@company.com',
1598
+ city: '上海',
1599
+ manager: '李四',
1600
+ commScore: '4',
1601
+ techScore: '5',
1602
+ projectScore: '4',
1603
+ collabScore: '4',
1604
+ cultureScore: '5',
1605
+ overallScore: '4.5',
1606
+ summary:
1607
+ '沟通清晰,项目推进稳定。以下为加长评估说明,用于验证弹窗 body 在超长表单下的 SimpleBar 滚动:标题与底部按钮应固定,仅中间表单区域滚动。'.repeat(3)
1608
+ };
1609
+ SECTION_DEFS[2].fields.forEach(([name], i) => {
1610
+ data[name] = &#96;补充说明内容 ${i + 1}:用于拉长表单高度。&#96;;
1611
+ });
1612
+ return data;
1613
+ };
1614
+
1615
+ const LongFormFields = () => (
1616
+ <Flex vertical gap={16}>
1617
+ {SECTION_DEFS.map(section => (
1618
+ <FormInfo
1619
+ key={section.key}
1620
+ bordered
1621
+ title={section.title}
1622
+ column={2}
1623
+ gap={20}
1624
+ list={section.fields.map(([name, label, rule]) => (
1625
+ <Input key={name} name={name} label={label} rule={rule || undefined} />
1626
+ ))}
1627
+ />
1628
+ ))}
1629
+ <FormInfo
1630
+ bordered
1631
+ title="评估摘要(长文本)"
1632
+ column={1}
1633
+ gap={20}
1634
+ list={[
1635
+ <TextArea key="summary" name="summary" label="综合评语" rule="REQ" block />,
1636
+ <TextArea
1637
+ key="risk"
1638
+ name="risk"
1639
+ label="风险与待跟进"
1640
+ block
1641
+ placeholder="列出风险点、待确认事项等"
1642
+ />,
1643
+ <TextArea
1644
+ key="plan"
1645
+ name="plan"
1646
+ label="入职 / 下轮计划"
1647
+ block
1648
+ placeholder="试用期目标、面试官建议等"
1649
+ />
1650
+ ]}
1651
+ />
1652
+ {Array.from({ length: 8 }, (_, block) => (
1653
+ <FormInfo
1654
+ key={&#96;extra-${block}&#96;}
1655
+ bordered
1656
+ title={&#96;附加问卷 ${block + 1}&#96;}
1657
+ column={2}
1658
+ gap={20}
1659
+ list={Array.from({ length: 6 }, (_, i) => {
1660
+ const name = &#96;q${block + 1}_${i + 1}&#96;;
1661
+ return <Input key={name} name={name} label={&#96;问题 ${block + 1}.${i + 1}&#96;} />;
1662
+ })}
1663
+ />
1664
+ ))}
1665
+ </Flex>
1666
+ );
1667
+
1668
+ const FormInfoModalExample = () => {
1669
+ const [open, setOpen] = useState(false);
1670
+ const [bodyScroll, setBodyScroll] = useState(true);
1671
+ const initialData = useMemo(() => buildInitialData(), []);
1672
+
1673
+ const renderModalBase = createModalRender({
1674
+ footerButtons: [],
1675
+ bodyScroll: true,
1676
+ size: 'large'
1677
+ });
1678
+
1679
+ const renderModal = ({
1680
+ formProps,
1681
+ saveText,
1682
+ autoClose,
1683
+ onCancel,
1684
+ footer,
1685
+ modalRender,
1686
+ children,
1687
+ ...props
1688
+ }) =>
1689
+ renderModalBase({
1690
+ ...props,
1691
+ bodyScroll,
1692
+ onClose: onCancel,
1693
+ footer: typeof footer === 'function' ? footer() : footer,
1694
+ modalRender,
1695
+ children
1696
+ });
1697
+
1698
+ return (
1699
+ <Space direction="vertical" style={{ width: '100%' }}>
1700
+ <Space wrap>
1701
+ <Button type="primary" onClick={() => setOpen(true)}>
1702
+ 打开深度评估表单(FormModal)
1703
+ </Button>
1704
+ <Space>
1705
+ <Text type="secondary">bodyScroll</Text>
1706
+ <Switch checked={bodyScroll} onChange={setBodyScroll} checkedChildren="开" unCheckedChildren="关" />
1707
+ </Space>
1708
+ </Space>
1709
+ <Text type="secondary">
1710
+ createModalRender 注入 Modal 默认 props;form-info 宿主字段与 onCancel→onClose 在 renderModal 内映射。
1711
+ </Text>
1712
+
1713
+ <FormModal
1714
+ title="候选人深度评估(超长表单)"
1715
+ open={open}
1716
+ onCancel={() => setOpen(false)}
1717
+ renderModal={renderModal}
1718
+ okText="保存评估"
1719
+ cancelText="取消"
1720
+ width={1000}
1721
+ formProps={{
1722
+ data: initialData,
1723
+ onSubmit: async data => {
1724
+ await new Promise(resolve => setTimeout(resolve, 500));
1725
+ message.success(&#96;已保存:${data.name}(共 ${Object.keys(data).length} 个字段)&#96;);
1726
+ }
1727
+ }}
1728
+ >
1729
+ <LongFormFields />
1730
+ </FormModal>
1731
+ </Space>
1732
+ );
1733
+ };
1734
+
1735
+ const BaseExample = () => (
1736
+ <App>
1737
+ <FormInfoModalExample />
1738
+ </App>
1739
+ );
1740
+
1741
+ render(<BaseExample />);
1742
+
1743
+ ```
1744
+
1745
+ - FormStepsModal + renderModal
1746
+ - 分步表单:FormStepsModal 在 modalProps 中传 renderModal;步骤内 FormInfo gap + Flex 垂直间距
1747
+ - _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)
1748
+
1749
+ ```jsx
1750
+ const { createModalRender, modalClassNames } = _ReactModal;
1751
+ const { default: FormInfo, FormStepsModal, List, Input, TextArea } = _FormInfo;
1752
+ const { Button, Space, Typography, App, message, Flex } = antd;
1753
+ const { useState } = React;
1754
+ const { Text } = Typography;
1755
+
1756
+ const renderStepsModalBase = createModalRender({
1757
+ footerButtons: [],
1758
+ bodyScroll: true,
1759
+ size: 'default',
1760
+ className: modalClassNames.stepsForm
1761
+ });
1762
+
1763
+ const renderStepsModal = ({
1764
+ formProps,
1765
+ saveText,
1766
+ autoClose,
1767
+ onCancel,
1768
+ footer,
1769
+ modalRender,
1770
+ children,
1771
+ className,
1772
+ ...props
1773
+ }) =>
1774
+ renderStepsModalBase({
1775
+ ...props,
1776
+ className,
1777
+ onClose: onCancel,
1778
+ footer: typeof footer === 'function' ? footer() : footer,
1779
+ modalRender,
1780
+ children
1781
+ });
1782
+
1783
+ const STEP_DATA = {
1784
+ name: '李四',
1785
+ employeeNo: 'E20240023',
1786
+ department: '产品部',
1787
+ phone: '13900139000',
1788
+ email: 'lisi@company.com',
1789
+ years: '6',
1790
+ position: '高级产品经理',
1791
+ commScore: '4',
1792
+ techScore: '4',
1793
+ projectScore: '5',
1794
+ summary: '产品规划清晰,跨团队推进能力强。',
1795
+ workExperience: [
1796
+ {
1797
+ companyName: '某互联网公司',
1798
+ role: '产品经理',
1799
+ years: '3年'
1800
+ }
1801
+ ],
1802
+ objectives: '1. 独立负责核心模块\n2. 推动跨部门协作\n3. 三个月内熟悉业务',
1803
+ risks: '对行业业务理解仍需加深'
1804
+ };
1805
+
1806
+ const FormInfoStepsModalExample = () => {
1807
+ const [open, setOpen] = useState(false);
1808
+
1809
+ return (
1810
+ <Space direction="vertical" style={{ width: '100%' }}>
1811
+ <Button type="primary" onClick={() => setOpen(true)}>
1812
+ 打开分步评估(FormStepsModal)
1813
+ </Button>
1814
+ <Text type="secondary">
1815
+ 三步完成候选人评估:基本信息 → 维度评分 → 经历与结论。
1816
+ </Text>
1817
+
1818
+ <FormStepsModal
1819
+ autoStep
1820
+ completeText="提交评估"
1821
+ nextText="下一步"
1822
+ onComplete={async allData => {
1823
+ await new Promise(resolve => setTimeout(resolve, 600));
1824
+ message.success(&#96;已提交 ${allData.length} 步数据&#96;);
1825
+ }}
1826
+ modalProps={{
1827
+ open,
1828
+ title: '候选人评估(分步)',
1829
+ width: 900,
1830
+ onCancel: () => setOpen(false),
1831
+ renderModal: renderStepsModal
1832
+ }}
1833
+ items={[
1834
+ {
1835
+ title: '基本信息',
1836
+ formProps: { data: STEP_DATA },
1837
+ children: (
1838
+ <Flex vertical gap={16}>
1839
+ <FormInfo
1840
+ bordered
1841
+ title="候选人信息"
1842
+ column={2}
1843
+ gap={20}
1844
+ list={[
1845
+ <Input name="name" label="姓名" rule="REQ" />,
1846
+ <Input name="employeeNo" label="工号" rule="REQ" disabled />,
1847
+ <Input name="department" label="部门" rule="REQ" />,
1848
+ <Input name="position" label="职位" rule="REQ" />,
1849
+ <Input name="phone" label="手机号" rule="REQ TEL" />,
1850
+ <Input name="email" label="邮箱" rule="EMAIL" />,
1851
+ <Input name="years" label="工作年限" rule="REQ" />
1852
+ ]}
1853
+ />
1854
+ </Flex>
1855
+ )
1856
+ },
1857
+ {
1858
+ title: '面试评分',
1859
+ formProps: { data: STEP_DATA },
1860
+ children: (
1861
+ <Flex vertical gap={16}>
1862
+ <FormInfo
1863
+ bordered
1864
+ title="维度评分"
1865
+ column={2}
1866
+ gap={20}
1867
+ list={[
1868
+ <Input name="commScore" label="沟通表达" rule="REQ" />,
1869
+ <Input name="techScore" label="专业深度" rule="REQ" />,
1870
+ <Input name="projectScore" label="项目复杂度" rule="REQ" />
1871
+ ]}
1872
+ />
1873
+ <FormInfo
1874
+ bordered
1875
+ title="评语"
1876
+ column={1}
1877
+ gap={20}
1878
+ list={[
1879
+ <TextArea name="summary" label="综合评语" rule="REQ" block rows={4} />
1880
+ ]}
1881
+ />
1882
+ </Flex>
1883
+ )
1884
+ },
1885
+ {
1886
+ title: '经历与结论',
1887
+ formProps: { data: STEP_DATA },
1888
+ children: (
1889
+ <Flex vertical gap={16}>
1890
+ <List
1891
+ title="工作经历"
1892
+ name="workExperience"
1893
+ bordered
1894
+ important
1895
+ maxLength={5}
1896
+ addText="添加经历"
1897
+ itemTitle={({ index, data }) => data?.companyName || &#96;经历 ${index + 1}&#96;}
1898
+ list={[
1899
+ <Input name="companyName" label="公司" rule="REQ" />,
1900
+ <Input name="role" label="职位" rule="REQ" />,
1901
+ <Input name="years" label="年限" placeholder="例如 2年" />
1902
+ ]}
1903
+ />
1904
+ <FormInfo
1905
+ bordered
1906
+ title="目标与风险"
1907
+ column={1}
1908
+ gap={20}
1909
+ list={[
1910
+ <TextArea name="objectives" label="培养目标" rule="REQ" block rows={4} />,
1911
+ <TextArea name="risks" label="风险与跟进" block rows={3} />
1912
+ ]}
1913
+ />
1914
+ </Flex>
1915
+ )
1916
+ }
1917
+ ]}
1918
+ />
1919
+ </Space>
1920
+ );
1921
+ };
1922
+
1923
+ const BaseExample = () => (
1924
+ <App>
1925
+ <FormInfoStepsModalExample />
1926
+ </App>
1927
+ );
1928
+
1929
+ render(<BaseExample />);
1930
+
1931
+ ```
1932
+
1933
+ ### API
1934
+
1935
+ #### Modal
1936
+
1937
+ 声明式弹窗。基于 antd Modal,关闭请使用 `onClose`(内部映射 antd `onCancel`)。
1938
+
1939
+ ##### 属性
1940
+
1941
+ | 属性 | 类型 | 默认值 | 描述 |
1942
+ |------|------|--------|------|
1943
+ | open | boolean | - | 是否显示 |
1944
+ | onClose | function | - | 关闭回调;受控时由此改 `open` |
1945
+ | title | ReactNode \| function | - | 标题;为 function 时入参含 `close` |
1946
+ | children | ReactNode \| function | - | 内容;为 function 时入参含 `close` |
1947
+ | onConfirm | function | - | 默认确认按钮;支持 Promise;返回 `false` 不关闭 |
1948
+ | onCancel | function | - | 默认取消按钮;语义同 `onConfirm` |
1949
+ | confirmText | ReactNode | - | 默认确认文案(默认 intl Confirm) |
1950
+ | cancelText | ReactNode | - | 默认取消文案(默认 intl Cancel) |
1951
+ | footer | ReactNode \| function \| null | - | 左侧 footer 插槽;与 `footerButtons` 均为「未设置」且 `footer === null` 时不渲染 footer |
1952
+ | footerButtons | array \| function | - | 右侧按钮区,见下表;未传时为取消+确认 |
1953
+ | size | `small` \| `default` \| `large` | `default` | 桌面宽度档位 |
1954
+ | noPadding | boolean | false | 去掉 body 内边距。未传且 `bodyScroll={false}` 时默认视为 true;显式 `noPadding={false}` 可保留内边距 |
1955
+ | maskClosable | boolean | false | 点击蒙层是否关闭 |
1956
+ | closable | boolean | true | 是否显示右上角关闭 |
1957
+ | bodyScroll | boolean | true | true 使用 SimpleBar;false 不挂 SimpleBar,内容自管滚动,且默认 noPadding(可用 `noPadding={false}` 覆盖) |
1958
+ | mobileFullscreen | boolean | true | 移动端是否全屏 |
1959
+ | getContainer | HTMLElement \| function | - | 挂载容器;嵌套时默认挂到外层 `.ant-modal-root` 外侧 |
1960
+ | width / zIndex / className / afterClose / style / styles | - | - | 透传或覆盖 antd Modal |
1961
+
1962
+ 其余未列出参数按 antd Modal 习惯透传。
1963
+
1964
+ ##### footerButtons 项
1965
+
1966
+ | 属性 | 类型 | 默认值 | 描述 |
1967
+ |------|------|--------|------|
1968
+ | children | ReactNode | - | 按钮文案 |
1969
+ | type | string | - | antd Button type |
1970
+ | onClick | function | - | 可返回 Promise;末参为 `targetProps`(含 `close`);`autoClose` 且结果 `!== false` 时关闭 |
1971
+ | autoClose | boolean | true | 点击后是否自动关闭 |
1972
+ | display | boolean \| function | - | `false` 隐藏;function 返回值决定是否展示 |
1973
+ | ButtonComponent | component | - | 自定义按钮;默认走 ButtonGroup 内 LoadingButton |
1974
+ | ... | - | - | 其余透传 Button |
1975
+
1976
+ - `footerButtons={[]}`:不渲染按钮列(可与左侧 `footer` 并存)。**移动端**此模式且 `footer` 有内容时(如 FormModal 自带 Submit/Cancel),按钮区会自动居中,与默认 `footerButtons` 行为一致。
1977
+ - `footer === null` 且未传 `footerButtons`:不渲染整个 footer 区
1978
+
1979
+ #### useModal
1980
+
1981
+ 命令式打开弹窗,参数同 Modal。需在 antd `App`(或 `App.useApp` 可用)上下文中使用。
1982
+
1983
+ ##### 返回值
1984
+
1985
+ | 属性 | 类型 | 描述 |
1986
+ |------|------|------|
1987
+ | modal | function | 调用后弹出 Modal;返回 `{ close }`;默认 `zIndex` 为 1100 |
1988
+
1989
+ #### CSS 变量
1990
+
1991
+ 挂在 `.modal` / `.modal-outer` 上,可业务覆盖。
1992
+
1993
+ | 变量 | 说明 |
1994
+ |------|------|
1995
+ | `--kne-modal-viewport-height` | 默认 `var(--kne-viewport-height, 100vh)` |
1996
+ | `--kne-modal-viewport-width` | 默认 `var(--kne-viewport-width, 100vw)` |
1997
+ | `--kne-modal-title-height` | 标题区高度贡献,默认 `48px`;无 title 为 `0` |
1998
+ | `--kne-modal-footer-height` | footer 高度贡献,默认 `58px`;无 footer 为 `0` |
1999
+ | `--kne-modal-viewport-gutter` | 桌面相对视口留白合计,默认 `120px`;移动全屏 `0` |
2000
+ | `--kne-modal-body-padding-vertical` | body 上下 padding 合计,默认 `48px`;`noPadding` 为 `0` |
2001
+ | `--kne-modal-body-padding-horizontal` | body 左右 padding 合计,默认 `48px`;`noPadding` 为 `0` |
2002
+ | `--kne-modal-body-height` | body 容器高度(**不**扣 body padding) |
2003
+ | `--kne-modal-content-height` | 内容高度;默认 `body-height − padding-vertical`。`bodyScroll={false}` 时在 `.modal-body` 内覆盖为 `100%`(相对 body 实高),供内部滚动容器使用 |
2004
+ | `--kne-modal-body-min-height` / `--kne-modal-body-max-height` | body 容器 min/max |
2005
+ | `--kne-modal-content-min-height` | content 侧 min |
2006
+ | `--kne-modal-content-width` | 内容宽度契约(扣 horizontal padding) |
2007
+
2008
+ #### 布局组合
2009
+
2010
+ Tabs / N 列分栏等复杂内容区须 **`bodyScroll={false}`**,由 `ModalScrollRegion`(SimpleBar)分区滚动,避免与 Modal body 双层 SimpleBar。
2011
+
2012
+ ##### ModalTabsLayout
2013
+
2014
+ antd `Tabs` 封装:顶栏占 title 位(配合无 `title` 的 Modal)、antd6 高度链、`destroyOnHidden` 默认 true。其余 props 透传 Tabs。
2015
+
2016
+ ##### ModalColumnsLayout
2017
+
2018
+ 固定 N 列 flex 分栏。`widths` 与 `children` 等长(如 `['36%', '1fr']`、`['200px', '1fr', '280px']`);`'1fr'` 占剩余宽度。
2019
+
2020
+ ##### ModalScrollRegion
2021
+
2022
+ 单块 SimpleBar 滚动区,用于 Tab 面板或每一列。首列/末列背景由父级 `:first-child` / `:last-child` 选中。
2023
+
2024
+ - `inset`(默认 `false`):为 `true` 时内容区增加 `16px 20px` 内边距(适合详情、概览等单列内容)
2025
+ - 分栏列表左列通常保持 `inset={false}`,由列表项自行控制间距
2026
+
2027
+ Tabs / 分栏弹窗须 **`bodyScroll={false}`**,此时 Modal 默认 **noPadding**(内容贴边),以便分栏铺满 body;需要外层留白时可传 **`noPadding={false}`**。
2028
+
2029
+ ##### modalClassNames
2030
+
2031
+ | 常量 | 值 | 用途 |
2032
+ |------|-----|------|
2033
+ | `stepsForm` | `react-modal-steps-form` | 分步弹窗挂 Modal `className`,去横向溢出 |
2034
+ | `splitter` | `react-modal-splitter` | antd Splitter 在 Modal 内的高度链 |
2035
+
2036
+ ##### createModalRender
2037
+
2038
+ ```ts
2039
+ createModalRender(modalDefaults) => (hostProps) => Modal
2040
+ ```
2041
+
2042
+ 仅合并 `modalDefaults` 与 `hostProps` 后渲染 Modal。宿主协议(`onCancel`→`onClose`、剥离非 Modal 字段、`footer` 函数等)由调用方在 `renderModal` 回调内完成。
2043
+
2044
+ 推荐 `modalDefaults`:
2045
+
2046
+ | 场景 | modalDefaults |
2047
+ |------|---------------|
2048
+ | 子内容自带 Footer | `{ footerButtons: [] }` |
2049
+ | 长内容 | `{ footerButtons: [], bodyScroll: true, size: 'large' }` |
2050
+ | 分步弹窗 | `{ footerButtons: [], bodyScroll: true, size: 'default', className: modalClassNames.stepsForm }` |