3 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
8 changed files with 149 additions and 39 deletions

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

@@ -173,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()
@@ -265,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[]> {
@@ -293,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 += `
@@ -573,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()
@@ -586,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

@@ -80,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'
@@ -87,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 {
@@ -98,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(
@@ -123,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) => {
@@ -196,17 +219,63 @@ export const ExtractorOperationHistoryModal: React.FC<ExtractorOperationHistoryM
} }
} }
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>
)} )}
@@ -215,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="刷新"
@@ -309,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) => {
@@ -320,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 */}

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,