issue-ai/src/components/tickets/TicketList.tsx

726 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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">&times;</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>
)
}