616 lines
28 KiB
TypeScript
616 lines
28 KiB
TypeScript
'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<string, 'blue' | 'green' | 'yellow' | 'red' | 'gray'> = {
|
||
'腾讯使用': '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<PaginatedResult<Asset>>({ 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<Set<number>>(new Set())
|
||
const [deleteTarget, setDeleteTarget] = useState<number | null>(null)
|
||
const [deleting, setDeleting] = useState(false)
|
||
const [exportModalOpen, setExportModalOpen] = useState(false)
|
||
const [exportMode, setExportMode] = useState<'all' | 'selected'>('all')
|
||
const [permissions, setPermissions] = useState<string[]>([])
|
||
|
||
// 列头筛选相关状态
|
||
const [tableMode, setTableMode] = useState<'sort' | 'filter'>('sort')
|
||
const [columnFilterValues, setColumnFilterValues] = useState<Record<string, string[]>>({})
|
||
const [openFilterColumn, setOpenFilterColumn] = useState<string | null>(null)
|
||
const [filterOptions, setFilterOptions] = useState<Record<string, string[]>>({})
|
||
const [filterLoading, setFilterLoading] = useState<Record<string, boolean>>({})
|
||
const [filterSearch, setFilterSearch] = useState<Record<string, string>>({})
|
||
const filterDropRef = useRef<HTMLDivElement>(null)
|
||
|
||
// 列宽拖拽调整
|
||
const [colWidths, setColWidths] = useState<Record<string, number>>({})
|
||
const [resizingCol, setResizingCol] = useState<string | null>(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<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])
|
||
|
||
// 关闭筛选下拉
|
||
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'
|
||
? <Filter size={12} />
|
||
: sortKey !== colKey
|
||
? <ChevronsUpDown size={13} />
|
||
: sortOrder === 'asc'
|
||
? <ChevronUp size={13} className="text-blue-500" />
|
||
: <ChevronDown size={13} className="text-blue-500" />
|
||
return (
|
||
<span
|
||
onClick={(e) => { 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}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
// 列头是否显示筛选图标(已选值时)
|
||
function FilterIndicator({ colKey }: { colKey: string }) {
|
||
const vals = columnFilterValues[colKey]
|
||
if (!vals || vals.length === 0) return null
|
||
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">
|
||
{vals.length}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
// 筛选下拉内容
|
||
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 (
|
||
<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">
|
||
{/* 搜索框 */}
|
||
<div className="p-2 border-b border-slate-100 dark:border-slate-700">
|
||
<input
|
||
type="text"
|
||
value={searchVal}
|
||
onChange={e => 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"
|
||
/>
|
||
</div>
|
||
|
||
{/* 选项列表 */}
|
||
<div className="max-h-56 overflow-y-auto">
|
||
{loading ? (
|
||
<div className="px-3 py-4 text-xs text-slate-400 text-center">加载中...</div>
|
||
) : filtered.length === 0 ? (
|
||
<div className="px-3 py-4 text-xs text-slate-400 text-center">无匹配结果</div>
|
||
) : (
|
||
filtered.map(opt => {
|
||
const checked = selected.includes(opt)
|
||
return (
|
||
<button
|
||
key={opt}
|
||
onClick={() => toggleFilterValue(column, opt)}
|
||
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">{opt || '(空)'}</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">
|
||
{selected.length > 0 ? `已选 ${selected.length} 项${hasMore ? '(最多50项)' : ''}` : `${options.length} 个值`}
|
||
</span>
|
||
{selected.length > 0 && (
|
||
<button
|
||
onClick={() => { clearColumnFilter(column); setOpenFilterColumn(null) }}
|
||
className="text-[10px] text-blue-600 dark:text-blue-400 hover:underline"
|
||
>
|
||
清除
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 切换模式:清除列筛选
|
||
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 (
|
||
<div>
|
||
<div className="flex items-center justify-between mb-6">
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">设备管理</h1>
|
||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||
共 <span className="font-medium">{total}</span> 台设备
|
||
{selectedIds.size > 0 && <span className="ml-2">,已选中 <span className="font-medium text-blue-600">{selectedIds.size}</span> 台</span>}
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{selectedIds.size > 0 && permissions.includes('assets:update') && (
|
||
<Link href={`/assets/batch-edit?ids=${[...selectedIds].join(',')}`}>
|
||
<Button variant="secondary" size="sm">批量编辑 {selectedIds.size} 台</Button>
|
||
</Link>
|
||
)}
|
||
{permissions.includes('assets:import') && (
|
||
<Link href="/assets/import"><Button variant="secondary" size="sm"><Upload size={14} />导入</Button></Link>
|
||
)}
|
||
{permissions.includes('assets:import') && (
|
||
<a href="/api/assets/template" download><Button variant="secondary" size="sm"><Download size={14} />模板</Button></a>
|
||
)}
|
||
{canExportSelected && selectedIds.size > 0 && (
|
||
<Button variant="secondary" size="sm" onClick={() => { setExportMode('selected'); setExportModalOpen(true) }}><Download size={14} />导出选中({selectedIds.size})</Button>
|
||
)}
|
||
{canExportAll && (
|
||
<Button variant="secondary" size="sm" onClick={() => { setExportMode('all'); setExportModalOpen(true) }}><Download size={14} />{selectedIds.size > 0 ? '导出全部' : '导出'}</Button>
|
||
)}
|
||
<Link href="/assets/new"><Button size="sm"><Plus size={14} />新增设备</Button></Link>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 搜索 + 高级筛选 */}
|
||
<div className="flex items-start gap-3 mb-4">
|
||
<div className="flex-1 flex items-center gap-2">
|
||
<div className="relative flex-1 max-w-sm">
|
||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||
<input
|
||
type="text"
|
||
value={search}
|
||
onChange={e => 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"
|
||
/>
|
||
</div>
|
||
<Button variant="secondary" size="sm" onClick={() => handleSearch(search)}>搜索</Button>
|
||
</div>
|
||
<Link href="/assets/advanced-search">
|
||
<Button variant="secondary" size="sm">
|
||
<Search size={14} />高级查询
|
||
</Button>
|
||
</Link>
|
||
</div>
|
||
|
||
{/* 活跃列筛选标签 */}
|
||
{hasActiveColumnFilters && (
|
||
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||
<span className="text-xs text-slate-500 dark:text-slate-400">列筛选:</span>
|
||
{Object.entries(columnFilterValues).filter(([, v]) => v.length > 0).map(([col, vals]) => {
|
||
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>
|
||
<span>: {vals.join(', ')}</span>
|
||
<button onClick={() => clearColumnFilter(col)} className="ml-0.5 hover:text-blue-900 dark:hover:text-blue-200">×</button>
|
||
</span>
|
||
)
|
||
})}
|
||
<button onClick={() => { setColumnFilterValues({}); 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={() => handlePageSizeChange(s)}
|
||
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 text-center">
|
||
<input type="checkbox" checked={allSelected} onChange={toggleAll}
|
||
className="rounded border-slate-300 dark:border-slate-600" />
|
||
</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-0.5">
|
||
{/* 左侧:模式切换按钮 */}
|
||
<button
|
||
onClick={() => switchMode(tableMode === 'sort' ? 'filter' : 'sort')}
|
||
className={`p-0.5 rounded transition-colors flex-shrink-0 ${
|
||
tableMode === 'sort' && col.sortable
|
||
? 'text-slate-400 hover:text-slate-600 dark:hover:text-slate-300'
|
||
: tableMode === 'filter'
|
||
? 'text-blue-500 bg-blue-50 dark:bg-blue-900/30'
|
||
: 'text-slate-300'
|
||
}`}
|
||
title={tableMode === 'sort' ? '切换到筛选模式' : '切换到排序模式'}
|
||
>
|
||
<ArrowUpDown size={12} />
|
||
</button>
|
||
{/* 中间:列标题 */}
|
||
<button
|
||
className={`font-medium min-w-0 break-all ${
|
||
col.sortable
|
||
? 'cursor-pointer hover:text-slate-900 dark:hover:text-slate-100'
|
||
: 'cursor-default'
|
||
} ${
|
||
openFilterColumn === col.key
|
||
? 'text-blue-600 dark:text-blue-400'
|
||
: tableMode === 'filter'
|
||
? 'text-blue-600 dark:text-blue-400 cursor-pointer'
|
||
: 'text-slate-600 dark:text-slate-300'
|
||
}`}
|
||
onClick={() => handleColumnClick(col.key, col.sortable)}
|
||
>
|
||
{col.label}
|
||
</button>
|
||
{/* 右侧:排序/筛选图标 */}
|
||
<SortIcon colKey={col.key} sortable={col.sortable} />
|
||
<FilterIndicator colKey={col.key} />
|
||
</div>
|
||
{openFilterColumn === col.key && (
|
||
<div ref={filterDropRef}>
|
||
<FilterDropdown column={col.key} />
|
||
</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>
|
||
))}
|
||
<th className="px-4 py-3 text-center font-medium text-slate-600 dark:text-slate-300" style={{ width: 120 }}>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-200 dark:divide-slate-700">
|
||
{loading ? (
|
||
<tr><td colSpan={COLUMNS.length + 2} className="px-4 py-12 text-center text-slate-500 dark:text-slate-400">加载中...</td></tr>
|
||
) : data.length === 0 ? (
|
||
<tr><td colSpan={COLUMNS.length + 2} className="px-4 py-12 text-center text-slate-500 dark:text-slate-400">暂无数据</td></tr>
|
||
) : data.map(row => (
|
||
<tr key={row.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/50 transition-colors">
|
||
<td className="px-4 py-3 text-center">
|
||
<input type="checkbox" checked={selectedIds.has(row.id)} onChange={() => toggleSelect(row.id)}
|
||
className="rounded border-slate-300 dark:border-slate-600" />
|
||
</td>
|
||
<td className="px-4 py-3 font-medium text-center break-all">
|
||
<Link href={`/assets/${row.id}`} className="text-blue-600 dark:text-blue-400 hover:underline">{row.node_name || '-'}</Link>
|
||
</td>
|
||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300 text-center break-all">{row.business_ip || '-'}</td>
|
||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300 text-center break-all">{row.hdm_ip ? <a href={`http://${row.hdm_ip}`} target="_blank" rel="noopener noreferrer" className="text-blue-600 dark:text-blue-400 hover:underline">{row.hdm_ip}</a> : '-'}</td>
|
||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300 text-center break-all">{row.device_type}</td>
|
||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300 text-center break-all">{row.manufacturer || '-'}</td>
|
||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300 text-center break-all">{row.device_model || '-'}</td>
|
||
<td className="px-4 py-3 text-slate-700 dark:text-slate-300 text-center break-all">{row.serial_number || '-'}</td>
|
||
<td className="px-4 py-3 text-center"><Badge color={statusColor[String(row.status ?? '')] || 'gray'}>{row.status ?? '-'}</Badge></td>
|
||
<td className="px-4 py-3 text-center">
|
||
<div className="flex items-center gap-1">
|
||
<Link href={`/assets/${row.id}`}
|
||
className="p-1.5 rounded-lg text-slate-500 hover:text-blue-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors">
|
||
<Eye size={16} />
|
||
</Link>
|
||
<Link href={`/assets/${row.id}/edit`}
|
||
className="p-1.5 rounded-lg text-slate-500 hover:text-green-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors">
|
||
<Edit size={16} />
|
||
</Link>
|
||
<button onClick={() => setDeleteTarget(row.id)}
|
||
className="p-1.5 rounded-lg text-slate-500 hover:text-red-500 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors">
|
||
<Trash2 size={16} />
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* 分页 */}
|
||
<div className="mt-4 flex justify-center">
|
||
<Pagination page={page} totalPages={totalPages} onPageChange={handlePageChange} />
|
||
</div>
|
||
|
||
{/* 删除确认 */}
|
||
<Modal open={deleteTarget !== null} onClose={() => setDeleteTarget(null)} title="确认删除">
|
||
<p className="text-slate-700 dark:text-slate-300">确定要删除这台设备吗?此操作不可撤销。</p>
|
||
<div className="flex justify-end gap-3 mt-4">
|
||
<Button variant="secondary" onClick={() => setDeleteTarget(null)}>取消</Button>
|
||
<Button variant="danger" loading={deleting} onClick={() => deleteTarget && handleDelete(deleteTarget)}>确认删除</Button>
|
||
</div>
|
||
</Modal>
|
||
|
||
{/* 导出确认 */}
|
||
<Modal open={exportModalOpen} onClose={() => setExportModalOpen(false)} title={exportMode === 'selected' ? '导出选中设备' : '导出全部设备'}>
|
||
{exportMode === 'selected' ? (
|
||
<p className="text-slate-700 dark:text-slate-300 mb-3">
|
||
即将导出已选中的 <span className="font-medium">{selectedIds.size}</span> 台设备的资产数据。
|
||
</p>
|
||
) : (
|
||
<>
|
||
<p className="text-slate-700 dark:text-slate-300 mb-3">
|
||
即将导出 <span className="font-medium">{total}</span> 台设备的资产数据。
|
||
</p>
|
||
{(Object.keys(columnFilterValues).length > 0 || search) && (
|
||
<p className="text-sm text-slate-500 dark:text-slate-400">当前筛选条件将一并应用,仅导出匹配的数据。</p>
|
||
)}
|
||
</>
|
||
)}
|
||
<div className="flex justify-end gap-3 mt-4">
|
||
<Button variant="secondary" onClick={() => setExportModalOpen(false)}>取消</Button>
|
||
<Button onClick={handleExportConfirm}><Download size={14} />确认导出</Button>
|
||
</div>
|
||
</Modal>
|
||
</div>
|
||
)
|
||
}
|