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",
"version": "1.7.0",
"version": "1.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "erpauto",
"version": "1.7.0",
"version": "1.8.0",
"hasInstallScript": true,
"dependencies": {
"@aws-sdk/client-s3": "^3.929.0",

View File

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

View File

@@ -256,7 +256,14 @@ export function registerExtractorHandlers(): void {
: result.errors.length > 0
? 'failed'
: '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 })
}

View File

@@ -21,6 +21,17 @@ import type {
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
*/
@@ -160,43 +171,24 @@ export class ExtractorOperationHistoryDAO {
* Update the status of all records in a batch
* @param batchId - Batch identifier
* @param status - New status (success, failed, partial)
* @param recordCount - Total record count for the batch
* @returns Update result
*/
async updateBatchStatus(
batchId: string,
status: string,
recordCount: number | null
): Promise<UpdateBatchStatusResult> {
async updateBatchStatus(batchId: string, status: string): Promise<UpdateBatchStatusResult> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
const isSqlServer = dbService.type === 'sqlserver'
const placeholder = isSqlServer ? '@p0' : '?'
let sqlString: string
let params: (string | number | null)[]
if (recordCount !== null) {
sqlString = `
const sqlString = `
UPDATE ${tableName}
SET Status = ${isSqlServer ? '@p0' : '?'},
RecordCount = ${isSqlServer ? '@p1' : '?'}
WHERE BatchId = ${isSqlServer ? '@p2' : '?'}
`
params = isSqlServer ? [status, recordCount, batchId] : [status, recordCount, batchId]
} else {
sqlString = `
UPDATE ${tableName}
SET Status = ${placeholder}
SET Status = ${isSqlServer ? '@p0' : '?'}
WHERE BatchId = ${isSqlServer ? '@p1' : '?'}
`
params = isSqlServer ? [status, batchId] : [status, batchId]
}
const params = [status, batchId]
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 }
} catch (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 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
errorMessage?: string,
recordCount?: number
): Promise<boolean> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
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}
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, [status, errorMessage || null, batchId, orderNumber])
await dbService.query(sqlString, params)
return true
} catch (error) {
@@ -252,7 +262,7 @@ export class ExtractorOperationHistoryDAO {
/**
* 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)
* @param options - Query options (limit, offset, usernames)
* @returns Array of batch statistics
*/
async getBatches(userId?: number, options?: GetBatchesOptions): Promise<BatchStats[]> {
@@ -280,6 +290,11 @@ export class ExtractorOperationHistoryDAO {
if (userId !== undefined) {
sqlString += isSqlServer ? ` WHERE UserId = @p0 ` : ` WHERE 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 += `
@@ -288,31 +303,30 @@ export class ExtractorOperationHistoryDAO {
`
if (options?.limit) {
// Add pagination - track current param count before adding new params
const offsetIndex = params.length
const limitIndex = params.length + 1
if (options.offset !== undefined) {
params.push(options.offset)
}
params.push(options.limit)
const safeLimit = Math.floor(options.limit)
const safeOffset = options.offset !== undefined ? Math.floor(options.offset) : undefined
if (isSqlServer) {
if (options.offset !== undefined) {
sqlString += ` OFFSET @p${offsetIndex} ROWS FETCH NEXT @p${limitIndex} ROWS ONLY`
// 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 {
// When no offset, use 0 for offset and next index for limit
sqlString += ` OFFSET 0 ROWS FETCH NEXT @p${offsetIndex} ROWS ONLY`
}
} else {
if (options.offset !== undefined) {
sqlString += ` LIMIT ?`
// For MySQL with offset, we need to modify the query
// Replace LIMIT with OFFSET LIMIT
const parts = sqlString.split(' LIMIT ?')
sqlString = parts[0] + ` OFFSET ? LIMIT ?` + (parts[1] || '')
// 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 ?`
sqlString += ` LIMIT ${safeLimit}`
}
}
}
@@ -323,9 +337,7 @@ export class ExtractorOperationHistoryDAO {
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(),
operationTime: formatDateTime(row.OperationTime),
status: row.Status as string,
totalOrders: row.TotalOrders as number,
totalRecords: (row.TotalRecords as number) || 0,
@@ -431,9 +443,7 @@ export class ExtractorOperationHistoryDAO {
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(),
operationTime: formatDateTime(row.OperationTime),
status: row.Status as string,
totalOrders: row.TotalOrders as number,
totalRecords: (row.TotalRecords as number) || 0,
@@ -565,9 +575,10 @@ export class ExtractorOperationHistoryDAO {
/**
* Count total batches with optional user filtering
* @param userId - Optional user ID for filtering
* @param usernames - Optional usernames filter for Admin users
* @returns Total number of batches
*/
async countBatches(userId?: number): Promise<number> {
async countBatches(userId?: number, usernames?: string[]): Promise<number> {
try {
const dbService = await this.getDatabaseService()
const tableName = this.getTableName()
@@ -578,11 +589,16 @@ export class ExtractorOperationHistoryDAO {
FROM ${tableName}
`
const params: number[] = []
const params: (number | string)[] = []
if (userId !== undefined) {
sqlString += isSqlServer ? ` WHERE UserId = @p0 ` : ` WHERE 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)

View File

@@ -46,7 +46,8 @@ export class ExtractorService {
downloadedFiles: [],
mergedFile: null,
recordCount: 0,
errors: []
errors: [],
orderRecordCounts: []
}
try {
@@ -79,6 +80,7 @@ export class ExtractorService {
const mergeResult = await this.mergeFiles(result.downloadedFiles)
result.mergedFile = mergeResult.mergedFile
result.recordCount = mergeResult.recordCount
result.orderRecordCounts = mergeResult.orderRecordCounts
// Add merge error to result if any
if (mergeResult.error) {
@@ -123,9 +125,14 @@ export class ExtractorService {
*/
private async mergeFiles(
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) {
return { mergedFile: null, recordCount: 0 }
return { mergedFile: null, recordCount: 0, orderRecordCounts: [] }
}
log.info('Starting merge', { fileCount: filePaths.length })
@@ -154,15 +161,21 @@ export class ExtractorService {
// Calculate total record count (total material rows)
let recordCount = 0
const orderRecordCounts: Array<{ orderNumber: string; recordCount: number }> = []
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 })
if (recordCount === 0) {
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
@@ -178,13 +191,13 @@ export class ExtractorService {
log.info('Saving merged file', { outputPath })
await this.saveMergedOrders(allOrders, outputPath)
log.info('Merged file saved successfully', { recordCount })
return { mergedFile: outputPath, recordCount }
return { mergedFile: outputPath, recordCount, orderRecordCounts }
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
const errorStack = error instanceof Error ? error.stack : ''
log.error('Failed to save merged file', { error: errorMsg, stack: errorStack })
// 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[]
/** Database import result (only populated if mergedFile was created) */
importResult?: ImportResult
/** Per-order material row counts */
orderRecordCounts: Array<{ orderNumber: string; recordCount: number }>
}
export interface OrderInfo {

View File

@@ -83,4 +83,6 @@ export interface GetBatchesOptions {
limit?: number
/** Number of batches to skip (for pagination) */
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.
*/
import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useCallback } from 'react'
import { Modal } from './ui/Modal'
import {
RefreshCw,
@@ -14,35 +14,15 @@ import {
ChevronRight,
CheckCircle,
XCircle,
Clock
Clock,
Copy
} from 'lucide-react'
import type { UserInfo } from './UserSelectionDialog'
// Local type definitions matching the backend types
interface BatchStats {
batchId: string
userId: number
username: string
operationTime: string
status: string
totalOrders: number
totalRecords: number
successCount: number
failedCount: number
}
interface OperationHistoryRecord {
id?: number
batchId: string
userId: number
username: string
productionId: string | null
orderNumber: string
operationTime: Date
status: string
recordCount: number | null
errorMessage: string | null
}
import type {
BatchStats,
OperationHistoryRecord
} from '../../../main/types/operation-history.types'
import { showSuccess, showError, showWarning } from '../stores/useAppStore'
interface ExtractorOperationHistoryModalProps {
isOpen: boolean
@@ -71,6 +51,24 @@ const statusIcons: Record<string, React.ReactNode> = {
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> = ({
isOpen,
onClose,
@@ -82,21 +80,22 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
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 [allUsers, setAllUsers] = useState<string[]>([])
const [selectedUsers, setSelectedUsers] = useState<string[]>([])
const isAdmin = user?.userType === 'Admin'
// Fetch batches when modal opens
useEffect(() => {
if (isOpen) {
void fetchBatches()
}
}, [isOpen])
const fetchBatches = async () => {
const fetchBatches = useCallback(async () => {
setLoading(true)
setError(null)
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) {
setBatches(result.data)
} else {
@@ -107,9 +106,22 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
} finally {
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 (batchDetails.has(batchId)) {
return
@@ -123,7 +135,19 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
} catch (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) => {
setExpandedBatches((prev) => {
@@ -175,15 +199,36 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
}
}
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'
})
const handleCopyColumn = async (field: 'productionId' | 'orderNumber', batchId: string) => {
const details = batchDetails.get(batchId) || []
const values = details
.map((d) => (field === 'productionId' ? d.productionId : d.orderNumber))
.filter(Boolean) // 移除空值
.join('\n') // 使用换行符分隔
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
@@ -192,11 +237,45 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
<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-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">
<span className="text-sm text-gray-600">
{isAdmin ? (
<span className="text-amber-600 font-medium"></span>
<span className="text-amber-600 font-medium">
{selectedUsers.length > 0 ? `已选择 ${selectedUsers.length} 个用户` : '显示所有用户记录'}
</span>
) : (
<span></span>
)}
@@ -205,8 +284,9 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
<span className="text-sm text-gray-500"> {batches.length} </span>
)}
</div>
</div>
<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()}
disabled={loading}
title="刷新"
@@ -299,6 +379,7 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
</div>
</div>
{isAdmin && (
<button
className="p-2 hover:bg-red-50 text-gray-400 hover:text-red-600 rounded transition-colors disabled:opacity-50"
onClick={(e) => {
@@ -310,6 +391,7 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
>
<Trash2 size={16} className={isDeleting ? 'animate-pulse' : ''} />
</button>
)}
</div>
{/* Batch details */}
@@ -320,10 +402,38 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
<thead className="bg-gray-50">
<tr>
<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 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 className="px-4 py-2 text-left font-medium text-gray-600">

View File

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

View File

@@ -7,12 +7,28 @@ import { useSharedProductionIds } from '../hooks/useSharedProductionIds'
import LogPanel from '../components/ui/LogPanel'
import { SegmentedProgressBar } from '../components/ui/SegmentedProgressBar'
import ExtractorOperationHistoryModal from '../components/ExtractorOperationHistoryModal'
import { useUserStore } from '../stores/useUserStore'
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 [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 {
isRunning,
@@ -90,13 +106,13 @@ const ExtractorPage: React.FC = () => {
</div>
</div>
{showHistoryModal && (
{showHistoryModal ? (
<ExtractorOperationHistoryModal
isOpen={showHistoryModal}
onClose={() => setShowHistoryModal(false)}
user={user}
/>
)}
) : null}
{!isRunning && isComplete && (
<div className="bg-green-50 rounded-xl p-8 flex items-center justify-center gap-4 shadow-md">