/** * 柜子详情页 * - 显示柜子基本信息(ID、状态、充电模式) * - **柜级**状态位(协议 §3.8:`status1`/`status2`)+ 柜门(整柜共用一扇,数据来自 `status_post`) * - 按仓控板分组展示 6 个通道的状态卡片(电池状态 / 电源状态两个独立维度) * * ⚠️ 展示层级(产品已确认,协议 §3.8): * - **柜级**位域(断电/烟雾/水位/倾倒/柜门/加热风扇/接触器/喷淋/声光报警/柜门超时)只在"柜子"层面出现; * - **仓体级**只有 `batt_status` / `pow_status`,仓体卡片里**不出现任何"门"状态** * (协议 §4.2 L403:柜门整柜共用一扇,**没有"按仓体开门"的指令**)。 */ 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 { Permission } from '@/components/Permission'; import { cabinetDoorTag, cabinetStatusTags, formatTempK100, normalizeFlag, parseBattStatus, parsePowStatus, } from '@/utils/deviceStatus'; /** * 通道(仓体)状态映射 * * 口径:`0空闲 / 1充电中 / 2充满 / 3故障`,与后端派生规则 * (`api-server/src/routes/cabinet_tree.rs::derive_channel_flags`,优先级 fault > full > charging > idle)、 * 小程序 `device-detail/index.vue::compClass`、`compartment-detail/index.vue::statusText` **三处完全一致**。 * ⚠️ 仓体级**没有"禁用"档**(禁用是设备/组织级状态,走 `CABINET_STATUS_MAP`)。 * 颜色取常识口径:空闲=灰 / 充电中=蓝 / 充满=绿 / 故障=红。 */ const STATUS_MAP: Record = { 0: { label: '空闲', color: 'gray' }, 1: { label: '充电中', color: 'blue' }, 2: { label: '充满', color: 'green' }, 3: { label: '故障', color: 'red' }, }; /** * 柜子**配置态**映射(`cabinets.status`):只有「启用 / 禁用」两档。 * * ⚠️ 这里**不含"在线/离线"**:`cabinets.status` 是启用/禁用配置态, * 全仓没有任何写入方会把它写成 `0`(离线),拿它判在线会让"一台设备都没连"时 * 依然显示"在线"。实时在线一律看接口的 `is_online`(Redis,见 `onlineTag`)。 * ⛔ 不要再往本表里加"离线"档。 */ const CABINET_STATUS_MAP: Record = { 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( '解锁柜门(下发的是解锁,门要人拉开)', '柜门是整柜共用的一扇:平台下发的是「解锁」,解锁后需要人到现场把门拉开,门不会自动打开,' + '柜门打开状态最迟 60 秒内随设备上报更新(非实时)。平台没有「关门」指令,关门也由人操作。\n' + '⚠️ 开门与充电/订单没有任何联动,不会影响正在进行的充电。', async () => { setDoorLoading(true); try { // 柜级指令:不带 id(协议 §4.2);不触碰订单与充电 await cabinetApi.openCabinet(Number(id)); Message.success('解锁指令已发送,请到现场拉开柜门'); } catch (e: unknown) { Message.error(e instanceof Error ? e.message : '开门失败'); } 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: 'green' }; /** * **实时在线**标签:`is_online` 来自接口(Redis `device:{imei}.online`, * 与设备列表 `is_online` 同源)。三态 —— `true` 在线 / `false` 离线 / `null`、缺失 = 无数据。 * * ⚠️ 与 `detail.status`(启用/禁用配置态)**无关**:后者永不表示在线,不得再用来推断。 * 无数据时显示"在线未知"而不是"离线"("没收到上报" 与 "明确离线" 必须区分)。 */ const onlineTag: { label: string; color: string } = detail.is_online === true ? { label: '在线', color: 'green' } : detail.is_online === false ? { label: '离线', color: 'red' } : { label: '在线未知', color: 'gray' }; /** 充电工作模式:后端未返回时为 null(界面上按默认「平台指令模式」展示并标注) */ const workMode: '0' | '1' | null = detail.work_mode === '1' ? '1' : detail.work_mode === '0' ? '0' : null; /** 柜级柜门三态(协议 §4.2):`status1` bit4;无数据 → 「未知」(绝不显示成"关闭") */ const doorTag = cabinetDoorTag(detail.status1); /** 柜级告警标签(协议 §3.8):`null` = 该维度无数据(与"确实无告警"的 `[]` 区分) */ const cabinetTags = cabinetStatusTags(detail.status1, detail.status2); 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' }; // 协议 §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 : '-'}
{comp.voltage ?? '-'}mV {comp.current ?? '-'}mA {comp.temp != null && {formatTempK100(comp.temp)}} {/* 电量单位是 ‰(协议附 A:4500 = 45.0%),需 /10 换算,否则会大 10 倍 */} {comp.soc != null && {(comp.soc / 10).toFixed(1)}%} {comp.battery_id && {comp.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 }))} />
); }