2 Commits

Author SHA1 Message Date
test
13187ddce1 feat(validation): add source number validation checks with localized error messages
Add validation to detect and report when no order numbers are found from Production IDs (either from shared inputs or file). Provides clear Chinese error messages to guide users when their inputs don't match any database records.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-08 19:18:54 +08:00
test
33b17e278d 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>
2026-03-08 19:13:25 +08:00
7 changed files with 321 additions and 29 deletions

View File

@@ -291,12 +291,38 @@ export function registerValidationHandlers(): void {
sourceNumbers = await getSourceNumbersFromInputs(sharedIds, dbService)
log.info(`Got ${sourceNumbers.length} source numbers from shared Production IDs`)
// Check if we got any order numbers from the shared Production IDs
if (sourceNumbers.length === 0) {
return {
success: false,
error: '共享的 Production ID 没有找到对应的订单数据。请确保在数据提取页面输入了有效的 Production ID 并成功获取了订单数据。',
stats: {
totalRecords: 0,
matchedCount: 0,
markedCount: 0
}
}
}
} else if (request.productionIdFile) {
// Read from file
const inputs = readProductionIds(request.productionIdFile)
log.info(`Read ${inputs.length} inputs from file`)
sourceNumbers = await getSourceNumbersFromInputs(inputs, dbService)
log.info(`Got ${sourceNumbers.length} source numbers`)
// Check if we got any order numbers from the file
if (sourceNumbers.length === 0) {
return {
success: false,
error: '文件中的 Production ID 没有找到对应的订单数据。请检查 Production ID 是否正确,或确保数据库中有对应的订单数据。',
stats: {
totalRecords: 0,
matchedCount: 0,
markedCount: 0
}
}
}
}
}
@@ -316,7 +342,7 @@ export function registerValidationHandlers(): void {
if (materialRecords.length === 0) {
return {
success: false,
error: 'No material records found',
error: '未找到物料记录。请检查数据库中是否有对应订单的物料数据。',
stats: {
totalRecords: 0,
matchedCount: 0,

View File

@@ -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>
)
}

View File

@@ -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
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>
)
}

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 {
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
}
}

View File

@@ -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>
)
}

View File

@@ -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