'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 = { 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(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 = { 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 (

批量编辑

已选中 {selected.length} 条工单

{batchField === 'fault_category' && ( )} {batchField === 'current_status' && (
{STATUS_OPTIONS.map(s => ( ))}
)} {batchField === 'counted_in_sla' && (
)} {error &&

{error}

}
) } 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([]) 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>(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(null) const [columnFilterValues, setColumnFilterValues] = useState>({}) const [columnTextFilter, setColumnTextFilter] = useState>({}) const [dateFilter, setDateFilter] = useState>({}) const [fieldOptions, setFieldOptions] = useState>({}) const [ticketNoFilter, setTicketNoFilter] = useState('') const [permissions, setPermissions] = useState([]) const filterDropRef = useRef(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>({}) const [resizingCol, setResizingCol] = useState(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 = {} 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' ? : !colDef.sortable ? null : sortKey !== colKey ? : sortOrder === 'asc' ? : if (!icon) return null return ( { 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} ) } 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 {count} } 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 (
setSearch(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') { setPage(1); fetchTickets() } }} />
{(permissions.includes('*') || permissions.includes('tickets:export')) && ( )}
{activeDeviceFilter && (
当前显示设备 {activeDeviceFilter.ip && <> {activeDeviceFilter.ip}} {activeDeviceFilter.name && <>({activeDeviceFilter.name})} 的所有工单,共 {pagination.total} 条
)} {hasActiveFilters && !activeDeviceFilter && (
列筛选: {ticketNoFilter && ( 工单号: {ticketNoFilter} )} {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 ( {colDef?.label || col}: {displayVals.join(', ')} ) })} {Object.entries(columnTextFilter).filter(([, v]) => v.length > 0).map(([col, text]) => { const colDef = COLUMNS.find(c => c.key === col) return ( {colDef?.label || col}: {text} ) })} {Object.entries(dateFilter).filter(([, v]) => v.start || v.end).map(([col, range]) => { const colDef = COLUMNS.find(c => c.key === col) return ( {colDef?.label || col}: {range.start || '*'} ~ {range.end || '*'} ) })}
)}
每页
{[20, 50, 100].map(s => ( ))}
{COLUMNS.map(col => ( ))} {showSlaColumn && } {showSlaColumn && } {showActions && } {loading ? ( ) : tickets.length === 0 ? ( ) : tickets.map(t => ( {showSlaColumn && ( )} {showSlaColumn && ( )} {showActions && ( )} ))}
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 }) }} />
{col.filterable && ( )}
{openFilterColumn === col.key && (
{col.filterType === 'text' ? (
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" />
) : col.filterType === 'date' ? (

派单时间范围

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" />
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" />
) : (
{(fieldOptions[col.key] || []).length === 0 &&

无可用选项

} {(fieldOptions[col.key] || []).map(opt => { const checked = (columnFilterValues[col.key] || []).includes(opt) const label = statusMap[opt]?.label || opt return ( ) })}
)}
{(columnFilterValues[col.key] || []).length > 0 ? `已选 ${(columnFilterValues[col.key] || []).length} 项` : `${(fieldOptions[col.key] || []).length} 个值`}
)}
onResizeStart(col.key, e)} >
超时时间SLA 倒计时操作
加载中...
暂无工单数据
{ setSelectedIds(prev => { const n = new Set(prev) if (e.target.checked) n.add(t.id) else n.delete(t.id) return n }) }} /> {t.id} {t.device_ip ? ( {t.device_ip} ) : '-'} {t.device_name ? ( {t.device_name} ) : '-'} {t.content || '-'} {statusMap[t.current_status]?.label || t.current_status} {t.fault_category || '-'} {t.assign_time ? new Date(t.assign_time).toLocaleString('zh-CN') : '-'} {t.assign_time && t.counted_in_sla !== 0 ? getTier1Deadline(t.assign_time).toLocaleString('zh-CN', { hourCycle: 'h23' }) : '-'}
setPage(p)} /> {showBatchEdit && ( setShowBatchEdit(false)} onSuccess={() => { setSelectedIds(new Set()); fetchTickets() }} /> )}
) } export default function TicketList({ onPaginationChange, defaultStatusFilter, showSlaColumn, showActions, hideDefaultFilterChips }: TicketListInnerProps) { return ( 加载中...
}> ) }