refactor: 枚举移入Models目录,命名空间更新为Rainbow.Entity.Models
大石头 authored at 2026-07-02 12:54:58
7.97 KiB
RainbowBridge
import { useEffect, useState, useRef } from 'react'
import { Button } from '@/components/atoms'
import { Modal } from '@/components/common/Modal'
import { DataTable, type DataColumn } from '@/components/common/DataTable'
import { showToast } from '@/stores/toastStore'
import { getDhcpPools, getDhcpBindings, addDhcpBinding, bulkImportDhcpBindings, deleteDhcpBinding, type DhcpPool, type DhcpBinding } from '@/lib/api'

export function DhcpPage() {
  const [pools, setPools] = useState<DhcpPool[]>([])
  const [bindings, setBindings] = useState<DhcpBinding[]>([])
  const [loading, setLoading] = useState(true)
  const [showAdd, setShowAdd] = useState(false)
  const [form, setForm] = useState({ mac: '', ip: '', hostName: '' })
  const [importing, setImporting] = useState(false)
  const fileInputRef = useRef<HTMLInputElement>(null)

  const fetchData = async () => {
    try {
      const [p, b] = await Promise.all([getDhcpPools(), getDhcpBindings()])
      setPools(p); setBindings(b)
    } catch { /* 静默 */ }
    setLoading(false)
  }

  useEffect(() => { fetchData() }, [])

  const handleAddBinding = async () => {
    if (!form.mac || !form.ip) { showToast('warning', 'MAC 和 IP 为必填'); return }
    try { await addDhcpBinding(form); showToast('success', '绑定已添加'); setShowAdd(false); setForm({ mac: '', ip: '', hostName: '' }); await fetchData() }
    catch { showToast('error', '添加失败') }
  }

  // ── CSV 导出 ──
  const handleExportCSV = () => {
    if (bindings.length === 0) { showToast('warning', '没有可导出的数据'); return }
    const header = 'MAC,IP,主机名'
    const rows = bindings.map(b => `${b.mac},${b.ip},${b.hostName ?? ''}`)
    const csv = [header, ...rows].join('\n')
    // UTF-8 BOM 确保 Excel 正确识别中文
    const bom = '\uFEFF'
    const blob = new Blob([bom + csv], { type: 'text/csv;charset=utf-8' })
    const url = URL.createObjectURL(blob)
    const a = document.createElement('a')
    a.href = url
    a.download = `dhcp-bindings-${new Date().toISOString().slice(0, 10)}.csv`
    a.click()
    URL.revokeObjectURL(url)
    showToast('success', `已导出 ${bindings.length} 条绑定`)
  }

  // ── CSV 导入 ──
  const handleImportClick = () => {
    fileInputRef.current?.click()
  }

  const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0]
    if (!file) return
    setImporting(true)
    try {
      const text = await file.text()
      const lines = text.split(/\r?\n/).filter(l => l.trim())
      if (lines.length < 2) { showToast('warning', 'CSV 文件格式不正确,至少需要表头和一条数据'); return }

      // 跳过表头,解析数据行
      const bindings: Array<{ mac: string; ip: string; hostName?: string }> = []
      for (let i = 1; i < lines.length; i++) {
        const cols = lines[i].split(',').map(c => c.trim().replace(/^"|"$/g, ''))
        if (cols.length < 2 || !cols[0] || !cols[1]) continue
        bindings.push({ mac: cols[0], ip: cols[1], hostName: cols[2] || undefined })
      }

      if (bindings.length === 0) { showToast('warning', '未解析到有效数据行'); return }

      const result = await bulkImportDhcpBindings(bindings)
      showToast('success', `导入完成:新增 ${result.added} 条,跳过 ${result.skipped} 条(已存在)`)
      await fetchData()
    } catch {
      showToast('error', '导入失败,请检查 CSV 文件格式(MAC,IP,主机名)')
    } finally {
      setImporting(false)
      // 重置 file input,允许重复导入同一文件
      if (fileInputRef.current) fileInputRef.current.value = ''
    }
  }

  const bindingColumns: DataColumn<DhcpBinding>[] = [
    { key: 'mac', label: 'MAC', className: 'font-mono text-xs' },
    { key: 'ip', label: 'IP', sortable: true },
    { key: 'hostName', label: '主机名', render: (b) => b.hostName || '-' },
    {
      key: 'actions', label: '操作',
      render: (b) => (
        <Button variant="ghost" size="sm" onClick={async () => {
          try { await deleteDhcpBinding(b.id); showToast('success', '已删除'); await fetchData() }
          catch { showToast('error', '删除失败') }
        }}>删除</Button>
      ),
    },
  ]

  return (
    <div className="p-6">
      <h1 className="text-lg font-bold text-[var(--color-text-primary)] mb-4">DHCP 管理</h1>

      {/* 地址池 */}
      <div className="glass-panel p-5 mb-6">
        <h2 className="text-sm font-semibold text-[var(--color-text-primary)] mb-4">地址池</h2>
        {pools.length === 0 ? (
          <p className="text-sm text-[var(--color-text-tertiary)] text-center py-8">暂无地址池数据</p>
        ) : (
          <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
            {pools.map((p) => (
              <div key={p.interfaceName} className="p-4 rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface-1)]">
                <p className="text-xs text-[var(--color-text-tertiary)]">{p.interfaceName}</p>
                <p className="text-sm font-medium text-[var(--color-text-primary)] mt-1">
                  {p.startIp} - {p.endIp}
                </p>
                <p className="text-xs text-[var(--color-text-muted)] mt-1">掩码: {p.netmask}</p>
              </div>
            ))}
          </div>
        )}
      </div>

      {/* 静态绑定 */}
      <div>
        <div className="flex items-center justify-between mb-3">
          <h2 className="text-sm font-semibold text-[var(--color-text-primary)]">静态绑定</h2>
          <div className="flex items-center gap-2">
            <Button size="sm" variant="soft" onClick={handleExportCSV}>导出 CSV</Button>
            <Button size="sm" variant="soft" onClick={handleImportClick} disabled={importing}>
              {importing ? '导入中…' : '导入 CSV'}
            </Button>
            <input ref={fileInputRef} type="file" accept=".csv" onChange={handleFileChange} className="hidden" />
            <Button size="sm" variant="soft" onClick={() => setShowAdd(true)}>添加绑定</Button>
          </div>
        </div>
        <DataTable
          data={bindings} columns={bindingColumns} rowKey={(b) => b.id}
          searchFields={['mac', 'ip', 'hostName']} searchPlaceholder="搜索 MAC / IP / 主机名…"
          loading={loading} emptyText="暂无静态绑定"
        />
      </div>

      {/* 添加绑定弹窗 */}
      <Modal open={showAdd} onClose={() => setShowAdd(false)} maxWidth="max-w-sm">
        <div className="p-6">
          <h3 className="text-sm font-semibold mb-4 text-[var(--color-text-primary)]">添加静态绑定</h3>
          <div className="space-y-3">
            <InputField label="MAC *" value={form.mac} onChange={(v) => setForm((f) => ({ ...f, mac: v }))} placeholder="00:11:22:33:44:55" />
            <InputField label="IP *" value={form.ip} onChange={(v) => setForm((f) => ({ ...f, ip: v }))} placeholder="192.168.1.100" />
            <InputField label="主机名" value={form.hostName} onChange={(v) => setForm((f) => ({ ...f, hostName: v }))} placeholder="可选" />
          </div>
          <div className="flex justify-end gap-2 mt-4">
            <Button variant="secondary" size="sm" onClick={() => setShowAdd(false)}>取消</Button>
            <Button variant="primary" size="sm" onClick={handleAddBinding}>确定</Button>
          </div>
        </div>
      </Modal>
    </div>
  )
}

function InputField({ label, value, onChange, placeholder }: {
  label: string; value: string; onChange: (v: string) => void; placeholder?: string
}) {
  return (
    <label className="block">
      <span className="text-xs text-[var(--color-text-secondary)]">{label}</span>
      <input value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder}
        className="mt-1 w-full px-3 py-2 text-sm 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" />
    </label>
  )
}