2026-07-02 05:38:01 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 柜子详情页
|
2026-09-19 23:40:46 +08:00
|
|
|
|
* - 显示柜子基本信息(ID、状态、充电模式)
|
2026-09-20 02:44:51 +08:00
|
|
|
|
* - **柜级**状态位(协议 §3.8:`status1`/`status2`)+ 柜门(整柜共用一扇,数据来自 `status_post`)
|
2026-09-19 23:40:46 +08:00
|
|
|
|
* - 按仓控板分组展示 6 个通道的状态卡片(电池状态 / 电源状态两个独立维度)
|
2026-09-20 02:44:51 +08:00
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 展示层级(产品已确认,协议 §3.8):
|
|
|
|
|
|
* - **柜级**位域(断电/烟雾/水位/倾倒/柜门/加热风扇/接触器/喷淋/声光报警/柜门超时)只在"柜子"层面出现;
|
|
|
|
|
|
* - **仓体级**只有 `batt_status` / `pow_status`,仓体卡片里**不出现任何"门"状态**
|
|
|
|
|
|
* (协议 §4.2 L403:柜门整柜共用一扇,**没有"按仓体开门"的指令**)。
|
2026-07-02 05:38:01 +08:00
|
|
|
|
*/
|
|
|
|
|
|
import { useEffect, useState } from 'react';
|
|
|
|
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
|
|
|
|
import {
|
|
|
|
|
|
Card,
|
|
|
|
|
|
Tag,
|
|
|
|
|
|
Typography,
|
2026-07-04 04:03:02 +08:00
|
|
|
|
Space,
|
2026-07-02 05:38:01 +08:00
|
|
|
|
Spin,
|
|
|
|
|
|
Button,
|
2026-07-02 17:27:32 +08:00
|
|
|
|
Message,
|
|
|
|
|
|
Modal,
|
2026-09-19 23:40:46 +08:00
|
|
|
|
Switch,
|
|
|
|
|
|
Select,
|
|
|
|
|
|
Radio,
|
|
|
|
|
|
Tooltip,
|
2026-07-02 05:38:01 +08:00
|
|
|
|
} from '@arco-design/web-react';
|
2026-09-19 23:40:46 +08:00
|
|
|
|
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';
|
2026-09-20 02:44:51 +08:00
|
|
|
|
import { Permission } from '@/components/Permission';
|
|
|
|
|
|
import {
|
|
|
|
|
|
cabinetDoorTag,
|
|
|
|
|
|
cabinetStatusTags,
|
|
|
|
|
|
formatTempK100,
|
|
|
|
|
|
normalizeFlag,
|
|
|
|
|
|
parseBattStatus,
|
|
|
|
|
|
parsePowStatus,
|
|
|
|
|
|
} from '@/utils/deviceStatus';
|
2026-07-02 05:38:01 +08:00
|
|
|
|
|
2026-09-20 02:44:51 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 通道(仓体)状态映射
|
|
|
|
|
|
*
|
|
|
|
|
|
* 口径:`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`)。
|
|
|
|
|
|
* 颜色取常识口径:空闲=灰 / 充电中=蓝 / 充满=绿 / 故障=红。
|
|
|
|
|
|
*/
|
2026-07-02 05:38:01 +08:00
|
|
|
|
const STATUS_MAP: Record<number, { label: string; color: string }> = {
|
2026-09-20 02:44:51 +08:00
|
|
|
|
0: { label: '空闲', color: 'gray' },
|
2026-07-02 05:38:01 +08:00
|
|
|
|
1: { label: '充电中', color: 'blue' },
|
2026-09-20 02:44:51 +08:00
|
|
|
|
2: { label: '充满', color: 'green' },
|
|
|
|
|
|
3: { label: '故障', color: 'red' },
|
2026-07-02 05:38:01 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-20 04:31:42 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 柜子**配置态**映射(`cabinets.status`):只有「启用 / 禁用」两档。
|
|
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 这里**不含"在线/离线"**:`cabinets.status` 是启用/禁用配置态,
|
|
|
|
|
|
* 全仓没有任何写入方会把它写成 `0`(离线),拿它判在线会让"一台设备都没连"时
|
|
|
|
|
|
* 依然显示"在线"。实时在线一律看接口的 `is_online`(Redis,见 `onlineTag`)。
|
|
|
|
|
|
* ⛔ 不要再往本表里加"离线"档。
|
|
|
|
|
|
*/
|
2026-07-02 05:38:01 +08:00
|
|
|
|
const CABINET_STATUS_MAP: Record<number, { label: string; color: string }> = {
|
2026-09-20 04:31:42 +08:00
|
|
|
|
1: { label: '启用', color: 'green' },
|
2026-09-19 23:40:46 +08:00
|
|
|
|
2: { label: '禁用', color: 'orange' },
|
2026-07-02 05:38:01 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-19 23:40:46 +08:00
|
|
|
|
/** 充电工作模式文案(协议: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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-02 05:38:01 +08:00
|
|
|
|
export default function CabinetDetail() {
|
|
|
|
|
|
const { id } = useParams<{ id: string }>();
|
|
|
|
|
|
const navigate = useNavigate();
|
2026-09-19 23:40:46 +08:00
|
|
|
|
const { user: currentUser } = useAuthStore();
|
|
|
|
|
|
const isSuperAdmin = (currentUser?.role ?? 0) >= 2;
|
2026-07-02 05:38:01 +08:00
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
const [detail, setDetail] = useState<CabinetDetail | null>(null);
|
2026-09-19 23:40:46 +08:00
|
|
|
|
const [powLoading, setPowLoading] = useState<'none' | 'on' | 'off'>('none');
|
2026-07-04 04:03:02 +08:00
|
|
|
|
const [doorLoading, setDoorLoading] = useState(false);
|
2026-09-19 23:40:46 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2026-07-04 04:03:02 +08:00
|
|
|
|
|
|
|
|
|
|
/** 复制到剪贴板 */
|
|
|
|
|
|
const copyText = (text: string, label: string) => {
|
2026-09-19 23:40:46 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-07-04 04:03:02 +08:00
|
|
|
|
};
|
2026-07-02 05:38:01 +08:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!id) return;
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
cabinetApi
|
|
|
|
|
|
.getDetail(Number(id))
|
|
|
|
|
|
.then((res) => setDetail(res.data))
|
|
|
|
|
|
.catch(() => {
|
|
|
|
|
|
// 如果详情接口不可用,显示提示
|
|
|
|
|
|
})
|
|
|
|
|
|
.finally(() => setLoading(false));
|
|
|
|
|
|
}, [id]);
|
|
|
|
|
|
|
2026-09-19 23:40:46 +08:00
|
|
|
|
/** 开门(柜级:整柜共用一扇门,只解锁,需人拉开) */
|
|
|
|
|
|
const handleOpenDoor = () => {
|
2026-07-02 17:27:32 +08:00
|
|
|
|
if (!id) return;
|
2026-09-19 23:40:46 +08:00
|
|
|
|
openConfirm(
|
2026-09-20 02:44:51 +08:00
|
|
|
|
'解锁柜门(下发的是解锁,门要人拉开)',
|
|
|
|
|
|
'柜门是整柜共用的一扇:平台下发的是「解锁」,解锁后需要人到现场把门拉开,门不会自动打开,'
|
|
|
|
|
|
+ '柜门打开状态最迟 60 秒内随设备上报更新(非实时)。平台没有「关门」指令,关门也由人操作。\n'
|
|
|
|
|
|
+ '⚠️ 开门与充电/订单没有任何联动,不会影响正在进行的充电。',
|
2026-09-19 23:40:46 +08:00
|
|
|
|
async () => {
|
|
|
|
|
|
setDoorLoading(true);
|
2026-07-02 17:27:32 +08:00
|
|
|
|
try {
|
2026-09-20 02:44:51 +08:00
|
|
|
|
// 柜级指令:不带 id(协议 §4.2);不触碰订单与充电
|
2026-09-19 23:40:46 +08:00
|
|
|
|
await cabinetApi.openCabinet(Number(id));
|
2026-09-20 02:44:51 +08:00
|
|
|
|
Message.success('解锁指令已发送,请到现场拉开柜门');
|
|
|
|
|
|
} catch (e: unknown) {
|
|
|
|
|
|
Message.error(e instanceof Error ? e.message : '开门失败');
|
2026-07-02 17:27:32 +08:00
|
|
|
|
} finally {
|
2026-09-19 23:40:46 +08:00
|
|
|
|
setDoorLoading(false);
|
2026-07-02 17:27:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-09-19 23:40:46 +08:00
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/** 接触器合闸 */
|
|
|
|
|
|
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');
|
|
|
|
|
|
}
|
2026-07-02 17:27:32 +08:00
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/** 接触器分闸 */
|
|
|
|
|
|
const handlePowOff = () => {
|
|
|
|
|
|
if (!id) return;
|
2026-09-19 23:40:46 +08:00
|
|
|
|
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');
|
2026-07-02 17:27:32 +08:00
|
|
|
|
try {
|
2026-09-19 23:40:46 +08:00
|
|
|
|
await cabinetApi.resetCabinet(Number(id));
|
|
|
|
|
|
Message.success('重启指令已发送');
|
2026-07-02 17:27:32 +08:00
|
|
|
|
} catch {
|
2026-09-19 23:40:46 +08:00
|
|
|
|
Message.error('重启失败');
|
2026-07-02 17:27:32 +08:00
|
|
|
|
} finally {
|
2026-09-19 23:40:46 +08:00
|
|
|
|
setResetLoading(null);
|
2026-07-02 17:27:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-09-19 23:40:46 +08:00
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/** 重启单块仓控板(协议 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);
|
|
|
|
|
|
}
|
2026-07-02 17:27:32 +08:00
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-02 05:38:01 +08:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-20 04:31:42 +08:00
|
|
|
|
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' };
|
2026-09-19 23:40:46 +08:00
|
|
|
|
/** 充电工作模式:后端未返回时为 null(界面上按默认「平台指令模式」展示并标注) */
|
|
|
|
|
|
const workMode: '0' | '1' | null = detail.work_mode === '1' ? '1' : detail.work_mode === '0' ? '0' : null;
|
2026-09-20 02:44:51 +08:00
|
|
|
|
/** 柜级柜门三态(协议 §4.2):`status1` bit4;无数据 → 「未知」(绝不显示成"关闭") */
|
|
|
|
|
|
const doorTag = cabinetDoorTag(detail.status1);
|
|
|
|
|
|
/** 柜级告警标签(协议 §3.8):`null` = 该维度无数据(与"确实无告警"的 `[]` 区分) */
|
|
|
|
|
|
const cabinetTags = cabinetStatusTags(detail.status1, detail.status2);
|
2026-07-02 05:38:01 +08:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
2026-09-19 23:40:46 +08:00
|
|
|
|
{/* 柜子信息 */}
|
|
|
|
|
|
<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>
|
2026-09-20 04:31:42 +08:00
|
|
|
|
{/* 实时在线(Redis `device:{imei}.online`,接口 `is_online`):三态,无数据 → 「在线未知」 */}
|
|
|
|
|
|
<Tag color={onlineTag.color} size="small">{onlineTag.label}</Tag>
|
|
|
|
|
|
{/* 配置态(启用/禁用):与上面"是否在线"是两个维度,同时展示 */}
|
2026-09-19 23:40:46 +08:00
|
|
|
|
<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(); }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-07-02 05:38:01 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-09-19 23:40:46 +08:00
|
|
|
|
{/* 第二行:状态指标(带背景的徽章式) */}
|
|
|
|
|
|
<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' }}>
|
2026-09-20 00:36:37 +08:00
|
|
|
|
🌡 柜温: <b style={{ color: 'var(--color-text-1)' }}>{formatTempK100(detail.temp, '--°C')}</b>
|
2026-09-19 23:40:46 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
{/* 停电状态(协议 §5.4):直流 / 交流两个独立事件,可同时出现 */}
|
|
|
|
|
|
<PowerFlag label="DC" fail={detail.pow_fail_dc} />
|
|
|
|
|
|
<PowerFlag label="AC" fail={detail.pow_fail_ac} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-09-20 02:44:51 +08:00
|
|
|
|
{/* 柜级状态位(协议 §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>
|
|
|
|
|
|
|
2026-09-19 23:40:46 +08:00
|
|
|
|
{/* 第三行:基本信息 + 操作按钮 */}
|
|
|
|
|
|
<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>
|
2026-09-21 05:02:09 +08:00
|
|
|
|
{/* 电表读数(整柜累计电能,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>
|
2026-09-19 23:40:46 +08:00
|
|
|
|
</div>
|
2026-07-04 04:03:02 +08:00
|
|
|
|
<Space>
|
2026-09-20 02:44:51 +08:00
|
|
|
|
{/* 开门(柜级解锁):权限码 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>
|
2026-09-19 23:40:46 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
2026-07-04 04:03:02 +08:00
|
|
|
|
</Space>
|
2026-09-19 23:40:46 +08:00
|
|
|
|
</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>
|
2026-07-02 05:38:01 +08:00
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 仓控板列表 */}
|
|
|
|
|
|
{detail.cabin_boards.map((board) => (
|
|
|
|
|
|
<Card
|
|
|
|
|
|
key={board.id}
|
2026-07-04 04:03:02 +08:00
|
|
|
|
title={`${detail.imei}-${board.board_id}`}
|
2026-09-19 23:40:46 +08:00
|
|
|
|
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>
|
|
|
|
|
|
}
|
2026-07-02 05:38:01 +08:00
|
|
|
|
size="small"
|
|
|
|
|
|
>
|
2026-07-04 04:03:02 +08:00
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}>
|
2026-07-02 05:38:01 +08:00
|
|
|
|
{board.compartments.map((comp) => {
|
|
|
|
|
|
const st = STATUS_MAP[comp.status] || { label: '未知', color: 'gray' };
|
2026-09-19 23:40:46 +08:00
|
|
|
|
// 协议 §3.8:电池状态与电源状态是两个相互独立维度,分别解析、分别展示
|
|
|
|
|
|
const batt = parseBattStatus(comp.batt_status);
|
|
|
|
|
|
const pow = parsePowStatus(comp.pow_status);
|
2026-07-02 05:38:01 +08:00
|
|
|
|
return (
|
2026-09-19 23:40:46 +08:00
|
|
|
|
<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>
|
2026-07-04 04:03:02 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div style={{ color: 'var(--color-text-2)', fontSize: 12, display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'nowrap', overflow: 'hidden' }}>
|
2026-09-20 00:36:37 +08:00
|
|
|
|
<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>}
|
2026-09-19 23:40:46 +08:00
|
|
|
|
<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>
|
2026-07-04 04:03:02 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-07-02 05:38:01 +08:00
|
|
|
|
);
|
|
|
|
|
|
})}
|
2026-07-04 04:03:02 +08:00
|
|
|
|
</div>
|
2026-07-02 05:38:01 +08:00
|
|
|
|
</Card>
|
|
|
|
|
|
))}
|
2026-09-19 23:40:46 +08:00
|
|
|
|
|
|
|
|
|
|
{/* 声明式确认弹窗(兼容 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>
|
2026-07-02 05:38:01 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-09-19 23:40:46 +08:00
|
|
|
|
|