import { useEffect, useState, useMemo, useCallback } from 'react'
import {
DndContext,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
useDraggable,
type DragStartEvent,
type DragEndEvent,
} from '@dnd-kit/core'
import { CSS } from '@dnd-kit/utilities'
import { Button, Badge } from '@/components/atoms'
import { DataTable, type DataColumn } from '@/components/common/DataTable'
import { Icon } from '@/components/common/Icon'
import { GroupPanel } from '@/components/common/GroupPanel'
import {
getDevices,
getDeviceGroups,
updateDeviceDetail,
type Device,
type DeviceGroup,
} from '@/lib/api'
import { showToast } from '@/stores/toastStore'
export function Devices() {
const [devices, setDevices] = useState<Device[]>([])
const [groups, setGroups] = useState<DeviceGroup[]>([])
const [loading, setLoading] = useState(true)
const [activeDevice, setActiveDevice] = useState<Device | null>(null)
// 指针传感器:5px 移动阈值防止误触
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
)
const fetchData = useCallback(async () => {
try {
const [devs, grps] = await Promise.all([getDevices(), getDeviceGroups()])
setDevices(devs)
setGroups(grps)
} catch { /* 静默 */ }
setLoading(false)
}, [])
useEffect(() => {
fetchData()
const t = setInterval(fetchData, 10000)
return () => clearInterval(t)
}, [fetchData])
// 分组名称映射
const groupNameMap = useMemo(() => {
const map = new Map<number, string>()
for (const g of groups) map.set(g.id, g.name)
return map
}, [groups])
// 拖拽开始
const handleDragStart = useCallback((event: DragStartEvent) => {
const deviceId = event.active.data.current?.deviceId as number | undefined
if (deviceId != null) {
setActiveDevice(devices.find((d) => d.id === deviceId) ?? null)
}
}, [devices])
// 拖拽结束:移动设备到目标分组
const handleDragEnd = useCallback(async (event: DragEndEvent) => {
setActiveDevice(null)
const { active, over } = event
if (!over) return
const deviceId = active.data.current?.deviceId as number | undefined
const groupId = over.data.current?.groupId as number | undefined
const groupName = over.data.current?.groupName as string | undefined
if (deviceId == null || groupId == null) return
// 未分组用 0 表示,API 不传 groupId 即取消分组
try {
await updateDeviceDetail(deviceId, { groupId: groupId > 0 ? groupId : 0 })
showToast('success', `已移至 ${groupName ?? '未分组'}`)
await fetchData()
} catch {
showToast('error', '移动失败')
}
}, [fetchData])
const columns: DataColumn<Device>[] = useMemo(() => [
{
key: 'drag',
label: '',
render: (d) => <DragHandle device={d} />,
},
{
key: 'mac',
label: 'MAC',
sortable: true,
className: 'font-mono text-xs',
render: (d) => d.mac || '-',
},
{
key: 'ip',
label: 'IP',
sortable: true,
render: (d) => d.ip || '-',
},
{
key: 'hostName',
label: '主机名',
sortable: true,
render: (d) => d.hostName || '-',
},
{
key: 'name',
label: '别名',
sortable: true,
render: (d) => d.name || '-',
},
{
key: 'group',
label: '分组',
sortable: true,
render: (d) => (
<span className="text-xs text-[var(--color-text-tertiary)]">
{d.groupID != null && d.groupID > 0 ? (groupNameMap.get(d.groupID) ?? '-') : '未分组'}
</span>
),
},
{
key: 'staticIP',
label: '静态IP',
render: (d) => (
<input
type="checkbox"
checked={d.staticIP}
onChange={(e) => {
updateDeviceDetail(d.id, { staticIp: e.target.checked })
.then(() => fetchData())
.catch(() => showToast('error', '操作失败'))
}}
className="w-4 h-4 accent-[var(--color-brand-500)] cursor-pointer"
title="锁定此设备IP"
/>
),
},
{
key: 'online',
label: '状态',
sortable: true,
render: (d) => (
<div className="flex items-center gap-1.5">
<Badge variant={d.online ? 'success' : 'default'}>
{d.online ? '在线' : '离线'}
</Badge>
{!d.enable && <Badge variant="danger">已拉黑</Badge>}
</div>
),
},
], [groupNameMap, fetchData])
return (
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<div className="flex h-full">
{/* 左侧分组面板 */}
<aside className="w-56 shrink-0 border-r border-[var(--color-border-subtle)] bg-[var(--color-surface-1)] p-3 overflow-y-auto">
<GroupPanel groups={groups} devices={devices} />
</aside>
{/* 右侧设备表格 */}
<main className="flex-1 min-w-0 p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-lg font-bold text-[var(--color-text-primary)]">终端管理</h1>
<div className="flex items-center gap-2">
<span className="text-xs text-[var(--color-text-tertiary)]">
拖拽设备前的 <Icon name="drag_indicator" size="xs" className="inline align-middle" /> 图标到左侧分组即可移动
</span>
<Button variant="soft" size="sm" onClick={fetchData}>
刷新
</Button>
</div>
</div>
<DataTable
data={devices}
columns={columns}
rowKey={(d) => d.id}
searchFields={['mac', 'ip', 'hostName', 'name']}
searchPlaceholder="搜索 MAC / IP / 主机名 / 别名…"
loading={loading}
emptyText="暂无设备数据(需在 Debian 环境运行)"
/>
</main>
</div>
{/* 拖拽覆盖层 */}
<DragOverlay>
{activeDevice ? (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[var(--color-surface-0)] border border-[var(--color-brand-400)] shadow-lg text-sm">
<Icon name="drag_indicator" size="sm" className="text-[var(--color-brand-400)]" />
<span className="font-mono text-xs">{activeDevice.mac}</span>
<span className="text-[var(--color-text-tertiary)]">{activeDevice.name || activeDevice.hostName || activeDevice.ip}</span>
</div>
) : null}
</DragOverlay>
</DndContext>
)
}
// ── 可拖拽的拖拽手柄 ──
function DragHandle({ device }: { device: Device }) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: `device-${device.id}`,
data: { deviceId: device.id, mac: device.mac, name: device.name },
})
const style = transform ? { transform: CSS.Translate.toString(transform) } : undefined
return (
<button
ref={setNodeRef}
{...listeners}
{...attributes}
style={style}
className={`
flex items-center justify-center w-6 h-6 rounded cursor-grab active:cursor-grabbing
transition-opacity hover:bg-[var(--color-surface-2)]
${isDragging ? 'opacity-50' : 'opacity-100'}
`}
title="拖拽到左侧分组移动设备"
>
<Icon name="drag_indicator" size="sm" className="text-[var(--color-text-tertiary)]" />
</button>
)
}
|