Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cbe9eef12 | ||
|
|
5b310d944b | ||
|
|
6e04f21b10 | ||
|
|
17fbd7d251 | ||
|
|
c6eb60ada7 | ||
|
|
571ec2325f | ||
|
|
557ed174c3 | ||
|
|
dd2cf1c576 | ||
|
|
b7e9e5e472 | ||
|
|
491f2afe3f |
6
docs/releases/1.6.2.md
Normal file
6
docs/releases/1.6.2.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# 1.6.2
|
||||||
|
|
||||||
|
## 系统优化
|
||||||
|
|
||||||
|
- 简化用户角色体系,移除未使用的 Guest 角色。
|
||||||
|
- 优化类型安全性,加强用户认证流程健壮性。
|
||||||
18
docs/releases/1.7.0.md
Normal file
18
docs/releases/1.7.0.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# 1.7.0
|
||||||
|
|
||||||
|
## 核心功能
|
||||||
|
|
||||||
|
- 新增提取操作历史记录功能,每次执行提取后自动保存订单号和总排号。
|
||||||
|
- 支持查看历史批次详情,包含操作时间、订单数、记录数、成功/失败统计。
|
||||||
|
- 批次记录可展开查看,显示总排号与订单号的对应关系。
|
||||||
|
|
||||||
|
## 界面与交互
|
||||||
|
|
||||||
|
- 提取页面新增"操作历史"按钮,点击打开历史记录对话框。
|
||||||
|
- 管理员可查看所有用户的历史记录,普通用户仅查看自己的记录。
|
||||||
|
- 支持删除历史批次,管理员可删除任意批次,普通用户仅可删除自己的记录。
|
||||||
|
|
||||||
|
## 数据存储
|
||||||
|
|
||||||
|
- 新增数据库表 `ExtractorOperationHistory`,支持 SQL Server 和 MySQL。
|
||||||
|
- 需执行数据库脚本创建表结构(详见项目文档)。
|
||||||
6
docs/releases/1.7.1.md
Normal file
6
docs/releases/1.7.1.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# 1.7.1
|
||||||
|
|
||||||
|
## 问题修复
|
||||||
|
|
||||||
|
- 修复 MySQL 数据库下操作历史查询报错问题。
|
||||||
|
- 优化历史记录数据结构,支持按订单统计记录数量。
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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')
|
||||||
}
|
}
|
||||||
|
|||||||
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')
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
608
src/main/services/database/extractor-operation-history-dao.ts
Normal file
608
src/main/services/database/extractor-operation-history-dao.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}` }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
373
src/renderer/src/components/ExtractorOperationHistoryModal.tsx
Normal file
373
src/renderer/src/components/ExtractorOperationHistoryModal.tsx
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user