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

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