12451 0441d91d1f feat(users+cabinets): 用户管理加角色过滤、数据范围仅操作员可配、柜子详情透出电表读数
## 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、消费链探测通过、二进制时间戳已核对)
2026-09-21 05:02:09 +08:00

766 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 柜子详情页
* - 显示柜子基本信息(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>
);
}