This commit is contained in:
wong
2025-05-09 09:22:47 +08:00
61 changed files with 3363 additions and 1460 deletions

View File

@@ -45,8 +45,8 @@ export const fetchDeviceHandleLogs = async (id: string | number, page: number =
// 更新设备任务配置
export const updateDeviceTaskConfig = async (
id: string | number,
config: {
deviceId: string | number;
autoAddFriend?: boolean;
autoReply?: boolean;
momentsSync?: boolean;
@@ -54,7 +54,6 @@ export const updateDeviceTaskConfig = async (
}
): Promise<ApiResponse<any>> => {
return api.post<ApiResponse<any>>(`/v1/devices/task-config`, {
id,
...config
});
};

View File

@@ -76,29 +76,67 @@ export function AuthProvider({ children }: AuthProviderProps) {
const storedToken = safeLocalStorage.getItem("token")
if (storedToken) {
// 验证token是否有效
const isValid = await validateToken()
if (isValid) {
// 从localStorage获取用户信息
const userDataStr = safeLocalStorage.getItem("user")
if (userDataStr) {
// 首先尝试从localStorage获取用户信息
const userDataStr = safeLocalStorage.getItem("userInfo")
if (userDataStr) {
try {
// 如果能解析用户数据,先设置登录状态
const userData = JSON.parse(userDataStr) as User
setToken(storedToken)
setUser(userData)
setIsAuthenticated(true)
} else {
// token有效但没有用户信息清除token
// 然后在后台尝试验证token但不影响当前登录状态
validateToken().then(isValid => {
// 只有在确认token绝对无效时才登出
// 网络错误等情况默认保持登录状态
if (isValid === false) {
console.warn('验证token失败但仍允许用户保持登录状态')
}
}).catch(error => {
// 捕获所有验证过程中的错误,并记录日志
console.error('验证token过程中出错:', error)
// 网络错误等不会导致登出
})
} catch (parseError) {
// 用户数据无法解析,需要清除
console.error('解析用户数据失败:', parseError)
handleLogout()
}
} else {
// token无效,清除
handleLogout()
// token但没有用户信息,可能是部分数据丢失
console.warn('找到token但没有用户信息尝试保持登录状态')
// 尝试验证token并获取用户信息
try {
const isValid = await validateToken()
if (isValid) {
// 如果token有效尝试从API获取用户信息
// 这里简化处理直接使用token
setToken(storedToken)
setIsAuthenticated(true)
} else {
// token确认无效清除
handleLogout()
}
} catch (error) {
// 验证过程出错,记录日志但不登出
console.error('验证token过程中出错:', error)
// 保留token允许用户继续使用
setToken(storedToken)
setIsAuthenticated(true)
}
}
}
} catch (error) {
console.error("验证token时出错:", error)
handleLogout()
console.error("初始化认证状态时出错:", error)
// 非401错误不应强制登出
if (error instanceof Error &&
(error.message.includes('401') ||
error.message.includes('未授权') ||
error.message.includes('token'))) {
handleLogout()
}
} finally {
setIsLoading(false)
setIsInitialized(true)

View File

@@ -1,6 +1,6 @@
"use client"
import { useState, useEffect } from "react"
import { useState, useEffect, useRef } from "react"
import { useParams, useRouter } from "next/navigation"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
@@ -83,6 +83,10 @@ export default function DeviceDetailPage() {
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,
@@ -176,82 +180,20 @@ export default function DeviceDetailPage() {
fetchRelatedAccounts()
}
} else {
// 如果API返回错误则使用备用模拟数据
toast.error("获取设备信息失败,显示备用数据")
fallbackToMockDevice()
// 如果API返回错误显示错误提示
toast.error("获取设备信息失败: " + ((response as any)?.msg || "未知错误"))
setLoading(false)
}
} catch (error) {
console.error("获取设备信息失败:", error)
toast.error("获取设备信息出错,显示备用数据")
fallbackToMockDevice()
toast.error("获取设备信息出错,请稍后重试")
setLoading(false)
} finally {
// 确保loading状态被关闭
setLoading(false)
}
}
const fallbackToMockDevice = () => {
const mockDevice: Device = {
id: params.id as string,
imei: "sd123123",
name: "设备 1",
status: "online",
battery: 85,
lastActive: "2024-02-09 15:30:45",
historicalIds: ["vx412321", "vfbadasd"],
wechatAccounts: [
{
id: "1",
avatar: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-q2rVrFbfDdAbSnT3ZTNE7gfn3QCbvr.png",
nickname: "老张",
wechatId: "wxid_abc123",
gender: 1,
status: 1,
statusText: "可加友",
wechatAlive: 1,
wechatAliveText: "正常",
addFriendStatus: 1,
totalFriend: 523,
lastActive: "2024-02-09 15:20:33",
},
{
id: "2",
avatar: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-q2rVrFbfDdAbSnT3ZTNE7gfn3QCbvr.png",
nickname: "老李",
wechatId: "wxid_xyz789",
gender: 1,
status: 0,
statusText: "已停用",
wechatAlive: 0,
wechatAliveText: "异常",
addFriendStatus: 0,
totalFriend: 245,
lastActive: "2024-02-09 14:15:22",
},
],
features: {
autoAddFriend: true,
autoReply: true,
momentsSync: false,
aiChat: true,
},
history: [
{
time: "2024-02-09 15:30:45",
action: "开启自动加好友",
operator: "系统",
},
{
time: "2024-02-09 14:20:33",
action: "添加微信号",
operator: "管理员",
},
],
totalFriend: 768,
thirtyDayMsgCount: 5678
}
setDevice(mockDevice)
}
fetchDevice()
}, [params.id, activeTab])
@@ -297,11 +239,24 @@ export default function DeviceDetailPage() {
try {
setLogsLoading(true)
const response = await fetchDeviceHandleLogs(params.id as string)
const response = await fetchDeviceHandleLogs(
params.id as string,
logPage,
logsPerPage
)
if (response && response.code === 200 && response.data) {
const logs = response.data.list || []
setHandleLogs(logs)
// 如果是第一页,替换数据;否则追加数据
if (logPage === 1) {
setHandleLogs(logs)
} else {
setHandleLogs(prev => [...prev, ...logs])
}
// 判断是否还有更多数据
setHasMoreLogs(logs.length === logsPerPage)
if (logs.length > 0) {
console.log('获取到操作记录:', logs.length)
@@ -319,6 +274,58 @@ export default function DeviceDetailPage() {
}
}
// 加载更多日志
const loadMoreLogs = () => {
if (logsLoading || !hasMoreLogs) return
setLogPage(prevPage => prevPage + 1)
}
// 监听滚动加载更多
useEffect(() => {
if (activeTab !== "history") return
const observerOptions = {
root: null,
rootMargin: '0px',
threshold: 0.1
}
const handleIntersect = (entries: IntersectionObserverEntry[]) => {
const [entry] = entries
if (entry.isIntersecting && hasMoreLogs && !logsLoading) {
loadMoreLogs()
}
}
const observer = new IntersectionObserver(handleIntersect, observerOptions)
if (logsEndRef.current) {
observer.observe(logsEndRef.current)
}
return () => {
if (logsEndRef.current) {
observer.unobserve(logsEndRef.current)
}
}
}, [activeTab, hasMoreLogs, logsLoading])
// 当切换到日志标签时重置页码
useEffect(() => {
if (activeTab === "history") {
setLogPage(1)
setHasMoreLogs(true)
}
}, [activeTab])
// 观察logPage变化加载数据
useEffect(() => {
if (activeTab === "history") {
fetchHandleLogs()
}
}, [logPage, activeTab])
// 处理标签页切换
const handleTabChange = (value: string) => {
setActiveTab(value)
@@ -361,22 +368,117 @@ export default function DeviceDetailPage() {
features: updatedFeatures
} : null)
// 调用API更新服务器配置
const response = await updateDeviceTaskConfig(configUpdate)
if (response && response.code === 200) {
toast.success(`${getFeatureName(feature)}${checked ? '已启用' : '已禁用'}`)
} else {
// 如果请求失败回滚UI变更
// 使用更安全的API调用方式避免自动重定向
try {
// 获取token
const token = localStorage.getItem('token');
if (!token) {
throw new Error('未找到授权信息');
}
// 直接使用fetch而不是通过API工具
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/v1/devices/task-config`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(configUpdate)
});
// 检查是否是401错误未授权这是唯一应该处理token的情况
if (response.status === 401) {
// 此处我们不立即跳转,而是给出错误提示
toast.error('认证已过期,请重新登录后再尝试操作');
console.error('API请求返回401未授权错误');
// 可以选择是否重定向到登录页面
// window.location.href = '/login';
throw new Error('认证已过期');
}
// 检查响应是否正常
if (!response.ok) {
// 所有非401的HTTP错误
console.warn(`API返回HTTP错误: ${response.status} ${response.statusText}`);
// 尝试解析错误详情
try {
const errorResult = await response.json();
// 显示详细错误信息但保持本地token
const errorMsg = errorResult?.msg || `服务器错误 (${response.status})`;
toast.error(`更新失败: ${errorMsg}`);
// 回滚UI更改
setDevice(prev => prev ? {
...prev,
features: { ...prev.features, [feature]: !checked }
} : null);
} catch (parseError) {
// 无法解析响应,可能是网络问题
console.error('无法解析错误响应:', parseError);
toast.error(`更新失败: 服务器无响应 (${response.status})`);
// 回滚UI更改
setDevice(prev => prev ? {
...prev,
features: { ...prev.features, [feature]: !checked }
} : null);
}
return; // 提前返回,避免继续处理
}
// 响应正常,尝试解析
try {
const result = await response.json();
// 检查API响应码
if (result && result.code === 200) {
toast.success(`${getFeatureName(feature)}${checked ? '已启用' : '已禁用'}`);
} else if (result && result.code === 401) {
// API明确返回401提示用户但不自动登出
toast.error('认证已过期,请重新登录后再尝试操作');
console.error('API请求返回401未授权状态码');
// 回滚UI更改
setDevice(prev => prev ? {
...prev,
features: { ...prev.features, [feature]: !checked }
} : null);
} else {
// 其他API错误
const errorMsg = result?.msg || '未知错误';
console.warn(`API返回业务错误: ${result?.code} - ${errorMsg}`);
toast.error(`更新失败: ${errorMsg}`);
// 回滚UI更改
setDevice(prev => prev ? {
...prev,
features: { ...prev.features, [feature]: !checked }
} : null);
}
} catch (parseError) {
// 无法解析响应JSON
console.error('无法解析API响应:', parseError);
toast.error('更新失败: 无法解析服务器响应');
// 回滚UI更改
setDevice(prev => prev ? {
...prev,
features: { ...prev.features, [feature]: !checked }
} : null);
}
} catch (fetchError) {
console.error('请求错误:', fetchError)
// 回滚UI更改
setDevice(prev => prev ? {
...prev,
features: { ...prev.features, [feature]: !checked }
} : null)
// 处理错误信息,使用类型断言解决字段不一致问题
const anyResponse = response as any;
const errorMsg = anyResponse ? (anyResponse.message || anyResponse.msg || '未知错误') : '未知错误';
toast.error(`更新失败: ${errorMsg}`)
// 显示友好的错误提示
toast.error('网络请求失败,请稍后重试')
}
} catch (error) {
console.error(`更新${getFeatureName(feature)}失败:`, error)
@@ -405,13 +507,40 @@ export default function DeviceDetailPage() {
return nameMap[feature] || feature
}
if (loading || !device) {
return <div>...</div>
if (loading) {
return (
<div className="flex h-screen w-full justify-center items-center bg-gray-50">
<div className="flex flex-col items-center space-y-4">
<div className="w-8 h-8 rounded-full border-2 border-blue-500 border-t-transparent animate-spin"></div>
<div className="text-gray-500">...</div>
</div>
</div>
)
}
if (!device) {
return (
<div className="flex h-screen w-full justify-center items-center bg-gray-50">
<div className="flex flex-col items-center space-y-4 p-6 bg-white rounded-lg 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为 "{params.id}"
</div>
<Button onClick={() => router.back()}>
<ChevronLeft className="h-4 w-4 mr-1" />
</Button>
</div>
</div>
)
}
return (
<div className="flex-1 bg-gray-50 min-h-screen">
<div className="max-w-[390px] mx-auto bg-white">
<div className="w-full mx-auto bg-white">
<header className="sticky top-0 z-10 bg-white border-b">
<div className="flex items-center justify-between p-4">
<div className="flex items-center space-x-3">
@@ -631,7 +760,10 @@ export default function DeviceDetailPage() {
<Button
variant="outline"
size="sm"
onClick={fetchHandleLogs}
onClick={() => {
setLogPage(1)
setHasMoreLogs(true)
}}
disabled={logsLoading}
>
{logsLoading ? (
@@ -648,7 +780,7 @@ export default function DeviceDetailPage() {
</Button>
</div>
<ScrollArea className="h-[calc(100vh-300px)]">
<ScrollArea className="h-[calc(min(80vh, 500px))]">
{logsLoading && handleLogs.length === 0 ? (
<div className="flex justify-center items-center py-8">
<div className="w-6 h-6 rounded-full border-2 border-blue-500 border-t-transparent animate-spin mr-2"></div>
@@ -669,6 +801,30 @@ export default function DeviceDetailPage() {
</div>
</div>
))}
{/* 加载更多区域 - 用于懒加载触发点 */}
<div
ref={logsEndRef}
className="py-2 flex justify-center items-center"
>
{logsLoading && hasMoreLogs ? (
<div className="flex items-center space-x-2">
<div className="w-4 h-4 rounded-full border-2 border-blue-500 border-t-transparent animate-spin"></div>
<span className="text-sm text-gray-500">...</span>
</div>
) : hasMoreLogs ? (
<Button
variant="ghost"
size="sm"
onClick={loadMoreLogs}
className="text-sm text-blue-500 hover:text-blue-600"
>
</Button>
) : (
<span className="text-xs text-gray-400">- -</span>
)}
</div>
</div>
) : (
<div className="text-center py-8 text-gray-500">
@@ -677,7 +833,10 @@ export default function DeviceDetailPage() {
variant="outline"
size="sm"
className="mt-2"
onClick={fetchHandleLogs}
onClick={() => {
setLogPage(1)
setHasMoreLogs(true)
}}
>
<RefreshCw className="h-4 w-4 mr-1" />

View File

@@ -47,6 +47,20 @@ export default function DevicesPage() {
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 // 每页显示20条记录
@@ -110,7 +124,7 @@ export default function DevicesPage() {
setIsLoading(false)
}
// 移除isLoading依赖只保留真正需要的依赖
}, [searchQuery, devicesPerPage])
}, [searchQuery]) // devicesPerPage是常量,不需要加入依赖
// 加载下一页数据的函数使用ref来追踪页码避免依赖循环
const loadNextPage = useCallback(() => {
@@ -126,8 +140,21 @@ export default function DevicesPage() {
// 只依赖必要的状态
}, [hasMore, isLoading, loadDevices]);
// 追踪组件是否已挂载
const isMounted = useRef(true);
// 组件卸载时更新挂载状态
useEffect(() => {
return () => {
isMounted.current = false;
};
}, []);
// 初始加载和搜索时刷新列表
useEffect(() => {
// 组件未挂载,不执行操作
if (!isMounted.current) return;
// 重置页码
setCurrentPage(1)
pageRef.current = 1
@@ -140,13 +167,11 @@ export default function DevicesPage() {
// 如果没有更多数据或者正在加载不创建observer
if (!hasMore || isLoading) return;
let isMounted = true; // 追踪组件是否已挂载
// 创建观察器观察加载点
const observer = new IntersectionObserver(
entries => {
// 如果交叉了,且有更多数据,且当前不在加载状态,且组件仍然挂载
if (entries[0].isIntersecting && hasMore && !isLoading && isMounted) {
if (entries[0].isIntersecting && hasMore && !isLoading && isMounted.current) {
loadNextPage();
}
},
@@ -160,7 +185,6 @@ export default function DevicesPage() {
// 清理观察器
return () => {
isMounted = false;
observer.disconnect();
}
}, [hasMore, isLoading, loadNextPage])
@@ -315,12 +339,127 @@ export default function DevicesPage() {
}
}
// 打开添加设备模态框时获取二维码
// 清理轮询函数
const cleanupPolling = useCallback(() => {
if (pollingTimerRef.current) {
clearTimeout(pollingTimerRef.current);
pollingTimerRef.current = null;
}
setPollingStatus({
isPolling: false,
message: '',
messageType: 'default',
showAnimation: false
});
}, []);
// 轮询检测设备添加状态
const startPolling = useCallback(() => {
let pollCount = 0;
const maxPolls = 60;
const pollInterval = 1000; // 1秒
const initialDelay = 5000; // 5秒后开始轮询
// 初始提示
setPollingStatus({
isPolling: false,
message: '请扫描二维码添加设备5秒后将开始检测添加结果',
messageType: 'default',
showAnimation: false
});
const poll = async () => {
try {
const accountId = localStorage.getItem('s2_accountId');
if (!accountId) {
setPollingStatus({
isPolling: false,
message: '未获取到用户信息,请重新登录',
messageType: 'error',
showAnimation: false
});
return;
}
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/v1/devices/add-results?accountId=${accountId}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
const data = await response.json();
if (data.code === 200 && data.data) {
if (data.data.added) {
setPollingStatus({
isPolling: false,
message: '设备添加成功。关闭后可继续',
messageType: 'success',
showAnimation: false
});
setQrCodeImage('/broken-qr.png'); // 显示损坏的二维码
cleanupPolling();
return;
}
}
pollCount++;
if (pollCount >= maxPolls) {
setPollingStatus({
isPolling: false,
message: '未检测到设备添加,请关闭后重试',
messageType: 'error',
showAnimation: false
});
cleanupPolling();
return;
}
pollingTimerRef.current = setTimeout(poll, pollInterval);
} catch (error) {
setPollingStatus({
isPolling: false,
message: '检测过程中发生错误,请重试',
messageType: 'error',
showAnimation: false
});
cleanupPolling();
}
};
// 5秒后开始轮询
pollingTimerRef.current = setTimeout(() => {
setPollingStatus({
isPolling: true,
message: '正在检测添加结果',
messageType: 'default',
showAnimation: true
});
poll();
}, initialDelay);
}, [cleanupPolling]);
// 修改打开添加设备模态框的处理函数
const handleOpenAddDeviceModal = () => {
setIsAddDeviceOpen(true)
setDeviceImei("")
setDeviceName("")
fetchDeviceQRCode()
setIsAddDeviceOpen(true);
setDeviceImei("");
setDeviceName("");
setQrCodeImage("");
setPollingStatus({
isPolling: false,
message: '',
messageType: 'default',
showAnimation: false
});
fetchDeviceQRCode();
startPolling();
}
// 修改关闭模态框的处理函数
const handleCloseAddDeviceModal = () => {
cleanupPolling();
setIsAddDeviceOpen(false);
}
// 通过IMEI添加设备
@@ -484,7 +623,10 @@ export default function DevicesPage() {
try {
const s2_accountId = localStorage.getItem('s2_accountId');
if (!s2_accountId) {
toast.error('未获取到用户信息,请重新登录');
toast({
title: '未获取到用户信息,请重新登录',
variant: 'destructive',
});
return;
}
@@ -503,16 +645,25 @@ export default function DevicesPage() {
const data = await response.json();
if (data.code === 200) {
toast.success('添加设备成功');
toast({
title: '添加设备成功',
variant: 'default',
});
setIsAddDeviceOpen(false);
// 刷新设备列表
loadDevices(1, true);
} else {
toast.error(data.msg || '添加设备失败');
toast({
title: data.msg || '添加设备失败',
variant: 'destructive',
});
}
} catch (error) {
console.error('添加设备失败:', error);
toast.error('添加设备失败,请稍后重试');
toast({
title: '添加设备失败,请稍后重试',
variant: 'destructive',
});
}
};
@@ -651,8 +802,12 @@ export default function DevicesPage() {
</Card>
</div>
{/* 添加设备对话框 */}
<Dialog open={isAddDeviceOpen} onOpenChange={setIsAddDeviceOpen}>
{/* 修改添加设备对话框 */}
<Dialog open={isAddDeviceOpen} onOpenChange={(open) => {
if (!open) {
handleCloseAddDeviceModal();
}
}}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
@@ -671,7 +826,24 @@ export default function DevicesPage() {
</TabsList> */}
<TabsContent value="scan" className="space-y-4 py-4">
<div className="flex flex-col items-center justify-center p-6 space-y-4">
<div className="flex flex-col items-center justify-center p-6 space-y-4 relative">
{/* 悬浮提示区域上移50% */}
<div className="absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2 z-10">
<div className="flex flex-col items-center w-full py-2">
{pollingStatus.isPolling || pollingStatus.showAnimation ? (
<>
<span className="text-sm text-gray-800"></span>
<div className="flex space-x-1 mt-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>
</>
) : (
<span className="text-sm text-gray-800">5</span>
)}
</div>
</div>
<div className="bg-white p-4 rounded-lg shadow-md border border-gray-200 w-full max-w-[280px] min-h-[280px] flex flex-col items-center justify-center">
{isLoadingQRCode ? (
<div className="flex flex-col items-center justify-center space-y-3">
@@ -719,7 +891,7 @@ export default function DevicesPage() {
type="button"
onClick={fetchDeviceQRCode}
disabled={isLoadingQRCode}
className="w-48"
className="w-48 mt-8"
>
{isLoadingQRCode ? (
<>
@@ -776,6 +948,28 @@ export default function DevicesPage() {
</Tabs>
</DialogContent>
</Dialog>
{/* 在二维码显示区域下方添加状态提示 */}
<div className="mt-4 text-center">
{pollingStatus.message && (
<div className="flex flex-col items-center space-y-2">
<p className={`text-sm ${
pollingStatus.messageType === 'success' ? 'text-green-600' :
pollingStatus.messageType === 'error' ? 'text-red-600' :
'text-gray-600'
}`}>
{pollingStatus.message}
</p>
{pollingStatus.showAnimation && (
<div className="flex 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>
)}
</div>
</div>
)
}

View File

@@ -154,6 +154,7 @@ export default function Home() {
color: "bg-orange-100 text-orange-600",
value: 167,
growth: 10,
sceneId: 7,
},
]
@@ -209,7 +210,11 @@ export default function Home() {
{scenarioFeatures
.sort((a, b) => b.value - a.value)
.map((scenario) => (
<Link href={`/scenarios/${scenario.id}`} key={scenario.id} className="block flex-1">
<div
key={scenario.id}
className="block flex-1 cursor-pointer"
onClick={() => router.push(`/scenarios/${scenario.id}?id=${scenario.sceneId || getSceneIdFromType(scenario.id)}`)}
>
<div className="flex flex-col items-center text-center space-y-2">
<div className={`w-12 h-12 rounded-full ${scenario.color} flex items-center justify-center`}>
<img src={scenario.icon || "/placeholder.svg"} alt={scenario.name} className="w-6 h-6" />
@@ -219,7 +224,7 @@ export default function Home() {
{scenario.name}
</div>
</div>
</Link>
</div>
))}
</div>
</Card>
@@ -236,3 +241,18 @@ export default function Home() {
)
}
function getSceneIdFromType(type: string): number {
const typeToIdMap: Record<string, number> = {
'douyin': 1,
'xiaohongshu': 2,
'weixinqun': 3,
'gongzhonghao': 4,
'kuaishou': 5,
'weibo': 6,
'haibao': 7,
'phone': 8,
'api': 9
};
return typeToIdMap[type] || 1;
}

View File

@@ -123,14 +123,82 @@ export default function NewAcquisitionPlan() {
// 根据标签和计划名称自动判断场景
const scenario = formData.scenario || determineScenario(formData.planName, formData.tags)
console.log("计划已创建:", { ...formData, scenario })
toast({
title: "创建成功",
description: "获客计划已创建完成",
})
// 准备请求数据
const requestData = {
sceneId: getSceneIdFromScenario(scenario), // 获取场景ID
name: formData.planName,
status: formData.enabled ? 1 : 0,
reqConf: JSON.stringify({
remarkType: formData.remarkType,
remarkKeyword: formData.remarkKeyword,
greeting: formData.greeting,
addFriendTimeStart: formData.addFriendTimeStart,
addFriendTimeEnd: formData.addFriendTimeEnd,
addFriendInterval: formData.addFriendInterval,
maxDailyFriends: formData.maxDailyFriends,
selectedDevices: formData.selectedDevices,
}),
msgConf: JSON.stringify({
messageInterval: formData.messageInterval,
messagePlans: formData.messagePlans,
}),
tagConf: JSON.stringify({
tags: formData.tags,
importedTags: formData.importedTags,
})
}
// 跳转到首页
router.push("/")
// 调用API创建获客计划
fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/v1/plan/scenes/create`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify(requestData)
})
.then(response => response.json())
.then(data => {
if (data.code === 200) {
toast({
title: "创建成功",
description: "获客计划已创建完成",
})
// 跳转到首页
router.push("/")
} else {
toast({
title: "创建失败",
description: data.msg || "服务器错误,请稍后重试",
variant: "destructive"
})
}
})
.catch(error => {
console.error("创建获客计划失败:", error);
toast({
title: "创建失败",
description: "网络错误,请稍后重试",
variant: "destructive"
})
})
}
// 将场景类型转换为场景ID
const getSceneIdFromScenario = (scenario: string): number => {
const scenarioMap: Record<string, number> = {
'douyin': 1,
'xiaohongshu': 2,
'weixinqun': 3,
'gongzhonghao': 4,
'kuaishou': 5,
'weibo': 6,
'haibao': 7,
'phone': 8,
'order': 9,
'other': 10
}
return scenarioMap[scenario] || 1 // 默认返回1
}
const handlePrev = () => {
@@ -206,7 +274,7 @@ export default function NewAcquisitionPlan() {
case 3:
return <MessageSettings formData={formData} onChange={setFormData} onNext={handleNext} onPrev={handlePrev} />
case 4:
return <TagSettings formData={formData} onComplete={handleSave} onPrev={handlePrev} onChange={setFormData} />
return <TagSettings formData={formData} onNext={handleSave} onPrev={handlePrev} onChange={setFormData} />
default:
return null
}

View File

@@ -35,7 +35,7 @@ export function TagSettings({ formData, onChange, onNext, onPrev }: TagSettingsP
// 当标签更新时更新formData
useEffect(() => {
onChange({ ...formData, tags })
}, [tags, onChange, formData])
}, [tags, onChange])
// 检查是否有标签
useEffect(() => {
@@ -145,7 +145,7 @@ export function TagSettings({ formData, onChange, onNext, onPrev }: TagSettingsP
)}
{hasWarnings && (
<Alert variant="warning" className="mt-4 bg-amber-50 border-amber-200">
<Alert variant="destructive" className="mt-4 bg-amber-50 border-amber-200">
<AlertCircle className="h-4 w-4 text-amber-500" />
<AlertDescription>便</AlertDescription>
</Alert>

View File

@@ -1,6 +1,6 @@
"use client"
import { useState } from "react"
import { useState, useEffect, useRef } from "react"
import { ChevronLeft, Copy, Link, HelpCircle } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useRouter } from "next/navigation"
@@ -35,6 +35,7 @@ const getChannelName = (channel: string) => {
return channelMap[channel] || channel
}
// 恢复Task接口定义
interface Task {
id: string
name: string
@@ -50,6 +51,21 @@ interface Task {
trend: { date: string; customers: number }[]
}
interface PlanItem {
id: number;
name: string;
status: number;
statusText: string;
createTime: number;
createTimeFormat: string;
deviceCount: number;
customerCount: number;
addedCount: number;
passRate: number;
lastExecutionTime: string;
nextExecutionTime: string;
}
interface DeviceStats {
active: number
}
@@ -76,12 +92,46 @@ export default function ChannelPage({ params }: { params: { channel: string } })
const router = useRouter()
const channel = params.channel
const channelName = getChannelName(params.channel)
// 从URL query参数获取场景ID
const [sceneId, setSceneId] = useState<number | null>(null);
// 使用ref追踪sceneId值避免重复请求
const sceneIdRef = useRef<number | null>(null);
// 追踪组件是否已挂载
const isMounted = useRef(true);
// 组件卸载时更新挂载状态
useEffect(() => {
return () => {
isMounted.current = false;
};
}, []);
// 获取URL中的查询参数
useEffect(() => {
// 组件未挂载,不执行操作
if (!isMounted.current) return;
// 从URL获取id参数
const urlParams = new URLSearchParams(window.location.search);
const idParam = urlParams.get('id');
if (idParam && !isNaN(Number(idParam))) {
setSceneId(Number(idParam));
sceneIdRef.current = Number(idParam);
} else {
// 如果没有传递有效的ID使用函数获取默认ID
const defaultId = getSceneIdFromChannel(channel);
setSceneId(defaultId);
sceneIdRef.current = defaultId;
}
}, [channel]);
const initialTasks = [
{
id: "1",
name: `${channelName}直播获客计划`,
status: "running",
status: "running" as const,
stats: {
devices: 5,
acquired: 31,
@@ -98,7 +148,7 @@ export default function ChannelPage({ params }: { params: { channel: string } })
{
id: "2",
name: `${channelName}评论区获客计划`,
status: "paused",
status: "paused" as const,
stats: {
devices: 3,
acquired: 15,
@@ -112,9 +162,11 @@ export default function ChannelPage({ params }: { params: { channel: string } })
customers: Math.floor(Math.random() * 20) + 20,
})),
},
]
] as Task[];
const [tasks, setTasks] = useState<Task[]>(initialTasks)
const [tasks, setTasks] = useState<Task[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [deviceStats, setDeviceStats] = useState<DeviceStats>({
active: 5,
@@ -144,7 +196,7 @@ export default function ChannelPage({ params }: { params: { channel: string } })
toast({
title: "计划已复制",
description: `已成功复制"${taskToCopy.name}"`,
variant: "success",
variant: "default",
})
}
}
@@ -156,7 +208,7 @@ export default function ChannelPage({ params }: { params: { channel: string } })
toast({
title: "计划已删除",
description: `已成功删除"${taskToDelete.name}"`,
variant: "success",
variant: "default",
})
}
}
@@ -167,7 +219,7 @@ export default function ChannelPage({ params }: { params: { channel: string } })
toast({
title: newStatus === "running" ? "计划已启动" : "计划已暂停",
description: `${newStatus === "running" ? "启动" : "暂停"}获客计划`,
variant: "success",
variant: "default",
})
}
@@ -192,10 +244,103 @@ export default function ChannelPage({ params }: { params: { channel: string } })
toast({
title: "已复制",
description: withParams ? "接口地址(含示例参数)已复制到剪贴板" : "接口地址已复制到剪贴板",
variant: "success",
variant: "default",
})
}
// 修改API数据处理部分
useEffect(() => {
// 组件未挂载,不执行操作
if (!isMounted.current) return;
// 防止重复请求如果sceneId没有变化且已经加载过数据则不重新请求
if (sceneId === sceneIdRef.current && tasks.length > 0 && !loading) {
return;
}
const fetchPlanList = async () => {
try {
setLoading(true);
// 如果sceneId还未确定则等待
if (sceneId === null) return;
// 调用API获取计划列表
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/v1/plan/list?id=${sceneId}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
}
);
const data = await response.json();
if (data.code === 1 && data.data && data.data.list) {
// 将API返回的数据转换为前端展示格式
const transformedTasks = data.data.list.map((item: PlanItem) => {
// 确保返回的对象完全符合Task类型
const status: "running" | "paused" | "completed" =
item.status === 1 ? "running" : "paused";
return {
id: item.id.toString(),
name: item.name,
status: status,
stats: {
devices: item.deviceCount,
acquired: item.customerCount,
added: item.addedCount,
},
lastUpdated: item.createTimeFormat,
executionTime: item.lastExecutionTime || "--",
nextExecutionTime: item.nextExecutionTime || "--",
trend: Array.from({ length: 7 }, (_, i) => ({
date: `2月${String(i + 1)}`,
customers: Math.floor(Math.random() * 20) + 10, // 模拟数据
})),
};
});
// 使用类型断言解决类型冲突
setTasks(transformedTasks as Task[]);
setError(null);
} else {
setError(data.msg || "获取计划列表失败");
// 如果API返回错误使用初始数据
setTasks(initialTasks);
}
} catch (err) {
console.error("获取计划列表失败:", err);
setError("网络错误,无法获取计划列表");
// 出错时使用初始数据
setTasks(initialTasks);
} finally {
setLoading(false);
}
};
fetchPlanList();
}, [sceneId]); // 只依赖sceneId变化触发请求
// 辅助函数根据渠道获取场景ID
const getSceneIdFromChannel = (channel: string): number => {
const channelMap: Record<string, number> = {
'douyin': 1,
'xiaohongshu': 2,
'weixinqun': 3,
'gongzhonghao': 4,
'kuaishou': 5,
'weibo': 6,
'haibao': 7,
'phone': 8,
'api': 9
};
return channelMap[channel] || 6;
};
return (
<div className="flex-1 bg-gradient-to-b from-blue-50 to-white min-h-screen">
<header className="sticky top-0 z-10 bg-white/80 backdrop-blur-sm border-b">
@@ -210,7 +355,21 @@ export default function ChannelPage({ params }: { params: { channel: string } })
</header>
<div className="p-4 max-w-7xl mx-auto">
{tasks.length > 0 ? (
{loading ? (
// 添加加载状态
<div className="text-center py-12 bg-white rounded-lg shadow-sm">
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<div className="text-gray-500">...</div>
</div>
) : error ? (
// 添加错误提示
<div className="text-center py-12 bg-white rounded-lg shadow-sm">
<div className="text-red-500 mb-4">{error}</div>
<Button variant="outline" onClick={() => window.location.reload()}>
</Button>
</div>
) : tasks.length > 0 ? (
tasks.map((task) => (
<div key={task.id} className="mb-6">
<ScenarioAcquisitionCard
@@ -254,7 +413,7 @@ export default function ChannelPage({ params }: { params: { channel: string } })
toast({
title: "已复制",
description: "API密钥已复制到剪贴板",
variant: "success",
variant: "default",
})
}}
>

View File

@@ -1,6 +1,6 @@
"use client"
import { useState, useEffect } from "react"
import { useState, useEffect, useRef } from "react"
import type React from "react"
import { TrendingUp, Users, ChevronLeft, Bot, Sparkles, Plus, Phone } from "lucide-react"
import { Card } from "@/components/ui/card"
@@ -97,8 +97,25 @@ export default function ScenariosPage() {
const [channels, setChannels] = useState<Channel[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// 使用ref跟踪组件挂载状态
const isMounted = useRef(true);
// 使用ref跟踪是否已经加载过数据
const hasLoadedRef = useRef(false);
// 组件卸载时更新挂载状态
useEffect(() => {
return () => {
isMounted.current = false;
};
}, []);
useEffect(() => {
// 组件未挂载,不执行操作
if (!isMounted.current) return;
// 如果已经加载过数据,不再重复请求
if (hasLoadedRef.current && channels.length > 0) return;
const loadScenes = async () => {
try {
setLoading(true)
@@ -116,15 +133,26 @@ export default function ScenariosPage() {
}
})
setChannels(transformedScenes)
// 只有在组件仍然挂载的情况下才更新状态
if (isMounted.current) {
setChannels(transformedScenes)
// 标记已加载过数据
hasLoadedRef.current = true;
}
} else {
setError(response.msg || "获取场景列表失败")
if (isMounted.current) {
setError(response.msg || "获取场景列表失败")
}
}
} catch (err) {
console.error("Failed to fetch scenes:", err)
setError("获取场景列表失败")
if (isMounted.current) {
setError("获取场景列表失败")
}
} finally {
setLoading(false)
if (isMounted.current) {
setLoading(false)
}
}
}

View File

@@ -1,6 +1,6 @@
"use client"
import { useState, useEffect } from "react"
import { useState, useEffect, useRef } from "react"
import type { Device } from "@/components/device-grid"
interface DeviceStatus {
@@ -25,21 +25,80 @@ async function fetchDeviceStatuses(deviceIds: string[]): Promise<Record<string,
export function useDeviceStatusPolling(devices: Device[]) {
const [statuses, setStatuses] = useState<Record<string, DeviceStatus>>({})
// 使用ref跟踪上一次的设备ID列表
const prevDeviceIdsRef = useRef<string[]>([]);
// 使用ref跟踪组件挂载状态
const isMounted = useRef(true);
// 记录轮询错误次数,用于实现退避策略
const errorCountRef = useRef(0);
// 检查设备列表是否有实质性变化
const hasDevicesChanged = (prevIds: string[], currentIds: string[]): boolean => {
if (prevIds.length !== currentIds.length) return true;
// 使用Set检查两个数组是否包含相同的元素
const prevSet = new Set(prevIds);
return currentIds.some(id => !prevSet.has(id));
};
useEffect(() => {
// 重置组件挂载状态
isMounted.current = true;
// 获取当前设备ID列表
const deviceIds = devices.map(d => d.id);
// 检查设备列表是否有变化
const deviceListChanged = hasDevicesChanged(prevDeviceIdsRef.current, deviceIds);
// 更新设备ID引用
prevDeviceIdsRef.current = deviceIds;
const pollStatus = async () => {
try {
const newStatuses = await fetchDeviceStatuses(devices.map((d) => d.id))
setStatuses((prevStatuses) => ({ ...prevStatuses, ...newStatuses }))
// 确保组件仍然挂载
if (isMounted.current) {
setStatuses((prevStatuses) => ({ ...prevStatuses, ...newStatuses }))
// 重置错误计数
errorCountRef.current = 0;
}
} catch (error) {
console.error("Failed to fetch device statuses:", error)
// 增加错误计数
errorCountRef.current += 1;
}
}
pollStatus() // 立即执行一次
const intervalId = setInterval(pollStatus, 30000) // 每30秒更新一次
// 仅当设备列表有变化或初始加载时才立即执行一次
if (deviceListChanged || Object.keys(statuses).length === 0) {
pollStatus();
}
// 使用基于错误次数的指数退避策略
const getPollingInterval = () => {
const baseInterval = 30000; // 基础间隔 30 秒
const maxInterval = 2 * 60 * 1000; // 最大间隔 2 分钟
if (errorCountRef.current === 0) return baseInterval;
// 计算指数退避间隔,但不超过最大间隔
const backoffInterval = Math.min(
baseInterval * Math.pow(1.5, Math.min(errorCountRef.current, 5)),
maxInterval
);
return backoffInterval;
};
// 设置轮询间隔
const intervalId = setInterval(pollStatus, getPollingInterval());
return () => clearInterval(intervalId)
// 清理函数
return () => {
isMounted.current = false;
clearInterval(intervalId)
}
}, [devices])
return statuses

View File

@@ -51,13 +51,79 @@ export const request = async <T>(
try {
const response = await fetch(`${API_BASE_URL}${url}`, options);
const result = await response.json();
// 检查网络响应状态
if (!response.ok) {
// 只有当响应状态码为401时才特殊处理为认证错误
if (response.status === 401) {
if (typeof window !== 'undefined') {
// 直接调用handleTokenExpired而不是handleApiError以处理401错误
// 因为有响应体的情况下我们应该让handleApiResponse处理401
// 在这里只处理没有响应体的401网络错误
const errorMessage = `Unauthorized: ${response.statusText}`;
console.error('授权错误:', errorMessage);
// 尝试解析响应如果无法解析才直接处理token过期
try {
await response.json();
// 如果能够解析,让后续代码处理
} catch (e) {
// 如果无法解析说明是纯网络层401错误直接处理token过期
if (typeof window !== 'undefined') {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.href = '/login';
throw new Error(errorMessage);
}
}
}
}
// 其他HTTP错误正常返回让上层组件自行处理
}
let result;
try {
result = await response.json();
} catch (parseError) {
// 处理JSON解析错误
console.error('无法解析响应JSON:', parseError);
throw new Error('服务器响应格式错误');
}
// 使用响应拦截器处理响应
return handleApiResponse<T>(response, result);
if (result && result.code === 401) {
if (typeof window !== 'undefined') {
localStorage.removeItem('token');
localStorage.removeItem('user');
// 使用客户端导航而不是直接修改window.location
setTimeout(() => {
window.location.href = '/login';
}, 0);
}
throw new Error(result.msg || '登录已过期,请重新登录');
}
// 返回结果而不调用可能会清除token的handleApiResponse
return result;
} catch (error) {
// 使用错误拦截器处理错误
return handleApiError(error);
// 只有在确认是401错误时才清除token
if (error instanceof Error &&
(error.message.includes('401') ||
(error.message.toLowerCase().includes('unauthorized') &&
error.message.toLowerCase().includes('token')))) {
if (typeof window !== 'undefined') {
localStorage.removeItem('token');
localStorage.removeItem('user');
setTimeout(() => {
window.location.href = '/login';
}, 0);
}
}
// 其他错误直接抛出但不调用handleApiError
console.error('API请求错误:', error);
throw error;
}
};
@@ -103,10 +169,58 @@ export const validateToken = async (): Promise<boolean> => {
}
try {
const response = await loginApi.getUserInfo();
return response.code === 200;
// 直接使用fetch而不是通过api调用以便捕获具体错误
const token = getToken();
if (!token) return false;
const response = await fetch(`${API_BASE_URL}/v1/auth/info`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
}
});
// 如果状态码是401明确是认证问题
if (response.status === 401) {
return false;
}
// 如果是其他HTTP错误我们不确定是否是认证问题
if (!response.ok) {
// 对于非401错误不要立即判定token无效
console.warn(`验证token时收到HTTP错误: ${response.status} ${response.statusText}`);
// 尝试读取响应内容
try {
const result = await response.json();
// 只有明确返回code为401才判断为token无效
if (result && result.code === 401) {
return false;
}
// 其他错误代码视为服务端问题不影响token有效性
return true;
} catch (parseError) {
// 无法解析响应视为网络或服务器问题不影响token
console.error('无法解析验证token的响应:', parseError);
return true;
}
}
// 正常情况下尝试解析响应并检查code
try {
const result = await response.json();
return result.code === 200;
} catch (parseError) {
// 无法解析响应视为网络或服务器问题不影响token
console.error('无法解析验证token的响应:', parseError);
return true;
}
} catch (error) {
return false;
// 网络错误或其他异常不应该导致token被视为无效
console.error('验证token时发生异常:', error);
// 对于网络连接问题不直接判定为token无效
return true;
}
};

View File

@@ -136,7 +136,7 @@ export const deviceApi = {
momentsSync?: number;
aiChat?: number;
}): Promise<ApiResponse<Device>> {
const response = await fetch(`${API_BASE}/task-config`, {
const response = await fetch(`${API_BASE}/devices/task-config`, {
method: "POST",
headers: {
"Content-Type": "application/json",

View File

@@ -1,4 +1,5 @@
import { useRouter } from "next/navigation";
import { toast } from "@/components/ui/use-toast";
// Token过期处理
export const handleTokenExpired = () => {
@@ -15,34 +16,49 @@ export const handleTokenExpired = () => {
}
};
// 响应拦截器
export const handleApiResponse = <T>(response: Response, result: any): T => {
// 处理token过期情况
if (result && (result.code === 401 || result.msg?.includes('token'))) {
// 仅在客户端处理token过期
if (typeof window !== 'undefined') {
handleTokenExpired();
// 显示API错误但不会重定向
export const showApiError = (error: any, defaultMessage: string = '请求失败') => {
if (typeof window === 'undefined') return; // 服务端不处理
let errorMessage = defaultMessage;
// 尝试从各种可能的错误格式中获取消息
if (error) {
if (typeof error === 'string') {
errorMessage = error;
} else if (error instanceof Error) {
errorMessage = error.message || defaultMessage;
} else if (typeof error === 'object') {
// 尝试从API响应中获取错误消息
errorMessage = error.msg || error.message || error.error || defaultMessage;
}
throw new Error(result.msg || '登录已过期,请重新登录');
}
// 显示错误消息
if (typeof toast !== 'undefined') {
toast({
title: "请求错误",
description: errorMessage,
variant: "destructive"
});
} else {
console.error('API错误:', errorMessage);
}
};
// 响应拦截器 - 此函数已不再使用,保留仅用于兼容性
export const handleApiResponse = <T>(response: Response, result: any): T => {
// 不再处理401响应而是直接返回结果
// 401的处理已移至api.ts中直接处理
console.warn('handleApiResponse已弃用请直接在api.ts中处理响应');
return result;
};
// 处理API错误
// 处理API错误 - 此函数已不再使用,保留仅用于兼容性
export const handleApiError = (error: unknown): never => {
console.error('API请求错误:', error);
if (error instanceof Error) {
// 如果是未授权错误可能是token过期
if (error.message.includes('401') || error.message.includes('token') || error.message.includes('授权')) {
// 仅在客户端处理token过期
if (typeof window !== 'undefined') {
handleTokenExpired();
}
}
throw error;
}
console.warn('handleApiError已弃用请直接在api.ts中处理错误');
throw new Error('未知错误,请稍后重试');
};