feat: 本次提交更新内容如下
定版本转移2025年7月17日
This commit is contained in:
@@ -1,869 +0,0 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import BackButton from '@/components/BackButton';
|
||||
import { useSimpleBack } from '@/hooks/useBackNavigation';
|
||||
import { Smartphone, Battery, Wifi, MessageCircle, Users, Settings, History, RefreshCw, Loader2 } from 'lucide-react';
|
||||
import { devicesApi, fetchDeviceDetail, fetchDeviceRelatedAccounts, fetchDeviceHandleLogs, updateDeviceTaskConfig } from '@/api/devices';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import Layout from '@/components/Layout';
|
||||
import BottomNav from '@/components/BottomNav';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
interface Device {
|
||||
id: string;
|
||||
imei: string;
|
||||
name: string;
|
||||
status: "online" | "offline";
|
||||
battery: number;
|
||||
lastActive: string;
|
||||
historicalIds: string[];
|
||||
wechatAccounts: WechatAccount[];
|
||||
features: {
|
||||
autoAddFriend: boolean;
|
||||
autoReply: boolean;
|
||||
momentsSync: boolean;
|
||||
aiChat: boolean;
|
||||
};
|
||||
history: {
|
||||
time: string;
|
||||
action: string;
|
||||
operator: string;
|
||||
}[];
|
||||
totalFriend: number;
|
||||
thirtyDayMsgCount: number;
|
||||
}
|
||||
|
||||
interface HandleLog {
|
||||
id: string | number;
|
||||
content: string;
|
||||
username: string;
|
||||
createTime: string;
|
||||
}
|
||||
|
||||
export default function DeviceDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { goBack } = useSimpleBack('/devices');
|
||||
const { toast } = useToast();
|
||||
const [device, setDevice] = useState<Device | null>(null);
|
||||
const [activeTab, setActiveTab] = useState("info");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [accountsLoading, setAccountsLoading] = useState(false);
|
||||
const [logsLoading, setLogsLoading] = useState(false);
|
||||
const [handleLogs, setHandleLogs] = useState<HandleLog[]>([]);
|
||||
const [logPage, setLogPage] = useState(1);
|
||||
const [hasMoreLogs, setHasMoreLogs] = useState(true);
|
||||
const logsPerPage = 10;
|
||||
const logsEndRef = useRef<HTMLDivElement>(null);
|
||||
const [savingFeatures, setSavingFeatures] = useState({
|
||||
autoAddFriend: false,
|
||||
autoReply: false,
|
||||
momentsSync: false,
|
||||
aiChat: false
|
||||
});
|
||||
|
||||
const [accountPage, setAccountPage] = useState(1);
|
||||
const [hasMoreAccounts, setHasMoreAccounts] = useState(true);
|
||||
const accountsPerPage = 10;
|
||||
const accountsEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 获取设备详情
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
|
||||
const fetchDevice = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetchDeviceDetail(id);
|
||||
|
||||
if (response && response.code === 200 && response.data) {
|
||||
const serverData = response.data;
|
||||
|
||||
// 构建符合前端期望格式的设备对象
|
||||
const formattedDevice: Device = {
|
||||
id: serverData.id?.toString() || "",
|
||||
imei: serverData.imei || "",
|
||||
name: serverData.memo || "未命名设备",
|
||||
status: serverData.alive === 1 ? "online" : "offline",
|
||||
battery: serverData.battery || 0,
|
||||
lastActive: serverData.lastUpdateTime || new Date().toISOString(),
|
||||
historicalIds: [],
|
||||
wechatAccounts: [],
|
||||
history: [],
|
||||
features: {
|
||||
autoAddFriend: false,
|
||||
autoReply: false,
|
||||
momentsSync: false,
|
||||
aiChat: false
|
||||
},
|
||||
totalFriend: serverData.totalFriend || 0,
|
||||
thirtyDayMsgCount: serverData.thirtyDayMsgCount || 0
|
||||
};
|
||||
|
||||
// 解析features
|
||||
if (serverData.features) {
|
||||
formattedDevice.features = {
|
||||
autoAddFriend: Boolean(serverData.features.autoAddFriend),
|
||||
autoReply: Boolean(serverData.features.autoReply),
|
||||
momentsSync: Boolean(serverData.features.momentsSync || serverData.features.contentSync),
|
||||
aiChat: Boolean(serverData.features.aiChat)
|
||||
};
|
||||
} else if (serverData.taskConfig) {
|
||||
try {
|
||||
const taskConfig = JSON.parse(serverData.taskConfig || '{}');
|
||||
|
||||
if (taskConfig) {
|
||||
formattedDevice.features = {
|
||||
autoAddFriend: Boolean(taskConfig.autoAddFriend),
|
||||
autoReply: Boolean(taskConfig.autoReply),
|
||||
momentsSync: Boolean(taskConfig.momentsSync),
|
||||
aiChat: Boolean(taskConfig.aiChat)
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('解析taskConfig失败:', err);
|
||||
}
|
||||
}
|
||||
|
||||
setDevice(formattedDevice);
|
||||
|
||||
// 获取设备任务配置
|
||||
await fetchTaskConfig();
|
||||
|
||||
// 如果当前激活标签是"accounts",则立即加载关联微信账号
|
||||
if (activeTab === "accounts") {
|
||||
fetchRelatedAccounts();
|
||||
}
|
||||
} else {
|
||||
toast({
|
||||
title: "获取设备信息失败",
|
||||
description: response.msg || "未知错误",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取设备信息失败:", error);
|
||||
toast({
|
||||
title: "获取设备信息失败",
|
||||
description: "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDevice();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id]);
|
||||
|
||||
// 获取设备关联微信账号
|
||||
const fetchRelatedAccounts = useCallback(async (page = 1) => {
|
||||
if (!id || accountsLoading) return;
|
||||
|
||||
try {
|
||||
setAccountsLoading(true);
|
||||
const response = await fetchDeviceRelatedAccounts(id);
|
||||
|
||||
if (response && response.code === 200 && response.data) {
|
||||
const accounts = response.data.accounts || [];
|
||||
|
||||
if (page === 1) {
|
||||
setDevice(prev => prev ? {
|
||||
...prev,
|
||||
wechatAccounts: accounts
|
||||
} : null);
|
||||
} else {
|
||||
setDevice(prev => prev ? {
|
||||
...prev,
|
||||
wechatAccounts: [...prev.wechatAccounts, ...accounts]
|
||||
} : null);
|
||||
}
|
||||
|
||||
setHasMoreAccounts(accounts.length === accountsPerPage);
|
||||
setAccountPage(page);
|
||||
} else {
|
||||
toast({
|
||||
title: "获取关联账号失败",
|
||||
description: response.msg || "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取关联账号失败:", error);
|
||||
toast({
|
||||
title: "获取关联账号失败",
|
||||
description: "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setAccountsLoading(false);
|
||||
}
|
||||
}, [id, accountsLoading, accountsPerPage, toast]);
|
||||
|
||||
// 获取操作记录
|
||||
const fetchHandleLogs = useCallback(async () => {
|
||||
if (!id || logsLoading) return;
|
||||
|
||||
try {
|
||||
setLogsLoading(true);
|
||||
const response = await fetchDeviceHandleLogs(id, logPage, logsPerPage);
|
||||
|
||||
if (response && response.code === 200 && response.data) {
|
||||
const logs = response.data.list || [];
|
||||
|
||||
if (logPage === 1) {
|
||||
setHandleLogs(logs);
|
||||
} else {
|
||||
setHandleLogs(prev => [...prev, ...logs]);
|
||||
}
|
||||
|
||||
setHasMoreLogs(logs.length === logsPerPage);
|
||||
} else {
|
||||
toast({
|
||||
title: "获取操作记录失败",
|
||||
description: response.msg || "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取操作记录失败:", error);
|
||||
toast({
|
||||
title: "获取操作记录失败",
|
||||
description: "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLogsLoading(false);
|
||||
}
|
||||
}, [id, logsLoading, logPage, logsPerPage, toast]);
|
||||
|
||||
// 加载更多操作记录
|
||||
const loadMoreLogs = useCallback(() => {
|
||||
if (logsLoading || !hasMoreLogs) return;
|
||||
setLogPage(prev => prev + 1);
|
||||
fetchHandleLogs();
|
||||
}, [logsLoading, hasMoreLogs, fetchHandleLogs]);
|
||||
|
||||
// 无限滚动加载操作记录
|
||||
useEffect(() => {
|
||||
if (!hasMoreLogs || logsLoading) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
if (entries[0].isIntersecting && hasMoreLogs && !logsLoading) {
|
||||
loadMoreLogs();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.5 }
|
||||
);
|
||||
|
||||
if (logsEndRef.current) {
|
||||
observer.observe(logsEndRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [hasMoreLogs, logsLoading, loadMoreLogs]);
|
||||
|
||||
// 获取任务配置
|
||||
const fetchTaskConfig = async () => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
const response = await devicesApi.getTaskConfig(id);
|
||||
if (response && response.code === 200 && response.data) {
|
||||
const config = response.data;
|
||||
setDevice(prev => prev ? {
|
||||
...prev,
|
||||
features: {
|
||||
autoAddFriend: Boolean(config.autoAddFriend),
|
||||
autoReply: Boolean(config.autoReply),
|
||||
momentsSync: Boolean(config.momentsSync),
|
||||
aiChat: Boolean(config.aiChat)
|
||||
}
|
||||
} : null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取任务配置失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 标签页切换处理
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value);
|
||||
|
||||
setTimeout(() => {
|
||||
if (value === "accounts" && device && (!device.wechatAccounts || device.wechatAccounts.length === 0)) {
|
||||
fetchRelatedAccounts(1);
|
||||
} else if (value === "history" && handleLogs.length === 0) {
|
||||
setLogPage(1);
|
||||
setHasMoreLogs(true);
|
||||
fetchHandleLogs();
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// 功能开关处理 - 只更新开关状态,不重新加载页面
|
||||
const handleFeatureChange = async (feature: keyof Device['features'], checked: boolean) => {
|
||||
if (!id) return;
|
||||
|
||||
// 立即更新UI状态,提供即时反馈
|
||||
setDevice(prev => prev ? {
|
||||
...prev,
|
||||
features: {
|
||||
...prev.features,
|
||||
[feature]: checked
|
||||
}
|
||||
} : null);
|
||||
|
||||
setSavingFeatures(prev => ({ ...prev, [feature]: true }));
|
||||
|
||||
try {
|
||||
const response = await updateDeviceTaskConfig({
|
||||
deviceId: id,
|
||||
[feature]: checked
|
||||
});
|
||||
|
||||
if (response && response.code === 200) {
|
||||
// 请求成功,显示成功提示
|
||||
toast({
|
||||
title: "设置成功",
|
||||
description: `${getFeatureName(feature)}已${checked ? '启用' : '禁用'}`,
|
||||
});
|
||||
} else {
|
||||
// 请求失败,回滚UI状态
|
||||
setDevice(prev => prev ? {
|
||||
...prev,
|
||||
features: {
|
||||
...prev.features,
|
||||
[feature]: !checked
|
||||
}
|
||||
} : null);
|
||||
|
||||
toast({
|
||||
title: "设置失败",
|
||||
description: response.msg || "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("设置功能失败:", error);
|
||||
|
||||
// 网络错误,回滚UI状态
|
||||
setDevice(prev => prev ? {
|
||||
...prev,
|
||||
features: {
|
||||
...prev.features,
|
||||
[feature]: !checked
|
||||
}
|
||||
} : null);
|
||||
|
||||
toast({
|
||||
title: "设置失败",
|
||||
description: "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setSavingFeatures(prev => ({ ...prev, [feature]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 获取功能名称
|
||||
const getFeatureName = (feature: string): string => {
|
||||
const names: Record<string, string> = {
|
||||
autoAddFriend: "自动加好友",
|
||||
autoReply: "自动回复",
|
||||
momentsSync: "朋友圈同步",
|
||||
aiChat: "AI会话"
|
||||
};
|
||||
return names[feature] || feature;
|
||||
};
|
||||
|
||||
// 加载更多账号
|
||||
const loadMoreAccounts = useCallback(() => {
|
||||
if (accountsLoading || !hasMoreAccounts) return;
|
||||
fetchRelatedAccounts(accountPage + 1);
|
||||
}, [accountsLoading, hasMoreAccounts, accountPage, fetchRelatedAccounts]);
|
||||
|
||||
// 无限滚动加载账号
|
||||
useEffect(() => {
|
||||
if (!hasMoreAccounts || accountsLoading) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
if (entries[0].isIntersecting && hasMoreAccounts && !accountsLoading) {
|
||||
loadMoreAccounts();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.5 }
|
||||
);
|
||||
|
||||
if (accountsEndRef.current) {
|
||||
observer.observe(accountsEndRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [hasMoreAccounts, accountsLoading, loadMoreAccounts]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout
|
||||
header={<PageHeader title="设备详情" defaultBackPath="/devices" rightContent={<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors"><Settings className="h-5 w-5" /></button>} />}
|
||||
footer={<BottomNav />}
|
||||
>
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
<p className="text-gray-500">加载设备信息中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!device) {
|
||||
return (
|
||||
<Layout
|
||||
header={<PageHeader title="设备详情" defaultBackPath="/devices" rightContent={<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors"><Settings className="h-5 w-5" /></button>} />}
|
||||
footer={<BottomNav />}
|
||||
>
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center space-y-4 p-6 bg-white rounded-xl shadow-sm max-w-md">
|
||||
<div className="w-12 h-12 flex items-center justify-center rounded-full bg-red-100">
|
||||
<Smartphone className="h-6 w-6 text-red-500" />
|
||||
</div>
|
||||
<div className="text-xl font-medium text-center">设备不存在或已被删除</div>
|
||||
<div className="text-sm text-gray-500 text-center">
|
||||
无法加载ID为 "{id}" 的设备信息,请检查设备是否存在。
|
||||
</div>
|
||||
<BackButton
|
||||
variant="button"
|
||||
text="返回上一页"
|
||||
onBack={goBack}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={<PageHeader title="设备详情" defaultBackPath="/devices" rightContent={<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors"><Settings className="h-5 w-5" /></button>} />}
|
||||
footer={<BottomNav />}
|
||||
>
|
||||
<div className="pb-20">
|
||||
<div className="p-4 space-y-4">
|
||||
{/* 设备基本信息卡片 */}
|
||||
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-blue-50 rounded-lg">
|
||||
<Smartphone className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold truncate">{device.name}</h2>
|
||||
<span className={`px-2.5 py-1 text-xs rounded-full font-medium ${
|
||||
device.status === "online"
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-gray-100 text-gray-600"
|
||||
}`}>
|
||||
{device.status === "online" ? "在线" : "离线"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
<span className="mr-1">IMEI:</span>
|
||||
{device.imei}
|
||||
</div>
|
||||
{device.historicalIds && device.historicalIds.length > 0 && (
|
||||
<div className="text-sm text-gray-500">历史ID: {device.historicalIds.join(", ")}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Battery className={`w-4 h-4 ${device.battery < 20 ? "text-red-500" : "text-green-500"}`} />
|
||||
<span className="text-sm">{device.battery}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Wifi className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-sm">{device.status === "online" ? "已连接" : "未连接"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-500">最后活跃:{device.lastActive}</div>
|
||||
</div>
|
||||
|
||||
{/* 标签页 */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div className="flex border-b border-gray-200">
|
||||
<button
|
||||
className={`flex-1 py-3 px-4 text-sm font-medium transition-colors ${
|
||||
activeTab === "info"
|
||||
? "text-blue-600 border-b-2 border-blue-600"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
onClick={() => handleTabChange("info")}
|
||||
>
|
||||
基本信息
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 py-3 px-4 text-sm font-medium transition-colors ${
|
||||
activeTab === "accounts"
|
||||
? "text-blue-600 border-b-2 border-blue-600"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
onClick={() => handleTabChange("accounts")}
|
||||
>
|
||||
关联账号
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 py-3 px-4 text-sm font-medium transition-colors ${
|
||||
activeTab === "history"
|
||||
? "text-blue-600 border-b-2 border-blue-600"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
onClick={() => handleTabChange("history")}
|
||||
>
|
||||
操作记录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 基本信息标签页 */}
|
||||
{activeTab === "info" && (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* 功能配置 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">自动加好友</div>
|
||||
<div className="text-xs text-gray-500">自动通过好友验证</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{savingFeatures.autoAddFriend && (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin text-blue-500" />
|
||||
)}
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(device.features.autoAddFriend)}
|
||||
onChange={(e) => handleFeatureChange('autoAddFriend', e.target.checked)}
|
||||
disabled={savingFeatures.autoAddFriend}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">自动回复</div>
|
||||
<div className="text-xs text-gray-500">自动回复好友消息</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{savingFeatures.autoReply && (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin text-blue-500" />
|
||||
)}
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(device.features.autoReply)}
|
||||
onChange={(e) => handleFeatureChange('autoReply', e.target.checked)}
|
||||
disabled={savingFeatures.autoReply}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">朋友圈同步</div>
|
||||
<div className="text-xs text-gray-500">自动同步朋友圈内容</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{savingFeatures.momentsSync && (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin text-blue-500" />
|
||||
)}
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(device.features.momentsSync)}
|
||||
onChange={(e) => handleFeatureChange('momentsSync', e.target.checked)}
|
||||
disabled={savingFeatures.momentsSync}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">AI会话</div>
|
||||
<div className="text-xs text-gray-500">启用AI智能对话</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{savingFeatures.aiChat && (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin text-blue-500" />
|
||||
)}
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(device.features.aiChat)}
|
||||
onChange={(e) => handleFeatureChange('aiChat', e.target.checked)}
|
||||
disabled={savingFeatures.aiChat}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-2 gap-4 mt-4">
|
||||
<div className="bg-gray-50 p-4 rounded-xl">
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
<Users className="w-4 h-4" />
|
||||
<span className="text-sm">好友总数</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-blue-600 mt-2">
|
||||
{(device.totalFriend || 0).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-4 rounded-xl">
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
<MessageCircle className="w-4 h-4" />
|
||||
<span className="text-sm">消息数量</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-blue-600 mt-2">
|
||||
{(device.thirtyDayMsgCount || 0).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 关联账号标签页 */}
|
||||
{activeTab === "accounts" && (
|
||||
<div className="p-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-md font-medium">微信账号列表</h3>
|
||||
<button
|
||||
className="px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors text-sm flex items-center gap-2"
|
||||
onClick={() => {
|
||||
setAccountPage(1);
|
||||
setHasMoreAccounts(true);
|
||||
fetchRelatedAccounts(1);
|
||||
}}
|
||||
disabled={accountsLoading}
|
||||
>
|
||||
{accountsLoading ? (
|
||||
<React.Fragment key="loading">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
刷新中
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<React.Fragment key="refresh">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
刷新
|
||||
</React.Fragment>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-[120px] max-h-[calc(100vh-300px)] overflow-y-auto">
|
||||
{accountsLoading && !device?.wechatAccounts?.length ? (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-blue-500 mr-2" />
|
||||
<span className="text-gray-500">加载微信账号中...</span>
|
||||
</div>
|
||||
) : device?.wechatAccounts && device.wechatAccounts.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{device.wechatAccounts.map((account) => (
|
||||
<div key={account.id} className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<img
|
||||
src={account.avatar || "/placeholder.svg"}
|
||||
alt={account.nickname}
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium truncate">{account.nickname}</div>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
account.wechatAlive === 1
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-red-100 text-red-700"
|
||||
}`}>
|
||||
{account.wechatAliveText}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">微信号: {account.wechatId}</div>
|
||||
<div className="text-sm text-gray-500">性别: {account.gender === 1 ? "男" : "女"}</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-sm text-gray-500">好友数: {account.totalFriend}</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
account.status === 1
|
||||
? "bg-blue-100 text-blue-700"
|
||||
: "bg-gray-100 text-gray-600"
|
||||
}`}>
|
||||
{account.statusText}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">最后活跃: {account.lastActive}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 加载更多区域 */}
|
||||
<div
|
||||
ref={accountsEndRef}
|
||||
className="py-2 flex justify-center items-center"
|
||||
>
|
||||
{accountsLoading && hasMoreAccounts ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-blue-500" />
|
||||
<span className="text-sm text-gray-500">加载更多...</span>
|
||||
</div>
|
||||
) : hasMoreAccounts ? (
|
||||
<button
|
||||
className="text-sm text-blue-500 hover:text-blue-600"
|
||||
onClick={loadMoreAccounts}
|
||||
>
|
||||
加载更多
|
||||
</button>
|
||||
) : device.wechatAccounts.length > 0 && (
|
||||
<span className="text-xs text-gray-400">- 已加载全部记录 -</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<p>此设备暂无关联的微信账号</p>
|
||||
<button
|
||||
className="mt-2 px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors text-sm flex items-center gap-2 mx-auto"
|
||||
onClick={() => fetchRelatedAccounts(1)}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作记录标签页 */}
|
||||
{activeTab === "history" && (
|
||||
<div className="p-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-md font-medium">操作记录</h3>
|
||||
<button
|
||||
className="px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors text-sm flex items-center gap-2"
|
||||
onClick={() => {
|
||||
setLogPage(1);
|
||||
setHasMoreLogs(true);
|
||||
fetchHandleLogs();
|
||||
}}
|
||||
disabled={logsLoading}
|
||||
>
|
||||
{logsLoading ? (
|
||||
<React.Fragment key="logs-loading">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
加载中
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<React.Fragment key="logs-refresh">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
刷新
|
||||
</React.Fragment>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="h-[calc(min(80vh, 500px))] overflow-y-auto">
|
||||
{logsLoading && handleLogs.length === 0 ? (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-blue-500 mr-2" />
|
||||
<span className="text-gray-500">加载操作记录中...</span>
|
||||
</div>
|
||||
) : handleLogs.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{handleLogs.map((log) => (
|
||||
<div key={log.id} className="flex items-start gap-3">
|
||||
<div className="p-2 bg-blue-50 rounded-full">
|
||||
<History className="w-4 h-4 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium">{log.content}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
操作人: {log.username} · {log.createTime}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 加载更多区域 */}
|
||||
<div
|
||||
ref={logsEndRef}
|
||||
className="py-2 flex justify-center items-center"
|
||||
>
|
||||
{logsLoading && hasMoreLogs ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-blue-500" />
|
||||
<span className="text-sm text-gray-500">加载更多...</span>
|
||||
</div>
|
||||
) : hasMoreLogs ? (
|
||||
<button
|
||||
className="text-sm text-blue-500 hover:text-blue-600"
|
||||
onClick={loadMoreLogs}
|
||||
>
|
||||
加载更多
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">- 已加载全部记录 -</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<p>暂无操作记录</p>
|
||||
<button
|
||||
className="mt-2 px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors text-sm flex items-center gap-2 mx-auto"
|
||||
onClick={() => {
|
||||
setLogPage(1);
|
||||
setHasMoreLogs(true);
|
||||
fetchHandleLogs();
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,719 +0,0 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Plus, Search, RefreshCw, QrCode, Loader2, AlertTriangle, X } from 'lucide-react';
|
||||
import { devicesApi } from '@/api';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import Layout from '@/components/Layout';
|
||||
import '@/components/Layout.css';
|
||||
|
||||
// 设备接口
|
||||
interface Device {
|
||||
id: number;
|
||||
imei: string;
|
||||
memo: string;
|
||||
wechatId: string;
|
||||
totalFriend: number;
|
||||
alive: number;
|
||||
status: "online" | "offline";
|
||||
}
|
||||
|
||||
export default function Devices() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const [devices, setDevices] = useState<Device[]>([]);
|
||||
const [isAddDeviceOpen, setIsAddDeviceOpen] = useState(false);
|
||||
const [stats, setStats] = useState({
|
||||
totalDevices: 0,
|
||||
onlineDevices: 0,
|
||||
});
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
// 恢复分页功能
|
||||
const [, setCurrentPage] = useState(1);
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState<number | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const observerTarget = useRef<HTMLDivElement>(null);
|
||||
const pageRef = useRef(1);
|
||||
const [deviceImei, setDeviceImei] = useState("");
|
||||
const [deviceName, setDeviceName] = useState("");
|
||||
const [qrCodeImage, setQrCodeImage] = useState("");
|
||||
const [isLoadingQRCode, setIsLoadingQRCode] = useState(false);
|
||||
const [isSubmittingImei, setIsSubmittingImei] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState("scan");
|
||||
const [pollingStatus, setPollingStatus] = useState<{
|
||||
isPolling: boolean;
|
||||
message: string;
|
||||
messageType: 'default' | 'success' | 'error';
|
||||
showAnimation: boolean;
|
||||
}>({
|
||||
isPolling: false,
|
||||
message: '',
|
||||
messageType: 'default',
|
||||
showAnimation: false
|
||||
});
|
||||
|
||||
const pollingTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const devicesPerPage = 20;
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [deviceToDelete, setDeviceToDelete] = useState<number | null>(null);
|
||||
|
||||
const loadDevices = useCallback(async (page: number, refresh: boolean = false) => {
|
||||
if (isLoading) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await devicesApi.getList(page, devicesPerPage, searchQuery);
|
||||
|
||||
if (response.code === 200 && response.data) {
|
||||
const serverDevices = response.data.list.map((device: any) => ({
|
||||
...device,
|
||||
status: device.alive === 1 ? "online" as const : "offline" as const
|
||||
}));
|
||||
|
||||
if (refresh) {
|
||||
setDevices(serverDevices);
|
||||
} else {
|
||||
setDevices(prev => [...prev, ...serverDevices]);
|
||||
}
|
||||
|
||||
const total = response.data.total;
|
||||
const online = response.data.list.filter((d: any) => d.alive === 1).length;
|
||||
setStats({
|
||||
totalDevices: total,
|
||||
onlineDevices: online
|
||||
});
|
||||
|
||||
setTotalCount(response.data.total);
|
||||
|
||||
const hasMoreData = serverDevices.length > 0 &&
|
||||
serverDevices.length === devicesPerPage &&
|
||||
(page * devicesPerPage) < response.data.total;
|
||||
setHasMore(hasMoreData);
|
||||
|
||||
pageRef.current = page;
|
||||
} else {
|
||||
toast({
|
||||
title: "获取设备列表失败",
|
||||
description: response.msg || "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取设备列表失败", error);
|
||||
toast({
|
||||
title: "获取设备列表失败",
|
||||
description: "请检查网络连接后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [searchQuery, isLoading, toast]);
|
||||
|
||||
const loadNextPage = useCallback(() => {
|
||||
if (isLoading || !hasMore) return;
|
||||
|
||||
const nextPage = pageRef.current + 1;
|
||||
setCurrentPage(nextPage);
|
||||
loadDevices(nextPage, false);
|
||||
}, [hasMore, isLoading, loadDevices]);
|
||||
|
||||
const isMounted = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMounted.current) return;
|
||||
|
||||
setCurrentPage(1);
|
||||
pageRef.current = 1;
|
||||
loadDevices(1, true);
|
||||
}, [searchQuery, loadDevices]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMore || isLoading) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
if (entries[0].isIntersecting && hasMore && !isLoading && isMounted.current) {
|
||||
loadNextPage();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.5 }
|
||||
);
|
||||
|
||||
if (typeof window !== 'undefined' && observerTarget.current) {
|
||||
observer.observe(observerTarget.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [hasMore, isLoading, loadNextPage]);
|
||||
|
||||
const fetchDeviceQRCode = async () => {
|
||||
try {
|
||||
setIsLoadingQRCode(true);
|
||||
setQrCodeImage("");
|
||||
|
||||
const accountId = localStorage.getItem('s2_accountId');
|
||||
if (!accountId) {
|
||||
toast({
|
||||
title: "获取二维码失败",
|
||||
description: "未获取到用户信息,请重新登录",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await devicesApi.getQRCode(accountId);
|
||||
|
||||
if (response.code === 200 && response.data) {
|
||||
setQrCodeImage(response.data.qrCode);
|
||||
// 开始轮询检测设备添加结果
|
||||
setTimeout(() => {
|
||||
startPolling();
|
||||
}, 5000);
|
||||
} else {
|
||||
toast({
|
||||
title: "获取二维码失败",
|
||||
description: response.msg || "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取二维码失败:", error);
|
||||
toast({
|
||||
title: "获取二维码失败",
|
||||
description: "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsLoadingQRCode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startPolling = () => {
|
||||
setPollingStatus({
|
||||
isPolling: true,
|
||||
message: "正在检测添加结果...",
|
||||
messageType: 'default',
|
||||
showAnimation: true
|
||||
});
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const response = await devicesApi.getList(1, 1);
|
||||
if (response.code === 200 && response.data) {
|
||||
const currentCount = response.data.total;
|
||||
if (currentCount > totalCount) {
|
||||
setPollingStatus({
|
||||
isPolling: false,
|
||||
message: "设备添加成功!",
|
||||
messageType: 'success',
|
||||
showAnimation: false
|
||||
});
|
||||
setIsAddDeviceOpen(false);
|
||||
loadDevices(1, true);
|
||||
if (pollingTimerRef.current) {
|
||||
clearTimeout(pollingTimerRef.current);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("轮询检测失败:", error);
|
||||
}
|
||||
|
||||
// 继续轮询
|
||||
pollingTimerRef.current = setTimeout(poll, 2000);
|
||||
};
|
||||
|
||||
poll();
|
||||
};
|
||||
|
||||
const handleOpenAddDeviceModal = () => {
|
||||
setIsAddDeviceOpen(true);
|
||||
setActiveTab("scan");
|
||||
setQrCodeImage("");
|
||||
setDeviceImei("");
|
||||
setDeviceName("");
|
||||
setPollingStatus({
|
||||
isPolling: false,
|
||||
message: '',
|
||||
messageType: 'default',
|
||||
showAnimation: false
|
||||
});
|
||||
|
||||
// 自动获取二维码
|
||||
setTimeout(() => {
|
||||
fetchDeviceQRCode();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleCloseAddDeviceModal = () => {
|
||||
setIsAddDeviceOpen(false);
|
||||
if (pollingTimerRef.current) {
|
||||
clearTimeout(pollingTimerRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddDeviceByImei = async () => {
|
||||
if (!deviceImei.trim() || !deviceName.trim()) {
|
||||
toast({
|
||||
title: "请填写完整信息",
|
||||
description: "设备名称和IMEI不能为空",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmittingImei(true);
|
||||
const response = await devicesApi.addByImei(deviceImei, deviceName);
|
||||
|
||||
if (response.code === 200) {
|
||||
toast({
|
||||
title: "添加成功",
|
||||
description: "设备已成功添加",
|
||||
});
|
||||
setIsAddDeviceOpen(false);
|
||||
loadDevices(1, true);
|
||||
} else {
|
||||
toast({
|
||||
title: "添加失败",
|
||||
description: response.msg || "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('添加设备失败:', error);
|
||||
toast({
|
||||
title: '添加设备失败,请稍后重试',
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmittingImei(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
setCurrentPage(1);
|
||||
pageRef.current = 1;
|
||||
loadDevices(1, true);
|
||||
};
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
if (!selectedDeviceId) {
|
||||
toast({
|
||||
title: "请选择要删除的设备",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setDeviceToDelete(selectedDeviceId);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!deviceToDelete) return;
|
||||
|
||||
try {
|
||||
const response = await devicesApi.delete(deviceToDelete);
|
||||
|
||||
if (response.code === 200) {
|
||||
toast({
|
||||
title: "删除成功",
|
||||
description: "设备已成功删除",
|
||||
});
|
||||
setSelectedDeviceId(null);
|
||||
loadDevices(1, true);
|
||||
} else {
|
||||
toast({
|
||||
title: "删除失败",
|
||||
description: response.msg || "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除设备失败:', error);
|
||||
toast({
|
||||
title: '删除设备失败,请稍后重试',
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsDeleteDialogOpen(false);
|
||||
setDeviceToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelDelete = () => {
|
||||
setIsDeleteDialogOpen(false);
|
||||
setDeviceToDelete(null);
|
||||
};
|
||||
|
||||
const handleDeviceClick = (deviceId: number, event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
navigate(`/devices/${deviceId}`);
|
||||
};
|
||||
|
||||
const handleAddDevice = async () => {
|
||||
if (activeTab === "manual") {
|
||||
await handleAddDeviceByImei();
|
||||
}
|
||||
};
|
||||
|
||||
// 过滤设备列表
|
||||
const filteredDevices = devices.filter(device => {
|
||||
if (statusFilter === "online") return device.status === "online";
|
||||
if (statusFilter === "offline") return device.status === "offline";
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<PageHeader
|
||||
title="设备管理"
|
||||
defaultBackPath="/"
|
||||
rightContent={
|
||||
<button
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 transition-colors"
|
||||
onClick={handleOpenAddDeviceModal}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
添加设备
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="bg-gray-50">
|
||||
<div className="p-4 space-y-4">
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100">
|
||||
<div className="text-sm text-gray-500 mb-1">总设备数</div>
|
||||
<div className="text-2xl font-bold text-blue-600">{stats.totalDevices}</div>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100">
|
||||
<div className="text-sm text-gray-500 mb-1">在线设备</div>
|
||||
<div className="text-2xl font-bold text-green-600">{stats.onlineDevices}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索和过滤 */}
|
||||
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索设备IMEI/备注"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="p-2.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-200 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="online">在线</option>
|
||||
<option value="offline">离线</option>
|
||||
</select>
|
||||
<button
|
||||
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
|
||||
onClick={handleDeleteClick}
|
||||
disabled={!selectedDeviceId}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 设备列表 */}
|
||||
<div className="space-y-3">
|
||||
{filteredDevices.map((device) => (
|
||||
<div
|
||||
key={device.id}
|
||||
className="bg-white p-4 rounded-xl shadow-sm border border-gray-100 hover:shadow-md transition-all cursor-pointer"
|
||||
onClick={(e) => handleDeviceClick(device.id, e)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDeviceId === device.id}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedDeviceId(device.id);
|
||||
} else {
|
||||
setSelectedDeviceId(null);
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="font-semibold text-gray-900 truncate">{device.memo || "未命名设备"}</div>
|
||||
<span className={`px-2.5 py-1 text-xs rounded-full font-medium ${
|
||||
device.status === "online"
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-gray-100 text-gray-600"
|
||||
}`}>
|
||||
{device.status === "online" ? "在线" : "离线"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1 text-sm text-gray-600">
|
||||
<div>IMEI: {device.imei}</div>
|
||||
<div>微信号: {device.wechatId || "未绑定或微信离线"}</div>
|
||||
<div>好友数: {device.totalFriend}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div ref={observerTarget} className="py-4 flex items-center justify-center">
|
||||
{isLoading && <div className="text-sm text-gray-500">加载中...</div>}
|
||||
{!hasMore && devices.length > 0 && <div className="text-sm text-gray-500">没有更多设备了</div>}
|
||||
{!hasMore && devices.length === 0 && <div className="text-sm text-gray-500">暂无设备</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 添加设备弹窗 */}
|
||||
{isAddDeviceOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl max-w-md w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900">添加设备</h2>
|
||||
<button
|
||||
onClick={handleCloseAddDeviceModal}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex border-b border-gray-200">
|
||||
<button
|
||||
className={`flex-1 py-2.5 px-4 text-sm font-medium transition-colors ${
|
||||
activeTab === "scan"
|
||||
? "text-blue-600 border-b-2 border-blue-600"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
onClick={() => {
|
||||
setActiveTab("scan");
|
||||
// 切换到扫码添加时自动获取二维码
|
||||
setTimeout(() => {
|
||||
fetchDeviceQRCode();
|
||||
}, 100);
|
||||
}}
|
||||
>
|
||||
扫码添加
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 py-2.5 px-4 text-sm font-medium transition-colors ${
|
||||
activeTab === "manual"
|
||||
? "text-blue-600 border-b-2 border-blue-600"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
onClick={() => setActiveTab("manual")}
|
||||
>
|
||||
手动添加
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "scan" && (
|
||||
<div className="py-3">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
{/* 状态提示 */}
|
||||
<div className="text-center">
|
||||
{pollingStatus.isPolling || pollingStatus.showAnimation ? (
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm text-gray-700">正在检测添加结果</span>
|
||||
<div className="flex justify-center space-x-1">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '0ms' }}></div>
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '150ms' }}></div>
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '300ms' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-600">5秒后将开始检测添加结果</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 二维码区域 */}
|
||||
<div className="bg-gray-50 p-3 rounded-xl w-full max-w-[220px] min-h-[220px] flex flex-col items-center justify-center">
|
||||
{isLoadingQRCode ? (
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
<p className="text-sm text-gray-500">正在获取二维码...</p>
|
||||
</div>
|
||||
) : qrCodeImage ? (
|
||||
<div id="qrcode-container" className="flex flex-col items-center space-y-2">
|
||||
<div className="relative w-44 h-44 flex items-center justify-center">
|
||||
<img
|
||||
src={qrCodeImage}
|
||||
alt="设备添加二维码"
|
||||
className="w-full h-full object-contain"
|
||||
onError={(e) => {
|
||||
console.error("二维码图片加载失败");
|
||||
e.currentTarget.style.display = 'none';
|
||||
const container = document.getElementById('qrcode-container');
|
||||
if (container) {
|
||||
const errorEl = container.querySelector('.qrcode-error');
|
||||
if (errorEl) {
|
||||
errorEl.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="qrcode-error hidden absolute inset-0 flex flex-col items-center justify-center text-center text-red-500 bg-white rounded-lg">
|
||||
<AlertTriangle className="h-6 w-6 mb-1" />
|
||||
<p className="text-xs">未能加载二维码,请点击刷新按钮重试</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-center text-gray-600">
|
||||
请使用手机扫描此二维码添加设备
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-gray-500">
|
||||
<QrCode className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">点击下方按钮获取二维码</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={fetchDeviceQRCode}
|
||||
disabled={isLoadingQRCode}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white py-2.5 rounded-xl disabled:bg-gray-300 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{isLoadingQRCode ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
获取中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
刷新二维码
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "manual" && (
|
||||
<div className="py-3 space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700">设备名称</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="请输入设备名称"
|
||||
value={deviceName}
|
||||
onChange={(e) => setDeviceName(e.target.value)}
|
||||
className="w-full px-4 py-2.5 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
为设备添加一个便于识别的名称
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700">设备IMEI</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="请输入设备IMEI"
|
||||
value={deviceImei}
|
||||
onChange={(e) => setDeviceImei(e.target.value)}
|
||||
className="w-full px-4 py-2.5 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
请输入设备IMEI码,可在设备信息中查看
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
className="flex-1 px-4 py-2.5 border border-gray-200 rounded-xl hover:bg-gray-50 transition-colors"
|
||||
onClick={() => setIsAddDeviceOpen(false)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="flex-1 px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-xl disabled:bg-gray-300 transition-colors"
|
||||
onClick={handleAddDevice}
|
||||
disabled={!deviceImei.trim() || !deviceName.trim() || isSubmittingImei}
|
||||
>
|
||||
{isSubmittingImei ? "添加中..." : "添加"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 删除确认弹窗 */}
|
||||
{isDeleteDialogOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl max-w-md w-full p-6">
|
||||
<div className="text-center mb-6">
|
||||
<AlertTriangle className="h-12 w-12 text-red-500 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">确认删除</h3>
|
||||
<p className="text-gray-600">
|
||||
设备删除后,本设备配置的计划任务操作也将失效。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
className="flex-1 px-4 py-3 border border-gray-200 rounded-xl hover:bg-gray-50 transition-colors"
|
||||
onClick={handleCancelDelete}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="flex-1 px-4 py-3 bg-red-600 hover:bg-red-700 text-white rounded-xl transition-colors"
|
||||
onClick={handleConfirmDelete}
|
||||
>
|
||||
确认删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user