refactor: 枚举移入Models目录,命名空间更新为Rainbow.Entity.Models
大石头 authored at 2026-07-02 12:54:58
7.38 KiB
RainbowBridge
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>
  )
}