/** * 柜子详情页 * - 显示柜子基本信息(ID、状态、充电模式) * - 按仓控板分组展示 6 个通道的状态卡片(电池状态 / 电源状态两个独立维度) */ import { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { Card, Tag, Typography, Space, Spin, Button, Message, Modal, Switch, Select, Radio, Tooltip, } from '@arco-design/web-react'; import { IconLeft, IconDesktop, IconCopy, IconUnlock, IconPlayArrow, IconPoweroff, IconSwap, IconRefresh } from '@arco-design/web-react/icon'; import { cabinetApi, orgApi, projectApi, type CabinetDetail, type Organization, type Project } from '@/api/devices'; import { useAuthStore } from '@/stores/auth'; import { normalizeFlag, parseBattStatus, parsePowStatus } from '@/utils/deviceStatus'; /** 状态映射 */ const STATUS_MAP: Record = { 0: { label: '空闲', color: 'green' }, 1: { label: '充电中', color: 'blue' }, 2: { label: '故障', color: 'red' }, 3: { label: '禁用', color: 'orange' }, }; /** 柜子状态映射 */ const CABINET_STATUS_MAP: Record = { 0: { label: '离线', color: 'red' }, 1: { label: '在线', color: 'green' }, 2: { label: '禁用', color: 'orange' }, }; /** 充电工作模式文案(协议:cabinets.work_mode) */ const WORK_MODE_LABELS: Record = { '0': '平台指令模式', '1': '自动充电工作模式', }; /** * 停电/供电状态指示(协议 §5.4:直流停电与交流停电是两条独立事件上报) * - 处于停电状态 → 醒目 Tag(直流红 / 交流橙) * - 正常 → 绿色徽章;无数据(null/缺失)→ 灰色徽章 "-" */ function PowerFlag({ label, fail }: { label: 'DC' | 'AC'; fail?: boolean | null }) { const flag = normalizeFlag(fail); if (flag === true) { return {label === 'AC' ? '交流停电' : '直流停电'}; } return ( {label === 'AC' ? '🔌' : '⚡'} {label}: {flag === false ? '正常' : '-'} ); } export default function CabinetDetail() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const { user: currentUser } = useAuthStore(); const isSuperAdmin = (currentUser?.role ?? 0) >= 2; const [loading, setLoading] = useState(true); const [detail, setDetail] = useState(null); const [powLoading, setPowLoading] = useState<'none' | 'on' | 'off'>('none'); const [doorLoading, setDoorLoading] = useState(false); const [compLoading, setCompLoading] = useState>(new Set()); const [disableLoading, setDisableLoading] = useState(false); /** 重启中的目标:'cabinet' = 整柜;`board-{id}` = 某块仓控板 */ const [resetLoading, setResetLoading] = useState(null); /** 重置安全码进行中 */ const [authLoading, setAuthLoading] = useState(false); /** 充电模式切换进行中 */ const [workModeLoading, setWorkModeLoading] = useState(false); // 分配项目弹窗 const [projectModalVisible, setProjectModalVisible] = useState(false); const [orgs, setOrgs] = useState([]); const [projects, setProjects] = useState([]); const [selectedOrgId, setSelectedOrgId] = useState(); const [selectedProjectId, setSelectedProjectId] = useState(); const [projectSaving, setProjectSaving] = useState(false); /** 打开分配项目弹窗,加载组织列表 */ const openProjectModal = async () => { setProjectModalVisible(true); if (isSuperAdmin) { // 超管:选择组织→项目 setSelectedOrgId(undefined); setSelectedProjectId(undefined); setProjects([]); try { const res = await orgApi.list(); setOrgs(res.data || []); } catch { Message.error('加载组织列表失败'); } } else { // 非超管:直接加载本组织的项目,不可选组织 const orgId = currentUser?.organization_id; if (!orgId) { Message.error('您未关联组织,无法分配项目'); setProjectModalVisible(false); return; } setSelectedOrgId(orgId); setSelectedProjectId(undefined); try { const res = await projectApi.list(orgId); setProjects(res.data || []); } catch { Message.error('加载项目列表失败'); } } }; /** 选择组织后加载该项目列表 */ const handleOrgChange = async (val: number | undefined) => { setSelectedOrgId(val); setSelectedProjectId(undefined); setProjects([]); if (!val) return; try { const res = await projectApi.list(val); setProjects(res.data || []); } catch { Message.error('加载项目列表失败'); } }; /** 保存分配项目 */ const handleSaveProject = async () => { if (!detail || !selectedProjectId) { Message.warning('请选择项目'); return; } setProjectSaving(true); try { await cabinetApi.update(detail.id, { project_id: selectedProjectId }); Message.success('项目已更新'); setProjectModalVisible(false); const res = await cabinetApi.getDetail(detail.id); setDetail(res.data); } catch { Message.error('更新失败'); } finally { setProjectSaving(false); } }; /** 声明式确认弹窗状态(替代 Modal.confirm,兼容 React 19) */ const [confirmState, setConfirmState] = useState<{ title: string; content: string; onOk: () => Promise; } | null>(null); const openConfirm = (title: string, content: string, onOk: () => Promise) => { setConfirmState({ title, content, onOk }); }; const closeConfirm = () => setConfirmState(null); const handleConfirmOk = async () => { if (!confirmState) return; try { await confirmState.onOk(); } finally { setConfirmState(null); } }; /** 复制到剪贴板 */ const copyText = (text: string, label: string) => { if (navigator.clipboard?.writeText) { navigator.clipboard.writeText(text).then( () => Message.success(`${label} 已复制`), () => Message.error('复制失败'), ); } else { // 降级方案:旧浏览器或非 HTTPS 环境使用 textarea const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy') ? Message.success(`${label} 已复制`) : Message.error('复制失败'); document.body.removeChild(ta); } }; useEffect(() => { if (!id) return; setLoading(true); cabinetApi .getDetail(Number(id)) .then((res) => setDetail(res.data)) .catch(() => { // 如果详情接口不可用,显示提示 }) .finally(() => setLoading(false)); }, [id]); /** 开门(柜级:整柜共用一扇门,只解锁,需人拉开) */ const handleOpenDoor = () => { if (!id) return; openConfirm( '解锁柜门', '确定要解锁柜门吗?柜门是整柜共用的一扇,解锁后需要人手动拉开;平台无"关门"指令,关门也由人操作。', async () => { setDoorLoading(true); try { await cabinetApi.openCabinet(Number(id)); Message.success('解锁指令已发送'); } catch { Message.error('开门失败'); } finally { setDoorLoading(false); } }, ); }; /** 接触器合闸 */ const handlePowOn = () => { if (!id) return; openConfirm('确认操作', '确定要合闸恢复380V供电吗?', async () => { setPowLoading('on'); try { await cabinetApi.powOn(Number(id)); Message.success('合闸指令已发送'); const res = await cabinetApi.getDetail(Number(id)); setDetail(res.data); } catch { Message.error('操作失败'); } finally { setPowLoading('none'); } }); }; /** 接触器分闸 */ const handlePowOff = () => { if (!id) return; openConfirm('确认操作', '确定要分闸切断380V供电吗?', async () => { setPowLoading('off'); try { await cabinetApi.powOff(Number(id)); Message.success('分闸指令已发送'); const res = await cabinetApi.getDetail(Number(id)); setDetail(res.data); } catch { Message.error('操作失败'); } finally { setPowLoading('none'); } }); }; /** 重启整柜 */ const handleCabinetReset = () => { if (!id) return; openConfirm( '重启整柜', '确定要重启整柜吗?重启期间该柜所有仓体将短暂离线,充电会中断。', async () => { setResetLoading('cabinet'); try { await cabinetApi.resetCabinet(Number(id)); Message.success('重启指令已发送'); } catch { Message.error('重启失败'); } finally { setResetLoading(null); } }, ); }; /** 重启单块仓控板(协议 id = IMEI-板号) */ const handleBoardReset = (boardId: number, boardNo: number) => { openConfirm( '重启仓控板', `确定要重启 ${boardNo} 号仓控板吗?该板下所有仓体将短暂离线,充电会中断。`, async () => { setResetLoading(`board-${boardId}`); try { await cabinetApi.resetBoard(boardId); Message.success('重启指令已发送'); } catch { Message.error('重启失败'); } finally { setResetLoading(null); } }, ); }; /** 通道充电 */ const handleCompOn = (compartmentId: number) => { openConfirm('开始充电', '确定要开始充电吗?', async () => { setCompLoading(prev => new Set(prev).add(compartmentId)); try { await cabinetApi.compartmentOn(compartmentId); Message.success('充电指令已发送'); const res = await cabinetApi.getDetail(Number(id)); setDetail(res.data); } catch { Message.error('充电指令发送失败'); } finally { setCompLoading(prev => { const next = new Set(prev); next.delete(compartmentId); return next; }); } }); }; /** 通道停止充电 */ const handleCompOff = (compartmentId: number) => { openConfirm('停止充电', '确定要停止充电吗?', async () => { setCompLoading(prev => new Set(prev).add(compartmentId)); try { await cabinetApi.compartmentOff(compartmentId); Message.success('停止指令已发送'); const res = await cabinetApi.getDetail(Number(id)); setDetail(res.data); } catch { Message.error('停止指令发送失败'); } finally { setCompLoading(prev => { const next = new Set(prev); next.delete(compartmentId); return next; }); } }); }; /** 禁用柜子 */ const handleDisable = () => { if (!detail) return; openConfirm('禁用柜子', '禁用后该柜子将无法充电/开门,确定要禁用吗?', async () => { setDisableLoading(true); try { await cabinetApi.setStatus(detail.id, 2); Message.success('已禁用'); const res = await cabinetApi.getDetail(detail.id); setDetail(res.data); } catch { Message.error('操作失败'); } finally { setDisableLoading(false); } }); }; /** 启用柜子 */ const handleEnable = () => { if (!detail) return; openConfirm('启用柜子', '确定要重新启用该柜子吗?', async () => { setDisableLoading(true); try { await cabinetApi.setStatus(detail.id, 1); Message.success('已启用'); const res = await cabinetApi.getDetail(detail.id); setDetail(res.data); } catch { Message.error('操作失败'); } finally { setDisableLoading(false); } }); }; /** 重新拉取详情 */ const reloadDetail = async (cabinetId: number) => { const res = await cabinetApi.getDetail(cabinetId); setDetail(res.data); }; /** * 重置安全码(**仅总管理员**,后端权限码 `device:auth_str:reset`) * 旧码立即失效,设备下次重连自动重新申领,无需现场操作 */ const handleRegenerateAuth = () => { if (!detail) return; const cabinetId = detail.id; openConfirm( '重置安全码', '重置后旧安全码立即失效,设备下次重连会自动重新申领新码,无需现场操作。确定要重置吗?', async () => { setAuthLoading(true); try { const res = await cabinetApi.regenerateAuth(cabinetId); const authStr = res.data?.auth_str; if (authStr) { Message.success(`安全码已重置,新安全码:${authStr}`); } else { Message.success('安全码已重置'); } } catch (e: unknown) { Message.error(e instanceof Error ? e.message : '重置安全码失败'); } finally { setAuthLoading(false); } }, ); }; /** 切换充电工作模式("0"=平台指令模式 "1"=自动充电工作模式),需二次确认 */ const handleWorkModeChange = (next: string) => { if (!detail) return; const cabinetId = detail.id; const target: '0' | '1' = next === '1' ? '1' : '0'; const current: '0' | '1' = detail.work_mode === '1' ? '1' : '0'; if (target === current) return; const content = target === '1' ? '切换为「自动充电工作模式」后:柜子检测到电池插入即自行开始充电,不再需要平台下发「开始充电」指令。确定切换吗?' : '切换为「平台指令模式」后:只有平台下发「开始充电」指令,柜子才会开始充电。确定切换吗?'; openConfirm('切换充电模式', content, async () => { setWorkModeLoading(true); try { await cabinetApi.setWorkMode(cabinetId, target); Message.success(`已切换为${WORK_MODE_LABELS[target]}`); await reloadDetail(cabinetId); } catch (e: unknown) { Message.error(e instanceof Error ? e.message : '充电模式切换失败'); } finally { setWorkModeLoading(false); } }); }; if (loading) { return (
); } if (!detail) { return ( 柜子不存在或加载失败 ); } const cabStatus = CABINET_STATUS_MAP[detail.status] || { label: '未知', color: 'gray' }; /** 充电工作模式:后端未返回时为 null(界面上按默认「平台指令模式」展示并标注) */ const workMode: '0' | '1' | null = detail.work_mode === '1' ? '1' : detail.work_mode === '0' ? '0' : null; return (
{/* 柜子信息 */} {/* 第一行:返回 + 标题 + 状态标签 + 所属项目 + 开关 */}
{/* 重置安全码:仅总管理员可见(后端权限码 device:auth_str:reset) */} {isSuperAdmin && ( )}
{/* 第四行:充电模式(work_mode)— 可切换,需二次确认 */}
充电模式 handleWorkModeChange(String(value))} options={[ { label: WORK_MODE_LABELS['0'], value: '0' }, { label: WORK_MODE_LABELS['1'], value: '1' }, ]} /> {workMode === '1' ? '柜子检测到电池后自行开始充电,无需平台下发指令' : '平台下发「开始充电」指令后柜子才充电'} {workMode === null && '(设备未返回该字段,按默认模式展示)'}
{/* 仓控板列表 */} {detail.cabin_boards.map((board) => ( copyText(`${detail.imei}-${board.board_id}`, '仓板ID')} /> } size="small" >
{board.compartments.map((comp) => { const st = STATUS_MAP[comp.status] || { label: '未知', color: 'gray' }; const c = comp as any; // 协议 §3.8:电池状态与电源状态是两个相互独立维度,分别解析、分别展示 const batt = parseBattStatus(comp.batt_status); const pow = parsePowStatus(comp.pow_status); return (
{/* 左侧色条 = 通道业务状态(原有单一状态标签改为色条,两个独立指示见右侧) */} {detail.imei}-{board.board_id}-{comp.channel_id} { e.stopPropagation(); copyText(`${detail.imei}-${board.board_id}-${comp.channel_id}`, '通道号'); }} /> {/* 两个独立指示:电池(蓝/绿系) + 电源(灰/橙系),字段缺失显示 "-" */}
{batt ? batt.label : '-'} {pow ? pow.label : '-'}
{c.voltage ?? '-'}mV {c.current ?? '-'}mA {c.temp != null && {((c.temp / 100) - 273.15).toFixed(1)}°C} {c.soc != null && {c.soc}%} {c.battery_id && {c.battery_id}}
); })}
))} {/* 声明式确认弹窗(兼容 React 19) */} {confirmState?.content} {/* 分配项目弹窗 */} setProjectModalVisible(false)} onOk={handleSaveProject} confirmLoading={projectSaving} style={{ width: 480 }} > {isSuperAdmin ? ( setSelectedProjectId(v as number)} options={projects.map((p) => ({ label: p.name, value: p.id }))} />
); }