'use client' import { useState, useEffect, useCallback, useRef } from 'react' import { useRouter } from 'next/navigation' import Link from 'next/link' import Pagination from '@/components/ui/Pagination' import Badge from '@/components/ui/Badge' import Button from '@/components/ui/Button' import Modal from '@/components/ui/Modal' import type { Asset, PaginatedResult } from '@/types' import { Plus, Upload, Download, Search, ChevronUp, ChevronDown, ChevronsUpDown, Eye, Edit, Trash2, Filter, ArrowUpDown, Check, } from 'lucide-react' const statusColor: Record = { '腾讯使用': 'green', '图灵使用': 'blue', '闲置': 'yellow', '备用': 'yellow', '维修中': 'red', '已下线': 'gray', } const COLUMNS = [ { key: 'node_name', label: '节点名称', sortable: true }, { key: 'business_ip', label: '业务IP', sortable: true }, { key: 'hdm_ip', label: 'HDM IP', sortable: true }, { key: 'device_type', label: '设备类型', sortable: true }, { key: 'manufacturer', label: '厂商', sortable: true }, { key: 'device_model', label: '设备型号', sortable: true }, { key: 'serial_number', label: '序列号', sortable: true }, { key: 'status', label: '状态', sortable: true }, ] export default function AssetsPage() { const router = useRouter() const [result, setResult] = useState>({ data: [], total: 0, page: 1, pageSize: 20, totalPages: 0 }) const [loading, setLoading] = useState(true) const [search, setSearch] = useState('') const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(20) const [sortKey, setSortKey] = useState('node_name') const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc') const [selectedIds, setSelectedIds] = useState>(new Set()) const [deleteTarget, setDeleteTarget] = useState(null) const [deleting, setDeleting] = useState(false) const [exportModalOpen, setExportModalOpen] = useState(false) const [exportMode, setExportMode] = useState<'all' | 'selected'>('all') const [permissions, setPermissions] = useState([]) // 列头筛选相关状态 const [tableMode, setTableMode] = useState<'sort' | 'filter'>('sort') const [columnFilterValues, setColumnFilterValues] = useState>({}) const [openFilterColumn, setOpenFilterColumn] = useState(null) const [filterOptions, setFilterOptions] = useState>({}) const [filterLoading, setFilterLoading] = useState>({}) const [filterSearch, setFilterSearch] = useState>({}) const filterDropRef = useRef(null) // 列宽拖拽调整 const [colWidths, setColWidths] = useState>({}) const [resizingCol, setResizingCol] = useState(null) const resizeRef = useRef<{ col: string; startX: number; startWidth: number } | null>(null) const [hasResized, setHasResized] = useState(false) 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]) // 关闭筛选下拉 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) }, []) // 加载某列的唯一值 const loadColumnOptions = useCallback((field: string) => { if (filterOptions[field]) return setFilterLoading(prev => ({ ...prev, [field]: true })) fetch(`/api/assets/field-values?field=${field}&q=`) .then(r => r.json()) .then(d => { if (d.values) setFilterOptions(prev => ({ ...prev, [field]: d.values })) }) .catch(() => {}) .finally(() => setFilterLoading(prev => ({ ...prev, [field]: false }))) }, [filterOptions]) // 列头点击:排序或打开筛选 function handleColumnClick(key: string, sortable: 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 (openFilterColumn === key) { setOpenFilterColumn(null) } else { setOpenFilterColumn(key) loadColumnOptions(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] const updated = { ...prev, [column]: next } if (next.length === 0) delete updated[column] return updated }) setPage(1) } // 清空某列筛选 function clearColumnFilter(column: string) { setColumnFilterValues(prev => { const next = { ...prev } delete next[column] return next }) setPage(1) } // 排序图标(可点击触发排序/筛选) function SortIcon({ colKey, sortable }: { colKey: string; sortable: boolean }) { if (!sortable) return null const icon = tableMode === 'filter' ? : sortKey !== colKey ? : sortOrder === 'asc' ? : return ( { e.stopPropagation(); handleColumnClick(colKey, sortable) }} 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 vals = columnFilterValues[colKey] if (!vals || vals.length === 0) return null return ( {vals.length} ) } // 筛选下拉内容 function FilterDropdown({ column }: { column: string }) { const options = filterOptions[column] || [] const selected = columnFilterValues[column] || [] const searchVal = filterSearch[column] || '' const loading = filterLoading[column] const filtered = searchVal ? options.filter(o => o.toLowerCase().includes(searchVal.toLowerCase())) : options const hasMore = options.length >= 50 return (
{/* 搜索框 */}
setFilterSearch(prev => ({ ...prev, [column]: e.target.value }))} placeholder="搜索..." 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" />
{/* 选项列表 */}
{loading ? (
加载中...
) : filtered.length === 0 ? (
无匹配结果
) : ( filtered.map(opt => { const checked = selected.includes(opt) return ( ) }) )}
{/* 底部操作 */}
{selected.length > 0 ? `已选 ${selected.length} 项${hasMore ? '(最多50项)' : ''}` : `${options.length} 个值`} {selected.length > 0 && ( )}
) } // 切换模式:清除列筛选 function switchMode(mode: 'sort' | 'filter') { setTableMode(mode) setOpenFilterColumn(null) if (mode === 'sort') { // 排序模式不清除筛选状态,保留 columnFilterValues } } useEffect(() => { fetch('/api/auth/me').then(r => r.json()).then(d => { if (d.user?.permissions) setPermissions(d.user.permissions) }).catch(() => {}) }, []) const fetchAssets = useCallback(async () => { setLoading(true) try { const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize), sortKey, sortOrder, }) if (search) params.set('search', search) for (const [field, values] of Object.entries(columnFilterValues)) { for (const v of values) { params.append(`filter_${field}`, v) } } const res = await fetch(`/api/assets?${params}`) if (res.status === 401) { router.push('/login'); return } const data = await res.json() setResult(data) } catch { setResult({ data: [], total: 0, page: 1, pageSize: 20, totalPages: 0 }) } finally { setLoading(false) } }, [page, pageSize, sortKey, sortOrder, search, columnFilterValues, router]) useEffect(() => { fetchAssets() }, [fetchAssets]) function handleSearch(q: string) { setSearch(q); setPage(1) } function handlePageChange(p: number) { setPage(p); setSelectedIds(new Set()) } function handlePageSizeChange(s: number) { setPageSize(s); setPage(1) } function toggleSelect(id: number) { setSelectedIds(prev => { const next = new Set(prev) if (next.has(id)) next.delete(id) else next.add(id) return next }) } function toggleAll() { const allIds = result.data.map(a => a.id) if (selectedIds.size === allIds.length) setSelectedIds(new Set()) else setSelectedIds(new Set(allIds)) } async function handleDelete(id: number) { setDeleting(true) try { const res = await fetch(`/api/assets/${id}`, { method: 'DELETE' }) if (res.ok) { setDeleteTarget(null); fetchAssets() } } finally { setDeleting(false) } } function handleExportConfirm() { const params = new URLSearchParams() if (exportMode === 'selected' && selectedIds.size > 0) { params.set('ids', [...selectedIds].join(',')) } else { if (search) params.set('search', search) for (const [field, values] of Object.entries(columnFilterValues)) { for (const v of values) { params.append(`filter_${field}`, v) } } } setExportModalOpen(false) window.open(`/api/assets/export?${params}`, '_blank') } const { data, total, totalPages } = result const allSelected = data.length > 0 && data.every(a => selectedIds.has(a.id)) // 是否有任何列筛选激活 const hasActiveColumnFilters = Object.values(columnFilterValues).some(v => v.length > 0) const canExportAll = permissions.includes('*') || permissions.includes('assets:export:all') const canExportSelected = permissions.includes('*') || permissions.includes('assets:export:selected') return (

设备管理

{total} 台设备 {selectedIds.size > 0 && ,已选中 {selectedIds.size}}

{selectedIds.size > 0 && permissions.includes('assets:update') && ( )} {permissions.includes('assets:import') && ( )} {permissions.includes('assets:import') && ( )} {canExportSelected && selectedIds.size > 0 && ( )} {canExportAll && ( )}
{/* 搜索 + 高级筛选 */}
setSearch(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSearch(search)} placeholder="快速搜索..." 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 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
{/* 活跃列筛选标签 */} {hasActiveColumnFilters && (
列筛选: {Object.entries(columnFilterValues).filter(([, v]) => v.length > 0).map(([col, vals]) => { const colDef = COLUMNS.find(c => c.key === col) return ( {colDef?.label || col} : {vals.join(', ')} ) })}
)} {/* 工具栏:每页行数 */}
每页
{[20, 50, 100].map(s => ( ))}
{/* 表格 */}
{/* 模式切换 + 全选 */} {COLUMNS.map(col => ( ))} {loading ? ( ) : data.length === 0 ? ( ) : data.map(row => ( ))}
{/* 左侧:模式切换按钮 */} {/* 中间:列标题 */} {/* 右侧:排序/筛选图标 */}
{openFilterColumn === col.key && (
)}
{/* 右侧:拖拽手柄(宽点击区 + 细可见线) */}
onResizeStart(col.key, e)} >
操作
加载中...
暂无数据
toggleSelect(row.id)} className="rounded border-slate-300 dark:border-slate-600" /> {row.node_name || '-'} {row.business_ip || '-'} {row.hdm_ip ? {row.hdm_ip} : '-'} {row.device_type} {row.manufacturer || '-'} {row.device_model || '-'} {row.serial_number || '-'} {row.status ?? '-'}
{/* 分页 */}
{/* 删除确认 */} setDeleteTarget(null)} title="确认删除">

确定要删除这台设备吗?此操作不可撤销。

{/* 导出确认 */} setExportModalOpen(false)} title={exportMode === 'selected' ? '导出选中设备' : '导出全部设备'}> {exportMode === 'selected' ? (

即将导出已选中的 {selectedIds.size} 台设备的资产数据。

) : ( <>

即将导出 {total} 台设备的资产数据。

{(Object.keys(columnFilterValues).length > 0 || search) && (

当前筛选条件将一并应用,仅导出匹配的数据。

)} )}
) }