feat: 本次提交更新内容如下

设备详情搞定
This commit is contained in:
笔记本里的永平
2025-07-22 17:19:12 +08:00
parent 47f85ee35a
commit a66e35b77b
3 changed files with 469 additions and 439 deletions

View File

@@ -1,4 +1,5 @@
# 基础环境变量示例 # 基础环境变量示例
VITE_API_BASE_URL=http://www.yishi.com VITE_API_BASE_URL=http://www.yishi.com
VITE_API_BASE_URL=https://ckbapi.quwanzhi.com
VITE_APP_TITLE=Nkebao Base VITE_APP_TITLE=Nkebao Base

View File

@@ -1,439 +1,431 @@
import React, { useEffect, useRef, useState, useCallback } from "react"; import React, { useEffect, useRef, useState, useCallback } from "react";
import { NavBar, Popup, Tabs, Toast, SpinLoading, Dialog } from "antd-mobile"; import { NavBar, Popup, Tabs, Toast, SpinLoading, Dialog } from "antd-mobile";
import { Button, Input, Pagination, Checkbox } from "antd"; import { Button, Input, Pagination, Checkbox } from "antd";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { AddOutline, DeleteOutline } from "antd-mobile-icons"; import { AddOutline, DeleteOutline } from "antd-mobile-icons";
import { import {
ReloadOutlined, ReloadOutlined,
SearchOutlined, SearchOutlined,
QrcodeOutlined, QrcodeOutlined,
ArrowLeftOutlined, ArrowLeftOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import Layout from "@/components/Layout/Layout"; import Layout from "@/components/Layout/Layout";
import MeauMobile from "@/components/MeauMobile/MeauMoible"; import MeauMobile from "@/components/MeauMobile/MeauMoible";
import { import {
fetchDeviceList, fetchDeviceList,
fetchDeviceQRCode, fetchDeviceQRCode,
addDeviceByImei, addDeviceByImei,
deleteDevice, deleteDevice,
} from "@/api/devices"; } from "@/api/devices";
import type { Device } from "@/types/device"; import type { Device } from "@/types/device";
import { comfirm } from "@/utils/common";
const Devices: React.FC = () => {
// 设备列表相关 const Devices: React.FC = () => {
const [devices, setDevices] = useState<Device[]>([]); // 设备列表相关
const [loading, setLoading] = useState(false); const [devices, setDevices] = useState<Device[]>([]);
const [search, setSearch] = useState(""); const [loading, setLoading] = useState(false);
const [status, setStatus] = useState<"all" | "online" | "offline">("all"); const [search, setSearch] = useState("");
const [page, setPage] = useState(1); const [status, setStatus] = useState<"all" | "online" | "offline">("all");
const [hasMore, setHasMore] = useState(true); const [page, setPage] = useState(1);
const [total, setTotal] = useState(0); const [hasMore, setHasMore] = useState(true);
const [selected, setSelected] = useState<(string | number)[]>([]); const [total, setTotal] = useState(0);
const observerRef = useRef<HTMLDivElement>(null); const [selected, setSelected] = useState<(string | number)[]>([]);
const [usePagination, setUsePagination] = useState(true); // 新增:是否使用分页 const observerRef = useRef<HTMLDivElement>(null);
const [usePagination, setUsePagination] = useState(true); // 新增:是否使用分页
// 添加设备弹窗
const [addVisible, setAddVisible] = useState(false); // 添加设备弹窗
const [addTab, setAddTab] = useState("scan"); const [addVisible, setAddVisible] = useState(false);
const [qrLoading, setQrLoading] = useState(false); const [addTab, setAddTab] = useState("scan");
const [qrCode, setQrCode] = useState<string | null>(null); const [qrLoading, setQrLoading] = useState(false);
const [imei, setImei] = useState(""); const [qrCode, setQrCode] = useState<string | null>(null);
const [name, setName] = useState(""); const [imei, setImei] = useState("");
const [addLoading, setAddLoading] = useState(false); const [name, setName] = useState("");
const [addLoading, setAddLoading] = useState(false);
// 删除弹窗
const [delVisible, setDelVisible] = useState(false); // 删除弹窗
const [delLoading, setDelLoading] = useState(false); const [delVisible, setDelVisible] = useState(false);
const [delLoading, setDelLoading] = useState(false);
const navigate = useNavigate();
// 加载设备列表 const navigate = useNavigate();
const loadDevices = useCallback( // 加载设备列表
async (reset = false) => { const loadDevices = useCallback(
if (loading) return; async (reset = false) => {
setLoading(true); if (loading) return;
try { setLoading(true);
const params: any = { page: reset ? 1 : page, limit: 20 }; try {
if (search) params.keyword = search; const params: any = { page: reset ? 1 : page, limit: 20 };
const res = await fetchDeviceList(params); if (search) params.keyword = search;
const list = Array.isArray(res.list) ? res.list : []; const res = await fetchDeviceList(params);
setDevices((prev) => (reset ? list : [...prev, ...list])); const list = Array.isArray(res.list) ? res.list : [];
setTotal(res.total || 0); setDevices((prev) => (reset ? list : [...prev, ...list]));
setHasMore(list.length === 20); setTotal(res.total || 0);
if (reset) setPage(1); setHasMore(list.length === 20);
} catch (e) { if (reset) setPage(1);
Toast.show({ content: "获取设备列表失败", position: "top" }); } catch (e) {
setHasMore(false); // 请求失败后不再继续请求 Toast.show({ content: "获取设备列表失败", position: "top" });
} finally { setHasMore(false); // 请求失败后不再继续请求
setLoading(false); } finally {
} setLoading(false);
}, }
[loading, search, page] },
); [loading, search, page]
);
// 首次加载和搜索
useEffect(() => { // 首次加载和搜索
loadDevices(true); useEffect(() => {
// eslint-disable-next-line loadDevices(true);
}, [search]); // eslint-disable-next-line
}, [search]);
// 无限滚动
useEffect(() => { // 无限滚动
if (!hasMore || loading) return; useEffect(() => {
const observer = new window.IntersectionObserver( if (!hasMore || loading) return;
(entries) => { const observer = new window.IntersectionObserver(
if (entries[0].isIntersecting && hasMore && !loading) { (entries) => {
setPage((p) => p + 1); if (entries[0].isIntersecting && hasMore && !loading) {
} setPage((p) => p + 1);
}, }
{ threshold: 0.5 } },
); { threshold: 0.5 }
if (observerRef.current) observer.observe(observerRef.current); );
return () => observer.disconnect(); if (observerRef.current) observer.observe(observerRef.current);
}, [hasMore, loading]); return () => observer.disconnect();
}, [hasMore, loading]);
// 分页加载
useEffect(() => { // 分页加载
if (page === 1) return; useEffect(() => {
loadDevices(); if (page === 1) return;
// eslint-disable-next-line loadDevices();
}, [page]); // eslint-disable-next-line
}, [page]);
// 状态筛选
const filtered = devices.filter((d) => { // 状态筛选
if (status === "all") return true; const filtered = devices.filter((d) => {
if (status === "online") return d.status === "online" || d.alive === 1; if (status === "all") return true;
if (status === "offline") return d.status === "offline" || d.alive === 0; if (status === "online") return d.status === "online" || d.alive === 1;
return true; if (status === "offline") return d.status === "offline" || d.alive === 0;
}); return true;
});
// 获取二维码
const handleGetQr = async () => { // 获取二维码
setQrLoading(true); const handleGetQr = async () => {
setQrCode(null); setQrLoading(true);
try { setQrCode(null);
const accountId = localStorage.getItem("s2_accountId") || ""; try {
if (!accountId) throw new Error("未获取到用户信息"); const accountId = localStorage.getItem("s2_accountId") || "";
const res = await fetchDeviceQRCode(accountId); if (!accountId) throw new Error("未获取到用户信息");
setQrCode(res.qrCode); const res = await fetchDeviceQRCode(accountId);
} catch (e: any) { setQrCode(res.qrCode);
Toast.show({ content: e.message || "获取二维码失败", position: "top" }); } catch (e: any) {
} finally { Toast.show({ content: e.message || "获取二维码失败", position: "top" });
setQrLoading(false); } finally {
} setQrLoading(false);
}; }
};
// 手动添加设备
const handleAddDevice = async () => { // 手动添加设备
if (!imei.trim() || !name.trim()) { const handleAddDevice = async () => {
Toast.show({ content: "请填写完整信息", position: "top" }); if (!imei.trim() || !name.trim()) {
return; Toast.show({ content: "请填写完整信息", position: "top" });
} return;
setAddLoading(true); }
try { setAddLoading(true);
await addDeviceByImei(imei, name); try {
Toast.show({ content: "添加成功", position: "top" }); await addDeviceByImei(imei, name);
setAddVisible(false); Toast.show({ content: "添加成功", position: "top" });
setImei(""); setAddVisible(false);
setName(""); setImei("");
loadDevices(true); setName("");
} catch (e: any) { loadDevices(true);
Toast.show({ content: e.message || "添加失败", position: "top" }); } catch (e: any) {
} finally { Toast.show({ content: e.message || "添加失败", position: "top" });
setAddLoading(false); } finally {
} setAddLoading(false);
}; }
};
// 删除设备
const handleDelete = async () => { // 删除设备
if (!selected.length) return; const handleDelete = async () => {
setDelLoading(true); setDelLoading(true);
try { try {
for (const id of selected) { for (const id of selected) {
await deleteDevice(Number(id)); await deleteDevice(Number(id));
} }
Toast.show({ content: `删除成功`, position: "top" }); Toast.show({ content: `删除成功`, position: "top" });
setDelVisible(false); setSelected([]);
setSelected([]); loadDevices(true);
loadDevices(true); } catch (e: any) {
} catch (e: any) { if (e) Toast.show({ content: e.message || "删除失败", position: "top" });
Toast.show({ content: e.message || "删除失败", position: "top" }); } finally {
} finally { setDelLoading(false);
setDelLoading(false); }
} };
};
// 删除按钮点击
// 跳转详情 const handleDeleteClick = async () => {
const goDetail = (id: string | number) => { try {
window.location.href = `/devices/${id}`; await comfirm(
}; `将删除${selected.length}个设备,删除后本设备配置的计划任务操作也将失效。确认删除?`,
{ title: "确认删除", confirmText: "确认删除", cancelText: "取消" }
// 分页切换 );
const handlePageChange = (p: number) => { handleDelete();
setPage(p); } catch {
loadDevices(true); // 用户取消,无需处理
}; }
};
return (
<Layout // 跳转详情
header={ const goDetail = (id: string | number) => {
<> window.location.href = `/devices/${id}`;
<NavBar };
back={null}
left={ // 分页切换
<div className="nav-title"> const handlePageChange = (p: number) => {
<ArrowLeftOutlined setPage(p);
twoToneColor="#1677ff" loadDevices(true);
onClick={() => navigate(-1)} };
/>
</div> return (
} <Layout
style={{ background: "#fff" }} header={
right={ <>
<Button <NavBar
size="small" back={null}
type="primary" left={
onClick={() => setAddVisible(true)} <div className="nav-title">
> <ArrowLeftOutlined
<AddOutline /> twoToneColor="#1677ff"
onClick={() => navigate(-1)}
</Button> />
} </div>
> }
<span style={{ color: "var(--primary-color)", fontWeight: 600 }}> style={{ background: "#fff" }}
right={
</span> <Button
</NavBar> size="small"
type="primary"
<div style={{ padding: "12px 12px 0 12px", background: "#fff" }}> onClick={() => setAddVisible(true)}
{/* 搜索栏 */} >
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}> <AddOutline />
<Input
placeholder="搜索设备IMEI/备注" </Button>
value={search} }
onChange={(e) => setSearch(e.target.value)} >
prefix={<SearchOutlined />} <span style={{ color: "var(--primary-color)", fontWeight: 600 }}>
allowClear
style={{ flex: 1 }} </span>
/> </NavBar>
<Button
onClick={() => loadDevices(true)} <div style={{ padding: "12px 12px 0 12px", background: "#fff" }}>
icon={<ReloadOutlined />} {/* 搜索栏 */}
> <div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
<Input
</Button> placeholder="搜索设备IMEI/备注"
</div> value={search}
{/* 筛选和删除 */} onChange={(e) => setSearch(e.target.value)}
<div style={{ display: "flex", gap: 8 }}> prefix={<SearchOutlined />}
<Tabs allowClear
activeKey={status} style={{ flex: 1 }}
onChange={(k) => setStatus(k as any)} />
style={{ flex: 1 }} <Button
> onClick={() => loadDevices(true)}
<Tabs.Tab title="全部" key="all" /> icon={<ReloadOutlined />}
<Tabs.Tab title="在线" key="online" /> >
<Tabs.Tab title="离线" key="offline" />
</Tabs> </Button>
<div style={{ paddingTop: 8 }}> </div>
<Button {/* 筛选和删除 */}
size="small" <div style={{ display: "flex", gap: 8 }}>
type="primary" <Tabs
danger activeKey={status}
icon={<DeleteOutline />} onChange={(k) => setStatus(k as any)}
disabled={selected.length === 0} style={{ flex: 1 }}
onClick={() => setDelVisible(true)} >
> <Tabs.Tab title="全部" key="all" />
<Tabs.Tab title="在线" key="online" />
</Button> <Tabs.Tab title="离线" key="offline" />
</div> </Tabs>
</div> <div style={{ paddingTop: 8 }}>
</div> <Button
</> size="small"
} type="primary"
footer={ danger
<div style={{ padding: 16, textAlign: "center", background: "#fff" }}> icon={<DeleteOutline />}
<Pagination disabled={selected.length === 0}
current={page} onClick={handleDeleteClick}
pageSize={20} >
total={total}
showSizeChanger={false} </Button>
onChange={handlePageChange} </div>
/> </div>
</div> </div>
} </>
loading={loading && devices.length === 0} }
> footer={
<div style={{ padding: 12 }}> <div style={{ padding: 16, textAlign: "center", background: "#fff" }}>
{/* 设备列表 */} <Pagination
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}> current={page}
{filtered.map((device) => ( pageSize={20}
<div total={total}
key={device.id} showSizeChanger={false}
style={{ onChange={handlePageChange}
background: "#fff", />
borderRadius: 12, </div>
padding: 12, }
boxShadow: "0 1px 4px #eee", loading={loading && devices.length === 0}
display: "flex", >
alignItems: "center", <div style={{ padding: 12 }}>
cursor: "pointer", {/* 设备列表 */}
border: selected.includes(device.id) <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
? "1.5px solid #1677ff" {filtered.map((device) => (
: "1px solid #f0f0f0", <div
}} key={device.id}
onClick={() => goDetail(device.id!)} style={{
> background: "#fff",
<Checkbox borderRadius: 12,
checked={selected.includes(device.id)} padding: 12,
onChange={(e) => { boxShadow: "0 1px 4px #eee",
e.stopPropagation(); display: "flex",
setSelected((prev) => alignItems: "center",
e.target.checked cursor: "pointer",
? [...prev, device.id!] border: selected.includes(device.id)
: prev.filter((id) => id !== device.id) ? "1.5px solid #1677ff"
); : "1px solid #f0f0f0",
}} }}
onClick={(e) => e.stopPropagation()} onClick={() => goDetail(device.id!)}
style={{ marginRight: 12 }} >
/> <Checkbox
<div style={{ flex: 1 }}> checked={selected.includes(device.id)}
<div style={{ fontWeight: 600, fontSize: 16 }}> onChange={(e) => {
{device.memo || "未命名设备"} e.stopPropagation();
</div> setSelected((prev) =>
<div style={{ fontSize: 14, color: "#999", marginTop: 2 }}> e.target.checked
IMEI: {device.imei} ? [...prev, device.id!]
</div> : prev.filter((id) => id !== device.id)
<div style={{ fontSize: 14, color: "#999", marginTop: 2 }}> );
: {device.wechatId || "未绑定"} }}
</div> onClick={(e) => e.stopPropagation()}
<div style={{ fontSize: 14, color: "#999", marginTop: 2 }}> style={{ marginRight: 12 }}
: {device.totalFriend ?? "-"} />
</div> <div style={{ flex: 1 }}>
</div> <div style={{ fontWeight: 600, fontSize: 16 }}>
<span {device.memo || "未命名设备"}
style={{ </div>
fontSize: 12, <div style={{ fontSize: 14, color: "#999", marginTop: 2 }}>
color: IMEI: {device.imei}
device.status === "online" || device.alive === 1 </div>
? "#52c41a" <div style={{ fontSize: 14, color: "#999", marginTop: 2 }}>
: "#aaa", : {device.wechatId || "未绑定"}
marginLeft: 8, </div>
}} <div style={{ fontSize: 14, color: "#999", marginTop: 2 }}>
> : {device.totalFriend ?? "-"}
{device.status === "online" || device.alive === 1 </div>
? "在线" </div>
: "离线"} <span
</span> style={{
</div> fontSize: 12,
))} color:
device.status === "online" || device.alive === 1
{/* 无限滚动提示(仅在不分页时显示) */} ? "#52c41a"
{!usePagination && ( : "#aaa",
<div marginLeft: 8,
ref={observerRef} }}
style={{ padding: 12, textAlign: "center", color: "#888" }} >
> {device.status === "online" || device.alive === 1
{loading && <SpinLoading style={{ "--size": "24px" }} />} ? "在线"
{!hasMore && devices.length > 0 && "没有更多设备了"} : "离线"}
{!hasMore && devices.length === 0 && "暂无设备"} </span>
</div> </div>
)} ))}
</div>
</div> {/* 无限滚动提示(仅在不分页时显示) */}
{/* 添加设备弹窗 */} {!usePagination && (
<Popup <div
visible={addVisible} ref={observerRef}
onMaskClick={() => setAddVisible(false)} style={{ padding: 12, textAlign: "center", color: "#888" }}
bodyStyle={{ >
borderTopLeftRadius: 16, {loading && <SpinLoading style={{ "--size": "24px" }} />}
borderTopRightRadius: 16, {!hasMore && devices.length > 0 && "没有更多设备了"}
minHeight: 320, {!hasMore && devices.length === 0 && "暂无设备"}
}} </div>
> )}
<div style={{ padding: 20 }}> </div>
<Tabs </div>
activeKey={addTab} {/* 添加设备弹窗 */}
onChange={setAddTab} <Popup
style={{ marginBottom: 16 }} visible={addVisible}
> onMaskClick={() => setAddVisible(false)}
<Tabs.Tab title="扫码添加" key="scan" /> bodyStyle={{
<Tabs.Tab title="手动添加" key="manual" /> borderTopLeftRadius: 16,
</Tabs> borderTopRightRadius: 16,
{addTab === "scan" && ( minHeight: 320,
<div style={{ textAlign: "center", minHeight: 200 }}> }}
<Button >
type="error" <div style={{ padding: 20 }}>
onClick={handleGetQr} <Tabs
loading={qrLoading} activeKey={addTab}
icon={<QrcodeOutlined />} onChange={setAddTab}
> style={{ marginBottom: 16 }}
>
</Button> <Tabs.Tab title="扫码添加" key="scan" />
{qrCode && ( <Tabs.Tab title="手动添加" key="manual" />
<div style={{ marginTop: 16 }}> </Tabs>
<img {addTab === "scan" && (
src={qrCode} <div style={{ textAlign: "center", minHeight: 200 }}>
alt="二维码" <Button
style={{ type="error"
width: 180, onClick={handleGetQr}
height: 180, loading={qrLoading}
background: "#f5f5f5", icon={<QrcodeOutlined />}
borderRadius: 8, >
}}
/> </Button>
<div style={{ color: "#888", fontSize: 12, marginTop: 8 }}> {qrCode && (
<div style={{ marginTop: 16 }}>
</div> <img
</div> src={qrCode}
)} alt="二维码"
</div> style={{
)} width: 180,
{addTab === "manual" && ( height: 180,
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}> background: "#f5f5f5",
<Input borderRadius: 8,
placeholder="设备名称" }}
value={name} />
onChange={(e) => setName(e.target.value)} <div style={{ color: "#888", fontSize: 12, marginTop: 8 }}>
allowClear
/> </div>
<Input </div>
placeholder="设备IMEI" )}
value={imei} </div>
onChange={(e) => setImei(e.target.value)} )}
allowClear {addTab === "manual" && (
/> <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<Button <Input
color="primary" placeholder="设备名称"
onClick={handleAddDevice} value={name}
loading={addLoading} onChange={(e) => setName(e.target.value)}
> allowClear
/>
</Button> <Input
</div> placeholder="设备IMEI"
)} value={imei}
</div> onChange={(e) => setImei(e.target.value)}
</Popup> allowClear
{/* 删除确认弹窗 */} />
<Dialog <Button
visible={delVisible} color="primary"
content={`将删除${selected.length}个设备,删除后本设备配置的计划任务操作也将失效。确认删除?`} onClick={handleAddDevice}
confirmText="确认删除" loading={addLoading}
cancelText="取消" >
onConfirm={handleDelete}
onCancel={() => setDelVisible(false)} </Button>
closeOnAction </div>
closeOnMaskClick )}
actions={[ </div>
{ </Popup>
key: "confirm", </Layout>
text: "确认删除", );
danger: true, };
// loading: delLoading, // antd-mobile Dialog.Action 不支持 loading 属性,去掉
}, export default Devices;
{ key: "cancel", text: "取消" },
]}
/>
</Layout>
);
};
export default Devices;

View File

@@ -0,0 +1,37 @@
import { Modal } from "antd-mobile";
/**
* 通用js调用弹窗Promise风格
* @param content 弹窗内容
* @param config 配置项title, cancelText, confirmText
* @returns Promise<void>
*/
export const comfirm = (
content: string,
config?: {
title?: string;
cancelText?: string;
confirmText?: string;
}
): Promise<void> => {
return new Promise((resolve, reject) => {
Modal.show({
title: config?.title || "提示",
content,
closeOnAction: true,
actions: [
{
key: "cancel",
text: config?.cancelText || "取消",
onClick: () => reject(),
},
{
key: "confirm",
text: config?.confirmText || "确认",
danger: true,
onClick: () => resolve(),
},
],
});
});
};