Compare commits
9 Commits
8dbdf6f394
...
bfa2445c76
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfa2445c76 | ||
|
|
b62ae10650 | ||
|
|
974eaac6dd | ||
|
|
2f5dc7607d | ||
|
|
a06276127d | ||
|
|
29cb79abfd | ||
|
|
dc3d577f6f | ||
|
|
50041d2a7b | ||
|
|
9833552652 |
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -12,13 +12,13 @@
|
|||||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||||
"start": "electron-vite preview",
|
"start": "electron-vite preview",
|
||||||
"dev": "electron-vite dev",
|
"dev": "chcp 65001 && electron-vite dev",
|
||||||
"build": "npm run typecheck && electron-vite build",
|
"build": "chcp 65001 && npm run typecheck && electron-vite build",
|
||||||
"postinstall": "electron-builder install-app-deps",
|
"postinstall": "electron-builder install-app-deps",
|
||||||
"build:unpack": "npm run build && electron-builder --dir",
|
"build:unpack": "chcp 65001 && npm run build && electron-builder --dir",
|
||||||
"build:win": "npm run build && electron-builder --win",
|
"build:win": "chcp 65001 && npm run build && electron-builder --win",
|
||||||
"build:mac": "electron-vite build && electron-builder --mac",
|
"build:mac": "chcp 65001 && electron-vite build && electron-builder --mac",
|
||||||
"build:linux": "electron-vite build && electron-builder --linux",
|
"build:linux": "chcp 65001 && electron-vite build && electron-builder --linux",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:run": "vitest run",
|
"test:run": "vitest run",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ dotenv.config({ path: resolve(__dirname, '../../.env') })
|
|||||||
function createWindow(): void {
|
function createWindow(): void {
|
||||||
// Create the browser window.
|
// Create the browser window.
|
||||||
const mainWindow = new BrowserWindow({
|
const mainWindow = new BrowserWindow({
|
||||||
width: 900,
|
width: 1200,
|
||||||
height: 670,
|
height: 670,
|
||||||
show: false,
|
show: false,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ErpAuthService } from '../services/erp/erp-auth'
|
|||||||
import { CleanerService } from '../services/erp/cleaner'
|
import { CleanerService } from '../services/erp/cleaner'
|
||||||
import { OrderNumberResolver } from '../services/erp/order-resolver'
|
import { OrderNumberResolver } from '../services/erp/order-resolver'
|
||||||
import { MySqlService } from '../services/database/mysql'
|
import { MySqlService } from '../services/database/mysql'
|
||||||
|
import { SqlServerService } from '../services/database/sql-server'
|
||||||
import { ResultExporter } from '../services/excel/result-exporter'
|
import { ResultExporter } from '../services/excel/result-exporter'
|
||||||
import { createLogger } from '../services/logger'
|
import { createLogger } from '../services/logger'
|
||||||
import { withErrorHandling, type IpcResult } from './index'
|
import { withErrorHandling, type IpcResult } from './index'
|
||||||
@@ -16,19 +17,45 @@ import type {
|
|||||||
|
|
||||||
const log = createLogger('CleanerHandler')
|
const log = createLogger('CleanerHandler')
|
||||||
|
|
||||||
/**
|
async function getDatabaseService(): Promise<MySqlService | SqlServerService> {
|
||||||
* Register IPC handlers for cleaner service
|
const dbType = process.env.DB_TYPE?.toLowerCase()
|
||||||
*/
|
|
||||||
|
if (dbType === 'sqlserver' || dbType === 'mssql') {
|
||||||
|
const sqlServerService = new SqlServerService({
|
||||||
|
server: process.env.DB_SERVER || 'localhost',
|
||||||
|
port: parseInt(process.env.DB_SQLSERVER_PORT || '1433', 10),
|
||||||
|
user: process.env.DB_USERNAME || 'sa',
|
||||||
|
password: process.env.DB_PASSWORD || '',
|
||||||
|
database: process.env.DB_NAME || '',
|
||||||
|
options: {
|
||||||
|
encrypt: false,
|
||||||
|
trustServerCertificate: process.env.DB_TRUST_SERVER_CERTIFICATE === 'yes'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await sqlServerService.connect()
|
||||||
|
return sqlServerService
|
||||||
|
} else {
|
||||||
|
const mysqlService = new MySqlService({
|
||||||
|
host: process.env.DB_MYSQL_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.DB_MYSQL_PORT || '3306', 10),
|
||||||
|
user: process.env.DB_USERNAME || 'root',
|
||||||
|
password: process.env.DB_PASSWORD || '',
|
||||||
|
database: process.env.DB_NAME || ''
|
||||||
|
})
|
||||||
|
await mysqlService.connect()
|
||||||
|
return mysqlService
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function registerCleanerHandlers(): void {
|
export function registerCleanerHandlers(): void {
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'cleaner:run',
|
'cleaner:run',
|
||||||
async (_event, input: CleanerInput): Promise<IpcResult<CleanerResult>> => {
|
async (_event, input: CleanerInput): Promise<IpcResult<CleanerResult>> => {
|
||||||
return withErrorHandling(async () => {
|
return withErrorHandling(async () => {
|
||||||
let authService: ErpAuthService | null = null
|
let authService: ErpAuthService | null = null
|
||||||
let mysqlService: MySqlService | null = null
|
let dbService: MySqlService | SqlServerService | null = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check environment variables
|
|
||||||
const erpUrl = process.env.ERP_URL || ''
|
const erpUrl = process.env.ERP_URL || ''
|
||||||
const erpUsername = process.env.ERP_USERNAME || ''
|
const erpUsername = process.env.ERP_USERNAME || ''
|
||||||
const erpPassword = process.env.ERP_PASSWORD || ''
|
const erpPassword = process.env.ERP_PASSWORD || ''
|
||||||
@@ -45,32 +72,24 @@ export function registerCleanerHandlers(): void {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve order numbers (convert productionIDs to 生产订单号)
|
const dbType = process.env.DB_TYPE?.toLowerCase()
|
||||||
const mysqlConfig = {
|
log.info(
|
||||||
host: process.env.DB_MYSQL_HOST || 'localhost',
|
`Connecting to ${dbType === 'sqlserver' || dbType === 'mssql' ? 'SQL Server' : 'MySQL'} for order resolution...`
|
||||||
port: parseInt(process.env.DB_MYSQL_PORT || '3306', 10),
|
)
|
||||||
user: process.env.DB_USERNAME || 'root',
|
|
||||||
password: process.env.DB_PASSWORD || '',
|
|
||||||
database: process.env.DB_NAME || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info('Connecting to MySQL for order resolution...')
|
|
||||||
mysqlService = new MySqlService(mysqlConfig)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await mysqlService.connect()
|
dbService = await getDatabaseService()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new DatabaseQueryError(
|
throw new DatabaseQueryError(
|
||||||
'MySQL 连接失败',
|
'数据库连接失败',
|
||||||
'DB_CONNECTION_FAILED',
|
'DB_CONNECTION_FAILED',
|
||||||
error instanceof Error ? error : undefined
|
error instanceof Error ? error : undefined
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolver = new OrderNumberResolver(mysqlService)
|
const resolver = new OrderNumberResolver(dbService)
|
||||||
const mappings = await resolver.resolve(input.orderNumbers)
|
const mappings = await resolver.resolve(input.orderNumbers)
|
||||||
|
|
||||||
// Get valid order numbers and warnings
|
|
||||||
const validOrderNumbers = resolver.getValidOrderNumbers(mappings)
|
const validOrderNumbers = resolver.getValidOrderNumbers(mappings)
|
||||||
const warnings = resolver.getWarnings(mappings)
|
const warnings = resolver.getWarnings(mappings)
|
||||||
|
|
||||||
@@ -87,12 +106,11 @@ export function registerCleanerHandlers(): void {
|
|||||||
|
|
||||||
log.info('Resolved order numbers', { count: validOrderNumbers.length })
|
log.info('Resolved order numbers', { count: validOrderNumbers.length })
|
||||||
|
|
||||||
// Create auth service and login
|
|
||||||
authService = new ErpAuthService({
|
authService = new ErpAuthService({
|
||||||
url: erpUrl,
|
url: erpUrl,
|
||||||
username: erpUsername,
|
username: erpUsername,
|
||||||
password: erpPassword,
|
password: erpPassword,
|
||||||
headless: true
|
headless: input.headless ?? true
|
||||||
})
|
})
|
||||||
|
|
||||||
log.info('Logging in to ERP...')
|
log.info('Logging in to ERP...')
|
||||||
@@ -107,7 +125,6 @@ export function registerCleanerHandlers(): void {
|
|||||||
}
|
}
|
||||||
log.info('Login successful')
|
log.info('Login successful')
|
||||||
|
|
||||||
// Create cleaner service and run cleaning with resolved order numbers
|
|
||||||
const cleaner = new CleanerService(authService)
|
const cleaner = new CleanerService(authService)
|
||||||
|
|
||||||
const modifiedInput: CleanerInput = {
|
const modifiedInput: CleanerInput = {
|
||||||
@@ -119,7 +136,6 @@ export function registerCleanerHandlers(): void {
|
|||||||
log.info('Starting cleaning', { orderCount: validOrderNumbers.length })
|
log.info('Starting cleaning', { orderCount: validOrderNumbers.length })
|
||||||
const result = await cleaner.clean(modifiedInput)
|
const result = await cleaner.clean(modifiedInput)
|
||||||
|
|
||||||
// Add warnings to result errors if any
|
|
||||||
if (warnings.length > 0) {
|
if (warnings.length > 0) {
|
||||||
result.errors = [...warnings, ...result.errors]
|
result.errors = [...warnings, ...result.errors]
|
||||||
}
|
}
|
||||||
@@ -131,7 +147,6 @@ export function registerCleanerHandlers(): void {
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
} finally {
|
} finally {
|
||||||
// Clean up: close browser
|
|
||||||
if (authService) {
|
if (authService) {
|
||||||
try {
|
try {
|
||||||
await authService.close()
|
await authService.close()
|
||||||
@@ -143,13 +158,12 @@ export function registerCleanerHandlers(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up: disconnect MySQL
|
if (dbService) {
|
||||||
if (mysqlService) {
|
|
||||||
try {
|
try {
|
||||||
await mysqlService.disconnect()
|
await dbService.disconnect()
|
||||||
log.debug('MySQL disconnected')
|
log.debug('Database disconnected')
|
||||||
} catch (closeError) {
|
} catch (closeError) {
|
||||||
log.warn('Error disconnecting MySQL', {
|
log.warn('Error disconnecting database', {
|
||||||
error: closeError instanceof Error ? closeError.message : String(closeError)
|
error: closeError instanceof Error ? closeError.message : String(closeError)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -159,9 +173,6 @@ export function registerCleanerHandlers(): void {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* Export validation results to Excel
|
|
||||||
*/
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'cleaner:exportResults',
|
'cleaner:exportResults',
|
||||||
async (_event, items: ExportResultItem[]): Promise<ExportResultResponse> => {
|
async (_event, items: ExportResultItem[]): Promise<ExportResultResponse> => {
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
import { ipcMain } from 'electron'
|
import { ipcMain, shell } from 'electron'
|
||||||
import * as fs from 'fs/promises'
|
import * as fs from 'fs/promises'
|
||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
import { createLogger } from '../services/logger'
|
import { createLogger } from '../services/logger'
|
||||||
|
|
||||||
const log = createLogger('FileHandler')
|
const log = createLogger('FileHandler')
|
||||||
|
|
||||||
/**
|
|
||||||
* Register IPC handlers for file operations
|
|
||||||
*/
|
|
||||||
export function registerFileHandlers(): void {
|
export function registerFileHandlers(): void {
|
||||||
// Read file content
|
|
||||||
ipcMain.handle('file:read', async (_event, filePath: string): Promise<string> => {
|
ipcMain.handle('file:read', async (_event, filePath: string): Promise<string> => {
|
||||||
try {
|
try {
|
||||||
log.debug('Reading file', { filePath })
|
log.debug('Reading file', { filePath })
|
||||||
@@ -21,11 +17,9 @@ export function registerFileHandlers(): void {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Write content to file
|
|
||||||
ipcMain.handle('file:write', async (_event, filePath: string, content: string): Promise<void> => {
|
ipcMain.handle('file:write', async (_event, filePath: string, content: string): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
log.debug('Writing file', { filePath })
|
log.debug('Writing file', { filePath })
|
||||||
// Ensure directory exists
|
|
||||||
const dir = path.dirname(filePath)
|
const dir = path.dirname(filePath)
|
||||||
await fs.mkdir(dir, { recursive: true })
|
await fs.mkdir(dir, { recursive: true })
|
||||||
await fs.writeFile(filePath, content, 'utf-8')
|
await fs.writeFile(filePath, content, 'utf-8')
|
||||||
@@ -36,7 +30,6 @@ export function registerFileHandlers(): void {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Check if file exists
|
|
||||||
ipcMain.handle('file:exists', async (_event, filePath: string): Promise<boolean> => {
|
ipcMain.handle('file:exists', async (_event, filePath: string): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
await fs.access(filePath)
|
await fs.access(filePath)
|
||||||
@@ -46,7 +39,6 @@ export function registerFileHandlers(): void {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// List files in directory
|
|
||||||
ipcMain.handle('file:list', async (_event, dirPath: string): Promise<string[]> => {
|
ipcMain.handle('file:list', async (_event, dirPath: string): Promise<string[]> => {
|
||||||
try {
|
try {
|
||||||
log.debug('Listing directory', { dirPath })
|
log.debug('Listing directory', { dirPath })
|
||||||
@@ -61,4 +53,15 @@ export function registerFileHandlers(): void {
|
|||||||
throw new Error(message)
|
throw new Error(message)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('file:openPath', async (_event, filePath: string): Promise<void> => {
|
||||||
|
try {
|
||||||
|
log.debug('Opening path in explorer', { filePath })
|
||||||
|
await shell.openPath(filePath)
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to open path'
|
||||||
|
log.error('Failed to open path', { filePath, error: message })
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ async function getValidationDatabaseService(): Promise<MySqlService | SqlServerS
|
|||||||
password: process.env.DB_PASSWORD || '',
|
password: process.env.DB_PASSWORD || '',
|
||||||
database: process.env.DB_NAME || '',
|
database: process.env.DB_NAME || '',
|
||||||
options: {
|
options: {
|
||||||
encrypt: process.env.DB_TRUST_SERVER_CERTIFICATE === 'yes',
|
encrypt: false,
|
||||||
trustServerCertificate: process.env.DB_TRUST_SERVER_CERTIFICATE === 'yes'
|
trustServerCertificate: process.env.DB_TRUST_SERVER_CERTIFICATE === 'yes'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -162,37 +162,52 @@ async function getSourceNumbersFromInputs(
|
|||||||
// Column name: 生产订单号 (SourceNumber)
|
// Column name: 生产订单号 (SourceNumber)
|
||||||
if (productionIds.length > 0) {
|
if (productionIds.length > 0) {
|
||||||
const contractTableName = getTableName('productionContractData_26年压力表合同数据')
|
const contractTableName = getTableName('productionContractData_26年压力表合同数据')
|
||||||
|
const batchSize = 2000
|
||||||
|
|
||||||
if (isSqlServer) {
|
if (isSqlServer) {
|
||||||
const sql = require('mssql')
|
const sql = require('mssql')
|
||||||
const placeholders = productionIds.map((_, idx) => `@p${idx}`).join(',')
|
const allOrderNumbers: string[] = []
|
||||||
const params: Record<string, { value: string; type: any }> = {}
|
|
||||||
|
|
||||||
productionIds.forEach((id, idx) => {
|
for (let i = 0; i < productionIds.length; i += batchSize) {
|
||||||
params[`p${idx}`] = { value: id, type: sql.NVarChar }
|
const batch = productionIds.slice(i, i + batchSize)
|
||||||
})
|
const placeholders = batch.map((_, idx) => `@p${idx}`).join(',')
|
||||||
|
const params: Record<string, { value: string; type: any }> = {}
|
||||||
|
|
||||||
const contractSql = `
|
batch.forEach((id, idx) => {
|
||||||
SELECT DISTINCT 生产订单号
|
params[`p${idx}`] = { value: id, type: sql.NVarChar }
|
||||||
FROM ${contractTableName}
|
})
|
||||||
WHERE 总排号 IN (${placeholders})
|
|
||||||
`
|
const contractSql = `
|
||||||
const contractResult = await (dbService as SqlServerService).queryWithParams(
|
SELECT DISTINCT 生产订单号
|
||||||
contractSql,
|
FROM ${contractTableName}
|
||||||
params
|
WHERE 总排号 IN (${placeholders})
|
||||||
)
|
`
|
||||||
const dbOrderNumbers = contractResult.rows.map((row) => row.生产订单号 as string)
|
const contractResult = await (dbService as SqlServerService).queryWithParams(
|
||||||
orderNumbers.push(...dbOrderNumbers)
|
contractSql,
|
||||||
|
params
|
||||||
|
)
|
||||||
|
const dbOrderNumbers = contractResult.rows.map((row) => row.生产订单号 as string)
|
||||||
|
allOrderNumbers.push(...dbOrderNumbers)
|
||||||
|
}
|
||||||
|
|
||||||
|
orderNumbers.push(...allOrderNumbers)
|
||||||
} else {
|
} else {
|
||||||
const placeholders = productionIds.map(() => '?').join(',')
|
const allOrderNumbers: string[] = []
|
||||||
const contractSql = `
|
|
||||||
SELECT DISTINCT 生产订单号
|
for (let i = 0; i < productionIds.length; i += batchSize) {
|
||||||
FROM ${contractTableName}
|
const batch = productionIds.slice(i, i + batchSize)
|
||||||
WHERE 总排号 IN (${placeholders})
|
const placeholders = batch.map(() => '?').join(',')
|
||||||
`
|
const contractSql = `
|
||||||
const contractResult = await (dbService as MySqlService).query(contractSql, productionIds)
|
SELECT DISTINCT 生产订单号
|
||||||
const dbOrderNumbers = contractResult.rows.map((row) => row.生产订单号 as string)
|
FROM ${contractTableName}
|
||||||
orderNumbers.push(...dbOrderNumbers)
|
WHERE 总排号 IN (${placeholders})
|
||||||
|
`
|
||||||
|
const contractResult = await (dbService as MySqlService).query(contractSql, batch)
|
||||||
|
const dbOrderNumbers = contractResult.rows.map((row) => row.生产订单号 as string)
|
||||||
|
allOrderNumbers.push(...dbOrderNumbers)
|
||||||
|
}
|
||||||
|
|
||||||
|
orderNumbers.push(...allOrderNumbers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -313,7 +313,7 @@ export class ConfigManager {
|
|||||||
// Order number parsing table configuration
|
// Order number parsing table configuration
|
||||||
lines.push('# 订单号解析表配置')
|
lines.push('# 订单号解析表配置')
|
||||||
lines.push('# 表名:包含 productionID 和 生产订单号 映射关系的表')
|
lines.push('# 表名:包含 productionID 和 生产订单号 映射关系的表')
|
||||||
lines.push(`DB_TABLE_NAME=productionContractData_26 年压力表合同数据`)
|
lines.push(`DB_TABLE_NAME=productionContractData_26年压力表合同数据`)
|
||||||
lines.push('# 字段名:总排号 (对应 productionID)')
|
lines.push('# 字段名:总排号 (对应 productionID)')
|
||||||
lines.push(`DB_FIELD_PRODUCTION_ID=总排号`)
|
lines.push(`DB_FIELD_PRODUCTION_ID=总排号`)
|
||||||
lines.push('# 字段名:生产订单号 (对应生产订单号)')
|
lines.push('# 字段名:生产订单号 (对应生产订单号)')
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ function buildDataSourceOptions(): DataSourceOptions {
|
|||||||
password: process.env.DB_PASSWORD || '',
|
password: process.env.DB_PASSWORD || '',
|
||||||
database: process.env.DB_NAME || '',
|
database: process.env.DB_NAME || '',
|
||||||
options: {
|
options: {
|
||||||
encrypt: process.env.DB_TRUST_SERVER_CERTIFICATE === 'yes',
|
encrypt: false,
|
||||||
trustServerCertificate: process.env.DB_TRUST_SERVER_CERTIFICATE === 'yes'
|
trustServerCertificate: process.env.DB_TRUST_SERVER_CERTIFICATE === 'yes'
|
||||||
},
|
},
|
||||||
...commonOptions
|
...commonOptions
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ export class DiscreteMaterialPlanDAO {
|
|||||||
const dbService = await this.getDatabaseService()
|
const dbService = await this.getDatabaseService()
|
||||||
const tableName = this.getTableName()
|
const tableName = this.getTableName()
|
||||||
const isSqlServer = dbService.type === 'sqlserver'
|
const isSqlServer = dbService.type === 'sqlserver'
|
||||||
const batchSize = 2000
|
const batchSize = 1500
|
||||||
const allResults: any[] = []
|
const allResults: any[] = []
|
||||||
|
|
||||||
for (let i = 0; i < sourceNumbers.length; i += batchSize) {
|
for (let i = 0; i < sourceNumbers.length; i += batchSize) {
|
||||||
@@ -250,7 +250,7 @@ export class DiscreteMaterialPlanDAO {
|
|||||||
const dbService = await this.getDatabaseService()
|
const dbService = await this.getDatabaseService()
|
||||||
const tableName = this.getTableName()
|
const tableName = this.getTableName()
|
||||||
const isSqlServer = dbService.type === 'sqlserver'
|
const isSqlServer = dbService.type === 'sqlserver'
|
||||||
const batchSize = 2000
|
const batchSize = 1500
|
||||||
const allResults: any[] = []
|
const allResults: any[] = []
|
||||||
|
|
||||||
for (let i = 0; i < sourceNumbers.length; i += batchSize) {
|
for (let i = 0; i < sourceNumbers.length; i += batchSize) {
|
||||||
@@ -365,16 +365,24 @@ export class DiscreteMaterialPlanDAO {
|
|||||||
const dbService = await this.getDatabaseService()
|
const dbService = await this.getDatabaseService()
|
||||||
const tableName = this.getTableName()
|
const tableName = this.getTableName()
|
||||||
const isSqlServer = dbService.type === 'sqlserver'
|
const isSqlServer = dbService.type === 'sqlserver'
|
||||||
const placeholders = this.buildPlaceholders(planNumbers.length, isSqlServer)
|
const batchSize = 1500
|
||||||
|
const allResults: any[] = []
|
||||||
|
|
||||||
const sqlString = `
|
for (let i = 0; i < planNumbers.length; i += batchSize) {
|
||||||
SELECT *
|
const batch = planNumbers.slice(i, i + batchSize)
|
||||||
FROM ${tableName}
|
const placeholders = this.buildPlaceholders(batch.length, isSqlServer)
|
||||||
WHERE PlanNumber IN (${placeholders})
|
|
||||||
`
|
|
||||||
|
|
||||||
const result = await dbService.query(sqlString, planNumbers)
|
const sqlString = `
|
||||||
return result.rows
|
SELECT *
|
||||||
|
FROM ${tableName}
|
||||||
|
WHERE PlanNumber IN (${placeholders})
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await dbService.query(sqlString, batch)
|
||||||
|
allResults.push(...result.rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
return allResults
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('Query by plan numbers error', {
|
log.error('Query by plan numbers error', {
|
||||||
error: error instanceof Error ? error.message : String(error)
|
error: error instanceof Error ? error.message : String(error)
|
||||||
@@ -703,17 +711,25 @@ export class DiscreteMaterialPlanDAO {
|
|||||||
const isSqlServer = dbService.type === 'sqlserver'
|
const isSqlServer = dbService.type === 'sqlserver'
|
||||||
|
|
||||||
if (sourceNumbers && sourceNumbers.length > 0) {
|
if (sourceNumbers && sourceNumbers.length > 0) {
|
||||||
const placeholders = this.buildPlaceholders(sourceNumbers.length, isSqlServer)
|
const batchSize = 1500
|
||||||
|
const allNames: string[] = []
|
||||||
|
|
||||||
const sqlString = `
|
for (let i = 0; i < sourceNumbers.length; i += batchSize) {
|
||||||
SELECT DISTINCT MaterialName
|
const batch = sourceNumbers.slice(i, i + batchSize)
|
||||||
FROM ${tableName}
|
const placeholders = this.buildPlaceholders(batch.length, isSqlServer)
|
||||||
WHERE SourceNumber IN (${placeholders})
|
|
||||||
AND MaterialName IS NOT NULL
|
|
||||||
`
|
|
||||||
|
|
||||||
const result = await dbService.query(sqlString, sourceNumbers)
|
const sqlString = `
|
||||||
return result.rows.map((row) => row.MaterialName as string).filter(Boolean)
|
SELECT DISTINCT MaterialName
|
||||||
|
FROM ${tableName}
|
||||||
|
WHERE SourceNumber IN (${placeholders})
|
||||||
|
AND MaterialName IS NOT NULL
|
||||||
|
`
|
||||||
|
|
||||||
|
const result = await dbService.query(sqlString, batch)
|
||||||
|
allNames.push(...result.rows.map((row) => row.MaterialName as string).filter(Boolean))
|
||||||
|
}
|
||||||
|
|
||||||
|
return allNames
|
||||||
} else {
|
} else {
|
||||||
const sqlString = `
|
const sqlString = `
|
||||||
SELECT DISTINCT MaterialName
|
SELECT DISTINCT MaterialName
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function createSqlServerConfig(): SqlServerConfig {
|
|||||||
password: process.env.DB_PASSWORD || '',
|
password: process.env.DB_PASSWORD || '',
|
||||||
database: process.env.DB_NAME || '',
|
database: process.env.DB_NAME || '',
|
||||||
options: {
|
options: {
|
||||||
encrypt: process.env.DB_TRUST_SERVER_CERTIFICATE === 'yes',
|
encrypt: false,
|
||||||
trustServerCertificate: process.env.DB_TRUST_SERVER_CERTIFICATE === 'yes'
|
trustServerCertificate: process.env.DB_TRUST_SERVER_CERTIFICATE === 'yes'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export class SqlServerService implements IDatabaseService {
|
|||||||
password: this.config.password,
|
password: this.config.password,
|
||||||
database: this.config.database,
|
database: this.config.database,
|
||||||
options: {
|
options: {
|
||||||
encrypt: this.config.options?.encrypt ?? true,
|
encrypt: this.config.options?.encrypt ?? false,
|
||||||
trustServerCertificate: this.config.options?.trustServerCertificate ?? false
|
trustServerCertificate: this.config.options?.trustServerCertificate ?? false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export class BIPUsersDAO {
|
|||||||
password: process.env.DB_PASSWORD || '',
|
password: process.env.DB_PASSWORD || '',
|
||||||
database: process.env.DB_NAME || '',
|
database: process.env.DB_NAME || '',
|
||||||
options: {
|
options: {
|
||||||
encrypt: process.env.DB_TRUST_SERVER_CERTIFICATE === 'yes',
|
encrypt: false,
|
||||||
trustServerCertificate: process.env.DB_TRUST_SERVER_CERTIFICATE === 'yes'
|
trustServerCertificate: process.env.DB_TRUST_SERVER_CERTIFICATE === 'yes'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export interface CleanerInput {
|
|||||||
orderNumbers: string[]
|
orderNumbers: string[]
|
||||||
materialCodes: string[]
|
materialCodes: string[]
|
||||||
dryRun: boolean
|
dryRun: boolean
|
||||||
|
headless?: boolean
|
||||||
onProgress?: (message: string, progress?: number) => void
|
onProgress?: (message: string, progress?: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,30 +59,11 @@ export interface SqlServerQueryResult {
|
|||||||
* File operation APIs
|
* File operation APIs
|
||||||
*/
|
*/
|
||||||
export interface FileAPI {
|
export interface FileAPI {
|
||||||
/**
|
|
||||||
* Read file content as text
|
|
||||||
* @param filePath - Path to the file
|
|
||||||
*/
|
|
||||||
readFile: (filePath: string) => Promise<string>
|
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>
|
writeFile: (filePath: string, content: string) => Promise<void>
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if file exists
|
|
||||||
* @param filePath - Path to the file
|
|
||||||
*/
|
|
||||||
fileExists: (filePath: string) => Promise<boolean>
|
fileExists: (filePath: string) => Promise<boolean>
|
||||||
|
|
||||||
/**
|
|
||||||
* Get list of files in directory
|
|
||||||
* @param dirPath - Directory path
|
|
||||||
*/
|
|
||||||
listFiles: (dirPath: string) => Promise<string[]>
|
listFiles: (dirPath: string) => Promise<string[]>
|
||||||
|
openPath: (filePath: string) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ const api = {
|
|||||||
writeFile: (filePath: string, content: string) =>
|
writeFile: (filePath: string, content: string) =>
|
||||||
ipcRenderer.invoke('file:write', filePath, content),
|
ipcRenderer.invoke('file:write', filePath, content),
|
||||||
fileExists: (filePath: string) => ipcRenderer.invoke('file:exists', filePath),
|
fileExists: (filePath: string) => ipcRenderer.invoke('file:exists', filePath),
|
||||||
listFiles: (dirPath: string) => ipcRenderer.invoke('file:list', dirPath)
|
listFiles: (dirPath: string) => ipcRenderer.invoke('file:list', dirPath),
|
||||||
|
openPath: (filePath: string) => ipcRenderer.invoke('file:openPath', filePath)
|
||||||
},
|
},
|
||||||
|
|
||||||
// Extractor service
|
// Extractor service
|
||||||
|
|||||||
@@ -378,14 +378,16 @@ function App(): React.JSX.Element {
|
|||||||
|
|
||||||
{/* ================= 主体内容区域 ================= */}
|
{/* ================= 主体内容区域 ================= */}
|
||||||
<div className="flex flex-1 overflow-hidden relative">
|
<div className="flex flex-1 overflow-hidden relative">
|
||||||
<main className="flex-1 overflow-y-auto bg-slate-50 p-6">
|
<main className="flex-1 overflow-hidden bg-slate-50 p-6 h-full">
|
||||||
{currentPage === 'home' && (
|
{currentPage === 'home' && (
|
||||||
<div className="max-w-4xl mx-auto mt-10 text-center animate-in fade-in slide-in-from-bottom-4 duration-500">
|
<div className="h-full overflow-auto">
|
||||||
<LayoutDashboard size={48} className="mx-auto text-blue-500 mb-4" />
|
<div className="max-w-4xl mx-auto mt-10 text-center animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
<h1 className="text-3xl font-bold text-slate-800 mb-4">欢迎使用 ERP Auto</h1>
|
<LayoutDashboard size={48} className="mx-auto text-blue-500 mb-4" />
|
||||||
<p className="text-slate-500 text-lg max-w-2xl mx-auto">
|
<h1 className="text-3xl font-bold text-slate-800 mb-4">欢迎使用 ERP Auto</h1>
|
||||||
自动化处理 ERP 系统中的数据提取和清理任务。请使用上方导航栏选择您需要的功能模块。
|
<p className="text-slate-500 text-lg max-w-2xl mx-auto">
|
||||||
</p>
|
自动化处理 ERP 系统中的数据提取和清理任务。请使用上方导航栏选择您需要的功能模块。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{currentPage === 'extractor' && <ExtractorPage />}
|
{currentPage === 'extractor' && <ExtractorPage />}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
|
||||||
interface OrderNumberInputProps {
|
interface OrderNumberInputProps {
|
||||||
value: string
|
value: string
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
label?: string
|
label?: string
|
||||||
enableFormatStats?: boolean // Whether to show format statistics
|
enableFormatStats?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
showReset?: boolean
|
||||||
|
onReset?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FormatStats {
|
interface FormatStats {
|
||||||
@@ -14,24 +18,20 @@ interface FormatStats {
|
|||||||
unknownCount: number
|
unknownCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regular expression patterns for order number recognition
|
|
||||||
const ORDER_PATTERNS = {
|
const ORDER_PATTERNS = {
|
||||||
// productionID: 2 digits + 1 letter + serial number (1+)
|
|
||||||
PRODUCTION_ID: /^\d{2}[A-Za-z]\d+$/,
|
PRODUCTION_ID: /^\d{2}[A-Za-z]\d+$/,
|
||||||
// 生产订单号:SC + 14 digits
|
|
||||||
ORDER_NUMBER: /^SC\d{14}$/
|
ORDER_NUMBER: /^SC\d{14}$/
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const OrderNumberInput: React.FC<OrderNumberInputProps> = ({
|
||||||
* OrderNumberInput - A textarea component for entering line-separated order numbers
|
|
||||||
* Supports automatic recognition of productionID and 生产订单号 formats
|
|
||||||
*/
|
|
||||||
export const OrderNumberInput: React.FC<OrderNumberInputProps> = ({
|
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
placeholder = '请输入订单号,每行一个\n支持两种格式:\n- productionID: 22A1, 22A123\n- 生产订单号:SC70202602120085',
|
placeholder = '每行输入一个订单号\n支持格式:\n- 总排号: 22A1, 22A123\n- 生产订单号: SC70202602120085',
|
||||||
label = '订单号列表',
|
label = '订单号列表',
|
||||||
enableFormatStats = true
|
enableFormatStats = true,
|
||||||
|
disabled = false,
|
||||||
|
showReset = false,
|
||||||
|
onReset
|
||||||
}) => {
|
}) => {
|
||||||
const [count, setCount] = useState(0)
|
const [count, setCount] = useState(0)
|
||||||
const [stats, setStats] = useState<FormatStats>({
|
const [stats, setStats] = useState<FormatStats>({
|
||||||
@@ -43,22 +43,15 @@ export const OrderNumberInput: React.FC<OrderNumberInputProps> = ({
|
|||||||
const recognizeType = (input: string): 'productionId' | 'orderNumber' | 'unknown' => {
|
const recognizeType = (input: string): 'productionId' | 'orderNumber' | 'unknown' => {
|
||||||
const trimmed = input.trim()
|
const trimmed = input.trim()
|
||||||
if (!trimmed) return 'unknown'
|
if (!trimmed) return 'unknown'
|
||||||
|
if (ORDER_PATTERNS.ORDER_NUMBER.test(trimmed)) return 'orderNumber'
|
||||||
if (ORDER_PATTERNS.ORDER_NUMBER.test(trimmed)) {
|
if (ORDER_PATTERNS.PRODUCTION_ID.test(trimmed)) return 'productionId'
|
||||||
return 'orderNumber'
|
|
||||||
}
|
|
||||||
if (ORDER_PATTERNS.PRODUCTION_ID.test(trimmed)) {
|
|
||||||
return 'productionId'
|
|
||||||
}
|
|
||||||
return 'unknown'
|
return 'unknown'
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Count non-empty lines and categorize by format
|
|
||||||
const lines = value.split('\n').filter((line) => line.trim().length > 0)
|
const lines = value.split('\n').filter((line) => line.trim().length > 0)
|
||||||
setCount(lines.length)
|
setCount(lines.length)
|
||||||
|
|
||||||
// Calculate format statistics
|
|
||||||
const newStats: FormatStats = {
|
const newStats: FormatStats = {
|
||||||
productionIdCount: 0,
|
productionIdCount: 0,
|
||||||
orderNumberCount: 0,
|
orderNumberCount: 0,
|
||||||
@@ -67,13 +60,9 @@ export const OrderNumberInput: React.FC<OrderNumberInputProps> = ({
|
|||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const type = recognizeType(line)
|
const type = recognizeType(line)
|
||||||
if (type === 'productionId') {
|
if (type === 'productionId') newStats.productionIdCount++
|
||||||
newStats.productionIdCount++
|
else if (type === 'orderNumber') newStats.orderNumberCount++
|
||||||
} else if (type === 'orderNumber') {
|
else newStats.unknownCount++
|
||||||
newStats.orderNumberCount++
|
|
||||||
} else {
|
|
||||||
newStats.unknownCount++
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setStats(newStats)
|
setStats(newStats)
|
||||||
@@ -84,96 +73,55 @@ export const OrderNumberInput: React.FC<OrderNumberInputProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="order-number-input">
|
<div className="flex flex-col h-full">
|
||||||
<div className="input-header">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<label>{label}</label>
|
<label className="text-sm font-medium text-slate-700">{label}</label>
|
||||||
<div className="stats-wrapper">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="count-badge">{count} 个</span>
|
<span className="bg-blue-50 text-blue-600 px-2 py-0.5 rounded-full text-xs font-medium">
|
||||||
|
{count} 个
|
||||||
|
</span>
|
||||||
{enableFormatStats && stats.productionIdCount > 0 && (
|
{enableFormatStats && stats.productionIdCount > 0 && (
|
||||||
<span className="stat-badge production-id">{stats.productionIdCount} 总排号</span>
|
<span className="bg-emerald-50 text-emerald-600 px-2 py-0.5 rounded-full text-xs font-medium">
|
||||||
|
{stats.productionIdCount} 总排号
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{enableFormatStats && stats.orderNumberCount > 0 && (
|
{enableFormatStats && stats.orderNumberCount > 0 && (
|
||||||
<span className="stat-badge order-number">{stats.orderNumberCount} 订单号</span>
|
<span className="bg-amber-50 text-amber-600 px-2 py-0.5 rounded-full text-xs font-medium">
|
||||||
|
{stats.orderNumberCount} 订单号
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{enableFormatStats && stats.unknownCount > 0 && (
|
{enableFormatStats && stats.unknownCount > 0 && (
|
||||||
<span className="stat-badge unknown">{stats.unknownCount} 未知格式</span>
|
<span className="bg-red-50 text-red-500 px-2 py-0.5 rounded-full text-xs font-medium">
|
||||||
|
{stats.unknownCount} 未知
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
value={value}
|
value={value}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
rows={10}
|
disabled={disabled}
|
||||||
className="order-textarea"
|
className="flex-1 w-full border border-slate-300 rounded-lg p-3 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
style={{
|
||||||
|
userSelect: disabled ? 'none' : 'text',
|
||||||
|
cursor: disabled ? 'not-allowed' : 'text'
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<style>{`
|
|
||||||
.order-number-input {
|
{showReset && (
|
||||||
margin-bottom: 16px;
|
<div className="flex items-center justify-end mt-2">
|
||||||
}
|
<button
|
||||||
.input-header {
|
onClick={onReset}
|
||||||
display: flex;
|
disabled={disabled}
|
||||||
justify-content: space-between;
|
className="flex items-center gap-1 px-3 py-1.5 text-xs text-slate-500 hover:text-red-600 hover:bg-red-50 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
align-items: center;
|
>
|
||||||
margin-bottom: 8px;
|
<X size={14} />
|
||||||
}
|
清空
|
||||||
.input-header label {
|
</button>
|
||||||
font-weight: 600;
|
</div>
|
||||||
font-size: 14px;
|
)}
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
.stats-wrapper {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.count-badge {
|
|
||||||
background: #e6f7ff;
|
|
||||||
color: #1890ff;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.stat-badge {
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.stat-badge.production-id {
|
|
||||||
background: #f6ffed;
|
|
||||||
color: #52c41a;
|
|
||||||
}
|
|
||||||
.stat-badge.order-number {
|
|
||||||
background: #fff7e6;
|
|
||||||
color: #fa8c16;
|
|
||||||
}
|
|
||||||
.stat-badge.unknown {
|
|
||||||
background: #fff1f0;
|
|
||||||
color: #ff4d4f;
|
|
||||||
}
|
|
||||||
.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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,13 @@ const CleanerPage: React.FC = () => {
|
|||||||
// Material type management dialog state
|
// Material type management dialog state
|
||||||
const [isTypeDialogOpen, setIsTypeDialogOpen] = useState(false)
|
const [isTypeDialogOpen, setIsTypeDialogOpen] = useState(false)
|
||||||
|
|
||||||
|
// Execution settings state
|
||||||
|
const [headless, setHeadless] = useState(() => {
|
||||||
|
const saved = sessionStorage.getItem('cleaner_headless')
|
||||||
|
return saved ? saved === 'true' : true
|
||||||
|
})
|
||||||
|
const [showSettingsMenu, setShowSettingsMenu] = useState(false)
|
||||||
|
|
||||||
// Check admin status and get shared Production IDs on mount
|
// Check admin status and get shared Production IDs on mount
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const initializePage = async () => {
|
const initializePage = async () => {
|
||||||
@@ -100,6 +107,23 @@ const CleanerPage: React.FC = () => {
|
|||||||
sessionStorage.setItem('cleaner_dryRun', dryRun.toString())
|
sessionStorage.setItem('cleaner_dryRun', dryRun.toString())
|
||||||
}, [dryRun])
|
}, [dryRun])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
sessionStorage.setItem('cleaner_headless', headless.toString())
|
||||||
|
}, [headless])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (showSettingsMenu) {
|
||||||
|
const target = event.target as HTMLElement
|
||||||
|
if (!target.closest('[data-settings-menu]')) {
|
||||||
|
setShowSettingsMenu(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [showSettingsMenu])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
sessionStorage.setItem('cleaner_validationMode', valMode)
|
sessionStorage.setItem('cleaner_validationMode', valMode)
|
||||||
}, [valMode])
|
}, [valMode])
|
||||||
@@ -253,7 +277,8 @@ const CleanerPage: React.FC = () => {
|
|||||||
const response = await window.electron.cleaner.runCleaner({
|
const response = await window.electron.cleaner.runCleaner({
|
||||||
orderNumbers: orderNumberList,
|
orderNumbers: orderNumberList,
|
||||||
materialCodes: materialCodeList,
|
materialCodes: materialCodeList,
|
||||||
dryRun
|
dryRun,
|
||||||
|
headless
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
@@ -307,10 +332,10 @@ const CleanerPage: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col xl:flex-row gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
<div className="h-full flex flex-col xl:flex-row gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
{/* 左栏:数据源与执行控制区 */}
|
{/* 左栏:数据源与执行控制区 (仅 Admin 可见) */}
|
||||||
<div className="xl:w-[380px] flex-shrink-0 flex flex-col gap-5">
|
{isAdmin && (
|
||||||
{/* 1. 数据来源选择 (仅 Admin 可见) */}
|
<div className="w-full xl:w-[380px] flex-shrink-0 flex flex-col gap-5 xl:self-start overflow-auto">
|
||||||
{isAdmin && (
|
{/* 1. 数据来源选择 */}
|
||||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-5">
|
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-5">
|
||||||
<h3 className="text-base font-semibold mb-4 flex items-center gap-2 text-slate-800">
|
<h3 className="text-base font-semibold mb-4 flex items-center gap-2 text-slate-800">
|
||||||
<DatabaseZap size={18} className="text-blue-500" />
|
<DatabaseZap size={18} className="text-blue-500" />
|
||||||
@@ -361,10 +386,8 @@ const CleanerPage: React.FC = () => {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 2. 负责人筛选 (仅 Admin 可见) */}
|
{/* 2. 负责人筛选 */}
|
||||||
{isAdmin && (
|
|
||||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-5">
|
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-5">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className="text-base font-semibold flex items-center gap-2 text-slate-800">
|
<h3 className="text-base font-semibold flex items-center gap-2 text-slate-800">
|
||||||
@@ -414,43 +437,24 @@ const CleanerPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 3. 基础执行控制区 */}
|
|
||||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-5 mt-auto">
|
|
||||||
{isAdmin && (
|
|
||||||
<div className="flex items-center justify-between bg-amber-50/50 p-3 rounded-lg border border-amber-200 mb-4">
|
|
||||||
<div>
|
|
||||||
<div className="font-semibold text-sm text-amber-900">预览模式 (Dry-Run)</div>
|
|
||||||
<div className="text-xs text-amber-700/80 mt-0.5">
|
|
||||||
仅执行页面操作定位,不保存更改
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setDryRun(!dryRun)}
|
|
||||||
className={`transition-colors flex-shrink-0 ml-4 ${dryRun ? 'text-amber-500' : 'text-slate-300'}`}
|
|
||||||
>
|
|
||||||
{dryRun ? <ToggleRight size={40} /> : <ToggleLeft size={40} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2.5">
|
|
||||||
<button
|
|
||||||
onClick={handleValidation}
|
|
||||||
disabled={isValidationRunning}
|
|
||||||
className="w-full bg-slate-100 hover:bg-slate-200 text-slate-800 py-3 rounded-lg font-medium transition-colors shadow-sm flex justify-center items-center gap-2 border border-slate-200 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Search size={18} /> {isValidationRunning ? '正在获取...' : '获取并校验物料状态'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* 右栏:结果表格与工具栏 */}
|
{/* 右栏:结果表格与工具栏 */}
|
||||||
<div className="flex-1 bg-white rounded-xl shadow-sm border border-slate-200 flex flex-col overflow-hidden min-h-[500px]">
|
<div className="flex-1 bg-white rounded-xl shadow-sm border border-slate-200 flex flex-col overflow-hidden">
|
||||||
|
{/* 获取物料状态按钮 */}
|
||||||
|
<div className="p-4 border-b border-slate-200 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={handleValidation}
|
||||||
|
disabled={isValidationRunning}
|
||||||
|
className="w-full bg-slate-100 hover:bg-slate-200 text-slate-800 py-3 rounded-lg font-medium transition-colors shadow-sm flex justify-center items-center gap-2 border border-slate-200 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Search size={18} /> {isValidationRunning ? '正在获取...' : '获取并校验物料状态'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 顶部操作条 */}
|
{/* 顶部操作条 */}
|
||||||
<div className="bg-slate-50 border-b border-slate-200 px-4 py-3 flex justify-between items-center">
|
<div className="bg-slate-50 border-b border-slate-200 px-4 py-3 flex justify-between items-center flex-shrink-0">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedItems(new Set(filteredResults.map((r) => r.materialCode)))}
|
onClick={() => setSelectedItems(new Set(filteredResults.map((r) => r.materialCode)))}
|
||||||
@@ -525,7 +529,7 @@ const CleanerPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 表格区域 */}
|
{/* 表格区域 */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<table className="w-full text-left text-sm whitespace-nowrap table-fixed">
|
<table className="w-full text-left text-sm whitespace-nowrap table-fixed">
|
||||||
<thead className="bg-slate-100 text-slate-600 sticky top-0 shadow-sm z-10 text-xs font-semibold">
|
<thead className="bg-slate-100 text-slate-600 sticky top-0 shadow-sm z-10 text-xs font-semibold">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -616,37 +620,67 @@ const CleanerPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 底部状态与执行区 */}
|
{/* 底部状态与执行区 */}
|
||||||
<div className="bg-white border-t border-slate-200 p-4 flex justify-between items-center shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)] z-10">
|
<div className="bg-white border-t border-slate-200 p-4 flex justify-between items-center shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)] z-10 flex-shrink-0">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="text-sm text-slate-600 font-medium">
|
<div className="text-sm text-slate-600 font-medium">
|
||||||
共计 {filteredResults.length} 条记录 | 已选中{' '}
|
共计 {filteredResults.length} 条记录 | 已选中{' '}
|
||||||
<span className="text-blue-600">{selectedItems.size}</span> 条
|
<span className="text-blue-600">{selectedItems.size}</span> 条
|
||||||
</div>
|
</div>
|
||||||
{isAdmin && dryRun && (
|
|
||||||
<span className="text-xs bg-amber-100 text-amber-700 px-2 py-1 rounded border border-amber-200">
|
|
||||||
当前为预览模式
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<div className="relative" data-settings-menu>
|
||||||
onClick={() => {
|
<button
|
||||||
setValidationResults([])
|
onClick={() => setShowSettingsMenu(!showSettingsMenu)}
|
||||||
setSelectedItems(new Set())
|
className="text-sm text-slate-600 bg-slate-100 hover:bg-slate-200 px-5 py-2.5 rounded-lg font-medium transition-colors flex items-center gap-2"
|
||||||
setHiddenItems(new Set())
|
>
|
||||||
}}
|
<Settings2 size={16} /> 执行设置
|
||||||
className="text-sm text-slate-600 bg-slate-100 hover:bg-slate-200 px-5 py-2.5 rounded-lg font-medium transition-colors"
|
</button>
|
||||||
>
|
{showSettingsMenu && (
|
||||||
重置列表
|
<div className="absolute bottom-full right-0 mb-2 bg-white rounded-lg shadow-lg border border-slate-200 py-3 px-4 min-w-[280px] z-50">
|
||||||
</button>
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-slate-800">预览模式 (Dry-Run)</div>
|
||||||
|
<div className="text-xs text-slate-500 mt-0.5">
|
||||||
|
仅执行页面操作定位,不保存更改
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setDryRun(!dryRun)}
|
||||||
|
className={`transition-colors flex-shrink-0 ml-4 ${dryRun ? 'text-amber-500' : 'text-slate-300'}`}
|
||||||
|
>
|
||||||
|
{dryRun ? <ToggleRight size={32} /> : <ToggleLeft size={32} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-slate-100 pt-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-slate-800">
|
||||||
|
后台模式 (Headless)
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-500 mt-0.5">
|
||||||
|
浏览器在后台运行,不显示界面
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setHeadless(!headless)}
|
||||||
|
className={`transition-colors flex-shrink-0 ml-4 ${headless ? 'text-blue-500' : 'text-slate-300'}`}
|
||||||
|
>
|
||||||
|
{headless ? <ToggleRight size={32} /> : <ToggleLeft size={32} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleExecuteDeletion}
|
onClick={handleExecuteDeletion}
|
||||||
disabled={isRunning || validationResults.length === 0}
|
disabled={isRunning}
|
||||||
className={`${isAdmin && dryRun ? 'bg-amber-500 hover:bg-amber-600' : 'bg-red-600 hover:bg-red-700 shadow-red-500/30'} text-white px-8 py-2.5 rounded-lg font-medium shadow-md transition-all flex items-center gap-2 disabled:opacity-50`}
|
className={`${dryRun ? 'bg-amber-500 hover:bg-amber-600' : 'bg-red-600 hover:bg-red-700 shadow-red-500/30'} text-white px-8 py-2.5 rounded-lg font-medium shadow-md transition-all flex items-center gap-2 disabled:opacity-50 w-[300px] justify-center`}
|
||||||
>
|
>
|
||||||
<Play size={18} fill="currentColor" />{' '}
|
<Play size={18} fill="currentColor" /> {dryRun ? '开始预览执行' : '正式执行 ERP 清理'}
|
||||||
{isAdmin && dryRun ? '开始预览执行' : '正式执行 ERP 清理'}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,51 +1,47 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
import { Download, Play, Terminal, Database } from 'lucide-react'
|
import { Download, Play, Terminal } from 'lucide-react'
|
||||||
|
import OrderNumberInput from '../components/OrderNumberInput'
|
||||||
// Import result type (matches the type from main process)
|
|
||||||
interface ImportResult {
|
|
||||||
success: boolean
|
|
||||||
recordsRead: number
|
|
||||||
recordsDeleted: number
|
|
||||||
recordsImported: number
|
|
||||||
uniqueSourceNumbers: number
|
|
||||||
errors: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extractor result type (matches the type from main process)
|
|
||||||
interface ExtractorResult {
|
|
||||||
downloadedFiles: string[]
|
|
||||||
mergedFile: string | null
|
|
||||||
recordCount: number
|
|
||||||
errors: string[]
|
|
||||||
importResult?: ImportResult
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ExtractorProgress {
|
interface ExtractorProgress {
|
||||||
message: string
|
message: string
|
||||||
progress: number
|
progress: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
type LogLevel = 'info' | 'success' | 'warning' | 'error' | 'system'
|
||||||
* ExtractorPage - Main page for ERP data extraction
|
|
||||||
*/
|
interface LogEntry {
|
||||||
|
timestamp: string
|
||||||
|
level: LogLevel
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLogColor = (level: LogLevel): string => {
|
||||||
|
switch (level) {
|
||||||
|
case 'error':
|
||||||
|
return 'text-red-400'
|
||||||
|
case 'warning':
|
||||||
|
return 'text-amber-400'
|
||||||
|
case 'success':
|
||||||
|
return 'text-emerald-400'
|
||||||
|
case 'system':
|
||||||
|
return 'text-blue-400'
|
||||||
|
default:
|
||||||
|
return 'text-slate-400'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const ExtractorPage: React.FC = () => {
|
const ExtractorPage: React.FC = () => {
|
||||||
const [orderNumbers, setOrderNumbers] = useState(() => {
|
const [orderNumbers, setOrderNumbers] = useState(() => {
|
||||||
// Restore from sessionStorage on mount
|
|
||||||
return sessionStorage.getItem('extractor_orderNumbers') || ''
|
return sessionStorage.getItem('extractor_orderNumbers') || ''
|
||||||
})
|
})
|
||||||
const [batchSize, setBatchSize] = useState(() => {
|
|
||||||
const saved = sessionStorage.getItem('extractor_batchSize')
|
|
||||||
return saved ? parseInt(saved, 10) : 100
|
|
||||||
})
|
|
||||||
const [isRunning, setIsRunning] = useState(false)
|
const [isRunning, setIsRunning] = useState(false)
|
||||||
const [progress, setProgress] = useState<ExtractorProgress | null>(null)
|
const [progress, setProgress] = useState<ExtractorProgress | null>(null)
|
||||||
const [result, setResult] = useState<ExtractorResult | null>(null)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [logs, setLogs] = useState<LogEntry[]>([])
|
||||||
|
const logsEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// Save to sessionStorage when orderNumbers changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
sessionStorage.setItem('extractor_orderNumbers', orderNumbers)
|
sessionStorage.setItem('extractor_orderNumbers', orderNumbers)
|
||||||
// Update shared Production IDs when orderNumbers changes
|
|
||||||
if (orderNumbers.trim()) {
|
if (orderNumbers.trim()) {
|
||||||
const orderNumberList = orderNumbers
|
const orderNumberList = orderNumbers
|
||||||
.split('\n')
|
.split('\n')
|
||||||
@@ -55,10 +51,14 @@ const ExtractorPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [orderNumbers])
|
}, [orderNumbers])
|
||||||
|
|
||||||
// Save to sessionStorage when batchSize changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
sessionStorage.setItem('extractor_batchSize', batchSize.toString())
|
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
}, [batchSize])
|
}, [logs])
|
||||||
|
|
||||||
|
const addLog = (level: LogLevel, message: string) => {
|
||||||
|
const timestamp = new Date().toLocaleTimeString('zh-CN', { hour12: false })
|
||||||
|
setLogs((prev) => [...prev, { timestamp, level, message }])
|
||||||
|
}
|
||||||
|
|
||||||
const handleExtract = async () => {
|
const handleExtract = async () => {
|
||||||
if (!orderNumbers.trim()) {
|
if (!orderNumbers.trim()) {
|
||||||
@@ -68,8 +68,10 @@ const ExtractorPage: React.FC = () => {
|
|||||||
|
|
||||||
setIsRunning(true)
|
setIsRunning(true)
|
||||||
setProgress(null)
|
setProgress(null)
|
||||||
setResult(null)
|
|
||||||
setError(null)
|
setError(null)
|
||||||
|
setLogs([])
|
||||||
|
|
||||||
|
addLog('system', '提取引擎启动,准备执行...')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const orderNumberList = orderNumbers
|
const orderNumberList = orderNumbers
|
||||||
@@ -77,208 +79,95 @@ const ExtractorPage: React.FC = () => {
|
|||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.filter((line) => line.length > 0)
|
.filter((line) => line.length > 0)
|
||||||
|
|
||||||
// Store Production IDs for sharing with cleaner page (before extraction starts)
|
|
||||||
await window.electron.validation.setSharedProductionIds(orderNumberList)
|
await window.electron.validation.setSharedProductionIds(orderNumberList)
|
||||||
console.log(`[Extractor] Stored ${orderNumberList.length} Production IDs for sharing`)
|
addLog('info', `已存储 ${orderNumberList.length} 个订单号用于跨模块共享`)
|
||||||
|
|
||||||
// Call extractor API through electron
|
|
||||||
const response = await window.electron.extractor.runExtractor({
|
const response = await window.electron.extractor.runExtractor({
|
||||||
orderNumbers: orderNumberList,
|
orderNumbers: orderNumberList
|
||||||
batchSize
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
setResult(response.data)
|
addLog(
|
||||||
|
'success',
|
||||||
|
`提取完成:下载 ${response.data.downloadedFiles.length} 个文件,共 ${response.data.recordCount} 条记录`
|
||||||
|
)
|
||||||
|
if (response.data.errors.length > 0) {
|
||||||
|
addLog('warning', `存在 ${response.data.errors.length} 个错误`)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setError(response.error || '提取失败')
|
setError(response.error || '提取失败')
|
||||||
|
addLog('error', response.error || '提取失败')
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : '发生未知错误')
|
const errMsg = err instanceof Error ? err.message : '发生未知错误'
|
||||||
|
setError(errMsg)
|
||||||
|
addLog('error', errMsg)
|
||||||
} finally {
|
} finally {
|
||||||
setIsRunning(false)
|
setIsRunning(false)
|
||||||
setProgress(null)
|
setProgress(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
setOrderNumbers('')
|
|
||||||
setBatchSize(100)
|
|
||||||
setResult(null)
|
|
||||||
setError(null)
|
|
||||||
setProgress(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const [logs, setLogs] = useState<string[]>([
|
|
||||||
'[10:00:01] [System] 提取引擎已就绪。',
|
|
||||||
'[10:00:02] [Info] 等待读取生产订单列表...'
|
|
||||||
])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (progress) {
|
if (progress) {
|
||||||
setLogs((prev) => [
|
addLog('info', progress.message)
|
||||||
...prev,
|
|
||||||
`[${new Date().toLocaleTimeString()}] [Info] ${progress.message}`
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
}, [progress])
|
}, [progress])
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setOrderNumbers('')
|
||||||
|
setError(null)
|
||||||
|
setProgress(null)
|
||||||
|
setLogs([])
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full gap-6">
|
<div className="flex h-full gap-4 relative">
|
||||||
{/* 左侧:共享数据区 (仅在数据提取页面显示) */}
|
<aside className="w-80 flex-shrink-0 bg-white border border-slate-200 flex flex-col shadow-sm rounded-xl overflow-hidden h-full">
|
||||||
<aside className="w-80 bg-white border border-slate-200 flex flex-col shadow-sm z-10 flex-shrink-0 animate-in slide-in-from-left duration-300 rounded-xl overflow-hidden h-full">
|
<div className="p-4 border-b border-slate-100">
|
||||||
<div className="flex-1 flex flex-col p-5 space-y-3 h-full">
|
<h3 className="text-sm font-semibold text-slate-800">订单号输入</h3>
|
||||||
<div>
|
</div>
|
||||||
<label className="text-sm font-medium text-slate-700">
|
<div className="flex-1 flex flex-col p-4 min-h-0">
|
||||||
支持输入总排号或者生产订单号
|
<OrderNumberInput
|
||||||
</label>
|
|
||||||
<p className="text-xs text-slate-500 leading-relaxed mt-1">
|
|
||||||
在此输入的数据将在“数据提取”与“物料清理”模块中自动共享,每行一个。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<textarea
|
|
||||||
className="flex-1 w-full border border-slate-300 rounded-lg p-3 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none shadow-inner bg-slate-50 h-full"
|
|
||||||
style={{ userSelect: 'text', cursor: 'text' }}
|
|
||||||
placeholder="PO-20231024-001 PO-20231024-002 PO-20231024-003..."
|
|
||||||
value={orderNumbers}
|
value={orderNumbers}
|
||||||
onChange={(e) => setOrderNumbers(e.target.value)}
|
onChange={setOrderNumbers}
|
||||||
|
label=""
|
||||||
|
enableFormatStats={true}
|
||||||
disabled={isRunning}
|
disabled={isRunning}
|
||||||
></textarea>
|
showReset={true}
|
||||||
|
onReset={handleReset}
|
||||||
<div className="flex items-center justify-between text-xs text-slate-500 pt-2">
|
/>
|
||||||
<span>
|
|
||||||
共解析:{' '}
|
|
||||||
<strong className="text-slate-700">
|
|
||||||
{orderNumbers.split('\n').filter((l) => l.trim()).length}
|
|
||||||
</strong>{' '}
|
|
||||||
个订单
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
className="text-slate-400 hover:text-slate-600"
|
|
||||||
onClick={handleReset}
|
|
||||||
disabled={isRunning}
|
|
||||||
>
|
|
||||||
清空
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* 右侧:动态功能面板 */}
|
<div className="flex-1 min-w-0 flex flex-col gap-4 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
<div className="flex-1 max-w-4xl space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-5 flex items-center justify-between">
|
||||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-6 flex items-center justify-between">
|
<div className="flex-1">
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-800 mb-1">
|
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-800 mb-1">
|
||||||
<Download size={20} className="text-blue-600" />
|
<Download size={20} className="text-blue-600" />
|
||||||
批量数据提取
|
批量数据提取
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-slate-500">
|
<p className="text-sm text-slate-500">遍历订单列表,自动执行数据导出并保存至数据库</p>
|
||||||
将遍历左侧列表中的所有生产订单,依次自动执行数据导出并保存。
|
|
||||||
</p>
|
|
||||||
{error && <p className="text-sm text-red-500 mt-2">{error}</p>}
|
{error && <p className="text-sm text-red-500 mt-2">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div className="flex items-center gap-4">
|
||||||
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white px-8 py-3 rounded-lg flex items-center gap-2 font-medium shadow-sm transition-colors text-base"
|
<button
|
||||||
onClick={handleExtract}
|
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white px-6 py-2.5 rounded-lg flex items-center gap-2 font-medium shadow-sm transition-colors"
|
||||||
disabled={isRunning || !orderNumbers.trim()}
|
onClick={handleExtract}
|
||||||
>
|
disabled={isRunning || !orderNumbers.trim()}
|
||||||
<Play size={20} fill="currentColor" />
|
>
|
||||||
{isRunning ? '提取中...' : '开始提取'}
|
<Play size={18} fill="currentColor" />
|
||||||
</button>
|
{isRunning ? '提取中...' : '开始提取'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 结果展示 */}
|
<div className="bg-slate-900 rounded-xl shadow-lg border border-slate-700 overflow-hidden flex flex-col min-h-[300px] flex-1">
|
||||||
{result && (
|
<div className="bg-slate-800 px-4 py-2 flex items-center justify-between border-b border-slate-700 flex-shrink-0">
|
||||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-6 flex flex-col gap-4">
|
|
||||||
<h3 className="text-emerald-600 font-semibold text-lg border-b pb-2">提取结果</h3>
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<div className="bg-slate-50 p-4 rounded-lg border border-slate-100 flex flex-col items-center justify-center">
|
|
||||||
<span className="text-slate-500 text-sm">下载文件数</span>
|
|
||||||
<span className="text-2xl font-bold text-slate-800">
|
|
||||||
{result.downloadedFiles.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="bg-slate-50 p-4 rounded-lg border border-slate-100 flex flex-col items-center justify-center">
|
|
||||||
<span className="text-slate-500 text-sm">记录数</span>
|
|
||||||
<span className="text-2xl font-bold text-slate-800">{result.recordCount}</span>
|
|
||||||
</div>
|
|
||||||
<div className="bg-slate-50 p-4 rounded-lg border border-slate-100 flex flex-col items-center justify-center">
|
|
||||||
<span className="text-slate-500 text-sm">错误数</span>
|
|
||||||
<span
|
|
||||||
className={`text-2xl font-bold ${result.errors.length > 0 ? 'text-red-500' : 'text-slate-800'}`}
|
|
||||||
>
|
|
||||||
{result.errors.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{result.mergedFile && (
|
|
||||||
<div className="bg-slate-50 p-4 rounded-lg border border-slate-100">
|
|
||||||
<span className="text-slate-500 text-sm block mb-1">合并文件路径</span>
|
|
||||||
<span className="text-sm font-mono text-slate-700 select-all break-all">
|
|
||||||
{result.mergedFile}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Database import results */}
|
|
||||||
{result?.importResult && (
|
|
||||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-6 flex flex-col gap-4">
|
|
||||||
<h3 className="font-semibold text-lg border-b pb-2 flex items-center gap-2">
|
|
||||||
<Database
|
|
||||||
size={20}
|
|
||||||
className={result.importResult.success ? 'text-emerald-600' : 'text-red-500'}
|
|
||||||
/>
|
|
||||||
<span className={result.importResult.success ? 'text-emerald-600' : 'text-red-500'}>
|
|
||||||
数据库写入结果
|
|
||||||
</span>
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-4 gap-4">
|
|
||||||
<div className="bg-slate-50 p-4 rounded-lg border border-slate-100 flex flex-col items-center justify-center">
|
|
||||||
<span className="text-slate-500 text-sm">读取记录</span>
|
|
||||||
<span className="text-2xl font-bold text-slate-800">
|
|
||||||
{result.importResult.recordsRead}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="bg-slate-50 p-4 rounded-lg border border-slate-100 flex flex-col items-center justify-center">
|
|
||||||
<span className="text-slate-500 text-sm">删除旧记录</span>
|
|
||||||
<span className="text-2xl font-bold text-amber-600">
|
|
||||||
{result.importResult.recordsDeleted}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="bg-slate-50 p-4 rounded-lg border border-slate-100 flex flex-col items-center justify-center">
|
|
||||||
<span className="text-slate-500 text-sm">写入新记录</span>
|
|
||||||
<span className="text-2xl font-bold text-emerald-600">
|
|
||||||
{result.importResult.recordsImported}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="bg-slate-50 p-4 rounded-lg border border-slate-100 flex flex-col items-center justify-center">
|
|
||||||
<span className="text-slate-500 text-sm">来源单号数</span>
|
|
||||||
<span className="text-2xl font-bold text-blue-600">
|
|
||||||
{result.importResult.uniqueSourceNumbers}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{result.importResult.errors.length > 0 && (
|
|
||||||
<div className="bg-red-50 p-4 rounded-lg border border-red-200">
|
|
||||||
<span className="text-red-600 text-sm font-medium block mb-1">错误信息</span>
|
|
||||||
<ul className="text-sm text-red-500 list-disc list-inside">
|
|
||||||
{result.importResult.errors.map((err, idx) => (
|
|
||||||
<li key={idx}>{err}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="bg-slate-900 rounded-xl shadow-lg border border-slate-700 overflow-hidden flex flex-col h-[500px]">
|
|
||||||
<div className="bg-slate-800 px-4 py-2 flex items-center justify-between border-b border-slate-700">
|
|
||||||
<div className="flex items-center gap-2 text-slate-400 text-sm">
|
<div className="flex items-center gap-2 text-slate-400 text-sm">
|
||||||
<Terminal size={16} />
|
<Terminal size={16} />
|
||||||
<span>执行日志 (Console)</span>
|
<span>执行日志</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-xs text-slate-500">进度: {progress?.progress || 0}%</span>
|
<span className="text-xs text-slate-500">进度: {progress?.progress || 0}%</span>
|
||||||
@@ -291,20 +180,17 @@ const ExtractorPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 p-4 font-mono text-sm overflow-y-auto leading-relaxed">
|
<div className="flex-1 p-4 font-mono text-sm overflow-y-auto leading-relaxed">
|
||||||
{logs.map((log, index) => (
|
{logs.length === 0 ? (
|
||||||
<div
|
<div className="text-slate-500 text-center py-8">等待执行...</div>
|
||||||
key={index}
|
) : (
|
||||||
className={
|
logs.map((log, index) => (
|
||||||
log.includes('[System]')
|
<div key={index} className={getLogColor(log.level)}>
|
||||||
? 'text-emerald-500'
|
<span className="text-slate-600">[{log.timestamp}]</span>{' '}
|
||||||
: log.includes('error') || log.includes('失败')
|
<span className="text-slate-500">[{log.level.toUpperCase()}]</span> {log.message}
|
||||||
? 'text-red-400'
|
</div>
|
||||||
: 'text-slate-400'
|
))
|
||||||
}
|
)}
|
||||||
>
|
<div ref={logsEndRef} />
|
||||||
{log}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ const SettingsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto animate-in fade-in slide-in-from-bottom-4 duration-500 mt-6">
|
<div className="flex justify-center animate-in fade-in slide-in-from-bottom-4 duration-500 mt-6">
|
||||||
{message && (
|
{message && (
|
||||||
<div
|
<div
|
||||||
className={`fixed top-5 left-1/2 -translate-x-1/2 px-6 py-3 rounded-lg shadow-lg z-[10000] text-sm font-medium transition-all ${message.type === 'success' ? 'bg-emerald-50 text-emerald-600 border border-emerald-200' : 'bg-red-50 text-red-600 border border-red-200'}`}
|
className={`fixed top-5 left-1/2 -translate-x-1/2 px-6 py-3 rounded-lg shadow-lg z-[10000] text-sm font-medium transition-all ${message.type === 'success' ? 'bg-emerald-50 text-emerald-600 border border-emerald-200' : 'bg-red-50 text-red-600 border border-red-200'}`}
|
||||||
@@ -103,7 +103,7 @@ const SettingsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
<div className="w-full max-w-xl bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||||
<div className="border-b border-slate-100 bg-slate-50 px-6 py-5">
|
<div className="border-b border-slate-100 bg-slate-50 px-6 py-5">
|
||||||
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-800">
|
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-800">
|
||||||
<SettingsIcon size={20} className="text-slate-600" />
|
<SettingsIcon size={20} className="text-slate-600" />
|
||||||
@@ -114,8 +114,8 @@ const SettingsPage: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 space-y-6 bg-white">
|
<div className="p-6 space-y-6 bg-white flex flex-col items-center">
|
||||||
<div>
|
<div className="w-full max-w-md">
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||||
ERP 基础访问地址 (URL)
|
ERP 基础访问地址 (URL)
|
||||||
</label>
|
</label>
|
||||||
@@ -128,7 +128,7 @@ const SettingsPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-5">
|
<div className="w-full max-w-md grid grid-cols-2 gap-5">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||||
登录账号 (Username)
|
登录账号 (Username)
|
||||||
@@ -155,7 +155,7 @@ const SettingsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-6 mt-2 border-t border-slate-100 flex justify-end">
|
<div className="w-full max-w-md pt-6 mt-2 border-t border-slate-100 flex justify-center">
|
||||||
<button
|
<button
|
||||||
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:hover:bg-blue-600 text-white px-8 py-2.5 rounded-lg flex items-center gap-2 font-medium shadow-sm transition-colors"
|
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:hover:bg-blue-600 text-white px-8 py-2.5 rounded-lg flex items-center gap-2 font-medium shadow-sm transition-colors"
|
||||||
onClick={handleSaveSettings}
|
onClick={handleSaveSettings}
|
||||||
|
|||||||
Reference in New Issue
Block a user