7 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
9 changed files with 235 additions and 55 deletions

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.1", "version": "1.8.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "erpauto", "name": "erpauto",
"version": "1.7.1", "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.1", "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

@@ -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
*/ */
@@ -162,10 +173,7 @@ export class ExtractorOperationHistoryDAO {
* @param status - New status (success, failed, partial) * @param status - New status (success, failed, partial)
* @returns Update result * @returns Update result
*/ */
async updateBatchStatus( async updateBatchStatus(batchId: string, status: string): Promise<UpdateBatchStatusResult> {
batchId: string,
status: string
): Promise<UpdateBatchStatusResult> {
try { try {
const dbService = await this.getDatabaseService() const dbService = await this.getDatabaseService()
const tableName = this.getTableName() const tableName = this.getTableName()
@@ -254,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[]> {
@@ -282,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 += `
@@ -324,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,
@@ -432,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,
@@ -566,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()
@@ -579,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

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

@@ -14,13 +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 { import type {
BatchStats, BatchStats,
OperationHistoryRecord OperationHistoryRecord
} from '../../../main/types/operation-history.types' } from '../../../main/types/operation-history.types'
import { showSuccess, showError, showWarning } from '../stores/useAppStore'
interface ExtractorOperationHistoryModalProps { interface ExtractorOperationHistoryModalProps {
isOpen: boolean isOpen: boolean
@@ -51,13 +53,20 @@ const statusIcons: Record<string, React.ReactNode> = {
const formatDateTime = (dateStr: string) => { const formatDateTime = (dateStr: string) => {
const date = new Date(dateStr) const date = new Date(dateStr)
return date.toLocaleString('zh-CN', {
year: 'numeric', // Check if the date is valid
month: '2-digit', if (isNaN(date.getTime())) {
day: '2-digit', return dateStr // Return original if invalid
hour: '2-digit', }
minute: '2-digit'
}) // 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> = ({
@@ -71,6 +80,8 @@ 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'
@@ -78,7 +89,13 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
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 {
@@ -89,6 +106,18 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [isAdmin, selectedUsers])
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( const fetchBatchDetails = useCallback(
@@ -114,8 +143,11 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
void fetchBatches() void fetchBatches()
if (isAdmin) {
void fetchAllUsers()
} }
}, [isOpen, fetchBatches]) }
}, [isOpen, fetchBatches, fetchAllUsers, isAdmin])
const toggleBatchExpansion = (batchId: string) => { const toggleBatchExpansion = (batchId: string) => {
setExpandedBatches((prev) => { setExpandedBatches((prev) => {
@@ -167,17 +199,83 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
} }
} }
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 if (!isOpen) return null
return ( return (
<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>
)} )}
@@ -186,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="刷新"
@@ -280,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) => {
@@ -291,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 */}
@@ -301,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,