refactor(ui): replace native alerts with toast notifications and confirm dialogs

This commit refactors all native browser alert() and confirm() dialogs to use
the app's custom UI components for consistent user experience.

**Changes:**
- Add ConfirmDialog component with danger/warning/info variants
- Add useConfirmDialog hook for promise-based dialog API
- Add formatListMessage utility for truncating long lists
- Replace 18 alert() calls with toast notifications in useCleaner.ts
- Replace 7 alert()/confirm() calls in MaterialTypeManagementDialog.tsx
- Render Toast component in App.tsx for global notifications
- Add keyboard shortcuts (Enter to confirm, Escape to cancel)

**Benefits:**
- Consistent UI design across all notifications
- Non-blocking notifications for better UX
- Better accessibility with proper ARIA roles and focus management
- Multi-line message support with truncation for long lists

**Files Modified:**
- src/renderer/src/components/ui/ConfirmDialog.tsx (new)
- src/renderer/src/stores/useAppStore.ts (add formatListMessage)
- src/renderer/src/hooks/useCleaner.ts (refactor error handling)
- src/renderer/src/components/MaterialTypeManagementDialog.tsx (refactor dialogs)
- src/renderer/src/pages/CleanerPage.tsx (add ConfirmDialog)
- src/renderer/src/App.tsx (render Toast component)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
test
2026-03-08 19:13:25 +08:00
parent bcfe0eecca
commit 33b17e278d
6 changed files with 294 additions and 28 deletions

View File

@@ -15,6 +15,7 @@ import LoginDialog from './components/LoginDialog'
import UserSelectionDialog, { import UserSelectionDialog, {
type UserInfo as SelectedUserInfo type UserInfo as SelectedUserInfo
} from './components/UserSelectionDialog' } from './components/UserSelectionDialog'
import { Toast } from './components/ui/Toast'
import ExtractorPage from './pages/ExtractorPage' import ExtractorPage from './pages/ExtractorPage'
import CleanerPage from './pages/CleanerPage' import CleanerPage from './pages/CleanerPage'
import SettingsPage from './pages/SettingsPage' import SettingsPage from './pages/SettingsPage'
@@ -409,6 +410,9 @@ function App(): React.JSX.Element {
{currentPage === 'settings' && <SettingsPage />} {currentPage === 'settings' && <SettingsPage />}
</main> </main>
</div> </div>
{/* Toast Notifications */}
<Toast />
</div> </div>
) )
} }

View File

@@ -9,6 +9,9 @@
import React, { useState, useEffect, useCallback, useRef } from 'react' import React, { useState, useEffect, useCallback, useRef } from 'react'
import { Plus, Trash2, Save, RotateCcw, Users } from 'lucide-react' import { Plus, Trash2, Save, RotateCcw, Users } from 'lucide-react'
import { Modal } from './ui/Modal' import { Modal } from './ui/Modal'
import { showSuccess, showError, showInfo } from '../stores/useAppStore'
import { useConfirmDialog } from './ui/ConfirmDialog'
import { ConfirmDialog } from './ui/ConfirmDialog'
interface MaterialTypeRecord { interface MaterialTypeRecord {
id?: number id?: number
@@ -49,6 +52,9 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
const tableRef = useRef<HTMLTableElement>(null) const tableRef = useRef<HTMLTableElement>(null)
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
// Confirmation dialog hook
const { confirm, dialog: confirmDialog } = useConfirmDialog()
// Calculate pending changes count // Calculate pending changes count
const pendingCount = rows.filter( const pendingCount = rows.filter(
(r) => r.state === 'new' || r.state === 'modified' || r.state === 'deleted' (r) => r.state === 'new' || r.state === 'modified' || r.state === 'deleted'
@@ -239,7 +245,7 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
} }
if (toInsert.length === 0 && toUpdate.length === 0 && toDelete.length === 0) { if (toInsert.length === 0 && toUpdate.length === 0 && toDelete.length === 0) {
alert('没有需要保存的更改') showInfo('没有需要保存的更改')
return return
} }
@@ -248,7 +254,12 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
if (toUpdate.length > 0) confirmParts.push(`更新 ${toUpdate.length} 条记录`) if (toUpdate.length > 0) confirmParts.push(`更新 ${toUpdate.length} 条记录`)
if (toDelete.length > 0) confirmParts.push(`删除 ${toDelete.length} 条记录`) if (toDelete.length > 0) confirmParts.push(`删除 ${toDelete.length} 条记录`)
if (!window.confirm(`确认以下操作?\n\n${confirmParts.join('\n')}`)) return const confirmed = await confirm({
title: '确认操作',
message: `确认以下操作?\n\n${confirmParts.join('\n')}`,
variant: 'warning'
})
if (!confirmed) return
setSaving(true) setSaving(true)
try { try {
@@ -262,31 +273,42 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
: undefined : undefined
if (result.success) { if (result.success) {
alert( showSuccess(
`保存完成!\n成功${payload?.stats?.success || 0}\n失败${payload?.stats?.failed || 0}` `保存完成!\n成功${payload?.stats?.success || 0}\n失败${payload?.stats?.failed || 0}`
) )
await loadData() await loadData()
} else { } else {
alert(`保存失败:${result.error || '未知错误'}`) showError(`保存失败:${result.error || '未知错误'}`)
} }
} catch (error) { } catch (error) {
alert(`保存失败:${error instanceof Error ? error.message : '未知错误'}`) showError(`保存失败:${error instanceof Error ? error.message : '未知错误'}`)
} finally { } finally {
setSaving(false) setSaving(false)
} }
} }
// Reset changes // Reset changes
const handleReset = () => { const handleReset = async () => {
if (pendingCount === 0) return if (pendingCount === 0) return
if (!window.confirm('确定要放弃所有未保存的更改吗?')) return const confirmed = await confirm({
loadData() title: '确认重置',
message: '确定要放弃所有未保存的更改吗?',
variant: 'warning'
})
if (confirmed) {
loadData()
}
} }
// Handle close with unsaved changes warning // Handle close with unsaved changes warning
const handleClose = () => { const handleClose = async () => {
if (pendingCount > 0) { if (pendingCount > 0) {
if (!window.confirm('有未保存的更改,确定要关闭吗?')) return const confirmed = await confirm({
title: '确认关闭',
message: '有未保存的更改,确定要关闭吗?',
variant: 'warning'
})
if (!confirmed) return
} }
onClose() onClose()
} }
@@ -533,6 +555,9 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
<span> {filteredRows.filter((r) => r.state !== 'deleted').length} </span> <span> {filteredRows.filter((r) => r.state !== 'deleted').length} </span>
</div> </div>
</div> </div>
{/* Confirmation Dialog */}
{confirmDialog && <ConfirmDialog {...confirmDialog} />}
</Modal> </Modal>
) )
} }

View File

@@ -0,0 +1,172 @@
/**
* ConfirmDialog Component
*
* A confirmation dialog component for displaying confirmation prompts.
* Extends the Modal component with consistent styling and behavior.
*/
import React, { useState, useCallback, useEffect } from 'react'
import { AlertTriangle, Info, AlertCircle } from 'lucide-react'
import { Modal } from './Modal'
import { Button } from './Button'
export type ConfirmDialogVariant = 'danger' | 'warning' | 'info'
export interface ConfirmDialogProps {
isOpen: boolean
title: string
message: string | React.ReactNode
confirmText?: string
cancelText?: string
variant?: ConfirmDialogVariant
onConfirm: () => void
onCancel: () => void
}
const variantStyles: Record<
ConfirmDialogVariant,
{ icon: React.ReactNode; iconColor: string; buttonVariant: 'danger' | 'primary' }
> = {
danger: {
icon: <AlertCircle className="w-6 h-6" />,
iconColor: 'text-red-500',
buttonVariant: 'danger'
},
warning: {
icon: <AlertTriangle className="w-6 h-6" />,
iconColor: 'text-yellow-500',
buttonVariant: 'primary'
},
info: {
icon: <Info className="w-6 h-6" />,
iconColor: 'text-blue-500',
buttonVariant: 'primary'
}
}
export function ConfirmDialog({
isOpen,
title,
message,
confirmText = '确认',
cancelText = '取消',
variant = 'info',
onConfirm,
onCancel
}: ConfirmDialogProps) {
// Handle keyboard shortcuts
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (!isOpen) return
if (e.key === 'Enter') {
e.preventDefault()
onConfirm()
} else if (e.key === 'Escape') {
e.preventDefault()
onCancel()
}
},
[isOpen, onConfirm, onCancel]
)
useEffect(() => {
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
}, [handleKeyDown])
const styles = variantStyles[variant]
return (
<Modal
isOpen={isOpen}
onClose={onCancel}
title={title}
size="md"
showCloseButton={false}
isAlertDialog={true}
initialFocusSelector="[data-autofocus]"
>
<div className="flex items-start gap-4">
{/* Icon */}
<div className={`flex-shrink-0 ${styles.iconColor}`}>{styles.icon}</div>
{/* Message */}
<div className="flex-1">
{typeof message === 'string' ? (
<p className="text-gray-700 whitespace-pre-wrap">{message}</p>
) : (
message
)}
</div>
</div>
{/* Buttons */}
<div className="flex justify-end gap-3 mt-6">
<Button variant="secondary" onClick={onCancel}>
{cancelText}
</Button>
<Button
data-autofocus="true"
variant={styles.buttonVariant}
onClick={onConfirm}
>
{confirmText}
</Button>
</div>
</Modal>
)
}
/**
* Hook for using confirmation dialogs
* Returns a confirm function that shows a dialog and resolves with user's choice
*/
export function useConfirmDialog() {
const [config, setConfig] = useState<
(Omit<ConfirmDialogProps, 'isOpen' | 'onConfirm' | 'onCancel'> & {
resolve: (value: boolean) => void
}) | null>(null)
const confirm = useCallback(
(
options: Omit<ConfirmDialogProps, 'isOpen' | 'onConfirm' | 'onCancel'>
): Promise<boolean> => {
return new Promise<boolean>((resolve) => {
setConfig({
...options,
resolve
})
})
},
[]
)
const handleConfirm = useCallback(() => {
if (config) {
config.resolve(true)
setConfig(null)
}
}, [config])
const handleCancel = useCallback(() => {
if (config) {
config.resolve(false)
setConfig(null)
}
}, [config])
const dialog = config
? {
...config,
isOpen: true,
onConfirm: handleConfirm,
onCancel: handleCancel
}
: null
return { confirm, dialog }
}
export default ConfirmDialog

View File

@@ -1,4 +1,6 @@
import { useState, useEffect, useMemo, useRef } from 'react' import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
import { showSuccess, showError, showWarning, formatListMessage } from '../stores/useAppStore'
import { ConfirmDialogProps } from '../components/ui/ConfirmDialog'
export interface ValidationResult { export interface ValidationResult {
materialName: string materialName: string
@@ -81,6 +83,32 @@ export function useCleaner() {
const [editValue, setEditValue] = useState('') const [editValue, setEditValue] = useState('')
const inputRef = useRef<HTMLInputElement | HTMLSelectElement>(null) const inputRef = useRef<HTMLInputElement | HTMLSelectElement>(null)
// Confirmation dialog state
const [confirmDialog, setConfirmDialog] = useState<ConfirmDialogProps | null>(null)
/**
* Show a confirmation dialog and return user's choice
*/
const showConfirmDialog = useCallback(
(options: Omit<ConfirmDialogProps, 'isOpen' | 'onConfirm' | 'onCancel'>): Promise<boolean> => {
return new Promise<boolean>((resolve) => {
setConfirmDialog({
...options,
isOpen: true,
onConfirm: () => {
setConfirmDialog(null)
resolve(true)
},
onCancel: () => {
setConfirmDialog(null)
resolve(false)
}
})
})
},
[]
)
// Check admin status and get shared Production IDs on mount // Check admin status and get shared Production IDs on mount
useEffect(() => { useEffect(() => {
const initializePage = async () => { const initializePage = async () => {
@@ -188,11 +216,12 @@ export function useCleaner() {
setManagers(Array.from(uniqueManagers)) setManagers(Array.from(uniqueManagers))
setSelectedManagers(uniqueManagers) setSelectedManagers(uniqueManagers)
} }
showSuccess('校验完成')
} else { } else {
alert(response.error || validationData?.error || '校验失败') showError(response.error || validationData?.error || '校验失败')
} }
} catch (err) { } catch (err) {
alert(err instanceof Error ? err.message : '校验过程中发生未知错误') showError(err instanceof Error ? err.message : '校验过程中发生未知错误')
} finally { } finally {
setIsValidationRunning(false) setIsValidationRunning(false)
} }
@@ -260,7 +289,10 @@ export function useCleaner() {
const handleConfirmDeletion = async () => { const handleConfirmDeletion = async () => {
const resultsToProcess = isAdmin ? validationResults : filteredResults const resultsToProcess = isAdmin ? validationResults : filteredResults
if (resultsToProcess.length === 0) return alert('没有可处理的数据') if (resultsToProcess.length === 0) {
showWarning('没有可处理的数据')
return
}
const materialsToUpsert: { materialCode: string; managerName: string }[] = [] const materialsToUpsert: { materialCode: string; managerName: string }[] = []
const materialsToDelete: string[] = [] const materialsToDelete: string[] = []
@@ -279,21 +311,28 @@ export function useCleaner() {
} }
if (missingManager.length > 0) { if (missingManager.length > 0) {
alert( showError(
`以下已勾选的记录缺少负责人信息,无法保存:\n\n${missingManager.slice(0, 10).join('\n')}` `以下已勾选的记录缺少负责人信息,无法保存:\n\n${formatListMessage(missingManager, 10)}`
) )
return return
} }
if (materialsToUpsert.length === 0 && materialsToDelete.length === 0) if (materialsToUpsert.length === 0 && materialsToDelete.length === 0) {
return alert('没有需要处理的记录') showWarning('没有需要处理的记录')
return
}
const confirmParts: string[] = [] const confirmParts: string[] = []
if (materialsToUpsert.length > 0) if (materialsToUpsert.length > 0)
confirmParts.push(`写入/更新 ${materialsToUpsert.length} 条记录`) confirmParts.push(`写入/更新 ${materialsToUpsert.length} 条记录`)
if (materialsToDelete.length > 0) confirmParts.push(`删除 ${materialsToDelete.length} 条记录`) if (materialsToDelete.length > 0) confirmParts.push(`删除 ${materialsToDelete.length} 条记录`)
if (!window.confirm(`确认以下操作吗?\n\n${confirmParts.join('\n')}`)) return const confirmed = await showConfirmDialog({
title: '确认操作',
message: `确认以下操作吗?\n\n${confirmParts.join('\n')}`,
variant: 'warning'
})
if (!confirmed) return
try { try {
setIsRunning(true) setIsRunning(true)
@@ -315,7 +354,7 @@ export function useCleaner() {
msgParts.push(`删除成功:${payload?.count || 0}`) msgParts.push(`删除成功:${payload?.count || 0}`)
} }
alert(`操作完成!\n\n${msgParts.join('\n')}`) showSuccess(`操作完成!\n\n${msgParts.join('\n')}`)
// Reload managers if admin // Reload managers if admin
if (isAdmin) { if (isAdmin) {
@@ -326,7 +365,7 @@ export function useCleaner() {
setManagers(payload?.managers ?? []) setManagers(payload?.managers ?? [])
} }
} catch (err) { } catch (err) {
alert(err instanceof Error ? err.message : '操作失败') showError(err instanceof Error ? err.message : '操作失败')
} finally { } finally {
setIsRunning(false) setIsRunning(false)
} }
@@ -334,7 +373,14 @@ export function useCleaner() {
const handleExecuteDeletion = async () => { const handleExecuteDeletion = async () => {
if (!dryRun) { if (!dryRun) {
if (!window.confirm('警告:正式执行将删除 ERP 系统中的物料数据,是否继续?')) return const confirmed = await showConfirmDialog({
title: '警告',
message: '正式执行将删除 ERP 系统中的物料数据,是否继续?',
variant: 'danger',
confirmText: '继续',
cancelText: '取消'
})
if (!confirmed) return
} }
setIsRunning(true) setIsRunning(true)
@@ -386,7 +432,7 @@ export function useCleaner() {
throw new Error(response.error || '清理失败') throw new Error(response.error || '清理失败')
} }
} catch (err) { } catch (err) {
alert(err instanceof Error ? err.message : '发生未知错误') showError(err instanceof Error ? err.message : '发生未知错误')
} finally { } finally {
setIsRunning(false) setIsRunning(false)
setIsExecuting(false) setIsExecuting(false)
@@ -397,7 +443,7 @@ export function useCleaner() {
const handleExportResults = async () => { const handleExportResults = async () => {
if (filteredResults.length === 0) { if (filteredResults.length === 0) {
alert('没有数据可导出') showWarning('没有数据可导出')
return return
} }
@@ -418,12 +464,12 @@ export function useCleaner() {
const exportData = response.success ? (response.data as any) : null const exportData = response.success ? (response.data as any) : null
if (response.success && exportData?.success !== false) { if (response.success && exportData?.success !== false) {
alert(`导出成功!\n文件已保存到${exportData?.filePath ?? ''}`) showSuccess(`导出成功!\n文件已保存到${exportData?.filePath ?? ''}`)
} else { } else {
throw new Error(response.error || exportData?.error || '导出失败') throw new Error(response.error || exportData?.error || '导出失败')
} }
} catch (err) { } catch (err) {
alert(err instanceof Error ? err.message : '导出过程中发生错误') showError(err instanceof Error ? err.message : '导出过程中发生错误')
} finally { } finally {
setIsExporting(false) setIsExporting(false)
} }
@@ -473,6 +519,7 @@ export function useCleaner() {
handleCheckboxToggle, handleCheckboxToggle,
handleConfirmDeletion, handleConfirmDeletion,
handleExecuteDeletion, handleExecuteDeletion,
handleExportResults handleExportResults,
confirmDialog
} }
} }

View File

@@ -17,6 +17,7 @@ import {
} from 'lucide-react' } from 'lucide-react'
import MaterialTypeManagementDialog from '../components/MaterialTypeManagementDialog' import MaterialTypeManagementDialog from '../components/MaterialTypeManagementDialog'
import ExecutionReportDialog from '../components/ExecutionReportDialog' import ExecutionReportDialog from '../components/ExecutionReportDialog'
import { ConfirmDialog } from '../components/ui/ConfirmDialog'
import { useCleaner } from '../hooks/useCleaner' import { useCleaner } from '../hooks/useCleaner'
const CleanerPage: React.FC = () => { const CleanerPage: React.FC = () => {
@@ -65,7 +66,8 @@ const CleanerPage: React.FC = () => {
handleCheckboxToggle, handleCheckboxToggle,
handleConfirmDeletion, handleConfirmDeletion,
handleExecuteDeletion, handleExecuteDeletion,
handleExportResults handleExportResults,
confirmDialog
} = useCleaner() } = useCleaner()
return ( return (
@@ -495,6 +497,9 @@ const CleanerPage: React.FC = () => {
startTime={startTime} startTime={startTime}
triggerRef={executeButtonRef} triggerRef={executeButtonRef}
/> />
{/* Confirmation Dialog */}
{confirmDialog && <ConfirmDialog {...confirmDialog} />}
</div> </div>
) )
} }

View File

@@ -112,4 +112,17 @@ export const selectToasts = (state: AppState) => state.toasts
export const selectSidebarCollapsed = (state: AppState) => state.sidebarCollapsed export const selectSidebarCollapsed = (state: AppState) => state.sidebarCollapsed
export const selectCurrentPage = (state: AppState) => state.currentPage export const selectCurrentPage = (state: AppState) => state.currentPage
/**
* Format a list of items into a multi-line message with truncation
* @param items - Array of strings to format
* @param maxItems - Maximum number of items to show before truncating (default: 10)
* @returns Formatted multi-line string
*/
export function formatListMessage(items: string[], maxItems: number = 10): string {
if (items.length <= maxItems) {
return items.join('\n')
}
return `${items.slice(0, maxItems).join('\n')}\n...及其他 ${items.length - maxItems}`
}
export default useAppStore export default useAppStore