726 lines
39 KiB
TypeScript
726 lines
39 KiB
TypeScript
'use client'
|
||
import { useState, useEffect, useCallback, useRef, Suspense } from 'react'
|
||
import Link from 'next/link'
|
||
import { useRouter, useSearchParams, usePathname } from 'next/navigation'
|
||
import { Button, Badge, Pagination } from '@/components/ui'
|
||
import { Search, Download, Eye, Pencil, Trash2, Filter, ArrowUpDown, ChevronsUpDown, ChevronUp, ChevronDown, Check, X, ExternalLink } from 'lucide-react'
|
||
import SelectWithInput from '@/components/ui/SelectWithInput'
|
||
import SlaCountdown from '@/components/tickets/SlaCountdown'
|
||
import { getTier1Deadline } from '@/lib/sla'
|
||
|
||
interface Ticket {
|
||
id: number
|
||
device_ip: string | null
|
||
device_name: string | null
|
||
content: string | null
|
||
assign_time: string | null
|
||
current_status: string
|
||
fault_category: string | null
|
||
parts_replaced: string | null
|
||
counted_in_sla: number
|
||
}
|
||
|
||
const statusMap: Record<string, { label: string; variant: 'default' | 'info' | 'warning' | 'success' | 'danger' }> = {
|
||
open: { label: '待处理', variant: 'info' },
|
||
in_progress: { label: '处理中', variant: 'warning' },
|
||
resolved: { label: '已解决', variant: 'success' },
|
||
closed: { label: '已关闭', variant: 'default' },
|
||
}
|
||
|
||
const STATUS_OPTIONS = ['open', 'in_progress', 'resolved', 'closed']
|
||
const FAULT_CATEGORY_OPTIONS = ['硬件故障', '软件故障', '网络故障', '存储故障', '电源故障', '无故障']
|
||
|
||
const COLUMNS = [
|
||
{ key: 'id', label: '工单号', sortable: true, filterable: true, filterType: 'text' as const },
|
||
{ key: 'device_ip', label: '业务IP', sortable: true, filterable: true, filterType: 'multi' as const },
|
||
{ key: 'device_name', label: '节点名称', sortable: true, filterable: true, filterType: 'multi' as const },
|
||
{ key: 'content', label: '工单内容', sortable: true, filterable: true, filterType: 'text' as const },
|
||
{ key: 'current_status', label: '状态', sortable: true, filterable: true, filterType: 'multi' as const },
|
||
{ key: 'fault_category', label: '故障大类', sortable: true, filterable: true, filterType: 'multi' as const },
|
||
{ key: 'assign_time', label: '派单时间', sortable: true, filterable: true, filterType: 'date' as const },
|
||
]
|
||
|
||
type FilterType = 'text' | 'multi' | 'date'
|
||
|
||
function BatchEditModal({ selected, onClose, onSuccess }: { selected: number[]; onClose: () => void; onSuccess: () => void }) {
|
||
const [batchField, setBatchField] = useState<'fault_category' | 'current_status' | 'counted_in_sla'>('fault_category')
|
||
const [faultCategory, setFaultCategory] = useState('')
|
||
const [currentStatus, setCurrentStatus] = useState('')
|
||
const [countedInSla, setCountedInSla] = useState<number | null>(null)
|
||
const [loading, setLoading] = useState(false)
|
||
const [error, setError] = useState('')
|
||
|
||
async function handleSubmit() {
|
||
if (!confirm(`确定要批量修改选中的 ${selected.length} 条工单吗?`)) return
|
||
setLoading(true)
|
||
setError('')
|
||
try {
|
||
const updates = selected.map(id => {
|
||
const body: Record<string, unknown> = { id }
|
||
if (batchField === 'fault_category' && faultCategory) body.fault_category = faultCategory
|
||
if (batchField === 'current_status' && currentStatus) body.current_status = currentStatus
|
||
if (batchField === 'counted_in_sla') body.counted_in_sla = countedInSla
|
||
return body
|
||
})
|
||
const res = await fetch('/api/tickets/batch', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ updates }),
|
||
})
|
||
if (!res.ok) {
|
||
const d = await res.json()
|
||
setError(d.error || '批量修改失败')
|
||
return
|
||
}
|
||
onSuccess()
|
||
onClose()
|
||
} catch {
|
||
setError('网络错误')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||
<div className="relative bg-white dark:bg-slate-900 rounded-xl shadow-xl border border-slate-200 dark:border-slate-700 w-full max-w-md mx-4">
|
||
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200 dark:border-slate-700">
|
||
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">批量编辑</h3>
|
||
<button onClick={onClose} className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 text-xl leading-none">×</button>
|
||
</div>
|
||
<div className="p-6 space-y-4">
|
||
<p className="text-sm text-slate-500 dark:text-slate-400">已选中 <strong>{selected.length}</strong> 条工单</p>
|
||
<div className="space-y-1">
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">修改字段</label>
|
||
<div className="flex gap-2">
|
||
<button onClick={() => setBatchField('fault_category')} className={`px-3 py-1.5 rounded-lg text-sm border ${batchField === 'fault_category' ? 'bg-blue-600 text-white border-blue-600' : 'border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-300'}`}>故障大类</button>
|
||
<button onClick={() => setBatchField('current_status')} className={`px-3 py-1.5 rounded-lg text-sm border ${batchField === 'current_status' ? 'bg-blue-600 text-white border-blue-600' : 'border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-300'}`}>状态</button>
|
||
<button onClick={() => setBatchField('counted_in_sla')} className={`px-3 py-1.5 rounded-lg text-sm border ${batchField === 'counted_in_sla' ? 'bg-blue-600 text-white border-blue-600' : 'border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-300'}`}>SLA计数</button>
|
||
</div>
|
||
</div>
|
||
{batchField === 'fault_category' && (
|
||
<SelectWithInput label="新故障大类" value={faultCategory} onChange={setFaultCategory} options={FAULT_CATEGORY_OPTIONS} placeholder="请选择或输入..." />
|
||
)}
|
||
{batchField === 'current_status' && (
|
||
<div className="space-y-1">
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">新状态</label>
|
||
<div className="flex gap-2">
|
||
{STATUS_OPTIONS.map(s => (
|
||
<button key={s} onClick={() => setCurrentStatus(s)}
|
||
className={`px-3 py-1.5 rounded-lg text-sm border ${currentStatus === s ? 'bg-blue-600 text-white border-blue-600' : 'border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-300'}`}>
|
||
{statusMap[s].label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{batchField === 'counted_in_sla' && (
|
||
<div className="space-y-1">
|
||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">SLA 计数</label>
|
||
<div className="flex gap-2">
|
||
<button onClick={() => setCountedInSla(1)} className={`px-4 py-1.5 rounded-lg text-sm border ${countedInSla === 1 ? 'bg-blue-600 text-white border-blue-600' : 'border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-300'}`}>计入</button>
|
||
<button onClick={() => setCountedInSla(0)} className={`px-4 py-1.5 rounded-lg text-sm border ${countedInSla === 0 ? 'bg-blue-600 text-white border-blue-600' : 'border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-300'}`}>不计入</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||
</div>
|
||
<div className="px-6 py-4 border-t border-slate-200 dark:border-slate-700 flex justify-end gap-3">
|
||
<Button variant="secondary" onClick={onClose}>取消</Button>
|
||
<Button onClick={handleSubmit} disabled={loading}>
|
||
{loading ? '保存中...' : `确认修改 ${selected.length} 条工单`}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
interface TicketListInnerProps {
|
||
onPaginationChange?: (pagination: { page: number; pageSize: number; total: number; totalPages: number }) => void
|
||
defaultStatusFilter?: string[]
|
||
showSlaColumn?: boolean
|
||
showActions?: boolean
|
||
hideDefaultFilterChips?: boolean
|
||
}
|
||
|
||
function TicketListInner({ onPaginationChange, defaultStatusFilter, showSlaColumn, showActions = true, hideDefaultFilterChips }: TicketListInnerProps) {
|
||
const router = useRouter()
|
||
const searchParams = useSearchParams()
|
||
const pathname = usePathname()
|
||
const [tickets, setTickets] = useState<Ticket[]>([])
|
||
const [pagination, setPagination] = useState({ page: 1, pageSize: 20, total: 0, totalPages: 0 })
|
||
const [loading, setLoading] = useState(true)
|
||
const [search, setSearch] = useState('')
|
||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||
const [showBatchEdit, setShowBatchEdit] = useState(false)
|
||
const [page, setPage] = useState(1)
|
||
const [pageSize, setPageSize] = useState(20)
|
||
const [sortKey, setSortKey] = useState('assign_time')
|
||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc')
|
||
const [tableMode, setTableMode] = useState<'sort' | 'filter'>('sort')
|
||
const [openFilterColumn, setOpenFilterColumn] = useState<string | null>(null)
|
||
const [columnFilterValues, setColumnFilterValues] = useState<Record<string, string[]>>({})
|
||
const [columnTextFilter, setColumnTextFilter] = useState<Record<string, string>>({})
|
||
const [dateFilter, setDateFilter] = useState<Record<string, { start: string; end: string }>>({})
|
||
const [fieldOptions, setFieldOptions] = useState<Record<string, string[]>>({})
|
||
const [ticketNoFilter, setTicketNoFilter] = useState('')
|
||
const [permissions, setPermissions] = useState<string[]>([])
|
||
const filterDropRef = useRef<HTMLDivElement>(null)
|
||
|
||
useEffect(() => {
|
||
fetch('/api/auth/me')
|
||
.then(r => r.json())
|
||
.then(u => { if (u.user?.permissions) setPermissions(u.user.permissions) })
|
||
.catch(() => {})
|
||
}, [])
|
||
|
||
// 列宽拖拽调整
|
||
const [colWidths, setColWidths] = useState<Record<string, number>>({})
|
||
const [resizingCol, setResizingCol] = useState<string | null>(null)
|
||
const [hasResized, setHasResized] = useState(false)
|
||
const resizeRef = useRef<{ col: string; startX: number; startWidth: number } | null>(null)
|
||
|
||
function snapAllColumns() {
|
||
if (hasResized) return
|
||
setHasResized(true)
|
||
const widths: Record<string, number> = {}
|
||
document.querySelectorAll('th[data-col-key]').forEach(th => {
|
||
const key = th.getAttribute('data-col-key')
|
||
if (key) widths[key] = th.getBoundingClientRect().width
|
||
})
|
||
setColWidths(widths)
|
||
}
|
||
|
||
function onResizeStart(colKey: string, e: React.MouseEvent) {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
snapAllColumns()
|
||
const th = (e.target as HTMLElement).closest('th')
|
||
const startWidth = th?.getBoundingClientRect().width || 150
|
||
resizeRef.current = { col: colKey, startX: e.clientX, startWidth }
|
||
setResizingCol(colKey)
|
||
document.body.style.userSelect = 'none'
|
||
document.body.style.cursor = 'col-resize'
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (!resizingCol) return
|
||
function onMouseMove(e: MouseEvent) {
|
||
if (!resizeRef.current) return
|
||
const { col, startX, startWidth } = resizeRef.current
|
||
setColWidths(prev => ({ ...prev, [col]: Math.max(60, startWidth + (e.clientX - startX)) }))
|
||
}
|
||
function onMouseUp() {
|
||
setResizingCol(null)
|
||
resizeRef.current = null
|
||
document.body.style.userSelect = ''
|
||
document.body.style.cursor = ''
|
||
}
|
||
document.addEventListener('mousemove', onMouseMove)
|
||
document.addEventListener('mouseup', onMouseUp)
|
||
return () => {
|
||
document.removeEventListener('mousemove', onMouseMove)
|
||
document.removeEventListener('mouseup', onMouseUp)
|
||
}
|
||
}, [resizingCol])
|
||
|
||
// 从 URL 初始化设备筛选
|
||
useEffect(() => {
|
||
function handleClick(e: MouseEvent) {
|
||
if (filterDropRef.current && !filterDropRef.current.contains(e.target as Node)) {
|
||
setOpenFilterColumn(null)
|
||
}
|
||
}
|
||
document.addEventListener('mousedown', handleClick)
|
||
return () => document.removeEventListener('mousedown', handleClick)
|
||
}, [])
|
||
useEffect(() => {
|
||
const ip = searchParams.get('device_ip')
|
||
const name = searchParams.get('device_name')
|
||
const sn = searchParams.get('device_sn')
|
||
if (ip) {
|
||
setColumnFilterValues(prev => ({ ...prev, device_ip: [ip] }))
|
||
}
|
||
if (name) {
|
||
setColumnFilterValues(prev => ({ ...prev, device_name: [name] }))
|
||
}
|
||
if (sn) {
|
||
setColumnFilterValues(prev => ({ ...prev, device_sn: [sn] }))
|
||
}
|
||
}, [searchParams])
|
||
|
||
// 应用 defaultStatusFilter prop 到列筛选状态
|
||
useEffect(() => {
|
||
if (defaultStatusFilter && defaultStatusFilter.length > 0) {
|
||
setColumnFilterValues(prev => {
|
||
if (prev.current_status && prev.current_status.length > 0) return prev
|
||
return { ...prev, current_status: defaultStatusFilter }
|
||
})
|
||
}
|
||
}, [defaultStatusFilter])
|
||
|
||
const loadFieldOptions = useCallback(async () => {
|
||
const fields = ['device_ip', 'device_name', 'fault_category', 'current_status']
|
||
const params = new URLSearchParams()
|
||
fields.forEach(f => params.append('field', f))
|
||
try {
|
||
const res = await fetch(`/api/tickets/field-values?${params}`)
|
||
if (res.ok) {
|
||
const data = await res.json()
|
||
setFieldOptions(data)
|
||
}
|
||
} catch { /* silent */ }
|
||
}, [])
|
||
|
||
useEffect(() => { loadFieldOptions() }, [loadFieldOptions])
|
||
|
||
|
||
const fetchTickets = useCallback(async () => {
|
||
setLoading(true)
|
||
try {
|
||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize), sortBy: sortKey, sortOrder })
|
||
if (search) params.set('search', search)
|
||
if (ticketNoFilter) params.set('search', ticketNoFilter)
|
||
for (const [field, values] of Object.entries(columnFilterValues)) {
|
||
for (const v of values) params.append(`filter_${field}`, v)
|
||
}
|
||
for (const [field, text] of Object.entries(columnTextFilter)) {
|
||
if (text) params.set(`filter_${field}`, text)
|
||
}
|
||
for (const [field, range] of Object.entries(dateFilter)) {
|
||
if (range.start) params.set(`${field}_start`, range.start)
|
||
if (range.end) params.set(`${field}_end`, range.end)
|
||
}
|
||
const res = await fetch(`/api/tickets?${params}`)
|
||
const data = await res.json()
|
||
if (res.ok) {
|
||
setTickets(data.tickets)
|
||
setPagination(data.pagination)
|
||
onPaginationChange?.(data.pagination)
|
||
}
|
||
} catch { /* silent */ }
|
||
finally { setLoading(false) }
|
||
}, [page, pageSize, sortKey, sortOrder, search, columnFilterValues, columnTextFilter, dateFilter, ticketNoFilter, onPaginationChange])
|
||
|
||
useEffect(() => { fetchTickets() }, [fetchTickets])
|
||
|
||
function handleColumnClick(key: string, sortable: boolean, filterable: boolean) {
|
||
if (tableMode === 'sort') {
|
||
if (!sortable) return
|
||
if (sortKey === key) setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc')
|
||
else { setSortKey(key); setSortOrder('asc') }
|
||
setPage(1)
|
||
} else {
|
||
if (!filterable) return
|
||
setOpenFilterColumn(openFilterColumn === key ? null : key)
|
||
}
|
||
}
|
||
|
||
function toggleFilterValue(column: string, value: string) {
|
||
setColumnFilterValues(prev => {
|
||
const current = prev[column] || []
|
||
const next = current.includes(value) ? current.filter(v => v !== value) : [...current, value]
|
||
return { ...prev, [column]: next }
|
||
})
|
||
setPage(1)
|
||
}
|
||
|
||
function clearColumnFilter(column: string) {
|
||
setColumnFilterValues(prev => { const n = { ...prev }; delete n[column]; return n })
|
||
setColumnTextFilter(prev => { const n = { ...prev }; delete n[column]; return n })
|
||
setDateFilter(prev => { const n = { ...prev }; delete n[column]; return n })
|
||
setPage(1)
|
||
}
|
||
|
||
function SortIcon({ colKey, colDef }: { colKey: string; colDef: (typeof COLUMNS)[0] }) {
|
||
if (tableMode === 'filter' && !colDef.filterable) return null
|
||
const icon = tableMode === 'filter'
|
||
? <Filter size={12} />
|
||
: !colDef.sortable
|
||
? null
|
||
: sortKey !== colKey
|
||
? <ChevronsUpDown size={13} />
|
||
: sortOrder === 'asc'
|
||
? <ChevronUp size={13} className="text-blue-500" />
|
||
: <ChevronDown size={13} className="text-blue-500" />
|
||
if (!icon) return null
|
||
return (
|
||
<span
|
||
onClick={(e) => { e.stopPropagation(); handleColumnClick(colKey, colDef.sortable, colDef.filterable) }}
|
||
className={`ml-1 flex-shrink-0 rounded transition-colors cursor-pointer ${tableMode === 'sort' && sortKey === colKey ? 'text-blue-500' : 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-300'}`}
|
||
title={tableMode === 'sort' ? '点击排序' : '点击筛选'}
|
||
>
|
||
{icon}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function FilterIndicator({ colKey }: { colKey: string }) {
|
||
const multi = columnFilterValues[colKey]
|
||
const text = columnTextFilter[colKey]
|
||
const date = dateFilter[colKey]
|
||
const hasFilter = (multi && multi.length > 0) || (text && text.length > 0) || (date && (date.start || date.end))
|
||
if (!hasFilter) return null
|
||
const count = (multi?.length || 0) + (text ? 1 : 0) + ((date?.start || date?.end) ? 1 : 0)
|
||
return <span className="ml-0.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-blue-600 text-white text-[10px] font-bold">{count}</span>
|
||
}
|
||
|
||
const hasActiveFilters =
|
||
Object.values(columnFilterValues).some(v => v.length > 0) ||
|
||
Object.values(columnTextFilter).some(v => v.length > 0) ||
|
||
Object.values(dateFilter).some(v => v.start || v.end) ||
|
||
ticketNoFilter.length > 0
|
||
|
||
async function handleDelete(id: number) {
|
||
if (!confirm('确定要删除此工单吗?')) return
|
||
const res = await fetch(`/api/tickets/${id}`, { method: 'DELETE' })
|
||
if (res.ok) fetchTickets()
|
||
}
|
||
|
||
function handleExport() {
|
||
if (tickets.length === 0) {
|
||
alert('当前列表没有可导出的工单')
|
||
return
|
||
}
|
||
const hasSelection = selectedIds.size > 0
|
||
const exportCount = hasSelection ? selectedIds.size : pagination.total
|
||
const msg = hasSelection
|
||
? `确定要导出选中的 ${exportCount} 条工单吗?`
|
||
: `确定要导出当前筛选的 ${exportCount} 条工单吗?`
|
||
if (!confirm(msg)) return
|
||
|
||
if (hasSelection) {
|
||
const ids = Array.from(selectedIds).join(',')
|
||
window.open(`/api/tickets/export?ids=${ids}`, '_blank')
|
||
return
|
||
}
|
||
|
||
const params = new URLSearchParams()
|
||
if (search) params.set('search', search)
|
||
params.set('sortBy', sortKey)
|
||
params.set('sortOrder', sortOrder)
|
||
for (const [field, values] of Object.entries(columnFilterValues)) {
|
||
for (const v of values) params.append(`filter_${field}`, v)
|
||
}
|
||
for (const [field, text] of Object.entries(columnTextFilter)) {
|
||
if (text) params.set(`filter_${field}`, text)
|
||
}
|
||
for (const [field, range] of Object.entries(dateFilter)) {
|
||
if (range.start) params.set(`${field}_start`, range.start)
|
||
if (range.end) params.set(`${field}_end`, range.end)
|
||
}
|
||
window.open(`/api/tickets/export?${params}`, '_blank')
|
||
}
|
||
|
||
const activeDeviceFilter = (() => {
|
||
if (columnFilterValues.device_ip?.length === 1 && columnFilterValues.device_name?.length === 1) {
|
||
return { ip: columnFilterValues.device_ip[0], name: columnFilterValues.device_name[0] }
|
||
}
|
||
if (columnFilterValues.device_ip?.length === 1) return { ip: columnFilterValues.device_ip[0], name: null }
|
||
if (columnFilterValues.device_name?.length === 1) return { ip: null, name: columnFilterValues.device_name[0] }
|
||
return null
|
||
})()
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex-1 flex items-center gap-3">
|
||
<div className="relative flex-1 max-w-sm">
|
||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={16} />
|
||
<input className="w-full pl-9 pr-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm text-slate-900 dark:text-white placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||
placeholder="快速搜索..." value={search}
|
||
onChange={e => setSearch(e.target.value)}
|
||
onKeyDown={e => { if (e.key === 'Enter') { setPage(1); fetchTickets() } }} />
|
||
</div>
|
||
<Button variant="secondary" size="sm" onClick={() => { setPage(1); fetchTickets() }}>搜索</Button>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{(permissions.includes('*') || permissions.includes('tickets:export')) && (
|
||
<Button variant="secondary" size="sm" onClick={handleExport}><Download size={14} />导出</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{activeDeviceFilter && (
|
||
<div className="flex items-center gap-2 px-4 py-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 text-sm">
|
||
<ExternalLink size={14} className="text-blue-600 dark:text-blue-400" />
|
||
<span className="text-blue-700 dark:text-blue-300">
|
||
当前显示设备
|
||
{activeDeviceFilter.ip && <> <strong>{activeDeviceFilter.ip}</strong></>}
|
||
{activeDeviceFilter.name && <>({activeDeviceFilter.name})</>}
|
||
的所有工单,共 {pagination.total} 条
|
||
</span>
|
||
<button
|
||
onClick={() => {
|
||
setColumnFilterValues(prev => {
|
||
const n = { ...prev }
|
||
delete n.device_ip; delete n.device_name
|
||
return n
|
||
})
|
||
setPage(1)
|
||
}}
|
||
className="ml-2 text-xs text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1">
|
||
<X size={12} />清除筛选
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{hasActiveFilters && !activeDeviceFilter && (
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="text-xs text-slate-500">列筛选:</span>
|
||
{ticketNoFilter && (
|
||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400 text-xs">
|
||
<span className="font-medium">工单号</span>: {ticketNoFilter}
|
||
<button onClick={() => { setTicketNoFilter(''); setPage(1) }} className="ml-0.5 hover:text-blue-900 dark:hover:text-blue-200"><X size={12} /></button>
|
||
</span>
|
||
)}
|
||
{Object.entries(columnFilterValues).filter(([, v]) => v.length > 0).map(([col, vals]) => {
|
||
// 隐藏默认状态筛选标签
|
||
if (hideDefaultFilterChips && col === 'current_status' && defaultStatusFilter) {
|
||
const isDefaultOnly = vals.length === defaultStatusFilter.length && vals.every(v => defaultStatusFilter.includes(v))
|
||
if (isDefaultOnly) return null
|
||
}
|
||
const colDef = COLUMNS.find(c => c.key === col)
|
||
const displayVals = vals.map(v => statusMap[v]?.label || v)
|
||
return (
|
||
<span key={col} className="inline-flex items-center gap-1 px-2 py-1 rounded bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400 text-xs">
|
||
<span className="font-medium">{colDef?.label || col}</span>: {displayVals.join(', ')}
|
||
<button onClick={() => { setColumnFilterValues(prev => { const n = { ...prev }; delete n[col]; return n }); setPage(1) }} className="ml-0.5 hover:text-blue-900 dark:hover:text-blue-200"><X size={12} /></button>
|
||
</span>
|
||
)
|
||
})}
|
||
{Object.entries(columnTextFilter).filter(([, v]) => v.length > 0).map(([col, text]) => {
|
||
const colDef = COLUMNS.find(c => c.key === col)
|
||
return (
|
||
<span key={col} className="inline-flex items-center gap-1 px-2 py-1 rounded bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400 text-xs">
|
||
<span className="font-medium">{colDef?.label || col}</span>: {text}
|
||
<button onClick={() => { setColumnTextFilter(prev => { const n = { ...prev }; delete n[col]; return n }); setPage(1) }} className="ml-0.5 hover:text-blue-900 dark:hover:text-blue-200"><X size={12} /></button>
|
||
</span>
|
||
)
|
||
})}
|
||
{Object.entries(dateFilter).filter(([, v]) => v.start || v.end).map(([col, range]) => {
|
||
const colDef = COLUMNS.find(c => c.key === col)
|
||
return (
|
||
<span key={col} className="inline-flex items-center gap-1 px-2 py-1 rounded bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400 text-xs">
|
||
<span className="font-medium">{colDef?.label || col}</span>: {range.start || '*'} ~ {range.end || '*'}
|
||
<button onClick={() => { setDateFilter(prev => { const n = { ...prev }; delete n[col]; return n }); setPage(1) }} className="ml-0.5 hover:text-blue-900 dark:hover:text-blue-200"><X size={12} /></button>
|
||
</span>
|
||
)
|
||
})}
|
||
<button onClick={() => { setColumnFilterValues({}); setColumnTextFilter({}); setDateFilter({}); setTicketNoFilter(''); setPage(1) }} className="text-xs text-slate-400 hover:text-slate-600 dark:hover:text-slate-300">清除全部</button>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex items-center justify-end mb-2">
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="text-xs text-slate-500 dark:text-slate-400">每页</span>
|
||
<div className="flex rounded-lg border border-slate-300 dark:border-slate-600 overflow-hidden">
|
||
{[20, 50, 100].map(s => (
|
||
<button key={s} onClick={() => { setPageSize(s); setPage(1) }}
|
||
className={`px-3 py-1 text-xs font-medium transition-colors ${pageSize === s ? 'bg-blue-600 text-white' : 'bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-700'}`}>{s}</button>
|
||
))}
|
||
</div>
|
||
<span className="text-xs text-slate-500 dark:text-slate-400">行</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="overflow-x-auto rounded-lg border border-slate-200 dark:border-slate-700">
|
||
<table className="w-full text-sm" style={{ tableLayout: hasResized ? 'fixed' : 'auto' }}>
|
||
<thead className="bg-slate-50 dark:bg-slate-800">
|
||
<tr>
|
||
<th className="px-4 py-3 w-10">
|
||
<input
|
||
type="checkbox"
|
||
className="w-4 h-4 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-blue-500 cursor-pointer"
|
||
checked={tickets.length > 0 && tickets.every(t => selectedIds.has(t.id))}
|
||
onChange={e => {
|
||
if (e.target.checked) setSelectedIds(prev => { const n = new Set(prev); tickets.forEach(t => n.add(t.id)); return n })
|
||
else setSelectedIds(prev => { const n = new Set(prev); tickets.forEach(t => n.delete(t.id)); return n })
|
||
}}
|
||
/>
|
||
</th>
|
||
{COLUMNS.map(col => (
|
||
<th key={col.key} data-col-key={col.key} className="px-4 py-3 text-center relative" style={colWidths[col.key] ? { width: colWidths[col.key] } : undefined}>
|
||
<div className="relative flex flex-col">
|
||
<div className="flex items-center justify-center gap-1">
|
||
{col.filterable && (
|
||
<button
|
||
onClick={() => setTableMode(tableMode === 'sort' ? 'filter' : 'sort')}
|
||
className="p-0.5 rounded transition-colors text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700 cursor-pointer"
|
||
title={tableMode === 'sort' ? '切换到筛选' : '切换到排序'}>
|
||
<ArrowUpDown size={12} />
|
||
</button>
|
||
)}
|
||
<button
|
||
className={`flex items-center justify-center gap-0.5 font-medium cursor-pointer hover:text-slate-900 dark:hover:text-slate-100 ${openFilterColumn === col.key ? 'text-blue-600 dark:text-blue-400' : 'text-slate-600 dark:text-slate-300'}`}
|
||
onClick={() => handleColumnClick(col.key, col.sortable, col.filterable)}>
|
||
{col.label}
|
||
<SortIcon colKey={col.key} colDef={col} />
|
||
<FilterIndicator colKey={col.key} />
|
||
</button>
|
||
</div>
|
||
{openFilterColumn === col.key && (
|
||
<div ref={filterDropRef}>
|
||
<div className="absolute top-full left-0 z-50 mt-1 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 shadow-xl w-fit min-w-48 max-w-80">
|
||
{col.filterType === 'text' ? (
|
||
<div className="p-2 border-b border-slate-100 dark:border-slate-700">
|
||
<input
|
||
autoFocus
|
||
type="text"
|
||
value={columnTextFilter[col.key] || ''}
|
||
onChange={e => setColumnTextFilter(prev => ({ ...prev, [col.key]: e.target.value }))}
|
||
onKeyDown={e => { if (e.key === 'Enter') { setPage(1); setOpenFilterColumn(null) } }}
|
||
placeholder={`输入${col.label}...`}
|
||
className="w-full px-2 py-1 rounded border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-xs text-slate-900 dark:text-slate-100 placeholder:text-slate-400 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||
/>
|
||
</div>
|
||
) : col.filterType === 'date' ? (
|
||
<div className="p-3">
|
||
<p className="text-xs text-slate-500 mb-2">派单时间范围</p>
|
||
<div className="flex gap-2">
|
||
<div className="flex-1">
|
||
<label className="text-xs text-slate-400">开始</label>
|
||
<input type="date" autoFocus value={dateFilter[col.key]?.start || ''}
|
||
onChange={e => setDateFilter(prev => ({ ...prev, [col.key]: { ...(prev[col.key] || { start: '', end: '' }), start: e.target.value } }))}
|
||
className="w-full px-2 py-1 rounded border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-xs text-slate-900 dark:text-slate-100 mt-0.5" />
|
||
</div>
|
||
<div className="flex-1">
|
||
<label className="text-xs text-slate-400">结束</label>
|
||
<input type="date" value={dateFilter[col.key]?.end || ''}
|
||
onChange={e => setDateFilter(prev => ({ ...prev, [col.key]: { ...(prev[col.key] || { start: '', end: '' }), end: e.target.value } }))}
|
||
className="w-full px-2 py-1 rounded border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-xs text-slate-900 dark:text-slate-100 mt-0.5" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="max-h-56 overflow-y-auto">
|
||
{(fieldOptions[col.key] || []).length === 0 && <p className="px-3 py-3 text-xs text-slate-400 text-center">无可用选项</p>}
|
||
{(fieldOptions[col.key] || []).map(opt => {
|
||
const checked = (columnFilterValues[col.key] || []).includes(opt)
|
||
const label = statusMap[opt]?.label || opt
|
||
return (
|
||
<button key={opt} onClick={() => { toggleFilterValue(col.key, opt); setPage(1) }}
|
||
className={`w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left transition-colors ${checked ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300' : 'text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700'}`}>
|
||
<span className={`w-4 h-4 rounded border flex items-center justify-center flex-shrink-0 ${checked ? 'bg-blue-600 border-blue-600' : 'border-slate-300 dark:border-slate-600'}`}>
|
||
{checked && <Check size={10} className="text-white" />}
|
||
</span>
|
||
<span className="truncate">{label || '(空)'}</span>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
<div className="px-2 py-1.5 border-t border-slate-100 dark:border-slate-700 flex items-center justify-between">
|
||
<span className="text-[10px] text-slate-400">
|
||
{(columnFilterValues[col.key] || []).length > 0
|
||
? `已选 ${(columnFilterValues[col.key] || []).length} 项`
|
||
: `${(fieldOptions[col.key] || []).length} 个值`}
|
||
</span>
|
||
<button onClick={() => { clearColumnFilter(col.key); setOpenFilterColumn(null) }}
|
||
className="text-[10px] text-blue-600 dark:text-blue-400 hover:underline">清除</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div
|
||
className="absolute top-1/2 right-0 -translate-y-1/2 h-4 w-2 cursor-col-resize z-10 flex items-center justify-center group"
|
||
onMouseDown={(e) => onResizeStart(col.key, e)}
|
||
>
|
||
<div className="w-px h-full bg-slate-300 dark:bg-slate-600 group-hover:bg-blue-400 transition-colors" />
|
||
</div>
|
||
</th>
|
||
))}
|
||
{showSlaColumn && <th className="px-4 py-3 text-center font-medium text-slate-600 dark:text-slate-300">超时时间</th>}
|
||
{showSlaColumn && <th className="px-4 py-3 text-center font-medium text-slate-600 dark:text-slate-300">SLA 倒计时</th>}
|
||
{showActions && <th className="px-4 py-3 text-left font-medium text-slate-600 dark:text-slate-300">操作</th>}
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-200 dark:divide-slate-700">
|
||
{loading ? (
|
||
<tr><td colSpan={COLUMNS.length + (showSlaColumn ? 4 : 2)} className="px-4 py-12 text-center text-slate-500 dark:text-slate-400">加载中...</td></tr>
|
||
) : tickets.length === 0 ? (
|
||
<tr><td colSpan={COLUMNS.length + (showSlaColumn ? 4 : 2)} className="px-4 py-12 text-center text-slate-500 dark:text-slate-400">暂无工单数据</td></tr>
|
||
) : tickets.map(t => (
|
||
<tr key={t.id} className={`hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors ${selectedIds.has(t.id) ? 'bg-blue-50 dark:bg-blue-900/10' : ''}`}>
|
||
<td className="px-4 py-3">
|
||
<input
|
||
type="checkbox"
|
||
className="w-4 h-4 rounded border-slate-300 dark:border-slate-600 text-blue-600 focus:ring-blue-500 cursor-pointer"
|
||
checked={selectedIds.has(t.id)}
|
||
onChange={e => {
|
||
setSelectedIds(prev => {
|
||
const n = new Set(prev)
|
||
if (e.target.checked) n.add(t.id)
|
||
else n.delete(t.id)
|
||
return n
|
||
})
|
||
}}
|
||
/>
|
||
</td>
|
||
<td className="px-4 py-3 font-medium"><Link href={`/tickets/${t.id}`} className="text-blue-600 dark:text-blue-400 hover:underline">{t.id}</Link></td>
|
||
<td className="px-4 py-3">
|
||
{t.device_ip ? (
|
||
<Link href={`${pathname}?device_ip=${encodeURIComponent(t.device_ip)}`} className="text-blue-600 dark:text-blue-400 hover:underline">{t.device_ip}</Link>
|
||
) : '-'}
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
{t.device_name ? (
|
||
<Link href={`${pathname}?device_name=${encodeURIComponent(t.device_name)}`} className="text-blue-600 dark:text-blue-400 hover:underline">{t.device_name}</Link>
|
||
) : '-'}
|
||
</td>
|
||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300 max-w-xs truncate">{t.content || '-'}</td>
|
||
<td className="px-4 py-3"><Badge variant={statusMap[t.current_status]?.variant || 'default'}>{statusMap[t.current_status]?.label || t.current_status}</Badge></td>
|
||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300">{t.fault_category || '-'}</td>
|
||
<td className="px-4 py-3 text-slate-500 dark:text-slate-400 text-sm">{t.assign_time ? new Date(t.assign_time).toLocaleString('zh-CN') : '-'}</td>
|
||
{showSlaColumn && (
|
||
<td className="px-4 py-3 text-sm text-slate-500 dark:text-slate-400">
|
||
{t.assign_time && t.counted_in_sla !== 0
|
||
? getTier1Deadline(t.assign_time).toLocaleString('zh-CN', { hourCycle: 'h23' })
|
||
: '-'}
|
||
</td>
|
||
)}
|
||
{showSlaColumn && (
|
||
<td className="px-4 py-3">
|
||
<SlaCountdown assignTime={t.assign_time || ''} countedInSla={t.counted_in_sla} />
|
||
</td>
|
||
)}
|
||
{showActions && (
|
||
<td className="px-4 py-3">
|
||
<div className="flex items-center gap-1">
|
||
<Link href={`/tickets/${t.id}`}><Button variant="ghost" size="sm"><Eye size={14} /></Button></Link>
|
||
<Link href={`/tickets/${t.id}/edit`}><Button variant="ghost" size="sm"><Pencil size={14} /></Button></Link>
|
||
<Button variant="ghost" size="sm" onClick={() => handleDelete(t.id)}><Trash2 size={14} className="text-red-500" /></Button>
|
||
</div>
|
||
</td>
|
||
)}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<Pagination page={pagination.page} totalPages={pagination.totalPages} onPageChange={p => setPage(p)} />
|
||
|
||
{showBatchEdit && (
|
||
<BatchEditModal
|
||
selected={Array.from(selectedIds)}
|
||
onClose={() => setShowBatchEdit(false)}
|
||
onSuccess={() => { setSelectedIds(new Set()); fetchTickets() }}
|
||
/>
|
||
)}
|
||
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function TicketList({ onPaginationChange, defaultStatusFilter, showSlaColumn, showActions, hideDefaultFilterChips }: TicketListInnerProps) {
|
||
return (
|
||
<Suspense fallback={<div className="text-center py-12 text-slate-500">加载中...</div>}>
|
||
<TicketListInner onPaginationChange={onPaginationChange} defaultStatusFilter={defaultStatusFilter} showSlaColumn={showSlaColumn} showActions={showActions} hideDefaultFilterChips={hideDefaultFilterChips} />
|
||
</Suspense>
|
||
)
|
||
}
|