feat: implement user authentication system

- Add SessionManager for managing user sessions (singleton pattern)
- Add BIPUsersDAO for database authentication
- Add LoginDialog component for username/password login
- Add UserSelectionDialog component for admin user selection
- Support silent login by computer name
- Implement main page with navigation to Extractor and Cleaner

Database:
- Table: dbo_BIPUsers
- Fields: UserName, Password, UserType, ComputerNmae

UI Flow:
1. Silent login on startup via computer name
2. Show login dialog if silent login fails
3. Display main page with user info and navigation
4. Support logout and re-login
This commit is contained in:
Misaka
2026-03-01 17:46:15 +08:00
parent 450eb41f96
commit 829851e3ca
12 changed files with 1671 additions and 112 deletions

View File

@@ -0,0 +1,223 @@
/**
* IPC handlers for User Authentication
*
* Provides APIs for the renderer process to:
* - Login with username and password
* - Silent login by computer name
* - Logout
* - Get current user info
* - Get all users (for admin user selection)
* - Switch user (admin only)
*/
import { ipcMain } from 'electron'
import { SessionManager } from '../services/user/session-manager'
import type { UserInfo } from '../types/user.types'
/**
* Login request
*/
export interface LoginRequest {
username: string
password: string
}
/**
* Login response
*/
export interface LoginResponse {
success: boolean
userInfo?: UserInfo
error?: string
}
/**
* Silent login response
*/
export interface SilentLoginResponse {
success: boolean
userInfo?: UserInfo
requiresUserSelection?: boolean // True if admin needs to select a user
error?: string
}
/**
* User selection response
*/
export interface UserSelectionResponse {
success: boolean
userInfo?: UserInfo
error?: string
}
/**
* Current user response
*/
export interface CurrentUserResponse {
isAuthenticated: boolean
userInfo?: UserInfo
}
/**
* Register IPC handlers for user authentication
*/
export function registerAuthHandlers(): void {
const sessionManager = SessionManager.getInstance()
/**
* Get computer name
*/
ipcMain.handle('auth:getComputerName', async (): Promise<string> => {
const os = await import('os')
return os.hostname()
})
/**
* Silent login by computer name
*/
ipcMain.handle(
'auth:silentLogin',
async (): Promise<SilentLoginResponse> => {
try {
const success = await sessionManager.loginByComputerName()
const userInfo = sessionManager.getUserInfo()
if (success && userInfo) {
// Check if admin needs user selection
const requiresUserSelection = userInfo.userType === 'Admin'
return {
success: true,
userInfo,
requiresUserSelection
}
}
return {
success: false,
requiresUserSelection: false
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
return {
success: false,
error: `无感登录失败:${message}`
}
}
}
)
/**
* Login with username and password
*/
ipcMain.handle(
'auth:login',
async (_event, request: LoginRequest): Promise<LoginResponse> => {
try {
const { username, password } = request
if (!username || !password) {
return {
success: false,
error: '请输入用户名和密码'
}
}
const success = await sessionManager.login(username, password)
const userInfo = sessionManager.getUserInfo()
if (success && userInfo) {
return {
success: true,
userInfo
}
}
return {
success: false,
error: '用户名或密码错误'
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
return {
success: false,
error: `登录失败:${message}`
}
}
}
)
/**
* Logout
*/
ipcMain.handle('auth:logout', async (): Promise<void> => {
sessionManager.logout()
})
/**
* Get current user
*/
ipcMain.handle(
'auth:getCurrentUser',
async (): Promise<CurrentUserResponse> => {
const isAuthenticated = sessionManager.isAuthenticated()
const userInfo = sessionManager.getUserInfo()
return {
isAuthenticated,
userInfo: userInfo ?? undefined
}
}
)
/**
* Get all users (for admin user selection)
*/
ipcMain.handle(
'auth:getAllUsers',
async (): Promise<UserInfo[]> => {
return await sessionManager.getAllUsers()
}
)
/**
* Switch user (admin only)
*/
ipcMain.handle(
'auth:switchUser',
async (_event, userInfo: UserInfo): Promise<UserSelectionResponse> => {
try {
const success = sessionManager.switchUser(userInfo)
if (success) {
const newUser = sessionManager.getUserInfo()
return {
success: true,
userInfo: newUser ?? undefined
}
}
return {
success: false,
error: '用户切换失败'
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
return {
success: false,
error: `用户切换失败:${message}`
}
}
}
)
/**
* Check if current user is admin
*/
ipcMain.handle(
'auth:isAdmin',
async (): Promise<boolean> => {
return sessionManager.isAdmin()
}
)
}

View File

@@ -8,6 +8,7 @@ import { registerExtractorHandlers } from './extractor-handler'
import { registerCleanerHandlers } from './cleaner-handler'
import { registerDatabaseHandlers } from './database-handler'
import { registerResolverHandlers } from './resolver-handler'
import { registerAuthHandlers } from './auth-handler'
/**
* Register all IPC handlers
@@ -18,4 +19,5 @@ export function registerIpcHandlers(): void {
registerCleanerHandlers()
registerDatabaseHandlers()
registerResolverHandlers()
registerAuthHandlers()
}

View File

@@ -0,0 +1,298 @@
/**
* BIPUsers DAO - Data access object for user authentication and management
*
* Mirrors the Python BIPUsersDAO functionality:
* - Authenticate users by username and password
* - Authenticate by computer name (silent login)
* - Get all users for admin user selection
* - Create, update, delete users
*/
import { MySqlService } from '../database/mysql'
import type { UserInfo } from '../../types/user.types'
/**
* Database configuration for BIPUsers table
*/
export const BIP_USERS_CONFIG = {
/** Table name in SQL Server: [dbo].[BIPUsers] */
TABLE_NAME_SQLSERVER: '[dbo].[BIPUsers]',
/** Table name in MySQL: dbo_BIPUsers */
TABLE_NAME_MYSQL: 'dbo_BIPUsers',
/** Column names */
COLUMNS: {
ID: 'ID',
USERNAME: 'UserName',
USER_TYPE: 'UserType',
PASSWORD: 'Password',
COMPUTER_NAME: 'ComputerNmae', // Note: typo in database schema
CREATE_TIME: 'CreateTime'
}
} as const
/**
* BIPUsers DAO Class
*/
export class BIPUsersDAO {
private mysqlService: MySqlService | null = null
/**
* Get MySQL service instance
*/
private async getMySqlService(): Promise<MySqlService> {
if (this.mysqlService && this.mysqlService.isConnected()) {
return this.mysqlService
}
this.mysqlService = new MySqlService({
host: process.env.DB_MYSQL_HOST || 'localhost',
port: parseInt(process.env.DB_MYSQL_PORT || '3306', 10),
user: process.env.DB_USERNAME || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || ''
})
await this.mysqlService.connect()
return this.mysqlService
}
/**
* Authenticate a user with username and password
* @param username - The username to authenticate
* @param password - The password to verify
* @returns User info if authentication successful, null otherwise
*/
async authenticate(username: string, password: string): Promise<UserInfo | null> {
try {
const mysqlService = await this.getMySqlService()
const sql = `
SELECT ID, UserName, UserType
FROM ${BIP_USERS_CONFIG.TABLE_NAME_MYSQL}
WHERE UserName = ? AND Password = ?
`
const result = await mysqlService.query(sql, [username, password])
if (result.rows.length > 0) {
const row = result.rows[0]
return {
id: row.ID as number,
username: row.UserName as string,
userType: row.UserType as 'Admin' | 'User' | 'Guest'
}
}
return null
} catch (error) {
console.error('[BIPUsersDAO] Authenticate error:', error)
return null
}
}
/**
* Authenticate a user using computer name (silent login)
* @param computerName - The computer name to authenticate
* @returns User info if authentication successful, null otherwise
*/
async authenticateByComputerName(computerName: string): Promise<UserInfo | null> {
try {
const mysqlService = await this.getMySqlService()
const sql = `
SELECT ID, UserName, UserType
FROM ${BIP_USERS_CONFIG.TABLE_NAME_MYSQL}
WHERE ComputerNmae = ?
`
const result = await mysqlService.query(sql, [computerName])
if (result.rows.length > 0) {
const row = result.rows[0]
return {
id: row.ID as number,
username: row.UserName as string,
userType: row.UserType as 'Admin' | 'User' | 'Guest'
}
}
return null
} catch (error) {
console.error('[BIPUsersDAO] Authenticate by computer name error:', error)
return null
}
}
/**
* Get all users from the database
* @returns List of user information
*/
async getAllUsers(): Promise<UserInfo[]> {
try {
const mysqlService = await this.getMySqlService()
const sql = `
SELECT ID, UserName, UserType, CreateTime
FROM ${BIP_USERS_CONFIG.TABLE_NAME_MYSQL}
ORDER BY UserName
`
const result = await mysqlService.query(sql)
return result.rows.map(row => ({
id: row.ID as number,
username: row.UserName as string,
userType: row.UserType as 'Admin' | 'User' | 'Guest',
createTime: row.CreateTime as Date | undefined
}))
} catch (error) {
console.error('[BIPUsersDAO] Get all users error:', error)
return []
}
}
/**
* Create a new user
* @param username - The username (must be unique)
* @param password - The password
* @param userType - User type ('Admin', 'User', or 'Guest')
* @param computerName - Optional computer name for silent login
* @returns True if successful
*/
async createUser(
username: string,
password: string,
userType: string,
computerName: string = ''
): Promise<boolean> {
try {
const mysqlService = await this.getMySqlService()
let sql: string
let params: any[]
if (computerName) {
sql = `
INSERT INTO ${BIP_USERS_CONFIG.TABLE_NAME_MYSQL}
(UserName, Password, UserType, ComputerNmae)
VALUES (?, ?, ?, ?)
`
params = [username, password, userType, computerName]
} else {
sql = `
INSERT INTO ${BIP_USERS_CONFIG.TABLE_NAME_MYSQL}
(UserName, Password, UserType)
VALUES (?, ?, ?)
`
params = [username, password, userType]
}
await mysqlService.query(sql, params)
return true
} catch (error) {
console.error('[BIPUsersDAO] Create user error:', error)
return false
}
}
/**
* Update a user's type
* @param username - The username to update
* @param userType - New user type
* @returns True if successful
*/
async updateUserType(username: string, userType: string): Promise<boolean> {
try {
const mysqlService = await this.getMySqlService()
const sql = `
UPDATE ${BIP_USERS_CONFIG.TABLE_NAME_MYSQL}
SET UserType = ?
WHERE UserName = ?
`
await mysqlService.query(sql, [userType, username])
return true
} catch (error) {
console.error('[BIPUsersDAO] Update user type error:', error)
return false
}
}
/**
* Update a user's password
* @param username - The username to update
* @param newPassword - The new password
* @returns True if successful
*/
async updatePassword(username: string, newPassword: string): Promise<boolean> {
try {
const mysqlService = await this.getMySqlService()
const sql = `
UPDATE ${BIP_USERS_CONFIG.TABLE_NAME_MYSQL}
SET Password = ?
WHERE UserName = ?
`
await mysqlService.query(sql, [newPassword, username])
return true
} catch (error) {
console.error('[BIPUsersDAO] Update password error:', error)
return false
}
}
/**
* Delete a user
* @param username - The username to delete
* @returns True if successful
*/
async deleteUser(username: string): Promise<boolean> {
try {
const mysqlService = await this.getMySqlService()
const sql = `
DELETE FROM ${BIP_USERS_CONFIG.TABLE_NAME_MYSQL}
WHERE UserName = ?
`
await mysqlService.query(sql, [username])
return true
} catch (error) {
console.error('[BIPUsersDAO] Delete user error:', error)
return false
}
}
/**
* Check if a username already exists
* @param username - The username to check
* @returns True if username exists
*/
async userExists(username: string): Promise<boolean> {
try {
const mysqlService = await this.getMySqlService()
const sql = `
SELECT COUNT(*) as count
FROM ${BIP_USERS_CONFIG.TABLE_NAME_MYSQL}
WHERE UserName = ?
`
const result = await mysqlService.query(sql, [username])
return result.rows.length > 0 && (result.rows[0].count as number) > 0
} catch (error) {
console.error('[BIPUsersDAO] User exists error:', error)
return false
}
}
/**
* Disconnect from database
*/
async disconnect(): Promise<void> {
if (this.mysqlService) {
await this.mysqlService.disconnect()
this.mysqlService = null
}
}
}

View File

@@ -0,0 +1,185 @@
/**
* User Session Manager - Singleton pattern for managing authenticated user session
*
* Mimics the Python SessionManager functionality:
* - Singleton pattern to maintain user state throughout application lifecycle
* - Support for username/password authentication
* - Support for silent login by computer name
* - Admin user can switch to other users
*/
import type { UserInfo } from '../../types/user.types'
/**
* Session Manager Class
*/
export class SessionManager {
private static instance: SessionManager | null = null
private currentUser: UserInfo | null = null
private originalAdminUser: UserInfo | null = null
private initialized: boolean = false
private constructor() {
if (this.initialized) {
return
}
this.initialized = true
}
/**
* Get the singleton instance
*/
public static getInstance(): SessionManager {
if (SessionManager.instance === null) {
SessionManager.instance = new SessionManager()
}
return SessionManager.instance
}
/**
* Authenticate and login a user
* @param username - The username to authenticate
* @param password - The password to verify
* @returns True if login successful, false otherwise
*/
public async login(username: string, password: string): Promise<boolean> {
try {
const { BIPUsersDAO } = await import('./bip-users-dao')
const dao = new BIPUsersDAO()
const userInfo = await dao.authenticate(username, password)
if (userInfo) {
this.currentUser = {
id: userInfo.id,
username: userInfo.username,
userType: userInfo.userType
}
return true
}
return false
} catch (error) {
console.error('[SessionManager] Login error:', error)
return false
}
}
/**
* Attempt silent login using computer name
* @returns True if login successful, false otherwise
*/
public async loginByComputerName(): Promise<boolean> {
try {
const { BIPUsersDAO } = await import('./bip-users-dao')
const dao = new BIPUsersDAO()
const computerName = require('os').hostname()
const userInfo = await dao.authenticateByComputerName(computerName)
if (userInfo) {
this.currentUser = {
id: userInfo.id,
username: userInfo.username,
userType: userInfo.userType
}
return true
}
return false
} catch (error) {
console.error('[SessionManager] Silent login error:', error)
return false
}
}
/**
* Logout the current user and clear session
*/
public logout(): void {
this.currentUser = null
this.originalAdminUser = null
}
/**
* Check if a user is currently authenticated
*/
public isAuthenticated(): boolean {
return this.currentUser !== null
}
/**
* Check if the current user is an admin
*/
public isAdmin(): boolean {
return this.currentUser?.userType === 'Admin'
}
/**
* Check if the current user is a guest
*/
public isGuest(): boolean {
return this.currentUser?.userType === 'Guest'
}
/**
* Get the current username
*/
public getUsername(): string | null {
return this.currentUser?.username ?? null
}
/**
* Get the current user type
*/
public getUserType(): string | null {
return this.currentUser?.userType ?? null
}
/**
* Get all current user information
*/
public getUserInfo(): UserInfo | null {
return this.currentUser
}
/**
* Switch to a different user (Admin only feature)
* @param userInfo - User info to switch to
* @returns True if switch successful
*/
public switchUser(userInfo: UserInfo): boolean {
if (!this.currentUser) {
return false
}
// Store original admin user for reference
if (!this.originalAdminUser) {
this.originalAdminUser = { ...this.currentUser }
}
this.currentUser = {
id: userInfo.id,
username: userInfo.username,
userType: userInfo.userType
}
return true
}
/**
* Get the original Admin user before any user switch
*/
public getOriginalAdmin(): UserInfo | null {
return this.originalAdminUser
}
/**
* Get all users from database (for Admin user selection)
*/
public async getAllUsers(): Promise<UserInfo[]> {
try {
const { BIPUsersDAO } = await import('./bip-users-dao')
const dao = new BIPUsersDAO()
return await dao.getAllUsers()
} catch (error) {
console.error('[SessionManager] Get all users error:', error)
return []
}
}
}

View File

@@ -0,0 +1,36 @@
/**
* User types and interfaces
*/
/**
* User type enumeration
*/
export type UserType = 'Admin' | 'User' | 'Guest'
/**
* User information interface
*/
export interface UserInfo {
/** User ID */
id: number
/** Username */
username: string
/** User type */
userType: UserType
/** Create time */
createTime?: Date
}
/**
* User session interface
*/
export interface UserSession {
/** Current authenticated user */
user: UserInfo | null
/** Session token (optional for future use) */
token?: string
/** Login timestamp */
loginTime: Date
/** Whether session is active */
isActive: boolean
}

View File

@@ -1,5 +1,13 @@
import type { FileAPI, ExtractorAPI, CleanerAPI, DatabaseAPI } from '../main/types/ipc-api.types'
import type { ResolverInput, ResolverResponse } from '../main/ipc/resolver-handler'
import type { UserInfo } from '../main/types/user.types'
import type {
LoginRequest,
LoginResponse,
SilentLoginResponse,
UserSelectionResponse,
CurrentUserResponse
} from '../main/ipc/auth-handler'
/**
* Order number resolver API
@@ -21,6 +29,46 @@ export interface ResolverAPI {
}>
}
/**
* Authentication API
*/
export interface AuthAPI {
/**
* Get computer name
*/
getComputerName: () => Promise<string>
/**
* Silent login by computer name
*/
silentLogin: () => Promise<SilentLoginResponse>
/**
* Login with username and password
* @param request - Login request with username and password
*/
login: (request: LoginRequest) => Promise<LoginResponse>
/**
* Logout
*/
logout: () => Promise<void>
/**
* Get current user
*/
getCurrentUser: () => Promise<CurrentUserResponse>
/**
* Get all users (for admin user selection)
*/
getAllUsers: () => Promise<UserInfo[]>
/**
* Switch user (admin only)
* @param userInfo - User info to switch to
*/
switchUser: (userInfo: UserInfo) => Promise<UserSelectionResponse>
/**
* Check if current user is admin
*/
isAdmin: () => Promise<boolean>
}
declare global {
interface Window {
electron: {
@@ -44,6 +92,7 @@ declare global {
cleaner: CleanerAPI
database: DatabaseAPI
resolver: ResolverAPI
auth: AuthAPI
}
api: unknown
}

View File

@@ -4,6 +4,8 @@ import type { MySqlConfig, SqlServerConfig } from '../main/types/ipc-api.types'
import type { ExtractorInput } from '../main/types/extractor.types'
import type { CleanerInput } from '../main/types/cleaner.types'
import type { ResolverInput } from '../main/ipc/resolver-handler'
import type { LoginRequest } from '../main/ipc/auth-handler'
import type { UserInfo } from '../main/types/user.types'
// Custom APIs for renderer
const api = {
@@ -32,6 +34,18 @@ const api = {
validateFormat: (inputs: string[]) => ipcRenderer.invoke('resolver:validateFormat', inputs)
},
// Authentication service
auth: {
getComputerName: () => ipcRenderer.invoke('auth:getComputerName'),
silentLogin: () => ipcRenderer.invoke('auth:silentLogin'),
login: (request: LoginRequest) => ipcRenderer.invoke('auth:login', request),
logout: () => ipcRenderer.invoke('auth:logout'),
getCurrentUser: () => ipcRenderer.invoke('auth:getCurrentUser'),
getAllUsers: () => ipcRenderer.invoke('auth:getAllUsers'),
switchUser: (userInfo: UserInfo) => ipcRenderer.invoke('auth:switchUser', userInfo),
isAdmin: () => ipcRenderer.invoke('auth:isAdmin')
},
// Database service
database: {
connectMySql: (config: MySqlConfig) => ipcRenderer.invoke('database:mysql:connect', config),

View File

@@ -2,11 +2,11 @@
<html>
<head>
<meta charset="UTF-8" />
<title>Electron</title>
<title>ERP Auto Tool</title>
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:"
content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;"
/>
</head>

View File

@@ -1,101 +1,315 @@
import { useState } from 'react'
import Versions from './components/Versions'
/**
* ERP App - Main application with authentication
*
* Mimics the Python ERPApp functionality:
* - Silent login by computer name on startup
* - Show login dialog if silent login fails
* - Show user selection dialog for Admin users
* - Display main content after successful authentication
*/
import React, { useState, useEffect } from 'react'
import LoginDialog from './components/LoginDialog'
import { ExtractorPage } from './pages/ExtractorPage'
import { CleanerPage } from './pages/CleanerPage'
import electronLogo from './assets/electron.svg'
type Page = 'home' | 'extractor' | 'cleaner'
interface CurrentUser {
username: string
userType: 'Admin' | 'User' | 'Guest'
}
function App(): React.JSX.Element {
// Authentication state
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [isAuthenticating, setIsAuthenticating] = useState(true)
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null)
const [computerName, setComputerName] = useState('')
// Dialog state
const [showLoginDialog, setShowLoginDialog] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
// Navigation state
const [currentPage, setCurrentPage] = useState<Page>('home')
const renderPage = () => {
switch (currentPage) {
case 'extractor':
return <ExtractorPage />
case 'cleaner':
return <CleanerPage />
default:
return (
<>
<img alt="logo" className="logo" src={electronLogo} />
<div className="creator">Powered by electron-vite</div>
<div className="text">
Build an Electron app with <span className="react">React</span>
&nbsp;and <span className="ts">TypeScript</span>
</div>
<p className="tip">
Please try pressing <code>F12</code> to open the devTool
</p>
<div className="actions">
<div className="action">
<a
href="#"
onClick={(e) => {
e.preventDefault()
setCurrentPage('extractor')
}}
>
</a>
</div>
<div className="action">
<a
href="#"
onClick={(e) => {
e.preventDefault()
setCurrentPage('cleaner')
}}
>
</a>
</div>
<div className="action">
<a href="https://electron-vite.org/" target="_blank" rel="noreferrer">
Documentation
</a>
</div>
</div>
<Versions />
</>
)
// Load error message from sessionStorage
const showError = (message: string) => {
setErrorMessage(message)
setTimeout(() => setErrorMessage(''), 3000)
}
// Initialize authentication on mount
useEffect(() => {
console.log('=== App: Initializing auth... ===')
initializeAuth()
}, [])
const initializeAuth = async () => {
console.log('=== App: Starting initializeAuth ===')
try {
// Get computer name
console.log('Getting computer name...')
const name = await window.electron.auth.getComputerName()
console.log('Computer name:', name)
setComputerName(name)
// Try silent login
console.log('Trying silent login...')
const result = await window.electron.auth.silentLogin()
console.log('Silent login result:', result)
if (result.success && result.userInfo) {
console.log('Silent login success:', result.userInfo)
setCurrentUser({
username: result.userInfo.username,
userType: result.userInfo.userType
})
// Check if admin needs user selection
if (result.requiresUserSelection) {
console.log('Admin user needs to select user - logging in anyway')
// For now, just log in as the current user
setIsAuthenticated(true)
} else {
console.log('Setting authenticated to true')
setIsAuthenticated(true)
}
} else {
console.log('Silent login failed, showing login dialog')
// Silent login failed, show login dialog
setShowLoginDialog(true)
}
} catch (error) {
console.error('Auth initialization error:', error)
setShowLoginDialog(true)
} finally {
console.log('Setting isAuthenticating to false')
setIsAuthenticating(false)
}
console.log('=== App: Auth initialization complete ===')
}
// Handle login dialog submit
const handleLogin = async (username: string, password: string): Promise<boolean> => {
try {
const result = await window.electron.auth.login({ username, password })
if (result.success && result.userInfo) {
setCurrentUser({
username: result.userInfo.username,
userType: result.userInfo.userType
})
// Check if admin needs user selection
if (result.userInfo.userType === 'Admin') {
setShowLoginDialog(false)
// TODO: Show user selection dialog
console.log('Admin user needs to select user')
} else {
setIsAuthenticated(true)
setShowLoginDialog(false)
}
return true
}
return false
} catch (error) {
console.error('Login error:', error)
return false
}
}
// Handle login dialog cancel
const handleLoginCancel = () => {
// User cancelled, keep showing dialog or exit
setShowLoginDialog(false)
}
// Handle logout
const handleLogout = async () => {
await window.electron.auth.logout()
setIsAuthenticated(false)
setCurrentUser(null)
setShowLoginDialog(true)
}
// Show loading state during authentication
if (isAuthenticating) {
return (
<div className="loading-container">
<div className="loading-content">
<div className="loading-spinner"></div>
<p>...</p>
</div>
<style>{`
.loading-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: #f5f7fa;
}
.loading-content {
text-align: center;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #e8e8e8;
border-top-color: #1890ff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
`}</style>
</div>
)
}
// Show login dialog if not authenticated
if (!isAuthenticated) {
console.log('Render: not authenticated, showLoginDialog:', showLoginDialog, 'computerName:', computerName)
return (
<>
<LoginDialog
isOpen={showLoginDialog}
computerName={computerName}
onLogin={handleLogin}
onCancel={handleLoginCancel}
onError={showError}
/>
{errorMessage && (
<div className="error-toast">{errorMessage}</div>
)}
{/* If showLoginDialog is false but not authenticated, show a message */}
{!showLoginDialog && (
<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>
<p style={{ color: '#666', marginTop: '16px' }}>...</p>
</div>
</div>
)}
<style>{`
.error-toast {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: #fff1f0;
border: 1px solid #ffa39e;
padding: 12px 24px;
border-radius: 6px;
color: #ff4d4f;
font-size: 14px;
z-index: 10000;
animation: slideDown 0.3s ease-out;
}
`}</style>
</>
)
}
// Show main content when authenticated
console.log('Render: authenticated, currentUser:', currentUser, 'currentPage:', currentPage)
return (
<div className="app">
{currentPage !== 'home' && (
<nav className="nav">
<button className="nav-btn" onClick={() => setCurrentPage('home')}>
<div style={{
minHeight: '100vh',
backgroundColor: '#f5f7fa'
}}>
{/* Header with user info and logout */}
<header style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '12px 24px',
backgroundColor: '#fff',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
marginBottom: '16px'
}}>
<div>
<span style={{
fontSize: '14px',
color: '#666'
}}>
{currentUser?.username} ({currentUser?.userType})
</span>
</div>
<div>
<button onClick={handleLogout} style={{
padding: '8px 16px',
backgroundColor: '#ff4d4f',
color: '#fff',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
cursor: 'pointer'
}}>
退
</button>
</nav>
)}
{renderPage()}
<style>{`
.app {
min-height: 100vh;
background: #f5f7fa;
}
.nav {
background: #fff;
padding: 12px 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.nav-btn {
background: #1890ff;
color: #fff;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
}
.nav-btn:hover {
background: #40a9ff;
}
`}</style>
</div>
</header>
{/* Main content */}
<div style={{ padding: '24px' }}>
{currentPage === 'home' && (
<div>
<h1 style={{ fontSize: '24px', color: '#333', marginBottom: '24px' }}></h1>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '16px'
}}>
<div onClick={() => setCurrentPage('extractor')} style={{
padding: '24px',
backgroundColor: '#fff',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
cursor: 'pointer',
textAlign: 'center'
}}>
<h3 style={{ margin: '0 0 8px 0', color: '#1890ff' }}></h3>
<p style={{ margin: 0, color: '#666', fontSize: '14px' }}> ERP </p>
</div>
<div onClick={() => setCurrentPage('cleaner')} style={{
padding: '24px',
backgroundColor: '#fff',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
cursor: 'pointer',
textAlign: 'center'
}}>
<h3 style={{ margin: '0 0 8px 0', color: '#52c41a' }}></h3>
<p style={{ margin: 0, color: '#666', fontSize: '14px' }}> ERP </p>
</div>
</div>
</div>
)}
{currentPage === 'extractor' && <ExtractorPage />}
{currentPage === 'cleaner' && <CleanerPage />}
</div>
</div>
)
}

View File

@@ -1,37 +1,18 @@
@import './base.css';
body {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
display: block;
margin: 0;
padding: 0;
overflow: auto;
background-image: url('./wavy-lines.svg');
background-size: cover;
user-select: none;
}
code {
font-weight: 600;
padding: 3px 5px;
border-radius: 2px;
background-color: var(--color-background-mute);
font-family:
ui-monospace,
SFMono-Regular,
SF Mono,
Menlo,
Consolas,
Liberation Mono,
monospace;
font-size: 85%;
}
#root {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
margin-bottom: 80px;
width: 100%;
min-height: 100vh;
}
.logo {

View File

@@ -0,0 +1,267 @@
/**
* Login Dialog - Modal dialog for user authentication
*
* Mimics the Python LoginDialog functionality:
* - Modal dialog for username/password input
* - Display computer name
* - Enter key to submit
*/
import React, { useState, useEffect, useRef } from 'react'
interface LoginDialogProps {
isOpen: boolean
computerName: string
onLogin: (username: string, password: string) => Promise<boolean>
onCancel: () => void
onError: (message: string) => void
}
export const LoginDialog: React.FC<LoginDialogProps> = ({
isOpen,
computerName,
onLogin,
onCancel,
onError
}) => {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [isLoggingIn, setIsLoggingIn] = useState(false)
const usernameInputRef = useRef<HTMLInputElement>(null)
// Focus on username input when dialog opens
useEffect(() => {
if (isOpen && usernameInputRef.current) {
usernameInputRef.current.focus()
}
}, [isOpen])
const handleLogin = async () => {
if (!username.trim()) {
onError('请输入用户名')
usernameInputRef.current?.focus()
return
}
if (!password.trim()) {
onError('请输入密码')
return
}
setIsLoggingIn(true)
const success = await onLogin(username.trim(), password.trim())
setIsLoggingIn(false)
if (!success) {
onError('用户名或密码错误')
setPassword('')
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleLogin()
} else if (e.key === 'Escape') {
onCancel()
}
}
if (!isOpen) return null
return (
<div className="login-overlay" onKeyDown={handleKeyDown}>
<div className="login-dialog">
<div className="login-header">
<h2 className="login-title"></h2>
<p className="computer-name">{computerName}</p>
</div>
<div className="login-body">
<div className="form-group">
<label className="form-label">:</label>
<input
ref={usernameInputRef}
type="text"
className="form-input"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="请输入用户名"
disabled={isLoggingIn}
/>
</div>
<div className="form-group">
<label className="form-label">:</label>
<input
type="password"
className="form-input"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="请输入密码"
disabled={isLoggingIn}
/>
</div>
</div>
<div className="login-footer">
<button
className="btn btn-primary"
onClick={handleLogin}
disabled={isLoggingIn}
>
{isLoggingIn ? '登录中...' : '登录'}
</button>
<button
className="btn btn-secondary"
onClick={onCancel}
disabled={isLoggingIn}
>
</button>
</div>
<div className="login-version">
v1.0
</div>
</div>
<style>{`
.login-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.login-dialog {
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
width: 400px;
padding: 24px;
animation: slideDown 0.2s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.login-header {
text-align: center;
margin-bottom: 20px;
}
.login-title {
font-size: 20px;
font-weight: 600;
color: #333;
margin: 0 0 8px 0;
}
.computer-name {
font-size: 12px;
color: #999;
margin: 0;
}
.login-body {
margin-bottom: 20px;
}
.form-group {
margin-bottom: 16px;
}
.form-label {
display: block;
font-size: 14px;
color: #666;
margin-bottom: 6px;
font-weight: 500;
}
.form-input {
width: 100%;
padding: 10px 12px;
border: 1px solid #d9d9d9;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.3s, box-shadow 0.3s;
box-sizing: border-box;
}
.form-input:focus {
outline: none;
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
.form-input:disabled {
background: #f5f5f5;
cursor: not-allowed;
}
.login-footer {
display: flex;
gap: 12px;
justify-content: center;
}
.btn {
padding: 10px 24px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary {
background: #1890ff;
color: #fff;
}
.btn-primary:hover:not(:disabled) {
background: #40a9ff;
}
.btn-secondary {
background: #f5f5f5;
color: #666;
}
.btn-secondary:hover:not(:disabled) {
background: #e8e8e8;
}
.login-version {
text-align: center;
font-size: 12px;
color: #999;
margin-top: 16px;
}
`}</style>
</div>
)
}
export default LoginDialog

View File

@@ -0,0 +1,290 @@
/**
* User Selection Dialog - For Admin user to select which user to operate as
*
* Mimics the Python UserSelectionDialog functionality:
* - Display list of all users
* - Allow admin to select a user
* - Return selected user info
*/
import React, { useState, useEffect } from 'react'
export interface UserInfo {
id: number
username: string
userType: 'Admin' | 'User' | 'Guest'
createTime?: Date
}
interface UserSelectionDialogProps {
isOpen: boolean
users: UserInfo[]
currentUsername: string
onSelectUser: (user: UserInfo) => void
onCancel: () => void
}
export const UserSelectionDialog: React.FC<UserSelectionDialogProps> = ({
isOpen,
users,
currentUsername,
onSelectUser,
onCancel
}) => {
const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
// Reset selection when dialog opens
useEffect(() => {
if (isOpen) {
setSelectedUserId(null)
}
}, [isOpen])
const handleConfirm = () => {
if (selectedUserId === null) {
return
}
const selectedUser = users.find(u => u.id === selectedUserId)
if (selectedUser) {
onSelectUser(selectedUser)
}
}
const handleDoubleClick = (user: UserInfo) => {
onSelectUser(user)
}
if (!isOpen) return null
return (
<div className="user-selection-overlay">
<div className="user-selection-dialog">
<div className="user-selection-header">
<h2 className="user-selection-title"></h2>
<p className="user-selection-hint">{currentUsername}</p>
</div>
<div className="user-selection-body">
<div className="user-list">
{users.map((user) => (
<div
key={user.id}
className={`user-item ${selectedUserId === user.id ? 'selected' : ''}`}
onClick={() => setSelectedUserId(user.id)}
onDoubleClick={() => handleDoubleClick(user)}
>
<div className="user-item-content">
<div className="user-item-row">
<span className="user-name">{user.username}</span>
<span className={`user-type user-type-${user.userType.toLowerCase()}`}>
{user.userType}
</span>
</div>
{user.createTime && (
<div className="user-item-row">
<span className="user-create-time">
{new Date(user.createTime).toLocaleString('zh-CN')}
</span>
</div>
)}
</div>
</div>
))}
</div>
</div>
<div className="user-selection-footer">
<button
className="btn btn-primary"
onClick={handleConfirm}
disabled={selectedUserId === null}
>
</button>
<button
className="btn btn-secondary"
onClick={onCancel}
>
</button>
</div>
<div className="user-selection-hint-footer">
</div>
</div>
<style>{`
.user-selection-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.user-selection-dialog {
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
width: 450px;
max-height: 80vh;
display: flex;
flex-direction: column;
}
.user-selection-header {
padding: 20px 24px;
border-bottom: 1px solid #f0f0f0;
}
.user-selection-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin: 0 0 8px 0;
}
.user-selection-hint {
font-size: 13px;
color: #666;
margin: 0;
}
.user-selection-body {
flex: 1;
overflow-y: auto;
padding: 16px 24px;
min-height: 200px;
max-height: 400px;
}
.user-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.user-item {
padding: 12px 16px;
border: 1px solid #e8e8e8;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.user-item:hover {
border-color: #1890ff;
background: #f6ffed;
}
.user-item.selected {
border-color: #1890ff;
background: #e6f7ff;
}
.user-item-content {
display: flex;
flex-direction: column;
gap: 4px;
}
.user-item-row {
display: flex;
align-items: center;
gap: 12px;
}
.user-name {
font-size: 14px;
font-weight: 500;
color: #333;
}
.user-type {
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
font-weight: 500;
}
.user-type-admin {
background: #fff7e6;
color: #fa8c16;
}
.user-type-user {
background: #e6f7ff;
color: #1890ff;
}
.user-type-guest {
background: #f5f5f5;
color: #666;
}
.user-create-time {
font-size: 12px;
color: #999;
}
.user-selection-footer {
display: flex;
gap: 12px;
justify-content: center;
padding: 16px 24px;
border-top: 1px solid #f0f0f0;
}
.btn {
padding: 10px 24px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary {
background: #1890ff;
color: #fff;
}
.btn-primary:hover:not(:disabled) {
background: #40a9ff;
}
.btn-secondary {
background: #f5f5f5;
color: #666;
}
.btn-secondary:hover:not(:disabled) {
background: #e8e8e8;
}
.user-selection-hint-footer {
text-align: center;
font-size: 12px;
color: #999;
padding: 8px 16px;
border-top: 1px solid #f0f0f0;
}
`}</style>
</div>
)
}
export default UserSelectionDialog