fix(web): 完善统一认证恢复与重配交互

为处理中、失败和清理中的配对展示明确状态与恢复动作,支持放弃并清理、作用域内退役冲突连接,并在 Active 状态引导管理员先安全禁用再使用新接入码。同步修复运行时切换后的浏览器会话残留与确认弹窗焦点约束。

验证:pnpm test;pnpm lint;pnpm build。
This commit is contained in:
2026-07-17 18:31:38 +08:00
parent a312ad880d
commit b179162330
9 changed files with 696 additions and 98 deletions
@@ -0,0 +1,29 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { ConfirmDialog } from './confirm-dialog';
describe('ConfirmDialog', () => {
it('associates its title and destructive-action description', () => {
const html = renderToStaticMarkup(
<ConfirmDialog open title="放弃当前配对?" description="将清理临时资源" onCancel={() => undefined} onConfirm={() => undefined} />,
);
const labelledBy = html.match(/aria-labelledby="([^"]+)"/)?.[1];
const describedBy = html.match(/aria-describedby="([^"]+)"/)?.[1];
expect(labelledBy).toBeTruthy();
expect(describedBy).toBeTruthy();
expect(html).toContain(`id="${labelledBy}"`);
expect(html).toContain(`id="${describedBy}"`);
expect(html).toContain('tabindex="-1"');
});
it('keeps the modal container focusable while its actions are loading', () => {
const html = renderToStaticMarkup(
<ConfirmDialog open loading title="安全退役?" onCancel={() => undefined} onConfirm={() => undefined} />,
);
expect(html).toContain('aria-busy="true"');
expect(html).toContain('tabindex="-1"');
expect(html.match(/disabled=""/g)?.length).toBe(2);
});
});
+56 -9
View File
@@ -18,32 +18,79 @@ export interface ConfirmDialogProps {
}
export function ConfirmDialog(props: ConfirmDialogProps) {
const { onCancel, open } = props;
const { open } = props;
const titleId = React.useId();
const descriptionId = React.useId();
const dialogRef = React.useRef<HTMLElement>(null);
const cancelRef = React.useRef<HTMLButtonElement>(null);
const loadingRef = React.useRef(props.loading);
const onCancelRef = React.useRef(props.onCancel);
loadingRef.current = props.loading;
onCancelRef.current = props.onCancel;
React.useEffect(() => {
if (!open) return undefined;
const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const focusFrame = window.requestAnimationFrame(() => {
if (cancelRef.current && !cancelRef.current.disabled) cancelRef.current.focus();
else dialogRef.current?.focus();
});
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onCancel();
if (event.key === 'Escape' && !loadingRef.current) {
event.preventDefault();
onCancelRef.current();
return;
}
if (event.key !== 'Tab' || !dialogRef.current) return;
const focusable = Array.from(dialogRef.current.querySelectorAll<HTMLElement>('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'));
if (!focusable.length) {
event.preventDefault();
dialogRef.current.focus();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (document.activeElement === dialogRef.current) {
event.preventDefault();
(event.shiftKey ? last : first).focus();
} else if (!dialogRef.current.contains(document.activeElement)) {
event.preventDefault();
first.focus();
} else if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [onCancel, open]);
return () => {
window.cancelAnimationFrame(focusFrame);
window.removeEventListener('keydown', onKeyDown);
previouslyFocused?.focus();
};
}, [open]);
React.useEffect(() => {
if (open && props.loading) dialogRef.current?.focus();
}, [open, props.loading]);
if (!open) return null;
return (
<div className="confirmDialogBackdrop" role="presentation">
<section className={cn('confirmDialog', props.className)} role="alertdialog" aria-modal="true" aria-labelledby="confirm-dialog-title">
<div className="confirmDialogIcon">
<section ref={dialogRef} className={cn('confirmDialog', props.className)} role="alertdialog" tabIndex={-1} aria-busy={props.loading || undefined} aria-modal="true" aria-labelledby={titleId} aria-describedby={props.description ? descriptionId : undefined}>
<div className="confirmDialogIcon" aria-hidden="true">
<AlertTriangle size={18} />
</div>
<div className="confirmDialogBody">
<strong id="confirm-dialog-title">{props.title}</strong>
{props.description && <p>{props.description}</p>}
<strong id={titleId}>{props.title}</strong>
{props.description && <p id={descriptionId}>{props.description}</p>}
{props.children}
</div>
<footer className="confirmDialogActions">
<Button type="button" variant="outline" size="sm" disabled={props.loading} onClick={props.onCancel}>
<Button ref={cancelRef} type="button" variant="outline" size="sm" disabled={props.loading} onClick={props.onCancel}>
{props.cancelLabel ?? '取消'}
</Button>
<Button