refactor: 枚举移入Models目录,命名空间更新为Rainbow.Entity.Models
大石头 authored at 2026-07-02 12:54:58
17.46 KiB
RainbowBridge
import { useEffect, useState, useCallback } from 'react'
import { Drawer } from '@/components/common/Drawer'
import { Badge } from '@/components/atoms'
import { Button } from '@/components/atoms'
import { Icon } from '@/components/common/Icon'
import {
  getDevice,
  getDeviceGroups,
  getMembers,
  updateDeviceDetail,
  blockDevice,
  unblockDevice,
  type Device,
  type DeviceGroup,
  type Member,
} from '@/lib/api'
import { showToast } from '@/stores/toastStore'

// ── 设备种类映射 ──

const KIND_OPTIONS: { value: number; label: string; icon: string }[] = [
  { value: 0, label: '未知', icon: 'devices' },
  { value: 1, label: '手机', icon: 'phone_android' },
  { value: 2, label: '电脑', icon: 'computer' },
  { value: 3, label: '平板', icon: 'tablet' },
  { value: 4, label: '电视', icon: 'tv' },
  { value: 5, label: 'IoT', icon: 'smart_toy' },
  { value: 6, label: '其他', icon: 'other_houses' },
  { value: 7, label: '路由器', icon: 'router' },
  { value: 8, label: '智能插座', icon: 'power' },
  { value: 9, label: '摄像头', icon: 'videocam' },
  { value: 10, label: '灯光开关', icon: 'light' },
  { value: 11, label: '传感器', icon: 'sensors' },
  { value: 12, label: '打印机', icon: 'printer' },
  { value: 13, label: '音箱', icon: 'speaker' },
  { value: 14, label: '游戏机', icon: 'gamepad' },
  { value: 15, label: '手表', icon: 'watch' },
  { value: 16, label: '空调', icon: 'ac_unit' },
  { value: 17, label: '厨房电器', icon: 'kitchen' },
]

function getKindInfo(kind?: number) {
  return KIND_OPTIONS.find((k) => k.value === kind) ?? KIND_OPTIONS[0]
}

// ── 格式化工具 ──

function fmtBytes(bytes?: number): string {
  if (bytes == null || bytes <= 0) return '0 B'
  if (bytes >= 1 << 30) return `${(bytes / (1 << 30)).toFixed(1)} GB`
  if (bytes >= 1 << 20) return `${(bytes / (1 << 20)).toFixed(1)} MB`
  if (bytes >= 1 << 10) return `${(bytes / (1 << 10)).toFixed(1)} KB`
  return `${bytes} B`
}

function fmtDuration(seconds?: number): string {
  if (seconds == null || seconds <= 0) return '-'
  const h = Math.floor(seconds / 3600)
  const m = Math.floor((seconds % 3600) / 60)
  if (h > 0) return `${h}小时${m}分`
  return `${m}分`
}

function fmtTime(time?: string): string {
  if (!time) return '-'
  try {
    const d = new Date(time)
    if (isNaN(d.getTime())) return time
    // 仅今天显示时间,否则显示日期
    const now = new Date()
    const isToday = d.toDateString() === now.toDateString()
    if (isToday) return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
    return d.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
  } catch {
    return time
  }
}

// ── 表单接口 ──

interface DeviceForm {
  name: string
  ip: string
  memberId: number
  kind: number
  icon: string
  groupId: number
  staticIP: boolean
  remark: string
}

// ── Props ──

interface DeviceDetailDrawerProps {
  deviceId: number | null
  onClose: () => void
  onSaved: () => void
}

export function DeviceDetailDrawer({ deviceId, onClose, onSaved }: DeviceDetailDrawerProps) {
  const [device, setDevice] = useState<Device | null>(null)
  const [groups, setGroups] = useState<DeviceGroup[]>([])
  const [members, setMembers] = useState<Member[]>([])
  const [loading, setLoading] = useState(false)
  const [saving, setSaving] = useState(false)
  const [blocking, setBlocking] = useState(false)
  const [form, setForm] = useState<DeviceForm>({
    name: '',
    ip: '',
    memberId: 0,
    kind: 0,
    icon: '',
    groupId: 0,
    staticIP: false,
    remark: '',
  })
  const [dirty, setDirty] = useState(false)

  // 根据字段名更新表单
  const updateForm = useCallback(<K extends keyof DeviceForm>(field: K, value: DeviceForm[K]) => {
    setForm((prev) => ({ ...prev, [field]: value }))
    setDirty(true)
  }, [])

  // 加载设备详情 + 分组列表 + 成员列表
  useEffect(() => {
    if (deviceId == null) {
      setDevice(null)
      return
    }

    let cancelled = false
    setLoading(true)

    Promise.all([
      getDevice(deviceId),
      getDeviceGroups(),
      getMembers(),
    ])
      .then(([dev, grpRes, memRes]) => {
        if (cancelled) return
        setDevice(dev)
        setGroups(grpRes.data)
        setMembers(memRes.data)
        setForm({
          name: dev.name ?? '',
          ip: dev.ip ?? '',
          memberId: dev.memberId ?? 0,
          kind: dev.kind ?? 0,
          icon: dev.icon ?? '',
          groupId: dev.groupId ?? 0,
          staticIP: dev.staticIP ?? false,
          remark: dev.remark ?? '',
        })
        setDirty(false)
      })
      .catch(() => {
        if (!cancelled) showToast('error', '加载设备详情失败')
      })
      .finally(() => {
        if (!cancelled) setLoading(false)
      })

    return () => { cancelled = true }
  }, [deviceId])

  // 保存
  const handleSave = useCallback(async () => {
    if (deviceId == null) return
    setSaving(true)
    try {
      await updateDeviceDetail(deviceId, {
        name: form.name,
        ip: form.ip || undefined,
        memberId: form.memberId > 0 ? form.memberId : undefined,
        kind: form.kind,
        icon: form.icon,
        groupId: form.groupId,
        staticIp: form.staticIP,
        remark: form.remark,
      })
      showToast('success', '设备信息已更新')
      setDirty(false)
      onSaved()
      onClose()
    } catch {
      showToast('error', '保存失败')
    } finally {
      setSaving(false)
    }
  }, [deviceId, form, onSaved, onClose])

  // 拉黑/解封设备
  const handleBlock = useCallback(async () => {
    if (!device?.mac) return
    setBlocking(true)
    try {
      if (device.enable) {
        await blockDevice(device.mac)
        showToast('success', '设备已拉黑,网络已阻断')
      } else {
        await unblockDevice(device.mac)
        showToast('success', '设备已解封,网络已恢复')
      }
      onSaved()
      onClose()
    } catch {
      showToast('error', device.enable ? '拉黑失败' : '解封失败')
    } finally {
      setBlocking(false)
    }
  }, [device, onSaved, onClose])

  const open = deviceId != null

  return (
    <Drawer open={open} onClose={onClose} title={device ? `${device.name || device.hostName || '未命名设备'} · ${device.mac}` : '设备详情'} maxWidth="max-w-md">
      {loading ? (
        <div className="flex items-center justify-center h-40 text-[var(--color-text-tertiary)] text-sm">
          加载中…
        </div>
      ) : device ? (
        <div className="flex flex-col h-full">
          <div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">

            {/* ── 状态栏 ── */}
            <div className="flex items-center gap-2 flex-wrap">
              <Icon name={getKindInfo(device.kind).icon} size="lg" className="text-[var(--color-text-secondary)]" />
              <Badge variant={device.online ? 'success' : 'default'}>
                {device.online ? '在线' : '离线'}
              </Badge>
              {!device.enable && <Badge variant="danger">已拉黑</Badge>}
              {form.staticIP && <Badge variant="info">静态IP</Badge>}
            </div>

            {/* ── 基本信息(只读) ── */}
            <section>
              <h3 className="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-tertiary)] mb-2">基本信息</h3>
              <div className="space-y-2">
                <InfoRow label="MAC" value={device.mac || '-'} mono />
                <EditField label="IP">
                  <input
                    type="text"
                    value={form.ip}
                    onChange={(e) => updateForm('ip', e.target.value)}
                    placeholder="输入 IP 地址"
                    className="w-full text-sm px-2.5 py-1 rounded-lg border border-[var(--color-border-default)] bg-[var(--color-surface-0)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:ring-2 focus:ring-[color:var(--color-brand-500)]/40 font-mono"
                  />
                </EditField>
                <InfoRow label="接口" value={device.interfaceName || '-'} />
                <InfoRow label="主机名" value={device.hostName || '-'} />
                <InfoRow label="厂商" value={device.vendor || '-'} />
              </div>
            </section>

            {/* ── 自定义信息(可编辑) ── */}
            <section>
              <h3 className="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-tertiary)] mb-2">自定义信息</h3>
              <div className="space-y-2.5">
                {/* 别名 */}
                <EditField label="别名">
                  <input
                    type="text"
                    value={form.name}
                    onChange={(e) => updateForm('name', e.target.value)}
                    placeholder="输入设备别名"
                    className="w-full text-sm px-2.5 py-1 rounded-lg border border-[var(--color-border-default)] bg-[var(--color-surface-0)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:ring-2 focus:ring-[color:var(--color-brand-500)]/40"
                  />
                </EditField>

                {/* 所属用户 */}
                <EditField label="所属用户">
                  <select
                    value={form.memberId}
                    onChange={(e) => updateForm('memberId', parseInt(e.target.value) || 0)}
                    className="w-full text-sm px-2.5 py-1 rounded-lg border border-[var(--color-border-default)] bg-[var(--color-surface-0)] text-[var(--color-text-primary)] focus:outline-none focus:ring-2 focus:ring-[color:var(--color-brand-500)]/40 appearance-none"
                  >
                    <option value={0}>- 未分配 -</option>
                    {members.map((m) => (
                      <option key={m.id} value={m.id}>{m.displayName || m.name}</option>
                    ))}
                  </select>
                </EditField>

                {/* 所属分组 */}
                <EditField label="所属分组">
                  <select
                    value={form.groupId}
                    onChange={(e) => updateForm('groupId', parseInt(e.target.value) || 0)}
                    className="w-full text-sm px-2.5 py-1 rounded-lg border border-[var(--color-border-default)] bg-[var(--color-surface-0)] text-[var(--color-text-primary)] focus:outline-none focus:ring-2 focus:ring-[color:var(--color-brand-500)]/40 appearance-none"
                  >
                    <option value={0}>- 未分组 -</option>
                    {groups.map((g) => (
                      <option key={g.id} value={g.id}>{g.name}</option>
                    ))}
                  </select>
                </EditField>

                {/* 设备种类 */}
                <EditField label="设备种类">
                  <div className="flex gap-1 flex-wrap">
                    {KIND_OPTIONS.map((opt) => (
                      <button
                        key={opt.value}
                        type="button"
                        onClick={() => updateForm('kind', opt.value)}
                        className={`
                          inline-flex items-center gap-1 px-2 py-1 text-xs rounded-lg border transition-all
                          ${form.kind === opt.value
                            ? 'border-[color:var(--color-brand-400)] bg-[color:var(--color-brand-50)] dark:bg-[color:var(--color-brand-900)]/30 text-[color:var(--color-brand-700)] dark:text-[color:var(--color-brand-200)]'
                            : 'border-[var(--color-border-subtle)] text-[var(--color-text-secondary)] hover:border-[var(--color-border-default)] hover:bg-[var(--color-surface-2)]'
                          }
                        `}
                      >
                        <Icon name={opt.icon} size="sm" />
                        {opt.label}
                      </button>
                    ))}
                  </div>
                </EditField>

                {/* 备注 */}
                <EditField label="备注">
                  <textarea
                    value={form.remark}
                    onChange={(e) => updateForm('remark', e.target.value)}
                    placeholder="输入备注信息"
                    rows={2}
                    className="w-full text-sm px-2.5 py-1 rounded-lg border border-[var(--color-border-default)] bg-[var(--color-surface-0)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:outline-none focus:ring-2 focus:ring-[color:var(--color-brand-500)]/40 resize-none"
                  />
                </EditField>

                {/* 静态IP */}
                <div className="flex items-center gap-3">
                  <span className="w-14 shrink-0 text-xs text-[var(--color-text-tertiary)]">静态IP</span>
                  <div className="flex items-center justify-between flex-1 gap-2">
                    <span className="text-xs text-[var(--color-text-tertiary)]">锁定此设备 IP 地址,自动同步 DHCP 绑定</span>
                    <button
                      type="button"
                      role="switch"
                      aria-checked={form.staticIP}
                      onClick={() => updateForm('staticIP', !form.staticIP)}
                      className={`
                        relative inline-flex items-center h-5 w-9 shrink-0 cursor-pointer rounded-full
                        transition-colors duration-200 ease-out
                        focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--color-brand-500)]/55
                        ${form.staticIP ? 'bg-[color:var(--color-brand-500)]' : 'bg-[var(--color-surface-3)]'}
                      `}
                    >
                      <span
                        className={`
                          pointer-events-none absolute inline-block h-4 w-4 rounded-full bg-white shadow-sm
                          transition-all duration-200 ease-out
                          top-0.5
                          ${form.staticIP ? 'left-[18px]' : 'left-0.5'}
                        `}
                      />
                    </button>
                  </div>
                </div>
              </div>
            </section>

            {/* ── 分隔线 ── */}
            <div className="border-t border-[var(--color-border-subtle)]" />

            {/* ── 统计信息(只读) ── */}
            <section>
              <h3 className="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-tertiary)] mb-3">统计信息</h3>
              <div className="grid grid-cols-2 gap-x-4 gap-y-2.5">
                <StatItem label="最后在线" value={fmtTime(device.lastOnline)} />
                <StatItem label="在线时长" value={fmtDuration(device.onlineTime)} />
                <StatItem label="今日下行" value={fmtBytes(device.rxBytes)} />
                <StatItem label="今日上行" value={fmtBytes(device.txBytes)} />
                <StatItem label="上线次数" value={device.logins != null ? `${device.logins}次` : '-'} />
                <StatItem label="首次发现" value={fmtTime(device.createTime)} />
                <StatItem label="最后更新" value={fmtTime(device.updateTime)} />
              </div>
            </section>

          </div>

          {/* ── 底部操作栏 ── */}
          <div className="shrink-0 px-6 py-4 border-t border-[var(--color-border-subtle)] flex items-center gap-2">
            <Button variant="primary" size="sm" onClick={handleSave} disabled={!dirty || saving} loading={saving}>
              {saving ? '保存中…' : '保存更改'}
            </Button>
            <Button variant="ghost" size="sm" onClick={onClose}>
              取消
            </Button>
            <div className="flex-1" />
            {device.enable ? (
              <Button variant="danger" size="sm" onClick={handleBlock} disabled={blocking} loading={blocking}>
                {blocking ? '处理中…' : '拉黑设备'}
              </Button>
            ) : (
              <Button variant="primary" size="sm" onClick={handleBlock} disabled={blocking} loading={blocking}>
                {blocking ? '处理中…' : '解封设备'}
              </Button>
            )}
          </div>
        </div>
      ) : (
        <div className="flex items-center justify-center h-40 text-[var(--color-text-tertiary)] text-sm">
          设备不存在
        </div>
      )}
    </Drawer>
  )
}

// ── 辅助子组件 ──

function InfoRow({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
  return (
    <div className="flex items-start gap-3">
      <span className="w-14 shrink-0 text-xs text-[var(--color-text-tertiary)] pt-0.5">{label}</span>
      <span className={`text-sm text-[var(--color-text-primary)] ${mono ? 'font-mono text-xs' : ''}`}>{value || '-'}</span>
    </div>
  )
}

function EditField({ label, children }: { label: string; children: React.ReactNode }) {
  return (
    <div className="flex items-center gap-3">
      <label className="w-14 shrink-0 text-xs text-[var(--color-text-tertiary)]">{label}</label>
      <div className="flex-1 min-w-0">{children}</div>
    </div>
  )
}

function StatItem({ label, value }: { label: string; value: string }) {
  return (
    <div>
      <span className="block text-xs text-[var(--color-text-tertiary)]">{label}</span>
      <span className="text-sm text-[var(--color-text-primary)]">{value}</span>
    </div>
  )
}