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:
@@ -2,6 +2,7 @@ import { app, shell, BrowserWindow, ipcMain } from 'electron'
|
|||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||||
import icon from '../../resources/icon.png?asset'
|
import icon from '../../resources/icon.png?asset'
|
||||||
|
import { registerIpcHandlers } from './ipc'
|
||||||
|
|
||||||
function createWindow(): void {
|
function createWindow(): void {
|
||||||
// Create the browser window.
|
// Create the browser window.
|
||||||
@@ -49,6 +50,9 @@ app.whenReady().then(() => {
|
|||||||
optimizer.watchWindowShortcuts(window)
|
optimizer.watchWindowShortcuts(window)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Register IPC handlers
|
||||||
|
registerIpcHandlers()
|
||||||
|
|
||||||
// IPC test
|
// IPC test
|
||||||
ipcMain.on('ping', () => console.log('pong'))
|
ipcMain.on('ping', () => console.log('pong'))
|
||||||
|
|
||||||
|
|||||||
49
src/main/ipc/cleaner-handler.ts
Normal file
49
src/main/ipc/cleaner-handler.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
181
src/main/ipc/database-handler.ts
Normal file
181
src/main/ipc/database-handler.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
49
src/main/ipc/extractor-handler.ts
Normal file
49
src/main/ipc/extractor-handler.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
55
src/main/ipc/file-handler.ts
Normal file
55
src/main/ipc/file-handler.ts
Normal 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
19
src/main/ipc/index.ts
Normal 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()
|
||||||
|
}
|
||||||
123
src/main/services/database/mysql.ts
Normal file
123
src/main/services/database/mysql.ts
Normal 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}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
184
src/main/services/database/sql-server.ts
Normal file
184
src/main/services/database/sql-server.ts
Normal 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}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
156
src/main/types/ipc-api.types.ts
Normal file
156
src/main/types/ipc-api.types.ts
Normal 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>
|
||||||
|
}
|
||||||
24
src/preload/index.d.ts
vendored
24
src/preload/index.d.ts
vendored
@@ -1,8 +1,28 @@
|
|||||||
import { ElectronAPI } from '@electron-toolkit/preload'
|
import type { FileAPI, ExtractorAPI, CleanerAPI, DatabaseAPI } from '../main/types/ipc-api.types'
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
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
|
api: unknown
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,61 @@
|
|||||||
import { contextBridge } from 'electron'
|
import { contextBridge, ipcRenderer } from 'electron'
|
||||||
import { electronAPI } from '@electron-toolkit/preload'
|
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
|
// 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
|
// Use `contextBridge` APIs to expose Electron APIs to
|
||||||
// renderer only if context isolation is enabled, otherwise
|
// renderer only if context isolation is enabled, otherwise
|
||||||
// just add to the DOM global.
|
// just add to the DOM global.
|
||||||
if (process.contextIsolated) {
|
if (process.contextIsolated) {
|
||||||
try {
|
try {
|
||||||
contextBridge.exposeInMainWorld('electron', electronAPI)
|
contextBridge.exposeInMainWorld('electron', { ...electronAPI, ...api })
|
||||||
contextBridge.exposeInMainWorld('api', api)
|
contextBridge.exposeInMainWorld('api', api)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// @ts-ignore (define in dts)
|
// @ts-ignore (define in dts)
|
||||||
window.electron = electronAPI
|
window.electron = { ...electronAPI, ...api }
|
||||||
// @ts-ignore (define in dts)
|
// @ts-ignore (define in dts)
|
||||||
window.api = api
|
window.api = api
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import Versions from './components/Versions'
|
import Versions from './components/Versions'
|
||||||
|
import { ExtractorPage } from './pages/ExtractorPage'
|
||||||
import electronLogo from './assets/electron.svg'
|
import electronLogo from './assets/electron.svg'
|
||||||
|
|
||||||
function App(): React.JSX.Element {
|
type Page = 'home' | 'extractor' | 'cleaner'
|
||||||
const ipcHandle = (): void => window.electron.ipcRenderer.send('ping')
|
|
||||||
|
|
||||||
|
function App(): React.JSX.Element {
|
||||||
|
const [currentPage, setCurrentPage] = useState<Page>('home')
|
||||||
|
|
||||||
|
const renderPage = () => {
|
||||||
|
switch (currentPage) {
|
||||||
|
case 'extractor':
|
||||||
|
return <ExtractorPage />
|
||||||
|
default:
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<img alt="logo" className="logo" src={electronLogo} />
|
<img alt="logo" className="logo" src={electronLogo} />
|
||||||
@@ -16,20 +25,65 @@ function App(): React.JSX.Element {
|
|||||||
Please try pressing <code>F12</code> to open the devTool
|
Please try pressing <code>F12</code> to open the devTool
|
||||||
</p>
|
</p>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
|
<div className="action">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setCurrentPage('extractor')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
数据提取
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
<div className="action">
|
<div className="action">
|
||||||
<a href="https://electron-vite.org/" target="_blank" rel="noreferrer">
|
<a href="https://electron-vite.org/" target="_blank" rel="noreferrer">
|
||||||
Documentation
|
Documentation
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="action">
|
|
||||||
<a target="_blank" rel="noreferrer" onClick={ipcHandle}>
|
|
||||||
Send IPC
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Versions />
|
||||||
<Versions></Versions>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
|||||||
92
src/renderer/src/components/OrderNumberInput.tsx
Normal file
92
src/renderer/src/components/OrderNumberInput.tsx
Normal 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
|
||||||
357
src/renderer/src/pages/ExtractorPage.tsx
Normal file
357
src/renderer/src/pages/ExtractorPage.tsx
Normal 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="请输入订单号,每行一个 例如: SC70202602120085 SC70202602120120 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
|
||||||
24
src/shared/ipc-channels.ts
Normal file
24
src/shared/ipc-channels.ts
Normal 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
|
||||||
270
tests/integration/mysql.test.ts
Normal file
270
tests/integration/mysql.test.ts
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
/**
|
||||||
|
* Integration tests for MySqlService
|
||||||
|
* These tests require a running MySQL instance
|
||||||
|
* Skip if MySQL is not available: npx vitest run --reporter=verbose
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||||
|
import { MySqlService, MySqlConfig } from '@services/database/mysql'
|
||||||
|
|
||||||
|
// MySQL test configuration
|
||||||
|
// In production, these should come from environment variables
|
||||||
|
const testConfig: MySqlConfig = {
|
||||||
|
host: process.env.MYSQL_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.MYSQL_PORT || '3306'),
|
||||||
|
user: process.env.MYSQL_USER || 'root',
|
||||||
|
password: process.env.MYSQL_PASSWORD || 'password',
|
||||||
|
database: process.env.MYSQL_DATABASE || 'test_db'
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MySqlService Integration Tests', () => {
|
||||||
|
let service: MySqlService
|
||||||
|
let isMysqlAvailable = true
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
service = new MySqlService(testConfig)
|
||||||
|
|
||||||
|
// Try to connect, skip tests if MySQL is not available
|
||||||
|
try {
|
||||||
|
await service.connect()
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('MySQL not available, skipping integration tests')
|
||||||
|
isMysqlAvailable = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (service && isMysqlAvailable) {
|
||||||
|
await service.disconnect()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('connect()', () => {
|
||||||
|
it('should connect to MySQL database', async () => {
|
||||||
|
if (!isMysqlAvailable) {
|
||||||
|
console.log('Skipped: MySQL not available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(service.isConnected()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw error when already connected', async () => {
|
||||||
|
if (!isMysqlAvailable) {
|
||||||
|
console.log('Skipped: MySQL not available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already connected in beforeAll
|
||||||
|
await expect(service.connect()).rejects.toThrow('Already connected')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isConnected()', () => {
|
||||||
|
it('should return true when connected', async () => {
|
||||||
|
if (!isMysqlAvailable) {
|
||||||
|
console.log('Skipped: MySQL not available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(service.isConnected()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false when not connected', async () => {
|
||||||
|
if (!isMysqlAvailable) {
|
||||||
|
console.log('Skipped: MySQL not available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const newService = new MySqlService(testConfig)
|
||||||
|
expect(newService.isConnected()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('disconnect()', () => {
|
||||||
|
it('should disconnect from MySQL database', async () => {
|
||||||
|
if (!isMysqlAvailable) {
|
||||||
|
console.log('Skipped: MySQL not available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await service.disconnect()
|
||||||
|
expect(service.isConnected()).toBe(false)
|
||||||
|
|
||||||
|
// Reconnect for other tests
|
||||||
|
await service.connect()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not throw when already disconnected', async () => {
|
||||||
|
if (!isMysqlAvailable) {
|
||||||
|
console.log('Skipped: MySQL not available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const newService = new MySqlService(testConfig)
|
||||||
|
await expect(newService.disconnect()).resolves.not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('query()', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
if (!isMysqlAvailable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure connected
|
||||||
|
if (!service.isConnected()) {
|
||||||
|
await service.connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create test table
|
||||||
|
await service.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS test_users (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
email VARCHAR(100),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Clean up test data
|
||||||
|
await service.query('DELETE FROM test_users WHERE email LIKE ?', ['%test%'])
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (!isMysqlAvailable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop test table
|
||||||
|
try {
|
||||||
|
await service.query('DROP TABLE IF EXISTS test_users')
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should execute SELECT 1', async () => {
|
||||||
|
if (!isMysqlAvailable) {
|
||||||
|
console.log('Skipped: MySQL not available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await service.query('SELECT 1 as value')
|
||||||
|
|
||||||
|
expect(result.columns).toContain('value')
|
||||||
|
expect(result.rowCount).toBe(1)
|
||||||
|
expect(result.rows[0]).toEqual({ value: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should execute INSERT with parameters', async () => {
|
||||||
|
if (!isMysqlAvailable) {
|
||||||
|
console.log('Skipped: MySQL not available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await service.query('INSERT INTO test_users (name, email) VALUES (?, ?)', [
|
||||||
|
'Test User',
|
||||||
|
'test@example.com'
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(result.rowCount).toBeGreaterThanOrEqual(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should execute SELECT with parameters', async () => {
|
||||||
|
if (!isMysqlAvailable) {
|
||||||
|
console.log('Skipped: MySQL not available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await service.query('SELECT id, name, email FROM test_users WHERE email = ?', [
|
||||||
|
'test@example.com'
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(result.columns).toEqual(['id', 'name', 'email'])
|
||||||
|
expect(result.rowCount).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(result.rows[0]?.name).toBe('Test User')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw error when not connected', async () => {
|
||||||
|
if (!isMysqlAvailable) {
|
||||||
|
console.log('Skipped: MySQL not available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const newService = new MySqlService(testConfig)
|
||||||
|
await expect(newService.query('SELECT 1')).rejects.toThrow('Not connected')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('transaction()', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
if (!isMysqlAvailable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure connected and table exists
|
||||||
|
if (!service.isConnected()) {
|
||||||
|
await service.connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
await service.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS test_accounts (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
balance DECIMAL(10, 2) NOT NULL DEFAULT 0
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Clean up and initialize
|
||||||
|
await service.query('DELETE FROM test_accounts')
|
||||||
|
await service.query('INSERT INTO test_accounts (balance) VALUES (100), (50)')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (!isMysqlAvailable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await service.query('DROP TABLE IF EXISTS test_accounts')
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should execute multiple queries in a transaction', async () => {
|
||||||
|
if (!isMysqlAvailable) {
|
||||||
|
console.log('Skipped: MySQL not available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transfer 20 from account 1 to account 2
|
||||||
|
await service.transaction([
|
||||||
|
{ sql: 'UPDATE test_accounts SET balance = balance - ? WHERE id = ?', params: [20, 1] },
|
||||||
|
{ sql: 'UPDATE test_accounts SET balance = balance + ? WHERE id = ?', params: [20, 2] }
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await service.query('SELECT balance FROM test_accounts ORDER BY id')
|
||||||
|
|
||||||
|
expect(result.rows[0]?.balance).toEqual(expect.anything())
|
||||||
|
expect(result.rows[1]?.balance).toEqual(expect.anything())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should rollback on error', async () => {
|
||||||
|
if (!isMysqlAvailable) {
|
||||||
|
console.log('Skipped: MySQL not available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get initial balances
|
||||||
|
const initial = await service.query('SELECT balance FROM test_accounts ORDER BY id')
|
||||||
|
|
||||||
|
// Try invalid transaction (syntax error)
|
||||||
|
await expect(service.transaction([{ sql: 'INVALID SQL STATEMENT' }])).rejects.toThrow()
|
||||||
|
|
||||||
|
// Verify balances unchanged
|
||||||
|
const result = await service.query('SELECT balance FROM test_accounts ORDER BY id')
|
||||||
|
expect(result.rows).toEqual(initial.rows)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
294
tests/integration/sql-server.test.ts
Normal file
294
tests/integration/sql-server.test.ts
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
/**
|
||||||
|
* Integration tests for SqlServerService
|
||||||
|
* These tests require a running SQL Server instance
|
||||||
|
* Skip if SQL Server is not available
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||||
|
import { SqlServerService, SqlServerConfig } from '@main/services/database/sql-server'
|
||||||
|
import * as sql from 'mssql'
|
||||||
|
|
||||||
|
// SQL Server test configuration
|
||||||
|
const testConfig: SqlServerConfig = {
|
||||||
|
server: process.env.SQL_SERVER_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.SQL_SERVER_PORT || '1433'),
|
||||||
|
user: process.env.SQL_SERVER_USER || 'sa',
|
||||||
|
password: process.env.SQL_SERVER_PASSWORD || 'password',
|
||||||
|
database: process.env.SQL_SERVER_DATABASE || 'testdb',
|
||||||
|
options: {
|
||||||
|
encrypt: false, // Set to true for Azure SQL
|
||||||
|
trustServerCertificate: true // Set to false in production with valid cert
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SqlServerService Integration Tests', () => {
|
||||||
|
let service: SqlServerService
|
||||||
|
let isSqlServerAvailable = true
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
service = new SqlServerService(testConfig)
|
||||||
|
|
||||||
|
// Try to connect, skip tests if SQL Server is not available
|
||||||
|
try {
|
||||||
|
await service.connect()
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('SQL Server not available, skipping integration tests')
|
||||||
|
isSqlServerAvailable = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (service && isSqlServerAvailable) {
|
||||||
|
await service.disconnect()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('connect()', () => {
|
||||||
|
it('should connect to SQL Server database', async () => {
|
||||||
|
if (!isSqlServerAvailable) {
|
||||||
|
console.log('Skipped: SQL Server not available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(service.isConnected()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw error when already connected', async () => {
|
||||||
|
if (!isSqlServerAvailable) {
|
||||||
|
console.log('Skipped: SQL Server not available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already connected in beforeAll
|
||||||
|
await expect(service.connect()).rejects.toThrow('Already connected')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isConnected()', () => {
|
||||||
|
it('should return true when connected', async () => {
|
||||||
|
if (!isSqlServerAvailable) {
|
||||||
|
console.log('Skipped: SQL Server not available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(service.isConnected()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false when not connected', async () => {
|
||||||
|
if (!isSqlServerAvailable) {
|
||||||
|
console.log('Skipped: SQL Server not available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const newService = new SqlServerService(testConfig)
|
||||||
|
expect(newService.isConnected()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('disconnect()', () => {
|
||||||
|
it('should disconnect from SQL Server database', async () => {
|
||||||
|
if (!isSqlServerAvailable) {
|
||||||
|
console.log('Skipped: SQL Server not available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await service.disconnect()
|
||||||
|
expect(service.isConnected()).toBe(false)
|
||||||
|
|
||||||
|
// Reconnect for other tests
|
||||||
|
await service.connect()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not throw when already disconnected', async () => {
|
||||||
|
if (!isSqlServerAvailable) {
|
||||||
|
console.log('Skipped: SQL Server not available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const newService = new SqlServerService(testConfig)
|
||||||
|
await expect(newService.disconnect()).resolves.not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('query()', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
if (!isSqlServerAvailable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure connected
|
||||||
|
if (!service.isConnected()) {
|
||||||
|
await service.connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create test table
|
||||||
|
await service.query(`
|
||||||
|
IF OBJECT_ID('dbo.TestUsers', 'U') IS NOT NULL
|
||||||
|
DROP TABLE dbo.TestUsers;
|
||||||
|
|
||||||
|
CREATE TABLE dbo.TestUsers (
|
||||||
|
id INT IDENTITY(1,1) PRIMARY KEY,
|
||||||
|
name NVARCHAR(100) NOT NULL,
|
||||||
|
email NVARCHAR(100),
|
||||||
|
created_at DATETIME DEFAULT GETDATE()
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Clean up test data
|
||||||
|
await service.query('DELETE FROM dbo.TestUsers WHERE email LIKE @email', {
|
||||||
|
email: { value: '%test%', type: sql.NVarChar }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (!isSqlServerAvailable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop test table
|
||||||
|
try {
|
||||||
|
await service.query('DROP TABLE IF EXISTS dbo.TestUsers')
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should execute SELECT 1', async () => {
|
||||||
|
if (!isSqlServerAvailable) {
|
||||||
|
console.log('Skipped: SQL Server not available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await service.query('SELECT 1 as value')
|
||||||
|
|
||||||
|
expect(result.columns).toContain('value')
|
||||||
|
expect(result.rowCount).toBe(1)
|
||||||
|
expect(result.rows[0]).toEqual({ value: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should execute INSERT with parameters', async () => {
|
||||||
|
if (!isSqlServerAvailable) {
|
||||||
|
console.log('Skipped: SQL Server not available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await service.query(
|
||||||
|
'INSERT INTO dbo.TestUsers (name, email) VALUES (@name, @email)',
|
||||||
|
{
|
||||||
|
name: { value: 'Test User', type: sql.NVarChar },
|
||||||
|
email: { value: 'test@example.com', type: sql.NVarChar }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.rowCount).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should execute SELECT with parameters', async () => {
|
||||||
|
if (!isSqlServerAvailable) {
|
||||||
|
console.log('Skipped: SQL Server not available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await service.query(
|
||||||
|
'SELECT id, name, email FROM dbo.TestUsers WHERE email = @email',
|
||||||
|
{
|
||||||
|
email: { value: 'test@example.com', type: sql.NVarChar }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.columns).toEqual(['id', 'name', 'email'])
|
||||||
|
expect(result.rowCount).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(result.rows[0]?.name).toBe('Test User')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw error when not connected', async () => {
|
||||||
|
if (!isSqlServerAvailable) {
|
||||||
|
console.log('Skipped: SQL Server not available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const newService = new SqlServerService(testConfig)
|
||||||
|
await expect(newService.query('SELECT 1')).rejects.toThrow('Not connected to SQL Server')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('transaction()', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
if (!isSqlServerAvailable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure connected and table exists
|
||||||
|
if (!service.isConnected()) {
|
||||||
|
await service.connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
await service.query(`
|
||||||
|
IF OBJECT_ID('dbo.TestAccounts', 'U') IS NOT NULL
|
||||||
|
DROP TABLE dbo.TestAccounts;
|
||||||
|
|
||||||
|
CREATE TABLE dbo.TestAccounts (
|
||||||
|
id INT IDENTITY(1,1) PRIMARY KEY,
|
||||||
|
balance DECIMAL(10, 2) NOT NULL DEFAULT 0
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Clean up and initialize
|
||||||
|
await service.query('DELETE FROM dbo.TestAccounts')
|
||||||
|
await service.query('INSERT INTO dbo.TestAccounts (balance) VALUES (100), (50)')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (!isSqlServerAvailable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await service.query('DROP TABLE IF EXISTS dbo.TestAccounts')
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should execute multiple queries in a transaction', async () => {
|
||||||
|
if (!isSqlServerAvailable) {
|
||||||
|
console.log('Skipped: SQL Server not available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transfer 20 from account 1 to account 2
|
||||||
|
await service.transaction([
|
||||||
|
{
|
||||||
|
sql: 'UPDATE dbo.TestAccounts SET balance = balance - @amount WHERE id = @id',
|
||||||
|
params: { amount: { value: 20, type: sql.Decimal }, id: { value: 1, type: sql.Int } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sql: 'UPDATE dbo.TestAccounts SET balance = balance + @amount WHERE id = @id',
|
||||||
|
params: { amount: { value: 20, type: sql.Decimal }, id: { value: 2, type: sql.Int } }
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await service.query('SELECT balance FROM dbo.TestAccounts ORDER BY id')
|
||||||
|
|
||||||
|
expect(result.rows[0]?.balance).toBeDefined()
|
||||||
|
expect(result.rows[1]?.balance).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should rollback on error', async () => {
|
||||||
|
if (!isSqlServerAvailable) {
|
||||||
|
console.log('Skipped: SQL Server not available')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get initial balances
|
||||||
|
const initial = await service.query('SELECT balance FROM dbo.TestAccounts ORDER BY id')
|
||||||
|
|
||||||
|
// Try invalid transaction (syntax error)
|
||||||
|
await expect(service.transaction([{ sql: 'INVALID SQL STATEMENT' }])).rejects.toThrow()
|
||||||
|
|
||||||
|
// Verify balances unchanged
|
||||||
|
const result = await service.query('SELECT balance FROM dbo.TestAccounts ORDER BY id')
|
||||||
|
expect(result.rows).toEqual(initial.rows)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
69
tests/unit/mysql.test.ts
Normal file
69
tests/unit/mysql.test.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for MySqlService
|
||||||
|
* These tests do not require a MySQL instance
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { MySqlService } from '@services/database/mysql'
|
||||||
|
|
||||||
|
const mockConfig = {
|
||||||
|
host: 'localhost',
|
||||||
|
port: 3306,
|
||||||
|
user: 'test',
|
||||||
|
password: 'test',
|
||||||
|
database: 'testdb'
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MySqlService Unit Tests', () => {
|
||||||
|
let service: MySqlService
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new MySqlService(mockConfig)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('should create service with config', () => {
|
||||||
|
expect(service).toBeDefined()
|
||||||
|
expect(service.isConnected()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isConnected', () => {
|
||||||
|
it('should return false when not connected', () => {
|
||||||
|
expect(service.isConnected()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('connect', () => {
|
||||||
|
it('should throw error with invalid credentials', async () => {
|
||||||
|
// This tests error handling without needing a real server
|
||||||
|
const invalidConfig = {
|
||||||
|
...mockConfig,
|
||||||
|
host: 'invalid-host-that-does-not-exist'
|
||||||
|
}
|
||||||
|
const invalidService = new MySqlService(invalidConfig)
|
||||||
|
|
||||||
|
await expect(invalidService.connect()).rejects.toThrow('Failed to connect to MySQL')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('query', () => {
|
||||||
|
it('should throw error when not connected', async () => {
|
||||||
|
await expect(service.query('SELECT 1')).rejects.toThrow('Not connected to MySQL')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('transaction', () => {
|
||||||
|
it('should throw error when not connected', async () => {
|
||||||
|
await expect(service.transaction([{ sql: 'SELECT 1' }])).rejects.toThrow(
|
||||||
|
'Not connected to MySQL'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('disconnect', () => {
|
||||||
|
it('should resolve when not connected', async () => {
|
||||||
|
await expect(service.disconnect()).resolves.not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
73
tests/unit/sql-server.test.ts
Normal file
73
tests/unit/sql-server.test.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for SqlServerService
|
||||||
|
* These tests do not require a SQL Server instance
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { SqlServerService } from '@main/services/database/sql-server'
|
||||||
|
|
||||||
|
const mockConfig = {
|
||||||
|
server: 'localhost',
|
||||||
|
port: 1433,
|
||||||
|
user: 'test',
|
||||||
|
password: 'test',
|
||||||
|
database: 'testdb',
|
||||||
|
options: {
|
||||||
|
encrypt: false,
|
||||||
|
trustServerCertificate: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SqlServerService Unit Tests', () => {
|
||||||
|
let service: SqlServerService
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new SqlServerService(mockConfig)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('constructor', () => {
|
||||||
|
it('should create service with config', () => {
|
||||||
|
expect(service).toBeDefined()
|
||||||
|
expect(service.isConnected()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isConnected', () => {
|
||||||
|
it('should return false when not connected', () => {
|
||||||
|
expect(service.isConnected()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('connect', () => {
|
||||||
|
it('should throw error with invalid credentials', async () => {
|
||||||
|
// This tests error handling without needing a real server
|
||||||
|
const invalidConfig = {
|
||||||
|
...mockConfig,
|
||||||
|
server: 'invalid-host-that-does-not-exist'
|
||||||
|
}
|
||||||
|
const invalidService = new SqlServerService(invalidConfig)
|
||||||
|
|
||||||
|
await expect(invalidService.connect()).rejects.toThrow('Failed to connect to SQL Server')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('query', () => {
|
||||||
|
it('should throw error when not connected', async () => {
|
||||||
|
await expect(service.query('SELECT 1')).rejects.toThrow('Not connected to SQL Server')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('transaction', () => {
|
||||||
|
it('should throw error when not connected', async () => {
|
||||||
|
await expect(service.transaction([{ sql: 'SELECT 1' }])).rejects.toThrow(
|
||||||
|
'Not connected to SQL Server'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('disconnect', () => {
|
||||||
|
it('should resolve when not connected', async () => {
|
||||||
|
await expect(service.disconnect()).resolves.not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
File diff suppressed because one or more lines are too long
@@ -4,7 +4,8 @@
|
|||||||
"src/renderer/src/env.d.ts",
|
"src/renderer/src/env.d.ts",
|
||||||
"src/renderer/src/**/*",
|
"src/renderer/src/**/*",
|
||||||
"src/renderer/src/**/*.tsx",
|
"src/renderer/src/**/*.tsx",
|
||||||
"src/preload/*.d.ts"
|
"src/preload/*.d.ts",
|
||||||
|
"src/main/types/*.ts"
|
||||||
],
|
],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"composite": true,
|
||||||
|
|||||||
1
tsconfig.web.tsbuildinfo
Normal file
1
tsconfig.web.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
@@ -1,4 +1,5 @@
|
|||||||
import { defineConfig } from 'vitest/config'
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
@@ -11,5 +12,13 @@ export default defineConfig({
|
|||||||
provider: 'v8',
|
provider: 'v8',
|
||||||
reporter: ['text', 'json', 'html']
|
reporter: ['text', 'json', 'html']
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@main': path.resolve(__dirname, './src/main'),
|
||||||
|
'@services': path.resolve(__dirname, './src/main/services'),
|
||||||
|
'@types': path.resolve(__dirname, './src/main/types'),
|
||||||
|
'@': path.resolve(__dirname, './src')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user