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:
@@ -15,6 +15,7 @@ import LoginDialog from './components/LoginDialog'
|
||||
import UserSelectionDialog, {
|
||||
type UserInfo as SelectedUserInfo
|
||||
} from './components/UserSelectionDialog'
|
||||
import { Toast } from './components/ui/Toast'
|
||||
import ExtractorPage from './pages/ExtractorPage'
|
||||
import CleanerPage from './pages/CleanerPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
@@ -409,6 +410,9 @@ function App(): React.JSX.Element {
|
||||
{currentPage === 'settings' && <SettingsPage />}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Toast Notifications */}
|
||||
<Toast />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { Plus, Trash2, Save, RotateCcw, Users } from 'lucide-react'
|
||||
import { Modal } from './ui/Modal'
|
||||
import { showSuccess, showError, showInfo } from '../stores/useAppStore'
|
||||
import { useConfirmDialog } from './ui/ConfirmDialog'
|
||||
import { ConfirmDialog } from './ui/ConfirmDialog'
|
||||
|
||||
interface MaterialTypeRecord {
|
||||
id?: number
|
||||
@@ -49,6 +52,9 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
|
||||
const tableRef = useRef<HTMLTableElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Confirmation dialog hook
|
||||
const { confirm, dialog: confirmDialog } = useConfirmDialog()
|
||||
|
||||
// Calculate pending changes count
|
||||
const pendingCount = rows.filter(
|
||||
(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) {
|
||||
alert('没有需要保存的更改')
|
||||
showInfo('没有需要保存的更改')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -248,7 +254,12 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
|
||||
if (toUpdate.length > 0) confirmParts.push(`更新 ${toUpdate.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)
|
||||
try {
|
||||
@@ -262,31 +273,42 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
|
||||
: undefined
|
||||
|
||||
if (result.success) {
|
||||
alert(
|
||||
showSuccess(
|
||||
`保存完成!\n成功:${payload?.stats?.success || 0} 条\n失败:${payload?.stats?.failed || 0} 条`
|
||||
)
|
||||
await loadData()
|
||||
} else {
|
||||
alert(`保存失败:${result.error || '未知错误'}`)
|
||||
showError(`保存失败:${result.error || '未知错误'}`)
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`保存失败:${error instanceof Error ? error.message : '未知错误'}`)
|
||||
showError(`保存失败:${error instanceof Error ? error.message : '未知错误'}`)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Reset changes
|
||||
const handleReset = () => {
|
||||
const handleReset = async () => {
|
||||
if (pendingCount === 0) return
|
||||
if (!window.confirm('确定要放弃所有未保存的更改吗?')) return
|
||||
loadData()
|
||||
const confirmed = await confirm({
|
||||
title: '确认重置',
|
||||
message: '确定要放弃所有未保存的更改吗?',
|
||||
variant: 'warning'
|
||||
})
|
||||
if (confirmed) {
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle close with unsaved changes warning
|
||||
const handleClose = () => {
|
||||
const handleClose = async () => {
|
||||
if (pendingCount > 0) {
|
||||
if (!window.confirm('有未保存的更改,确定要关闭吗?')) return
|
||||
const confirmed = await confirm({
|
||||
title: '确认关闭',
|
||||
message: '有未保存的更改,确定要关闭吗?',
|
||||
variant: 'warning'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
onClose()
|
||||
}
|
||||
@@ -533,6 +555,9 @@ export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialog
|
||||
<span>共 {filteredRows.filter((r) => r.state !== 'deleted').length} 条记录</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
{confirmDialog && <ConfirmDialog {...confirmDialog} />}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
172
src/renderer/src/components/ui/ConfirmDialog.tsx
Normal file
172
src/renderer/src/components/ui/ConfirmDialog.tsx
Normal 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
|
||||
@@ -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 {
|
||||
materialName: string
|
||||
@@ -81,6 +83,32 @@ export function useCleaner() {
|
||||
const [editValue, setEditValue] = useState('')
|
||||
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
|
||||
useEffect(() => {
|
||||
const initializePage = async () => {
|
||||
@@ -188,11 +216,12 @@ export function useCleaner() {
|
||||
setManagers(Array.from(uniqueManagers))
|
||||
setSelectedManagers(uniqueManagers)
|
||||
}
|
||||
showSuccess('校验完成')
|
||||
} else {
|
||||
alert(response.error || validationData?.error || '校验失败')
|
||||
showError(response.error || validationData?.error || '校验失败')
|
||||
}
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : '校验过程中发生未知错误')
|
||||
showError(err instanceof Error ? err.message : '校验过程中发生未知错误')
|
||||
} finally {
|
||||
setIsValidationRunning(false)
|
||||
}
|
||||
@@ -260,7 +289,10 @@ export function useCleaner() {
|
||||
const handleConfirmDeletion = async () => {
|
||||
const resultsToProcess = isAdmin ? validationResults : filteredResults
|
||||
|
||||
if (resultsToProcess.length === 0) return alert('没有可处理的数据')
|
||||
if (resultsToProcess.length === 0) {
|
||||
showWarning('没有可处理的数据')
|
||||
return
|
||||
}
|
||||
|
||||
const materialsToUpsert: { materialCode: string; managerName: string }[] = []
|
||||
const materialsToDelete: string[] = []
|
||||
@@ -279,21 +311,28 @@ export function useCleaner() {
|
||||
}
|
||||
|
||||
if (missingManager.length > 0) {
|
||||
alert(
|
||||
`以下已勾选的记录缺少负责人信息,无法保存:\n\n${missingManager.slice(0, 10).join('\n')}`
|
||||
showError(
|
||||
`以下已勾选的记录缺少负责人信息,无法保存:\n\n${formatListMessage(missingManager, 10)}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (materialsToUpsert.length === 0 && materialsToDelete.length === 0)
|
||||
return alert('没有需要处理的记录')
|
||||
if (materialsToUpsert.length === 0 && materialsToDelete.length === 0) {
|
||||
showWarning('没有需要处理的记录')
|
||||
return
|
||||
}
|
||||
|
||||
const confirmParts: string[] = []
|
||||
if (materialsToUpsert.length > 0)
|
||||
confirmParts.push(`写入/更新 ${materialsToUpsert.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 {
|
||||
setIsRunning(true)
|
||||
@@ -315,7 +354,7 @@ export function useCleaner() {
|
||||
msgParts.push(`删除成功:${payload?.count || 0} 条`)
|
||||
}
|
||||
|
||||
alert(`操作完成!\n\n${msgParts.join('\n')}`)
|
||||
showSuccess(`操作完成!\n\n${msgParts.join('\n')}`)
|
||||
|
||||
// Reload managers if admin
|
||||
if (isAdmin) {
|
||||
@@ -326,7 +365,7 @@ export function useCleaner() {
|
||||
setManagers(payload?.managers ?? [])
|
||||
}
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : '操作失败')
|
||||
showError(err instanceof Error ? err.message : '操作失败')
|
||||
} finally {
|
||||
setIsRunning(false)
|
||||
}
|
||||
@@ -334,7 +373,14 @@ export function useCleaner() {
|
||||
|
||||
const handleExecuteDeletion = async () => {
|
||||
if (!dryRun) {
|
||||
if (!window.confirm('警告:正式执行将删除 ERP 系统中的物料数据,是否继续?')) return
|
||||
const confirmed = await showConfirmDialog({
|
||||
title: '警告',
|
||||
message: '正式执行将删除 ERP 系统中的物料数据,是否继续?',
|
||||
variant: 'danger',
|
||||
confirmText: '继续',
|
||||
cancelText: '取消'
|
||||
})
|
||||
if (!confirmed) return
|
||||
}
|
||||
|
||||
setIsRunning(true)
|
||||
@@ -386,7 +432,7 @@ export function useCleaner() {
|
||||
throw new Error(response.error || '清理失败')
|
||||
}
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : '发生未知错误')
|
||||
showError(err instanceof Error ? err.message : '发生未知错误')
|
||||
} finally {
|
||||
setIsRunning(false)
|
||||
setIsExecuting(false)
|
||||
@@ -397,7 +443,7 @@ export function useCleaner() {
|
||||
|
||||
const handleExportResults = async () => {
|
||||
if (filteredResults.length === 0) {
|
||||
alert('没有数据可导出')
|
||||
showWarning('没有数据可导出')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -418,12 +464,12 @@ export function useCleaner() {
|
||||
const exportData = response.success ? (response.data as any) : null
|
||||
|
||||
if (response.success && exportData?.success !== false) {
|
||||
alert(`导出成功!\n文件已保存到:${exportData?.filePath ?? ''}`)
|
||||
showSuccess(`导出成功!\n文件已保存到:${exportData?.filePath ?? ''}`)
|
||||
} else {
|
||||
throw new Error(response.error || exportData?.error || '导出失败')
|
||||
}
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : '导出过程中发生错误')
|
||||
showError(err instanceof Error ? err.message : '导出过程中发生错误')
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
}
|
||||
@@ -473,6 +519,7 @@ export function useCleaner() {
|
||||
handleCheckboxToggle,
|
||||
handleConfirmDeletion,
|
||||
handleExecuteDeletion,
|
||||
handleExportResults
|
||||
handleExportResults,
|
||||
confirmDialog
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import MaterialTypeManagementDialog from '../components/MaterialTypeManagementDialog'
|
||||
import ExecutionReportDialog from '../components/ExecutionReportDialog'
|
||||
import { ConfirmDialog } from '../components/ui/ConfirmDialog'
|
||||
import { useCleaner } from '../hooks/useCleaner'
|
||||
|
||||
const CleanerPage: React.FC = () => {
|
||||
@@ -65,7 +66,8 @@ const CleanerPage: React.FC = () => {
|
||||
handleCheckboxToggle,
|
||||
handleConfirmDeletion,
|
||||
handleExecuteDeletion,
|
||||
handleExportResults
|
||||
handleExportResults,
|
||||
confirmDialog
|
||||
} = useCleaner()
|
||||
|
||||
return (
|
||||
@@ -495,6 +497,9 @@ const CleanerPage: React.FC = () => {
|
||||
startTime={startTime}
|
||||
triggerRef={executeButtonRef}
|
||||
/>
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
{confirmDialog && <ConfirmDialog {...confirmDialog} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -112,4 +112,17 @@ export const selectToasts = (state: AppState) => state.toasts
|
||||
export const selectSidebarCollapsed = (state: AppState) => state.sidebarCollapsed
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user