Files
RiskAgent/web/src/design-system/components/Dialog.tsx
T
freedakgmail c670b9e454 外包风险评估系统:领域引擎+前端+服务端持久化与生产部署
- 确定性领域引擎(分类/评分/分级/红线/费用/裁决)+LLM(通义千问)语言理解
- 6步评估向导、服务端草稿持久化(跨设备/编辑草稿保护)
- 工作流(草稿→风控→管理层)、RBAC、报告导出、校准、客户/费率/红线/最低工资管理
- 专业图标体系替换全部emoji、看板美化
- 生产化:API_BASE可配置(同源反代)、auth密钥惰性读取修复RBAC
- 444单测+204前端测试+51 e2e
2026-06-13 01:06:39 +08:00

167 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Dialog — 令牌驱动的模态对话框(task 18.8Req 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>
);
}