8 Commits

Author SHA1 Message Date
Misaka_Company
811361a1a3 1.7.2 2026-04-01 13:56:14 +08:00
Misaka_Company
ffbda4c618 docs: add release notes for version 1.7.2 2026-04-01 13:55:50 +08:00
Misaka_Company
348b02600d fix(time): use UTC methods for operation history display
The database stores time in UTC format, and the UI should display UTC
time without timezone conversion. Use getUTCXxx() methods instead of
getHours() to avoid adding 8-hour timezone offset.

Also extract common datetime formatting logic to reduce code duplication.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 13:54:26 +08:00
Misaka_Company
d004f8e9f8 feat(history): add one-click copy for production IDs and order numbers
- Add copy buttons in table headers for "总排号" and "订单号" columns
- Copy all non-empty values as newline-separated text
- Show toast notification with copied data count
- Handle clipboard errors gracefully

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 10:00:31 +08:00
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
10 changed files with 212 additions and 132 deletions

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

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

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

@@ -0,0 +1,6 @@
# 1.7.2
## 问题修复
- 修复操作历史时间显示错误时区转换导致时间快8小时
- 操作历史支持一键复制总排号和订单号。

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "erpauto", "name": "erpauto",
"version": "1.7.0", "version": "1.7.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "erpauto", "name": "erpauto",
"version": "1.7.0", "version": "1.7.2",
"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.7.0", "version": "1.7.2",
"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

@@ -256,7 +256,14 @@ export function registerExtractorHandlers(): void {
: result.errors.length > 0 : result.errors.length > 0
? 'failed' ? 'failed'
: 'success' : 'success'
await historyDao.updateBatchStatus(batchId, status, result.recordCount)
// 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 }) log.info('Operation history batch status updated', { batchId, status })
} }

View File

@@ -21,6 +21,17 @@ import type {
const log = createLogger('ExtractorOperationHistoryDAO') const log = createLogger('ExtractorOperationHistoryDAO')
/**
* Format datetime value from database to ISO string
* mssql driver returns Date objects in UTC format
*/
function formatDateTime(value: unknown): string {
if (value instanceof Date) {
return value.toISOString()
}
return value ? String(value) : new Date().toISOString()
}
/** /**
* Configuration for ExtractorOperationHistory table * Configuration for ExtractorOperationHistory table
*/ */
@@ -160,43 +171,27 @@ export class ExtractorOperationHistoryDAO {
* Update the status of all records in a batch * Update the status of all records in a batch
* @param batchId - Batch identifier * @param batchId - Batch identifier
* @param status - New status (success, failed, partial) * @param status - New status (success, failed, partial)
* @param recordCount - Total record count for the batch
* @returns Update result * @returns Update result
*/ */
async updateBatchStatus( async updateBatchStatus(
batchId: string, batchId: string,
status: string, status: string
recordCount: number | null
): Promise<UpdateBatchStatusResult> { ): Promise<UpdateBatchStatusResult> {
try { try {
const dbService = await this.getDatabaseService() const dbService = await this.getDatabaseService()
const tableName = this.getTableName() const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver' const isSqlServer = dbService.type === 'sqlserver'
const placeholder = isSqlServer ? '@p0' : '?' const sqlString = `
let sqlString: string UPDATE ${tableName}
let params: (string | number | null)[] SET Status = ${isSqlServer ? '@p0' : '?'}
WHERE BatchId = ${isSqlServer ? '@p1' : '?'}
if (recordCount !== null) { `
sqlString = ` const params = [status, batchId]
UPDATE ${tableName}
SET Status = ${isSqlServer ? '@p0' : '?'},
RecordCount = ${isSqlServer ? '@p1' : '?'}
WHERE BatchId = ${isSqlServer ? '@p2' : '?'}
`
params = isSqlServer ? [status, recordCount, batchId] : [status, recordCount, batchId]
} else {
sqlString = `
UPDATE ${tableName}
SET Status = ${placeholder}
WHERE BatchId = ${isSqlServer ? '@p1' : '?'}
`
params = isSqlServer ? [status, batchId] : [status, batchId]
}
await dbService.query(sqlString, params) await dbService.query(sqlString, params)
log.info('Batch status updated', { batchId, status, recordCount }) log.info('Batch status updated', { batchId, status })
return { success: true, updatedCount: 1 } return { success: true, updatedCount: 1 }
} catch (error) { } catch (error) {
log.error('Update batch status error', { log.error('Update batch status error', {
@@ -208,33 +203,51 @@ export class ExtractorOperationHistoryDAO {
} }
/** /**
* Update a single record's status and error message * Update a single record's status, error message, and optional record count
* @param batchId - Batch identifier * @param batchId - Batch identifier
* @param orderNumber - Order number * @param orderNumber - Order number
* @param status - New status * @param status - New status
* @param errorMessage - Optional error message * @param errorMessage - Optional error message
* @param recordCount - Optional per-order record count
* @returns True if successful * @returns True if successful
*/ */
async updateRecordStatus( async updateRecordStatus(
batchId: string, batchId: string,
orderNumber: string, orderNumber: string,
status: string, status: string,
errorMessage?: string errorMessage?: string,
recordCount?: number
): Promise<boolean> { ): Promise<boolean> {
try { try {
const dbService = await this.getDatabaseService() const dbService = await this.getDatabaseService()
const tableName = this.getTableName() const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver' const isSqlServer = dbService.type === 'sqlserver'
const sqlString = ` let sqlString: string
UPDATE ${tableName} let params: (string | number | null)[]
SET Status = ${isSqlServer ? '@p0' : '?'},
ErrorMessage = ${isSqlServer ? '@p1' : '?'}
WHERE BatchId = ${isSqlServer ? '@p2' : '?'}
AND OrderNumber = ${isSqlServer ? '@p3' : '?'}
`
await dbService.query(sqlString, [status, errorMessage || null, batchId, orderNumber]) 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 return true
} catch (error) { } catch (error) {
@@ -288,31 +301,30 @@ export class ExtractorOperationHistoryDAO {
` `
if (options?.limit) { if (options?.limit) {
// Add pagination - track current param count before adding new params const safeLimit = Math.floor(options.limit)
const offsetIndex = params.length const safeOffset = options.offset !== undefined ? Math.floor(options.offset) : undefined
const limitIndex = params.length + 1
if (options.offset !== undefined) {
params.push(options.offset)
}
params.push(options.limit)
if (isSqlServer) { if (isSqlServer) {
if (options.offset !== undefined) { // SQL Server: use parameterized OFFSET/FETCH
sqlString += ` OFFSET @p${offsetIndex} ROWS FETCH NEXT @p${limitIndex} ROWS ONLY` 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 { } else {
// When no offset, use 0 for offset and next index for limit
sqlString += ` OFFSET 0 ROWS FETCH NEXT @p${offsetIndex} ROWS ONLY` sqlString += ` OFFSET 0 ROWS FETCH NEXT @p${offsetIndex} ROWS ONLY`
} }
} else { } else {
if (options.offset !== undefined) { // MySQL: embed validated integer values directly.
sqlString += ` LIMIT ?` // connection.execute() uses binary protocol prepared statements,
// For MySQL with offset, we need to modify the query // which do not reliably support ? placeholders in LIMIT/OFFSET clauses.
// Replace LIMIT with OFFSET LIMIT if (safeOffset !== undefined) {
const parts = sqlString.split(' LIMIT ?') sqlString += ` LIMIT ${safeLimit} OFFSET ${safeOffset}`
sqlString = parts[0] + ` OFFSET ? LIMIT ?` + (parts[1] || '')
} else { } else {
sqlString += ` LIMIT ?` sqlString += ` LIMIT ${safeLimit}`
} }
} }
} }
@@ -323,9 +335,7 @@ export class ExtractorOperationHistoryDAO {
batchId: row.BatchId as string, batchId: row.BatchId as string,
userId: row.UserId as number, userId: row.UserId as number,
username: row.Username as string, username: row.Username as string,
operationTime: row.OperationTime operationTime: formatDateTime(row.OperationTime),
? new Date(row.OperationTime as string).toISOString()
: new Date().toISOString(),
status: row.Status as string, status: row.Status as string,
totalOrders: row.TotalOrders as number, totalOrders: row.TotalOrders as number,
totalRecords: (row.TotalRecords as number) || 0, totalRecords: (row.TotalRecords as number) || 0,
@@ -431,9 +441,7 @@ export class ExtractorOperationHistoryDAO {
batchId: row.BatchId as string, batchId: row.BatchId as string,
userId: row.UserId as number, userId: row.UserId as number,
username: row.Username as string, username: row.Username as string,
operationTime: row.OperationTime operationTime: formatDateTime(row.OperationTime),
? new Date(row.OperationTime as string).toISOString()
: new Date().toISOString(),
status: row.Status as string, status: row.Status as string,
totalOrders: row.TotalOrders as number, totalOrders: row.TotalOrders as number,
totalRecords: (row.TotalRecords as number) || 0, totalRecords: (row.TotalRecords as number) || 0,

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

@@ -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

@@ -5,7 +5,7 @@
* Admin users see all users' records, regular users see only their own. * Admin users see all users' records, regular users see only their own.
*/ */
import React, { useState, useEffect } from 'react' import React, { useState, useEffect, useCallback } from 'react'
import { Modal } from './ui/Modal' import { Modal } from './ui/Modal'
import { import {
RefreshCw, RefreshCw,
@@ -14,35 +14,15 @@ import {
ChevronRight, ChevronRight,
CheckCircle, CheckCircle,
XCircle, XCircle,
Clock Clock,
Copy
} from 'lucide-react' } from 'lucide-react'
import type { UserInfo } from './UserSelectionDialog' import type { UserInfo } from './UserSelectionDialog'
import type {
// Local type definitions matching the backend types BatchStats,
interface BatchStats { OperationHistoryRecord
batchId: string } from '../../../main/types/operation-history.types'
userId: number import { showSuccess, showError, showWarning } from '../stores/useAppStore'
username: string
operationTime: string
status: string
totalOrders: number
totalRecords: number
successCount: number
failedCount: number
}
interface OperationHistoryRecord {
id?: number
batchId: string
userId: number
username: string
productionId: string | null
orderNumber: string
operationTime: Date
status: string
recordCount: number | null
errorMessage: string | null
}
interface ExtractorOperationHistoryModalProps { interface ExtractorOperationHistoryModalProps {
isOpen: boolean isOpen: boolean
@@ -71,6 +51,24 @@ const statusIcons: Record<string, React.ReactNode> = {
pending: <Clock size={16} className="text-gray-500" /> pending: <Clock size={16} className="text-gray-500" />
} }
const formatDateTime = (dateStr: string) => {
const date = new Date(dateStr)
// Check if the date is valid
if (isNaN(date.getTime())) {
return dateStr // Return original if invalid
}
// Use UTC methods to display the time as stored in database (without timezone conversion)
const year = date.getUTCFullYear()
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
const day = String(date.getUTCDate()).padStart(2, '0')
const hours = String(date.getUTCHours()).padStart(2, '0')
const minutes = String(date.getUTCMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}`
}
export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryModalProps> = ({ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryModalProps> = ({
isOpen, isOpen,
onClose, onClose,
@@ -85,14 +83,7 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
const isAdmin = user?.userType === 'Admin' const isAdmin = user?.userType === 'Admin'
// Fetch batches when modal opens const fetchBatches = useCallback(async () => {
useEffect(() => {
if (isOpen) {
void fetchBatches()
}
}, [isOpen])
const fetchBatches = async () => {
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
@@ -107,23 +98,33 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }, [])
const fetchBatchDetails = async (batchId: string) => { const fetchBatchDetails = useCallback(
// If already loaded, don't fetch again async (batchId: string) => {
if (batchDetails.has(batchId)) { // If already loaded, don't fetch again
return 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) 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) => { const toggleBatchExpansion = (batchId: string) => {
setExpandedBatches((prev) => { setExpandedBatches((prev) => {
@@ -175,15 +176,24 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
} }
} }
const formatDateTime = (dateStr: string) => { const handleCopyColumn = async (field: 'productionId' | 'orderNumber', batchId: string) => {
const date = new Date(dateStr) const details = batchDetails.get(batchId) || []
return date.toLocaleString('zh-CN', { const values = details
year: 'numeric', .map((d) => (field === 'productionId' ? d.productionId : d.orderNumber))
month: '2-digit', .filter(Boolean) // 移除空值
day: '2-digit', .join('\n') // 使用换行符分隔
hour: '2-digit',
minute: '2-digit' if (!values) {
}) showWarning('没有可复制的数据')
return
}
try {
await navigator.clipboard.writeText(values)
showSuccess(`已复制 ${values.split('\n').length} 条数据`)
} catch {
showError('复制失败,请手动复制')
}
} }
if (!isOpen) return null if (!isOpen) return null
@@ -320,10 +330,38 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> <tr>
<th className="px-4 py-2 text-left font-medium text-gray-600"> <th className="px-4 py-2 text-left font-medium text-gray-600">
<div className="flex items-center gap-2">
<button
className="p-1 hover:bg-gray-200 rounded transition-colors"
onClick={() =>
void handleCopyColumn('productionId', batch.batchId)
}
title="复制所有总排号"
>
<Copy
size={14}
className="text-gray-500 hover:text-gray-700"
/>
</button>
</div>
</th> </th>
<th className="px-4 py-2 text-left font-medium text-gray-600"> <th className="px-4 py-2 text-left font-medium text-gray-600">
<div className="flex items-center gap-2">
<button
className="p-1 hover:bg-gray-200 rounded transition-colors"
onClick={() =>
void handleCopyColumn('orderNumber', batch.batchId)
}
title="复制所有订单号"
>
<Copy
size={14}
className="text-gray-500 hover:text-gray-700"
/>
</button>
</div>
</th> </th>
<th className="px-4 py-2 text-left font-medium text-gray-600"> <th className="px-4 py-2 text-left font-medium text-gray-600">

View File

@@ -90,13 +90,13 @@ const ExtractorPage: React.FC = () => {
</div> </div>
</div> </div>
{showHistoryModal && ( {showHistoryModal ? (
<ExtractorOperationHistoryModal <ExtractorOperationHistoryModal
isOpen={showHistoryModal} isOpen={showHistoryModal}
onClose={() => setShowHistoryModal(false)} onClose={() => setShowHistoryModal(false)}
user={user} 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">