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>
)
}
|