fix(a11y): improve focus restoration with timing fixes, validation, and error handling
Core improvements: - Fix focus restoration timing by using queueMicrotask only (removed double-layer async) - Add comprehensive error handling with dev-mode logging for all failure scenarios - Validate element visibility (display: none, visibility: hidden) before restoring focus - Check disabled state and implement fallback to nearest focusable ancestor - Add findNearestFocusableElement() helper for robust fallback strategy - Add tabindex="-1" to focusable selectors for better focus management - Use preventScroll option when calling focus() to prevent scroll jumps Additional fixes: - Remove unnecessary type conversion in Modal.tsx - Fix TypeScript unused variable errors in main process - Clean up unused imports in bip-users-dao.ts - Add ARIA attributes and focus management to LoginDialog - Add triggerRef support to UserSelectionDialog and ExecutionReportDialog - Refactor ExecutionReportDialog to use Modal component - Improve MaterialTypeManagementDialog with focus management Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -59,9 +59,11 @@ export function withErrorHandling<T>(
|
|||||||
const code = getErrorCode(error)
|
const code = getErrorCode(error)
|
||||||
|
|
||||||
// Serialize error with full details
|
// Serialize error with full details
|
||||||
const serializedError = serializeError(error)
|
if (process.env.NODE_ENV === 'production') {
|
||||||
const errorToLog =
|
sanitizeError(serializeError(error))
|
||||||
process.env.NODE_ENV === 'production' ? sanitizeError(serializedError) : serializedError
|
} else {
|
||||||
|
serializeError(error)
|
||||||
|
}
|
||||||
|
|
||||||
if (isBaseError(error)) {
|
if (isBaseError(error)) {
|
||||||
logError(log, `[${context}] ${error.name}`, error, {
|
logError(log, `[${context}] ${error.name}`, error, {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { ConfigManager } from '../config/config-manager'
|
|||||||
import sql from 'mssql'
|
import sql from 'mssql'
|
||||||
import type { UserInfo } from '../../types/user.types'
|
import type { UserInfo } from '../../types/user.types'
|
||||||
import { createLogger, logError } from '../logger'
|
import { createLogger, logError } from '../logger'
|
||||||
import { serializeError } from '../logger/error-utils'
|
|
||||||
|
|
||||||
const log = createLogger('BipUsersDao')
|
const log = createLogger('BipUsersDao')
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ function App(): React.JSX.Element {
|
|||||||
// Track if current session is switched by Admin
|
// Track if current session is switched by Admin
|
||||||
const [isSwitchedByAdmin, setIsSwitchedByAdmin] = useState(false)
|
const [isSwitchedByAdmin, setIsSwitchedByAdmin] = useState(false)
|
||||||
|
|
||||||
|
// Ref for logout button (for focus restoration)
|
||||||
|
const logoutButtonRef = React.useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
// Navigation state
|
// Navigation state
|
||||||
const [currentPage, setCurrentPage] = useState<Page>('extractor') // Default to extractor for the new layout
|
const [currentPage, setCurrentPage] = useState<Page>('extractor') // Default to extractor for the new layout
|
||||||
|
|
||||||
@@ -251,6 +254,7 @@ function App(): React.JSX.Element {
|
|||||||
currentUsername={currentUser?.username || ''}
|
currentUsername={currentUser?.username || ''}
|
||||||
onSelectUser={handleUserSelect}
|
onSelectUser={handleUserSelect}
|
||||||
onCancel={handleUserSelectionCancel}
|
onCancel={handleUserSelectionCancel}
|
||||||
|
triggerRef={logoutButtonRef}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{errorMessage && <div className="error-toast">{errorMessage}</div>}
|
{errorMessage && <div className="error-toast">{errorMessage}</div>}
|
||||||
@@ -374,6 +378,7 @@ function App(): React.JSX.Element {
|
|||||||
</span>
|
</span>
|
||||||
{shouldShowLogout && (
|
{shouldShowLogout && (
|
||||||
<button
|
<button
|
||||||
|
ref={logoutButtonRef}
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="ml-2 text-slate-400 hover:text-red-400 transition-colors"
|
className="ml-2 text-slate-400 hover:text-red-400 transition-colors"
|
||||||
title="退出登录"
|
title="退出登录"
|
||||||
|
|||||||
@@ -7,10 +7,9 @@
|
|||||||
* - Error list (if any)
|
* - Error list (if any)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useRef } from 'react'
|
import React from 'react'
|
||||||
import { CheckCircle, XCircle, SkipForward, Package, Loader2 } from 'lucide-react'
|
import { CheckCircle, XCircle, SkipForward, Package, Loader2 } from 'lucide-react'
|
||||||
import FocusLock from 'react-focus-lock'
|
import { Modal } from './ui/Modal'
|
||||||
import { useDialogFocus } from '../hooks/useDialogFocus'
|
|
||||||
|
|
||||||
interface CleanerProgress {
|
interface CleanerProgress {
|
||||||
message: string
|
message: string
|
||||||
@@ -34,6 +33,7 @@ interface ExecutionReportDialogProps {
|
|||||||
isExecuting?: boolean
|
isExecuting?: boolean
|
||||||
progress?: CleanerProgress | null
|
progress?: CleanerProgress | null
|
||||||
startTime?: number | null
|
startTime?: number | null
|
||||||
|
triggerRef?: React.RefObject<HTMLElement | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
|
export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
|
||||||
@@ -46,21 +46,14 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
|
|||||||
dryRun = false,
|
dryRun = false,
|
||||||
isExecuting = false,
|
isExecuting = false,
|
||||||
progress = null,
|
progress = null,
|
||||||
startTime = null
|
startTime = null,
|
||||||
|
triggerRef
|
||||||
}) => {
|
}) => {
|
||||||
const dialogRef = useRef<HTMLDivElement>(null)
|
|
||||||
const [now, setNow] = React.useState(() => Date.now())
|
const [now, setNow] = React.useState(() => Date.now())
|
||||||
|
|
||||||
// Setup focus management with conditional escape key handling
|
|
||||||
const { focusLockProps } = useDialogFocus({
|
|
||||||
isOpen,
|
|
||||||
dialogRef,
|
|
||||||
onClose,
|
|
||||||
shouldCloseOnEscape: () => !isExecuting // Only close when NOT executing
|
|
||||||
})
|
|
||||||
|
|
||||||
const hasErrors = errors.length > 0
|
const hasErrors = errors.length > 0
|
||||||
const showProgress = isExecuting && progress
|
const showProgress = isExecuting && progress
|
||||||
|
const isProgressing = !!showProgress
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!showProgress || !startTime) return
|
if (!showProgress || !startTime) return
|
||||||
@@ -99,633 +92,208 @@ export const ExecutionReportDialog: React.FC<ExecutionReportDialogProps> = ({
|
|||||||
}
|
}
|
||||||
}, [showProgress, startTime, progress, now])
|
}, [showProgress, startTime, progress, now])
|
||||||
|
|
||||||
if (!isOpen) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FocusLock {...focusLockProps}>
|
<Modal
|
||||||
<div
|
isOpen={isOpen}
|
||||||
className="execution-report-overlay"
|
onClose={onClose}
|
||||||
onClick={showProgress ? undefined : onClose}
|
title={
|
||||||
role="dialog"
|
isProgressing
|
||||||
aria-modal="true"
|
? '正在执行清理...'
|
||||||
aria-labelledby={
|
: dryRun
|
||||||
showProgress ? 'execution-dialog-progress-title' : 'execution-dialog-report-title'
|
? '预览执行报告'
|
||||||
}
|
: hasErrors
|
||||||
>
|
? '执行完成 (有错误)'
|
||||||
<div
|
: '执行完成'
|
||||||
ref={dialogRef}
|
}
|
||||||
className="execution-report-dialog"
|
size={isProgressing ? 'lg' : 'md'}
|
||||||
onClick={(e) => e.stopPropagation()}
|
showCloseButton={!isExecuting}
|
||||||
style={{ width: showProgress ? '560px' : '480px' }}
|
triggerRef={triggerRef}
|
||||||
>
|
isAlertDialog={isProgressing}
|
||||||
{showProgress ? (
|
disableEscapeKey={isProgressing}
|
||||||
// Progress View
|
ariaDescribedBy={isProgressing ? 'execution-dialog-progress-desc' : undefined}
|
||||||
<div className="progress-header">
|
initialFocusSelector={!isProgressing ? '.btn-report-close' : undefined}
|
||||||
<div className="progress-icon-wrapper">
|
>
|
||||||
<Loader2 className="progress-icon spinning" />
|
{isProgressing ? (
|
||||||
</div>
|
// Progress View
|
||||||
<h2 id="execution-dialog-progress-title" className="progress-title">
|
<div className="text-center">
|
||||||
正在执行清理...
|
<div className="flex justify-center mb-4">
|
||||||
</h2>
|
<div className="relative">
|
||||||
<p className="progress-subtitle" aria-live="polite" aria-atomic="true">
|
<Loader2 className="w-12 h-12 text-blue-500 animate-spin" />
|
||||||
{progress?.message || '处理中...'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
// Result View
|
<p
|
||||||
<div className="report-header">
|
id="execution-dialog-progress-desc"
|
||||||
<div className="report-icon-wrapper">
|
className="text-sm text-gray-600 mb-4"
|
||||||
{dryRun ? (
|
aria-live="polite"
|
||||||
<Package className="report-icon preview" />
|
aria-atomic="true"
|
||||||
) : hasErrors ? (
|
>
|
||||||
<XCircle className="report-icon error" />
|
{progress?.message || '处理中...'}
|
||||||
) : (
|
</p>
|
||||||
<CheckCircle className="report-icon success" />
|
</div>
|
||||||
)}
|
) : (
|
||||||
|
// Result View
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
<div className="flex justify-center mb-4">
|
||||||
|
{dryRun ? (
|
||||||
|
<Package className="w-12 h-12 text-amber-500" />
|
||||||
|
) : hasErrors ? (
|
||||||
|
<XCircle className="w-12 h-12 text-red-500" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle className="w-12 h-12 text-green-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{dryRun
|
||||||
|
? '预览模式 - 未实际删除数据'
|
||||||
|
: hasErrors
|
||||||
|
? '部分操作未能完成,请查看下方错误信息'
|
||||||
|
: '所有操作已成功完成'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isProgressing ? (
|
||||||
|
// Progress View Content
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="progress-bar-container w-full h-6 bg-gradient-to-r from-blue-50 to-sky-100 rounded-full overflow-hidden border border-blue-200 relative">
|
||||||
|
<div
|
||||||
|
className="progress-bar-fill h-full bg-gradient-to-r from-blue-500 to-blue-700 transition-all duration-300 relative overflow-hidden"
|
||||||
|
style={{ width: `${progress?.progress || 0}%` }}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-shimmer" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-around py-4 bg-gray-50 rounded-lg border border-gray-200">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className="text-xs text-gray-600">订单进度</span>
|
||||||
|
<span className="text-lg font-semibold text-blue-600">
|
||||||
|
{Math.min(progress!.currentOrderIndex, progress!.totalOrders)} /{' '}
|
||||||
|
{progress!.totalOrders}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{progress!.totalMaterialsInOrder > 0 && (
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className="text-xs text-gray-600">物料进度</span>
|
||||||
|
<span className="text-lg font-semibold text-blue-600">
|
||||||
|
{progress!.currentMaterialIndex} / {progress!.totalMaterialsInOrder}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 id="execution-dialog-report-title" className="report-title">
|
)}
|
||||||
{dryRun ? '预览执行报告' : hasErrors ? '执行完成 (有错误)' : '执行完成'}
|
<div className="flex flex-col items-center">
|
||||||
</h2>
|
<span className="text-xs text-gray-600">总进度</span>
|
||||||
<p className="report-subtitle">
|
<span className="text-lg font-semibold text-blue-600">
|
||||||
{dryRun
|
{Math.round(progress?.progress || 0)}%
|
||||||
? '预览模式 - 未实际删除数据'
|
</span>
|
||||||
: hasErrors
|
</div>
|
||||||
? '部分操作未能完成,请查看下方错误信息'
|
</div>
|
||||||
: '所有操作已成功完成'}
|
|
||||||
</p>
|
{progress?.currentOrderNumber && (
|
||||||
|
<div className="flex items-center justify-center gap-2 p-3 bg-blue-50 rounded-lg border border-blue-200">
|
||||||
|
<Package size={14} className="text-blue-600" />
|
||||||
|
<span className="text-sm text-gray-600">当前订单:</span>
|
||||||
|
<span className="text-sm font-medium text-gray-900 font-mono">
|
||||||
|
{progress.currentOrderNumber}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="report-body">
|
{estimatedTime && (
|
||||||
{showProgress ? (
|
<div className="flex flex-col items-center gap-2 p-3 bg-green-50 rounded-lg border border-green-200">
|
||||||
// Progress View Content
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<div className="progress-content">
|
<span className="text-gray-600">预估还要</span>
|
||||||
<div className="progress-bar-container">
|
<span className="font-semibold text-green-600">
|
||||||
<div
|
{estimatedTime.remainingMinutes} 分钟
|
||||||
className="progress-bar-fill"
|
</span>
|
||||||
style={{ width: `${progress?.progress || 0}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="progress-stats">
|
|
||||||
<div className="progress-stat-item">
|
|
||||||
<span className="stat-label">订单进度</span>
|
|
||||||
<span className="stat-value">
|
|
||||||
{Math.min(progress!.currentOrderIndex, progress!.totalOrders)} /{' '}
|
|
||||||
{progress!.totalOrders}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{progress!.totalMaterialsInOrder > 0 && (
|
|
||||||
<div className="progress-stat-item">
|
|
||||||
<span className="stat-label">物料进度</span>
|
|
||||||
<span className="stat-value">
|
|
||||||
{progress!.currentMaterialIndex} / {progress!.totalMaterialsInOrder}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="progress-stat-item">
|
|
||||||
<span className="stat-label">总进度</span>
|
|
||||||
<span className="stat-value">{Math.round(progress?.progress || 0)}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{progress?.currentOrderNumber && (
|
|
||||||
<div className="current-order-info">
|
|
||||||
<Package size={14} className="order-icon" />
|
|
||||||
<span className="order-label">当前订单:</span>
|
|
||||||
<span className="order-number">{progress.currentOrderNumber}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{estimatedTime && (
|
|
||||||
<div className="estimated-time-info">
|
|
||||||
<div className="estimated-time-item">
|
|
||||||
<span className="time-label">预估还要</span>
|
|
||||||
<span className="time-value">{estimatedTime.remainingMinutes} 分钟</span>
|
|
||||||
</div>
|
|
||||||
<div className="estimated-time-item">
|
|
||||||
<span className="time-label">将会在</span>
|
|
||||||
<span className="time-value">{estimatedTime.formattedTime}</span>
|
|
||||||
<span className="time-label">执行完毕</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<div className="flex items-center gap-2 text-sm">
|
||||||
// Result View Content
|
<span className="text-gray-600">将会在</span>
|
||||||
<>
|
<span className="font-semibold text-green-600">{estimatedTime.formattedTime}</span>
|
||||||
<div className="stats-grid">
|
<span className="text-gray-600">执行完毕</span>
|
||||||
<div className="stat-card">
|
</div>
|
||||||
<div className="stat-icon orders">
|
</div>
|
||||||
<Package size={20} />
|
)}
|
||||||
</div>
|
|
||||||
<div className="stat-content">
|
|
||||||
<div className="stat-label">处理订单</div>
|
|
||||||
<div className="stat-value">{ordersProcessed}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-icon deleted">
|
|
||||||
<CheckCircle size={20} />
|
|
||||||
</div>
|
|
||||||
<div className="stat-content">
|
|
||||||
<div className="stat-label">{dryRun ? '拟删除物料' : '删除物料'}</div>
|
|
||||||
<div className="stat-value">{materialsDeleted}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-icon skipped">
|
|
||||||
<SkipForward size={20} />
|
|
||||||
</div>
|
|
||||||
<div className="stat-content">
|
|
||||||
<div className="stat-label">跳过物料</div>
|
|
||||||
<div className="stat-value">{materialsSkipped}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasErrors && (
|
|
||||||
<div className="stat-card">
|
|
||||||
<div className="stat-icon errors">
|
|
||||||
<XCircle size={20} />
|
|
||||||
</div>
|
|
||||||
<div className="stat-content">
|
|
||||||
<div className="stat-label">错误数量</div>
|
|
||||||
<div className="stat-value error">{errors.length}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasErrors && (
|
|
||||||
<div className="errors-section">
|
|
||||||
<div className="errors-title">错误详情</div>
|
|
||||||
<div className="errors-list">
|
|
||||||
{errors.map((error, index) => (
|
|
||||||
<div key={index} className="error-item">
|
|
||||||
<XCircle size={14} className="error-icon" />
|
|
||||||
<span className="error-text">{error}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!hasErrors && !dryRun && (
|
|
||||||
<div className="success-message">
|
|
||||||
<CheckCircle size={16} className="success-icon" />
|
|
||||||
<span>操作已成功完成,数据已同步到 ERP 系统</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!hasErrors && dryRun && (
|
|
||||||
<div className="preview-message">
|
|
||||||
<Package size={16} className="preview-icon" />
|
|
||||||
<span>预览模式结束,数据未实际修改。确认无误后可正式执行。</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="report-footer">
|
|
||||||
{!showProgress && (
|
|
||||||
<button className="btn-report-close" onClick={onClose}>
|
|
||||||
关闭
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>{`
|
|
||||||
.execution-report-overlay {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 9999;
|
|
||||||
animation: fadeIn 0.2s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideDown {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-20px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
from {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.execution-report-dialog {
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
|
||||||
max-width: 90vw;
|
|
||||||
animation: slideDown 0.2s ease-out;
|
|
||||||
transition: width 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-header,
|
|
||||||
.report-header {
|
|
||||||
text-align: center;
|
|
||||||
padding: 24px 24px 16px 24px;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-icon-wrapper,
|
|
||||||
.report-icon-wrapper {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-icon {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
color: #1890ff;
|
|
||||||
animation: spin 1.5s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-title {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #333;
|
|
||||||
margin: 0 0 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-subtitle {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #666;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.report-icon {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.report-icon.success {
|
|
||||||
color: #52c41a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.report-icon.error {
|
|
||||||
color: #ff4d4f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.report-icon.preview {
|
|
||||||
color: #faad14;
|
|
||||||
}
|
|
||||||
|
|
||||||
.report-title {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #333;
|
|
||||||
margin: 0 0 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.report-subtitle {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #999;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.report-body {
|
|
||||||
padding: 20px 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Progress View Styles */
|
|
||||||
.progress-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar-container {
|
|
||||||
width: 100%;
|
|
||||||
height: 24px;
|
|
||||||
background: linear-gradient(90deg, #e6f7ff 0%, #bae7ff 100%);
|
|
||||||
border-radius: 12px;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid #91d5ff;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar-fill {
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, #1890ff 0%, #096dd9 100%);
|
|
||||||
border-radius: 12px;
|
|
||||||
transition: width 0.3s ease;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar-fill::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
transparent 0%,
|
|
||||||
rgba(255, 255, 255, 0.3) 50%,
|
|
||||||
transparent 100%
|
|
||||||
);
|
|
||||||
animation: shimmer 1.5s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes shimmer {
|
|
||||||
0% {
|
|
||||||
transform: translateX(-100%);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: translateX(100%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-stats {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-around;
|
|
||||||
padding: 16px;
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #e8e8e8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-stat-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-stat-item .stat-label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-stat-item .stat-value {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1890ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.current-order-info {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px;
|
|
||||||
background: #f0f5ff;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid #d6e4ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.current-order-info .order-icon {
|
|
||||||
color: #1890ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.current-order-info .order-label {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.current-order-info .order-number {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #333;
|
|
||||||
font-family: monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimated-time-info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px;
|
|
||||||
background: #f6ffed;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid #b7eb8f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimated-time-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimated-time-item .time-label {
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimated-time-item .time-value {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #52c41a;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Result View Styles */
|
|
||||||
.stats-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card {
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 12px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
border: 1px solid #e8e8e8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card:nth-child(4) {
|
|
||||||
grid-column: span 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-icon {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border-radius: 6px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-icon.orders {
|
|
||||||
background: #e6f7ff;
|
|
||||||
color: #1890ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-icon.deleted {
|
|
||||||
background: #f6ffed;
|
|
||||||
color: #52c41a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-icon.skipped {
|
|
||||||
background: #fff7e6;
|
|
||||||
color: #faad14;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-icon.errors {
|
|
||||||
background: #fff1f0;
|
|
||||||
color: #ff4d4f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-content {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #666;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #333;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value.error {
|
|
||||||
color: #ff4d4f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.errors-section {
|
|
||||||
margin-top: 16px;
|
|
||||||
padding-top: 16px;
|
|
||||||
border-top: 1px solid #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.errors-title {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #ff4d4f;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.errors-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
max-height: 150px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
background: #fff1f0;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid #ffccc7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-icon {
|
|
||||||
color: #ff4d4f;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-top: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-text {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #333;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.success-message,
|
|
||||||
.preview-message {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.success-message {
|
|
||||||
background: #f6ffed;
|
|
||||||
color: #52c41a;
|
|
||||||
border: 1px solid #b7eb8f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-message {
|
|
||||||
background: #fff7e6;
|
|
||||||
color: #faad14;
|
|
||||||
border: 1px solid #ffd591;
|
|
||||||
}
|
|
||||||
|
|
||||||
.success-icon,
|
|
||||||
.preview-icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.report-footer {
|
|
||||||
padding: 16px 24px;
|
|
||||||
border-top: 1px solid #f0f0f0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-report-close {
|
|
||||||
background: #1890ff;
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 10px 32px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-report-close:hover {
|
|
||||||
background: #40a9ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-report-close:active {
|
|
||||||
background: #096dd9;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
</FocusLock>
|
// Result View Content
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3 flex items-center gap-3 border border-gray-200">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-blue-50 flex items-center justify-center flex-shrink-0">
|
||||||
|
<Package size={20} className="text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-xs text-gray-600">处理订单</div>
|
||||||
|
<div className="text-xl font-semibold text-gray-900">{ordersProcessed}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3 flex items-center gap-3 border border-gray-200">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-green-50 flex items-center justify-center flex-shrink-0">
|
||||||
|
<CheckCircle size={20} className="text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-xs text-gray-600">{dryRun ? '拟删除物料' : '删除物料'}</div>
|
||||||
|
<div className="text-xl font-semibold text-gray-900">{materialsDeleted}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3 flex items-center gap-3 border border-gray-200">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-amber-50 flex items-center justify-center flex-shrink-0">
|
||||||
|
<SkipForward size={20} className="text-amber-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-xs text-gray-600">跳过物料</div>
|
||||||
|
<div className="text-xl font-semibold text-gray-900">{materialsSkipped}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasErrors && (
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3 flex items-center gap-3 border border-gray-200 col-span-3">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-red-50 flex items-center justify-center flex-shrink-0">
|
||||||
|
<XCircle size={20} className="text-red-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-xs text-gray-600">错误数量</div>
|
||||||
|
<div className="text-xl font-semibold text-red-600">{errors.length}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasErrors && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||||
|
<div className="text-sm font-semibold text-red-600 mb-2">错误详情</div>
|
||||||
|
<div className="flex flex-col gap-2 max-h-40 overflow-y-auto">
|
||||||
|
{errors.map((error, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-start gap-2 p-2 bg-red-50 rounded border border-red-200"
|
||||||
|
>
|
||||||
|
<XCircle size={14} className="text-red-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<span className="text-sm text-gray-900 break-words">{error}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasErrors && !dryRun && (
|
||||||
|
<div className="flex items-center justify-center gap-2 p-3 bg-green-50 rounded-lg border border-green-200 text-green-700 text-sm">
|
||||||
|
<CheckCircle size={16} className="flex-shrink-0" />
|
||||||
|
<span>操作已成功完成,数据已同步到 ERP 系统</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasErrors && dryRun && (
|
||||||
|
<div className="flex items-center justify-center gap-2 p-3 bg-amber-50 rounded-lg border border-amber-200 text-amber-700 text-sm">
|
||||||
|
<Package size={16} className="flex-shrink-0" />
|
||||||
|
<span>预览模式结束,数据未实际修改。确认无误后可正式执行。</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useRef } from 'react'
|
import React, { useState, useRef } from 'react'
|
||||||
import FocusLock from 'react-focus-lock'
|
import { Modal } from './ui/Modal'
|
||||||
import { useDialogFocus } from '../hooks/useDialogFocus'
|
|
||||||
|
|
||||||
interface LoginDialogProps {
|
interface LoginDialogProps {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
@@ -31,26 +30,15 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({
|
|||||||
const [isLoggingIn, setIsLoggingIn] = useState(false)
|
const [isLoggingIn, setIsLoggingIn] = useState(false)
|
||||||
const [errorMessage, setErrorMessage] = useState('')
|
const [errorMessage, setErrorMessage] = useState('')
|
||||||
const usernameInputRef = useRef<HTMLInputElement>(null)
|
const usernameInputRef = useRef<HTMLInputElement>(null)
|
||||||
const dialogRef = useRef<HTMLDivElement>(null)
|
|
||||||
const errorRef = useRef<HTMLDivElement>(null)
|
const errorRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// Setup focus management with useDialogFocus hook
|
|
||||||
const { focusLockProps } = useDialogFocus({
|
|
||||||
isOpen,
|
|
||||||
dialogRef,
|
|
||||||
onClose: onCancel,
|
|
||||||
initialFocusSelector: 'input[type="text"]' // Focus username input initially
|
|
||||||
})
|
|
||||||
|
|
||||||
// Display error message with aria-live
|
// Display error message with aria-live
|
||||||
const showError = (message: string): void => {
|
const showError = (message: string): void => {
|
||||||
setErrorMessage(message)
|
setErrorMessage(message)
|
||||||
// Also call the original onError callback for backward compatibility
|
|
||||||
onError(message)
|
onError(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLogin = async (): Promise<void> => {
|
const handleLogin = async (): Promise<void> => {
|
||||||
// Clear error message when attempting login
|
|
||||||
setErrorMessage('')
|
setErrorMessage('')
|
||||||
|
|
||||||
if (!username.trim()) {
|
if (!username.trim()) {
|
||||||
@@ -80,89 +68,81 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isOpen) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FocusLock {...focusLockProps}>
|
<Modal
|
||||||
<div
|
isOpen={isOpen}
|
||||||
className="login-overlay"
|
onClose={onCancel}
|
||||||
onKeyDown={handleKeyDown}
|
title="请登录"
|
||||||
role="dialog"
|
size="md"
|
||||||
aria-modal="true"
|
showCloseButton={true}
|
||||||
aria-labelledby="login-dialog-title"
|
initialFocusSelector='input[type="text"]'
|
||||||
>
|
ariaDescribedBy={errorMessage ? 'login-dialog-error' : undefined}
|
||||||
<div className="login-dialog" ref={dialogRef}>
|
>
|
||||||
<div className="login-header">
|
<div onKeyDown={handleKeyDown}>
|
||||||
<h2 id="login-dialog-title" className="login-title">
|
{/* Error message area with aria-live for screen readers */}
|
||||||
请登录
|
{errorMessage && (
|
||||||
</h2>
|
<div
|
||||||
<p className="computer-name">当前计算机:{computerName}</p>
|
ref={errorRef}
|
||||||
|
id="login-dialog-error"
|
||||||
|
className="mb-4 p-3 bg-red-50 border border-red-200 rounded-md text-red-700 text-sm"
|
||||||
|
role="alert"
|
||||||
|
aria-live="polite"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{errorMessage}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Error message area with aria-live for screen readers */}
|
<div className="space-y-4">
|
||||||
{errorMessage && (
|
<div className="text-sm text-gray-600">当前计算机:{computerName}</div>
|
||||||
<div
|
|
||||||
ref={errorRef}
|
|
||||||
className="error-message mb-4 p-3 bg-red-50 border border-red-200 rounded-md text-red-700 text-sm"
|
|
||||||
role="alert"
|
|
||||||
aria-live="polite"
|
|
||||||
tabIndex={-1}
|
|
||||||
>
|
|
||||||
{errorMessage}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="login-body">
|
<div>
|
||||||
<div className="form-group">
|
<label className="block text-sm text-slate-700 mb-1">用户名:</label>
|
||||||
<label className="form-label text-slate-700">用户名:</label>
|
<input
|
||||||
<input
|
ref={usernameInputRef}
|
||||||
ref={usernameInputRef}
|
type="text"
|
||||||
type="text"
|
className="border border-slate-300 rounded-md p-2 w-full text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
className="form-input border border-slate-300 rounded-md p-2 w-full text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
value={username}
|
||||||
value={username}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
placeholder="请输入用户名"
|
||||||
placeholder="请输入用户名"
|
|
||||||
disabled={isLoggingIn}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group mt-4">
|
|
||||||
<label className="form-label text-slate-700">密码:</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
className="form-input border border-slate-300 rounded-md p-2 w-full text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
placeholder="请输入密码"
|
|
||||||
disabled={isLoggingIn}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="login-footer mt-6 flex justify-end gap-3">
|
|
||||||
<button
|
|
||||||
className="btn btn-secondary px-4 py-2 rounded-md bg-slate-100 hover:bg-slate-200 text-slate-700 transition-colors"
|
|
||||||
onClick={onCancel}
|
|
||||||
disabled={isLoggingIn}
|
disabled={isLoggingIn}
|
||||||
>
|
/>
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-primary px-4 py-2 rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50"
|
|
||||||
onClick={handleLogin}
|
|
||||||
disabled={isLoggingIn}
|
|
||||||
>
|
|
||||||
{isLoggingIn ? '登录中...' : '登录'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="login-version">v1.0</div>
|
<div>
|
||||||
|
<label className="block text-sm text-slate-700 mb-1">密码:</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="border border-slate-300 rounded-md p-2 w-full text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="请输入密码"
|
||||||
|
disabled={isLoggingIn}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 rounded-md bg-slate-100 hover:bg-slate-200 text-slate-700 transition-colors"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isLoggingIn}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 rounded-md bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50"
|
||||||
|
onClick={handleLogin}
|
||||||
|
disabled={isLoggingIn}
|
||||||
|
>
|
||||||
|
{isLoggingIn ? '登录中...' : '登录'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 text-xs text-gray-400 text-right">v1.0</div>
|
||||||
</div>
|
</div>
|
||||||
</FocusLock>
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default LoginDialog
|
export default LoginDialog
|
||||||
|
|
||||||
// Styles are now handled by Tailwind classes in the component
|
|
||||||
|
|||||||
@@ -27,13 +27,15 @@ interface MaterialTypeManagementDialogProps {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
currentUsername: string
|
currentUsername: string
|
||||||
|
triggerRef?: React.RefObject<HTMLButtonElement | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialogProps> = ({
|
export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialogProps> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
currentUsername
|
currentUsername,
|
||||||
|
triggerRef
|
||||||
}) => {
|
}) => {
|
||||||
const [rows, setRows] = useState<RowState[]>([])
|
const [rows, setRows] = useState<RowState[]>([])
|
||||||
const [managers, setManagers] = useState<string[]>([])
|
const [managers, setManagers] = useState<string[]>([])
|
||||||
@@ -304,7 +306,13 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={handleClose} title="物料类型管理" size="2xl">
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={handleClose}
|
||||||
|
title="物料类型管理"
|
||||||
|
size="2xl"
|
||||||
|
triggerRef={triggerRef}
|
||||||
|
>
|
||||||
<div onKeyDown={handleKeyDown}>
|
<div onKeyDown={handleKeyDown}>
|
||||||
{/* Manager filter (admin only) */}
|
{/* Manager filter (admin only) */}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
|
|||||||
@@ -8,8 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
import FocusLock from 'react-focus-lock'
|
import { Modal } from './ui/Modal'
|
||||||
import { useDialogFocus } from '../hooks/useDialogFocus'
|
|
||||||
|
|
||||||
export interface UserInfo {
|
export interface UserInfo {
|
||||||
id: number
|
id: number
|
||||||
@@ -24,6 +23,7 @@ interface UserSelectionDialogProps {
|
|||||||
currentUsername: string
|
currentUsername: string
|
||||||
onSelectUser: (user: UserInfo) => void
|
onSelectUser: (user: UserInfo) => void
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
|
triggerRef?: React.RefObject<HTMLElement | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UserSelectionDialog: React.FC<UserSelectionDialogProps> = ({
|
export const UserSelectionDialog: React.FC<UserSelectionDialogProps> = ({
|
||||||
@@ -31,18 +31,12 @@ export const UserSelectionDialog: React.FC<UserSelectionDialogProps> = ({
|
|||||||
users,
|
users,
|
||||||
currentUsername,
|
currentUsername,
|
||||||
onSelectUser,
|
onSelectUser,
|
||||||
onCancel
|
onCancel,
|
||||||
|
triggerRef
|
||||||
}) => {
|
}) => {
|
||||||
const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
|
const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
|
||||||
const dialogRef = useRef<HTMLDivElement>(null)
|
const dialogRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const { focusLockProps } = useDialogFocus({
|
|
||||||
isOpen,
|
|
||||||
dialogRef,
|
|
||||||
onClose: onCancel,
|
|
||||||
initialFocusSelector: users.length > 0 ? '.user-item:first-child' : undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
// Reset selection when dialog opens
|
// Reset selection when dialog opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
@@ -65,238 +59,85 @@ export const UserSelectionDialog: React.FC<UserSelectionDialogProps> = ({
|
|||||||
onSelectUser(user)
|
onSelectUser(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userTypeStyles: Record<string, string> = {
|
||||||
|
Admin: 'bg-amber-50 text-amber-600',
|
||||||
|
User: 'bg-blue-50 text-blue-600',
|
||||||
|
Guest: 'bg-gray-100 text-gray-600'
|
||||||
|
}
|
||||||
|
|
||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FocusLock {...focusLockProps}>
|
<Modal
|
||||||
<div
|
isOpen={isOpen}
|
||||||
className="user-selection-overlay"
|
onClose={onCancel}
|
||||||
role="dialog"
|
title="选择用户"
|
||||||
aria-modal="true"
|
size="md"
|
||||||
aria-labelledby="user-selection-dialog-title"
|
triggerRef={triggerRef}
|
||||||
>
|
initialFocusSelector={users.length > 0 ? '.user-item:first-child' : undefined}
|
||||||
<div className="user-selection-dialog" ref={dialogRef}>
|
ariaDescribedBy="user-selection-description"
|
||||||
<div className="user-selection-header">
|
>
|
||||||
<h2 className="user-selection-title" id="user-selection-dialog-title">
|
<div ref={dialogRef} className="max-h-[60vh] flex flex-col">
|
||||||
选择用户
|
<div className="text-sm text-gray-600 mb-4">当前登录:{currentUsername}</div>
|
||||||
</h2>
|
<p id="user-selection-description" className="sr-only">
|
||||||
<p className="user-selection-hint">当前登录:{currentUsername}</p>
|
从列表中选择一个用户,双击可直接确认选择
|
||||||
</div>
|
</p>
|
||||||
|
|
||||||
<div className="user-selection-body">
|
<div className="flex-1 overflow-y-auto mb-4">
|
||||||
<div className="user-list">
|
<div className="flex flex-col gap-2">
|
||||||
{users.map((user) => (
|
{users.map((user) => (
|
||||||
<div
|
<div
|
||||||
key={user.id}
|
key={user.id}
|
||||||
className={`user-item ${selectedUserId === user.id ? 'selected' : ''}`}
|
className={`user-item p-3 border border-gray-200 rounded-lg cursor-pointer transition-all hover:border-blue-500 hover:bg-green-50 ${
|
||||||
onClick={() => setSelectedUserId(user.id)}
|
selectedUserId === user.id ? 'border-blue-500 bg-blue-50' : ''
|
||||||
onDoubleClick={() => handleDoubleClick(user)}
|
}`}
|
||||||
>
|
onClick={() => setSelectedUserId(user.id)}
|
||||||
<div className="user-item-content">
|
onDoubleClick={() => handleDoubleClick(user)}
|
||||||
<div className="user-item-row">
|
tabIndex={0}
|
||||||
<span className="user-name">{user.username}</span>
|
onKeyDown={(e) => {
|
||||||
<span className={`user-type user-type-${user.userType.toLowerCase()}`}>
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
{user.userType}
|
setSelectedUserId(user.id)
|
||||||
</span>
|
}
|
||||||
</div>
|
}}
|
||||||
{user.createTime && (
|
>
|
||||||
<div className="user-item-row">
|
<div className="flex items-center justify-between">
|
||||||
<span className="user-create-time">
|
<span className="text-sm font-medium text-gray-900">{user.username}</span>
|
||||||
创建于:{new Date(user.createTime).toLocaleString('zh-CN')}
|
<span
|
||||||
</span>
|
className={`text-xs px-2 py-1 rounded font-medium ${userTypeStyles[user.userType]}`}
|
||||||
</div>
|
>
|
||||||
)}
|
{user.userType}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
{user.createTime && (
|
||||||
</div>
|
<div className="mt-1 text-xs text-gray-500">
|
||||||
|
创建于:{new Date(user.createTime).toLocaleString('zh-CN')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="user-selection-footer">
|
<div className="border-t border-gray-200 pt-4">
|
||||||
|
<div className="flex justify-center gap-3">
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="px-6 py-2 rounded-md bg-blue-600 hover:bg-blue-700 text-white font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
disabled={selectedUserId === null}
|
disabled={selectedUserId === null}
|
||||||
>
|
>
|
||||||
确认
|
确认
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-secondary" onClick={onCancel}>
|
<button
|
||||||
|
className="px-6 py-2 rounded-md bg-gray-100 hover:bg-gray-200 text-gray-700 font-medium transition-colors"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-center text-xs text-gray-500 mt-3">双击用户可直接选择</div>
|
||||||
<div className="user-selection-hint-footer">双击用户可直接选择</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Modal>
|
||||||
<style>{`
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 9999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-selection-dialog {
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
|
||||||
width: 450px;
|
|
||||||
max-height: 80vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-selection-header {
|
|
||||||
padding: 20px 24px;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-selection-title {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #333;
|
|
||||||
margin: 0 0 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-selection-hint {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #666;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-selection-body {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 16px 24px;
|
|
||||||
min-height: 200px;
|
|
||||||
max-height: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-item {
|
|
||||||
padding: 12px 16px;
|
|
||||||
border: 1px solid #e8e8e8;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-item:hover {
|
|
||||||
border-color: #1890ff;
|
|
||||||
background: #f6ffed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-item.selected {
|
|
||||||
border-color: #1890ff;
|
|
||||||
background: #e6f7ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-item-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-item-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-name {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-type {
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-type-admin {
|
|
||||||
background: #fff7e6;
|
|
||||||
color: #fa8c16;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-type-user {
|
|
||||||
background: #e6f7ff;
|
|
||||||
color: #1890ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-type-guest {
|
|
||||||
background: #f5f5f5;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-create-time {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-selection-footer {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 16px 24px;
|
|
||||||
border-top: 1px solid #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
padding: 10px 24px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: #1890ff;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
|
||||||
background: #40a9ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: #f5f5f5;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover:not(:disabled) {
|
|
||||||
background: #e8e8e8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-selection-hint-footer {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-top: 1px solid #f0f0f0;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</FocusLock>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ interface ModalProps {
|
|||||||
triggerRef?: React.RefObject<HTMLElement | null>
|
triggerRef?: React.RefObject<HTMLElement | null>
|
||||||
/** ID of the title element (for aria-labelledby) */
|
/** ID of the title element (for aria-labelledby) */
|
||||||
titleId?: string
|
titleId?: string
|
||||||
|
/** Selector for the element to focus initially inside the modal */
|
||||||
|
initialFocusSelector?: string
|
||||||
|
/** ID of the element that describes the modal (for aria-describedby) */
|
||||||
|
ariaDescribedBy?: string
|
||||||
|
/** Whether this is an alert dialog (role="alertdialog" instead of "dialog") */
|
||||||
|
isAlertDialog?: boolean
|
||||||
|
/** Whether to disable escape key handling (e.g., during execution) */
|
||||||
|
disableEscapeKey?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const sizeStyles: Record<string, string> = {
|
const sizeStyles: Record<string, string> = {
|
||||||
@@ -39,7 +47,11 @@ export function Modal({
|
|||||||
size = 'md',
|
size = 'md',
|
||||||
showCloseButton = true,
|
showCloseButton = true,
|
||||||
triggerRef,
|
triggerRef,
|
||||||
titleId
|
titleId,
|
||||||
|
initialFocusSelector,
|
||||||
|
ariaDescribedBy,
|
||||||
|
isAlertDialog = false,
|
||||||
|
disableEscapeKey = false
|
||||||
}: ModalProps): React.JSX.Element | null {
|
}: ModalProps): React.JSX.Element | null {
|
||||||
const dialogRef = useRef<HTMLDivElement>(null)
|
const dialogRef = useRef<HTMLDivElement>(null)
|
||||||
const [generatedId] = useState(
|
const [generatedId] = useState(
|
||||||
@@ -56,18 +68,27 @@ export function Modal({
|
|||||||
isOpen,
|
isOpen,
|
||||||
dialogRef,
|
dialogRef,
|
||||||
onClose,
|
onClose,
|
||||||
triggerRef: triggerRef || undefined
|
triggerRef,
|
||||||
|
initialFocusSelector,
|
||||||
|
shouldCloseOnEscape: !disableEscapeKey
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!isOpen) return null
|
if (!isOpen) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FocusLock {...focusLockProps}>
|
<FocusLock {...focusLockProps}>
|
||||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
<div
|
||||||
|
className="fixed inset-0 z-50 overflow-y-auto"
|
||||||
|
role={isAlertDialog ? 'alertdialog' : 'dialog'}
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={generatedTitleId}
|
||||||
|
aria-describedby={ariaDescribedBy}
|
||||||
|
>
|
||||||
{/* Backdrop */}
|
{/* Backdrop */}
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
|
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Modal container */}
|
{/* Modal container */}
|
||||||
@@ -75,9 +96,6 @@ export function Modal({
|
|||||||
<div
|
<div
|
||||||
ref={dialogRef}
|
ref={dialogRef}
|
||||||
className={`relative w-full ${sizeStyles[size]} bg-white rounded-lg shadow-xl transform transition-all`}
|
className={`relative w-full ${sizeStyles[size]} bg-white rounded-lg shadow-xl transform transition-all`}
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby={generatedTitleId}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -92,6 +110,7 @@ export function Modal({
|
|||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-1 text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
|
className="p-1 text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
|
||||||
|
aria-label="关闭对话框"
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -166,21 +166,29 @@ export function useDialogFocus(options: UseDialogFocusOptions): UseDialogFocusRe
|
|||||||
if (initialFocusSelector) {
|
if (initialFocusSelector) {
|
||||||
const focusElement = dialogElement.querySelector(initialFocusSelector) as HTMLElement
|
const focusElement = dialogElement.querySelector(initialFocusSelector) as HTMLElement
|
||||||
if (focusElement && typeof focusElement.focus === 'function') {
|
if (focusElement && typeof focusElement.focus === 'function') {
|
||||||
// Delay focus to ensure DOM is ready
|
// Check if element is visible and focusable
|
||||||
requestAnimationFrame(() => {
|
const style = window.getComputedStyle(focusElement)
|
||||||
focusElement.focus()
|
if (style.display !== 'none' && style.visibility !== 'hidden') {
|
||||||
})
|
requestAnimationFrame(() => {
|
||||||
return
|
focusElement.focus({ preventScroll: true })
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Fallback: element found but not visible, log warning and try default
|
||||||
|
console.warn(`Focus element found but not visible: ${initialFocusSelector}`)
|
||||||
|
} else {
|
||||||
|
// Fallback: element not found, log warning and try default
|
||||||
|
console.warn(`Focus element not found for selector: ${initialFocusSelector}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, focus the first interactive element
|
// Otherwise, focus the first interactive element
|
||||||
const focusableSelectors = [
|
const focusableSelectors = [
|
||||||
'button:not([disabled])',
|
'button:not([disabled]):not([tabindex="-1"])',
|
||||||
'a[href]',
|
'a[href]',
|
||||||
'input:not([disabled])',
|
'input:not([disabled]):not([tabindex="-1"])',
|
||||||
'select:not([disabled])',
|
'select:not([disabled]):not([tabindex="-1"])',
|
||||||
'textarea:not([disabled])',
|
'textarea:not([disabled]):not([tabindex="-1"])',
|
||||||
'[tabindex]:not([tabindex="-1"])'
|
'[tabindex]:not([tabindex="-1"])'
|
||||||
]
|
]
|
||||||
const firstFocusable = dialogElement.querySelector(
|
const firstFocusable = dialogElement.querySelector(
|
||||||
@@ -188,7 +196,7 @@ export function useDialogFocus(options: UseDialogFocusOptions): UseDialogFocusRe
|
|||||||
) as HTMLElement
|
) as HTMLElement
|
||||||
if (firstFocusable && typeof firstFocusable.focus === 'function') {
|
if (firstFocusable && typeof firstFocusable.focus === 'function') {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
firstFocusable.focus()
|
firstFocusable.focus({ preventScroll: true })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,16 +211,101 @@ export function useDialogFocus(options: UseDialogFocusOptions): UseDialogFocusRe
|
|||||||
|
|
||||||
const restoreFocus = (): void => {
|
const restoreFocus = (): void => {
|
||||||
const triggerElement = triggerRef.current
|
const triggerElement = triggerRef.current
|
||||||
if (triggerElement && typeof triggerElement.focus === 'function') {
|
|
||||||
// Delay to ensure dialog is fully unmounted
|
// Check if element still exists in DOM
|
||||||
requestAnimationFrame(() => {
|
if (!triggerElement || !document.contains(triggerElement)) {
|
||||||
triggerElement.focus()
|
if (import.meta.env.DEV) {
|
||||||
})
|
console.warn('[useDialogFocus] Trigger element not found in DOM, cannot restore focus')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if element has a focus method
|
||||||
|
if (typeof triggerElement.focus !== 'function') {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.warn('[useDialogFocus] Trigger element does not have a focus method')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if element is visible (not display: none)
|
||||||
|
const style = window.getComputedStyle(triggerElement)
|
||||||
|
if (style.display === 'none') {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.warn('[useDialogFocus] Trigger element is display: none, cannot restore focus')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style.visibility === 'hidden') {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.warn('[useDialogFocus] Trigger element is visibility: hidden, cannot restore focus')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if element is disabled
|
||||||
|
if (triggerElement instanceof HTMLButtonElement && triggerElement.disabled) {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.warn('[useDialogFocus] Trigger element is disabled, cannot restore focus')
|
||||||
|
}
|
||||||
|
// Try to find nearest enabled ancestor or fallback to body
|
||||||
|
const focusableParent = findNearestFocusableElement(triggerElement)
|
||||||
|
if (focusableParent) {
|
||||||
|
focusableParent.focus({ preventScroll: true })
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.info('[useDialogFocus] Restored focus to nearest focusable ancestor')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// All checks passed, restore focus
|
||||||
|
try {
|
||||||
|
triggerElement.focus({ preventScroll: true })
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.info('[useDialogFocus] Successfully restored focus to trigger element')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.error('[useDialogFocus] Error restoring focus:', error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for next tick to ensure dialog is closed
|
// Helper function to find nearest focusable ancestor
|
||||||
setTimeout(restoreFocus, 0)
|
const findNearestFocusableElement = (element: HTMLElement): HTMLElement | null => {
|
||||||
|
let parent = element.parentElement
|
||||||
|
const focusableSelectors = [
|
||||||
|
'button:not([disabled]):not([tabindex="-1"])',
|
||||||
|
'a[href]',
|
||||||
|
'input:not([disabled]):not([tabindex="-1"])',
|
||||||
|
'select:not([disabled]):not([tabindex="-1"])',
|
||||||
|
'textarea:not([disabled]):not([tabindex="-1"])',
|
||||||
|
'[tabindex]:not([tabindex="-1"])'
|
||||||
|
]
|
||||||
|
|
||||||
|
while (parent && parent !== document.body) {
|
||||||
|
// Check if parent itself is focusable
|
||||||
|
if (
|
||||||
|
focusableSelectors.some((selector) => parent?.matches(selector)) &&
|
||||||
|
window.getComputedStyle(parent).display !== 'none' &&
|
||||||
|
window.getComputedStyle(parent).visibility !== 'hidden'
|
||||||
|
) {
|
||||||
|
return parent
|
||||||
|
}
|
||||||
|
// Check if parent contains focusable element
|
||||||
|
const focusableChild = parent.querySelector(focusableSelectors.join(', ')) as HTMLElement
|
||||||
|
if (focusableChild) {
|
||||||
|
return focusableChild
|
||||||
|
}
|
||||||
|
parent = parent.parentElement
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use microtask queue to ensure this runs after DOM cleanup
|
||||||
|
queueMicrotask(restoreFocus)
|
||||||
}, [isOpen, triggerRef])
|
}, [isOpen, triggerRef])
|
||||||
|
|
||||||
// Return focus lock configuration
|
// Return focus lock configuration
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ import ExecutionReportDialog from '../components/ExecutionReportDialog'
|
|||||||
import { useCleaner } from '../hooks/useCleaner'
|
import { useCleaner } from '../hooks/useCleaner'
|
||||||
|
|
||||||
const CleanerPage: React.FC = () => {
|
const CleanerPage: React.FC = () => {
|
||||||
|
const typeManagementButtonRef = React.useRef<HTMLButtonElement>(null)
|
||||||
|
const executeButtonRef = React.useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isAdmin,
|
isAdmin,
|
||||||
currentUsername,
|
currentUsername,
|
||||||
@@ -249,6 +252,7 @@ const CleanerPage: React.FC = () => {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsTypeDialogOpen(true)}
|
onClick={() => setIsTypeDialogOpen(true)}
|
||||||
|
ref={typeManagementButtonRef}
|
||||||
className="text-xs bg-white border border-slate-300 text-slate-700 px-3 py-1.5 rounded shadow-sm hover:bg-slate-50 flex items-center gap-1.5"
|
className="text-xs bg-white border border-slate-300 text-slate-700 px-3 py-1.5 rounded shadow-sm hover:bg-slate-50 flex items-center gap-1.5"
|
||||||
>
|
>
|
||||||
<Settings2 size={14} /> 类型管理
|
<Settings2 size={14} /> 类型管理
|
||||||
@@ -458,6 +462,7 @@ const CleanerPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleExecuteDeletion}
|
onClick={handleExecuteDeletion}
|
||||||
|
ref={executeButtonRef}
|
||||||
disabled={isRunning}
|
disabled={isRunning}
|
||||||
className={`${dryRun ? 'bg-amber-500 hover:bg-amber-600' : 'bg-red-600 hover:bg-red-700 shadow-red-500/30'} text-white px-8 py-2.5 rounded-lg font-medium shadow-md transition-all flex items-center gap-2 disabled:opacity-50 w-[300px] justify-center`}
|
className={`${dryRun ? 'bg-amber-500 hover:bg-amber-600' : 'bg-red-600 hover:bg-red-700 shadow-red-500/30'} text-white px-8 py-2.5 rounded-lg font-medium shadow-md transition-all flex items-center gap-2 disabled:opacity-50 w-[300px] justify-center`}
|
||||||
>
|
>
|
||||||
@@ -473,6 +478,7 @@ const CleanerPage: React.FC = () => {
|
|||||||
onClose={() => setIsTypeDialogOpen(false)}
|
onClose={() => setIsTypeDialogOpen(false)}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
currentUsername={currentUsername}
|
currentUsername={currentUsername}
|
||||||
|
triggerRef={typeManagementButtonRef}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Execution Report Dialog */}
|
{/* Execution Report Dialog */}
|
||||||
@@ -487,6 +493,7 @@ const CleanerPage: React.FC = () => {
|
|||||||
isExecuting={isExecuting}
|
isExecuting={isExecuting}
|
||||||
progress={progress}
|
progress={progress}
|
||||||
startTime={startTime}
|
startTime={startTime}
|
||||||
|
triggerRef={executeButtonRef}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user