c670b9e454
- 确定性领域引擎(分类/评分/分级/红线/费用/裁决)+LLM(通义千问)语言理解 - 6步评估向导、服务端草稿持久化(跨设备/编辑草稿保护) - 工作流(草稿→风控→管理层)、RBAC、报告导出、校准、客户/费率/红线/最低工资管理 - 专业图标体系替换全部emoji、看板美化 - 生产化:API_BASE可配置(同源反代)、auth密钥惰性读取修复RBAC - 444单测+204前端测试+51 e2e
167 lines
4.6 KiB
TypeScript
167 lines
4.6 KiB
TypeScript
/**
|
||
* Dialog — 令牌驱动的模态对话框(task 18.8,Req 19.1 / 19.5)。
|
||
*
|
||
* 受控显隐(`open` + `onClose`)。可访问性(便于 task 22.x):
|
||
* - 容器 `role="dialog"` 且 `aria-modal="true"`;
|
||
* - `aria-labelledby` 关联标题;
|
||
* - 打开时自动聚焦关闭按钮;按 `Escape` 触发关闭;
|
||
* - 关闭按钮图标取自单一来源图标集(Req 19.5)。
|
||
*
|
||
* 完整的焦点陷阱(focus trap)留待 task 22.x;此处提供合理的基础行为。
|
||
*/
|
||
|
||
import { useEffect, useRef } from 'react';
|
||
import type { CSSProperties, ReactNode } from 'react';
|
||
import { colorVar, FONT_FAMILY, RADIUS, space, typographyStyle } from './styles.js';
|
||
import { Icon } from './Icon.js';
|
||
|
||
/** `Dialog` 组件属性。 */
|
||
export interface DialogProps {
|
||
/** 是否显示。 */
|
||
readonly open: boolean;
|
||
/** 请求关闭的回调(点击遮罩、关闭按钮或按 Escape 时触发)。 */
|
||
readonly onClose: () => void;
|
||
/** 对话框标题。 */
|
||
readonly title: string;
|
||
/** 对话框主体内容。 */
|
||
readonly children: ReactNode;
|
||
/** 可选底部操作区。 */
|
||
readonly footer?: ReactNode;
|
||
/** 关闭按钮的无障碍标签,默认「关闭」。 */
|
||
readonly closeLabel?: string;
|
||
}
|
||
|
||
/**
|
||
* 模态对话框。显隐受控,具备基础键盘与焦点行为(Req 19.1)。
|
||
*/
|
||
export function Dialog({
|
||
open,
|
||
onClose,
|
||
title,
|
||
children,
|
||
footer,
|
||
closeLabel = '关闭',
|
||
}: DialogProps): JSX.Element | null {
|
||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||
const titleId = useRef(`dialog-title-${Math.random().toString(36).slice(2)}`).current;
|
||
|
||
// 打开时聚焦关闭按钮,提供合理的初始焦点。
|
||
useEffect(() => {
|
||
if (open) {
|
||
closeButtonRef.current?.focus();
|
||
}
|
||
}, [open]);
|
||
|
||
// Escape 关闭。
|
||
useEffect(() => {
|
||
if (!open) {
|
||
return;
|
||
}
|
||
const handler = (event: KeyboardEvent): void => {
|
||
if (event.key === 'Escape') {
|
||
onClose();
|
||
}
|
||
};
|
||
document.addEventListener('keydown', handler);
|
||
return () => {
|
||
document.removeEventListener('keydown', handler);
|
||
};
|
||
}, [open, onClose]);
|
||
|
||
if (!open) {
|
||
return null;
|
||
}
|
||
|
||
const overlayStyle: CSSProperties = {
|
||
position: 'fixed',
|
||
inset: 0,
|
||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
padding: `${space(4)}px`,
|
||
zIndex: 1000,
|
||
};
|
||
|
||
const dialogStyle: CSSProperties = {
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
width: '100%',
|
||
maxWidth: '480px',
|
||
maxHeight: '100%',
|
||
backgroundColor: colorVar('color.bg.elevated'),
|
||
border: `1px solid ${colorVar('color.border.default')}`,
|
||
borderRadius: `${RADIUS.md}px`,
|
||
fontFamily: FONT_FAMILY,
|
||
color: colorVar('color.text.primary'),
|
||
overflow: 'hidden',
|
||
};
|
||
|
||
const headerStyle: CSSProperties = {
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
gap: `${space(3)}px`,
|
||
padding: `${space(3)}px ${space(4)}px`,
|
||
borderBottom: `1px solid ${colorVar('color.border.default')}`,
|
||
...typographyStyle('title'),
|
||
fontWeight: 700,
|
||
};
|
||
|
||
const closeButtonStyle: CSSProperties = {
|
||
display: 'inline-flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
padding: `${space(1)}px`,
|
||
background: 'transparent',
|
||
border: 'none',
|
||
borderRadius: `${RADIUS.sm}px`,
|
||
cursor: 'pointer',
|
||
color: colorVar('color.text.secondary'),
|
||
};
|
||
|
||
return (
|
||
<div
|
||
style={overlayStyle}
|
||
onClick={(event) => {
|
||
if (event.target === event.currentTarget) {
|
||
onClose();
|
||
}
|
||
}}
|
||
>
|
||
<div role="dialog" aria-modal="true" aria-labelledby={titleId} style={dialogStyle}>
|
||
<header style={headerStyle}>
|
||
<h2 id={titleId} style={{ margin: 0, ...typographyStyle('title') }}>
|
||
{title}
|
||
</h2>
|
||
<button
|
||
ref={closeButtonRef}
|
||
type="button"
|
||
aria-label={closeLabel}
|
||
onClick={onClose}
|
||
style={closeButtonStyle}
|
||
>
|
||
<Icon name="close" size={20} />
|
||
</button>
|
||
</header>
|
||
<div style={{ padding: `${space(4)}px`, overflowY: 'auto', ...typographyStyle('body') }}>
|
||
{children}
|
||
</div>
|
||
{footer !== undefined ? (
|
||
<footer
|
||
style={{
|
||
display: 'flex',
|
||
justifyContent: 'flex-end',
|
||
gap: `${space(2)}px`,
|
||
padding: `${space(3)}px ${space(4)}px`,
|
||
borderTop: `1px solid ${colorVar('color.border.default')}`,
|
||
}}
|
||
>
|
||
{footer}
|
||
</footer>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|