11 Commits

Author SHA1 Message Date
Misaka_Company
63a292c5f9 1.8.0 2026-04-01 15:18:31 +08:00
Misaka_Company
51f8e0a6e7 docs: add release notes for version 1.8.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 15:18:02 +08:00
Misaka_Company
c8ab58d390 feat(history): add Admin-only delete button and multi-user filter with chips
- Add Admin-only delete button in operation history modal
- Replace dropdown with multi-select chip filters for Admin users
- Support filtering by multiple usernames using IN clause
- Fix user state propagation by passing currentUser via props
- Change GetBatchesOptions.username to usernames (array)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 15:17:20 +08:00
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
13 changed files with 353 additions and 163 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小时
- 操作历史支持一键复制总排号和订单号。

12
docs/releases/1.8.0.md Normal file
View File

@@ -0,0 +1,12 @@
# 1.8.0
## 权限控制
- 操作历史删除按钮仅对管理员可见,普通用户无法删除历史记录。
- 修复用户状态传递问题,确保权限判断正确生效。
## 界面与交互
- 管理员可使用多选标签Chip按用户筛选操作历史。
- 支持同时选择多个用户查看记录,点击标签即可切换选中状态。
- 添加"清空筛选"按钮,一键恢复显示所有用户记录。

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "erpauto", "name": "erpauto",
"version": "1.7.0", "version": "1.8.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "erpauto", "name": "erpauto",
"version": "1.7.0", "version": "1.8.0",
"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.8.0",
"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,24 @@ 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, status: string): Promise<UpdateBatchStatusResult> {
batchId: string,
status: string,
recordCount: number | null
): 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
let params: (string | number | null)[]
if (recordCount !== null) {
sqlString = `
UPDATE ${tableName} UPDATE ${tableName}
SET Status = ${isSqlServer ? '@p0' : '?'}, 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' : '?'} WHERE BatchId = ${isSqlServer ? '@p1' : '?'}
` `
params = isSqlServer ? [status, batchId] : [status, batchId] const params = [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 +200,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
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} UPDATE ${tableName}
SET Status = ${isSqlServer ? '@p0' : '?'}, SET Status = ${isSqlServer ? '@p0' : '?'},
ErrorMessage = ${isSqlServer ? '@p1' : '?'} ErrorMessage = ${isSqlServer ? '@p1' : '?'}
WHERE BatchId = ${isSqlServer ? '@p2' : '?'} WHERE BatchId = ${isSqlServer ? '@p2' : '?'}
AND OrderNumber = ${isSqlServer ? '@p3' : '?'} AND OrderNumber = ${isSqlServer ? '@p3' : '?'}
` `
params = [status, errorMessage || null, batchId, orderNumber]
}
await dbService.query(sqlString, [status, errorMessage || null, batchId, orderNumber]) await dbService.query(sqlString, params)
return true return true
} catch (error) { } catch (error) {
@@ -252,7 +262,7 @@ export class ExtractorOperationHistoryDAO {
/** /**
* Get batch statistics with optional user filtering * Get batch statistics with optional user filtering
* @param userId - Optional user ID for filtering (Admin gets all, User gets own) * @param userId - Optional user ID for filtering (Admin gets all, User gets own)
* @param options - Query options (limit, offset) * @param options - Query options (limit, offset, usernames)
* @returns Array of batch statistics * @returns Array of batch statistics
*/ */
async getBatches(userId?: number, options?: GetBatchesOptions): Promise<BatchStats[]> { async getBatches(userId?: number, options?: GetBatchesOptions): Promise<BatchStats[]> {
@@ -280,6 +290,11 @@ export class ExtractorOperationHistoryDAO {
if (userId !== undefined) { if (userId !== undefined) {
sqlString += isSqlServer ? ` WHERE UserId = @p0 ` : ` WHERE UserId = ? ` sqlString += isSqlServer ? ` WHERE UserId = @p0 ` : ` WHERE UserId = ? `
params.push(userId) params.push(userId)
} else if (options?.usernames && options.usernames.length > 0) {
// Admin user filtering by multiple usernames using IN clause
const placeholders = this.buildPlaceholders(options.usernames.length, isSqlServer)
sqlString += ` WHERE Username IN (${placeholders}) `
params.push(...options.usernames)
} }
sqlString += ` sqlString += `
@@ -288,31 +303,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 +337,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 +443,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,
@@ -565,9 +575,10 @@ export class ExtractorOperationHistoryDAO {
/** /**
* Count total batches with optional user filtering * Count total batches with optional user filtering
* @param userId - Optional user ID for filtering * @param userId - Optional user ID for filtering
* @param usernames - Optional usernames filter for Admin users
* @returns Total number of batches * @returns Total number of batches
*/ */
async countBatches(userId?: number): Promise<number> { async countBatches(userId?: number, usernames?: string[]): Promise<number> {
try { try {
const dbService = await this.getDatabaseService() const dbService = await this.getDatabaseService()
const tableName = this.getTableName() const tableName = this.getTableName()
@@ -578,11 +589,16 @@ export class ExtractorOperationHistoryDAO {
FROM ${tableName} FROM ${tableName}
` `
const params: number[] = [] const params: (number | string)[] = []
if (userId !== undefined) { if (userId !== undefined) {
sqlString += isSqlServer ? ` WHERE UserId = @p0 ` : ` WHERE UserId = ? ` sqlString += isSqlServer ? ` WHERE UserId = @p0 ` : ` WHERE UserId = ? `
params.push(userId) params.push(userId)
} else if (usernames && usernames.length > 0) {
// Admin user filtering by multiple usernames using IN clause
const placeholders = this.buildPlaceholders(usernames.length, isSqlServer)
sqlString += ` WHERE Username IN (${placeholders}) `
params.push(...usernames)
} }
const result = await dbService.query(sqlString, params) const result = await dbService.query(sqlString, params)

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

@@ -83,4 +83,6 @@ export interface GetBatchesOptions {
limit?: number limit?: number
/** Number of batches to skip (for pagination) */ /** Number of batches to skip (for pagination) */
offset?: number offset?: number
/** Optional username filter for Admin users (supports multiple) */
usernames?: string[]
} }

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,
@@ -82,21 +80,22 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
const [expandedBatches, setExpandedBatches] = useState<Set<string>>(new Set()) const [expandedBatches, setExpandedBatches] = useState<Set<string>>(new Set())
const [batchDetails, setBatchDetails] = useState<Map<string, OperationHistoryRecord[]>>(new Map()) const [batchDetails, setBatchDetails] = useState<Map<string, OperationHistoryRecord[]>>(new Map())
const [deleting, setDeleting] = useState<Set<string>>(new Set()) const [deleting, setDeleting] = useState<Set<string>>(new Set())
const [allUsers, setAllUsers] = useState<string[]>([])
const [selectedUsers, setSelectedUsers] = useState<string[]>([])
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 {
const result = await window.electron.operationHistory.getBatches({ limit: 100 }) // Admin user can pass usernames filter
const options =
isAdmin && selectedUsers.length > 0
? { limit: 100, usernames: selectedUsers }
: { limit: 100 }
const result = await window.electron.operationHistory.getBatches(options)
if (result.success && result.data) { if (result.success && result.data) {
setBatches(result.data) setBatches(result.data)
} else { } else {
@@ -107,9 +106,22 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }, [isAdmin, selectedUsers])
const fetchBatchDetails = async (batchId: string) => { const fetchAllUsers = useCallback(async () => {
try {
const result = await window.electron.auth.getAllUsers()
if (result.success && result.data) {
const usernames = result.data.map((u: UserInfo) => u.username)
setAllUsers(usernames)
}
} catch (err) {
console.error('Failed to fetch users:', err)
}
}, [])
const fetchBatchDetails = useCallback(
async (batchId: string) => {
// If already loaded, don't fetch again // If already loaded, don't fetch again
if (batchDetails.has(batchId)) { if (batchDetails.has(batchId)) {
return return
@@ -123,7 +135,19 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
} catch (err) { } catch (err) {
console.error('Failed to fetch batch details:', err) console.error('Failed to fetch batch details:', err)
} }
},
[batchDetails]
)
// Fetch batches when modal opens
useEffect(() => {
if (isOpen) {
void fetchBatches()
if (isAdmin) {
void fetchAllUsers()
} }
}
}, [isOpen, fetchBatches, fetchAllUsers, isAdmin])
const toggleBatchExpansion = (batchId: string) => { const toggleBatchExpansion = (batchId: string) => {
setExpandedBatches((prev) => { setExpandedBatches((prev) => {
@@ -175,15 +199,36 @@ 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('复制失败,请手动复制')
}
}
const toggleUserFilter = (username: string) => {
setSelectedUsers((prev) =>
prev.includes(username)
? prev.filter((u) => u !== username)
: [...prev, username]
)
}
const clearUserFilters = () => {
setSelectedUsers([])
} }
if (!isOpen) return null if (!isOpen) return null
@@ -192,11 +237,45 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
<Modal isOpen={isOpen} onClose={onClose} title="操作历史" size="3xl"> <Modal isOpen={isOpen} onClose={onClose} title="操作历史" size="3xl">
<div className="flex flex-col h-[70vh]"> <div className="flex flex-col h-[70vh]">
{/* Toolbar */} {/* Toolbar */}
<div className="flex items-center justify-between mb-4 pb-4 border-b border-gray-200"> <div className="flex items-start justify-between mb-4 pb-4 border-b border-gray-200">
<div className="flex-1">
{isAdmin && allUsers.length > 0 && (
<div className="mb-3">
<div className="text-xs text-gray-500 mb-2"></div>
<div className="flex flex-wrap gap-2">
{allUsers.map((username) => {
const isSelected = selectedUsers.includes(username)
return (
<button
key={username}
onClick={() => toggleUserFilter(username)}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-all ${
isSelected
? 'bg-blue-600 text-white shadow-sm'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{username}
</button>
)
})}
{selectedUsers.length > 0 && (
<button
onClick={clearUserFilters}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium bg-red-50 text-red-600 hover:bg-red-100 transition-all"
>
</button>
)}
</div>
</div>
)}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span className="text-sm text-gray-600"> <span className="text-sm text-gray-600">
{isAdmin ? ( {isAdmin ? (
<span className="text-amber-600 font-medium"></span> <span className="text-amber-600 font-medium">
{selectedUsers.length > 0 ? `已选择 ${selectedUsers.length} 个用户` : '显示所有用户记录'}
</span>
) : ( ) : (
<span></span> <span></span>
)} )}
@@ -205,8 +284,9 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
<span className="text-sm text-gray-500"> {batches.length} </span> <span className="text-sm text-gray-500"> {batches.length} </span>
)} )}
</div> </div>
</div>
<button <button
className="p-2 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50" className="p-2 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50 flex-shrink-0"
onClick={() => void fetchBatches()} onClick={() => void fetchBatches()}
disabled={loading} disabled={loading}
title="刷新" title="刷新"
@@ -299,6 +379,7 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
</div> </div>
</div> </div>
{isAdmin && (
<button <button
className="p-2 hover:bg-red-50 text-gray-400 hover:text-red-600 rounded transition-colors disabled:opacity-50" className="p-2 hover:bg-red-50 text-gray-400 hover:text-red-600 rounded transition-colors disabled:opacity-50"
onClick={(e) => { onClick={(e) => {
@@ -310,6 +391,7 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
> >
<Trash2 size={16} className={isDeleting ? 'animate-pulse' : ''} /> <Trash2 size={16} className={isDeleting ? 'animate-pulse' : ''} />
</button> </button>
)}
</div> </div>
{/* Batch details */} {/* Batch details */}
@@ -320,10 +402,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

@@ -185,7 +185,7 @@ export function AuthenticatedAppShell({
</div> </div>
</div> </div>
)} )}
{currentPage === 'extractor' && <ExtractorPage />} {currentPage === 'extractor' && <ExtractorPage currentUser={currentUser} />}
{currentPage === 'cleaner' && <CleanerPage />} {currentPage === 'cleaner' && <CleanerPage />}
{currentPage === 'settings' && <SettingsPage />} {currentPage === 'settings' && <SettingsPage />}
</main> </main>

View File

@@ -7,12 +7,28 @@ 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 ExtractorOperationHistoryModal from '../components/ExtractorOperationHistoryModal'
import { useUserStore } from '../stores/useUserStore' import type { CurrentUser } from '../hooks/useAppBootstrap'
const ExtractorPage: React.FC = () => { interface ExtractorPageProps {
currentUser: CurrentUser | null
}
const ExtractorPage: React.FC<ExtractorPageProps> = ({ currentUser }) => {
const [orderNumbers, setOrderNumbers] = usePersistentTextState('extractor_orderNumbers') const [orderNumbers, setOrderNumbers] = usePersistentTextState('extractor_orderNumbers')
const [showHistoryModal, setShowHistoryModal] = React.useState(false) const [showHistoryModal, setShowHistoryModal] = React.useState(false)
const user = useUserStore((state) => state.user)
// Convert currentUser to UserInfo format for the modal
const user = React.useMemo(
() =>
currentUser
? {
id: 0, // ID is not needed for modal display logic
username: currentUser.username,
userType: currentUser.userType
}
: null,
[currentUser]
)
const { const {
isRunning, isRunning,
@@ -90,13 +106,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">