feat: add TypeORM, logger, schemas, hooks and stores

- Add TypeORM integration with data-source, entities and repositories
- Add logger service for structured logging
- Add Zod validation schemas for auth, cleaner and extractor
- Add custom React hooks (useAuth, useCleaner, useExtractor, useValidation)
- Add Zustand stores (useAppStore, useUserStore)
- Add UI components (Button, Modal, Toast)
- Add error types and ErpBrowserManager
- Refactor IPC handlers and services
- Add unit tests for new modules

Co-Authored-By: Claude (glm-5) <noreply@anthropic.com>
This commit is contained in:
Misaka
2026-03-02 23:10:15 +08:00
parent 982fb8fde6
commit a05c8a9037
57 changed files with 5150 additions and 974 deletions

View File

@@ -9,17 +9,11 @@
*/
import React, { useState, useEffect } from 'react'
import {
LayoutDashboard,
Download,
Trash2,
Settings,
Database,
User,
LogOut
} from 'lucide-react'
import { LayoutDashboard, Download, Trash2, Settings, Database, User, LogOut } from 'lucide-react'
import LoginDialog from './components/LoginDialog'
import UserSelectionDialog, { type UserInfo as SelectedUserInfo } from './components/UserSelectionDialog'
import UserSelectionDialog, {
type UserInfo as SelectedUserInfo
} from './components/UserSelectionDialog'
import ExtractorPage from './pages/ExtractorPage'
import CleanerPage from './pages/CleanerPage'
import SettingsPage from './pages/SettingsPage'
@@ -225,7 +219,12 @@ function App(): React.JSX.Element {
// Show login dialog if not authenticated
if (!isAuthenticated) {
console.log('Render: not authenticated, showLoginDialog:', showLoginDialog, 'computerName:', computerName)
console.log(
'Render: not authenticated, showLoginDialog:',
showLoginDialog,
'computerName:',
computerName
)
return (
<>
<LoginDialog
@@ -245,26 +244,28 @@ function App(): React.JSX.Element {
onCancel={handleUserSelectionCancel}
/>
{errorMessage && (
<div className="error-toast">{errorMessage}</div>
)}
{errorMessage && <div className="error-toast">{errorMessage}</div>}
{/* If showLoginDialog is false but not authenticated, show a message */}
{!showLoginDialog && !showUserSelection && (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
backgroundColor: '#f5f7fa'
}}>
<div style={{
padding: '40px',
backgroundColor: '#fff',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
textAlign: 'center'
}}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
backgroundColor: '#f5f7fa'
}}
>
<div
style={{
padding: '40px',
backgroundColor: '#fff',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
textAlign: 'center'
}}
>
<p style={{ color: '#52c41a', fontSize: '18px', fontWeight: 600 }}>
{currentUser?.username}
</p>
@@ -299,12 +300,11 @@ function App(): React.JSX.Element {
const navItems = [
{ id: 'extractor', label: '数据提取 (Extractor)', icon: <Download size={18} /> },
{ id: 'cleaner', label: '物料验证与清理 (Cleaner)', icon: <Trash2 size={18} /> },
{ id: 'settings', label: '系统设置 (Settings)', icon: <Settings size={18} /> },
];
{ id: 'settings', label: '系统设置 (Settings)', icon: <Settings size={18} /> }
]
return (
<div className="flex flex-col h-screen bg-slate-50 text-slate-800 font-sans overflow-hidden">
{/* ================= 顶部导航与标题栏 ================= */}
<header
className="h-16 bg-slate-900 text-slate-300 flex items-center justify-between px-4 shadow-md z-20 flex-shrink-0"
@@ -317,13 +317,20 @@ function App(): React.JSX.Element {
<div className="w-3 h-3 rounded-full bg-green-500"></div>
</div>
<div className="flex items-center gap-2 text-white font-bold text-lg cursor-pointer" onClick={() => setCurrentPage('home')} style={{ WebkitAppRegion: 'no-drag' } as any}>
<div
className="flex items-center gap-2 text-white font-bold text-lg cursor-pointer"
onClick={() => setCurrentPage('home')}
style={{ WebkitAppRegion: 'no-drag' } as any}
>
<LayoutDashboard size={22} className="text-blue-500" />
<span>ERP Auto</span>
</div>
</div>
<nav className="flex items-center gap-2 bg-slate-800 p-1 rounded-lg" style={{ WebkitAppRegion: 'no-drag' } as any}>
<nav
className="flex items-center gap-2 bg-slate-800 p-1 rounded-lg"
style={{ WebkitAppRegion: 'no-drag' } as any}
>
{navItems.map((item) => (
<button
key={item.id}
@@ -340,14 +347,20 @@ function App(): React.JSX.Element {
))}
</nav>
<div className="flex items-center gap-4 text-sm" style={{ WebkitAppRegion: 'no-drag' } as any}>
<div
className="flex items-center gap-4 text-sm"
style={{ WebkitAppRegion: 'no-drag' } as any}
>
<div className="flex items-center gap-2 text-xs bg-slate-800 px-3 py-1.5 rounded-full border border-slate-700">
<Database size={14} className="text-green-500" />
<span className="text-slate-300"></span>
</div>
<div className="flex items-center gap-2 bg-slate-800 px-3 py-1.5 rounded-full">
<User size={16} className="text-slate-400" />
<span className="font-medium text-slate-200" title={`User Type: ${currentUser?.userType}`}>
<span
className="font-medium text-slate-200"
title={`User Type: ${currentUser?.userType}`}
>
{currentUser?.username}
</span>
{shouldShowLogout && (

View File

@@ -1,4 +1,4 @@
@import "tailwindcss";
@import 'tailwindcss';
@import './base.css';
body {

View File

@@ -120,9 +120,7 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({
</button>
</div>
<div className="login-version">
v1.0
</div>
<div className="login-version">v1.0</div>
</div>
<style>{`

View File

@@ -45,7 +45,7 @@ export const UserSelectionDialog: React.FC<UserSelectionDialogProps> = ({
return
}
const selectedUser = users.find(u => u.id === selectedUserId)
const selectedUser = users.find((u) => u.id === selectedUserId)
if (selectedUser) {
onSelectUser(selectedUser)
}
@@ -102,17 +102,12 @@ export const UserSelectionDialog: React.FC<UserSelectionDialogProps> = ({
>
</button>
<button
className="btn btn-secondary"
onClick={onCancel}
>
<button className="btn btn-secondary" onClick={onCancel}>
</button>
</div>
<div className="user-selection-hint-footer">
</div>
<div className="user-selection-hint-footer"></div>
</div>
<style>{`

View File

@@ -0,0 +1,80 @@
/**
* Button Component
*
* A reusable button component with variants and sizes.
*/
import React from 'react'
type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost'
type ButtonSize = 'sm' | 'md' | 'lg'
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant
size?: ButtonSize
loading?: boolean
icon?: React.ReactNode
children: React.ReactNode
}
const variantStyles: Record<ButtonVariant, string> = {
primary: 'bg-blue-600 hover:bg-blue-700 text-white border-transparent',
secondary: 'bg-gray-100 hover:bg-gray-200 text-gray-800 border-gray-300',
danger: 'bg-red-600 hover:bg-red-700 text-white border-transparent',
ghost: 'bg-transparent hover:bg-gray-100 text-gray-700 border-transparent'
}
const sizeStyles: Record<ButtonSize, string> = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg'
}
export function Button({
variant = 'primary',
size = 'md',
loading = false,
icon,
children,
className = '',
disabled,
...props
}: ButtonProps) {
const baseStyles =
'inline-flex items-center justify-center font-medium rounded-lg border transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed'
return (
<button
className={`${baseStyles} ${variantStyles[variant]} ${sizeStyles[size]} ${className}`}
disabled={disabled || loading}
{...props}
>
{loading && (
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
)}
{icon && !loading && <span className="mr-2">{icon}</span>}
{children}
</button>
)
}
export default Button

View File

@@ -0,0 +1,92 @@
/**
* Modal Component
*
* A reusable modal dialog component.
*/
import React, { useEffect, useCallback } from 'react'
import { X } from 'lucide-react'
interface ModalProps {
isOpen: boolean
onClose: () => void
title?: string
children: React.ReactNode
size?: 'sm' | 'md' | 'lg' | 'xl'
showCloseButton?: boolean
}
const sizeStyles: Record<string, string> = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl'
}
export function Modal({
isOpen,
onClose,
title,
children,
size = 'md',
showCloseButton = true
}: ModalProps) {
// Handle escape key
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose()
}
},
[onClose]
)
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleKeyDown)
document.body.style.overflow = 'hidden'
}
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.body.style.overflow = 'unset'
}
}, [isOpen, handleKeyDown])
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
{/* Backdrop */}
<div className="fixed inset-0 bg-black bg-opacity-50 transition-opacity" onClick={onClose} />
{/* Modal container */}
<div className="flex min-h-full items-center justify-center p-4">
<div
className={`relative w-full ${sizeStyles[size]} bg-white rounded-lg shadow-xl transform transition-all`}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
{(title || showCloseButton) && (
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
{title && <h3 className="text-lg font-semibold text-gray-900">{title}</h3>}
{showCloseButton && (
<button
onClick={onClose}
className="p-1 text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
>
<X className="w-5 h-5" />
</button>
)}
</div>
)}
{/* Content */}
<div className="px-6 py-4">{children}</div>
</div>
</div>
</div>
)
}
export default Modal

View File

@@ -0,0 +1,90 @@
/**
* Toast Component
*
* A toast notification component for displaying messages.
*/
import React from 'react'
import { CheckCircle, XCircle, AlertTriangle, Info, X } from 'lucide-react'
import { useAppStore, selectToasts } from '../../stores/useAppStore'
type ToastType = 'success' | 'error' | 'warning' | 'info'
interface ToastItemProps {
id: string
type: ToastType
message: string
onClose: (id: string) => void
}
const typeStyles: Record<ToastType, { bg: string; border: string; icon: string }> = {
success: {
bg: 'bg-green-50',
border: 'border-green-200',
icon: 'text-green-500'
},
error: {
bg: 'bg-red-50',
border: 'border-red-200',
icon: 'text-red-500'
},
warning: {
bg: 'bg-yellow-50',
border: 'border-yellow-200',
icon: 'text-yellow-500'
},
info: {
bg: 'bg-blue-50',
border: 'border-blue-200',
icon: 'text-blue-500'
}
}
const ToastIcon: Record<ToastType, React.ReactNode> = {
success: <CheckCircle className="w-5 h-5" />,
error: <XCircle className="w-5 h-5" />,
warning: <AlertTriangle className="w-5 h-5" />,
info: <Info className="w-5 h-5" />
}
function ToastItem({ id, type, message, onClose }: ToastItemProps) {
const styles = typeStyles[type]
return (
<div
className={`flex items-center gap-3 px-4 py-3 rounded-lg border ${styles.bg} ${styles.border} shadow-lg min-w-72`}
>
<span className={styles.icon}>{ToastIcon[type]}</span>
<p className="flex-1 text-sm text-gray-800">{message}</p>
<button
onClick={() => onClose(id)}
className="p-1 text-gray-400 hover:text-gray-600 focus:outline-none"
>
<X className="w-4 h-4" />
</button>
</div>
)
}
export function Toast() {
const toasts = useAppStore(selectToasts)
const removeToast = useAppStore((state) => state.removeToast)
if (toasts.length === 0) return null
return (
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
{toasts.map((toast) => (
<ToastItem
key={toast.id}
id={toast.id}
type={toast.type}
message={toast.message}
onClose={removeToast}
/>
))}
</div>
)
}
export default Toast

View File

@@ -0,0 +1,211 @@
/**
* IPC Hook for Authentication operations
*
* Provides a React-friendly interface for auth IPC calls
* with loading state, error handling, and user data management.
*/
import { useState, useCallback } from 'react'
// Types based on the user types
interface UserInfo {
id: number
username: string
userType: 'Admin' | 'User' | 'Guest'
computerName?: string
}
interface LoginCredentials {
username: string
password: string
}
interface UseAuthState {
loading: boolean
user: UserInfo | null
error: string | null
isAuthenticated: boolean
}
interface UseAuthReturn extends UseAuthState {
login: (credentials: LoginCredentials) => Promise<boolean>
silentLogin: () => Promise<{ success: boolean; requiresUserSelection?: boolean }>
logout: () => Promise<void>
getCurrentUser: () => Promise<UserInfo | null>
getAllUsers: () => Promise<UserInfo[]>
switchUser: (userInfo: UserInfo) => Promise<boolean>
isAdmin: () => Promise<boolean>
reset: () => void
}
/**
* Hook for authentication operations
*/
export function useAuth(): UseAuthReturn {
const [state, setState] = useState<UseAuthState>({
loading: false,
user: null,
error: null,
isAuthenticated: false
})
const login = useCallback(async (credentials: LoginCredentials): Promise<boolean> => {
setState((prev) => ({ ...prev, loading: true, error: null }))
try {
const result = await window.electron.ipcRenderer.invoke('auth:login', credentials)
if (result.success && result.userInfo) {
setState({
loading: false,
user: result.userInfo,
error: null,
isAuthenticated: true
})
return true
} else {
setState((prev) => ({
...prev,
loading: false,
error: result.error || 'Login failed'
}))
return false
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
setState((prev) => ({ ...prev, loading: false, error: message }))
return false
}
}, [])
const silentLogin = useCallback(async (): Promise<{
success: boolean
requiresUserSelection?: boolean
}> => {
setState((prev) => ({ ...prev, loading: true, error: null }))
try {
const result = await window.electron.ipcRenderer.invoke('auth:silentLogin')
if (result.success && result.userInfo) {
setState({
loading: false,
user: result.userInfo,
error: null,
isAuthenticated: true
})
return { success: true, requiresUserSelection: result.requiresUserSelection }
} else {
setState((prev) => ({
...prev,
loading: false,
error: result.error || null
}))
return { success: false }
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
setState((prev) => ({ ...prev, loading: false, error: message }))
return { success: false }
}
}, [])
const logout = useCallback(async (): Promise<void> => {
try {
await window.electron.ipcRenderer.invoke('auth:logout')
setState({
loading: false,
user: null,
error: null,
isAuthenticated: false
})
} catch (error) {
console.error('Logout error:', error)
}
}, [])
const getCurrentUser = useCallback(async (): Promise<UserInfo | null> => {
try {
const result = await window.electron.ipcRenderer.invoke('auth:getCurrentUser')
if (result.isAuthenticated && result.userInfo) {
setState((prev) => ({
...prev,
user: result.userInfo,
isAuthenticated: true
}))
return result.userInfo
}
return null
} catch {
return null
}
}, [])
const getAllUsers = useCallback(async (): Promise<UserInfo[]> => {
try {
return await window.electron.ipcRenderer.invoke('auth:getAllUsers')
} catch {
return []
}
}, [])
const switchUser = useCallback(async (userInfo: UserInfo): Promise<boolean> => {
setState((prev) => ({ ...prev, loading: true, error: null }))
try {
const result = await window.electron.ipcRenderer.invoke('auth:switchUser', userInfo)
if (result.success && result.userInfo) {
setState({
loading: false,
user: result.userInfo,
error: null,
isAuthenticated: true
})
return true
} else {
setState((prev) => ({
...prev,
loading: false,
error: result.error || 'Switch user failed'
}))
return false
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
setState((prev) => ({ ...prev, loading: false, error: message }))
return false
}
}, [])
const isAdmin = useCallback(async (): Promise<boolean> => {
try {
return await window.electron.ipcRenderer.invoke('auth:isAdmin')
} catch {
return false
}
}, [])
const reset = useCallback(() => {
setState({
loading: false,
user: null,
error: null,
isAuthenticated: false
})
}, [])
return {
...state,
login,
silentLogin,
logout,
getCurrentUser,
getAllUsers,
switchUser,
isAdmin,
reset
}
}
export default useAuth

View File

@@ -0,0 +1,74 @@
/**
* IPC Hook for Cleaner operations
*
* Provides a React-friendly interface for cleaner IPC calls
* with loading state, error handling, and data management.
*/
import { useState, useCallback } from 'react'
// Types based on the cleaner types
interface CleanerInput {
orderNumbers: string[]
materialCodes: string[]
dryRun: boolean
}
interface CleanerResult {
processedCount: number
errors: string[]
}
interface UseCleanerState {
loading: boolean
data: CleanerResult | null
error: string | null
}
interface UseCleanerReturn extends UseCleanerState {
execute: (input: CleanerInput) => Promise<CleanerResult | null>
reset: () => void
}
/**
* Hook for executing cleaner operations
*/
export function useCleaner(): UseCleanerReturn {
const [state, setState] = useState<UseCleanerState>({
loading: false,
data: null,
error: null
})
const execute = useCallback(async (input: CleanerInput): Promise<CleanerResult | null> => {
setState({ loading: true, data: null, error: null })
try {
const result = await window.electron.ipcRenderer.invoke('cleaner:run', input)
if (result.success) {
setState({ loading: false, data: result.data, error: null })
return result.data
} else {
setState({ loading: false, data: null, error: result.error || 'Unknown error' })
return null
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
setState({ loading: false, data: null, error: message })
return null
}
}, [])
const reset = useCallback(() => {
setState({ loading: false, data: null, error: null })
}, [])
return {
...state,
execute,
reset
}
}
export default useCleaner

View File

@@ -0,0 +1,73 @@
/**
* IPC Hook for Extractor operations
*
* Provides a React-friendly interface for extractor IPC calls
* with loading state, error handling, and data management.
*/
import { useState, useCallback } from 'react'
// Types based on the extractor types
interface ExtractorInput {
orderNumbers: string[]
batchSize?: number
}
interface ExtractorResult {
data: Record<string, unknown>[]
errors: string[]
}
interface UseExtractorState {
loading: boolean
data: ExtractorResult | null
error: string | null
}
interface UseExtractorReturn extends UseExtractorState {
execute: (input: ExtractorInput) => Promise<ExtractorResult | null>
reset: () => void
}
/**
* Hook for executing extractor operations
*/
export function useExtractor(): UseExtractorReturn {
const [state, setState] = useState<UseExtractorState>({
loading: false,
data: null,
error: null
})
const execute = useCallback(async (input: ExtractorInput): Promise<ExtractorResult | null> => {
setState({ loading: true, data: null, error: null })
try {
const result = await window.electron.ipcRenderer.invoke('extractor:run', input)
if (result.success) {
setState({ loading: false, data: result.data, error: null })
return result.data
} else {
setState({ loading: false, data: null, error: result.error || 'Unknown error' })
return null
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
setState({ loading: false, data: null, error: message })
return null
}
}, [])
const reset = useCallback(() => {
setState({ loading: false, data: null, error: null })
}, [])
return {
...state,
execute,
reset
}
}
export default useExtractor

View File

@@ -0,0 +1,152 @@
/**
* IPC Hook for Validation operations
*
* Provides a React-friendly interface for validation IPC calls
* with loading state, error handling, and data management.
*/
import { useState, useCallback } from 'react'
// Types based on validation types
interface ValidationRequest {
mode: 'database_full' | 'database_filtered'
productionIdFile?: string
useSharedProductionIds?: boolean
}
interface ValidationResult {
materialName: string
materialCode: string
specification: string
model: string
managerName: string
isMarkedForDeletion: boolean
matchedTypeKeyword?: string
}
interface ValidationStats {
totalRecords: number
matchedCount: number
markedCount: number
}
interface ValidationResponse {
success: boolean
results?: ValidationResult[]
stats?: ValidationStats
error?: string
}
interface UseValidationState {
loading: boolean
data: ValidationResult[] | null
stats: ValidationStats | null
error: string | null
}
interface UseValidationReturn extends UseValidationState {
validate: (request: ValidationRequest) => Promise<ValidationResponse | null>
setSharedProductionIds: (ids: string[]) => Promise<void>
getSharedProductionIds: () => Promise<string[]>
getCleanerData: () => Promise<{ orderNumbers: string[]; materialCodes: string[] } | null>
reset: () => void
}
/**
* Hook for validation operations
*/
export function useValidation(): UseValidationReturn {
const [state, setState] = useState<UseValidationState>({
loading: false,
data: null,
stats: null,
error: null
})
const validate = useCallback(
async (request: ValidationRequest): Promise<ValidationResponse | null> => {
setState((prev) => ({ ...prev, loading: true, error: null }))
try {
const result = await window.electron.ipcRenderer.invoke('validation:validate', request)
if (result.success) {
setState({
loading: false,
data: result.results || null,
stats: result.stats || null,
error: null
})
return result
} else {
setState((prev) => ({
...prev,
loading: false,
error: result.error || 'Validation failed'
}))
return result
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
setState((prev) => ({ ...prev, loading: false, error: message }))
return null
}
},
[]
)
const setSharedProductionIds = useCallback(async (ids: string[]): Promise<void> => {
try {
await window.electron.ipcRenderer.invoke('validation:setSharedProductionIds', ids)
} catch (error) {
console.error('Failed to set shared production IDs:', error)
}
}, [])
const getSharedProductionIds = useCallback(async (): Promise<string[]> => {
try {
const result = await window.electron.ipcRenderer.invoke('validation:getSharedProductionIds')
return result?.productionIds || []
} catch {
return []
}
}, [])
const getCleanerData = useCallback(async (): Promise<{
orderNumbers: string[]
materialCodes: string[]
} | null> => {
try {
const result = await window.electron.ipcRenderer.invoke('validation:getCleanerData')
if (result.success) {
return {
orderNumbers: result.orderNumbers || [],
materialCodes: result.materialCodes || []
}
}
return null
} catch {
return null
}
}, [])
const reset = useCallback(() => {
setState({
loading: false,
data: null,
stats: null,
error: null
})
}, [])
return {
...state,
validate,
setSharedProductionIds,
getSharedProductionIds,
getCleanerData,
reset
}
}
export default useValidation

View File

@@ -103,13 +103,11 @@ const CleanerPage: React.FC = () => {
const filteredResults = React.useMemo(() => {
let results = validationResults
if (!isAdmin && currentUsername) {
results = results.filter(r => r.managerName === currentUsername || !r.managerName)
results = results.filter((r) => r.managerName === currentUsername || !r.managerName)
} else if (managers.length > 0 && selectedManagers.size > 0) {
results = results.filter(
r => selectedManagers.has(r.managerName) || !r.managerName
)
results = results.filter((r) => selectedManagers.has(r.managerName) || !r.managerName)
}
results = results.filter(r => !hiddenItems.has(r.materialCode))
results = results.filter((r) => !hiddenItems.has(r.materialCode))
return results
}, [validationResults, isAdmin, currentUsername, managers, selectedManagers, hiddenItems])
@@ -129,18 +127,12 @@ const CleanerPage: React.FC = () => {
if (response.success && response.results) {
setValidationResults(response.results)
const markedCodes = new Set(
response.results
.filter(r => r.isMarkedForDeletion)
.map(r => r.materialCode)
response.results.filter((r) => r.isMarkedForDeletion).map((r) => r.materialCode)
)
setSelectedItems(markedCodes)
if (isAdmin) {
const uniqueManagers = new Set(
response.results
.map(r => r.managerName)
.filter(Boolean)
)
const uniqueManagers = new Set(response.results.map((r) => r.managerName).filter(Boolean))
setManagers([...uniqueManagers])
setSelectedManagers(uniqueManagers)
}
@@ -155,7 +147,7 @@ const CleanerPage: React.FC = () => {
}
const handleCheckboxToggle = (materialCode: string) => {
setSelectedItems(prev => {
setSelectedItems((prev) => {
const newSet = new Set(prev)
if (newSet.has(materialCode)) newSet.delete(materialCode)
else newSet.add(materialCode)
@@ -183,14 +175,18 @@ const CleanerPage: React.FC = () => {
}
if (missingManager.length > 0) {
alert(`以下已勾选的记录缺少负责人信息,无法保存:\n\n${missingManager.slice(0, 10).join('\n')}`)
alert(
`以下已勾选的记录缺少负责人信息,无法保存:\n\n${missingManager.slice(0, 10).join('\n')}`
)
return
}
if (materialsToUpsert.length === 0 && materialsToDelete.length === 0) return alert('没有需要处理的记录')
if (materialsToUpsert.length === 0 && materialsToDelete.length === 0)
return alert('没有需要处理的记录')
const confirmParts: string[] = []
if (materialsToUpsert.length > 0) confirmParts.push(`写入/更新 ${materialsToUpsert.length} 条记录`)
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
@@ -215,8 +211,8 @@ const CleanerPage: React.FC = () => {
// Reload managers if admin
if (isAdmin) {
const resp = await window.electron.materials.getManagers()
setManagers(resp.managers)
const resp = await window.electron.materials.getManagers()
setManagers(resp.managers)
}
} catch (err) {
alert(err instanceof Error ? err.message : '操作失败')
@@ -240,8 +236,10 @@ const CleanerPage: React.FC = () => {
const orderNumberList = cleanerDataResult.orderNumbers || []
const materialCodeList = cleanerDataResult.materialCodes || []
if (orderNumberList.length === 0) throw new Error('没有订单号数据。请先到数据提取页面输入 Production ID。')
if (materialCodeList.length === 0) throw new Error('没有物料代码数据。请确认已在物料清理界面确认要删除的物料。')
if (orderNumberList.length === 0)
throw new Error('没有订单号数据。请先到数据提取页面输入 Production ID。')
if (materialCodeList.length === 0)
throw new Error('没有物料代码数据。请确认已在物料清理界面确认要删除的物料。')
const response = await window.electron.cleaner.runCleaner({
orderNumbers: orderNumberList,
@@ -250,11 +248,11 @@ const CleanerPage: React.FC = () => {
})
if (response.success && response.data) {
let msg = `清理执行完毕:\n处理订单: ${response.data.ordersProcessed}\n删除物料: ${response.data.materialsDeleted}\n跳过物料: ${response.data.materialsSkipped}`
if (response.data.errors.length) {
msg += `\n错误: ${response.data.errors.join(', ')}`
}
alert(msg)
let msg = `清理执行完毕:\n处理订单: ${response.data.ordersProcessed}\n删除物料: ${response.data.materialsDeleted}\n跳过物料: ${response.data.materialsSkipped}`
if (response.data.errors.length) {
msg += `\n错误: ${response.data.errors.join(', ')}`
}
alert(msg)
} else {
throw new Error(response.error || '清理失败')
}
@@ -267,10 +265,8 @@ const CleanerPage: React.FC = () => {
return (
<div className="h-full flex flex-col xl:flex-row gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
{/* 左栏:数据源与执行控制区 */}
<div className="xl:w-[380px] flex-shrink-0 flex flex-col gap-5">
{/* 1. 数据来源选择 (仅 Admin 可见) */}
{isAdmin && (
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-5">
@@ -280,7 +276,9 @@ const CleanerPage: React.FC = () => {
</h3>
<div className="space-y-3">
<label className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${valMode === 'full' ? 'bg-blue-50 border-blue-200' : 'hover:bg-slate-50 border-slate-200'}`}>
<label
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${valMode === 'full' ? 'bg-blue-50 border-blue-200' : 'hover:bg-slate-50 border-slate-200'}`}
>
<input
type="radio"
name="valMode"
@@ -294,7 +292,9 @@ const CleanerPage: React.FC = () => {
</div>
</label>
<label className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${valMode === 'filtered' ? 'bg-blue-50 border-blue-200' : 'hover:bg-slate-50 border-slate-200'}`}>
<label
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${valMode === 'filtered' ? 'bg-blue-50 border-blue-200' : 'hover:bg-slate-50 border-slate-200'}`}
>
<input
type="radio"
name="valMode"
@@ -303,12 +303,16 @@ const CleanerPage: React.FC = () => {
onChange={() => setValMode('filtered')}
/>
<div className="w-full">
<div className="text-sm font-medium text-slate-800"> - ProductionID </div>
<div className="text-sm font-medium text-slate-800">
- ProductionID
</div>
<div className="text-xs text-slate-500 mt-0.5"></div>
{valMode === 'filtered' && (
<div className="mt-3 text-xs text-blue-700 bg-blue-100/50 rounded p-2 flex items-start gap-1.5">
<Layers size={14} className="mt-0.5 flex-shrink-0" />
<span>使<strong></strong></span>
<span>
使<strong></strong>
</span>
</div>
)}
</div>
@@ -329,23 +333,30 @@ const CleanerPage: React.FC = () => {
<button
onClick={() => setSelectedManagers(new Set(managers))}
className="text-xs text-blue-600 hover:underline"
></button>
>
</button>
<button
onClick={() => setSelectedManagers(new Set())}
className="text-xs text-slate-500 hover:underline"
></button>
>
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-2 mt-3 max-h-[120px] overflow-y-auto pr-1">
{managers.map(manager => (
<label key={manager} className="flex items-center gap-2 text-sm text-slate-700 cursor-pointer hover:bg-slate-50 p-1.5 rounded">
{managers.map((manager) => (
<label
key={manager}
className="flex items-center gap-2 text-sm text-slate-700 cursor-pointer hover:bg-slate-50 p-1.5 rounded"
>
<input
type="checkbox"
className="rounded text-blue-600"
checked={selectedManagers.has(manager)}
onChange={(e) => {
setSelectedManagers(prev => {
setSelectedManagers((prev) => {
const newSet = new Set(prev)
if (e.target.checked) newSet.add(manager)
else newSet.delete(manager)
@@ -356,7 +367,9 @@ const CleanerPage: React.FC = () => {
{manager || '未分配'}
</label>
))}
{managers.length === 0 && <div className="text-sm text-slate-400"></div>}
{managers.length === 0 && (
<div className="text-sm text-slate-400"></div>
)}
</div>
</div>
)}
@@ -367,7 +380,9 @@ const CleanerPage: React.FC = () => {
<div className="flex items-center justify-between bg-amber-50/50 p-3 rounded-lg border border-amber-200 mb-4">
<div>
<div className="font-semibold text-sm text-amber-900"> (Dry-Run)</div>
<div className="text-xs text-amber-700/80 mt-0.5"></div>
<div className="text-xs text-amber-700/80 mt-0.5">
</div>
</div>
<button
onClick={() => setDryRun(!dryRun)}
@@ -392,39 +407,41 @@ const CleanerPage: React.FC = () => {
{/* 右栏:结果表格与工具栏 */}
<div className="flex-1 bg-white rounded-xl shadow-sm border border-slate-200 flex flex-col overflow-hidden min-h-[500px]">
{/* 顶部操作条 */}
<div className="bg-slate-50 border-b border-slate-200 px-4 py-3 flex justify-between items-center">
<div className="flex flex-wrap items-center gap-2">
<button
onClick={() => setSelectedItems(new Set(filteredResults.map(r => r.materialCode)))}
onClick={() => setSelectedItems(new Set(filteredResults.map((r) => r.materialCode)))}
className="text-xs bg-white border border-slate-300 text-slate-700 px-2.5 py-1.5 rounded shadow-sm hover:bg-slate-50 flex items-center gap-1"
>
<CheckSquare size={14} className="text-blue-600"/>
<CheckSquare size={14} className="text-blue-600" />
</button>
<button
onClick={() => setSelectedItems(new Set())}
className="text-xs bg-white border border-slate-300 text-slate-700 px-2.5 py-1.5 rounded shadow-sm hover:bg-slate-50 flex items-center gap-1"
>
<Square size={14} className="text-slate-400"/>
<Square size={14} className="text-slate-400" />
</button>
<div className="w-px h-4 bg-slate-300 mx-1"></div>
<button
onClick={() => {
const checkedCodes = filteredResults.filter(r => selectedItems.has(r.materialCode)).map(r => r.materialCode)
if (checkedCodes.length) setHiddenItems(prev => new Set([...prev, ...checkedCodes]))
const checkedCodes = filteredResults
.filter((r) => selectedItems.has(r.materialCode))
.map((r) => r.materialCode)
if (checkedCodes.length)
setHiddenItems((prev) => new Set([...prev, ...checkedCodes]))
}}
className="text-xs bg-white border border-slate-300 text-slate-700 px-2.5 py-1.5 rounded shadow-sm hover:bg-slate-50 flex items-center gap-1"
>
<EyeOff size={14}/>
<EyeOff size={14} />
</button>
<button
onClick={() => setHiddenItems(new Set())}
className="text-xs bg-white border border-slate-300 text-slate-700 px-2.5 py-1.5 rounded shadow-sm hover:bg-slate-50 flex items-center gap-1"
>
<Eye size={14}/>
<Eye size={14} />
</button>
<div className="w-px h-4 bg-slate-300 mx-1"></div>
@@ -458,37 +475,77 @@ const CleanerPage: React.FC = () => {
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3 w-40"> <span className="text-[10px] font-normal text-slate-400">()</span></th>
<th className="px-4 py-3 w-40">
<span className="text-[10px] font-normal text-slate-400">()</span>
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filteredResults.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-slate-400 text-sm">
{validationResults.length === 0 ? '暂无数据,请点击左侧按钮获取并校验物料' : '当前筛选条件下暂无数据'}
{validationResults.length === 0
? '暂无数据,请点击左侧按钮获取并校验物料'
: '当前筛选条件下暂无数据'}
</td>
</tr>
) : (
filteredResults.map(result => {
filteredResults.map((result) => {
const isChecked = selectedItems.has(result.materialCode)
const trClass = isChecked ? 'bg-blue-50/30' : 'hover:bg-slate-50'
const noManager = !result.managerName?.trim()
const managerCellClass = noManager ? 'text-amber-500 text-xs italic' : 'text-slate-700'
const managerCellClass = noManager
? 'text-amber-500 text-xs italic'
: 'text-slate-700'
return (
<tr key={result.materialCode} className={`${trClass} transition-colors ${noManager && isChecked ? 'bg-amber-50/20' : ''}`}>
<tr
key={result.materialCode}
className={`${trClass} transition-colors ${noManager && isChecked ? 'bg-amber-50/20' : ''}`}
>
<td className="px-4 py-3 text-center truncate">
{isChecked ? (
<CheckSquare onClick={() => handleCheckboxToggle(result.materialCode)} size={16} className="text-blue-600 inline cursor-pointer" />
<CheckSquare
onClick={() => handleCheckboxToggle(result.materialCode)}
size={16}
className="text-blue-600 inline cursor-pointer"
/>
) : (
<Square onClick={() => handleCheckboxToggle(result.materialCode)} size={16} className="text-slate-300 inline cursor-pointer" />
<Square
onClick={() => handleCheckboxToggle(result.materialCode)}
size={16}
className="text-slate-300 inline cursor-pointer"
/>
)}
</td>
<td className="px-4 py-3 font-medium text-slate-800 truncate" title={result.materialName}>{result.materialName}</td>
<td className="px-4 py-3 font-mono text-xs text-slate-600 truncate" title={result.materialCode}>{result.materialCode}</td>
<td className="px-4 py-3 text-slate-500 text-xs truncate" title={result.specification}>{result.specification || '-'}</td>
<td className="px-4 py-3 text-slate-500 text-xs truncate" title={result.model}>{result.model || '-'}</td>
<td className={`px-4 py-3 truncate ${managerCellClass}`} title={result.managerName || '空(待分配)'}>
<td
className="px-4 py-3 font-medium text-slate-800 truncate"
title={result.materialName}
>
{result.materialName}
</td>
<td
className="px-4 py-3 font-mono text-xs text-slate-600 truncate"
title={result.materialCode}
>
{result.materialCode}
</td>
<td
className="px-4 py-3 text-slate-500 text-xs truncate"
title={result.specification}
>
{result.specification || '-'}
</td>
<td
className="px-4 py-3 text-slate-500 text-xs truncate"
title={result.model}
>
{result.model || '-'}
</td>
<td
className={`px-4 py-3 truncate ${managerCellClass}`}
title={result.managerName || '空(待分配)'}
>
{result.managerName || '空(待分配)'}
</td>
</tr>
@@ -503,7 +560,8 @@ const CleanerPage: React.FC = () => {
<div className="bg-white border-t border-slate-200 p-4 flex justify-between items-center shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)] z-10">
<div className="flex items-center gap-3">
<div className="text-sm text-slate-600 font-medium">
{filteredResults.length} | <span className="text-blue-600">{selectedItems.size}</span>
{filteredResults.length} | {' '}
<span className="text-blue-600">{selectedItems.size}</span>
</div>
{isAdmin && dryRun && (
<span className="text-xs bg-amber-100 text-amber-700 px-2 py-1 rounded border border-amber-200">
@@ -528,13 +586,12 @@ const CleanerPage: React.FC = () => {
disabled={isRunning || validationResults.length === 0}
className={`${isAdmin && 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`}
>
<Play size={18} fill="currentColor" /> {isAdmin && dryRun ? '开始预览执行' : '正式执行 ERP 清理'}
<Play size={18} fill="currentColor" />{' '}
{isAdmin && dryRun ? '开始预览执行' : '正式执行 ERP 清理'}
</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -38,8 +38,8 @@ const ExtractorPage: React.FC = () => {
if (orderNumbers.trim()) {
const orderNumberList = orderNumbers
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0)
.map((line) => line.trim())
.filter((line) => line.length > 0)
window.electron.validation.setSharedProductionIds(orderNumberList)
}
}, [orderNumbers])
@@ -104,7 +104,10 @@ const ExtractorPage: React.FC = () => {
useEffect(() => {
if (progress) {
setLogs(prev => [...prev, `[${new Date().toLocaleTimeString()}] [Info] ${progress.message}`])
setLogs((prev) => [
...prev,
`[${new Date().toLocaleTimeString()}] [Info] ${progress.message}`
])
}
}, [progress])
@@ -114,7 +117,9 @@ const ExtractorPage: React.FC = () => {
<aside className="w-80 bg-white border border-slate-200 flex flex-col shadow-sm z-10 flex-shrink-0 animate-in slide-in-from-left duration-300 rounded-xl overflow-hidden h-full">
<div className="flex-1 flex flex-col p-5 space-y-3 h-full">
<div>
<label className="text-sm font-medium text-slate-700"></label>
<label className="text-sm font-medium text-slate-700">
</label>
<p className="text-xs text-slate-500 leading-relaxed mt-1">
</p>
@@ -130,8 +135,20 @@ const ExtractorPage: React.FC = () => {
></textarea>
<div className="flex items-center justify-between text-xs text-slate-500 pt-2">
<span>: <strong className="text-slate-700">{orderNumbers.split('\n').filter(l => l.trim()).length}</strong> </span>
<button className="text-slate-400 hover:text-slate-600" onClick={handleReset} disabled={isRunning}></button>
<span>
:{' '}
<strong className="text-slate-700">
{orderNumbers.split('\n').filter((l) => l.trim()).length}
</strong>{' '}
</span>
<button
className="text-slate-400 hover:text-slate-600"
onClick={handleReset}
disabled={isRunning}
>
</button>
</div>
</div>
</aside>
@@ -144,7 +161,9 @@ const ExtractorPage: React.FC = () => {
<Download size={20} className="text-blue-600" />
</h2>
<p className="text-sm text-slate-500"></p>
<p className="text-sm text-slate-500">
</p>
{error && <p className="text-sm text-red-500 mt-2">{error}</p>}
</div>
@@ -161,21 +180,27 @@ const ExtractorPage: React.FC = () => {
{/* 结果展示 */}
{result && (
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-6 flex flex-col gap-4">
<h3 className="text-emerald-600 font-semibold text-lg border-b pb-2"></h3>
<div className="grid grid-cols-3 gap-4">
<div className="bg-slate-50 p-4 rounded-lg border border-slate-100 flex flex-col items-center justify-center">
<span className="text-slate-500 text-sm"></span>
<span className="text-2xl font-bold text-slate-800">{result.downloadedFiles.length}</span>
</div>
<div className="bg-slate-50 p-4 rounded-lg border border-slate-100 flex flex-col items-center justify-center">
<span className="text-slate-500 text-sm"></span>
<span className="text-2xl font-bold text-slate-800">{result.recordCount}</span>
</div>
<div className="bg-slate-50 p-4 rounded-lg border border-slate-100 flex flex-col items-center justify-center">
<span className="text-slate-500 text-sm"></span>
<span className={`text-2xl font-bold ${result.errors.length > 0 ? 'text-red-500' : 'text-slate-800'}`}>{result.errors.length}</span>
</div>
</div>
<h3 className="text-emerald-600 font-semibold text-lg border-b pb-2"></h3>
<div className="grid grid-cols-3 gap-4">
<div className="bg-slate-50 p-4 rounded-lg border border-slate-100 flex flex-col items-center justify-center">
<span className="text-slate-500 text-sm"></span>
<span className="text-2xl font-bold text-slate-800">
{result.downloadedFiles.length}
</span>
</div>
<div className="bg-slate-50 p-4 rounded-lg border border-slate-100 flex flex-col items-center justify-center">
<span className="text-slate-500 text-sm"></span>
<span className="text-2xl font-bold text-slate-800">{result.recordCount}</span>
</div>
<div className="bg-slate-50 p-4 rounded-lg border border-slate-100 flex flex-col items-center justify-center">
<span className="text-slate-500 text-sm"></span>
<span
className={`text-2xl font-bold ${result.errors.length > 0 ? 'text-red-500' : 'text-slate-800'}`}
>
{result.errors.length}
</span>
</div>
</div>
</div>
)}
@@ -187,12 +212,26 @@ const ExtractorPage: React.FC = () => {
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-slate-500">: {progress?.progress || 0}%</span>
<button className="text-xs text-slate-400 hover:text-white transition-colors" onClick={() => setLogs([])}></button>
<button
className="text-xs text-slate-400 hover:text-white transition-colors"
onClick={() => setLogs([])}
>
</button>
</div>
</div>
<div className="flex-1 p-4 font-mono text-sm overflow-y-auto leading-relaxed">
{logs.map((log, index) => (
<div key={index} className={log.includes('[System]') ? 'text-emerald-500' : log.includes('error') || log.includes('失败') ? 'text-red-400' : 'text-slate-400'}>
<div
key={index}
className={
log.includes('[System]')
? 'text-emerald-500'
: log.includes('error') || log.includes('失败')
? 'text-red-400'
: 'text-slate-400'
}
>
{log}
</div>
))}

View File

@@ -20,7 +20,10 @@ const SettingsPage: React.FC = () => {
const [isModified, setIsModified] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [message, setMessage] = useState<{ type: 'success' | 'error' | 'info'; text: string } | null>(null)
const [message, setMessage] = useState<{
type: 'success' | 'error' | 'info'
text: string
} | null>(null)
useEffect(() => {
loadSettings()
@@ -82,9 +85,10 @@ const SettingsPage: React.FC = () => {
return (
<div className="max-w-2xl mx-auto animate-in fade-in slide-in-from-bottom-4 duration-500 mt-6">
{message && (
<div className={`fixed top-5 left-1/2 -translate-x-1/2 px-6 py-3 rounded-lg shadow-lg z-[10000] text-sm font-medium transition-all ${message.type === 'success' ? 'bg-emerald-50 text-emerald-600 border border-emerald-200' : 'bg-red-50 text-red-600 border border-red-200'}`}>
<div
className={`fixed top-5 left-1/2 -translate-x-1/2 px-6 py-3 rounded-lg shadow-lg z-[10000] text-sm font-medium transition-all ${message.type === 'success' ? 'bg-emerald-50 text-emerald-600 border border-emerald-200' : 'bg-red-50 text-red-600 border border-red-200'}`}
>
{message.text}
</div>
)}
@@ -102,7 +106,9 @@ const SettingsPage: React.FC = () => {
<div className="p-6 space-y-6 bg-white">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">ERP 访 (URL)</label>
<label className="block text-sm font-medium text-slate-700 mb-1.5">
ERP 访 (URL)
</label>
<input
type="url"
placeholder="https://erp.example.com"
@@ -114,7 +120,9 @@ const SettingsPage: React.FC = () => {
<div className="grid grid-cols-2 gap-5">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5"> (Username)</label>
<label className="block text-sm font-medium text-slate-700 mb-1.5">
(Username)
</label>
<input
type="text"
placeholder="输入 ERP 账号"
@@ -124,7 +132,9 @@ const SettingsPage: React.FC = () => {
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5"> (Password)</label>
<label className="block text-sm font-medium text-slate-700 mb-1.5">
(Password)
</label>
<input
type="password"
placeholder="••••••••"

View File

@@ -0,0 +1,115 @@
/**
* Application Store using Zustand
*
* Manages global application state including errors, notifications, and UI state.
*/
import { create } from 'zustand'
// Toast/notification type
interface Toast {
id: string
type: 'success' | 'error' | 'warning' | 'info'
message: string
duration?: number
}
interface AppState {
// Global error state
globalError: string | null
// Toast notifications
toasts: Toast[]
// UI state
sidebarCollapsed: boolean
currentPage: string
// Actions
setGlobalError: (error: string | null) => void
clearGlobalError: () => void
addToast: (toast: Omit<Toast, 'id'>) => void
removeToast: (id: string) => void
clearToasts: () => void
setSidebarCollapsed: (collapsed: boolean) => void
toggleSidebar: () => void
setCurrentPage: (page: string) => void
}
/**
* Generate a unique ID for toasts
*/
const generateId = (): string => {
return `toast-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
}
/**
* Application store for managing global state
*/
export const useAppStore = create<AppState>((set, get) => ({
globalError: null,
toasts: [],
sidebarCollapsed: false,
currentPage: 'extractor',
setGlobalError: (error) => set({ globalError: error }),
clearGlobalError: () => set({ globalError: null }),
addToast: (toast) => {
const id = generateId()
const newToast = { ...toast, id }
set((state) => ({
toasts: [...state.toasts, newToast]
}))
// Auto-remove toast after duration (default 5 seconds)
const duration = toast.duration ?? 5000
if (duration > 0) {
setTimeout(() => {
get().removeToast(id)
}, duration)
}
},
removeToast: (id) =>
set((state) => ({
toasts: state.toasts.filter((t) => t.id !== id)
})),
clearToasts: () => set({ toasts: [] }),
setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }),
toggleSidebar: () => set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
setCurrentPage: (page) => set({ currentPage: page })
}))
// Convenience functions for toast notifications
export const showSuccess = (message: string, duration?: number) => {
useAppStore.getState().addToast({ type: 'success', message, duration })
}
export const showError = (message: string, duration?: number) => {
useAppStore.getState().addToast({ type: 'error', message, duration })
}
export const showWarning = (message: string, duration?: number) => {
useAppStore.getState().addToast({ type: 'warning', message, duration })
}
export const showInfo = (message: string, duration?: number) => {
useAppStore.getState().addToast({ type: 'info', message, duration })
}
// Selectors
export const selectGlobalError = (state: AppState) => state.globalError
export const selectToasts = (state: AppState) => state.toasts
export const selectSidebarCollapsed = (state: AppState) => state.sidebarCollapsed
export const selectCurrentPage = (state: AppState) => state.currentPage
export default useAppStore

View File

@@ -0,0 +1,67 @@
/**
* User Store using Zustand
*
* Manages global user authentication state across the application.
*/
import { create } from 'zustand'
// User info interface matching the backend types
interface UserInfo {
id: number
username: string
userType: 'Admin' | 'User' | 'Guest'
computerName?: string
}
interface UserState {
user: UserInfo | null
isAuthenticated: boolean
loading: boolean
error: string | null
// Actions
setUser: (user: UserInfo | null) => void
setAuthenticated: (isAuthenticated: boolean) => void
setLoading: (loading: boolean) => void
setError: (error: string | null) => void
clearUser: () => void
}
/**
* User store for managing authentication state
*/
export const useUserStore = create<UserState>((set) => ({
user: null,
isAuthenticated: false,
loading: false,
error: null,
setUser: (user) =>
set({
user,
isAuthenticated: user !== null,
error: null
}),
setAuthenticated: (isAuthenticated) => set({ isAuthenticated }),
setLoading: (loading) => set({ loading }),
setError: (error) => set({ error }),
clearUser: () =>
set({
user: null,
isAuthenticated: false,
error: null
})
}))
// Selectors for common state access patterns
export const selectUser = (state: UserState) => state.user
export const selectIsAuthenticated = (state: UserState) => state.isAuthenticated
export const selectIsAdmin = (state: UserState) => state.user?.userType === 'Admin'
export const selectUsername = (state: UserState) => state.user?.username ?? ''
export default useUserStore