10 Commits

Author SHA1 Message Date
Misaka_Company
3cbe9eef12 1.7.1 2026-04-01 08:36:50 +08:00
Misaka_Company
5b310d944b docs: add release notes for version 1.7.1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 08:36:09 +08:00
Misaka
6e04f21b10 fix(extractor): track per-order RecordCount in operation history
Previously updateBatchStatus wrote the batch-level total recordCount to
every row, causing the detail view to show misleading identical counts.
Now mergeFiles collects per-order material counts, the handler writes
each order's count individually via updateRecordStatus, and batch
aggregation uses SUM instead of MAX for accurate totals.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 20:42:22 +08:00
Misaka
17fbd7d251 fix(extractor): resolve MySQL LIMIT placeholder error in operation history query
MySQL binary protocol prepared statements (connection.execute()) do not
support ? placeholders in LIMIT/OFFSET clauses, causing "Incorrect
arguments to mysqld_stmt_execute". Embed validated integer values directly
for MySQL while keeping parameterized queries for SQL Server.

Also apply React best practices to ExtractorOperationHistoryModal:
- Hoist formatDateTime to module level
- Wrap async handlers with useCallback for stable effect dependencies
- Import shared types instead of duplicating definitions
- Use ternary for conditional rendering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 19:42:44 +08:00
Misaka_Company
c6eb60ada7 1.7.0 2026-03-31 15:28:26 +08:00
Misaka_Company
571ec2325f docs: add release notes for version 1.7.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 15:28:08 +08:00
Misaka_Company
557ed174c3 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>
2026-03-31 15:22:53 +08:00
Misaka_Company
dd2cf1c576 1.6.2 2026-03-26 12:51:28 +08:00
Misaka_Company
b7e9e5e472 docs: add release notes for version 1.6.2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 12:51:15 +08:00
Misaka_Company
491f2afe3f refactor(auth): remove Guest role from user type system
Remove all references to 'Guest' user type from the codebase, simplifying the role system to only support 'Admin' and 'User' roles.

Changes:
- Update type definitions to exclude 'Guest' from UserType
- Remove isGuest() method from SessionManager
- Remove Guest-specific logic from update services
- Update all type assertions from 'Admin | User | Guest' to 'Admin | User'
- Remove Guest UI styling from UserSelectionDialog
- Replace Guest fallback with ValidationError in settings handler

Error handling:
- Zod schema now rejects 'Guest' as invalid user type
- TypeScript will fail compilation if 'Guest' is referenced
- Runtime errors occur if database contains Guest users

No database migration needed (confirmed: no Guest users exist)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 12:50:18 +08:00
31 changed files with 1410 additions and 42 deletions

6
docs/releases/1.6.2.md Normal file
View File

@@ -0,0 +1,6 @@
# 1.6.2
## 系统优化
- 简化用户角色体系,移除未使用的 Guest 角色。
- 优化类型安全性,加强用户认证流程健壮性。

18
docs/releases/1.7.0.md Normal file
View File

@@ -0,0 +1,18 @@
# 1.7.0
## 核心功能
- 新增提取操作历史记录功能,每次执行提取后自动保存订单号和总排号。
- 支持查看历史批次详情,包含操作时间、订单数、记录数、成功/失败统计。
- 批次记录可展开查看,显示总排号与订单号的对应关系。
## 界面与交互
- 提取页面新增"操作历史"按钮,点击打开历史记录对话框。
- 管理员可查看所有用户的历史记录,普通用户仅查看自己的记录。
- 支持删除历史批次,管理员可删除任意批次,普通用户仅可删除自己的记录。
## 数据存储
- 新增数据库表 `ExtractorOperationHistory`,支持 SQL Server 和 MySQL。
- 需执行数据库脚本创建表结构(详见项目文档)。

6
docs/releases/1.7.1.md Normal file
View File

@@ -0,0 +1,6 @@
# 1.7.1
## 问题修复
- 修复 MySQL 数据库下操作历史查询报错问题。
- 优化历史记录数据结构,支持按订单统计记录数量。

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "erpauto", "name": "erpauto",
"version": "1.6.1", "version": "1.7.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "erpauto", "name": "erpauto",
"version": "1.6.1", "version": "1.7.1",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.929.0", "@aws-sdk/client-s3": "^3.929.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "erpauto", "name": "erpauto",
"version": "1.6.1", "version": "1.7.1",
"description": "An Electron application with React and TypeScript", "description": "An Electron application with React and TypeScript",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "example.com", "author": "example.com",

View File

@@ -3,6 +3,7 @@ import { ErpAuthService } from '../services/erp/erp-auth'
import { ExtractorService } from '../services/erp/extractor' import { ExtractorService } from '../services/erp/extractor'
import { OrderNumberResolver } from '../services/erp/order-resolver' import { OrderNumberResolver } from '../services/erp/order-resolver'
import { create, type IDatabaseService } from '../services/database' import { create, type IDatabaseService } from '../services/database'
import { ExtractorOperationHistoryDAO } from '../services/database/extractor-operation-history-dao'
import { createLogger } from '../services/logger' import { createLogger } from '../services/logger'
import { logAudit } from '../services/logger/audit-logger' import { logAudit } from '../services/logger/audit-logger'
import { SessionManager } from '../services/user/session-manager' 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 { UserErpConfigService } from '../services/user/user-erp-config-service'
import { ConfigManager } from '../services/config/config-manager' import { ConfigManager } from '../services/config/config-manager'
import { IPC_CHANNELS } from '../../shared/ipc-channels' import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { randomUUID } from 'crypto'
const log = createLogger('ExtractorHandler') const log = createLogger('ExtractorHandler')
@@ -141,6 +143,29 @@ export function registerExtractorHandlers(): void {
log.info('Resolved order numbers', { count: validOrderNumbers.length }) 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 // Log deduplication summary
sendLog(sender, 'info', dedupReport.summary) sendLog(sender, 'info', dedupReport.summary)
@@ -223,11 +248,29 @@ 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'
// Write per-order record counts
for (const { orderNumber, recordCount } of result.orderRecordCounts) {
await historyDao.updateRecordStatus(batchId, orderNumber, status, undefined, recordCount)
}
// Update batch status without recordCount (per-order counts are set individually)
await historyDao.updateBatchStatus(batchId, status)
log.info('Operation history batch status updated', { batchId, status })
}
// Audit log: EXTRACT (non-blocking) // Audit log: EXTRACT (non-blocking)
const os = await import('os') const os = await import('os')
const currentUser = SessionManager.getInstance().getUserInfo()
if (currentUser) { if (currentUser) {
const status: 'success' | 'failure' | 'partial' = const auditStatus: 'success' | 'failure' | 'partial' =
result.errors.length > 0 && result.recordCount > 0 result.errors.length > 0 && result.recordCount > 0
? 'partial' ? 'partial'
: result.errors.length > 0 : result.errors.length > 0
@@ -237,7 +280,7 @@ export function registerExtractorHandlers(): void {
username: currentUser.username, username: currentUser.username,
computerName: os.hostname(), computerName: os.hostname(),
resource: 'MATERIAL_PLAN', resource: 'MATERIAL_PLAN',
status, status: auditStatus,
metadata: { metadata: {
orderCount: validOrderNumbers.length, orderCount: validOrderNumbers.length,
recordCount: result.recordCount, recordCount: result.recordCount,

View File

@@ -17,6 +17,7 @@ import { registerLoggerHandlers } from './logger-handler'
import { registerReportHandlers } from './report-handler' import { registerReportHandlers } from './report-handler'
import { registerUpdateHandlers } from './update-handler' import { registerUpdateHandlers } from './update-handler'
import { registerPlaywrightBrowserHandlers } from './playwright-browser' import { registerPlaywrightBrowserHandlers } from './playwright-browser'
import { registerOperationHistoryHandlers } from './operation-history-handler'
import { createLogger, logError } from '../services/logger' import { createLogger, logError } from '../services/logger'
import { serializeError, sanitizeError } from '../services/logger/error-utils' import { serializeError, sanitizeError } from '../services/logger/error-utils'
import { getErrorMessage, getErrorCode, isBaseError } from '../types/errors' import { getErrorMessage, getErrorCode, isBaseError } from '../types/errors'
@@ -107,5 +108,6 @@ export function registerIpcHandlers(): void {
registerReportHandlers() registerReportHandlers()
registerUpdateHandlers() registerUpdateHandlers()
registerPlaywrightBrowserHandlers() registerPlaywrightBrowserHandlers()
registerOperationHistoryHandlers()
log.info('All IPC handlers registered') log.info('All IPC handlers registered')
} }

View 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')
}

View File

@@ -28,7 +28,13 @@ export function registerSettingsHandlers(): void {
ipcMain.handle(IPC_CHANNELS.SETTINGS_GET_USER_TYPE, async (): Promise<IpcResult<UserType>> => { ipcMain.handle(IPC_CHANNELS.SETTINGS_GET_USER_TYPE, async (): Promise<IpcResult<UserType>> => {
return withErrorHandling( return withErrorHandling(
async () => (sessionManager.getUserType() as UserType) || 'Guest', async () => {
const userType = sessionManager.getUserType()
if (!userType) {
throw new ValidationError('未找到用户类型', 'VAL_INVALID_INPUT')
}
return userType as UserType
},
'settings:getUserType' 'settings:getUserType'
) )
}) })

View File

@@ -20,7 +20,7 @@ export type LoginRequestZod = z.infer<typeof LoginRequestSchema>
export const UserInfoSchema = z.object({ export const UserInfoSchema = z.object({
id: z.number().int().positive(), id: z.number().int().positive(),
username: z.string().min(1), username: z.string().min(1),
userType: z.enum(['Admin', 'User', 'Guest']), userType: z.enum(['Admin', 'User']),
computerName: z.string().optional() computerName: z.string().optional()
}) })

View File

@@ -0,0 +1,608 @@
/**
* 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)
* @returns Update result
*/
async updateBatchStatus(
batchId: string,
status: string
): Promise<UpdateBatchStatusResult> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
const sqlString = `
UPDATE ${tableName}
SET Status = ${isSqlServer ? '@p0' : '?'}
WHERE BatchId = ${isSqlServer ? '@p1' : '?'}
`
const params = [status, batchId]
await dbService.query(sqlString, params)
log.info('Batch status updated', { batchId, status })
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, error message, and optional record count
* @param batchId - Batch identifier
* @param orderNumber - Order number
* @param status - New status
* @param errorMessage - Optional error message
* @param recordCount - Optional per-order record count
* @returns True if successful
*/
async updateRecordStatus(
batchId: string,
orderNumber: string,
status: string,
errorMessage?: string,
recordCount?: number
): Promise<boolean> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
let sqlString: string
let params: (string | number | null)[]
if (recordCount !== undefined) {
sqlString = `
UPDATE ${tableName}
SET Status = ${isSqlServer ? '@p0' : '?'},
ErrorMessage = ${isSqlServer ? '@p1' : '?'},
RecordCount = ${isSqlServer ? '@p2' : '?'}
WHERE BatchId = ${isSqlServer ? '@p3' : '?'}
AND OrderNumber = ${isSqlServer ? '@p4' : '?'}
`
params = [status, errorMessage || null, recordCount, batchId, orderNumber]
} else {
sqlString = `
UPDATE ${tableName}
SET Status = ${isSqlServer ? '@p0' : '?'},
ErrorMessage = ${isSqlServer ? '@p1' : '?'}
WHERE BatchId = ${isSqlServer ? '@p2' : '?'}
AND OrderNumber = ${isSqlServer ? '@p3' : '?'}
`
params = [status, errorMessage || null, batchId, orderNumber]
}
await dbService.query(sqlString, params)
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) {
const safeLimit = Math.floor(options.limit)
const safeOffset = options.offset !== undefined ? Math.floor(options.offset) : undefined
if (isSqlServer) {
// SQL Server: use parameterized OFFSET/FETCH
const offsetIndex = params.length
if (safeOffset !== undefined) {
params.push(safeOffset)
}
params.push(safeLimit)
if (safeOffset !== undefined) {
sqlString += ` OFFSET @p${offsetIndex} ROWS FETCH NEXT @p${offsetIndex + 1} ROWS ONLY`
} else {
sqlString += ` OFFSET 0 ROWS FETCH NEXT @p${offsetIndex} ROWS ONLY`
}
} else {
// MySQL: embed validated integer values directly.
// connection.execute() uses binary protocol prepared statements,
// which do not reliably support ? placeholders in LIMIT/OFFSET clauses.
if (safeOffset !== undefined) {
sqlString += ` LIMIT ${safeLimit} OFFSET ${safeOffset}`
} else {
sqlString += ` LIMIT ${safeLimit}`
}
}
}
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
}
}
}

View File

@@ -46,7 +46,8 @@ export class ExtractorService {
downloadedFiles: [], downloadedFiles: [],
mergedFile: null, mergedFile: null,
recordCount: 0, recordCount: 0,
errors: [] errors: [],
orderRecordCounts: []
} }
try { try {
@@ -79,6 +80,7 @@ export class ExtractorService {
const mergeResult = await this.mergeFiles(result.downloadedFiles) const mergeResult = await this.mergeFiles(result.downloadedFiles)
result.mergedFile = mergeResult.mergedFile result.mergedFile = mergeResult.mergedFile
result.recordCount = mergeResult.recordCount result.recordCount = mergeResult.recordCount
result.orderRecordCounts = mergeResult.orderRecordCounts
// Add merge error to result if any // Add merge error to result if any
if (mergeResult.error) { if (mergeResult.error) {
@@ -123,9 +125,14 @@ export class ExtractorService {
*/ */
private async mergeFiles( private async mergeFiles(
filePaths: string[] filePaths: string[]
): Promise<{ mergedFile: string | null; recordCount: number; error?: string }> { ): Promise<{
mergedFile: string | null
recordCount: number
error?: string
orderRecordCounts: Array<{ orderNumber: string; recordCount: number }>
}> {
if (filePaths.length === 0) { if (filePaths.length === 0) {
return { mergedFile: null, recordCount: 0 } return { mergedFile: null, recordCount: 0, orderRecordCounts: [] }
} }
log.info('Starting merge', { fileCount: filePaths.length }) log.info('Starting merge', { fileCount: filePaths.length })
@@ -154,15 +161,21 @@ export class ExtractorService {
// Calculate total record count (total material rows) // Calculate total record count (total material rows)
let recordCount = 0 let recordCount = 0
const orderRecordCounts: Array<{ orderNumber: string; recordCount: number }> = []
for (const order of allOrders) { for (const order of allOrders) {
recordCount += order.materials.length const count = order.materials.length
recordCount += count
orderRecordCounts.push({
orderNumber: order.orderInfo.productionOrder || '',
recordCount: count
})
} }
log.info('Merge summary', { orderCount: allOrders.length, recordCount }) log.info('Merge summary', { orderCount: allOrders.length, recordCount })
if (recordCount === 0) { if (recordCount === 0) {
log.warn('No records found in any downloaded files') log.warn('No records found in any downloaded files')
return { mergedFile: null, recordCount: 0 } return { mergedFile: null, recordCount: 0, orderRecordCounts }
} }
// Generate output filename with timestamp // Generate output filename with timestamp
@@ -178,13 +191,13 @@ export class ExtractorService {
log.info('Saving merged file', { outputPath }) log.info('Saving merged file', { outputPath })
await this.saveMergedOrders(allOrders, outputPath) await this.saveMergedOrders(allOrders, outputPath)
log.info('Merged file saved successfully', { recordCount }) log.info('Merged file saved successfully', { recordCount })
return { mergedFile: outputPath, recordCount } return { mergedFile: outputPath, recordCount, orderRecordCounts }
} catch (error) { } catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error) const errorMsg = error instanceof Error ? error.message : String(error)
const errorStack = error instanceof Error ? error.stack : '' const errorStack = error instanceof Error ? error.stack : ''
log.error('Failed to save merged file', { error: errorMsg, stack: errorStack }) log.error('Failed to save merged file', { error: errorMsg, stack: errorStack })
// Return parsed record count and error info even if save fails // Return parsed record count and error info even if save fails
return { mergedFile: null, recordCount, error: `保存合并文件失败:${errorMsg}` } return { mergedFile: null, recordCount, orderRecordCounts, error: `保存合并文件失败:${errorMsg}` }
} }
} }

View File

@@ -29,7 +29,7 @@ export class UpdateCatalogService {
public getDialogCatalog(status: UpdateStatus, catalog: UpdateCatalog): UpdateDialogCatalog { public getDialogCatalog(status: UpdateStatus, catalog: UpdateCatalog): UpdateDialogCatalog {
const currentUserType = status.currentUserType const currentUserType = status.currentUserType
if (!status.enabled || !currentUserType || currentUserType === 'Guest') { if (!status.enabled || !currentUserType) {
return { mode: 'disabled' } return { mode: 'disabled' }
} }

View File

@@ -106,7 +106,7 @@ export class UpdateService {
this.ensureInitialized() this.ensureInitialized()
this.status.currentUserType = userType this.status.currentUserType = userType
if (!this.status.enabled || !userType || userType === 'Guest') { if (!this.status.enabled || !userType) {
this.clearPolling() this.clearPolling()
this.catalog = { stable: [], preview: [] } this.catalog = { stable: [], preview: [] }
this.publishStatus({ this.publishStatus({

View File

@@ -139,7 +139,7 @@ export class BIPUsersDAO {
return { return {
id: row.ID as number, id: row.ID as number,
username: row.UserName as string, username: row.UserName as string,
userType: row.UserType as 'Admin' | 'User' | 'Guest' userType: row.UserType as 'Admin' | 'User'
} }
} }
return null return null
@@ -157,7 +157,7 @@ export class BIPUsersDAO {
return { return {
id: row.ID as number, id: row.ID as number,
username: row.UserName as string, username: row.UserName as string,
userType: row.UserType as 'Admin' | 'User' | 'Guest' userType: row.UserType as 'Admin' | 'User'
} }
} }
return null return null
@@ -198,7 +198,7 @@ export class BIPUsersDAO {
return { return {
id: row.ID as number, id: row.ID as number,
username: row.UserName as string, username: row.UserName as string,
userType: row.UserType as 'Admin' | 'User' | 'Guest' userType: row.UserType as 'Admin' | 'User'
} }
} }
return null return null
@@ -216,7 +216,7 @@ export class BIPUsersDAO {
return { return {
id: row.ID as number, id: row.ID as number,
username: row.UserName as string, username: row.UserName as string,
userType: row.UserType as 'Admin' | 'User' | 'Guest' userType: row.UserType as 'Admin' | 'User'
} }
} }
return null return null
@@ -254,7 +254,7 @@ export class BIPUsersDAO {
return result.rows.map((row) => ({ return result.rows.map((row) => ({
id: row.ID as number, id: row.ID as number,
username: row.UserName as string, username: row.UserName as string,
userType: row.UserType as 'Admin' | 'User' | 'Guest', userType: row.UserType as 'Admin' | 'User',
createTime: row.CreateTime as Date | undefined createTime: row.CreateTime as Date | undefined
})) }))
} catch (error) { } catch (error) {
@@ -270,7 +270,7 @@ export class BIPUsersDAO {
* Create a new user * Create a new user
* @param username - The username (must be unique) * @param username - The username (must be unique)
* @param password - The password * @param password - The password
* @param userType - User type ('Admin', 'User', or 'Guest') * @param userType - User type ('Admin' or 'User')
* @param computerName - Optional computer name for silent login * @param computerName - Optional computer name for silent login
* @returns True if successful * @returns True if successful
*/ */

View File

@@ -126,13 +126,6 @@ export class SessionManager {
return this.currentUser?.userType === 'Admin' return this.currentUser?.userType === 'Admin'
} }
/**
* Check if the current user is a guest
*/
public isGuest(): boolean {
return this.currentUser?.userType === 'Guest'
}
/** /**
* Get the current username * Get the current username
*/ */

View File

@@ -43,6 +43,8 @@ export interface ExtractorResult {
errors: string[] errors: string[]
/** Database import result (only populated if mergedFile was created) */ /** Database import result (only populated if mergedFile was created) */
importResult?: ImportResult importResult?: ImportResult
/** Per-order material row counts */
orderRecordCounts: Array<{ orderNumber: string; recordCount: number }>
} }
export interface OrderInfo { export interface OrderInfo {

View 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
}

View File

@@ -10,7 +10,7 @@
/** /**
* User type for settings permission control * User type for settings permission control
*/ */
export type UserType = 'Admin' | 'User' | 'Guest' export type UserType = 'Admin' | 'User'
/** /**
* Database type selection * Database type selection

View File

@@ -5,7 +5,7 @@
/** /**
* User type enumeration * User type enumeration
*/ */
export type UserType = 'Admin' | 'User' | 'Guest' export type UserType = 'Admin' | 'User'
/** /**
* User information interface * User information interface

View File

@@ -17,6 +17,7 @@ import {
} from './materials' } from './materials'
import { loggerApi } from './logger' import { loggerApi } from './logger'
import { playwrightBrowserApi } from './browser-download' import { playwrightBrowserApi } from './browser-download'
import { operationHistoryApi } from './operation-history'
export const api = { export const api = {
process: processApi, process: processApi,
@@ -35,7 +36,8 @@ export const api = {
logger: loggerApi, logger: loggerApi,
report: reportApi, report: reportApi,
update: updateApi, update: updateApi,
playwrightBrowser: playwrightBrowserApi playwrightBrowser: playwrightBrowserApi,
operationHistory: operationHistoryApi
} as const } as const
export type ElectronApi = typeof api export type ElectronApi = typeof api

View 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

View File

@@ -157,6 +157,37 @@ export interface PlaywrightBrowserAPI {
onProgress: (callback: (data: DownloadProgress) => void) => () => void 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 { export interface ProcessAPI {
versions: { versions: {
electron: string electron: string
@@ -185,6 +216,7 @@ declare global {
report: ReportAPI report: ReportAPI
update: UpdateAPI update: UpdateAPI
playwrightBrowser: PlaywrightBrowserAPI playwrightBrowser: PlaywrightBrowserAPI
operationHistory: OperationHistoryAPI
} }
api: unknown api: unknown
} }

View File

@@ -0,0 +1,373 @@
/**
* 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, useCallback } from 'react'
import { Modal } from './ui/Modal'
import {
RefreshCw,
Trash2,
ChevronDown,
ChevronRight,
CheckCircle,
XCircle,
Clock
} from 'lucide-react'
import type { UserInfo } from './UserSelectionDialog'
import type {
BatchStats,
OperationHistoryRecord
} from '../../../main/types/operation-history.types'
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" />
}
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'
})
}
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'
const fetchBatches = useCallback(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 = useCallback(
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)
}
},
[batchDetails]
)
// Fetch batches when modal opens
useEffect(() => {
if (isOpen) {
void fetchBatches()
}
}, [isOpen, fetchBatches])
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
})
}
}
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

View File

@@ -13,7 +13,7 @@ import { Modal } from './ui/Modal'
export interface UserInfo { export interface UserInfo {
id: number id: number
username: string username: string
userType: 'Admin' | 'User' | 'Guest' userType: 'Admin' | 'User'
createTime?: Date createTime?: Date
} }
@@ -61,8 +61,7 @@ export const UserSelectionDialog: React.FC<UserSelectionDialogProps> = ({
const userTypeStyles: Record<string, string> = { const userTypeStyles: Record<string, string> = {
Admin: 'bg-amber-50 text-amber-600', Admin: 'bg-amber-50 text-amber-600',
User: 'bg-blue-50 text-blue-600', User: 'bg-blue-50 text-blue-600'
Guest: 'bg-gray-100 text-gray-600'
} }
if (!isOpen) return null if (!isOpen) return null

View File

@@ -4,7 +4,7 @@ import UserSelectionDialog, { type UserInfo as SelectedUserInfo } from '../UserS
interface CurrentUser { interface CurrentUser {
username: string username: string
userType: 'Admin' | 'User' | 'Guest' userType: 'Admin' | 'User'
} }
interface UnauthenticatedAppProps { interface UnauthenticatedAppProps {

View File

@@ -17,7 +17,7 @@ export interface CurrentUser {
export interface SelectedUserInfo { export interface SelectedUserInfo {
id: number id: number
username: string username: string
userType: 'Admin' | 'User' | 'Guest' userType: 'Admin' | 'User'
computerName?: string computerName?: string
} }

View File

@@ -3,7 +3,7 @@ import { useState, useCallback } from 'react'
interface UserInfo { interface UserInfo {
id: number id: number
username: string username: string
userType: 'Admin' | 'User' | 'Guest' userType: 'Admin' | 'User'
computerName?: string computerName?: string
} }

View File

@@ -1,14 +1,18 @@
import React from 'react' 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 OrderNumberInput from '../components/OrderNumberInput'
import { useExtractor } from '../hooks/useExtractor' import { useExtractor } from '../hooks/useExtractor'
import { usePersistentTextState } from '../hooks/usePersistentTextState' import { usePersistentTextState } from '../hooks/usePersistentTextState'
import { useSharedProductionIds } from '../hooks/useSharedProductionIds' import { useSharedProductionIds } from '../hooks/useSharedProductionIds'
import LogPanel from '../components/ui/LogPanel' import LogPanel from '../components/ui/LogPanel'
import { SegmentedProgressBar } from '../components/ui/SegmentedProgressBar' import { SegmentedProgressBar } from '../components/ui/SegmentedProgressBar'
import ExtractorOperationHistoryModal from '../components/ExtractorOperationHistoryModal'
import { useUserStore } from '../stores/useUserStore'
const ExtractorPage: React.FC = () => { const ExtractorPage: React.FC = () => {
const [orderNumbers, setOrderNumbers] = usePersistentTextState('extractor_orderNumbers') const [orderNumbers, setOrderNumbers] = usePersistentTextState('extractor_orderNumbers')
const [showHistoryModal, setShowHistoryModal] = React.useState(false)
const user = useUserStore((state) => state.user)
const { const {
isRunning, isRunning,
@@ -67,6 +71,14 @@ const ExtractorPage: React.FC = () => {
</div> </div>
<div className="flex items-center gap-4"> <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 <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" 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} onClick={handleExtract}
@@ -78,6 +90,14 @@ const ExtractorPage: React.FC = () => {
</div> </div>
</div> </div>
{showHistoryModal ? (
<ExtractorOperationHistoryModal
isOpen={showHistoryModal}
onClose={() => setShowHistoryModal(false)}
user={user}
/>
) : null}
{!isRunning && isComplete && ( {!isRunning && isComplete && (
<div className="bg-green-50 rounded-xl p-8 flex items-center justify-center gap-4 shadow-md"> <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} /> <CheckCircle className="text-green-600" size={35} />

View File

@@ -10,7 +10,7 @@ import { create } from 'zustand'
interface UserInfo { interface UserInfo {
id: number id: number
username: string username: string
userType: 'Admin' | 'User' | 'Guest' userType: 'Admin' | 'User'
computerName?: string computerName?: string
} }

View File

@@ -111,7 +111,12 @@ export const IPC_CHANNELS = {
PLAYWRIGHT_BROWSER_DOWNLOAD: 'playwright-browser:download', PLAYWRIGHT_BROWSER_DOWNLOAD: 'playwright-browser:download',
PLAYWRIGHT_BROWSER_CANCEL: 'playwright-browser:cancel', PLAYWRIGHT_BROWSER_CANCEL: 'playwright-browser:cancel',
PLAYWRIGHT_BROWSER_PROGRESS: 'playwright-browser:progress', 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 } as const
/** /**