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