feat: add material type management feature

- Add MaterialTypeManagementDialog component for managing material type keywords
- Add MaterialsTypeToBeDeletedDAO for database operations
- Add material-type-handler IPC handlers
- Update CleanerPage with type management button
- Add database fix scripts for AUTO_INCREMENT
- Update documentation for settings partial save and validation flow

Co-Authored-By: Claude (glm-5) <noreply@anthropic.com>
This commit is contained in:
Misaka
2026-03-03 22:51:11 +08:00
parent 7aa1abbc22
commit e23cf71f78
23 changed files with 1764 additions and 213 deletions

View File

@@ -0,0 +1,523 @@
/**
* Material Type Management Dialog
*
* Provides a dialog for managing material type keywords used to identify
* materials for deletion. Admin users can see all records and filter by manager.
* Regular users can only see and edit their own records.
*/
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { Plus, Trash2, Save, RotateCcw, Users } from 'lucide-react'
import { Modal } from './ui/Modal'
interface MaterialTypeRecord {
id?: number
materialName: string
managerName: string
}
interface RowState {
record: MaterialTypeRecord
state: 'original' | 'new' | 'modified' | 'deleted'
originalRecord?: MaterialTypeRecord
}
interface MaterialTypeManagementDialogProps {
isOpen: boolean
onClose: () => void
isAdmin: boolean
currentUsername: string
}
export const MaterialTypeManagementDialog: React.FC<MaterialTypeManagementDialogProps> = ({
isOpen,
onClose,
isAdmin,
currentUsername
}) => {
const [rows, setRows] = useState<RowState[]>([])
const [managers, setManagers] = useState<string[]>([])
const [selectedManagers, setSelectedManagers] = useState<Set<string>>(new Set())
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [editingCell, setEditingCell] = useState<{ rowIndex: number; field: string } | null>(null)
const [editValue, setEditValue] = useState('')
const [selectedRowIndex, setSelectedRowIndex] = useState<number | null>(null)
const tableRef = useRef<HTMLTableElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
// Calculate pending changes count
const pendingCount = rows.filter(
(r) => r.state === 'new' || r.state === 'modified' || r.state === 'deleted'
).length
// Load data when dialog opens
useEffect(() => {
if (isOpen) {
loadData()
}
}, [isOpen, isAdmin, currentUsername])
// Focus input when editing starts
useEffect(() => {
if (editingCell && inputRef.current) {
inputRef.current.focus()
inputRef.current.select()
}
}, [editingCell])
const loadData = async () => {
setLoading(true)
try {
// Load managers list
const managersResult = await window.electron.materialType.getManagers()
if (managersResult.success && managersResult.data) {
setManagers(managersResult.data)
if (isAdmin) {
setSelectedManagers(new Set(managersResult.data))
}
}
// Load records
let records: MaterialTypeRecord[] = []
if (isAdmin) {
const result = await window.electron.materialType.getAll()
if (result.success && result.data) {
records = result.data
}
} else {
const result = await window.electron.materialType.getByManager(currentUsername)
if (result.success && result.data) {
records = result.data
}
}
setRows(
records.map((record) => ({
record,
state: 'original' as const,
originalRecord: { ...record }
}))
)
setSelectedRowIndex(null)
} catch (error) {
console.error('Failed to load material types:', error)
} finally {
setLoading(false)
}
}
// Filter rows by selected managers (admin only)
const filteredRows = React.useMemo(() => {
if (!isAdmin) return rows
if (selectedManagers.size === 0) return rows
return rows.filter((row) => selectedManagers.has(row.record.managerName) || row.state === 'new')
}, [rows, isAdmin, selectedManagers])
// Handle keyboard events
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (editingCell) {
if (event.key === 'Enter') {
saveEdit()
} else if (event.key === 'Escape') {
cancelEdit()
}
return
}
if (event.key === 'Insert') {
event.preventDefault()
insertNewRow()
} else if (event.key === 'Delete' && selectedRowIndex !== null) {
event.preventDefault()
deleteRow(selectedRowIndex)
}
},
[editingCell, selectedRowIndex]
)
// Insert new row
const insertNewRow = () => {
const newRow: RowState = {
record: {
materialName: '',
managerName: isAdmin ? '' : currentUsername
},
state: 'new'
}
setRows((prev) => [...prev, newRow])
// Start editing the material name cell
const newIndex = rows.length
setTimeout(() => {
setEditingCell({ rowIndex: newIndex, field: 'materialName' })
setEditValue('')
}, 0)
}
// Delete row
const deleteRow = (index: number) => {
setRows((prev) => {
const newRows = [...prev]
const row = newRows[index]
if (row.state === 'new') {
// Remove new rows directly
newRows.splice(index, 1)
} else {
// Mark existing rows as deleted
newRows[index] = { ...row, state: 'deleted' }
}
return newRows
})
setSelectedRowIndex(null)
}
// Start editing a cell
const startEdit = (rowIndex: number, field: string) => {
const row = rows[rowIndex]
if (row.state === 'deleted') return
setEditingCell({ rowIndex, field })
setEditValue(row.record[field as keyof MaterialTypeRecord] as string)
}
// Save edit
const saveEdit = () => {
if (!editingCell) return
const { rowIndex, field } = editingCell
setRows((prev) => {
const newRows = [...prev]
const row = newRows[rowIndex]
const newValue = editValue.trim()
// Update the record
newRows[rowIndex] = {
...row,
record: {
...row.record,
[field]: newValue
},
state: row.state === 'new' ? 'new' : 'modified'
}
return newRows
})
setEditingCell(null)
setEditValue('')
}
// Cancel edit
const cancelEdit = () => {
setEditingCell(null)
setEditValue('')
}
// Save all changes
const handleSave = async () => {
const toInsert: MaterialTypeRecord[] = []
const toUpdate: { old: MaterialTypeRecord; new: MaterialTypeRecord }[] = []
const toDelete: MaterialTypeRecord[] = []
for (const row of rows) {
if (row.state === 'new' && row.record.materialName.trim()) {
toInsert.push(row.record)
} else if (row.state === 'modified' && row.originalRecord) {
toUpdate.push({ old: row.originalRecord, new: row.record })
} else if (row.state === 'deleted' && row.originalRecord) {
toDelete.push(row.originalRecord)
}
}
if (toInsert.length === 0 && toUpdate.length === 0 && toDelete.length === 0) {
alert('没有需要保存的更改')
return
}
const confirmParts: string[] = []
if (toInsert.length > 0) confirmParts.push(`新增 ${toInsert.length} 条记录`)
if (toUpdate.length > 0) confirmParts.push(`更新 ${toUpdate.length} 条记录`)
if (toDelete.length > 0) confirmParts.push(`删除 ${toDelete.length} 条记录`)
if (!window.confirm(`确认以下操作?\n\n${confirmParts.join('\n')}`)) return
setSaving(true)
try {
const result = await window.electron.materialType.upsertBatch({
toInsert,
toUpdate,
toDelete
})
if (result.success) {
alert(
`保存完成!\n成功${result.stats?.success || 0}\n失败${result.stats?.failed || 0}`
)
await loadData()
} else {
alert(`保存失败:${result.error || '未知错误'}`)
}
} catch (error) {
alert(`保存失败:${error instanceof Error ? error.message : '未知错误'}`)
} finally {
setSaving(false)
}
}
// Reset changes
const handleReset = () => {
if (pendingCount === 0) return
if (!window.confirm('确定要放弃所有未保存的更改吗?')) return
loadData()
}
// Handle close with unsaved changes warning
const handleClose = () => {
if (pendingCount > 0) {
if (!window.confirm('有未保存的更改,确定要关闭吗?')) return
}
onClose()
}
// Get row background color
const getRowStyle = (row: RowState): React.CSSProperties => {
if (row.state === 'deleted') {
return { backgroundColor: '#fee2e2', textDecoration: 'line-through', opacity: 0.6 }
}
if (row.state === 'new') {
return { backgroundColor: '#dcfce7' }
}
if (row.state === 'modified') {
return { backgroundColor: '#fef9c3' }
}
return {}
}
return (
<Modal isOpen={isOpen} onClose={handleClose} title="物料类型管理" size="2xl">
<div onKeyDown={handleKeyDown}>
{/* Manager filter (admin only) */}
{isAdmin && (
<div className="mb-4 p-3 bg-slate-50 rounded-lg border border-slate-200">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 text-sm font-medium text-slate-700">
<Users size={16} />
</div>
<div className="flex gap-2">
<button
onClick={() => setSelectedManagers(new Set(managers))}
className="text-xs text-blue-600 hover:underline"
>
</button>
<button
onClick={() => setSelectedManagers(new Set())}
className="text-xs text-slate-500 hover:underline"
>
</button>
</div>
</div>
<div className="flex flex-wrap gap-2">
{managers.map((manager) => (
<label
key={manager}
className="flex items-center gap-1.5 text-xs text-slate-600 cursor-pointer hover:bg-white px-2 py-1 rounded"
>
<input
type="checkbox"
className="rounded text-blue-600"
checked={selectedManagers.has(manager)}
onChange={(e) => {
setSelectedManagers((prev) => {
const newSet = new Set(prev)
if (e.target.checked) newSet.add(manager)
else newSet.delete(manager)
return newSet
})
}}
/>
{manager}
</label>
))}
</div>
</div>
)}
{/* Toolbar */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<button
onClick={insertNewRow}
className="flex items-center gap-1.5 text-xs bg-green-50 border border-green-200 text-green-700 px-3 py-1.5 rounded hover:bg-green-100"
>
<Plus size={14} /> (Insert)
</button>
<button
onClick={() => selectedRowIndex !== null && deleteRow(selectedRowIndex)}
disabled={selectedRowIndex === null}
className="flex items-center gap-1.5 text-xs bg-red-50 border border-red-200 text-red-700 px-3 py-1.5 rounded hover:bg-red-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Trash2 size={14} /> (Delete)
</button>
<button
onClick={handleReset}
disabled={pendingCount === 0}
className="flex items-center gap-1.5 text-xs bg-slate-50 border border-slate-200 text-slate-700 px-3 py-1.5 rounded hover:bg-slate-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
<RotateCcw size={14} />
</button>
</div>
<div className="flex items-center gap-3">
{pendingCount > 0 && (
<span className="text-xs text-amber-600 bg-amber-50 px-2 py-1 rounded">
{pendingCount}
</span>
)}
<button
onClick={handleSave}
disabled={saving || pendingCount === 0}
className="flex items-center gap-1.5 text-xs bg-blue-500 text-white px-3 py-1.5 rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save size={14} /> {saving ? '保存中...' : '保存'}
</button>
</div>
</div>
{/* Table */}
<div className="border border-slate-200 rounded-lg overflow-hidden max-h-[400px] overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-12 text-slate-500">...</div>
) : (
<table ref={tableRef} className="w-full text-sm">
<thead className="bg-slate-100 sticky top-0">
<tr>
<th className="px-4 py-2 text-left font-medium text-slate-700 w-64">
</th>
<th className="px-4 py-2 text-left font-medium text-slate-700"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filteredRows.filter((r) => r.state !== 'deleted').length === 0 ? (
<tr>
<td colSpan={2} className="px-4 py-8 text-center text-slate-400">
"新增"
</td>
</tr>
) : (
filteredRows
.filter((r) => r.state !== 'deleted')
.map((row, index) => {
const originalIndex = rows.indexOf(row)
const isSelected = selectedRowIndex === originalIndex
const isEditingMaterial =
editingCell?.rowIndex === originalIndex &&
editingCell?.field === 'materialName'
const isEditingManager =
editingCell?.rowIndex === originalIndex &&
editingCell?.field === 'managerName'
return (
<tr
key={index}
style={getRowStyle(row)}
className={`${isSelected ? 'ring-2 ring-blue-300 ring-inset' : ''} hover:bg-slate-50 cursor-pointer`}
onClick={() => setSelectedRowIndex(originalIndex)}
>
<td className="px-4 py-2 border-r border-slate-100">
{isEditingMaterial ? (
<input
ref={inputRef}
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={saveEdit}
onKeyDown={(e) => {
if (e.key === 'Enter') saveEdit()
if (e.key === 'Escape') cancelEdit()
}}
className="w-full px-2 py-1 border border-blue-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
) : (
<div
className="min-h-[24px] cursor-text"
onDoubleClick={() => startEdit(originalIndex, 'materialName')}
>
{row.record.materialName || (
<span className="text-slate-400 italic"></span>
)}
</div>
)}
</td>
<td className="px-4 py-2">
{isEditingManager ? (
isAdmin ? (
<select
ref={inputRef as any}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={saveEdit}
onKeyDown={(e) => {
if (e.key === 'Enter') saveEdit()
if (e.key === 'Escape') cancelEdit()
}}
className="w-full px-2 py-1 border border-blue-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value=""></option>
{managers.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
) : (
<input
ref={inputRef}
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={saveEdit}
onKeyDown={(e) => {
if (e.key === 'Enter') saveEdit()
if (e.key === 'Escape') cancelEdit()
}}
className="w-full px-2 py-1 border border-blue-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
)
) : (
<div
className="min-h-[24px] cursor-text"
onDoubleClick={() => startEdit(originalIndex, 'managerName')}
>
{row.record.managerName || (
<span className="text-slate-400 italic"></span>
)}
</div>
)}
</td>
</tr>
)
})
)}
</tbody>
</table>
)}
</div>
{/* Footer info */}
<div className="mt-3 text-xs text-slate-500 flex justify-between">
<span>
| Insert | Delete
{isAdmin && ' | 绿色=新增 | 黄色=已修改'}
</span>
<span> {filteredRows.filter((r) => r.state !== 'deleted').length} </span>
</div>
</div>
</Modal>
)
}
export default MaterialTypeManagementDialog

View File

@@ -12,7 +12,7 @@ interface ModalProps {
onClose: () => void
title?: string
children: React.ReactNode
size?: 'sm' | 'md' | 'lg' | 'xl'
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl'
showCloseButton?: boolean
}
@@ -20,7 +20,9 @@ const sizeStyles: Record<string, string> = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl'
xl: 'max-w-xl',
'2xl': 'max-w-2xl',
'3xl': 'max-w-3xl'
}
export function Modal({

View File

@@ -15,6 +15,7 @@ import {
Settings2,
FileSpreadsheet
} from 'lucide-react'
import MaterialTypeManagementDialog from '../components/MaterialTypeManagementDialog'
/**
* Material validation result interface
@@ -60,6 +61,9 @@ const CleanerPage: React.FC = () => {
const [sharedProductionIdsCount, setSharedProductionIdsCount] = useState(0)
console.log(sharedProductionIdsCount)
// Material type management dialog state
const [isTypeDialogOpen, setIsTypeDialogOpen] = useState(false)
// Check admin status and get shared Production IDs on mount
React.useEffect(() => {
const initializePage = async () => {
@@ -470,7 +474,10 @@ const CleanerPage: React.FC = () => {
</div>
<div className="flex items-center gap-2">
<button className="text-xs bg-white border border-slate-300 text-slate-700 px-3 py-1.5 rounded shadow-sm hover:bg-slate-50 flex items-center gap-1.5">
<button
onClick={() => setIsTypeDialogOpen(true)}
className="text-xs bg-white border border-slate-300 text-slate-700 px-3 py-1.5 rounded shadow-sm hover:bg-slate-50 flex items-center gap-1.5"
>
<Settings2 size={14} />
</button>
<button className="text-xs bg-blue-50 border border-blue-200 text-blue-700 px-3 py-1.5 rounded shadow-sm hover:bg-blue-100 flex items-center gap-1.5 font-medium">
@@ -606,6 +613,14 @@ const CleanerPage: React.FC = () => {
</div>
</div>
</div>
{/* Material Type Management Dialog */}
<MaterialTypeManagementDialog
isOpen={isTypeDialogOpen}
onClose={() => setIsTypeDialogOpen(false)}
isAdmin={isAdmin}
currentUsername={currentUsername}
/>
</div>
)
}

View File

@@ -201,6 +201,14 @@ const ExtractorPage: React.FC = () => {
</span>
</div>
</div>
{result.mergedFile && (
<div className="bg-slate-50 p-4 rounded-lg border border-slate-100">
<span className="text-slate-500 text-sm block mb-1"></span>
<span className="text-sm font-mono text-slate-700 select-all break-all">
{result.mergedFile}
</span>
</div>
)}
</div>
)}