## 1. 用户管理:角色过滤(页面总共只有三种角色,默认罗列全部太吵) 前端加角色下拉 + 后端 `GET /users` 新增 `role` 参数(0/1/2,越界当作不过滤)。 后端把 `build_user_list_where` 从**二元穷举 match 重构为条件累加** —— 加第三个过滤器时穷举会涨到 8 分支且极易漏。同时修一个潜在坑: **OR 条件必须整体加括号**,否则 `phone LIKE ? OR name LIKE ?` 与 `organization_id = ?` 用 AND 混排时会变成 `(A AND phone LIKE ?) OR name LIKE ?`,把组织/角色过滤整个击穿 (搜个姓名就跨组织串数据)。 新增 6 个守门单测(含"占位符个数必须与 bind 次数一致"的反证), 已自证能失败:去掉 OR 括号 → 2 个用例 FAILED,还原 → 6 passed。 ## 2. 数据范围:仅操作员(role=0)可配 按用户口径: - **总管理员**:豁免范围限制,不需要配; - **企业管理员**:天然管理本组织全部设备,配项目级范围反而有歧义; - **操作员**:才需要设置可管理的项目。 前端对该按钮按 `record.role !== 0` 置灰并加 title 说明。 **创建/编辑时操作员必须选组织**(没有企业归属就看不到任何设备),校验文案也分开提示。 ## 3. 柜子详情:透出电表读数 接口早已返回 `meter_wh`/`meter_at`,但**前端从没接过**(用户看不到)。 现加到详情页基本信息行(IMEI/ICCID/名称 之后),Wh → kWh 展示并附收到时刻; `null` = 从未上报过,显示 `-` 而**不是 0 度**。 ## ⛔ 表号:系统里没有,做不了 全仓搜不到 `meter_no`/`meter_id`/`meter_sn`/`表号`,协议里 `cabinet.meter` 只是读数(Wh),**没有电表编号字段**。这需要硬件侧在协议里加字段, 平台无法单方面补。 ## ⚠️ 我这次犯的流程错误(已修正) 改了 Rust 代码后**只跑了 cargo check/test,没重新 `zigbuild` release 产物**, `deploy -SkipBuild` 于是把几小时前的旧二进制发上去了 —— 第一次实测角色过滤 三种角色全返回、监控词。重建后才通过。 **教训:改 Rust 后必须重建 release 产物再发。** ## 验收 - `cargo check --all-targets` 两服务:**0 error / 0 warning** - `cargo test --bin pms-api`:**210 passed**(204 + 6 新) - web `npm run typecheck`:exit 0 - **角色过滤实测**(测试环境,临时超管+自签 JWT,用完即删): ``` role=0 → 2 人,role 集合 [0] OK role=1 → 1 人,role 集合 [1] OK role=2 → 2 人,role 集合 [2] OK 不带 role → [0,1,2] OK role=0 + keyword → 1 人 OK(keyword 单独查命中 0,证明不是靠关键字通过) role=9 越界 → 5 人 OK(当作不过滤) ``` - 已发布测试 + 生产(四服务 active、0 ERROR、消费链探测通过、二进制时间戳已核对)
766 lines
33 KiB
TypeScript
766 lines
33 KiB
TypeScript
/**
|
||
* 柜子详情页
|
||
* - 显示柜子基本信息(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<number, { label: string; color: string }> = {
|
||
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<number, { label: string; color: string }> = {
|
||
1: { label: '启用', color: 'green' },
|
||
2: { label: '禁用', color: 'orange' },
|
||
};
|
||
|
||
/** 充电工作模式文案(协议:cabinets.work_mode) */
|
||
const WORK_MODE_LABELS: Record<string, string> = {
|
||
'0': '平台指令模式',
|
||
'1': '自动充电工作模式',
|
||
};
|
||
|
||
/**
|
||
* 停电/供电状态指示(协议 §5.4:直流停电与交流停电是两条独立事件上报)
|
||
* - 处于停电状态 → 醒目 Tag(直流红 / 交流橙)
|
||
* - 正常 → 绿色徽章;无数据(null/缺失)→ 灰色徽章 "-"
|
||
*/
|
||
function PowerFlag({ label, fail }: { label: 'DC' | 'AC'; fail?: boolean | null }) {
|
||
const flag = normalizeFlag(fail);
|
||
if (flag === true) {
|
||
return <Tag color={label === 'AC' ? 'orange' : 'red'} size="small">{label === 'AC' ? '交流停电' : '直流停电'}</Tag>;
|
||
}
|
||
return (
|
||
<span style={{
|
||
fontSize: 11,
|
||
background: flag === false ? '#f6ffed' : 'var(--color-fill-2)',
|
||
color: flag === false ? '#16a34a' : 'var(--color-text-2)',
|
||
borderRadius: 4,
|
||
padding: '2px 8px',
|
||
lineHeight: '18px',
|
||
}}>
|
||
{label === 'AC' ? '🔌' : '⚡'} {label}: <b>{flag === false ? '正常' : '-'}</b>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
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<CabinetDetail | null>(null);
|
||
const [powLoading, setPowLoading] = useState<'none' | 'on' | 'off'>('none');
|
||
const [doorLoading, setDoorLoading] = useState(false);
|
||
const [compLoading, setCompLoading] = useState<Set<number>>(new Set());
|
||
const [disableLoading, setDisableLoading] = useState(false);
|
||
/** 重启中的目标:'cabinet' = 整柜;`board-{id}` = 某块仓控板 */
|
||
const [resetLoading, setResetLoading] = useState<string | null>(null);
|
||
/** 重置安全码进行中 */
|
||
const [authLoading, setAuthLoading] = useState(false);
|
||
/** 充电模式切换进行中 */
|
||
const [workModeLoading, setWorkModeLoading] = useState(false);
|
||
|
||
// 分配项目弹窗
|
||
const [projectModalVisible, setProjectModalVisible] = useState(false);
|
||
const [orgs, setOrgs] = useState<Organization[]>([]);
|
||
const [projects, setProjects] = useState<Project[]>([]);
|
||
const [selectedOrgId, setSelectedOrgId] = useState<number | undefined>();
|
||
const [selectedProjectId, setSelectedProjectId] = useState<number | undefined>();
|
||
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<void>;
|
||
} | null>(null);
|
||
|
||
const openConfirm = (title: string, content: string, onOk: () => Promise<void>) => {
|
||
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 (
|
||
<Card>
|
||
<div style={{ textAlign: 'center', padding: '60px 0' }}>
|
||
<Spin dot />
|
||
</div>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
if (!detail) {
|
||
return (
|
||
<Card>
|
||
<Typography.Text type="secondary">柜子不存在或加载失败</Typography.Text>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||
{/* 柜子信息 */}
|
||
<Card size="small" bodyStyle={{ padding: '12px 16px' }}>
|
||
{/* 第一行:返回 + 标题 + 状态标签 + 所属项目 + 开关 */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
|
||
<Button type="text" icon={<IconLeft />} onClick={() => navigate('/devices')} style={{ padding: '0 4px' }} />
|
||
<IconDesktop style={{ fontSize: 20, color: 'var(--color-text-2)' }} />
|
||
<Typography.Text style={{ fontSize: 16, fontWeight: 600 }}>柜子 {detail.abstract_id}</Typography.Text>
|
||
{/* 实时在线(Redis `device:{imei}.online`,接口 `is_online`):三态,无数据 → 「在线未知」 */}
|
||
<Tag color={onlineTag.color} size="small">{onlineTag.label}</Tag>
|
||
{/* 配置态(启用/禁用):与上面"是否在线"是两个维度,同时展示 */}
|
||
<Tag color={cabStatus.color} size="small">{cabStatus.label}</Tag>
|
||
<div style={{ flex: 1 }} />
|
||
<span style={{ fontSize: 12, color: 'var(--color-text-2)', background: 'var(--color-fill-1)', borderRadius: 4, padding: '2px 8px' }}>
|
||
📁 {detail.project_name || '未分配'}
|
||
<IconSwap style={{ cursor: 'pointer', color: 'var(--color-text-3)', fontSize: 12, marginLeft: 4 }} onClick={openProjectModal} />
|
||
</span>
|
||
{isSuperAdmin && (
|
||
<Switch
|
||
checked={detail.status !== 2}
|
||
checkedText="启用"
|
||
uncheckedText="禁用"
|
||
loading={disableLoading}
|
||
onChange={(checked) => { if (checked) handleEnable(); else handleDisable(); }}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{/* 第二行:状态指标(带背景的徽章式) */}
|
||
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
|
||
<span style={{ fontSize: 11, color: 'var(--color-text-2)', background: 'var(--color-fill-2)', borderRadius: 4, padding: '2px 8px', lineHeight: '18px' }}>
|
||
📶 RSSI: <b style={{ color: 'var(--color-text-1)' }}>{detail.rssi != null ? detail.rssi : '-'}</b>
|
||
</span>
|
||
<span style={{ fontSize: 11, color: 'var(--color-text-2)', background: 'var(--color-fill-2)', borderRadius: 4, padding: '2px 8px', lineHeight: '18px' }}>
|
||
🌡 柜温: <b style={{ color: 'var(--color-text-1)' }}>{formatTempK100(detail.temp, '--°C')}</b>
|
||
</span>
|
||
{/* 停电状态(协议 §5.4):直流 / 交流两个独立事件,可同时出现 */}
|
||
<PowerFlag label="DC" fail={detail.pow_fail_dc} />
|
||
<PowerFlag label="AC" fail={detail.pow_fail_ac} />
|
||
</div>
|
||
|
||
{/* 柜级状态位(协议 §3.8):**归属层级 = 柜子**,仓体卡片里不出现这些位 */}
|
||
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap', alignItems: 'center' }}>
|
||
{/* 柜门(协议 §4.2):整柜共用一扇;平台下发的 open 只是"解锁",门要人拉开,
|
||
关门也由人操作;状态永远来自 status_post(约 60 秒一次),**不是实时量** */}
|
||
<Tooltip content="柜门状态来自设备最近一次上报(status_post,约 60 秒一次),非实时。平台下发的「开门」只是解锁,门需要人拉开;没有「关门」指令,关门也由人操作。">
|
||
<Tag color={doorTag.color} size="small">🚪 {doorTag.label}</Tag>
|
||
</Tooltip>
|
||
<span style={{ fontSize: 11, color: 'var(--color-text-3)' }}>(来自最近一次上报,约 60 秒一次,非实时)</span>
|
||
<span style={{ fontSize: 12, color: 'var(--color-text-2)' }}>柜级告警</span>
|
||
{cabinetTags === null ? (
|
||
// 无数据:绝不能显示成"无告警"(那会把"没上报"说成"正常")
|
||
<span style={{ fontSize: 11, color: 'var(--color-text-3)', background: 'var(--color-fill-2)', borderRadius: 4, padding: '2px 8px', lineHeight: '18px' }}>
|
||
未知(未上报)
|
||
</span>
|
||
) : cabinetTags.length === 0 ? (
|
||
<span style={{ fontSize: 11, color: '#16a34a', background: '#f6ffed', borderRadius: 4, padding: '2px 8px', lineHeight: '18px' }}>
|
||
无
|
||
</span>
|
||
) : (
|
||
cabinetTags.map((t) => <Tag key={t.label} color={t.color} size="small">{t.label}</Tag>)
|
||
)}
|
||
<span style={{ fontSize: 11, color: 'var(--color-text-4)' }}>
|
||
原始值 status1={detail.status1 ?? 'null'} / status2={detail.status2 ?? 'null'}
|
||
</span>
|
||
</div>
|
||
|
||
{/* 第三行:基本信息 + 操作按钮 */}
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
|
||
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--color-text-2)', flexWrap: 'wrap' }}>
|
||
<span>IMEI: <b style={{ color: 'var(--color-text-1)' }}>{detail.imei}</b> <IconCopy style={{ cursor: 'pointer', color: 'var(--color-text-3)', fontSize: 12 }} onClick={() => copyText(detail.imei, 'IMEI')} /></span>
|
||
<span>ICCID: <b style={{ color: 'var(--color-text-1)' }}>{detail.iccid || '-'}</b> {detail.iccid && <IconCopy style={{ cursor: 'pointer', color: 'var(--color-text-3)', fontSize: 12 }} onClick={() => copyText(detail.iccid!, 'ICCID')} />}</span>
|
||
<span>名称: <b style={{ color: 'var(--color-text-1)' }}>{detail.name || '-'}</b></span>
|
||
{/* 电表读数(整柜累计电能,Wh → 界面按 kWh 展示)。
|
||
⚠️ null = 从未上报过,必须显示 "-"(无数据)而不是 0 度。 */}
|
||
<span>
|
||
电表读数:{' '}
|
||
<b style={{ color: 'var(--color-text-1)' }}>
|
||
{detail.meter_wh == null ? '-' : `${(detail.meter_wh / 1000).toFixed(1)} kWh`}
|
||
</b>
|
||
{detail.meter_at && (
|
||
<span style={{ color: 'var(--color-text-4)', fontSize: 11 }}>
|
||
{' '}({detail.meter_at.slice(0, 19).replace('T', ' ')})
|
||
</span>
|
||
)}
|
||
</span>
|
||
</div>
|
||
<Space>
|
||
{/* 开门(柜级解锁):权限码 charge:open_door(与后端 check_permission 一致)。
|
||
⚠️ 这是**独立流程**,与订单/充电互不联动(协议 §5.7 / §7.2) */}
|
||
<Permission code="charge:open_door">
|
||
<Button size="small" loading={doorLoading} icon={<IconUnlock />} onClick={handleOpenDoor}>解锁柜门</Button>
|
||
</Permission>
|
||
<Button size="small" type="primary" status="success" loading={powLoading === 'on'} icon={<IconPlayArrow />} onClick={handlePowOn}>合闸</Button>
|
||
<Button size="small" type="primary" status="danger" loading={powLoading === 'off'} icon={<IconPoweroff />} onClick={handlePowOff}>分闸</Button>
|
||
<Button size="small" loading={resetLoading === 'cabinet'} icon={<IconRefresh />} onClick={handleCabinetReset}>重启</Button>
|
||
{/* 重置安全码:仅总管理员可见(后端权限码 device:auth_str:reset) */}
|
||
{isSuperAdmin && (
|
||
<Button size="small" loading={authLoading} icon={<IconRefresh />} onClick={handleRegenerateAuth}>重置安全码</Button>
|
||
)}
|
||
</Space>
|
||
</div>
|
||
|
||
{/* 第四行:充电模式(work_mode)— 可切换,需二次确认 */}
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 10, paddingTop: 10, borderTop: '1px solid var(--color-border)', flexWrap: 'wrap' }}>
|
||
<span style={{ fontSize: 12, color: 'var(--color-text-2)' }}>充电模式</span>
|
||
<Radio.Group
|
||
type="button"
|
||
size="small"
|
||
value={workMode ?? '0'}
|
||
disabled={workModeLoading}
|
||
onChange={(value) => handleWorkModeChange(String(value))}
|
||
options={[
|
||
{ label: WORK_MODE_LABELS['0'], value: '0' },
|
||
{ label: WORK_MODE_LABELS['1'], value: '1' },
|
||
]}
|
||
/>
|
||
<span style={{ fontSize: 11, color: 'var(--color-text-3)' }}>
|
||
{workMode === '1'
|
||
? '柜子检测到电池后自行开始充电,无需平台下发指令'
|
||
: '平台下发「开始充电」指令后柜子才充电'}
|
||
{workMode === null && '(设备未返回该字段,按默认模式展示)'}
|
||
</span>
|
||
</div>
|
||
</Card>
|
||
|
||
{/* 仓控板列表 */}
|
||
{detail.cabin_boards.map((board) => (
|
||
<Card
|
||
key={board.id}
|
||
title={`${detail.imei}-${board.board_id}`}
|
||
extra={
|
||
<Space size={8}>
|
||
<Button
|
||
size="mini"
|
||
loading={resetLoading === `board-${board.id}`}
|
||
icon={<IconRefresh />}
|
||
onClick={() => handleBoardReset(board.id, board.board_id)}
|
||
>
|
||
重启
|
||
</Button>
|
||
<IconCopy style={{ cursor: 'pointer', color: 'var(--color-text-3)', fontSize: 14 }} onClick={() => copyText(`${detail.imei}-${board.board_id}`, '仓板ID')} />
|
||
</Space>
|
||
}
|
||
size="small"
|
||
>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}>
|
||
{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 (
|
||
<div key={comp.id} style={{ border: '1px solid var(--color-border)', borderLeft: `3px solid ${st.color}`, borderRadius: 6, padding: '6px 8px', fontSize: 12 }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 3, flexWrap: 'wrap', rowGap: 3 }}>
|
||
{/* 左侧色条 = 通道业务状态(原有单一状态标签改为色条,两个独立指示见右侧) */}
|
||
<Tooltip content={`通道状态:${st.label}`}>
|
||
<span style={{ fontWeight: 600, fontSize: 13, display: 'inline-flex', alignItems: 'center', gap: 3 }}>
|
||
{detail.imei}-{board.board_id}-{comp.channel_id}
|
||
<IconCopy style={{ cursor: 'pointer', color: 'var(--color-text-4)', fontSize: 12 }} onClick={(e) => { e.stopPropagation(); copyText(`${detail.imei}-${board.board_id}-${comp.channel_id}`, '通道号'); }} />
|
||
</span>
|
||
</Tooltip>
|
||
{/* 两个独立指示:电池(蓝/绿系) + 电源(灰/橙系),字段缺失显示 "-" */}
|
||
<div style={{ display: 'flex', gap: 4, alignItems: 'center', flexShrink: 0 }}>
|
||
<Tooltip content={`电池状态(batt_status${comp.batt_status ? ` ${comp.batt_status}` : ''})`}>
|
||
<Tag color={batt ? batt.color : 'gray'} size="small">{batt ? batt.label : '-'}</Tag>
|
||
</Tooltip>
|
||
<Tooltip content={`电源状态(pow_status${comp.pow_status ? ` ${comp.pow_status}` : ''})`}>
|
||
<Tag color={pow ? pow.color : 'gray'} size="small">{pow ? pow.label : '-'}</Tag>
|
||
</Tooltip>
|
||
</div>
|
||
</div>
|
||
<div style={{ color: 'var(--color-text-2)', fontSize: 12, display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'nowrap', overflow: 'hidden' }}>
|
||
<span>{comp.voltage ?? '-'}mV</span>
|
||
<span>{comp.current ?? '-'}mA</span>
|
||
{comp.temp != null && <span>{formatTempK100(comp.temp)}</span>}
|
||
{/* 电量单位是 ‰(协议附 A:4500 = 45.0%),需 /10 换算,否则会大 10 倍 */}
|
||
{comp.soc != null && <span style={{ color: '#2563eb', fontWeight: 600 }}>{(comp.soc / 10).toFixed(1)}%</span>}
|
||
{comp.battery_id && <span style={{ color: 'var(--color-text-3)', flexShrink: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{comp.battery_id}</span>}
|
||
<div style={{ marginLeft: 'auto', display: 'flex', gap: 4, flexShrink: 0 }}>
|
||
<Button
|
||
size="mini"
|
||
type="primary"
|
||
status="success"
|
||
loading={compLoading.has(comp.id)}
|
||
disabled={comp.status !== 0}
|
||
onClick={() => handleCompOn(comp.id)}
|
||
>充电</Button>
|
||
<Button
|
||
size="mini"
|
||
type="primary"
|
||
status="danger"
|
||
loading={compLoading.has(comp.id)}
|
||
disabled={comp.status !== 1}
|
||
onClick={() => handleCompOff(comp.id)}
|
||
>停止</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</Card>
|
||
))}
|
||
|
||
{/* 声明式确认弹窗(兼容 React 19) */}
|
||
<Modal
|
||
title={confirmState?.title || ''}
|
||
visible={!!confirmState}
|
||
onCancel={closeConfirm}
|
||
onOk={handleConfirmOk}
|
||
okButtonProps={{ status: 'danger' }}
|
||
>
|
||
{confirmState?.content}
|
||
</Modal>
|
||
|
||
{/* 分配项目弹窗 */}
|
||
<Modal
|
||
title="分配项目"
|
||
visible={projectModalVisible}
|
||
onCancel={() => setProjectModalVisible(false)}
|
||
onOk={handleSaveProject}
|
||
confirmLoading={projectSaving}
|
||
style={{ width: 480 }}
|
||
>
|
||
<Space direction="vertical" style={{ width: '100%' }} size="medium">
|
||
{isSuperAdmin ? (
|
||
<Select
|
||
placeholder="选择组织"
|
||
style={{ width: '100%' }}
|
||
value={selectedOrgId}
|
||
onChange={handleOrgChange}
|
||
options={orgs.map((o) => ({ label: o.name, value: o.id }))}
|
||
/>
|
||
) : (
|
||
<div style={{ fontSize: 13, color: 'var(--color-text-2)' }}>
|
||
所属组织:{orgs.find((o) => o.id === selectedOrgId)?.name || '—'}
|
||
</div>
|
||
)}
|
||
<Select
|
||
placeholder="选择项目"
|
||
style={{ width: '100%' }}
|
||
value={selectedProjectId}
|
||
onChange={(v) => setSelectedProjectId(v as number)}
|
||
options={projects.map((p) => ({ label: p.name, value: p.id }))}
|
||
/>
|
||
</Space>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
}
|
||
|