feat(extractor): add operation history tracking
Add a new operation history feature for the extractor module that tracks all extraction operations with persistent database storage. Features: - Records extraction operations with batch tracking (UUID-based) - Preserves production ID to order number mapping - Shows batch statistics (orders, records, success/failure counts) - Expandable details for each batch showing individual order records - User-based permission: Admin sees all records, User sees own records only - Delete functionality with permission validation Database: - New ExtractorOperationHistory table schema - Supports both SQL Server and MySQL - Indexed on BatchId, UserId, and OperationTime Files: - Add DAO class for history operations - Add IPC handler with permission checks - Add preload API wrapper - Add React modal component with expandable batch details - Integrate history recording into extractor handler - Add operation history button to ExtractorPage Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { ErpAuthService } from '../services/erp/erp-auth'
|
||||
import { ExtractorService } from '../services/erp/extractor'
|
||||
import { OrderNumberResolver } from '../services/erp/order-resolver'
|
||||
import { create, type IDatabaseService } from '../services/database'
|
||||
import { ExtractorOperationHistoryDAO } from '../services/database/extractor-operation-history-dao'
|
||||
import { createLogger } from '../services/logger'
|
||||
import { logAudit } from '../services/logger/audit-logger'
|
||||
import { SessionManager } from '../services/user/session-manager'
|
||||
@@ -12,6 +13,7 @@ import type { ExtractorInput, ExtractorResult, ExtractionProgress } from '../typ
|
||||
import { UserErpConfigService } from '../services/user/user-erp-config-service'
|
||||
import { ConfigManager } from '../services/config/config-manager'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import { randomUUID } from 'crypto'
|
||||
|
||||
const log = createLogger('ExtractorHandler')
|
||||
|
||||
@@ -141,6 +143,29 @@ export function registerExtractorHandlers(): void {
|
||||
|
||||
log.info('Resolved order numbers', { count: validOrderNumbers.length })
|
||||
|
||||
// Initialize operation history recording
|
||||
const currentUser = SessionManager.getInstance().getUserInfo()
|
||||
const historyDao = new ExtractorOperationHistoryDAO()
|
||||
const batchId = randomUUID()
|
||||
|
||||
// Save order records to history (preserve productionId -> orderNumber mapping)
|
||||
if (currentUser) {
|
||||
const orderRecords = mappings.map((m) => ({
|
||||
productionId: m.productionId || null,
|
||||
orderNumber: m.orderNumber || m.input
|
||||
}))
|
||||
await historyDao.insertBatchRecords(
|
||||
batchId,
|
||||
currentUser.id,
|
||||
currentUser.username,
|
||||
orderRecords
|
||||
)
|
||||
log.info('Operation history batch created', {
|
||||
batchId,
|
||||
recordCount: orderRecords.length
|
||||
})
|
||||
}
|
||||
|
||||
// Log deduplication summary
|
||||
sendLog(sender, 'info', dedupReport.summary)
|
||||
|
||||
@@ -223,11 +248,22 @@ export function registerExtractorHandlers(): void {
|
||||
})
|
||||
}
|
||||
|
||||
// Update operation history batch status
|
||||
if (currentUser) {
|
||||
const status: 'success' | 'failed' | 'partial' =
|
||||
result.errors.length > 0 && result.recordCount > 0
|
||||
? 'partial'
|
||||
: result.errors.length > 0
|
||||
? 'failed'
|
||||
: 'success'
|
||||
await historyDao.updateBatchStatus(batchId, status, result.recordCount)
|
||||
log.info('Operation history batch status updated', { batchId, status })
|
||||
}
|
||||
|
||||
// Audit log: EXTRACT (non-blocking)
|
||||
const os = await import('os')
|
||||
const currentUser = SessionManager.getInstance().getUserInfo()
|
||||
if (currentUser) {
|
||||
const status: 'success' | 'failure' | 'partial' =
|
||||
const auditStatus: 'success' | 'failure' | 'partial' =
|
||||
result.errors.length > 0 && result.recordCount > 0
|
||||
? 'partial'
|
||||
: result.errors.length > 0
|
||||
@@ -237,7 +273,7 @@ export function registerExtractorHandlers(): void {
|
||||
username: currentUser.username,
|
||||
computerName: os.hostname(),
|
||||
resource: 'MATERIAL_PLAN',
|
||||
status,
|
||||
status: auditStatus,
|
||||
metadata: {
|
||||
orderCount: validOrderNumbers.length,
|
||||
recordCount: result.recordCount,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { registerLoggerHandlers } from './logger-handler'
|
||||
import { registerReportHandlers } from './report-handler'
|
||||
import { registerUpdateHandlers } from './update-handler'
|
||||
import { registerPlaywrightBrowserHandlers } from './playwright-browser'
|
||||
import { registerOperationHistoryHandlers } from './operation-history-handler'
|
||||
import { createLogger, logError } from '../services/logger'
|
||||
import { serializeError, sanitizeError } from '../services/logger/error-utils'
|
||||
import { getErrorMessage, getErrorCode, isBaseError } from '../types/errors'
|
||||
@@ -107,5 +108,6 @@ export function registerIpcHandlers(): void {
|
||||
registerReportHandlers()
|
||||
registerUpdateHandlers()
|
||||
registerPlaywrightBrowserHandlers()
|
||||
registerOperationHistoryHandlers()
|
||||
log.info('All IPC handlers registered')
|
||||
}
|
||||
|
||||
124
src/main/ipc/operation-history-handler.ts
Normal file
124
src/main/ipc/operation-history-handler.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* IPC Handler for Extractor Operation History
|
||||
*
|
||||
* Handles IPC requests for operation history management:
|
||||
* - Get batch list (filtered by user for non-admin users)
|
||||
* - Get batch details
|
||||
* - Delete batches
|
||||
*/
|
||||
|
||||
import { ipcMain } from 'electron'
|
||||
import { ExtractorOperationHistoryDAO } from '../services/database/extractor-operation-history-dao'
|
||||
import { SessionManager } from '../services/user/session-manager'
|
||||
import { withErrorHandling, type IpcResult } from './index'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import { createLogger } from '../services/logger'
|
||||
import type {
|
||||
BatchStats,
|
||||
OperationHistoryRecord,
|
||||
GetBatchesOptions
|
||||
} from '../types/operation-history.types'
|
||||
|
||||
const log = createLogger('OperationHistoryHandler')
|
||||
|
||||
/**
|
||||
* Register IPC handlers for operation history
|
||||
*/
|
||||
export function registerOperationHistoryHandlers(): void {
|
||||
const dao = new ExtractorOperationHistoryDAO()
|
||||
|
||||
/**
|
||||
* Get batches list
|
||||
* Admin users get all batches, regular users get only their own
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.OPERATION_HISTORY_GET_BATCHES,
|
||||
async (event, options?: GetBatchesOptions): Promise<IpcResult<BatchStats[]>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const currentUser = SessionManager.getInstance().getUserInfo()
|
||||
|
||||
if (!currentUser) {
|
||||
throw new Error('用户未登录')
|
||||
}
|
||||
|
||||
// Admin gets all batches, User gets only their own
|
||||
const userId = currentUser.userType === 'Admin' ? undefined : currentUser.id
|
||||
|
||||
log.info('Getting operation history batches', {
|
||||
userId: currentUser.id,
|
||||
userType: currentUser.userType,
|
||||
filtered: userId !== undefined
|
||||
})
|
||||
|
||||
const batches = await dao.getBatches(userId, options)
|
||||
return batches
|
||||
}, 'operationHistory:getBatches')
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Get batch details
|
||||
* Users can only view their own batch details, admins can view all
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.OPERATION_HISTORY_GET_BATCH_DETAILS,
|
||||
async (event, batchId: string): Promise<IpcResult<OperationHistoryRecord[]>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const currentUser = SessionManager.getInstance().getUserInfo()
|
||||
|
||||
if (!currentUser) {
|
||||
throw new Error('用户未登录')
|
||||
}
|
||||
|
||||
log.info('Getting batch details', { batchId, userId: currentUser.id })
|
||||
|
||||
const details = await dao.getBatchDetails(batchId)
|
||||
|
||||
// For non-admin users, verify they own this batch
|
||||
if (currentUser.userType !== 'Admin' && details.length > 0) {
|
||||
const batchOwnerId = details[0].userId
|
||||
if (batchOwnerId !== currentUser.id) {
|
||||
throw new Error('没有权限查看此批次详情')
|
||||
}
|
||||
}
|
||||
|
||||
return details
|
||||
}, 'operationHistory:getBatchDetails')
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Delete a batch
|
||||
* Users can only delete their own batches, admins can delete any
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.OPERATION_HISTORY_DELETE_BATCH,
|
||||
async (event, batchId: string): Promise<IpcResult<{ deleted: boolean }>> => {
|
||||
return withErrorHandling(async () => {
|
||||
const currentUser = SessionManager.getInstance().getUserInfo()
|
||||
|
||||
if (!currentUser) {
|
||||
throw new Error('用户未登录')
|
||||
}
|
||||
|
||||
const isAdmin = currentUser.userType === 'Admin'
|
||||
|
||||
log.info('Deleting batch', {
|
||||
batchId,
|
||||
userId: currentUser.id,
|
||||
isAdmin
|
||||
})
|
||||
|
||||
const result = await dao.deleteBatch(batchId, currentUser.id, isAdmin)
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '删除批次失败')
|
||||
}
|
||||
|
||||
return { deleted: true }
|
||||
}, 'operationHistory:deleteBatch')
|
||||
}
|
||||
)
|
||||
|
||||
log.info('Operation history IPC handlers registered')
|
||||
}
|
||||
607
src/main/services/database/extractor-operation-history-dao.ts
Normal file
607
src/main/services/database/extractor-operation-history-dao.ts
Normal file
@@ -0,0 +1,607 @@
|
||||
/**
|
||||
* Data Access Object for ExtractorOperationHistory table
|
||||
*
|
||||
* Handles database operations for tracking extraction operation history:
|
||||
* - Batch record insertion
|
||||
* - Batch status updates
|
||||
* - Querying batches (with user filtering for non-admin users)
|
||||
* - Getting batch details
|
||||
* - Deleting batches
|
||||
*/
|
||||
|
||||
import { create, type IDatabaseService } from './index'
|
||||
import { createLogger } from '../logger'
|
||||
import type {
|
||||
OperationHistoryRecord,
|
||||
BatchStats,
|
||||
InsertBatchRecordInput,
|
||||
UpdateBatchStatusResult,
|
||||
GetBatchesOptions
|
||||
} from '../../types/operation-history.types'
|
||||
|
||||
const log = createLogger('ExtractorOperationHistoryDAO')
|
||||
|
||||
/**
|
||||
* Configuration for ExtractorOperationHistory table
|
||||
*/
|
||||
export const EXTRACTOR_OPERATION_HISTORY_CONFIG = {
|
||||
TABLE_NAME_SQLSERVER: '[dbo].[ExtractorOperationHistory]',
|
||||
TABLE_NAME_MYSQL: 'dbo_ExtractorOperationHistory',
|
||||
COLUMNS: {
|
||||
ID: 'ID',
|
||||
BATCH_ID: 'BatchId',
|
||||
USER_ID: 'UserId',
|
||||
USERNAME: 'Username',
|
||||
PRODUCTION_ID: 'ProductionId',
|
||||
ORDER_NUMBER: 'OrderNumber',
|
||||
OPERATION_TIME: 'OperationTime',
|
||||
STATUS: 'Status',
|
||||
RECORD_COUNT: 'RecordCount',
|
||||
ERROR_MESSAGE: 'ErrorMessage'
|
||||
}
|
||||
} as const
|
||||
|
||||
/**
|
||||
* ExtractorOperationHistory DAO Class
|
||||
*/
|
||||
export class ExtractorOperationHistoryDAO {
|
||||
private dbService: IDatabaseService | null = null
|
||||
|
||||
/**
|
||||
* Get the appropriate table name based on database type
|
||||
*/
|
||||
private getTableName(): string {
|
||||
const isSqlServer = this.dbService?.type === 'sqlserver'
|
||||
return isSqlServer
|
||||
? EXTRACTOR_OPERATION_HISTORY_CONFIG.TABLE_NAME_SQLSERVER
|
||||
: EXTRACTOR_OPERATION_HISTORY_CONFIG.TABLE_NAME_MYSQL
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database service instance using DatabaseFactory
|
||||
*/
|
||||
private async getDatabaseService(): Promise<IDatabaseService> {
|
||||
if (this.dbService && this.dbService.isConnected()) {
|
||||
return this.dbService
|
||||
}
|
||||
|
||||
this.dbService = await create()
|
||||
return this.dbService
|
||||
}
|
||||
|
||||
/**
|
||||
* Build placeholders for IN clause based on database type
|
||||
*/
|
||||
private buildPlaceholders(count: number, isSqlServer: boolean): string {
|
||||
return isSqlServer
|
||||
? Array.from({ length: count }, (_, idx) => `@p${idx}`).join(',')
|
||||
: Array.from({ length: count }, () => '?').join(',')
|
||||
}
|
||||
|
||||
// ==================== INSERT ====================
|
||||
|
||||
/**
|
||||
* Insert batch records for a single extraction operation
|
||||
* @param batchId - Unique batch identifier
|
||||
* @param userId - User ID performing the operation
|
||||
* @param username - Username performing the operation
|
||||
* @param records - Array of order records to insert
|
||||
* @returns True if successful
|
||||
*/
|
||||
async insertBatchRecords(
|
||||
batchId: string,
|
||||
userId: number,
|
||||
username: string,
|
||||
records: InsertBatchRecordInput[]
|
||||
): Promise<boolean> {
|
||||
if (!records || records.length === 0) {
|
||||
log.warn('No records to insert')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
|
||||
for (const record of records) {
|
||||
try {
|
||||
if (isSqlServer) {
|
||||
const sqlString = `
|
||||
INSERT INTO ${tableName}
|
||||
(BatchId, UserId, Username, ProductionId, OrderNumber, OperationTime, Status)
|
||||
VALUES
|
||||
(@p0, @p1, @p2, @p3, @p4, GETDATE(), 'pending')
|
||||
`
|
||||
await dbService.query(sqlString, [
|
||||
batchId,
|
||||
userId,
|
||||
username,
|
||||
record.productionId || null,
|
||||
record.orderNumber
|
||||
])
|
||||
} else {
|
||||
const sqlString = `
|
||||
INSERT INTO ${tableName}
|
||||
(BatchId, UserId, Username, ProductionId, OrderNumber, OperationTime, Status)
|
||||
VALUES
|
||||
(?, ?, ?, ?, ?, NOW(), 'pending')
|
||||
`
|
||||
await dbService.query(sqlString, [
|
||||
batchId,
|
||||
userId,
|
||||
username,
|
||||
record.productionId || null,
|
||||
record.orderNumber
|
||||
])
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error inserting individual record', {
|
||||
batchId,
|
||||
orderNumber: record.orderNumber,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
log.info('Batch records inserted', { batchId, count: records.length })
|
||||
return true
|
||||
} catch (error) {
|
||||
log.error('Insert batch records error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== UPDATE ====================
|
||||
|
||||
/**
|
||||
* Update the status of all records in a batch
|
||||
* @param batchId - Batch identifier
|
||||
* @param status - New status (success, failed, partial)
|
||||
* @param recordCount - Total record count for the batch
|
||||
* @returns Update result
|
||||
*/
|
||||
async updateBatchStatus(
|
||||
batchId: string,
|
||||
status: string,
|
||||
recordCount: number | null
|
||||
): Promise<UpdateBatchStatusResult> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
let sqlString: string
|
||||
let params: (string | number | null)[]
|
||||
|
||||
if (recordCount !== null) {
|
||||
sqlString = `
|
||||
UPDATE ${tableName}
|
||||
SET Status = ${isSqlServer ? '@p0' : '?'},
|
||||
RecordCount = ${isSqlServer ? '@p1' : '?'}
|
||||
WHERE BatchId = ${isSqlServer ? '@p2' : '?'}
|
||||
`
|
||||
params = isSqlServer ? [status, recordCount, batchId] : [status, recordCount, batchId]
|
||||
} else {
|
||||
sqlString = `
|
||||
UPDATE ${tableName}
|
||||
SET Status = ${placeholder}
|
||||
WHERE BatchId = ${isSqlServer ? '@p1' : '?'}
|
||||
`
|
||||
params = isSqlServer ? [status, batchId] : [status, batchId]
|
||||
}
|
||||
|
||||
await dbService.query(sqlString, params)
|
||||
|
||||
log.info('Batch status updated', { batchId, status, recordCount })
|
||||
return { success: true, updatedCount: 1 }
|
||||
} catch (error) {
|
||||
log.error('Update batch status error', {
|
||||
batchId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return { success: false, updatedCount: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single record's status and error message
|
||||
* @param batchId - Batch identifier
|
||||
* @param orderNumber - Order number
|
||||
* @param status - New status
|
||||
* @param errorMessage - Optional error message
|
||||
* @returns True if successful
|
||||
*/
|
||||
async updateRecordStatus(
|
||||
batchId: string,
|
||||
orderNumber: string,
|
||||
status: string,
|
||||
errorMessage?: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
|
||||
const sqlString = `
|
||||
UPDATE ${tableName}
|
||||
SET Status = ${isSqlServer ? '@p0' : '?'},
|
||||
ErrorMessage = ${isSqlServer ? '@p1' : '?'}
|
||||
WHERE BatchId = ${isSqlServer ? '@p2' : '?'}
|
||||
AND OrderNumber = ${isSqlServer ? '@p3' : '?'}
|
||||
`
|
||||
|
||||
await dbService.query(sqlString, [status, errorMessage || null, batchId, orderNumber])
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
log.error('Update record status error', {
|
||||
batchId,
|
||||
orderNumber,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== READ ====================
|
||||
|
||||
/**
|
||||
* Get batch statistics with optional user filtering
|
||||
* @param userId - Optional user ID for filtering (Admin gets all, User gets own)
|
||||
* @param options - Query options (limit, offset)
|
||||
* @returns Array of batch statistics
|
||||
*/
|
||||
async getBatches(userId?: number, options?: GetBatchesOptions): Promise<BatchStats[]> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
|
||||
let sqlString = `
|
||||
SELECT
|
||||
BatchId,
|
||||
UserId,
|
||||
Username,
|
||||
MIN(OperationTime) as OperationTime,
|
||||
MAX(Status) as Status,
|
||||
COUNT(*) as TotalOrders,
|
||||
SUM(COALESCE(RecordCount, 0)) as TotalRecords,
|
||||
SUM(CASE WHEN Status = 'success' THEN 1 ELSE 0 END) as SuccessCount,
|
||||
SUM(CASE WHEN Status = 'failed' THEN 1 ELSE 0 END) as FailedCount
|
||||
FROM ${tableName}
|
||||
`
|
||||
|
||||
const params: (number | string)[] = []
|
||||
|
||||
if (userId !== undefined) {
|
||||
sqlString += isSqlServer ? ` WHERE UserId = @p0 ` : ` WHERE UserId = ? `
|
||||
params.push(userId)
|
||||
}
|
||||
|
||||
sqlString += `
|
||||
GROUP BY BatchId, UserId, Username
|
||||
ORDER BY OperationTime DESC
|
||||
`
|
||||
|
||||
if (options?.limit) {
|
||||
// Add pagination - track current param count before adding new params
|
||||
const offsetIndex = params.length
|
||||
const limitIndex = params.length + 1
|
||||
|
||||
if (options.offset !== undefined) {
|
||||
params.push(options.offset)
|
||||
}
|
||||
params.push(options.limit)
|
||||
|
||||
if (isSqlServer) {
|
||||
if (options.offset !== undefined) {
|
||||
sqlString += ` OFFSET @p${offsetIndex} ROWS FETCH NEXT @p${limitIndex} ROWS ONLY`
|
||||
} else {
|
||||
// When no offset, use 0 for offset and next index for limit
|
||||
sqlString += ` OFFSET 0 ROWS FETCH NEXT @p${offsetIndex} ROWS ONLY`
|
||||
}
|
||||
} else {
|
||||
if (options.offset !== undefined) {
|
||||
sqlString += ` LIMIT ?`
|
||||
// For MySQL with offset, we need to modify the query
|
||||
// Replace LIMIT with OFFSET LIMIT
|
||||
const parts = sqlString.split(' LIMIT ?')
|
||||
sqlString = parts[0] + ` OFFSET ? LIMIT ?` + (parts[1] || '')
|
||||
} else {
|
||||
sqlString += ` LIMIT ?`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await dbService.query(sqlString, params)
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
batchId: row.BatchId as string,
|
||||
userId: row.UserId as number,
|
||||
username: row.Username as string,
|
||||
operationTime: row.OperationTime
|
||||
? new Date(row.OperationTime as string).toISOString()
|
||||
: new Date().toISOString(),
|
||||
status: row.Status as string,
|
||||
totalOrders: row.TotalOrders as number,
|
||||
totalRecords: (row.TotalRecords as number) || 0,
|
||||
successCount: (row.SuccessCount as number) || 0,
|
||||
failedCount: (row.FailedCount as number) || 0
|
||||
}))
|
||||
} catch (error) {
|
||||
log.error('Get batches error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed records for a specific batch
|
||||
* @param batchId - Batch identifier
|
||||
* @returns Array of operation records
|
||||
*/
|
||||
async getBatchDetails(batchId: string): Promise<OperationHistoryRecord[]> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
const sqlString = `
|
||||
SELECT
|
||||
ID,
|
||||
BatchId,
|
||||
UserId,
|
||||
Username,
|
||||
ProductionId,
|
||||
OrderNumber,
|
||||
OperationTime,
|
||||
Status,
|
||||
RecordCount,
|
||||
ErrorMessage
|
||||
FROM ${tableName}
|
||||
WHERE BatchId = ${placeholder}
|
||||
ORDER BY ID
|
||||
`
|
||||
|
||||
const result = await dbService.query(sqlString, [batchId])
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
id: row.ID as number,
|
||||
batchId: row.BatchId as string,
|
||||
userId: row.UserId as number,
|
||||
username: row.Username as string,
|
||||
productionId: row.ProductionId as string | null,
|
||||
orderNumber: row.OrderNumber as string,
|
||||
operationTime: new Date(row.OperationTime as string),
|
||||
status: row.Status as string,
|
||||
recordCount: row.RecordCount as number | null,
|
||||
errorMessage: row.ErrorMessage as string | null
|
||||
}))
|
||||
} catch (error) {
|
||||
log.error('Get batch details error', {
|
||||
batchId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single batch's statistics
|
||||
* @param batchId - Batch identifier
|
||||
* @returns Batch statistics or null
|
||||
*/
|
||||
async getBatchStats(batchId: string): Promise<BatchStats | null> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
const sqlString = `
|
||||
SELECT
|
||||
BatchId,
|
||||
UserId,
|
||||
Username,
|
||||
MIN(OperationTime) as OperationTime,
|
||||
MAX(Status) as Status,
|
||||
COUNT(*) as TotalOrders,
|
||||
SUM(COALESCE(RecordCount, 0)) as TotalRecords,
|
||||
SUM(CASE WHEN Status = 'success' THEN 1 ELSE 0 END) as SuccessCount,
|
||||
SUM(CASE WHEN Status = 'failed' THEN 1 ELSE 0 END) as FailedCount
|
||||
FROM ${tableName}
|
||||
WHERE BatchId = ${placeholder}
|
||||
GROUP BY BatchId, UserId, Username
|
||||
`
|
||||
|
||||
const result = await dbService.query(sqlString, [batchId])
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const row = result.rows[0]
|
||||
return {
|
||||
batchId: row.BatchId as string,
|
||||
userId: row.UserId as number,
|
||||
username: row.Username as string,
|
||||
operationTime: row.OperationTime
|
||||
? new Date(row.OperationTime as string).toISOString()
|
||||
: new Date().toISOString(),
|
||||
status: row.Status as string,
|
||||
totalOrders: row.TotalOrders as number,
|
||||
totalRecords: (row.TotalRecords as number) || 0,
|
||||
successCount: (row.SuccessCount as number) || 0,
|
||||
failedCount: (row.FailedCount as number) || 0
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Get batch stats error', {
|
||||
batchId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== DELETE ====================
|
||||
|
||||
/**
|
||||
* Delete a batch with permission checking
|
||||
* @param batchId - Batch identifier
|
||||
* @param requestingUserId - User ID requesting the deletion
|
||||
* @param isAdmin - Whether the requesting user is an admin
|
||||
* @returns True if successful
|
||||
*/
|
||||
async deleteBatch(
|
||||
batchId: string,
|
||||
requestingUserId: number,
|
||||
isAdmin: boolean
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
|
||||
// First check if the batch exists and if the user has permission
|
||||
const batchStats = await this.getBatchStats(batchId)
|
||||
|
||||
if (!batchStats) {
|
||||
return { success: false, error: '批次不存在' }
|
||||
}
|
||||
|
||||
// Non-admin users can only delete their own batches
|
||||
if (!isAdmin && batchStats.userId !== requestingUserId) {
|
||||
return { success: false, error: '没有权限删除此批次' }
|
||||
}
|
||||
|
||||
// Delete the batch
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
const sqlString = `
|
||||
DELETE FROM ${tableName}
|
||||
WHERE BatchId = ${placeholder}
|
||||
`
|
||||
|
||||
const result = await dbService.query(sqlString, [batchId])
|
||||
|
||||
log.info('Batch deleted', { batchId, rowCount: result.rowCount })
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
log.error('Delete batch error', {
|
||||
batchId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all batches for a specific user
|
||||
* @param userId - User ID
|
||||
* @returns Number of batches deleted
|
||||
*/
|
||||
async deleteByUser(userId: number): Promise<number> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
const sqlString = `
|
||||
DELETE FROM ${tableName}
|
||||
WHERE UserId = ${placeholder}
|
||||
`
|
||||
|
||||
const result = await dbService.query(sqlString, [userId])
|
||||
return result.rowCount
|
||||
} catch (error) {
|
||||
log.error('Delete by user error', {
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== UTILITIES ====================
|
||||
|
||||
/**
|
||||
* Check if a batch exists
|
||||
* @param batchId - Batch identifier
|
||||
* @returns True if batch exists
|
||||
*/
|
||||
async batchExists(batchId: string): Promise<boolean> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
|
||||
const placeholder = isSqlServer ? '@p0' : '?'
|
||||
const sqlString = `
|
||||
SELECT COUNT(*) as count
|
||||
FROM ${tableName}
|
||||
WHERE BatchId = ${placeholder}
|
||||
`
|
||||
|
||||
const result = await dbService.query(sqlString, [batchId])
|
||||
return result.rows.length > 0 && (result.rows[0].count as number) > 0
|
||||
} catch (error) {
|
||||
log.error('Batch exists error', {
|
||||
batchId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total batches with optional user filtering
|
||||
* @param userId - Optional user ID for filtering
|
||||
* @returns Total number of batches
|
||||
*/
|
||||
async countBatches(userId?: number): Promise<number> {
|
||||
try {
|
||||
const dbService = await this.getDatabaseService()
|
||||
const tableName = this.getTableName()
|
||||
const isSqlServer = dbService.type === 'sqlserver'
|
||||
|
||||
let sqlString = `
|
||||
SELECT COUNT(DISTINCT BatchId) as count
|
||||
FROM ${tableName}
|
||||
`
|
||||
|
||||
const params: number[] = []
|
||||
|
||||
if (userId !== undefined) {
|
||||
sqlString += isSqlServer ? ` WHERE UserId = @p0 ` : ` WHERE UserId = ? `
|
||||
params.push(userId)
|
||||
}
|
||||
|
||||
const result = await dbService.query(sqlString, params)
|
||||
return result.rows.length > 0 ? (result.rows[0].count as number) : 0
|
||||
} catch (error) {
|
||||
log.error('Count batches error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from database
|
||||
*/
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.dbService) {
|
||||
await this.dbService.disconnect()
|
||||
this.dbService = null
|
||||
}
|
||||
}
|
||||
}
|
||||
86
src/main/types/operation-history.types.ts
Normal file
86
src/main/types/operation-history.types.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Operation History Type Definitions
|
||||
*
|
||||
* Type definitions for the Extractor Operation History feature.
|
||||
* Tracks extraction operations with batch and individual order record details.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Individual operation history record
|
||||
*/
|
||||
export interface OperationHistoryRecord {
|
||||
/** Auto-increment ID */
|
||||
id?: number
|
||||
/** Batch ID - shared among all orders in a single extraction operation */
|
||||
batchId: string
|
||||
/** User ID who performed the operation */
|
||||
userId: number
|
||||
/** Username who performed the operation */
|
||||
username: string
|
||||
/** Original input production ID (e.g., "22A1"), null if input was already an order number */
|
||||
productionId: string | null
|
||||
/** Resolved order number (e.g., "SC70202602120085") */
|
||||
orderNumber: string
|
||||
/** When the operation was performed */
|
||||
operationTime: Date
|
||||
/** Operation status: pending, success, failed, partial */
|
||||
status: string
|
||||
/** Number of records extracted for this order */
|
||||
recordCount: number | null
|
||||
/** Error message if operation failed */
|
||||
errorMessage: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch statistics - aggregated view of a batch operation
|
||||
*/
|
||||
export interface BatchStats {
|
||||
/** Unique batch identifier */
|
||||
batchId: string
|
||||
/** User ID who performed the operation */
|
||||
userId: number
|
||||
/** Username who performed the operation */
|
||||
username: string
|
||||
/** When the operation started */
|
||||
operationTime: string
|
||||
/** Overall batch status: pending, success, failed, partial */
|
||||
status: string
|
||||
/** Total number of orders in the batch */
|
||||
totalOrders: number
|
||||
/** Total records extracted across all orders */
|
||||
totalRecords: number
|
||||
/** Number of orders that succeeded */
|
||||
successCount: number
|
||||
/** Number of orders that failed */
|
||||
failedCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for inserting batch records
|
||||
*/
|
||||
export interface InsertBatchRecordInput {
|
||||
/** Original input production ID (e.g., "22A1") */
|
||||
productionId: string | null
|
||||
/** Resolved order number (e.g., "SC70202602120085") */
|
||||
orderNumber: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Result for batch status update
|
||||
*/
|
||||
export interface UpdateBatchStatusResult {
|
||||
/** Whether the update was successful */
|
||||
success: boolean
|
||||
/** Number of records updated */
|
||||
updatedCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for querying batches
|
||||
*/
|
||||
export interface GetBatchesOptions {
|
||||
/** Maximum number of batches to return */
|
||||
limit?: number
|
||||
/** Number of batches to skip (for pagination) */
|
||||
offset?: number
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from './materials'
|
||||
import { loggerApi } from './logger'
|
||||
import { playwrightBrowserApi } from './browser-download'
|
||||
import { operationHistoryApi } from './operation-history'
|
||||
|
||||
export const api = {
|
||||
process: processApi,
|
||||
@@ -35,7 +36,8 @@ export const api = {
|
||||
logger: loggerApi,
|
||||
report: reportApi,
|
||||
update: updateApi,
|
||||
playwrightBrowser: playwrightBrowserApi
|
||||
playwrightBrowser: playwrightBrowserApi,
|
||||
operationHistory: operationHistoryApi
|
||||
} as const
|
||||
|
||||
export type ElectronApi = typeof api
|
||||
|
||||
30
src/preload/api/operation-history.ts
Normal file
30
src/preload/api/operation-history.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import { invokeIpc } from '../lib/ipc'
|
||||
import type {
|
||||
BatchStats,
|
||||
OperationHistoryRecord,
|
||||
GetBatchesOptions
|
||||
} from '../../main/types/operation-history.types'
|
||||
import type { IpcResult } from '../../main/types/ipc.types'
|
||||
|
||||
export const operationHistoryApi = {
|
||||
/**
|
||||
* Get list of operation batches
|
||||
* Admin users receive all batches, regular users only their own
|
||||
*/
|
||||
getBatches: (options?: GetBatchesOptions): Promise<IpcResult<BatchStats[]>> =>
|
||||
invokeIpc(IPC_CHANNELS.OPERATION_HISTORY_GET_BATCHES, options),
|
||||
|
||||
/**
|
||||
* Get detailed records for a specific batch
|
||||
*/
|
||||
getBatchDetails: (batchId: string): Promise<IpcResult<OperationHistoryRecord[]>> =>
|
||||
invokeIpc(IPC_CHANNELS.OPERATION_HISTORY_GET_BATCH_DETAILS, batchId),
|
||||
|
||||
/**
|
||||
* Delete a batch
|
||||
* Admin users can delete any batch, regular users only their own
|
||||
*/
|
||||
deleteBatch: (batchId: string): Promise<IpcResult<{ deleted: boolean }>> =>
|
||||
invokeIpc(IPC_CHANNELS.OPERATION_HISTORY_DELETE_BATCH, batchId)
|
||||
} as const
|
||||
32
src/preload/index.d.ts
vendored
32
src/preload/index.d.ts
vendored
@@ -157,6 +157,37 @@ export interface PlaywrightBrowserAPI {
|
||||
onProgress: (callback: (data: DownloadProgress) => void) => () => void
|
||||
}
|
||||
|
||||
export interface OperationHistoryAPI {
|
||||
getBatches: (options?: { limit?: number; offset?: number }) => Promise<IpcResult<BatchStats[]>>
|
||||
getBatchDetails: (batchId: string) => Promise<IpcResult<OperationHistoryRecord[]>>
|
||||
deleteBatch: (batchId: string) => Promise<IpcResult<{ deleted: boolean }>>
|
||||
}
|
||||
|
||||
export interface BatchStats {
|
||||
batchId: string
|
||||
userId: number
|
||||
username: string
|
||||
operationTime: string
|
||||
status: string
|
||||
totalOrders: number
|
||||
totalRecords: number
|
||||
successCount: number
|
||||
failedCount: number
|
||||
}
|
||||
|
||||
export interface OperationHistoryRecord {
|
||||
id?: number
|
||||
batchId: string
|
||||
userId: number
|
||||
username: string
|
||||
productionId: string | null
|
||||
orderNumber: string
|
||||
operationTime: Date
|
||||
status: string
|
||||
recordCount: number | null
|
||||
errorMessage: string | null
|
||||
}
|
||||
|
||||
export interface ProcessAPI {
|
||||
versions: {
|
||||
electron: string
|
||||
@@ -185,6 +216,7 @@ declare global {
|
||||
report: ReportAPI
|
||||
update: UpdateAPI
|
||||
playwrightBrowser: PlaywrightBrowserAPI
|
||||
operationHistory: OperationHistoryAPI
|
||||
}
|
||||
api: unknown
|
||||
}
|
||||
|
||||
392
src/renderer/src/components/ExtractorOperationHistoryModal.tsx
Normal file
392
src/renderer/src/components/ExtractorOperationHistoryModal.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* Extractor Operation History Modal
|
||||
*
|
||||
* Displays extraction operation history with batch statistics and details.
|
||||
* Admin users see all users' records, regular users see only their own.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Modal } from './ui/Modal'
|
||||
import {
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock
|
||||
} from 'lucide-react'
|
||||
import type { UserInfo } from './UserSelectionDialog'
|
||||
|
||||
// Local type definitions matching the backend types
|
||||
interface BatchStats {
|
||||
batchId: string
|
||||
userId: number
|
||||
username: string
|
||||
operationTime: string
|
||||
status: string
|
||||
totalOrders: number
|
||||
totalRecords: number
|
||||
successCount: number
|
||||
failedCount: number
|
||||
}
|
||||
|
||||
interface OperationHistoryRecord {
|
||||
id?: number
|
||||
batchId: string
|
||||
userId: number
|
||||
username: string
|
||||
productionId: string | null
|
||||
orderNumber: string
|
||||
operationTime: Date
|
||||
status: string
|
||||
recordCount: number | null
|
||||
errorMessage: string | null
|
||||
}
|
||||
|
||||
interface ExtractorOperationHistoryModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
user?: UserInfo | null
|
||||
}
|
||||
|
||||
const statusStyles: Record<string, string> = {
|
||||
success: 'bg-green-100 text-green-700',
|
||||
partial: 'bg-amber-100 text-amber-700',
|
||||
failed: 'bg-red-100 text-red-700',
|
||||
pending: 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
success: '成功',
|
||||
partial: '部分成功',
|
||||
failed: '失败',
|
||||
pending: '进行中'
|
||||
}
|
||||
|
||||
const statusIcons: Record<string, React.ReactNode> = {
|
||||
success: <CheckCircle size={16} className="text-green-600" />,
|
||||
partial: <Clock size={16} className="text-amber-600" />,
|
||||
failed: <XCircle size={16} className="text-red-600" />,
|
||||
pending: <Clock size={16} className="text-gray-500" />
|
||||
}
|
||||
|
||||
export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
user
|
||||
}) => {
|
||||
const [batches, setBatches] = useState<BatchStats[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [expandedBatches, setExpandedBatches] = useState<Set<string>>(new Set())
|
||||
const [batchDetails, setBatchDetails] = useState<Map<string, OperationHistoryRecord[]>>(new Map())
|
||||
const [deleting, setDeleting] = useState<Set<string>>(new Set())
|
||||
|
||||
const isAdmin = user?.userType === 'Admin'
|
||||
|
||||
// Fetch batches when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void fetchBatches()
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const fetchBatches = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await window.electron.operationHistory.getBatches({ limit: 100 })
|
||||
if (result.success && result.data) {
|
||||
setBatches(result.data)
|
||||
} else {
|
||||
setError(result.error || '获取历史记录失败')
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '获取历史记录失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchBatchDetails = async (batchId: string) => {
|
||||
// If already loaded, don't fetch again
|
||||
if (batchDetails.has(batchId)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.electron.operationHistory.getBatchDetails(batchId)
|
||||
if (result.success && result.data) {
|
||||
setBatchDetails((prev) => new Map(prev).set(batchId, result.data!))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch batch details:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleBatchExpansion = (batchId: string) => {
|
||||
setExpandedBatches((prev) => {
|
||||
const newSet = new Set(prev)
|
||||
if (newSet.has(batchId)) {
|
||||
newSet.delete(batchId)
|
||||
} else {
|
||||
newSet.add(batchId)
|
||||
void fetchBatchDetails(batchId)
|
||||
}
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeleteBatch = async (batchId: string) => {
|
||||
if (deleting.has(batchId)) return
|
||||
|
||||
const confirmed = confirm('确定要删除此批次记录吗?此操作不可撤销。')
|
||||
if (!confirmed) return
|
||||
|
||||
setDeleting((prev) => new Set(prev).add(batchId))
|
||||
|
||||
try {
|
||||
const result = await window.electron.operationHistory.deleteBatch(batchId)
|
||||
if (result.success) {
|
||||
// Remove from local state
|
||||
setBatches((prev) => prev.filter((b) => b.batchId !== batchId))
|
||||
setBatchDetails((prev) => {
|
||||
const newMap = new Map(prev)
|
||||
newMap.delete(batchId)
|
||||
return newMap
|
||||
})
|
||||
setExpandedBatches((prev) => {
|
||||
const newSet = new Set(prev)
|
||||
newSet.delete(batchId)
|
||||
return newSet
|
||||
})
|
||||
} else {
|
||||
alert(result.error || '删除失败')
|
||||
}
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : '删除失败')
|
||||
} finally {
|
||||
setDeleting((prev) => {
|
||||
const newSet = new Set(prev)
|
||||
newSet.delete(batchId)
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const formatDateTime = (dateStr: string) => {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="操作历史" size="3xl">
|
||||
<div className="flex flex-col h-[70vh]">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between mb-4 pb-4 border-b border-gray-200">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-600">
|
||||
{isAdmin ? (
|
||||
<span className="text-amber-600 font-medium">管理员模式:显示所有用户记录</span>
|
||||
) : (
|
||||
<span>仅显示您的操作记录</span>
|
||||
)}
|
||||
</span>
|
||||
{batches.length > 0 && (
|
||||
<span className="text-sm text-gray-500">共 {batches.length} 条批次</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50"
|
||||
onClick={() => void fetchBatches()}
|
||||
disabled={loading}
|
||||
title="刷新"
|
||||
>
|
||||
<RefreshCw size={18} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Batch list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading && batches.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32 text-gray-500">加载中...</div>
|
||||
) : batches.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32 text-gray-500">暂无操作记录</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{batches.map((batch) => {
|
||||
const isExpanded = expandedBatches.has(batch.batchId)
|
||||
const details = batchDetails.get(batch.batchId) || []
|
||||
const isDeleting = deleting.has(batch.batchId)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={batch.batchId}
|
||||
className="border border-gray-200 rounded-lg overflow-hidden"
|
||||
>
|
||||
{/* Batch summary */}
|
||||
<div
|
||||
className={`flex items-center justify-between p-4 cursor-pointer transition-colors ${
|
||||
isExpanded ? 'bg-gray-50' : 'hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={() => toggleBatchExpansion(batch.batchId)}
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<button className="p-1 hover:bg-gray-200 rounded">
|
||||
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
|
||||
</button>
|
||||
|
||||
<div className="flex-1 grid grid-cols-6 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="text-gray-500 text-xs">操作时间</div>
|
||||
<div className="font-medium text-gray-900">
|
||||
{formatDateTime(batch.operationTime)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500 text-xs">操作用户</div>
|
||||
<div className="font-medium text-gray-900">{batch.username}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500 text-xs">状态</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{statusIcons[batch.status] || statusIcons.pending}
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
statusStyles[batch.status] || statusStyles.pending
|
||||
}`}
|
||||
>
|
||||
{statusLabels[batch.status] || batch.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500 text-xs">订单数</div>
|
||||
<div className="font-medium text-gray-900">{batch.totalOrders}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500 text-xs">记录数</div>
|
||||
<div className="font-medium text-gray-900">{batch.totalRecords}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-gray-500 text-xs">成功/失败</div>
|
||||
<div className="font-medium text-gray-900">
|
||||
<span className="text-green-600">{batch.successCount}</span>
|
||||
{batch.failedCount > 0 && (
|
||||
<>
|
||||
{' / '}
|
||||
<span className="text-red-600">{batch.failedCount}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="p-2 hover:bg-red-50 text-gray-400 hover:text-red-600 rounded transition-colors disabled:opacity-50"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
void handleDeleteBatch(batch.batchId)
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
title="删除批次"
|
||||
>
|
||||
<Trash2 size={16} className={isDeleting ? 'animate-pulse' : ''} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Batch details */}
|
||||
{isExpanded && details.length > 0 && (
|
||||
<div className="border-t border-gray-200 bg-white">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-medium text-gray-600">
|
||||
总排号
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-gray-600">
|
||||
订单号
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-gray-600">
|
||||
状态
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-gray-600">
|
||||
记录数
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left font-medium text-gray-600">
|
||||
错误信息
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{details.map((detail) => (
|
||||
<tr key={detail.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-2 text-gray-900">
|
||||
{detail.productionId || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-900 font-mono text-xs">
|
||||
{detail.orderNumber}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${
|
||||
statusStyles[detail.status] || statusStyles.pending
|
||||
}`}
|
||||
>
|
||||
{statusIcons[detail.status]}
|
||||
{statusLabels[detail.status] || detail.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-900">
|
||||
{detail.recordCount ?? '-'}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-red-600 text-xs max-w-xs truncate">
|
||||
{detail.errorMessage || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="pt-4 border-t border-gray-200 flex justify-end">
|
||||
<button
|
||||
className="px-6 py-2 rounded-md bg-gray-100 hover:bg-gray-200 text-gray-700 font-medium transition-colors"
|
||||
onClick={onClose}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExtractorOperationHistoryModal
|
||||
@@ -1,14 +1,18 @@
|
||||
import React from 'react'
|
||||
import { Download, Play, CheckCircle } from 'lucide-react'
|
||||
import { Download, Play, CheckCircle, History } from 'lucide-react'
|
||||
import OrderNumberInput from '../components/OrderNumberInput'
|
||||
import { useExtractor } from '../hooks/useExtractor'
|
||||
import { usePersistentTextState } from '../hooks/usePersistentTextState'
|
||||
import { useSharedProductionIds } from '../hooks/useSharedProductionIds'
|
||||
import LogPanel from '../components/ui/LogPanel'
|
||||
import { SegmentedProgressBar } from '../components/ui/SegmentedProgressBar'
|
||||
import ExtractorOperationHistoryModal from '../components/ExtractorOperationHistoryModal'
|
||||
import { useUserStore } from '../stores/useUserStore'
|
||||
|
||||
const ExtractorPage: React.FC = () => {
|
||||
const [orderNumbers, setOrderNumbers] = usePersistentTextState('extractor_orderNumbers')
|
||||
const [showHistoryModal, setShowHistoryModal] = React.useState(false)
|
||||
const user = useUserStore((state) => state.user)
|
||||
|
||||
const {
|
||||
isRunning,
|
||||
@@ -67,6 +71,14 @@ const ExtractorPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
className="bg-slate-100 hover:bg-slate-200 text-slate-700 px-4 py-2.5 rounded-lg flex items-center gap-2 font-medium transition-colors"
|
||||
onClick={() => setShowHistoryModal(true)}
|
||||
disabled={isRunning}
|
||||
>
|
||||
<History size={18} />
|
||||
操作历史
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
onClick={handleExtract}
|
||||
@@ -78,6 +90,14 @@ const ExtractorPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showHistoryModal && (
|
||||
<ExtractorOperationHistoryModal
|
||||
isOpen={showHistoryModal}
|
||||
onClose={() => setShowHistoryModal(false)}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isRunning && isComplete && (
|
||||
<div className="bg-green-50 rounded-xl p-8 flex items-center justify-center gap-4 shadow-md">
|
||||
<CheckCircle className="text-green-600" size={35} />
|
||||
|
||||
@@ -111,7 +111,12 @@ export const IPC_CHANNELS = {
|
||||
PLAYWRIGHT_BROWSER_DOWNLOAD: 'playwright-browser:download',
|
||||
PLAYWRIGHT_BROWSER_CANCEL: 'playwright-browser:cancel',
|
||||
PLAYWRIGHT_BROWSER_PROGRESS: 'playwright-browser:progress',
|
||||
PLAYWRIGHT_BROWSER_CHECK: 'playwright-browser:check'
|
||||
PLAYWRIGHT_BROWSER_CHECK: 'playwright-browser:check',
|
||||
|
||||
// Operation History
|
||||
OPERATION_HISTORY_GET_BATCHES: 'operationHistory:getBatches',
|
||||
OPERATION_HISTORY_GET_BATCH_DETAILS: 'operationHistory:getBatchDetails',
|
||||
OPERATION_HISTORY_DELETE_BATCH: 'operationHistory:deleteBatch'
|
||||
} as const
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user