Merge branch 'yongxu-dev3' of https://e.coding.net/g-xtcy5189/cunkebao/cunkebao_v3 into yongxu-dev3
# Conflicts: # nkebao/.env.development resolved by origin/yongxu-dev3(远端) version # nkebao/src/api/request.ts resolved by yongxu-dev3 version
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
44
nkebao/src/api/devices.ts
Normal file
44
nkebao/src/api/devices.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import request from "./request";
|
||||||
|
|
||||||
|
// 获取设备列表
|
||||||
|
export const fetchDeviceList = (params: {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
keyword?: string;
|
||||||
|
}) => request("/v1/devices", params, "GET");
|
||||||
|
|
||||||
|
// 获取设备详情
|
||||||
|
export const fetchDeviceDetail = (id: string | number) =>
|
||||||
|
request(`/v1/devices/${id}`);
|
||||||
|
|
||||||
|
// 获取设备关联微信账号
|
||||||
|
export const fetchDeviceRelatedAccounts = (id: string | number) =>
|
||||||
|
request(`/v1/wechats/related-device/${id}`);
|
||||||
|
|
||||||
|
// 获取设备操作日志
|
||||||
|
export const fetchDeviceHandleLogs = (
|
||||||
|
id: string | number,
|
||||||
|
page = 1,
|
||||||
|
limit = 10
|
||||||
|
) => request(`/v1/devices/${id}/handle-logs`, { page, limit }, "GET");
|
||||||
|
|
||||||
|
// 更新设备任务配置
|
||||||
|
export const updateDeviceTaskConfig = (config: {
|
||||||
|
deviceId: string | number;
|
||||||
|
autoAddFriend?: boolean;
|
||||||
|
autoReply?: boolean;
|
||||||
|
momentsSync?: boolean;
|
||||||
|
aiChat?: boolean;
|
||||||
|
}) => request("/v1/devices/task-config", config, "POST");
|
||||||
|
|
||||||
|
// 删除设备
|
||||||
|
export const deleteDevice = (id: number) =>
|
||||||
|
request(`/v1/devices/${id}`, undefined, "DELETE");
|
||||||
|
|
||||||
|
// 获取设备二维码
|
||||||
|
export const fetchDeviceQRCode = (accountId: string) =>
|
||||||
|
request("/v1/api/device/add", { accountId }, "POST");
|
||||||
|
|
||||||
|
// 通过IMEI添加设备
|
||||||
|
export const addDeviceByImei = (imei: string, name: string) =>
|
||||||
|
request("/v1/api/device/add-by-imei", { imei, name }, "POST");
|
||||||
@@ -1,28 +1,369 @@
|
|||||||
import React from "react";
|
import React, { useEffect, useState, useCallback, useRef } from "react";
|
||||||
import { NavBar } from "antd-mobile";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import { NavBar, Tabs, Switch, Toast, SpinLoading, Button } from "antd-mobile";
|
||||||
|
import { SettingOutlined, RedoOutlined } 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 {
|
||||||
|
fetchDeviceDetail,
|
||||||
|
fetchDeviceRelatedAccounts,
|
||||||
|
fetchDeviceHandleLogs,
|
||||||
|
updateDeviceTaskConfig,
|
||||||
|
} from "@/api/devices";
|
||||||
|
import type { Device, WechatAccount, HandleLog } from "@/types/device";
|
||||||
|
|
||||||
const DeviceDetail: React.FC = () => {
|
const DeviceDetail: React.FC = () => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [device, setDevice] = useState<Device | null>(null);
|
||||||
|
const [tab, setTab] = useState("info");
|
||||||
|
const [accounts, setAccounts] = useState<WechatAccount[]>([]);
|
||||||
|
const [accountsLoading, setAccountsLoading] = useState(false);
|
||||||
|
const [logs, setLogs] = useState<HandleLog[]>([]);
|
||||||
|
const [logsLoading, setLogsLoading] = useState(false);
|
||||||
|
const [featureSaving, setFeatureSaving] = useState<{ [k: string]: boolean }>(
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 获取设备详情
|
||||||
|
const loadDetail = useCallback(async () => {
|
||||||
|
if (!id) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetchDeviceDetail(id);
|
||||||
|
setDevice(res);
|
||||||
|
} catch (e: any) {
|
||||||
|
Toast.show({ content: e.message || "获取设备详情失败", position: "top" });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
// 获取关联账号
|
||||||
|
const loadAccounts = useCallback(async () => {
|
||||||
|
if (!id) return;
|
||||||
|
setAccountsLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetchDeviceRelatedAccounts(id);
|
||||||
|
setAccounts(Array.isArray(res.accounts) ? res.accounts : []);
|
||||||
|
} catch (e: any) {
|
||||||
|
Toast.show({ content: e.message || "获取关联账号失败", position: "top" });
|
||||||
|
} finally {
|
||||||
|
setAccountsLoading(false);
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
// 获取操作日志
|
||||||
|
const loadLogs = useCallback(async () => {
|
||||||
|
if (!id) return;
|
||||||
|
setLogsLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetchDeviceHandleLogs(id, 1, 20);
|
||||||
|
setLogs(Array.isArray(res.list) ? res.list : []);
|
||||||
|
} catch (e: any) {
|
||||||
|
Toast.show({ content: e.message || "获取操作日志失败", position: "top" });
|
||||||
|
} finally {
|
||||||
|
setLogsLoading(false);
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadDetail();
|
||||||
|
// eslint-disable-next-line
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tab === "accounts") loadAccounts();
|
||||||
|
if (tab === "logs") loadLogs();
|
||||||
|
// eslint-disable-next-line
|
||||||
|
}, [tab]);
|
||||||
|
|
||||||
|
// 功能开关
|
||||||
|
const handleFeatureChange = async (
|
||||||
|
feature: keyof Device["features"],
|
||||||
|
checked: boolean
|
||||||
|
) => {
|
||||||
|
if (!id) return;
|
||||||
|
setFeatureSaving((prev) => ({ ...prev, [feature]: true }));
|
||||||
|
try {
|
||||||
|
await updateDeviceTaskConfig({ deviceId: id, [feature]: checked });
|
||||||
|
setDevice((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
features: { ...prev.features, [feature]: checked },
|
||||||
|
}
|
||||||
|
: prev
|
||||||
|
);
|
||||||
|
Toast.show({
|
||||||
|
content: `${getFeatureName(feature)}已${checked ? "开启" : "关闭"}`,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
Toast.show({ content: e.message || "设置失败", position: "top" });
|
||||||
|
} finally {
|
||||||
|
setFeatureSaving((prev) => ({ ...prev, [feature]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFeatureName = (feature: string) => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
autoAddFriend: "自动加好友",
|
||||||
|
autoReply: "自动回复",
|
||||||
|
momentsSync: "朋友圈同步",
|
||||||
|
aiChat: "AI会话",
|
||||||
|
};
|
||||||
|
return map[feature] || feature;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout
|
<Layout
|
||||||
header={
|
header={
|
||||||
<NavBar
|
<NavBar
|
||||||
backArrow
|
onBack={() => navigate(-1)}
|
||||||
style={{ background: "#fff" }}
|
style={{ background: "#fff" }}
|
||||||
onBack={() => window.history.back()}
|
right={
|
||||||
|
<Button size="small" color="primary">
|
||||||
|
<SettingOutlined />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div style={{ color: "var(--primary-color)", fontWeight: 600 }}>
|
<span style={{ color: "var(--primary-color)", fontWeight: 600 }}>
|
||||||
设备详情
|
设备详情
|
||||||
</div>
|
</span>
|
||||||
</NavBar>
|
</NavBar>
|
||||||
}
|
}
|
||||||
footer={<MeauMobile />}
|
loading={loading}
|
||||||
>
|
>
|
||||||
<div style={{ padding: 20, textAlign: "center", color: "#666" }}>
|
{!device ? (
|
||||||
<h3>设备详情页面</h3>
|
<div style={{ padding: 32, textAlign: "center", color: "#888" }}>
|
||||||
<p>此页面正在开发中...</p>
|
<SpinLoading style={{ "--size": "32px" }} />
|
||||||
</div>
|
<div style={{ marginTop: 16 }}>正在加载设备信息...</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ padding: 12 }}>
|
||||||
|
{/* 基本信息卡片 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#fff",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
marginBottom: 16,
|
||||||
|
boxShadow: "0 1px 4px #eee",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 18 }}>
|
||||||
|
{device.memo || "未命名设备"}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: "#888", marginTop: 4 }}>
|
||||||
|
IMEI: {device.imei}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: "#888", marginTop: 4 }}>
|
||||||
|
微信号: {device.wechatId || "未绑定"}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: "#888", marginTop: 4 }}>
|
||||||
|
好友数: {device.totalFriend ?? "-"}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color:
|
||||||
|
device.status === "online" || device.alive === 1
|
||||||
|
? "#52c41a"
|
||||||
|
: "#aaa",
|
||||||
|
marginTop: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{device.status === "online" || device.alive === 1
|
||||||
|
? "在线"
|
||||||
|
: "离线"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 标签页 */}
|
||||||
|
<Tabs activeKey={tab} onChange={setTab} style={{ marginBottom: 12 }}>
|
||||||
|
<Tabs.Tab title="功能开关" key="info" />
|
||||||
|
<Tabs.Tab title="关联账号" key="accounts" />
|
||||||
|
<Tabs.Tab title="操作日志" key="logs" />
|
||||||
|
</Tabs>
|
||||||
|
{/* 功能开关 */}
|
||||||
|
{tab === "info" && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#fff",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
boxShadow: "0 1px 4px #eee",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 18,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{["autoAddFriend", "autoReply", "momentsSync", "aiChat"].map(
|
||||||
|
(f) => (
|
||||||
|
<div
|
||||||
|
key={f}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500 }}>{getFeatureName(f)}</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={
|
||||||
|
!!device.features?.[f as keyof Device["features"]]
|
||||||
|
}
|
||||||
|
loading={!!featureSaving[f]}
|
||||||
|
onChange={(checked) =>
|
||||||
|
handleFeatureChange(
|
||||||
|
f as keyof Device["features"],
|
||||||
|
checked
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 关联账号 */}
|
||||||
|
{tab === "accounts" && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#fff",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
boxShadow: "0 1px 4px #eee",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{accountsLoading ? (
|
||||||
|
<div
|
||||||
|
style={{ textAlign: "center", color: "#888", padding: 32 }}
|
||||||
|
>
|
||||||
|
<SpinLoading />
|
||||||
|
</div>
|
||||||
|
) : accounts.length === 0 ? (
|
||||||
|
<div
|
||||||
|
style={{ textAlign: "center", color: "#aaa", padding: 32 }}
|
||||||
|
>
|
||||||
|
暂无关联微信账号
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{ display: "flex", flexDirection: "column", gap: 12 }}
|
||||||
|
>
|
||||||
|
{accounts.map((acc) => (
|
||||||
|
<div
|
||||||
|
key={acc.id}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 12,
|
||||||
|
background: "#f7f8fa",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={acc.avatar || "/placeholder.svg"}
|
||||||
|
alt={acc.nickname}
|
||||||
|
style={{
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
background: "#eee",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontWeight: 500 }}>{acc.nickname}</div>
|
||||||
|
<div style={{ fontSize: 12, color: "#888" }}>
|
||||||
|
微信号: {acc.wechatId}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: "#888" }}>
|
||||||
|
好友数: {acc.totalFriend}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: "#aaa" }}>
|
||||||
|
最后活跃: {acc.lastActive}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: acc.wechatAlive === 1 ? "#52c41a" : "#aaa",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{acc.wechatAliveText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ textAlign: "center", marginTop: 16 }}>
|
||||||
|
<Button size="small" onClick={loadAccounts}>
|
||||||
|
<RedoOutlined />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 操作日志 */}
|
||||||
|
{tab === "logs" && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#fff",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
boxShadow: "0 1px 4px #eee",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{logsLoading ? (
|
||||||
|
<div
|
||||||
|
style={{ textAlign: "center", color: "#888", padding: 32 }}
|
||||||
|
>
|
||||||
|
<SpinLoading />
|
||||||
|
</div>
|
||||||
|
) : logs.length === 0 ? (
|
||||||
|
<div
|
||||||
|
style={{ textAlign: "center", color: "#aaa", padding: 32 }}
|
||||||
|
>
|
||||||
|
暂无操作日志
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{ display: "flex", flexDirection: "column", gap: 12 }}
|
||||||
|
>
|
||||||
|
{logs.map((log) => (
|
||||||
|
<div
|
||||||
|
key={log.id}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 2,
|
||||||
|
background: "#f7f8fa",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 500 }}>{log.content}</div>
|
||||||
|
<div style={{ fontSize: 12, color: "#888" }}>
|
||||||
|
操作人: {log.username} · {log.createTime}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ textAlign: "center", marginTop: 16 }}>
|
||||||
|
<Button size="small" onClick={loadLogs}>
|
||||||
|
<RedoOutlined />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,35 +1,429 @@
|
|||||||
import React from "react";
|
import React, { useEffect, useRef, useState, useCallback } from "react";
|
||||||
import { NavBar, Button } from "antd-mobile";
|
import { NavBar, Popup, Tabs, Toast, SpinLoading, Dialog } from "antd-mobile";
|
||||||
import { AddOutline } from "antd-mobile-icons";
|
import { Button, Input, Pagination, Checkbox } from "antd";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { AddOutline, DeleteOutline } from "antd-mobile-icons";
|
||||||
|
import {
|
||||||
|
ReloadOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
QrcodeOutlined,
|
||||||
|
ArrowLeftOutlined,
|
||||||
|
} 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 {
|
||||||
|
fetchDeviceList,
|
||||||
|
fetchDeviceQRCode,
|
||||||
|
addDeviceByImei,
|
||||||
|
deleteDevice,
|
||||||
|
} from "@/api/devices";
|
||||||
|
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 [search, setSearch] = useState("");
|
||||||
|
const [status, setStatus] = useState<"all" | "online" | "offline">("all");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [selected, setSelected] = useState<(string | number)[]>([]);
|
||||||
|
const observerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [usePagination, setUsePagination] = useState(true); // 新增:是否使用分页
|
||||||
|
|
||||||
|
// 添加设备弹窗
|
||||||
|
const [addVisible, setAddVisible] = useState(false);
|
||||||
|
const [addTab, setAddTab] = useState("scan");
|
||||||
|
const [qrLoading, setQrLoading] = useState(false);
|
||||||
|
const [qrCode, setQrCode] = useState<string | null>(null);
|
||||||
|
const [imei, setImei] = useState("");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [addLoading, setAddLoading] = useState(false);
|
||||||
|
|
||||||
|
// 删除弹窗
|
||||||
|
const [delVisible, setDelVisible] = useState(false);
|
||||||
|
const [delLoading, setDelLoading] = useState(false);
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
// 加载设备列表
|
||||||
|
const loadDevices = useCallback(
|
||||||
|
async (reset = false) => {
|
||||||
|
if (loading) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params: any = { page: reset ? 1 : page, limit: 20 };
|
||||||
|
if (search) params.keyword = search;
|
||||||
|
const res = await fetchDeviceList(params);
|
||||||
|
const list = Array.isArray(res.list) ? res.list : [];
|
||||||
|
setDevices((prev) => (reset ? list : [...prev, ...list]));
|
||||||
|
setTotal(res.total || 0);
|
||||||
|
setHasMore(list.length === 20);
|
||||||
|
if (reset) setPage(1);
|
||||||
|
} catch (e) {
|
||||||
|
Toast.show({ content: "获取设备列表失败", position: "top" });
|
||||||
|
setHasMore(false); // 请求失败后不再继续请求
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loading, search, page]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 首次加载和搜索
|
||||||
|
useEffect(() => {
|
||||||
|
loadDevices(true);
|
||||||
|
// eslint-disable-next-line
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
// 无限滚动
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasMore || loading) return;
|
||||||
|
const observer = new window.IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0].isIntersecting && hasMore && !loading) {
|
||||||
|
setPage((p) => p + 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.5 }
|
||||||
|
);
|
||||||
|
if (observerRef.current) observer.observe(observerRef.current);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [hasMore, loading]);
|
||||||
|
|
||||||
|
// 分页加载
|
||||||
|
useEffect(() => {
|
||||||
|
if (page === 1) return;
|
||||||
|
loadDevices();
|
||||||
|
// eslint-disable-next-line
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
|
// 状态筛选
|
||||||
|
const filtered = devices.filter((d) => {
|
||||||
|
if (status === "all") return true;
|
||||||
|
if (status === "online") return d.status === "online" || d.alive === 1;
|
||||||
|
if (status === "offline") return d.status === "offline" || d.alive === 0;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取二维码
|
||||||
|
const handleGetQr = async () => {
|
||||||
|
setQrLoading(true);
|
||||||
|
setQrCode(null);
|
||||||
|
try {
|
||||||
|
const accountId = localStorage.getItem("s2_accountId") || "";
|
||||||
|
if (!accountId) throw new Error("未获取到用户信息");
|
||||||
|
const res = await fetchDeviceQRCode(accountId);
|
||||||
|
setQrCode(res.qrCode);
|
||||||
|
} catch (e: any) {
|
||||||
|
Toast.show({ content: e.message || "获取二维码失败", position: "top" });
|
||||||
|
} finally {
|
||||||
|
setQrLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 手动添加设备
|
||||||
|
const handleAddDevice = async () => {
|
||||||
|
if (!imei.trim() || !name.trim()) {
|
||||||
|
Toast.show({ content: "请填写完整信息", position: "top" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAddLoading(true);
|
||||||
|
try {
|
||||||
|
await addDeviceByImei(imei, name);
|
||||||
|
Toast.show({ content: "添加成功", position: "top" });
|
||||||
|
setAddVisible(false);
|
||||||
|
setImei("");
|
||||||
|
setName("");
|
||||||
|
loadDevices(true);
|
||||||
|
} catch (e: any) {
|
||||||
|
Toast.show({ content: e.message || "添加失败", position: "top" });
|
||||||
|
} finally {
|
||||||
|
setAddLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除设备
|
||||||
|
const handleDelete = async () => {
|
||||||
|
setDelLoading(true);
|
||||||
|
try {
|
||||||
|
for (const id of selected) {
|
||||||
|
await deleteDevice(Number(id));
|
||||||
|
}
|
||||||
|
Toast.show({ content: `删除成功`, position: "top" });
|
||||||
|
setSelected([]);
|
||||||
|
loadDevices(true);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e) Toast.show({ content: e.message || "删除失败", position: "top" });
|
||||||
|
} finally {
|
||||||
|
setDelLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除按钮点击
|
||||||
|
const handleDeleteClick = async () => {
|
||||||
|
try {
|
||||||
|
await comfirm(
|
||||||
|
`将删除${selected.length}个设备,删除后本设备配置的计划任务操作也将失效。确认删除?`,
|
||||||
|
{ title: "确认删除", confirmText: "确认删除", cancelText: "取消" }
|
||||||
|
);
|
||||||
|
handleDelete();
|
||||||
|
} catch {
|
||||||
|
// 用户取消,无需处理
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 跳转详情
|
||||||
|
const goDetail = (id: string | number) => {
|
||||||
|
window.location.href = `/devices/${id}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 分页切换
|
||||||
|
const handlePageChange = (p: number) => {
|
||||||
|
setPage(p);
|
||||||
|
loadDevices(true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout
|
<Layout
|
||||||
header={
|
header={
|
||||||
<NavBar
|
<>
|
||||||
back={null}
|
<NavBar
|
||||||
style={{ background: "#fff" }}
|
back={null}
|
||||||
left={
|
left={
|
||||||
<div style={{ color: "var(--primary-color)", fontWeight: 600 }}>
|
<div className="nav-title">
|
||||||
|
<ArrowLeftOutlined
|
||||||
|
twoToneColor="#1677ff"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
style={{ background: "#fff" }}
|
||||||
|
right={
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
onClick={() => setAddVisible(true)}
|
||||||
|
>
|
||||||
|
<AddOutline />
|
||||||
|
添加设备
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span style={{ color: "var(--primary-color)", fontWeight: 600 }}>
|
||||||
设备管理
|
设备管理
|
||||||
|
</span>
|
||||||
|
</NavBar>
|
||||||
|
|
||||||
|
<div style={{ padding: "12px 12px 0 12px", background: "#fff" }}>
|
||||||
|
{/* 搜索栏 */}
|
||||||
|
<div style={{ display: "flex", gap: 8, marginBottom: 12 }}>
|
||||||
|
<Input
|
||||||
|
placeholder="搜索设备IMEI/备注"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
prefix={<SearchOutlined />}
|
||||||
|
allowClear
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={() => loadDevices(true)}
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
{/* 筛选和删除 */}
|
||||||
right={
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
<Button size="small" color="primary">
|
<Tabs
|
||||||
<AddOutline />
|
activeKey={status}
|
||||||
<span style={{ marginLeft: 4, fontSize: 12 }}>添加设备</span>
|
onChange={(k) => setStatus(k as any)}
|
||||||
</Button>
|
style={{ flex: 1 }}
|
||||||
}
|
>
|
||||||
/>
|
<Tabs.Tab title="全部" key="all" />
|
||||||
|
<Tabs.Tab title="在线" key="online" />
|
||||||
|
<Tabs.Tab title="离线" key="offline" />
|
||||||
|
</Tabs>
|
||||||
|
<div style={{ paddingTop: 8 }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutline />}
|
||||||
|
disabled={selected.length === 0}
|
||||||
|
onClick={handleDeleteClick}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
footer={<MeauMobile />}
|
footer={
|
||||||
|
<div style={{ padding: 16, textAlign: "center", background: "#fff" }}>
|
||||||
|
<Pagination
|
||||||
|
current={page}
|
||||||
|
pageSize={20}
|
||||||
|
total={total}
|
||||||
|
showSizeChanger={false}
|
||||||
|
onChange={handlePageChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
loading={loading && devices.length === 0}
|
||||||
>
|
>
|
||||||
<div style={{ padding: 20, textAlign: "center", color: "#666" }}>
|
<div style={{ padding: 12 }}>
|
||||||
<h3>设备管理页面</h3>
|
{/* 设备列表 */}
|
||||||
<p>此页面正在开发中...</p>
|
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||||
|
{filtered.map((device) => (
|
||||||
|
<div
|
||||||
|
key={device.id}
|
||||||
|
style={{
|
||||||
|
background: "#fff",
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 12,
|
||||||
|
boxShadow: "0 1px 4px #eee",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
cursor: "pointer",
|
||||||
|
border: selected.includes(device.id)
|
||||||
|
? "1.5px solid #1677ff"
|
||||||
|
: "1px solid #f0f0f0",
|
||||||
|
}}
|
||||||
|
onClick={() => goDetail(device.id!)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={selected.includes(device.id)}
|
||||||
|
onChange={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelected((prev) =>
|
||||||
|
e.target.checked
|
||||||
|
? [...prev, device.id!]
|
||||||
|
: prev.filter((id) => id !== device.id)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ marginRight: 12 }}
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 16 }}>
|
||||||
|
{device.memo || "未命名设备"}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 14, color: "#999", marginTop: 2 }}>
|
||||||
|
IMEI: {device.imei}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 14, color: "#999", marginTop: 2 }}>
|
||||||
|
微信号: {device.wechatId || "未绑定"}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 14, color: "#999", marginTop: 2 }}>
|
||||||
|
好友数: {device.totalFriend ?? "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color:
|
||||||
|
device.status === "online" || device.alive === 1
|
||||||
|
? "#52c41a"
|
||||||
|
: "#aaa",
|
||||||
|
marginLeft: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{device.status === "online" || device.alive === 1
|
||||||
|
? "在线"
|
||||||
|
: "离线"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 无限滚动提示(仅在不分页时显示) */}
|
||||||
|
{!usePagination && (
|
||||||
|
<div
|
||||||
|
ref={observerRef}
|
||||||
|
style={{ padding: 12, textAlign: "center", color: "#888" }}
|
||||||
|
>
|
||||||
|
{loading && <SpinLoading style={{ "--size": "24px" }} />}
|
||||||
|
{!hasMore && devices.length > 0 && "没有更多设备了"}
|
||||||
|
{!hasMore && devices.length === 0 && "暂无设备"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* 添加设备弹窗 */}
|
||||||
|
<Popup
|
||||||
|
visible={addVisible}
|
||||||
|
onMaskClick={() => setAddVisible(false)}
|
||||||
|
bodyStyle={{
|
||||||
|
borderTopLeftRadius: 16,
|
||||||
|
borderTopRightRadius: 16,
|
||||||
|
minHeight: 320,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ padding: 20 }}>
|
||||||
|
<Tabs
|
||||||
|
activeKey={addTab}
|
||||||
|
onChange={setAddTab}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
>
|
||||||
|
<Tabs.Tab title="扫码添加" key="scan" />
|
||||||
|
<Tabs.Tab title="手动添加" key="manual" />
|
||||||
|
</Tabs>
|
||||||
|
{addTab === "scan" && (
|
||||||
|
<div style={{ textAlign: "center", minHeight: 200 }}>
|
||||||
|
<Button
|
||||||
|
type="error"
|
||||||
|
onClick={handleGetQr}
|
||||||
|
loading={qrLoading}
|
||||||
|
icon={<QrcodeOutlined />}
|
||||||
|
>
|
||||||
|
获取二维码
|
||||||
|
</Button>
|
||||||
|
{qrCode && (
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<img
|
||||||
|
src={qrCode}
|
||||||
|
alt="二维码"
|
||||||
|
style={{
|
||||||
|
width: 180,
|
||||||
|
height: 180,
|
||||||
|
background: "#f5f5f5",
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ color: "#888", fontSize: 12, marginTop: 8 }}>
|
||||||
|
请用手机扫码添加设备
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{addTab === "manual" && (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
|
<Input
|
||||||
|
placeholder="设备名称"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="设备IMEI"
|
||||||
|
value={imei}
|
||||||
|
onChange={(e) => setImei(e.target.value)}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
onClick={handleAddDevice}
|
||||||
|
loading={addLoading}
|
||||||
|
>
|
||||||
|
添加
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
67
nkebao/src/types/device.ts
Normal file
67
nkebao/src/types/device.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
export type DeviceStatus = "online" | "offline" | "busy" | "error";
|
||||||
|
|
||||||
|
export interface Device {
|
||||||
|
id: number | string;
|
||||||
|
imei: string;
|
||||||
|
memo?: string;
|
||||||
|
wechatId?: string;
|
||||||
|
totalFriend?: number;
|
||||||
|
alive?: number;
|
||||||
|
status?: DeviceStatus;
|
||||||
|
nickname?: string;
|
||||||
|
battery?: number;
|
||||||
|
lastActive?: string;
|
||||||
|
features?: {
|
||||||
|
autoAddFriend?: boolean;
|
||||||
|
autoReply?: boolean;
|
||||||
|
momentsSync?: boolean;
|
||||||
|
aiChat?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceListResponse {
|
||||||
|
list: Device[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceDetailResponse {
|
||||||
|
id: number | string;
|
||||||
|
imei: string;
|
||||||
|
memo?: string;
|
||||||
|
wechatId?: string;
|
||||||
|
alive?: number;
|
||||||
|
totalFriend?: number;
|
||||||
|
nickname?: string;
|
||||||
|
battery?: number;
|
||||||
|
lastActive?: string;
|
||||||
|
features?: {
|
||||||
|
autoAddFriend?: boolean;
|
||||||
|
autoReply?: boolean;
|
||||||
|
momentsSync?: boolean;
|
||||||
|
aiChat?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WechatAccount {
|
||||||
|
id: string;
|
||||||
|
avatar: string;
|
||||||
|
nickname: string;
|
||||||
|
wechatId: string;
|
||||||
|
gender: number;
|
||||||
|
status: number;
|
||||||
|
statusText: string;
|
||||||
|
wechatAlive: number;
|
||||||
|
wechatAliveText: string;
|
||||||
|
addFriendStatus: number;
|
||||||
|
totalFriend: number;
|
||||||
|
lastActive: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HandleLog {
|
||||||
|
id: string | number;
|
||||||
|
content: string;
|
||||||
|
username: string;
|
||||||
|
createTime: string;
|
||||||
|
}
|
||||||
37
nkebao/src/utils/common.ts
Normal file
37
nkebao/src/utils/common.ts
Normal 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(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user