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:
@@ -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 && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "tailwindcss";
|
||||
@import 'tailwindcss';
|
||||
@import './base.css';
|
||||
|
||||
body {
|
||||
|
||||
@@ -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>{`
|
||||
|
||||
@@ -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>{`
|
||||
|
||||
80
src/renderer/src/components/ui/Button.tsx
Normal file
80
src/renderer/src/components/ui/Button.tsx
Normal 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
|
||||
92
src/renderer/src/components/ui/Modal.tsx
Normal file
92
src/renderer/src/components/ui/Modal.tsx
Normal 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
|
||||
90
src/renderer/src/components/ui/Toast.tsx
Normal file
90
src/renderer/src/components/ui/Toast.tsx
Normal 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
|
||||
211
src/renderer/src/hooks/useAuth.ts
Normal file
211
src/renderer/src/hooks/useAuth.ts
Normal 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
|
||||
74
src/renderer/src/hooks/useCleaner.ts
Normal file
74
src/renderer/src/hooks/useCleaner.ts
Normal 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
|
||||
73
src/renderer/src/hooks/useExtractor.ts
Normal file
73
src/renderer/src/hooks/useExtractor.ts
Normal 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
|
||||
152
src/renderer/src/hooks/useValidation.ts
Normal file
152
src/renderer/src/hooks/useValidation.ts
Normal 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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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="••••••••"
|
||||
|
||||
115
src/renderer/src/stores/useAppStore.ts
Normal file
115
src/renderer/src/stores/useAppStore.ts
Normal 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
|
||||
67
src/renderer/src/stores/useUserStore.ts
Normal file
67
src/renderer/src/stores/useUserStore.ts
Normal 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
|
||||
Reference in New Issue
Block a user