feat: implement IPC handlers, database services and Extractor UI

- Add SqlServerService and MySqlService for database persistence
- Implement IPC handlers for file, extractor, cleaner, and database operations
- Define IPC API types and update preload script
- Create ExtractorPage UI with OrderNumberInput component
- Add unit and integration tests for MySQL and SQL Server
- Update vitest config with path aliases
This commit is contained in:
Misaka
2026-03-01 15:46:01 +08:00
parent c39e1504aa
commit 5760b56f70
23 changed files with 2156 additions and 33 deletions

View File

@@ -2,6 +2,7 @@ import { app, shell, BrowserWindow, ipcMain } from 'electron'
import { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
import { registerIpcHandlers } from './ipc'
function createWindow(): void {
// Create the browser window.
@@ -49,6 +50,9 @@ app.whenReady().then(() => {
optimizer.watchWindowShortcuts(window)
})
// Register IPC handlers
registerIpcHandlers()
// IPC test
ipcMain.on('ping', () => console.log('pong'))

View File

@@ -0,0 +1,49 @@
import { ipcMain } from 'electron'
import { ErpAuthService } from '../services/erp/erp-auth'
import { CleanerService } from '../services/erp/cleaner'
import type { CleanerInput, CleanerResult } from '../types/cleaner.types'
/**
* Register IPC handlers for cleaner service
*/
export function registerCleanerHandlers(): void {
ipcMain.handle(
'cleaner:run',
async (
_event,
input: CleanerInput
): Promise<{ success: boolean; data?: CleanerResult; error?: string }> => {
let authService: ErpAuthService | null = null
try {
// Create auth service and login
authService = new ErpAuthService({
url: process.env.ERP_URL || '',
username: process.env.ERP_USERNAME || '',
password: process.env.ERP_PASSWORD || '',
headless: true
})
await authService.login()
// Create cleaner service and run cleaning
const cleaner = new CleanerService(authService)
const result = await cleaner.clean(input)
return { success: true, data: result }
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
return { success: false, error: message }
} finally {
// Clean up: close browser
if (authService) {
try {
await authService.close()
} catch {
// Ignore cleanup errors
}
}
}
}
)
}

View File

@@ -0,0 +1,181 @@
import { ipcMain } from 'electron'
import { MySqlService } from '../services/database/mysql'
import { SqlServerService } from '../services/database/sql-server'
import type {
MySqlConfig,
MySqlQueryResult,
SqlServerConfig,
SqlServerQueryResult
} from '../types/ipc-api.types'
// Store MySQL service instances per window/connection
const mysqlServices = new Map<string, MySqlService>()
// Store SQL Server service instances per window/connection
const sqlServerServices = new Map<string, SqlServerService>()
/**
* Get or create MySQL service for a connection ID
*/
function getMySqlService(connectionId: string): MySqlService | undefined {
return mysqlServices.get(connectionId)
}
/**
* Set MySQL service for a connection ID
*/
function setMySqlService(connectionId: string, service: MySqlService): void {
mysqlServices.set(connectionId, service)
}
/**
* Delete MySQL service for a connection ID
*/
function deleteMySqlService(connectionId: string): void {
mysqlServices.delete(connectionId)
}
/**
* Get or create SQL Server service for a connection ID
*/
function getSqlServerService(connectionId: string): SqlServerService | undefined {
return sqlServerServices.get(connectionId)
}
/**
* Set SQL Server service for a connection ID
*/
function setSqlServerService(connectionId: string, service: SqlServerService): void {
sqlServerServices.set(connectionId, service)
}
/**
* Delete SQL Server service for a connection ID
*/
function deleteSqlServerService(connectionId: string): void {
sqlServerServices.delete(connectionId)
}
/**
* Register IPC handlers for database operations
*/
export function registerDatabaseHandlers(): void {
// Connect to MySQL
ipcMain.handle('database:mysql:connect', async (event, config: MySqlConfig): Promise<void> => {
try {
// Use window ID as connection identifier
const windowId = (event.sender as any).id.toString()
const service = new MySqlService(config)
await service.connect()
setMySqlService(windowId, service)
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to connect to MySQL'
throw new Error(message)
}
})
// Disconnect from MySQL
ipcMain.handle('database:mysql:disconnect', async (event): Promise<void> => {
try {
const windowId = (event.sender as any).id.toString()
const service = getMySqlService(windowId)
if (service) {
await service.disconnect()
deleteMySqlService(windowId)
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to disconnect from MySQL'
throw new Error(message)
}
})
// Check if MySQL is connected
ipcMain.handle('database:mysql:isConnected', async (event): Promise<boolean> => {
const windowId = (event.sender as any).id.toString()
const service = getMySqlService(windowId)
return service ? service.isConnected() : false
})
// Execute MySQL query
ipcMain.handle(
'database:mysql:query',
async (event, sql: string, params?: any[]): Promise<MySqlQueryResult> => {
try {
const windowId = (event.sender as any).id.toString()
const service = getMySqlService(windowId)
if (!service) {
throw new Error('Not connected to MySQL. Call connect() first.')
}
return await service.query(sql, params)
} catch (error) {
const message = error instanceof Error ? error.message : 'MySQL query failed'
throw new Error(message)
}
}
)
// Connect to SQL Server
ipcMain.handle(
'database:sqlserver:connect',
async (event, config: SqlServerConfig): Promise<void> => {
try {
const windowId = (event.sender as any).id.toString()
const service = new SqlServerService(config)
await service.connect()
setSqlServerService(windowId, service)
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to connect to SQL Server'
throw new Error(message)
}
}
)
// Disconnect from SQL Server
ipcMain.handle('database:sqlserver:disconnect', async (event): Promise<void> => {
try {
const windowId = (event.sender as any).id.toString()
const service = getSqlServerService(windowId)
if (service) {
await service.disconnect()
deleteSqlServerService(windowId)
}
} catch (error) {
const message =
error instanceof Error ? error.message : 'Failed to disconnect from SQL Server'
throw new Error(message)
}
})
// Check if SQL Server is connected
ipcMain.handle('database:sqlserver:isConnected', async (event): Promise<boolean> => {
const windowId = (event.sender as any).id.toString()
const service = getSqlServerService(windowId)
return service ? service.isConnected() : false
})
// Execute SQL Server query
ipcMain.handle(
'database:sqlserver:query',
async (
event,
sqlString: string,
params?: Record<string, unknown>
): Promise<SqlServerQueryResult> => {
try {
const windowId = (event.sender as any).id.toString()
const service = getSqlServerService(windowId)
if (!service) {
throw new Error('Not connected to SQL Server. Call connect() first.')
}
return await service.query(sqlString, params)
} catch (error) {
const message = error instanceof Error ? error.message : 'SQL Server query failed'
throw new Error(message)
}
}
)
}

View File

@@ -0,0 +1,49 @@
import { ipcMain } from 'electron'
import { ErpAuthService } from '../services/erp/erp-auth'
import { ExtractorService } from '../services/erp/extractor'
import type { ExtractorInput, ExtractorResult } from '../types/extractor.types'
/**
* Register IPC handlers for extractor service
*/
export function registerExtractorHandlers(): void {
ipcMain.handle(
'extractor:run',
async (
_event,
input: ExtractorInput
): Promise<{ success: boolean; data?: ExtractorResult; error?: string }> => {
let authService: ErpAuthService | null = null
try {
// Create auth service and login
authService = new ErpAuthService({
url: process.env.ERP_URL || '',
username: process.env.ERP_USERNAME || '',
password: process.env.ERP_PASSWORD || '',
headless: true
})
await authService.login()
// Create extractor service and run extraction
const extractor = new ExtractorService(authService)
const result = await extractor.extract(input)
return { success: true, data: result }
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
return { success: false, error: message }
} finally {
// Clean up: close browser
if (authService) {
try {
await authService.close()
} catch {
// Ignore cleanup errors
}
}
}
}
)
}

View File

@@ -0,0 +1,55 @@
import { ipcMain } from 'electron'
import * as fs from 'fs/promises'
import * as path from 'path'
/**
* Register IPC handlers for file operations
*/
export function registerFileHandlers(): void {
// Read file content
ipcMain.handle('file:read', async (_event, filePath: string): Promise<string> => {
try {
return await fs.readFile(filePath, 'utf-8')
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to read file'
throw new Error(message)
}
})
// Write content to file
ipcMain.handle('file:write', async (_event, filePath: string, content: string): Promise<void> => {
try {
// Ensure directory exists
const dir = path.dirname(filePath)
await fs.mkdir(dir, { recursive: true })
await fs.writeFile(filePath, content, 'utf-8')
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to write file'
throw new Error(message)
}
})
// Check if file exists
ipcMain.handle('file:exists', async (_event, filePath: string): Promise<boolean> => {
try {
await fs.access(filePath)
return true
} catch {
return false
}
})
// List files in directory
ipcMain.handle('file:list', async (_event, dirPath: string): Promise<string[]> => {
try {
const entries = await fs.readdir(dirPath, { withFileTypes: true })
return entries
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.sort()
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to list directory'
throw new Error(message)
}
})
}

19
src/main/ipc/index.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* IPC Handler registration
* Centralized registration for all IPC handlers
*/
import { registerFileHandlers } from './file-handler'
import { registerExtractorHandlers } from './extractor-handler'
import { registerCleanerHandlers } from './cleaner-handler'
import { registerDatabaseHandlers } from './database-handler'
/**
* Register all IPC handlers
*/
export function registerIpcHandlers(): void {
registerFileHandlers()
registerExtractorHandlers()
registerCleanerHandlers()
registerDatabaseHandlers()
}

View File

@@ -0,0 +1,123 @@
import mysql from 'mysql2/promise'
export interface MySqlConfig {
host: string
port: number
user: string
password: string
database: string
}
export interface MySqlQueryResult {
rows: Record<string, unknown>[]
columns: string[]
rowCount: number
}
export class MySqlService {
private connection: mysql.Connection | null = null
private config: MySqlConfig
constructor(config: MySqlConfig) {
this.config = config
}
/**
* Connect to MySQL database
*/
async connect(): Promise<void> {
if (this.connection) {
throw new Error('Already connected to MySQL')
}
try {
this.connection = await mysql.createConnection({
host: this.config.host,
port: this.config.port,
user: this.config.user,
password: this.config.password,
database: this.config.database
})
// Test connection
await this.connection.ping()
} catch (error) {
throw new Error(`Failed to connect to MySQL: ${(error as Error).message}`)
}
}
/**
* Disconnect from MySQL database
*/
async disconnect(): Promise<void> {
if (!this.connection) {
return
}
try {
await this.connection.end()
this.connection = null
} catch (error) {
throw new Error(`Failed to disconnect from MySQL: ${(error as Error).message}`)
}
}
/**
* Check if connected to MySQL database
*/
isConnected(): boolean {
return this.connection !== null
}
/**
* Execute a query and return results
*/
async query(sql: string, params?: any[]): Promise<MySqlQueryResult> {
if (!this.connection) {
throw new Error('Not connected to MySQL. Call connect() first.')
}
try {
const [rows, fields] = await this.connection.execute(sql, params)
// Convert rows to plain objects and extract column names
const columns = fields.map((field) => field.name)
const rowCount = Array.isArray(rows) ? rows.length : 0
// Type assertion for rows - mysql2 returns different types based on query
const typedRows = Array.isArray(rows) ? (rows as Record<string, unknown>[]) : []
return {
rows: typedRows,
columns,
rowCount
}
} catch (error) {
throw new Error(`MySQL query failed: ${(error as Error).message}`)
}
}
/**
* Execute multiple queries in a transaction
*/
async transaction(queries: { sql: string; params?: any[] }[]): Promise<void> {
if (!this.connection) {
throw new Error('Not connected to MySQL. Call connect() first.')
}
try {
await this.connection.beginTransaction()
for (const { sql, params } of queries) {
await this.connection.execute(sql, params)
}
await this.connection.commit()
} catch (error) {
if (this.connection) {
await this.connection.rollback()
}
throw new Error(`MySQL transaction failed: ${(error as Error).message}`)
}
}
}

View File

@@ -0,0 +1,184 @@
import sql from 'mssql'
export interface SqlServerConfig {
server: string
port: number
user: string
password: string
database: string
options?: {
encrypt?: boolean
trustServerCertificate?: boolean
}
}
export interface SqlServerQueryResult {
rows: Record<string, unknown>[]
columns: string[]
rowCount: number
}
export class SqlServerService {
private pool: sql.ConnectionPool | null = null
private config: SqlServerConfig
constructor(config: SqlServerConfig) {
this.config = config
}
/**
* Connect to SQL Server database
*/
async connect(): Promise<void> {
if (this.pool) {
throw new Error('Already connected to SQL Server')
}
try {
const poolConfig: sql.config = {
server: this.config.server,
port: this.config.port,
user: this.config.user,
password: this.config.password,
database: this.config.database,
options: {
encrypt: this.config.options?.encrypt ?? true,
trustServerCertificate: this.config.options?.trustServerCertificate ?? false
}
}
this.pool = new sql.ConnectionPool(poolConfig)
await this.pool.connect()
} catch (error) {
throw new Error(`Failed to connect to SQL Server: ${(error as Error).message}`)
}
}
/**
* Disconnect from SQL Server database
*/
async disconnect(): Promise<void> {
if (!this.pool) {
return
}
try {
await this.pool.close()
this.pool = null
} catch (error) {
throw new Error(`Failed to disconnect from SQL Server: ${(error as Error).message}`)
}
}
/**
* Check if connected to SQL Server database
*/
isConnected(): boolean {
return this.pool !== null && this.pool.connected
}
/**
* Execute a query and return results
*/
async query(sqlString: string, params?: Record<string, unknown>): Promise<SqlServerQueryResult> {
if (!this.pool) {
throw new Error('Not connected to SQL Server. Call connect() first.')
}
try {
const request = this.pool.request()
// Add parameters if provided
if (params) {
for (const [key, value] of Object.entries(params)) {
request.input(key, value)
}
}
const result = await request.query(sqlString)
// Convert recordset to array of objects
const columns = result.recordset.columns?.map((col) => col.name) || []
const rows = result.recordset as Record<string, unknown>[]
return {
rows,
columns,
rowCount: result.rowsAffected?.[0] || rows.length
}
} catch (error) {
throw new Error(`SQL Server query failed: ${(error as Error).message}`)
}
}
/**
* Execute a prepared statement with parameters
*/
async queryWithParams(
sqlString: string,
params: Record<string, { value: unknown; type?: sql.ISqlType }>
): Promise<SqlServerQueryResult> {
if (!this.pool) {
throw new Error('Not connected to SQL Server. Call connect() first.')
}
try {
const request = this.pool.request()
// Add parameters with explicit types
for (const [key, { value, type }] of Object.entries(params)) {
if (type) {
request.input(key, type, value)
} else {
request.input(key, value)
}
}
const result = await request.query(sqlString)
// Convert recordset to array of objects
const columns = result.recordset.columns?.map((col) => col.name) || []
const rows = result.recordset as Record<string, unknown>[]
return {
rows,
columns,
rowCount: result.rowsAffected?.[0] || rows.length
}
} catch (error) {
throw new Error(`SQL Server query failed: ${(error as Error).message}`)
}
}
/**
* Execute multiple queries in a transaction
*/
async transaction(queries: { sql: string; params?: Record<string, unknown> }[]): Promise<void> {
if (!this.pool) {
throw new Error('Not connected to SQL Server. Call connect() first.')
}
const transaction = new sql.Transaction(this.pool)
try {
await transaction.begin()
for (const { sql: sqlString, params } of queries) {
const request = new sql.Request(transaction)
if (params) {
for (const [key, value] of Object.entries(params)) {
request.input(key, value)
}
}
await request.query(sqlString)
}
await transaction.commit()
} catch (error) {
await transaction.rollback()
throw new Error(`SQL Server transaction failed: ${(error as Error).message}`)
}
}
}

View File

@@ -0,0 +1,156 @@
/**
* IPC API type definitions for renderer process
* These types define the API exposed to the renderer process via contextBridge
*/
import type { ExtractorInput, ExtractorResult } from './extractor.types'
import type { CleanerInput, CleanerResult } from './cleaner.types'
/**
* MySQL connection configuration
*/
export interface MySqlConfig {
host: string
port: number
user: string
password: string
database: string
}
/**
* MySQL query result
*/
export interface MySqlQueryResult {
rows: Record<string, unknown>[]
columns: string[]
rowCount: number
}
/**
* SQL Server connection configuration
*/
export interface SqlServerConfig {
server: string
port: number
user: string
password: string
database: string
options?: {
encrypt?: boolean
trustServerCertificate?: boolean
}
}
/**
* SQL Server query result
*/
export interface SqlServerQueryResult {
rows: Record<string, unknown>[]
columns: string[]
rowCount: number
}
/**
* File operation APIs
*/
export interface FileAPI {
/**
* Read file content as text
* @param filePath - Path to the file
*/
readFile: (filePath: string) => Promise<string>
/**
* Write content to file
* @param filePath - Path to the file
* @param content - Content to write
*/
writeFile: (filePath: string, content: string) => Promise<void>
/**
* Check if file exists
* @param filePath - Path to the file
*/
fileExists: (filePath: string) => Promise<boolean>
/**
* Get list of files in directory
* @param dirPath - Directory path
*/
listFiles: (dirPath: string) => Promise<string[]>
}
/**
* Extractor service APIs
*/
export interface ExtractorAPI {
/**
* Run ERP data extractor
* @param input - Extractor input parameters
*/
runExtractor: (
input: ExtractorInput
) => Promise<{ success: boolean; data?: ExtractorResult; error?: string }>
}
/**
* Cleaner service APIs
*/
export interface CleanerAPI {
/**
* Run ERP cleaner service
* @param input - Cleaner input parameters
*/
runCleaner: (input: CleanerInput) => Promise<CleanerResult>
}
/**
* Database service APIs
*/
export interface DatabaseAPI {
/**
* Connect to MySQL database
* @param config - MySQL connection config
*/
connectMySql: (config: MySqlConfig) => Promise<void>
/**
* Disconnect from MySQL database
*/
disconnectMySql: () => Promise<void>
/**
* Check if MySQL is connected
*/
isMySqlConnected: () => Promise<boolean>
/**
* Execute MySQL query
* @param sql - SQL query
* @param params - Query parameters
*/
queryMySql: (sql: string, params?: unknown[]) => Promise<MySqlQueryResult>
/**
* Connect to SQL Server database
* @param config - SQL Server connection config
*/
connectSqlServer: (config: SqlServerConfig) => Promise<void>
/**
* Disconnect from SQL Server database
*/
disconnectSqlServer: () => Promise<void>
/**
* Check if SQL Server is connected
*/
isSqlServerConnected: () => Promise<boolean>
/**
* Execute SQL Server query
* @param sql - SQL query
* @param params - Query parameters
*/
querySqlServer: (sql: string, params?: Record<string, unknown>) => Promise<SqlServerQueryResult>
}

View File

@@ -1,8 +1,28 @@
import { ElectronAPI } from '@electron-toolkit/preload'
import type { FileAPI, ExtractorAPI, CleanerAPI, DatabaseAPI } from '../main/types/ipc-api.types'
declare global {
interface Window {
electron: ElectronAPI
electron: {
ipcRenderer: {
send: (channel: string, ...args: unknown[]) => void
on: (channel: string, func: (...args: unknown[]) => void) => void
once: (channel: string, func: (...args: unknown[]) => void) => void
removeListener: (channel: string, func: (...args: unknown[]) => void) => void
removeAllListeners: (channel: string) => void
invoke: (channel: string, ...args: unknown[]) => Promise<unknown>
}
process: {
versions: {
electron: string
chrome: string
node: string
}
}
file: FileAPI
extractor: ExtractorAPI
cleaner: CleanerAPI
database: DatabaseAPI
}
api: unknown
}
}

View File

@@ -1,22 +1,61 @@
import { contextBridge } from 'electron'
import { contextBridge, ipcRenderer } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
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'
// Custom APIs for renderer
const api = {}
const api = {
// File operations
file: {
readFile: (filePath: string) => ipcRenderer.invoke('file:read', filePath),
writeFile: (filePath: string, content: string) =>
ipcRenderer.invoke('file:write', filePath, content),
fileExists: (filePath: string) => ipcRenderer.invoke('file:exists', filePath),
listFiles: (dirPath: string) => ipcRenderer.invoke('file:list', dirPath)
},
// Extractor service
extractor: {
runExtractor: (input: ExtractorInput) => ipcRenderer.invoke('extractor:run', input)
},
// Cleaner service
cleaner: {
runCleaner: (input: CleanerInput) => ipcRenderer.invoke('cleaner:run', input)
},
// Database service
database: {
connectMySql: (config: MySqlConfig) => ipcRenderer.invoke('database:mysql:connect', config),
disconnectMySql: () => ipcRenderer.invoke('database:mysql:disconnect'),
isMySqlConnected: () => ipcRenderer.invoke('database:mysql:isConnected'),
queryMySql: (sql: string, params?: any[]) =>
ipcRenderer.invoke('database:mysql:query', sql, params),
// SQL Server
connectSqlServer: (config: SqlServerConfig) =>
ipcRenderer.invoke('database:sqlserver:connect', config),
disconnectSqlServer: () => ipcRenderer.invoke('database:sqlserver:disconnect'),
isSqlServerConnected: () => ipcRenderer.invoke('database:sqlserver:isConnected'),
querySqlServer: (sql: string, params?: Record<string, unknown>) =>
ipcRenderer.invoke('database:sqlserver:query', sql, params)
}
} as const
// Use `contextBridge` APIs to expose Electron APIs to
// renderer only if context isolation is enabled, otherwise
// just add to the DOM global.
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('electron', { ...electronAPI, ...api })
contextBridge.exposeInMainWorld('api', api)
} catch (error) {
console.error(error)
}
} else {
// @ts-ignore (define in dts)
window.electron = electronAPI
window.electron = { ...electronAPI, ...api }
// @ts-ignore (define in dts)
window.api = api
}

View File

@@ -1,34 +1,88 @@
import { useState } from 'react'
import Versions from './components/Versions'
import { ExtractorPage } from './pages/ExtractorPage'
import electronLogo from './assets/electron.svg'
type Page = 'home' | 'extractor' | 'cleaner'
function App(): React.JSX.Element {
const ipcHandle = (): void => window.electron.ipcRenderer.send('ping')
const [currentPage, setCurrentPage] = useState<Page>('home')
const renderPage = () => {
switch (currentPage) {
case 'extractor':
return <ExtractorPage />
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="https://electron-vite.org/" target="_blank" rel="noreferrer">
Documentation
</a>
</div>
</div>
<Versions />
</>
)
}
}
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="https://electron-vite.org/" target="_blank" rel="noreferrer">
Documentation
</a>
</div>
<div className="action">
<a target="_blank" rel="noreferrer" onClick={ipcHandle}>
Send IPC
</a>
</div>
</div>
<Versions></Versions>
</>
<div className="app">
{currentPage !== 'home' && (
<nav className="nav">
<button className="nav-btn" onClick={() => setCurrentPage('home')}>
</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>
)
}

View File

@@ -0,0 +1,92 @@
import React, { useState, useEffect } from 'react'
interface OrderNumberInputProps {
value: string
onChange: (value: string) => void
placeholder?: string
label?: string
}
/**
* OrderNumberInput - A textarea component for entering line-separated order numbers
* Displays a count of valid order numbers entered
*/
export const OrderNumberInput: React.FC<OrderNumberInputProps> = ({
value,
onChange,
placeholder = '请输入订单号,每行一个',
label = '订单号列表'
}) => {
const [count, setCount] = useState(0)
useEffect(() => {
// Count non-empty lines
const lines = value.split('\n').filter((line) => line.trim().length > 0)
setCount(lines.length)
}, [value])
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange(e.target.value)
}
return (
<div className="order-number-input">
<div className="input-header">
<label>{label}</label>
<span className="count-badge">{count} </span>
</div>
<textarea
value={value}
onChange={handleChange}
placeholder={placeholder}
rows={10}
className="order-textarea"
/>
<style>{`
.order-number-input {
margin-bottom: 16px;
}
.input-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.input-header label {
font-weight: 600;
font-size: 14px;
color: #333;
}
.count-badge {
background: #e6f7ff;
color: #1890ff;
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.order-textarea {
width: 100%;
padding: 12px;
border: 1px solid #d9d9d9;
border-radius: 6px;
font-size: 14px;
font-family: 'Consolas', 'Monaco', monospace;
resize: vertical;
transition: border-color 0.3s;
box-sizing: border-box;
}
.order-textarea:focus {
outline: none;
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
.order-textarea::placeholder {
color: #bfbfbf;
}
`}</style>
</div>
)
}
export default OrderNumberInput

View File

@@ -0,0 +1,357 @@
import React, { useState } from 'react'
import { OrderNumberInput } from '../components/OrderNumberInput'
// Extractor result type (matches the type from main process)
interface ExtractorResult {
downloadedFiles: string[]
mergedFile: string | null
recordCount: number
errors: string[]
}
interface ExtractorProgress {
message: string
progress: number
}
/**
* ExtractorPage - Main page for ERP data extraction
*/
export const ExtractorPage: React.FC = () => {
const [orderNumbers, setOrderNumbers] = useState('')
const [batchSize, setBatchSize] = useState(100)
const [isRunning, setIsRunning] = useState(false)
const [progress, setProgress] = useState<ExtractorProgress | null>(null)
const [result, setResult] = useState<ExtractorResult | null>(null)
const [error, setError] = useState<string | null>(null)
const handleExtract = async () => {
if (!orderNumbers.trim()) {
setError('请输入至少一个订单号')
return
}
setIsRunning(true)
setProgress(null)
setResult(null)
setError(null)
try {
const orderNumberList = orderNumbers
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
// Call extractor API through electron
const response = await window.electron.extractor.runExtractor({
orderNumbers: orderNumberList,
batchSize
})
if (response.success && response.data) {
setResult(response.data)
} else {
setError(response.error || '提取失败')
}
} catch (err) {
setError(err instanceof Error ? err.message : '发生未知错误')
} finally {
setIsRunning(false)
setProgress(null)
}
}
const handleReset = () => {
setOrderNumbers('')
setBatchSize(100)
setResult(null)
setError(null)
setProgress(null)
}
return (
<div className="extractor-page">
<h1 className="page-title">ERP </h1>
<div className="extractor-content">
{/* Input Section */}
<div className="input-section">
<OrderNumberInput
value={orderNumbers}
onChange={setOrderNumbers}
label="订单号列表"
placeholder="请输入订单号,每行一个&#10;例如:&#10;SC70202602120085&#10;SC70202602120120&#10;SC70202602120137"
/>
<div className="batch-size-input">
<label></label>
<input
type="number"
value={batchSize}
onChange={(e) => setBatchSize(parseInt(e.target.value) || 100)}
min={1}
max={1000}
disabled={isRunning}
/>
<span className="hint"></span>
</div>
<div className="button-group">
<button
className="btn btn-primary"
onClick={handleExtract}
disabled={isRunning || !orderNumbers.trim()}
>
{isRunning ? '提取中...' : '开始提取'}
</button>
<button className="btn btn-secondary" onClick={handleReset} disabled={isRunning}>
</button>
</div>
</div>
{/* Progress Section */}
{progress && (
<div className="progress-section">
<div className="progress-bar">
<div className="progress-fill" style={{ width: `${progress.progress}%` }} />
</div>
<p className="progress-message">{progress.message}</p>
</div>
)}
{/* Error Section */}
{error && (
<div className="error-section">
<h3></h3>
<p>{error}</p>
</div>
)}
{/* Result Section */}
{result && (
<div className="result-section">
<h3></h3>
<div className="result-stats">
<div className="stat-item">
<span className="stat-label"></span>
<span className="stat-value">{result.downloadedFiles.length}</span>
</div>
<div className="stat-item">
<span className="stat-label"></span>
<span className="stat-value">{result.recordCount}</span>
</div>
<div className="stat-item">
<span className="stat-label"></span>
<span className="stat-value error">{result.errors.length}</span>
</div>
</div>
{result.downloadedFiles.length > 0 && (
<div className="file-list">
<h4></h4>
<ul>
{result.downloadedFiles.map((file, index) => (
<li key={index}>{file}</li>
))}
</ul>
</div>
)}
{result.errors.length > 0 && (
<div className="error-list">
<h4></h4>
<ul>
{result.errors.map((err, index) => (
<li key={index} className="error-item">
{err}
</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
<style>{`
.extractor-page {
padding: 24px;
max-width: 800px;
margin: 0 auto;
}
.page-title {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 24px;
}
.extractor-content {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 24px;
}
.input-section {
margin-bottom: 24px;
}
.batch-size-input {
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 12px;
}
.batch-size-input label {
font-weight: 500;
font-size: 14px;
}
.batch-size-input input {
padding: 6px 12px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 14px;
width: 100px;
}
.batch-size-input input:disabled {
background: #f5f5f5;
cursor: not-allowed;
}
.batch-size-input .hint {
font-size: 12px;
color: #999;
}
.button-group {
display: flex;
gap: 12px;
}
.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;
}
.progress-section {
margin-top: 24px;
padding: 16px;
background: #f5f5f5;
border-radius: 6px;
}
.progress-bar {
height: 8px;
background: #e8e8e8;
border-radius: 4px;
overflow: hidden;
margin-bottom: 8px;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #1890ff, #40a9ff);
transition: width 0.3s;
}
.progress-message {
font-size: 14px;
color: #666;
margin: 0;
}
.error-section {
margin-top: 24px;
padding: 16px;
background: #fff1f0;
border: 1px solid #ffa39e;
border-radius: 6px;
}
.error-section h3 {
color: #ff4d4f;
margin: 0 0 8px 0;
font-size: 16px;
}
.error-section p {
color: #666;
margin: 0;
}
.result-section {
margin-top: 24px;
padding: 16px;
background: #f6ffed;
border: 1px solid #b7eb8f;
border-radius: 6px;
}
.result-section h3 {
color: #52c41a;
margin: 0 0 16px 0;
font-size: 16px;
}
.result-stats {
display: flex;
gap: 24px;
margin-bottom: 16px;
}
.stat-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.stat-label {
font-size: 12px;
color: #999;
}
.stat-value {
font-size: 24px;
font-weight: 600;
color: #333;
}
.stat-value.error {
color: #ff4d4f;
}
.file-list, .error-list {
margin-top: 16px;
}
.file-list h4, .error-list h4 {
font-size: 14px;
color: #666;
margin-bottom: 8px;
}
.file-list ul, .error-list ul {
list-style: none;
padding: 0;
margin: 0;
}
.file-list li, .error-list li {
padding: 6px 12px;
background: #fff;
border-radius: 4px;
margin-bottom: 4px;
font-size: 13px;
font-family: 'Consolas', 'Monaco', monospace;
}
.error-list li {
background: #fff1f0;
color: #ff4d4f;
}
`}</style>
</div>
)
}
export default ExtractorPage

View File

@@ -0,0 +1,24 @@
/**
* IPC Channel definitions
* Centralized channel names for main-renderer communication
*/
export const IPC_CHANNELS = {
// File operations
FILE_READ: 'file:read',
FILE_WRITE: 'file:write',
FILE_EXISTS: 'file:exists',
FILE_LIST: 'file:list',
// Extractor service
EXTRACTOR_RUN: 'extractor:run',
// Cleaner service
CLEANER_RUN: 'cleaner:run',
// Database service - MySQL
DATABASE_MYSQL_CONNECT: 'database:mysql:connect',
DATABASE_MYSQL_DISCONNECT: 'database:mysql:disconnect',
DATABASE_MYSQL_IS_CONNECTED: 'database:mysql:isConnected',
DATABASE_MYSQL_QUERY: 'database:mysql:query'
} as const