Merge branch 'develop' of https://e.coding.net/g-xtcy5189/cunkebao/cunkebao_v3 into develop
This commit is contained in:
@@ -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
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" />
|
||||
刷新
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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('未知错误,请稍后重试');
|
||||
};
|
||||
Reference in New Issue
Block a user