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",
"version": "1.7.1",
"version": "1.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "erpauto",
"version": "1.7.1",
"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.1",
"version": "1.8.0",
"description": "An Electron application with React and TypeScript",
"main": "./out/main/index.js",
"author": "example.com",

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

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

@@ -14,13 +14,15 @@ import {
ChevronRight,
CheckCircle,
XCircle,
Clock
Clock,
Copy
} from 'lucide-react'
import type { UserInfo } from './UserSelectionDialog'
import type {
BatchStats,
OperationHistoryRecord
} from '../../../main/types/operation-history.types'
import { showSuccess, showError, showWarning } from '../stores/useAppStore'
interface ExtractorOperationHistoryModalProps {
isOpen: boolean
@@ -51,13 +53,20 @@ const statusIcons: Record<string, React.ReactNode> = {
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'
})
// 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> = ({
@@ -71,6 +80,8 @@ 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'
@@ -78,7 +89,13 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
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 {
@@ -89,6 +106,18 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
} finally {
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(
@@ -114,8 +143,11 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
useEffect(() => {
if (isOpen) {
void fetchBatches()
if (isAdmin) {
void fetchAllUsers()
}
}
}, [isOpen, fetchBatches])
}, [isOpen, fetchBatches, fetchAllUsers, isAdmin])
const toggleBatchExpansion = (batchId: string) => {
setExpandedBatches((prev) => {
@@ -167,27 +199,94 @@ 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
return (
<Modal isOpen={isOpen} onClose={onClose} title="操作历史" size="3xl">
<div className="flex flex-col h-[70vh]">
{/* Toolbar */}
<div className="flex items-center justify-between mb-4 pb-4 border-b border-gray-200">
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">
{isAdmin ? (
<span className="text-amber-600 font-medium"></span>
) : (
<span></span>
)}
</span>
{batches.length > 0 && (
<span className="text-sm text-gray-500"> {batches.length} </span>
<div 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">
{selectedUsers.length > 0 ? `已选择 ${selectedUsers.length} 个用户` : '显示所有用户记录'}
</span>
) : (
<span></span>
)}
</span>
{batches.length > 0 && (
<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="刷新"
@@ -280,17 +379,19 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
</div>
</div>
<button
className="p-2 hover:bg-red-50 text-gray-400 hover:text-red-600 rounded transition-colors disabled:opacity-50"
onClick={(e) => {
e.stopPropagation()
void handleDeleteBatch(batch.batchId)
}}
disabled={isDeleting}
title="删除批次"
>
<Trash2 size={16} className={isDeleting ? 'animate-pulse' : ''} />
</button>
{isAdmin && (
<button
className="p-2 hover:bg-red-50 text-gray-400 hover:text-red-600 rounded transition-colors disabled:opacity-50"
onClick={(e) => {
e.stopPropagation()
void handleDeleteBatch(batch.batchId)
}}
disabled={isDeleting}
title="删除批次"
>
<Trash2 size={16} className={isDeleting ? 'animate-pulse' : ''} />
</button>
)}
</div>
{/* Batch details */}
@@ -301,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,