diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index bf79e63..b2beaf1 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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' && } + + {/* Toast Notifications */} + ) } diff --git a/src/renderer/src/components/MaterialTypeManagementDialog.tsx b/src/renderer/src/components/MaterialTypeManagementDialog.tsx index e14a9cd..01a7db8 100644 --- a/src/renderer/src/components/MaterialTypeManagementDialog.tsx +++ b/src/renderer/src/components/MaterialTypeManagementDialog.tsx @@ -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(null) const inputRef = useRef(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 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 { + 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共 {filteredRows.filter((r) => r.state !== 'deleted').length} 条记录 + + {/* Confirmation Dialog */} + {confirmDialog && } ) } diff --git a/src/renderer/src/components/ui/ConfirmDialog.tsx b/src/renderer/src/components/ui/ConfirmDialog.tsx new file mode 100644 index 0000000..dbd449a --- /dev/null +++ b/src/renderer/src/components/ui/ConfirmDialog.tsx @@ -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: , + iconColor: 'text-red-500', + buttonVariant: 'danger' + }, + warning: { + icon: , + iconColor: 'text-yellow-500', + buttonVariant: 'primary' + }, + info: { + icon: , + 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 ( + +
+ {/* Icon */} +
{styles.icon}
+ + {/* Message */} +
+ {typeof message === 'string' ? ( +

{message}

+ ) : ( + message + )} +
+
+ + {/* Buttons */} +
+ + +
+
+ ) +} + +/** + * 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 & { + resolve: (value: boolean) => void + }) | null>(null) + + const confirm = useCallback( + ( + options: Omit + ): Promise => { + return new Promise((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 diff --git a/src/renderer/src/hooks/useCleaner.ts b/src/renderer/src/hooks/useCleaner.ts index a6c5fc4..72e8bcc 100644 --- a/src/renderer/src/hooks/useCleaner.ts +++ b/src/renderer/src/hooks/useCleaner.ts @@ -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(null) + // Confirmation dialog state + const [confirmDialog, setConfirmDialog] = useState(null) + + /** + * Show a confirmation dialog and return user's choice + */ + const showConfirmDialog = useCallback( + (options: Omit): Promise => { + return new Promise((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 } } diff --git a/src/renderer/src/pages/CleanerPage.tsx b/src/renderer/src/pages/CleanerPage.tsx index a4106a6..a044ae9 100644 --- a/src/renderer/src/pages/CleanerPage.tsx +++ b/src/renderer/src/pages/CleanerPage.tsx @@ -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 && } ) } diff --git a/src/renderer/src/stores/useAppStore.ts b/src/renderer/src/stores/useAppStore.ts index b8d5b4c..2c843de 100644 --- a/src/renderer/src/stores/useAppStore.ts +++ b/src/renderer/src/stores/useAppStore.ts @@ -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