feat: 本次提交更新内容如下
定版本转移2025年7月17日
This commit is contained in:
@@ -1,490 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Bell, Smartphone, Users, Activity, MessageSquare, TrendingUp } from 'lucide-react';
|
||||
import Chart from 'chart.js/auto';
|
||||
import Layout from '@/components/Layout';
|
||||
import BottomNav from '@/components/BottomNav';
|
||||
import UnifiedHeader, { HeaderPresets } from '@/components/UnifiedHeader';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import '@/components/Layout.css';
|
||||
|
||||
// API接口定义
|
||||
const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || "https://ckbapi.quwanzhi.com";
|
||||
|
||||
// 统一的API请求客户端
|
||||
async function apiRequest<T>(url: string): Promise<T> {
|
||||
try {
|
||||
const token = typeof window !== "undefined" ? localStorage.getItem("token") : null;
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
console.log("发送API请求:", url);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers,
|
||||
mode: "cors",
|
||||
});
|
||||
|
||||
console.log("API响应状态:", response.status, response.statusText);
|
||||
|
||||
// 检查响应头的Content-Type
|
||||
const contentType = response.headers.get("content-type");
|
||||
console.log("响应Content-Type:", contentType);
|
||||
|
||||
if (!response.ok) {
|
||||
// 如果是401未授权,清除本地存储
|
||||
if (response.status === 401) {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("userInfo");
|
||||
}
|
||||
}
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// 检查是否是JSON响应
|
||||
if (!contentType || !contentType.includes("application/json")) {
|
||||
const text = await response.text();
|
||||
console.log("非JSON响应内容:", text.substring(0, 200));
|
||||
throw new Error("服务器返回了非JSON格式的数据,可能是HTML错误页面");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log("API响应数据:", data);
|
||||
|
||||
// 检查业务状态码
|
||||
if (data.code && data.code !== 200 && data.code !== 0) {
|
||||
throw new Error(data.message || "请求失败");
|
||||
}
|
||||
|
||||
return data.data || data;
|
||||
} catch (error) {
|
||||
console.error("API请求失败:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const navigate = useNavigate();
|
||||
const chartRef = useRef<HTMLCanvasElement>(null);
|
||||
const chartInstance = useRef<any>(null);
|
||||
|
||||
// 统一设备数据
|
||||
const [stats, setStats] = useState({
|
||||
totalDevices: 0,
|
||||
onlineDevices: 0,
|
||||
totalWechatAccounts: 0,
|
||||
onlineWechatAccounts: 0,
|
||||
});
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [apiError, setApiError] = useState("");
|
||||
|
||||
// 场景获客数据
|
||||
const scenarioFeatures = [
|
||||
{
|
||||
id: "douyin",
|
||||
name: "抖音获客",
|
||||
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-QR8ManuDplYTySUJsY4mymiZkDYnQ9.png",
|
||||
color: "bg-blue-100 text-blue-600",
|
||||
value: 156,
|
||||
growth: 12,
|
||||
},
|
||||
{
|
||||
id: "xiaohongshu",
|
||||
name: "小红书获客",
|
||||
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-yvnMxpoBUzcvEkr8DfvHgPHEo1kmQ3.png",
|
||||
color: "bg-red-100 text-red-600",
|
||||
value: 89,
|
||||
growth: 8,
|
||||
},
|
||||
{
|
||||
id: "gongzhonghao",
|
||||
name: "公众号获客",
|
||||
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-Gsg0CMf5tsZb41mioszdjqU1WmsRxW.png",
|
||||
color: "bg-green-100 text-green-600",
|
||||
value: 234,
|
||||
growth: 15,
|
||||
},
|
||||
{
|
||||
id: "haibao",
|
||||
name: "海报获客",
|
||||
icon: "https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-x92XJgXy4MI7moNYlA1EAes2FqDxMH.png",
|
||||
color: "bg-orange-100 text-orange-600",
|
||||
value: 167,
|
||||
growth: 10,
|
||||
},
|
||||
];
|
||||
|
||||
// 今日数据统计
|
||||
const todayStats = [
|
||||
{
|
||||
title: "朋友圈同步",
|
||||
value: "12",
|
||||
icon: <MessageSquare className="h-4 w-4" />,
|
||||
color: "text-purple-600",
|
||||
path: "/workspace/moments-sync",
|
||||
},
|
||||
{
|
||||
title: "群发任务",
|
||||
value: "8",
|
||||
icon: <Users className="h-4 w-4" />,
|
||||
color: "text-orange-600",
|
||||
path: "/workspace/group-push",
|
||||
},
|
||||
{
|
||||
title: "获客转化",
|
||||
value: "85%",
|
||||
icon: <TrendingUp className="h-4 w-4" />,
|
||||
color: "text-green-600",
|
||||
path: "/scenarios",
|
||||
},
|
||||
{
|
||||
title: "系统活跃度",
|
||||
value: "98%",
|
||||
icon: <Activity className="h-4 w-4" />,
|
||||
color: "text-blue-600",
|
||||
path: "/workspace",
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
// 获取统计数据
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setApiError("");
|
||||
|
||||
// 检查是否有token
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) {
|
||||
console.log("未找到登录token,使用默认数据");
|
||||
setStats({
|
||||
totalDevices: 42,
|
||||
onlineDevices: 35,
|
||||
totalWechatAccounts: 42,
|
||||
onlineWechatAccounts: 35,
|
||||
});
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 尝试请求API数据
|
||||
try {
|
||||
// 并行请求多个接口
|
||||
const [deviceStatsResult, wechatStatsResult] = await Promise.allSettled([
|
||||
apiRequest(`${API_BASE_URL}/v1/dashboard/device-stats`),
|
||||
apiRequest(`${API_BASE_URL}/v1/dashboard/wechat-stats`),
|
||||
]);
|
||||
|
||||
const newStats = {
|
||||
totalDevices: 0,
|
||||
onlineDevices: 0,
|
||||
totalWechatAccounts: 0,
|
||||
onlineWechatAccounts: 0,
|
||||
};
|
||||
|
||||
// 处理设备统计数据
|
||||
if (deviceStatsResult.status === "fulfilled") {
|
||||
const deviceData = deviceStatsResult.value as any;
|
||||
newStats.totalDevices = deviceData.total || 0;
|
||||
newStats.onlineDevices = deviceData.online || 0;
|
||||
} else {
|
||||
console.warn("设备统计API失败:", deviceStatsResult.reason);
|
||||
}
|
||||
|
||||
// 处理微信号统计数据
|
||||
if (wechatStatsResult.status === "fulfilled") {
|
||||
const wechatData = wechatStatsResult.value as any;
|
||||
newStats.totalWechatAccounts = wechatData.total || 0;
|
||||
newStats.onlineWechatAccounts = wechatData.active || 0;
|
||||
} else {
|
||||
console.warn("微信号统计API失败:", wechatStatsResult.reason);
|
||||
}
|
||||
|
||||
setStats(newStats);
|
||||
} catch (apiError) {
|
||||
console.warn("API请求失败,使用默认数据:", apiError);
|
||||
setApiError(apiError instanceof Error ? apiError.message : "API连接失败");
|
||||
|
||||
// 使用默认数据
|
||||
setStats({
|
||||
totalDevices: 42,
|
||||
onlineDevices: 35,
|
||||
totalWechatAccounts: 42,
|
||||
onlineWechatAccounts: 35,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取统计数据失败:", error);
|
||||
setApiError(error instanceof Error ? error.message : "数据加载失败");
|
||||
|
||||
// 使用默认数据
|
||||
setStats({
|
||||
totalDevices: 42,
|
||||
onlineDevices: 35,
|
||||
totalWechatAccounts: 42,
|
||||
onlineWechatAccounts: 35,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
|
||||
// 定时刷新数据(每30秒)
|
||||
const interval = setInterval(fetchStats, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []); // 移除stats依赖
|
||||
|
||||
const handleDevicesClick = () => {
|
||||
navigate('/profile/devices');
|
||||
};
|
||||
|
||||
const handleWechatClick = () => {
|
||||
navigate('/wechat-accounts');
|
||||
};
|
||||
|
||||
// 使用Chart.js创建图表
|
||||
useEffect(() => {
|
||||
if (chartRef.current && !isLoading) {
|
||||
// 如果已经有图表实例,先销毁它
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.destroy();
|
||||
}
|
||||
|
||||
const ctx = chartRef.current.getContext("2d");
|
||||
|
||||
// 添加null检查
|
||||
if (!ctx) return;
|
||||
|
||||
// 创建新的图表实例
|
||||
chartInstance.current = new Chart(ctx, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: ["周一", "周二", "周三", "周四", "周五", "周六", "周日"],
|
||||
datasets: [
|
||||
{
|
||||
label: "获客数量",
|
||||
data: [120, 150, 180, 200, 230, 210, 190],
|
||||
backgroundColor: "rgba(59, 130, 246, 0.2)",
|
||||
borderColor: "rgba(59, 130, 246, 1)",
|
||||
borderWidth: 2,
|
||||
tension: 0.3,
|
||||
pointRadius: 4,
|
||||
pointBackgroundColor: "rgba(59, 130, 246, 1)",
|
||||
pointHoverRadius: 6,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.9)",
|
||||
titleColor: "#333",
|
||||
bodyColor: "#666",
|
||||
borderColor: "#ddd",
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
displayColors: false,
|
||||
callbacks: {
|
||||
label: (context) => `获客数量: ${context.parsed.y}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: "rgba(0, 0, 0, 0.05)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 组件卸载时清理图表实例
|
||||
return () => {
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.destroy();
|
||||
}
|
||||
};
|
||||
}, [isLoading]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<div className="bg-white border-b">
|
||||
<div className="flex justify-between items-center p-4">
|
||||
<h1 className="text-xl font-semibold text-blue-600">存客宝</h1>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
footer={<BottomNav />}
|
||||
>
|
||||
<div className="bg-gray-50">
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Card key={i} className="p-3 bg-white animate-pulse">
|
||||
<div className="h-4 bg-gray-200 rounded mb-2"></div>
|
||||
<div className="h-6 bg-gray-200 rounded"></div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<UnifiedHeader
|
||||
title="存客宝"
|
||||
showBack={false}
|
||||
titleColor="blue"
|
||||
rightContent={
|
||||
<>
|
||||
{apiError && (
|
||||
<div className="text-xs text-orange-600 bg-orange-50 px-2 py-1 rounded mr-2">
|
||||
API连接异常,显示默认数据
|
||||
</div>
|
||||
)}
|
||||
<button className="p-2 hover:bg-gray-100 rounded-full">
|
||||
<Bell className="h-5 w-5 text-gray-600" />
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
footer={<BottomNav />}
|
||||
>
|
||||
<div className="bg-gray-50">
|
||||
<div className="p-4 space-y-4">
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="cursor-pointer" onClick={handleDevicesClick}>
|
||||
<Card className="p-3 bg-white hover:shadow-md transition-all">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-gray-500 mb-1">设备数量</span>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-lg font-bold text-blue-600">{stats.totalDevices}</span>
|
||||
<Smartphone className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="cursor-pointer" onClick={handleWechatClick}>
|
||||
<Card className="p-3 bg-white hover:shadow-md transition-all">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-gray-500 mb-1">微信号数量</span>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-lg font-bold text-blue-600">{stats.totalWechatAccounts}</span>
|
||||
<Users className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<Card className="p-3 bg-white">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-gray-500 mb-1">在线微信号</span>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-lg font-bold text-blue-600">{stats.onlineWechatAccounts}</span>
|
||||
<Activity className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<Progress
|
||||
value={
|
||||
stats.totalWechatAccounts > 0 ? (stats.onlineWechatAccounts / stats.totalWechatAccounts) * 100 : 0
|
||||
}
|
||||
className="h-1"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 场景获客统计 */}
|
||||
<Card className="p-4 bg-white">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h2 className="text-base font-semibold">场景获客统计</h2>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
{scenarioFeatures
|
||||
.sort((a, b) => b.value - a.value)
|
||||
.slice(0, 4) // 只显示前4个
|
||||
.map((scenario) => (
|
||||
<div
|
||||
key={scenario.id}
|
||||
className="block flex-1 cursor-pointer"
|
||||
onClick={() => navigate(`/scenarios/${scenario.id}?name=${encodeURIComponent(scenario.name)}`)}
|
||||
>
|
||||
<div className="flex flex-col items-center text-center space-y-1">
|
||||
<div className={`w-10 h-10 rounded-full ${scenario.color} flex items-center justify-center`}>
|
||||
<img src={scenario.icon || "/placeholder.svg"} alt={scenario.name} className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="text-sm font-medium">{scenario.value}</div>
|
||||
<div className="text-xs text-gray-500 whitespace-nowrap overflow-hidden text-ellipsis w-full">
|
||||
{scenario.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 今日数据统计 */}
|
||||
<Card className="p-4 bg-white">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h2 className="text-base font-semibold">今日数据</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{todayStats.map((stat, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100 transition-colors"
|
||||
onClick={() => stat.path && navigate(stat.path)}
|
||||
>
|
||||
<div className={`p-2 rounded-full bg-white ${stat.color}`}>{stat.icon}</div>
|
||||
<div>
|
||||
<div className="text-lg font-semibold">{stat.value}</div>
|
||||
<div className="text-xs text-gray-500">{stat.title}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 每日获客趋势 */}
|
||||
<Card className="p-4 bg-white">
|
||||
<h2 className="text-base font-semibold mb-3">每日获客趋势</h2>
|
||||
<div className="w-full h-48 relative">
|
||||
<canvas ref={chartRef} />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function ContactImport() {
|
||||
return <div>导入通讯录页</div>;
|
||||
}
|
||||
@@ -1,382 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ChevronLeft, Filter, Search, RefreshCw, Plus, Edit, Trash2, Eye, MoreVertical, Copy } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import { get, del } from '@/api/request';
|
||||
import Layout from '@/components/Layout';
|
||||
import UnifiedHeader from '@/components/UnifiedHeader';
|
||||
import BottomNav from '@/components/BottomNav';
|
||||
|
||||
interface ApiResponse<T = any> {
|
||||
code: number;
|
||||
msg: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
interface LibraryListResponse {
|
||||
list: ContentLibrary[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface WechatGroupMember {
|
||||
id: string;
|
||||
nickname: string;
|
||||
wechatId: string;
|
||||
avatar: string;
|
||||
gender?: 'male' | 'female';
|
||||
role?: 'owner' | 'admin' | 'member';
|
||||
joinTime?: string;
|
||||
}
|
||||
|
||||
interface ContentLibrary {
|
||||
id: string;
|
||||
name: string;
|
||||
source: 'friends' | 'groups';
|
||||
targetAudience: {
|
||||
id: string;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
}[];
|
||||
creator: string;
|
||||
creatorName?: string;
|
||||
itemCount: number;
|
||||
lastUpdated: string;
|
||||
enabled: boolean;
|
||||
sourceFriends: string[];
|
||||
sourceGroups: string[];
|
||||
friendsData?: any[];
|
||||
groupsData?: any[];
|
||||
keywordInclude: string[];
|
||||
keywordExclude: string[];
|
||||
isEnabled: number;
|
||||
aiPrompt: string;
|
||||
timeEnabled: number;
|
||||
timeStart: string;
|
||||
timeEnd: string;
|
||||
status: number;
|
||||
createTime: string;
|
||||
updateTime: string;
|
||||
sourceType: number;
|
||||
selectedGroupMembers?: WechatGroupMember[];
|
||||
}
|
||||
|
||||
function CardMenu({ onView, onEdit, onDelete, onViewMaterials }: {
|
||||
onView: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onViewMaterials: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
if (open) document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<button onClick={() => setOpen((v) => !v)} style={{ background: "none", border: "none", padding: 0, margin: 0, cursor: "pointer" }}>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: 28,
|
||||
background: "#fff",
|
||||
borderRadius: 8,
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
|
||||
zIndex: 100,
|
||||
minWidth: 120,
|
||||
padding: 4,
|
||||
}}
|
||||
>
|
||||
<div onClick={() => { onEdit(); setOpen(false); }} style={{ padding: 8, cursor: "pointer", display: "flex", alignItems: "center", borderRadius: 6, fontSize: 14, gap: 6, transition: "background .2s" }} onMouseOver={e => (e.currentTarget as HTMLDivElement).style.background="#f5f5f5"} onMouseOut={e => (e.currentTarget as HTMLDivElement).style.background=""}>
|
||||
<Edit className="h-4 w-4 mr-2" />编辑
|
||||
</div>
|
||||
<div onClick={() => { onDelete(); setOpen(false); }} style={{ padding: 8, cursor: "pointer", display: "flex", alignItems: "center", borderRadius: 6, fontSize: 14, gap: 6, color: "#e53e3e", transition: "background .2s" }} onMouseOver={e => (e.currentTarget as HTMLDivElement).style.background="#f5f5f5"} onMouseOut={e => (e.currentTarget as HTMLDivElement).style.background=""}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />删除
|
||||
</div>
|
||||
<div onClick={() => { onViewMaterials(); setOpen(false); }} style={{ padding: 8, cursor: "pointer", display: "flex", alignItems: "center", borderRadius: 6, fontSize: 14, gap: 6, transition: "background .2s" }} onMouseOver={e => (e.currentTarget as HTMLDivElement).style.background="#f5f5f5"} onMouseOut={e => (e.currentTarget as HTMLDivElement).style.background=""}>
|
||||
<Eye className="h-4 w-4 mr-2" />查看素材
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Content() {
|
||||
const navigate = useNavigate();
|
||||
const [libraries, setLibraries] = useState<ContentLibrary[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('all');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
// 获取内容库列表
|
||||
const fetchLibraries = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const queryParams = new URLSearchParams({
|
||||
page: '1',
|
||||
limit: '100',
|
||||
...(searchQuery ? { keyword: searchQuery } : {}),
|
||||
...(activeTab !== 'all' ? { sourceType: activeTab === 'friends' ? '1' : '2' } : {})
|
||||
});
|
||||
const response = await get<ApiResponse<LibraryListResponse>>(`/v1/content/library/list?${queryParams.toString()}`);
|
||||
|
||||
if (response.code === 200 && response.data) {
|
||||
// 转换数据格式以匹配原有UI
|
||||
const transformedLibraries = response.data.list.map((item: any) => {
|
||||
const friendsData = Array.isArray(item.selectedFriends) ? item.selectedFriends : [];
|
||||
const groupsData = Array.isArray(item.selectedGroups) ? item.selectedGroups : [];
|
||||
|
||||
const transformedItem: ContentLibrary = {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
source: item.sourceType === 1 ? 'friends' : 'groups',
|
||||
targetAudience: [
|
||||
...friendsData.map((friend: any) => ({
|
||||
id: friend.id,
|
||||
nickname: friend.nickname || `好友${friend.id}`,
|
||||
avatar: friend.avatar || '/placeholder.svg'
|
||||
})),
|
||||
...groupsData.map((group: any) => ({
|
||||
id: group.id,
|
||||
nickname: group.name || `群组${group.id}`,
|
||||
avatar: group.avatar || '/placeholder.svg'
|
||||
}))
|
||||
],
|
||||
creator: item.creatorName || '系统',
|
||||
creatorName: item.creatorName,
|
||||
itemCount: item.itemCount,
|
||||
lastUpdated: item.updateTime,
|
||||
enabled: item.isEnabled === 1,
|
||||
sourceFriends: item.sourceFriends || [],
|
||||
sourceGroups: item.sourceGroups || [],
|
||||
friendsData: friendsData,
|
||||
groupsData: groupsData,
|
||||
keywordInclude: item.keywordInclude || [],
|
||||
keywordExclude: item.keywordExclude || [],
|
||||
isEnabled: item.isEnabled,
|
||||
aiPrompt: item.aiPrompt || '',
|
||||
timeEnabled: item.timeEnabled,
|
||||
timeStart: item.timeStart || '',
|
||||
timeEnd: item.timeEnd || '',
|
||||
status: item.status,
|
||||
createTime: item.createTime,
|
||||
updateTime: item.updateTime,
|
||||
sourceType: item.sourceType,
|
||||
selectedGroupMembers: item.selectedGroupMembers || []
|
||||
};
|
||||
return transformedItem;
|
||||
});
|
||||
setLibraries(transformedLibraries);
|
||||
} else {
|
||||
toast({ title: '获取失败', description: response.msg || '获取内容库列表失败' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('获取内容库列表失败:', error);
|
||||
toast({ title: '网络错误', description: error?.message || '请检查网络连接' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchQuery, activeTab, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLibraries();
|
||||
}, [fetchLibraries]);
|
||||
|
||||
const handleCreateNew = () => {
|
||||
navigate('/content/new');
|
||||
};
|
||||
|
||||
const handleEdit = (id: string) => {
|
||||
navigate(`/content/edit/${id}`);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
const response = await del<ApiResponse>(`/v1/content/library/delete?id=${id}`);
|
||||
if (response.code === 200) {
|
||||
toast({ title: '删除成功', description: '内容库已删除' });
|
||||
fetchLibraries();
|
||||
} else {
|
||||
toast({ title: '删除失败', description: response.msg || '删除失败' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('删除内容库失败:', error);
|
||||
toast({ title: '网络错误', description: error?.message || '请检查网络连接' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewMaterials = (id: string) => {
|
||||
navigate(`/content/materials/${id}`);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
fetchLibraries();
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchLibraries();
|
||||
};
|
||||
|
||||
const filteredLibraries = libraries.filter(
|
||||
(library) =>
|
||||
library.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
library.targetAudience.some((target) => target.nickname.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<UnifiedHeader title="内容库" showBack />
|
||||
<div className="bg-white shadow-sm rounded-b-xl px-4 pt-4 pb-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索内容库..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="pl-9 rounded-full bg-gray-50 border-none focus:ring-2 focus:ring-blue-100"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="rounded-full border-gray-200"
|
||||
>
|
||||
<RefreshCw className={`h-5 w-5 ${loading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
<Button onClick={handleCreateNew} className="rounded-full px-4 py-2" size="sm">
|
||||
<Plus className="h-4 w-4 mr-1" />新建
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-3 rounded-full bg-gray-100">
|
||||
<TabsTrigger value="all" className="rounded-full">全部</TabsTrigger>
|
||||
<TabsTrigger value="friends" className="rounded-full">微信好友</TabsTrigger>
|
||||
<TabsTrigger value="groups" className="rounded-full">聊天群</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
footer={<BottomNav />}
|
||||
>
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-3">
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center py-12">
|
||||
<RefreshCw className="h-8 w-8 text-blue-500 animate-spin" />
|
||||
</div>
|
||||
) : filteredLibraries.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
|
||||
<img src="/empty-state-content.svg" alt="暂无内容库" className="w-32 h-32 mb-4 opacity-80" />
|
||||
<div className="mb-2">暂无内容库,快去新建一个吧!</div>
|
||||
<Button onClick={handleCreateNew} size="sm" className="rounded-full px-6">新建内容库</Button>
|
||||
</div>
|
||||
) : (
|
||||
filteredLibraries.map((library, idx) => (
|
||||
<Card
|
||||
key={library.id}
|
||||
className={`p-4 rounded-xl shadow-sm border border-gray-100 transition hover:shadow-md bg-white ${idx !== filteredLibraries.length - 1 ? 'mb-2' : ''}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h3 className="font-medium text-base text-gray-900">{library.name}</h3>
|
||||
<Badge variant={library.isEnabled === 1 ? 'default' : 'secondary'} className="text-xs rounded-full px-2">
|
||||
{library.isEnabled === 1 ? '已启用' : '未启用'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>来源:</span>
|
||||
{library.sourceType === 1 && library.sourceFriends?.length > 0 ? (
|
||||
<div className="flex -space-x-1 overflow-hidden">
|
||||
{(library.friendsData || []).slice(0, 3).map((friend) => (
|
||||
<img
|
||||
key={friend.id}
|
||||
src={friend.avatar || '/placeholder.svg'}
|
||||
alt={friend.nickname || `好友${friend.id}`}
|
||||
className="inline-block h-6 w-6 rounded-full ring-2 ring-white"
|
||||
/>
|
||||
))}
|
||||
{library.sourceFriends.length > 3 && (
|
||||
<span className="flex items-center justify-center h-6 w-6 rounded-full bg-gray-200 text-xs font-medium text-gray-800">
|
||||
+{library.sourceFriends.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : library.sourceType === 2 && library.sourceGroups?.length > 0 ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex -space-x-1 overflow-hidden">
|
||||
{(library.groupsData || []).slice(0, 3).map((group) => (
|
||||
<img
|
||||
key={group.id}
|
||||
src={group.avatar || '/placeholder.svg'}
|
||||
alt={group.name || `群组${group.id}`}
|
||||
className="inline-block h-6 w-6 rounded-full ring-2 ring-white"
|
||||
/>
|
||||
))}
|
||||
{library.sourceGroups.length > 3 && (
|
||||
<span className="flex items-center justify-center h-6 w-6 rounded-full bg-gray-200 text-xs font-medium text-gray-800">
|
||||
+{library.sourceGroups.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-6 h-6 bg-gray-200 rounded-full"></div>
|
||||
)}
|
||||
</div>
|
||||
<div>创建人:{library.creator}</div>
|
||||
<div>内容数量:{library.itemCount}</div>
|
||||
<div>更新时间:{new Date(library.updateTime).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}</div>
|
||||
</div>
|
||||
</div>
|
||||
<CardMenu
|
||||
onView={() => navigate(`/content/${library.id}`)}
|
||||
onEdit={() => handleEdit(library.id)}
|
||||
onDelete={() => handleDelete(library.id)}
|
||||
onViewMaterials={() => handleViewMaterials(library.id)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,482 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import Layout from "@/components/Layout";
|
||||
import UnifiedHeader from "@/components/UnifiedHeader";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Collapse, CollapsePanel, Button } from "tdesign-mobile-react";
|
||||
import { toast } from "@/components/ui/toast";
|
||||
import FriendSelection from "@/components/FriendSelection";
|
||||
import GroupSelection from "@/components/GroupSelection";
|
||||
import { get, post } from "@/api/request";
|
||||
// TODO: 引入微信好友/群组选择器、日期选择器等组件
|
||||
|
||||
interface WechatFriend {
|
||||
id: string;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
}
|
||||
interface WechatGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
interface ContentLibraryForm {
|
||||
name: string;
|
||||
sourceType: "friends" | "groups";
|
||||
keywordsInclude: string;
|
||||
keywordsExclude: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
selectedFriends: WechatFriend[];
|
||||
selectedGroups: WechatGroup[];
|
||||
useAI: boolean;
|
||||
aiPrompt: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export default function NewContentLibraryPage() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const isEdit = !!id;
|
||||
const [form, setForm] = useState<ContentLibraryForm>({
|
||||
name: "",
|
||||
sourceType: "friends",
|
||||
keywordsInclude: "",
|
||||
keywordsExclude: "",
|
||||
startDate: "",
|
||||
endDate: "",
|
||||
selectedFriends: [],
|
||||
selectedGroups: [],
|
||||
useAI: false,
|
||||
aiPrompt: "",
|
||||
enabled: true,
|
||||
});
|
||||
const [selectedFriendObjs, setSelectedFriendObjs] = useState<WechatFriend[]>(
|
||||
[]
|
||||
);
|
||||
const [selectedGroupObjs, setSelectedGroupObjs] = useState<WechatGroup[]>([]);
|
||||
const [isFriendSelectorOpen, setIsFriendSelectorOpen] = useState(false);
|
||||
const [isGroupSelectorOpen, setIsGroupSelectorOpen] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit) {
|
||||
(async () => {
|
||||
const res = await get(`/v1/content/library/detail?id=${id}`);
|
||||
if (res && res.code === 200 && res.data) {
|
||||
const data = res.data;
|
||||
// 时间戳转YYYY-MM-DD
|
||||
const formatDate = (val: number) => {
|
||||
if (
|
||||
!val ||
|
||||
val === 0 ||
|
||||
typeof val !== "number" ||
|
||||
isNaN(val) ||
|
||||
val < 1000000000
|
||||
)
|
||||
return "";
|
||||
try {
|
||||
const d = new Date(val * 1000);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
return d.toISOString().slice(0, 10);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
name: data.name || "",
|
||||
sourceType: data.sourceType === 1 ? "friends" : "groups",
|
||||
keywordsInclude: (data.keywordInclude || []).join(","),
|
||||
keywordsExclude: (data.keywordExclude || []).join(","),
|
||||
startDate: formatDate(data.timeStart),
|
||||
endDate: formatDate(data.timeEnd),
|
||||
selectedFriends: (
|
||||
data.selectedFriends ||
|
||||
data.sourceFriends ||
|
||||
[]
|
||||
).map((fid: number | string) => ({
|
||||
id: String(fid),
|
||||
nickname: String(fid),
|
||||
avatar: "",
|
||||
})),
|
||||
selectedGroups: (data.sourceGroups || []).map(
|
||||
(gid: number | string) => ({
|
||||
id: String(gid),
|
||||
name: String(gid),
|
||||
avatar: "",
|
||||
})
|
||||
),
|
||||
useAI: data.aiEnabled === 1,
|
||||
aiPrompt: data.aiPrompt || "",
|
||||
enabled: data.status === 1,
|
||||
}));
|
||||
setSelectedFriendObjs(
|
||||
(data.selectedFriends || data.sourceFriends || []).map(
|
||||
(fid: number | string) => ({
|
||||
id: String(fid),
|
||||
nickname: String(fid),
|
||||
avatar: "",
|
||||
})
|
||||
)
|
||||
);
|
||||
setSelectedGroupObjs(
|
||||
(data.sourceGroups || []).map((gid: number | string) => ({
|
||||
id: String(gid),
|
||||
name: String(gid),
|
||||
avatar: "",
|
||||
}))
|
||||
);
|
||||
}
|
||||
})();
|
||||
}
|
||||
}, [isEdit, id]);
|
||||
|
||||
// TODO: 选择器、日期选择器等逻辑
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const payload = {
|
||||
id: isEdit ? id : undefined,
|
||||
name: form.name,
|
||||
sourceType: form.sourceType === "friends" ? 1 : 2,
|
||||
friends: form.selectedFriends.map((f) => Number(f.id)),
|
||||
groups: form.selectedGroups.map((g) => Number(g.id)),
|
||||
groupMembers: {},
|
||||
keywordInclude: form.keywordsInclude
|
||||
? form.keywordsInclude
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
keywordExclude: form.keywordsExclude
|
||||
? form.keywordsExclude
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
aiPrompt: form.aiPrompt,
|
||||
timeEnabled: form.startDate || form.endDate ? 1 : 0,
|
||||
startTime: form.startDate || "",
|
||||
endTime: form.endDate || "",
|
||||
status: form.enabled ? 1 : 0,
|
||||
};
|
||||
if (isEdit) {
|
||||
await post("/v1/content/library/update", payload);
|
||||
} else {
|
||||
await post("/v1/content/library/create", payload);
|
||||
}
|
||||
toast({
|
||||
title: isEdit ? "保存成功" : "创建成功",
|
||||
description: "内容库已保存",
|
||||
});
|
||||
navigate("/content");
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: isEdit ? "保存失败" : "创建失败",
|
||||
description: "保存内容库失败",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<UnifiedHeader
|
||||
title={isEdit ? "编辑内容库" : "新建内容库"}
|
||||
showBack
|
||||
onBack={() => navigate(-1)}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<div className="p-4">
|
||||
<Button
|
||||
theme="primary"
|
||||
block
|
||||
onClick={handleSave}
|
||||
disabled={isSubmitting || !form.name}
|
||||
>
|
||||
{isSubmitting
|
||||
? isEdit
|
||||
? "保存中..."
|
||||
: "创建中..."
|
||||
: isEdit
|
||||
? "保存"
|
||||
: "创建内容库"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex-1 bg-gray-50 ">
|
||||
<div className="p-4 space-y-4 max-w-lg mx-auto">
|
||||
<Card className="p-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block font-medium mb-1">
|
||||
内容库名称 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, name: e.target.value }))
|
||||
}
|
||||
placeholder="请输入内容库名称"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-medium mb-1">数据来源配置</label>
|
||||
<Tabs
|
||||
value={form.sourceType}
|
||||
onValueChange={(val) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
sourceType: val as "friends" | "groups",
|
||||
}))
|
||||
}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="friends">选择微信好友</TabsTrigger>
|
||||
<TabsTrigger value="groups">选择聊天群</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="friends">
|
||||
<FriendSelection
|
||||
selectedFriends={form.selectedFriends.map((f) => f.id)}
|
||||
onSelect={(ids) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
selectedFriends: ids.map((id) => ({
|
||||
id,
|
||||
nickname: id,
|
||||
avatar: "",
|
||||
})),
|
||||
}))
|
||||
}
|
||||
onSelectDetail={setSelectedFriendObjs}
|
||||
enableDeviceFilter={false}
|
||||
placeholder="选择微信好友"
|
||||
/>
|
||||
{selectedFriendObjs.length > 0 && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{selectedFriendObjs.map((friend) => (
|
||||
<div
|
||||
key={friend.id}
|
||||
className="flex items-center justify-between bg-gray-100 p-2 rounded-md"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{friend.avatar ? (
|
||||
<img
|
||||
src={friend.avatar}
|
||||
alt={friend.nickname}
|
||||
className="w-8 h-8 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-gray-300 flex items-center justify-center text-white text-sm">
|
||||
{friend.nickname?.charAt(0) || "友"}
|
||||
</div>
|
||||
)}
|
||||
<span>{friend.nickname}</span>
|
||||
</div>
|
||||
<button
|
||||
className="text-gray-400 hover:text-red-500 ml-2"
|
||||
onClick={() => {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
selectedFriends: f.selectedFriends.filter(
|
||||
(frd) => frd.id !== friend.id
|
||||
),
|
||||
}));
|
||||
setSelectedFriendObjs((objs) =>
|
||||
objs.filter((frd) => frd.id !== friend.id)
|
||||
);
|
||||
}}
|
||||
title="移除"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="groups">
|
||||
<GroupSelection
|
||||
selectedGroups={form.selectedGroups.map((g) => g.id)}
|
||||
onSelect={(ids) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
selectedGroups: ids.map((id) => {
|
||||
const old = f.selectedGroups.find(
|
||||
(g) => g.id === id
|
||||
);
|
||||
return old || { id, name: id, avatar: "" };
|
||||
}),
|
||||
}))
|
||||
}
|
||||
onSelectDetail={setSelectedGroupObjs}
|
||||
placeholder="选择群聊"
|
||||
/>
|
||||
{selectedGroupObjs.length > 0 && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{selectedGroupObjs.map((group) => (
|
||||
<div
|
||||
key={group.id}
|
||||
className="flex items-center justify-between bg-gray-100 p-2 rounded-md"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{group.avatar ? (
|
||||
<img
|
||||
src={group.avatar}
|
||||
alt={group.name}
|
||||
className="w-8 h-8 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-gray-300 flex items-center justify-center text-white text-sm">
|
||||
{group.name?.charAt(0) || "群"}
|
||||
</div>
|
||||
)}
|
||||
<span>{group.name}</span>
|
||||
</div>
|
||||
<button
|
||||
className="text-gray-400 hover:text-red-500 ml-2"
|
||||
onClick={() => {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
selectedGroups: f.selectedGroups.filter(
|
||||
(grp) => grp.id !== group.id
|
||||
),
|
||||
}));
|
||||
setSelectedGroupObjs((objs) =>
|
||||
objs.filter((grp) => grp.id !== group.id)
|
||||
);
|
||||
}}
|
||||
title="移除"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
<Collapse>
|
||||
<CollapsePanel header="关键字设置" value="keywords">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block font-medium mb-1">
|
||||
关键字匹配
|
||||
</label>
|
||||
<Textarea
|
||||
value={form.keywordsInclude}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
keywordsInclude: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="如果设置了关键字,系统只会采集含有关键字的内容。多个关键字,用半角的','隔开。"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-medium mb-1">
|
||||
关键字排除
|
||||
</label>
|
||||
<Textarea
|
||||
value={form.keywordsExclude}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
keywordsExclude: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="排除含有这些关键字的内容。多个关键字,用半角的','隔开。"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsePanel>
|
||||
</Collapse>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="block font-medium">是否启用AI</label>
|
||||
</div>
|
||||
<div className="w-10">
|
||||
<Switch
|
||||
checked={form.useAI}
|
||||
onCheckedChange={(checked) =>
|
||||
setForm((f) => ({ ...f, useAI: checked }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1 ">
|
||||
当启用AI之后,该内容库下的所有内容,都会通过AI重新生成内容。
|
||||
</p>
|
||||
{form.useAI && (
|
||||
<div>
|
||||
<label className="block font-medium mb-1">AI 提示词</label>
|
||||
<Textarea
|
||||
value={form.aiPrompt}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, aiPrompt: e.target.value }))
|
||||
}
|
||||
placeholder="请输入 AI 提示词"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block font-medium mb-2">时间限制</label>
|
||||
{/* TODO: 替换为TDesign日期范围选择器 */}
|
||||
<div
|
||||
className="flex mb-2"
|
||||
style={{ justifyContent: "space-between" }}
|
||||
>
|
||||
<label className="text-sm w-20 ">开始时间</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={form.startDate}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, startDate: e.target.value }))
|
||||
}
|
||||
className="inline-block w-1/2 "
|
||||
/>
|
||||
</div>
|
||||
<div className="flex ">
|
||||
<label className="text-sm w-20">结束时间</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={form.endDate}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, endDate: e.target.value }))
|
||||
}
|
||||
className="inline-block w-1/2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block font-medium mb-1">是否启用</label>
|
||||
<Switch
|
||||
checked={form.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setForm((f) => ({ ...f, enabled: checked }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
{/* TODO: 微信好友/群组选择器弹窗、日期选择器弹窗 */}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import Layout from '@/components/Layout';
|
||||
import UnifiedHeader from '@/components/UnifiedHeader';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
import { get, del } from '@/api/request';
|
||||
import { Plus, Search, Edit, Trash2, UserCircle2, Tag, BarChart } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
|
||||
interface MaterialItem {
|
||||
id: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
type?: string; // 可选: text/image/video/link
|
||||
images?: string[];
|
||||
video?: string;
|
||||
createTime?: string;
|
||||
status?: string;
|
||||
title?: string; // Added for new card structure
|
||||
creatorName?: string; // Added for new card structure
|
||||
aiAnalysis?: string; // Added for AI analysis result
|
||||
resUrls?: string[]; // Added for image URLs
|
||||
}
|
||||
|
||||
export default function Materials() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const [materials, setMaterials] = useState<MaterialItem[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [aiDialogOpen, setAiDialogOpen] = useState(false);
|
||||
const [selectedMaterial, setSelectedMaterial] = useState<MaterialItem | null>(null);
|
||||
|
||||
// 拉取素材列表
|
||||
const fetchMaterials = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await get(`/v1/content/library/item-list?page=1&limit=100&libraryId=${id}${searchQuery ? `&keyword=${encodeURIComponent(searchQuery)}` : ''}`);
|
||||
if (res && res.code === 200 && Array.isArray(res.data?.list)) {
|
||||
setMaterials(res.data.list);
|
||||
} else {
|
||||
setMaterials([]);
|
||||
toast({ title: '获取失败', description: res?.msg || '获取素材列表失败' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
setMaterials([]);
|
||||
toast({ title: '网络错误', description: error?.message || '请检查网络连接' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchMaterials();
|
||||
// eslint-disable-next-line
|
||||
}, [id]);
|
||||
|
||||
const handleSearch = () => {
|
||||
fetchMaterials();
|
||||
};
|
||||
|
||||
const handleDelete = async (materialId: string) => {
|
||||
if (!window.confirm('确定要删除该素材吗?')) return;
|
||||
try {
|
||||
const res = await del(`/v1/content/library/material/delete?id=${materialId}`);
|
||||
if (res && res.code === 200) {
|
||||
toast({ title: '删除成功', description: '素材已删除' });
|
||||
fetchMaterials();
|
||||
} else {
|
||||
toast({ title: '删除失败', description: res?.msg || '删除素材失败' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({ title: '网络错误', description: error?.message || '请检查网络连接' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleNewMaterial = () => {
|
||||
navigate(`/content/materials/new/${id}`);
|
||||
};
|
||||
|
||||
const handleEdit = (materialId: string) => {
|
||||
navigate(`/content/materials/edit/${id}/${materialId}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<UnifiedHeader title="素材列表" showBack onBack={() => navigate(-1)}
|
||||
|
||||
rightContent={
|
||||
<>
|
||||
<Button onClick={handleNewMaterial} variant="default">
|
||||
<Plus className="h-4 w-4 mr-1" />新建素材
|
||||
</Button>
|
||||
</>
|
||||
}/>
|
||||
<div className="flex items-center gap-2 m-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索素材内容或标签..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleSearch(); }}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleSearch} variant="outline">搜索</Button>
|
||||
|
||||
</div>
|
||||
</>
|
||||
|
||||
}
|
||||
>
|
||||
<div className="flex-1 bg-gray-50 min-h-screen pb-16">
|
||||
<div className="p-4 space-y-4 max-w-2xl mx-auto">
|
||||
<div className="space-y-2">
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-gray-400">加载中...</div>
|
||||
) : materials.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400">暂无素材</div>
|
||||
) : (
|
||||
materials.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="bg-white rounded-2xl border border-gray-200 shadow-sm p-5 mb-4 flex flex-col"
|
||||
style={{ boxShadow: '0 2px 8px 0 rgba(0,0,0,0.04)' }}
|
||||
>
|
||||
{/* 顶部头像+系统创建+ID */}
|
||||
<div className="flex items-center mb-2">
|
||||
<div className="w-12 h-12 rounded-full bg-blue-100 flex items-center justify-center text-2xl mr-3">
|
||||
<UserCircle2 className="w-10 h-10 text-blue-400" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold text-base text-gray-800 leading-tight">系统创建</span>
|
||||
<span className="mt-1">
|
||||
<span className="bg-blue-50 text-blue-700 text-xs font-bold rounded-full px-3 py-0.5 align-middle">ID: {item.id}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* 标题 */}
|
||||
<div className="font-bold text-lg text-gray-900 mb-2 mt-1">{item.title ? `【${item.title}】` : (item.content.length > 20 ? `【${item.content.slice(0, 20)}...】` : `【${item.content}】`)}</div>
|
||||
{/* 内容 */}
|
||||
<div className="text-base text-gray-800 whitespace-pre-line mb-3" style={{ lineHeight: '1.8' }}>{item.content}</div>
|
||||
{/* 图片展示 */}
|
||||
{item.resUrls && item.resUrls.length > 0 && (
|
||||
<div className="flex flex-col gap-2 mb-3">
|
||||
{item.resUrls.map((url: string, idx: number) => (
|
||||
<img
|
||||
key={idx}
|
||||
src={url}
|
||||
alt="素材图片"
|
||||
className="w-full max-w-full rounded-lg border"
|
||||
style={{ height: 'auto', boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 标签 */}
|
||||
{item.tags && item.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{item.tags.map((tag, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
<Tag className="h-3 w-3 mr-1" />
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 操作按钮区 */}
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => handleEdit(item.id)}>
|
||||
<Edit className="h-4 w-4 mr-1" />编辑
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => { setSelectedMaterial(item); setAiDialogOpen(true); }}>
|
||||
<BarChart className="h-4 w-4 mr-1" />AI分析
|
||||
</Button>
|
||||
<Dialog open={aiDialogOpen && selectedMaterial?.id === item.id} onOpenChange={setAiDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>AI 分析结果</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="mt-4">
|
||||
<p>{selectedMaterial?.aiAnalysis || '正在分析中...'}</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<Button size="sm" variant="destructive" onClick={() => handleDelete(item.id)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from 'tdesign-mobile-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
import Layout from '@/components/Layout';
|
||||
import UnifiedHeader from '@/components/UnifiedHeader';
|
||||
import { get, post } from '@/api/request';
|
||||
import UploadImage from '@/components/UploadImage';
|
||||
import UploadVideo from '@/components/UploadVideo';
|
||||
|
||||
export default function NewMaterial() {
|
||||
const navigate = useNavigate();
|
||||
const { id, materialId } = useParams(); // materialId 作为编辑标识
|
||||
const [content, setContent] = useState('');
|
||||
const [comment, setComment] = useState('');
|
||||
const [contentType, setContentType] = useState<number>(1);
|
||||
const [desc, setDesc] = useState('');
|
||||
const [coverImage, setCoverImage] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
const [videoUrl, setVideoUrl] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isEdit, setIsEdit] = useState(false);
|
||||
const [sendTime, setSendTime] = useState('');
|
||||
const [images, setImages] = useState<string[]>([]);
|
||||
const [isFirstLoad, setIsFirstLoad] = useState(true);
|
||||
// 优化图片上传逻辑,确保每次选择图片后立即上传并回显
|
||||
|
||||
|
||||
// 判断模式并拉取详情
|
||||
useEffect(() => {
|
||||
if (materialId) {
|
||||
setIsEdit(true);
|
||||
get(`/v1/content/library/get-item-detail?id=${materialId}`)
|
||||
.then(res => {
|
||||
if (res && res.code === 200 && res.data) {
|
||||
setContent(res.data.content || '');
|
||||
setComment(res.data.comment || '');
|
||||
setSendTime(res.data.sendTime || '');
|
||||
if (isFirstLoad && res.data.contentType) {
|
||||
setContentType(Number(res.data.contentType));
|
||||
setIsFirstLoad(false);
|
||||
}
|
||||
setDesc(res.data.desc || '');
|
||||
setCoverImage(res.data.coverImage || '');
|
||||
setUrl(res.data.url || '');
|
||||
setVideoUrl(res.data.videoUrl || '');
|
||||
setImages(res.data.resUrls || []); // 图片回显
|
||||
} else {
|
||||
toast({ title: '获取失败', description: res?.msg || '获取素材详情失败', variant: 'destructive' });
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
toast({ title: '网络错误', description: error?.message || '请检查网络连接', variant: 'destructive' });
|
||||
});
|
||||
} else {
|
||||
setIsEdit(false);
|
||||
setContent('');
|
||||
setComment('');
|
||||
setSendTime('');
|
||||
setContentType(1);
|
||||
setImages([]);
|
||||
setIsFirstLoad(true);
|
||||
}
|
||||
}, [materialId]);
|
||||
|
||||
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!content) {
|
||||
toast({
|
||||
title: '错误',
|
||||
description: '请输入素材内容',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
let res;
|
||||
if (isEdit) {
|
||||
// 编辑模式,调用新接口,所有字段取表单值
|
||||
const payload = {
|
||||
id: materialId,
|
||||
contentType,
|
||||
content,
|
||||
comment,
|
||||
sendTime,
|
||||
resUrls: images,
|
||||
};
|
||||
res = await post('/v1/content/library/update-item', payload);
|
||||
} else {
|
||||
// 新建模式,所有字段取表单值
|
||||
const payload = {
|
||||
libraryId: id,
|
||||
type: contentType,
|
||||
content,
|
||||
comment,
|
||||
sendTime,
|
||||
resUrls: images,
|
||||
};
|
||||
res = await post('/v1/content/library/create-item', payload);
|
||||
}
|
||||
if (res && res.code === 200) {
|
||||
toast({ title: '成功', description: isEdit ? '素材已更新' : '新素材已创建' });
|
||||
navigate(-1);
|
||||
} else {
|
||||
toast({ title: isEdit ? '保存失败' : '创建失败', description: res?.msg || (isEdit ? '保存素材失败' : '创建新素材失败'), variant: 'destructive' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({ title: '网络错误', description: error?.message || '请检查网络连接', variant: 'destructive' });
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 移除未用的 handleUploadImage 及 uploadImage 相关代码
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={<UnifiedHeader title={isEdit ? '编辑素材' : '新建素材'} showBack onBack={() => navigate(-1)} />}
|
||||
footer={
|
||||
<div className='m-2'>
|
||||
{/* 2. 按钮onClick绑定handleSave */}
|
||||
<Button theme="primary" block onClick={handleSave} disabled={isSubmitting}>
|
||||
{isSubmitting ? (isEdit ? '保存中...' : '创建中...') : (isEdit ? '保存修改' : '保存素材')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex-1 bg-gray-50 min-h-screen">
|
||||
<div className="p-4 max-w-lg mx-auto">
|
||||
<Card className="p-8 rounded-3xl shadow-xl bg-white">
|
||||
<form className="space-y-8">
|
||||
{/* 基础信息分组 */}
|
||||
<div className="mb-6">
|
||||
<div className="text-xs text-gray-400 mb-2 tracking-widest">基础信息</div>
|
||||
<Label className="font-bold flex items-center mb-2">发布时间</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={sendTime}
|
||||
onChange={e => setSendTime(e.target.value)}
|
||||
className="w-full h-12 rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base placeholder:text-gray-300"
|
||||
placeholder="请选择发布时间"
|
||||
/>
|
||||
<Label className="font-bold flex items-center mb-2 mt-4"><span className="text-red-500 mr-1">*</span>类型</Label>
|
||||
<select
|
||||
value={contentType}
|
||||
onChange={e => setContentType(Number(e.target.value))}
|
||||
className="w-full h-12 border border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base bg-white appearance-none"
|
||||
>
|
||||
<option value="" disabled>请选择类型</option>
|
||||
<option value={1}>图片</option>
|
||||
<option value={2}>链接</option>
|
||||
<option value={3}>视频</option>
|
||||
<option value={4}>文本</option>
|
||||
<option value={5}>小程序</option>
|
||||
</select>
|
||||
</div>
|
||||
{/* 内容信息分组 */}
|
||||
<div className="mb-6">
|
||||
<div className="text-xs text-gray-400 mb-2 tracking-widest">内容信息</div>
|
||||
<Label htmlFor="content" className="font-bold flex items-center mb-2"><span className="text-red-500 mr-1">*</span>内容</Label>
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={e => setContent(e.target.value)}
|
||||
placeholder="请输入内容"
|
||||
className="w-full rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base min-h-[120px] bg-gray-50 placeholder:text-gray-300"
|
||||
rows={8}
|
||||
/>
|
||||
{(contentType === 2 || contentType === 6) && (
|
||||
<>
|
||||
<Label htmlFor="desc" className="font-bold flex items-center mb-2"><span className="text-red-500 mr-1">*</span>描述</Label>
|
||||
<Input
|
||||
id="desc"
|
||||
value={desc}
|
||||
onChange={e => setDesc(e.target.value)}
|
||||
placeholder="请输入描述"
|
||||
className="w-full h-12 rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base placeholder:text-gray-300"
|
||||
/>
|
||||
<Label className="font-bold mb-2 mt-4">封面图</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<UploadImage
|
||||
value={images}
|
||||
onChange={urls => {
|
||||
setCoverImage(urls[0]);
|
||||
}}
|
||||
max={1}
|
||||
accept="image/*"
|
||||
/>
|
||||
</div>
|
||||
<Label htmlFor="url" className="font-bold flex items-center mb-2 mt-4"><span className="text-red-500 mr-1">*</span>链接地址</Label>
|
||||
<Input
|
||||
id="url"
|
||||
value={url}
|
||||
onChange={e => setUrl(e.target.value)}
|
||||
placeholder="请输入链接地址"
|
||||
className="w-full h-12 rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base placeholder:text-gray-300"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{contentType === 3 && (
|
||||
<>
|
||||
<Label className="font-bold mb-2">上传视频</Label>
|
||||
<div className="pt-4">
|
||||
<UploadVideo
|
||||
value={videoUrl}
|
||||
onChange={setVideoUrl}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* 素材上传分组(仅图片类型和小程序类型) */}
|
||||
{([1,5].includes(contentType)) && (
|
||||
<div className="mb-6">
|
||||
<div className="text-xs text-gray-400 mb-2 tracking-widest">素材上传(最多上传9张)</div>
|
||||
{contentType === 1 && (
|
||||
<div className="mb-6">
|
||||
<UploadImage
|
||||
value={images}
|
||||
onChange={urls => {
|
||||
setImages(urls);
|
||||
}}
|
||||
max={9}
|
||||
accept="image/*"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{contentType === 5 && (
|
||||
<div className="space-y-6">
|
||||
<Label htmlFor="appTitle" className="font-bold mb-2">小程序名称</Label>
|
||||
<Input id="appTitle" placeholder="请输入小程序名称" className="w-full h-12 rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base placeholder:text-gray-300" />
|
||||
<Label htmlFor="appId" className="font-bold mb-2">AppID</Label>
|
||||
<Input id="appId" placeholder="请输入AppID" className="w-full h-12 rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base placeholder:text-gray-300" />
|
||||
<Label className="font-bold mb-2">小程序封面图</Label>
|
||||
<UploadImage
|
||||
value={images}
|
||||
onChange={urls => {
|
||||
setImages(urls);
|
||||
}}
|
||||
max={9}
|
||||
accept="image/*"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* 评论/备注分组 */}
|
||||
<div className="mb-6">
|
||||
<div className="text-xs text-gray-400 mb-2 tracking-widest">评论/备注</div>
|
||||
<Textarea
|
||||
value={comment}
|
||||
onChange={e => setComment(e.target.value)}
|
||||
placeholder="请输入评论或备注"
|
||||
className="w-full rounded-2xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 px-4 text-base min-h-[80px] bg-gray-50 placeholder:text-gray-300"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,869 +0,0 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import BackButton from '@/components/BackButton';
|
||||
import { useSimpleBack } from '@/hooks/useBackNavigation';
|
||||
import { Smartphone, Battery, Wifi, MessageCircle, Users, Settings, History, RefreshCw, Loader2 } from 'lucide-react';
|
||||
import { devicesApi, fetchDeviceDetail, fetchDeviceRelatedAccounts, fetchDeviceHandleLogs, updateDeviceTaskConfig } from '@/api/devices';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import Layout from '@/components/Layout';
|
||||
import BottomNav from '@/components/BottomNav';
|
||||
|
||||
interface WechatAccount {
|
||||
id: string;
|
||||
avatar: string;
|
||||
nickname: string;
|
||||
wechatId: string;
|
||||
gender: number;
|
||||
status: number;
|
||||
statusText: string;
|
||||
wechatAlive: number;
|
||||
wechatAliveText: string;
|
||||
addFriendStatus: number;
|
||||
totalFriend: number;
|
||||
lastActive: string;
|
||||
}
|
||||
|
||||
interface Device {
|
||||
id: string;
|
||||
imei: string;
|
||||
name: string;
|
||||
status: "online" | "offline";
|
||||
battery: number;
|
||||
lastActive: string;
|
||||
historicalIds: string[];
|
||||
wechatAccounts: WechatAccount[];
|
||||
features: {
|
||||
autoAddFriend: boolean;
|
||||
autoReply: boolean;
|
||||
momentsSync: boolean;
|
||||
aiChat: boolean;
|
||||
};
|
||||
history: {
|
||||
time: string;
|
||||
action: string;
|
||||
operator: string;
|
||||
}[];
|
||||
totalFriend: number;
|
||||
thirtyDayMsgCount: number;
|
||||
}
|
||||
|
||||
interface HandleLog {
|
||||
id: string | number;
|
||||
content: string;
|
||||
username: string;
|
||||
createTime: string;
|
||||
}
|
||||
|
||||
export default function DeviceDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { goBack } = useSimpleBack('/devices');
|
||||
const { toast } = useToast();
|
||||
const [device, setDevice] = useState<Device | null>(null);
|
||||
const [activeTab, setActiveTab] = useState("info");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [accountsLoading, setAccountsLoading] = useState(false);
|
||||
const [logsLoading, setLogsLoading] = useState(false);
|
||||
const [handleLogs, setHandleLogs] = useState<HandleLog[]>([]);
|
||||
const [logPage, setLogPage] = useState(1);
|
||||
const [hasMoreLogs, setHasMoreLogs] = useState(true);
|
||||
const logsPerPage = 10;
|
||||
const logsEndRef = useRef<HTMLDivElement>(null);
|
||||
const [savingFeatures, setSavingFeatures] = useState({
|
||||
autoAddFriend: false,
|
||||
autoReply: false,
|
||||
momentsSync: false,
|
||||
aiChat: false
|
||||
});
|
||||
|
||||
const [accountPage, setAccountPage] = useState(1);
|
||||
const [hasMoreAccounts, setHasMoreAccounts] = useState(true);
|
||||
const accountsPerPage = 10;
|
||||
const accountsEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 获取设备详情
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
|
||||
const fetchDevice = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetchDeviceDetail(id);
|
||||
|
||||
if (response && response.code === 200 && response.data) {
|
||||
const serverData = response.data;
|
||||
|
||||
// 构建符合前端期望格式的设备对象
|
||||
const formattedDevice: Device = {
|
||||
id: serverData.id?.toString() || "",
|
||||
imei: serverData.imei || "",
|
||||
name: serverData.memo || "未命名设备",
|
||||
status: serverData.alive === 1 ? "online" : "offline",
|
||||
battery: serverData.battery || 0,
|
||||
lastActive: serverData.lastUpdateTime || new Date().toISOString(),
|
||||
historicalIds: [],
|
||||
wechatAccounts: [],
|
||||
history: [],
|
||||
features: {
|
||||
autoAddFriend: false,
|
||||
autoReply: false,
|
||||
momentsSync: false,
|
||||
aiChat: false
|
||||
},
|
||||
totalFriend: serverData.totalFriend || 0,
|
||||
thirtyDayMsgCount: serverData.thirtyDayMsgCount || 0
|
||||
};
|
||||
|
||||
// 解析features
|
||||
if (serverData.features) {
|
||||
formattedDevice.features = {
|
||||
autoAddFriend: Boolean(serverData.features.autoAddFriend),
|
||||
autoReply: Boolean(serverData.features.autoReply),
|
||||
momentsSync: Boolean(serverData.features.momentsSync || serverData.features.contentSync),
|
||||
aiChat: Boolean(serverData.features.aiChat)
|
||||
};
|
||||
} else if (serverData.taskConfig) {
|
||||
try {
|
||||
const taskConfig = JSON.parse(serverData.taskConfig || '{}');
|
||||
|
||||
if (taskConfig) {
|
||||
formattedDevice.features = {
|
||||
autoAddFriend: Boolean(taskConfig.autoAddFriend),
|
||||
autoReply: Boolean(taskConfig.autoReply),
|
||||
momentsSync: Boolean(taskConfig.momentsSync),
|
||||
aiChat: Boolean(taskConfig.aiChat)
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('解析taskConfig失败:', err);
|
||||
}
|
||||
}
|
||||
|
||||
setDevice(formattedDevice);
|
||||
|
||||
// 获取设备任务配置
|
||||
await fetchTaskConfig();
|
||||
|
||||
// 如果当前激活标签是"accounts",则立即加载关联微信账号
|
||||
if (activeTab === "accounts") {
|
||||
fetchRelatedAccounts();
|
||||
}
|
||||
} else {
|
||||
toast({
|
||||
title: "获取设备信息失败",
|
||||
description: response.msg || "未知错误",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取设备信息失败:", error);
|
||||
toast({
|
||||
title: "获取设备信息失败",
|
||||
description: "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDevice();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id]);
|
||||
|
||||
// 获取设备关联微信账号
|
||||
const fetchRelatedAccounts = useCallback(async (page = 1) => {
|
||||
if (!id || accountsLoading) return;
|
||||
|
||||
try {
|
||||
setAccountsLoading(true);
|
||||
const response = await fetchDeviceRelatedAccounts(id);
|
||||
|
||||
if (response && response.code === 200 && response.data) {
|
||||
const accounts = response.data.accounts || [];
|
||||
|
||||
if (page === 1) {
|
||||
setDevice(prev => prev ? {
|
||||
...prev,
|
||||
wechatAccounts: accounts
|
||||
} : null);
|
||||
} else {
|
||||
setDevice(prev => prev ? {
|
||||
...prev,
|
||||
wechatAccounts: [...prev.wechatAccounts, ...accounts]
|
||||
} : null);
|
||||
}
|
||||
|
||||
setHasMoreAccounts(accounts.length === accountsPerPage);
|
||||
setAccountPage(page);
|
||||
} else {
|
||||
toast({
|
||||
title: "获取关联账号失败",
|
||||
description: response.msg || "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取关联账号失败:", error);
|
||||
toast({
|
||||
title: "获取关联账号失败",
|
||||
description: "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setAccountsLoading(false);
|
||||
}
|
||||
}, [id, accountsLoading, accountsPerPage, toast]);
|
||||
|
||||
// 获取操作记录
|
||||
const fetchHandleLogs = useCallback(async () => {
|
||||
if (!id || logsLoading) return;
|
||||
|
||||
try {
|
||||
setLogsLoading(true);
|
||||
const response = await fetchDeviceHandleLogs(id, logPage, logsPerPage);
|
||||
|
||||
if (response && response.code === 200 && response.data) {
|
||||
const logs = response.data.list || [];
|
||||
|
||||
if (logPage === 1) {
|
||||
setHandleLogs(logs);
|
||||
} else {
|
||||
setHandleLogs(prev => [...prev, ...logs]);
|
||||
}
|
||||
|
||||
setHasMoreLogs(logs.length === logsPerPage);
|
||||
} else {
|
||||
toast({
|
||||
title: "获取操作记录失败",
|
||||
description: response.msg || "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取操作记录失败:", error);
|
||||
toast({
|
||||
title: "获取操作记录失败",
|
||||
description: "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLogsLoading(false);
|
||||
}
|
||||
}, [id, logsLoading, logPage, logsPerPage, toast]);
|
||||
|
||||
// 加载更多操作记录
|
||||
const loadMoreLogs = useCallback(() => {
|
||||
if (logsLoading || !hasMoreLogs) return;
|
||||
setLogPage(prev => prev + 1);
|
||||
fetchHandleLogs();
|
||||
}, [logsLoading, hasMoreLogs, fetchHandleLogs]);
|
||||
|
||||
// 无限滚动加载操作记录
|
||||
useEffect(() => {
|
||||
if (!hasMoreLogs || logsLoading) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
if (entries[0].isIntersecting && hasMoreLogs && !logsLoading) {
|
||||
loadMoreLogs();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.5 }
|
||||
);
|
||||
|
||||
if (logsEndRef.current) {
|
||||
observer.observe(logsEndRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [hasMoreLogs, logsLoading, loadMoreLogs]);
|
||||
|
||||
// 获取任务配置
|
||||
const fetchTaskConfig = async () => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
const response = await devicesApi.getTaskConfig(id);
|
||||
if (response && response.code === 200 && response.data) {
|
||||
const config = response.data;
|
||||
setDevice(prev => prev ? {
|
||||
...prev,
|
||||
features: {
|
||||
autoAddFriend: Boolean(config.autoAddFriend),
|
||||
autoReply: Boolean(config.autoReply),
|
||||
momentsSync: Boolean(config.momentsSync),
|
||||
aiChat: Boolean(config.aiChat)
|
||||
}
|
||||
} : null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取任务配置失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 标签页切换处理
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value);
|
||||
|
||||
setTimeout(() => {
|
||||
if (value === "accounts" && device && (!device.wechatAccounts || device.wechatAccounts.length === 0)) {
|
||||
fetchRelatedAccounts(1);
|
||||
} else if (value === "history" && handleLogs.length === 0) {
|
||||
setLogPage(1);
|
||||
setHasMoreLogs(true);
|
||||
fetchHandleLogs();
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// 功能开关处理 - 只更新开关状态,不重新加载页面
|
||||
const handleFeatureChange = async (feature: keyof Device['features'], checked: boolean) => {
|
||||
if (!id) return;
|
||||
|
||||
// 立即更新UI状态,提供即时反馈
|
||||
setDevice(prev => prev ? {
|
||||
...prev,
|
||||
features: {
|
||||
...prev.features,
|
||||
[feature]: checked
|
||||
}
|
||||
} : null);
|
||||
|
||||
setSavingFeatures(prev => ({ ...prev, [feature]: true }));
|
||||
|
||||
try {
|
||||
const response = await updateDeviceTaskConfig({
|
||||
deviceId: id,
|
||||
[feature]: checked
|
||||
});
|
||||
|
||||
if (response && response.code === 200) {
|
||||
// 请求成功,显示成功提示
|
||||
toast({
|
||||
title: "设置成功",
|
||||
description: `${getFeatureName(feature)}已${checked ? '启用' : '禁用'}`,
|
||||
});
|
||||
} else {
|
||||
// 请求失败,回滚UI状态
|
||||
setDevice(prev => prev ? {
|
||||
...prev,
|
||||
features: {
|
||||
...prev.features,
|
||||
[feature]: !checked
|
||||
}
|
||||
} : null);
|
||||
|
||||
toast({
|
||||
title: "设置失败",
|
||||
description: response.msg || "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("设置功能失败:", error);
|
||||
|
||||
// 网络错误,回滚UI状态
|
||||
setDevice(prev => prev ? {
|
||||
...prev,
|
||||
features: {
|
||||
...prev.features,
|
||||
[feature]: !checked
|
||||
}
|
||||
} : null);
|
||||
|
||||
toast({
|
||||
title: "设置失败",
|
||||
description: "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setSavingFeatures(prev => ({ ...prev, [feature]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 获取功能名称
|
||||
const getFeatureName = (feature: string): string => {
|
||||
const names: Record<string, string> = {
|
||||
autoAddFriend: "自动加好友",
|
||||
autoReply: "自动回复",
|
||||
momentsSync: "朋友圈同步",
|
||||
aiChat: "AI会话"
|
||||
};
|
||||
return names[feature] || feature;
|
||||
};
|
||||
|
||||
// 加载更多账号
|
||||
const loadMoreAccounts = useCallback(() => {
|
||||
if (accountsLoading || !hasMoreAccounts) return;
|
||||
fetchRelatedAccounts(accountPage + 1);
|
||||
}, [accountsLoading, hasMoreAccounts, accountPage, fetchRelatedAccounts]);
|
||||
|
||||
// 无限滚动加载账号
|
||||
useEffect(() => {
|
||||
if (!hasMoreAccounts || accountsLoading) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
if (entries[0].isIntersecting && hasMoreAccounts && !accountsLoading) {
|
||||
loadMoreAccounts();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.5 }
|
||||
);
|
||||
|
||||
if (accountsEndRef.current) {
|
||||
observer.observe(accountsEndRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [hasMoreAccounts, accountsLoading, loadMoreAccounts]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout
|
||||
header={<PageHeader title="设备详情" defaultBackPath="/devices" rightContent={<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors"><Settings className="h-5 w-5" /></button>} />}
|
||||
footer={<BottomNav />}
|
||||
>
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
<p className="text-gray-500">加载设备信息中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!device) {
|
||||
return (
|
||||
<Layout
|
||||
header={<PageHeader title="设备详情" defaultBackPath="/devices" rightContent={<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors"><Settings className="h-5 w-5" /></button>} />}
|
||||
footer={<BottomNav />}
|
||||
>
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center space-y-4 p-6 bg-white rounded-xl shadow-sm max-w-md">
|
||||
<div className="w-12 h-12 flex items-center justify-center rounded-full bg-red-100">
|
||||
<Smartphone className="h-6 w-6 text-red-500" />
|
||||
</div>
|
||||
<div className="text-xl font-medium text-center">设备不存在或已被删除</div>
|
||||
<div className="text-sm text-gray-500 text-center">
|
||||
无法加载ID为 "{id}" 的设备信息,请检查设备是否存在。
|
||||
</div>
|
||||
<BackButton
|
||||
variant="button"
|
||||
text="返回上一页"
|
||||
onBack={goBack}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={<PageHeader title="设备详情" defaultBackPath="/devices" rightContent={<button className="p-2 hover:bg-gray-100 rounded-lg transition-colors"><Settings className="h-5 w-5" /></button>} />}
|
||||
footer={<BottomNav />}
|
||||
>
|
||||
<div className="pb-20">
|
||||
<div className="p-4 space-y-4">
|
||||
{/* 设备基本信息卡片 */}
|
||||
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-blue-50 rounded-lg">
|
||||
<Smartphone className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold truncate">{device.name}</h2>
|
||||
<span className={`px-2.5 py-1 text-xs rounded-full font-medium ${
|
||||
device.status === "online"
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-gray-100 text-gray-600"
|
||||
}`}>
|
||||
{device.status === "online" ? "在线" : "离线"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
<span className="mr-1">IMEI:</span>
|
||||
{device.imei}
|
||||
</div>
|
||||
{device.historicalIds && device.historicalIds.length > 0 && (
|
||||
<div className="text-sm text-gray-500">历史ID: {device.historicalIds.join(", ")}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Battery className={`w-4 h-4 ${device.battery < 20 ? "text-red-500" : "text-green-500"}`} />
|
||||
<span className="text-sm">{device.battery}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Wifi className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-sm">{device.status === "online" ? "已连接" : "未连接"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-500">最后活跃:{device.lastActive}</div>
|
||||
</div>
|
||||
|
||||
{/* 标签页 */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div className="flex border-b border-gray-200">
|
||||
<button
|
||||
className={`flex-1 py-3 px-4 text-sm font-medium transition-colors ${
|
||||
activeTab === "info"
|
||||
? "text-blue-600 border-b-2 border-blue-600"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
onClick={() => handleTabChange("info")}
|
||||
>
|
||||
基本信息
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 py-3 px-4 text-sm font-medium transition-colors ${
|
||||
activeTab === "accounts"
|
||||
? "text-blue-600 border-b-2 border-blue-600"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
onClick={() => handleTabChange("accounts")}
|
||||
>
|
||||
关联账号
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 py-3 px-4 text-sm font-medium transition-colors ${
|
||||
activeTab === "history"
|
||||
? "text-blue-600 border-b-2 border-blue-600"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
onClick={() => handleTabChange("history")}
|
||||
>
|
||||
操作记录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 基本信息标签页 */}
|
||||
{activeTab === "info" && (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* 功能配置 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">自动加好友</div>
|
||||
<div className="text-xs text-gray-500">自动通过好友验证</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{savingFeatures.autoAddFriend && (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin text-blue-500" />
|
||||
)}
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(device.features.autoAddFriend)}
|
||||
onChange={(e) => handleFeatureChange('autoAddFriend', e.target.checked)}
|
||||
disabled={savingFeatures.autoAddFriend}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">自动回复</div>
|
||||
<div className="text-xs text-gray-500">自动回复好友消息</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{savingFeatures.autoReply && (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin text-blue-500" />
|
||||
)}
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(device.features.autoReply)}
|
||||
onChange={(e) => handleFeatureChange('autoReply', e.target.checked)}
|
||||
disabled={savingFeatures.autoReply}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">朋友圈同步</div>
|
||||
<div className="text-xs text-gray-500">自动同步朋友圈内容</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{savingFeatures.momentsSync && (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin text-blue-500" />
|
||||
)}
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(device.features.momentsSync)}
|
||||
onChange={(e) => handleFeatureChange('momentsSync', e.target.checked)}
|
||||
disabled={savingFeatures.momentsSync}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">AI会话</div>
|
||||
<div className="text-xs text-gray-500">启用AI智能对话</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{savingFeatures.aiChat && (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin text-blue-500" />
|
||||
)}
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(device.features.aiChat)}
|
||||
onChange={(e) => handleFeatureChange('aiChat', e.target.checked)}
|
||||
disabled={savingFeatures.aiChat}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-2 gap-4 mt-4">
|
||||
<div className="bg-gray-50 p-4 rounded-xl">
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
<Users className="w-4 h-4" />
|
||||
<span className="text-sm">好友总数</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-blue-600 mt-2">
|
||||
{(device.totalFriend || 0).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-4 rounded-xl">
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
<MessageCircle className="w-4 h-4" />
|
||||
<span className="text-sm">消息数量</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-blue-600 mt-2">
|
||||
{(device.thirtyDayMsgCount || 0).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 关联账号标签页 */}
|
||||
{activeTab === "accounts" && (
|
||||
<div className="p-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-md font-medium">微信账号列表</h3>
|
||||
<button
|
||||
className="px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors text-sm flex items-center gap-2"
|
||||
onClick={() => {
|
||||
setAccountPage(1);
|
||||
setHasMoreAccounts(true);
|
||||
fetchRelatedAccounts(1);
|
||||
}}
|
||||
disabled={accountsLoading}
|
||||
>
|
||||
{accountsLoading ? (
|
||||
<React.Fragment key="loading">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
刷新中
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<React.Fragment key="refresh">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
刷新
|
||||
</React.Fragment>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-[120px] max-h-[calc(100vh-300px)] overflow-y-auto">
|
||||
{accountsLoading && !device?.wechatAccounts?.length ? (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-blue-500 mr-2" />
|
||||
<span className="text-gray-500">加载微信账号中...</span>
|
||||
</div>
|
||||
) : device?.wechatAccounts && device.wechatAccounts.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{device.wechatAccounts.map((account) => (
|
||||
<div key={account.id} className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<img
|
||||
src={account.avatar || "/placeholder.svg"}
|
||||
alt={account.nickname}
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium truncate">{account.nickname}</div>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
account.wechatAlive === 1
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-red-100 text-red-700"
|
||||
}`}>
|
||||
{account.wechatAliveText}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">微信号: {account.wechatId}</div>
|
||||
<div className="text-sm text-gray-500">性别: {account.gender === 1 ? "男" : "女"}</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-sm text-gray-500">好友数: {account.totalFriend}</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
account.status === 1
|
||||
? "bg-blue-100 text-blue-700"
|
||||
: "bg-gray-100 text-gray-600"
|
||||
}`}>
|
||||
{account.statusText}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">最后活跃: {account.lastActive}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 加载更多区域 */}
|
||||
<div
|
||||
ref={accountsEndRef}
|
||||
className="py-2 flex justify-center items-center"
|
||||
>
|
||||
{accountsLoading && hasMoreAccounts ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-blue-500" />
|
||||
<span className="text-sm text-gray-500">加载更多...</span>
|
||||
</div>
|
||||
) : hasMoreAccounts ? (
|
||||
<button
|
||||
className="text-sm text-blue-500 hover:text-blue-600"
|
||||
onClick={loadMoreAccounts}
|
||||
>
|
||||
加载更多
|
||||
</button>
|
||||
) : device.wechatAccounts.length > 0 && (
|
||||
<span className="text-xs text-gray-400">- 已加载全部记录 -</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<p>此设备暂无关联的微信账号</p>
|
||||
<button
|
||||
className="mt-2 px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors text-sm flex items-center gap-2 mx-auto"
|
||||
onClick={() => fetchRelatedAccounts(1)}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作记录标签页 */}
|
||||
{activeTab === "history" && (
|
||||
<div className="p-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-md font-medium">操作记录</h3>
|
||||
<button
|
||||
className="px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors text-sm flex items-center gap-2"
|
||||
onClick={() => {
|
||||
setLogPage(1);
|
||||
setHasMoreLogs(true);
|
||||
fetchHandleLogs();
|
||||
}}
|
||||
disabled={logsLoading}
|
||||
>
|
||||
{logsLoading ? (
|
||||
<React.Fragment key="logs-loading">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
加载中
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<React.Fragment key="logs-refresh">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
刷新
|
||||
</React.Fragment>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="h-[calc(min(80vh, 500px))] overflow-y-auto">
|
||||
{logsLoading && handleLogs.length === 0 ? (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-blue-500 mr-2" />
|
||||
<span className="text-gray-500">加载操作记录中...</span>
|
||||
</div>
|
||||
) : handleLogs.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{handleLogs.map((log) => (
|
||||
<div key={log.id} className="flex items-start gap-3">
|
||||
<div className="p-2 bg-blue-50 rounded-full">
|
||||
<History className="w-4 h-4 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium">{log.content}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
操作人: {log.username} · {log.createTime}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 加载更多区域 */}
|
||||
<div
|
||||
ref={logsEndRef}
|
||||
className="py-2 flex justify-center items-center"
|
||||
>
|
||||
{logsLoading && hasMoreLogs ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-blue-500" />
|
||||
<span className="text-sm text-gray-500">加载更多...</span>
|
||||
</div>
|
||||
) : hasMoreLogs ? (
|
||||
<button
|
||||
className="text-sm text-blue-500 hover:text-blue-600"
|
||||
onClick={loadMoreLogs}
|
||||
>
|
||||
加载更多
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">- 已加载全部记录 -</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<p>暂无操作记录</p>
|
||||
<button
|
||||
className="mt-2 px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors text-sm flex items-center gap-2 mx-auto"
|
||||
onClick={() => {
|
||||
setLogPage(1);
|
||||
setHasMoreLogs(true);
|
||||
fetchHandleLogs();
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,719 +0,0 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Plus, Search, RefreshCw, QrCode, Loader2, AlertTriangle, X } from 'lucide-react';
|
||||
import { devicesApi } from '@/api';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import Layout from '@/components/Layout';
|
||||
import '@/components/Layout.css';
|
||||
|
||||
// 设备接口
|
||||
interface Device {
|
||||
id: number;
|
||||
imei: string;
|
||||
memo: string;
|
||||
wechatId: string;
|
||||
totalFriend: number;
|
||||
alive: number;
|
||||
status: "online" | "offline";
|
||||
}
|
||||
|
||||
export default function Devices() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const [devices, setDevices] = useState<Device[]>([]);
|
||||
const [isAddDeviceOpen, setIsAddDeviceOpen] = useState(false);
|
||||
const [stats, setStats] = useState({
|
||||
totalDevices: 0,
|
||||
onlineDevices: 0,
|
||||
});
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
// 恢复分页功能
|
||||
const [, setCurrentPage] = useState(1);
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState<number | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const observerTarget = useRef<HTMLDivElement>(null);
|
||||
const pageRef = useRef(1);
|
||||
const [deviceImei, setDeviceImei] = useState("");
|
||||
const [deviceName, setDeviceName] = useState("");
|
||||
const [qrCodeImage, setQrCodeImage] = useState("");
|
||||
const [isLoadingQRCode, setIsLoadingQRCode] = useState(false);
|
||||
const [isSubmittingImei, setIsSubmittingImei] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState("scan");
|
||||
const [pollingStatus, setPollingStatus] = useState<{
|
||||
isPolling: boolean;
|
||||
message: string;
|
||||
messageType: 'default' | 'success' | 'error';
|
||||
showAnimation: boolean;
|
||||
}>({
|
||||
isPolling: false,
|
||||
message: '',
|
||||
messageType: 'default',
|
||||
showAnimation: false
|
||||
});
|
||||
|
||||
const pollingTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const devicesPerPage = 20;
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [deviceToDelete, setDeviceToDelete] = useState<number | null>(null);
|
||||
|
||||
const loadDevices = useCallback(async (page: number, refresh: boolean = false) => {
|
||||
if (isLoading) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await devicesApi.getList(page, devicesPerPage, searchQuery);
|
||||
|
||||
if (response.code === 200 && response.data) {
|
||||
const serverDevices = response.data.list.map((device: any) => ({
|
||||
...device,
|
||||
status: device.alive === 1 ? "online" as const : "offline" as const
|
||||
}));
|
||||
|
||||
if (refresh) {
|
||||
setDevices(serverDevices);
|
||||
} else {
|
||||
setDevices(prev => [...prev, ...serverDevices]);
|
||||
}
|
||||
|
||||
const total = response.data.total;
|
||||
const online = response.data.list.filter((d: any) => d.alive === 1).length;
|
||||
setStats({
|
||||
totalDevices: total,
|
||||
onlineDevices: online
|
||||
});
|
||||
|
||||
setTotalCount(response.data.total);
|
||||
|
||||
const hasMoreData = serverDevices.length > 0 &&
|
||||
serverDevices.length === devicesPerPage &&
|
||||
(page * devicesPerPage) < response.data.total;
|
||||
setHasMore(hasMoreData);
|
||||
|
||||
pageRef.current = page;
|
||||
} else {
|
||||
toast({
|
||||
title: "获取设备列表失败",
|
||||
description: response.msg || "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取设备列表失败", error);
|
||||
toast({
|
||||
title: "获取设备列表失败",
|
||||
description: "请检查网络连接后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [searchQuery, isLoading, toast]);
|
||||
|
||||
const loadNextPage = useCallback(() => {
|
||||
if (isLoading || !hasMore) return;
|
||||
|
||||
const nextPage = pageRef.current + 1;
|
||||
setCurrentPage(nextPage);
|
||||
loadDevices(nextPage, false);
|
||||
}, [hasMore, isLoading, loadDevices]);
|
||||
|
||||
const isMounted = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMounted.current) return;
|
||||
|
||||
setCurrentPage(1);
|
||||
pageRef.current = 1;
|
||||
loadDevices(1, true);
|
||||
}, [searchQuery, loadDevices]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMore || isLoading) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
if (entries[0].isIntersecting && hasMore && !isLoading && isMounted.current) {
|
||||
loadNextPage();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.5 }
|
||||
);
|
||||
|
||||
if (typeof window !== 'undefined' && observerTarget.current) {
|
||||
observer.observe(observerTarget.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [hasMore, isLoading, loadNextPage]);
|
||||
|
||||
const fetchDeviceQRCode = async () => {
|
||||
try {
|
||||
setIsLoadingQRCode(true);
|
||||
setQrCodeImage("");
|
||||
|
||||
const accountId = localStorage.getItem('s2_accountId');
|
||||
if (!accountId) {
|
||||
toast({
|
||||
title: "获取二维码失败",
|
||||
description: "未获取到用户信息,请重新登录",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await devicesApi.getQRCode(accountId);
|
||||
|
||||
if (response.code === 200 && response.data) {
|
||||
setQrCodeImage(response.data.qrCode);
|
||||
// 开始轮询检测设备添加结果
|
||||
setTimeout(() => {
|
||||
startPolling();
|
||||
}, 5000);
|
||||
} else {
|
||||
toast({
|
||||
title: "获取二维码失败",
|
||||
description: response.msg || "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取二维码失败:", error);
|
||||
toast({
|
||||
title: "获取二维码失败",
|
||||
description: "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsLoadingQRCode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startPolling = () => {
|
||||
setPollingStatus({
|
||||
isPolling: true,
|
||||
message: "正在检测添加结果...",
|
||||
messageType: 'default',
|
||||
showAnimation: true
|
||||
});
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const response = await devicesApi.getList(1, 1);
|
||||
if (response.code === 200 && response.data) {
|
||||
const currentCount = response.data.total;
|
||||
if (currentCount > totalCount) {
|
||||
setPollingStatus({
|
||||
isPolling: false,
|
||||
message: "设备添加成功!",
|
||||
messageType: 'success',
|
||||
showAnimation: false
|
||||
});
|
||||
setIsAddDeviceOpen(false);
|
||||
loadDevices(1, true);
|
||||
if (pollingTimerRef.current) {
|
||||
clearTimeout(pollingTimerRef.current);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("轮询检测失败:", error);
|
||||
}
|
||||
|
||||
// 继续轮询
|
||||
pollingTimerRef.current = setTimeout(poll, 2000);
|
||||
};
|
||||
|
||||
poll();
|
||||
};
|
||||
|
||||
const handleOpenAddDeviceModal = () => {
|
||||
setIsAddDeviceOpen(true);
|
||||
setActiveTab("scan");
|
||||
setQrCodeImage("");
|
||||
setDeviceImei("");
|
||||
setDeviceName("");
|
||||
setPollingStatus({
|
||||
isPolling: false,
|
||||
message: '',
|
||||
messageType: 'default',
|
||||
showAnimation: false
|
||||
});
|
||||
|
||||
// 自动获取二维码
|
||||
setTimeout(() => {
|
||||
fetchDeviceQRCode();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleCloseAddDeviceModal = () => {
|
||||
setIsAddDeviceOpen(false);
|
||||
if (pollingTimerRef.current) {
|
||||
clearTimeout(pollingTimerRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddDeviceByImei = async () => {
|
||||
if (!deviceImei.trim() || !deviceName.trim()) {
|
||||
toast({
|
||||
title: "请填写完整信息",
|
||||
description: "设备名称和IMEI不能为空",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmittingImei(true);
|
||||
const response = await devicesApi.addByImei(deviceImei, deviceName);
|
||||
|
||||
if (response.code === 200) {
|
||||
toast({
|
||||
title: "添加成功",
|
||||
description: "设备已成功添加",
|
||||
});
|
||||
setIsAddDeviceOpen(false);
|
||||
loadDevices(1, true);
|
||||
} else {
|
||||
toast({
|
||||
title: "添加失败",
|
||||
description: response.msg || "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('添加设备失败:', error);
|
||||
toast({
|
||||
title: '添加设备失败,请稍后重试',
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmittingImei(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
setCurrentPage(1);
|
||||
pageRef.current = 1;
|
||||
loadDevices(1, true);
|
||||
};
|
||||
|
||||
const handleDeleteClick = () => {
|
||||
if (!selectedDeviceId) {
|
||||
toast({
|
||||
title: "请选择要删除的设备",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setDeviceToDelete(selectedDeviceId);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!deviceToDelete) return;
|
||||
|
||||
try {
|
||||
const response = await devicesApi.delete(deviceToDelete);
|
||||
|
||||
if (response.code === 200) {
|
||||
toast({
|
||||
title: "删除成功",
|
||||
description: "设备已成功删除",
|
||||
});
|
||||
setSelectedDeviceId(null);
|
||||
loadDevices(1, true);
|
||||
} else {
|
||||
toast({
|
||||
title: "删除失败",
|
||||
description: response.msg || "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除设备失败:', error);
|
||||
toast({
|
||||
title: '删除设备失败,请稍后重试',
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsDeleteDialogOpen(false);
|
||||
setDeviceToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelDelete = () => {
|
||||
setIsDeleteDialogOpen(false);
|
||||
setDeviceToDelete(null);
|
||||
};
|
||||
|
||||
const handleDeviceClick = (deviceId: number, event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
navigate(`/devices/${deviceId}`);
|
||||
};
|
||||
|
||||
const handleAddDevice = async () => {
|
||||
if (activeTab === "manual") {
|
||||
await handleAddDeviceByImei();
|
||||
}
|
||||
};
|
||||
|
||||
// 过滤设备列表
|
||||
const filteredDevices = devices.filter(device => {
|
||||
if (statusFilter === "online") return device.status === "online";
|
||||
if (statusFilter === "offline") return device.status === "offline";
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<PageHeader
|
||||
title="设备管理"
|
||||
defaultBackPath="/"
|
||||
rightContent={
|
||||
<button
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 transition-colors"
|
||||
onClick={handleOpenAddDeviceModal}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
添加设备
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="bg-gray-50">
|
||||
<div className="p-4 space-y-4">
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100">
|
||||
<div className="text-sm text-gray-500 mb-1">总设备数</div>
|
||||
<div className="text-2xl font-bold text-blue-600">{stats.totalDevices}</div>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100">
|
||||
<div className="text-sm text-gray-500 mb-1">在线设备</div>
|
||||
<div className="text-2xl font-bold text-green-600">{stats.onlineDevices}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索和过滤 */}
|
||||
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索设备IMEI/备注"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="p-2.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-200 rounded-lg bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="online">在线</option>
|
||||
<option value="offline">离线</option>
|
||||
</select>
|
||||
<button
|
||||
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
|
||||
onClick={handleDeleteClick}
|
||||
disabled={!selectedDeviceId}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 设备列表 */}
|
||||
<div className="space-y-3">
|
||||
{filteredDevices.map((device) => (
|
||||
<div
|
||||
key={device.id}
|
||||
className="bg-white p-4 rounded-xl shadow-sm border border-gray-100 hover:shadow-md transition-all cursor-pointer"
|
||||
onClick={(e) => handleDeviceClick(device.id, e)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedDeviceId === device.id}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedDeviceId(device.id);
|
||||
} else {
|
||||
setSelectedDeviceId(null);
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="font-semibold text-gray-900 truncate">{device.memo || "未命名设备"}</div>
|
||||
<span className={`px-2.5 py-1 text-xs rounded-full font-medium ${
|
||||
device.status === "online"
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-gray-100 text-gray-600"
|
||||
}`}>
|
||||
{device.status === "online" ? "在线" : "离线"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1 text-sm text-gray-600">
|
||||
<div>IMEI: {device.imei}</div>
|
||||
<div>微信号: {device.wechatId || "未绑定或微信离线"}</div>
|
||||
<div>好友数: {device.totalFriend}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div ref={observerTarget} className="py-4 flex items-center justify-center">
|
||||
{isLoading && <div className="text-sm text-gray-500">加载中...</div>}
|
||||
{!hasMore && devices.length > 0 && <div className="text-sm text-gray-500">没有更多设备了</div>}
|
||||
{!hasMore && devices.length === 0 && <div className="text-sm text-gray-500">暂无设备</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 添加设备弹窗 */}
|
||||
{isAddDeviceOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl max-w-md w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900">添加设备</h2>
|
||||
<button
|
||||
onClick={handleCloseAddDeviceModal}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex border-b border-gray-200">
|
||||
<button
|
||||
className={`flex-1 py-2.5 px-4 text-sm font-medium transition-colors ${
|
||||
activeTab === "scan"
|
||||
? "text-blue-600 border-b-2 border-blue-600"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
onClick={() => {
|
||||
setActiveTab("scan");
|
||||
// 切换到扫码添加时自动获取二维码
|
||||
setTimeout(() => {
|
||||
fetchDeviceQRCode();
|
||||
}, 100);
|
||||
}}
|
||||
>
|
||||
扫码添加
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 py-2.5 px-4 text-sm font-medium transition-colors ${
|
||||
activeTab === "manual"
|
||||
? "text-blue-600 border-b-2 border-blue-600"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
onClick={() => setActiveTab("manual")}
|
||||
>
|
||||
手动添加
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === "scan" && (
|
||||
<div className="py-3">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
{/* 状态提示 */}
|
||||
<div className="text-center">
|
||||
{pollingStatus.isPolling || pollingStatus.showAnimation ? (
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm text-gray-700">正在检测添加结果</span>
|
||||
<div className="flex justify-center space-x-1">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '0ms' }}></div>
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '150ms' }}></div>
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '300ms' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-600">5秒后将开始检测添加结果</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 二维码区域 */}
|
||||
<div className="bg-gray-50 p-3 rounded-xl w-full max-w-[220px] min-h-[220px] flex flex-col items-center justify-center">
|
||||
{isLoadingQRCode ? (
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
<p className="text-sm text-gray-500">正在获取二维码...</p>
|
||||
</div>
|
||||
) : qrCodeImage ? (
|
||||
<div id="qrcode-container" className="flex flex-col items-center space-y-2">
|
||||
<div className="relative w-44 h-44 flex items-center justify-center">
|
||||
<img
|
||||
src={qrCodeImage}
|
||||
alt="设备添加二维码"
|
||||
className="w-full h-full object-contain"
|
||||
onError={(e) => {
|
||||
console.error("二维码图片加载失败");
|
||||
e.currentTarget.style.display = 'none';
|
||||
const container = document.getElementById('qrcode-container');
|
||||
if (container) {
|
||||
const errorEl = container.querySelector('.qrcode-error');
|
||||
if (errorEl) {
|
||||
errorEl.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="qrcode-error hidden absolute inset-0 flex flex-col items-center justify-center text-center text-red-500 bg-white rounded-lg">
|
||||
<AlertTriangle className="h-6 w-6 mb-1" />
|
||||
<p className="text-xs">未能加载二维码,请点击刷新按钮重试</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-center text-gray-600">
|
||||
请使用手机扫描此二维码添加设备
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-gray-500">
|
||||
<QrCode className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">点击下方按钮获取二维码</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={fetchDeviceQRCode}
|
||||
disabled={isLoadingQRCode}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white py-2.5 rounded-xl disabled:bg-gray-300 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{isLoadingQRCode ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
获取中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
刷新二维码
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "manual" && (
|
||||
<div className="py-3 space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700">设备名称</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="请输入设备名称"
|
||||
value={deviceName}
|
||||
onChange={(e) => setDeviceName(e.target.value)}
|
||||
className="w-full px-4 py-2.5 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
为设备添加一个便于识别的名称
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700">设备IMEI</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="请输入设备IMEI"
|
||||
value={deviceImei}
|
||||
onChange={(e) => setDeviceImei(e.target.value)}
|
||||
className="w-full px-4 py-2.5 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
请输入设备IMEI码,可在设备信息中查看
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
className="flex-1 px-4 py-2.5 border border-gray-200 rounded-xl hover:bg-gray-50 transition-colors"
|
||||
onClick={() => setIsAddDeviceOpen(false)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="flex-1 px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-xl disabled:bg-gray-300 transition-colors"
|
||||
onClick={handleAddDevice}
|
||||
disabled={!deviceImei.trim() || !deviceName.trim() || isSubmittingImei}
|
||||
>
|
||||
{isSubmittingImei ? "添加中..." : "添加"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 删除确认弹窗 */}
|
||||
{isDeleteDialogOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl max-w-md w-full p-6">
|
||||
<div className="text-center mb-6">
|
||||
<AlertTriangle className="h-12 w-12 text-red-500 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">确认删除</h3>
|
||||
<p className="text-gray-600">
|
||||
设备删除后,本设备配置的计划任务操作也将失效。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
className="flex-1 px-4 py-3 border border-gray-200 rounded-xl hover:bg-gray-50 transition-colors"
|
||||
onClick={handleCancelDelete}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="flex-1 px-4 py-3 bg-red-600 hover:bg-red-700 text-white rounded-xl transition-colors"
|
||||
onClick={handleConfirmDelete}
|
||||
>
|
||||
确认删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,455 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { Eye, EyeOff, Phone } from 'lucide-react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import { authApi } from '@/api';
|
||||
import WeChatIcon from '@/components/icons/WeChatIcon';
|
||||
import AppleIcon from '@/components/icons/AppleIcon';
|
||||
|
||||
// 定义登录表单类型
|
||||
interface LoginForm {
|
||||
phone: string;
|
||||
password: string;
|
||||
verificationCode: string;
|
||||
agreeToTerms: boolean;
|
||||
}
|
||||
|
||||
export default function Login() {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'password' | 'verification'>('password');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [form, setForm] = useState<LoginForm>({
|
||||
phone: '',
|
||||
password: '',
|
||||
verificationCode: '',
|
||||
agreeToTerms: false,
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { toast } = useToast();
|
||||
const { login } = useAuth();
|
||||
|
||||
// 检查URL是否为登录页面
|
||||
const isLoginPage = (url: string) => {
|
||||
try {
|
||||
const urlObj = new URL(url, window.location.origin);
|
||||
return urlObj.pathname === '/login' || urlObj.pathname.endsWith('/login');
|
||||
} catch {
|
||||
// 如果URL格式不正确,返回false
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 倒计时效果
|
||||
useEffect(() => {
|
||||
if (countdown > 0) {
|
||||
const timer = setTimeout(() => setCountdown(countdown - 1), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [countdown]);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setForm((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setForm((prev) => ({ ...prev, agreeToTerms: e.target.checked }));
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
if (!form.phone) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: '请输入手机号',
|
||||
description: '手机号不能为空',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// 手机号格式验证
|
||||
const phoneRegex = /^1[3-9]\d{9}$/;
|
||||
if (!phoneRegex.test(form.phone)) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: '手机号格式错误',
|
||||
description: '请输入正确的11位手机号',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!form.agreeToTerms) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: '请同意用户协议',
|
||||
description: '需要同意用户协议和隐私政策才能继续',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (activeTab === 'password' && !form.password) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: '请输入密码',
|
||||
description: '密码不能为空',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (activeTab === 'verification' && !form.verificationCode) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: '请输入验证码',
|
||||
description: '验证码不能为空',
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (activeTab === 'password') {
|
||||
// 发送账号密码登录请求
|
||||
const response = await authApi.login(form.phone, form.password);
|
||||
|
||||
if (response.code === 200 && response.data) {
|
||||
// 保存登录信息
|
||||
localStorage.setItem('token', response.data.token);
|
||||
localStorage.setItem('token_expired', response.data.token_expired);
|
||||
localStorage.setItem('s2_accountId', response.data.member.s2_accountId);
|
||||
|
||||
// 保存用户信息
|
||||
localStorage.setItem('userInfo', JSON.stringify(response.data.member));
|
||||
|
||||
// 调用认证上下文的登录方法
|
||||
login(response.data.token, response.data.member);
|
||||
|
||||
// 显示成功提示
|
||||
toast({
|
||||
title: '登录成功',
|
||||
description: '欢迎回来!',
|
||||
});
|
||||
|
||||
// 跳转到首页或重定向URL
|
||||
const returnUrl = searchParams.get('returnUrl');
|
||||
if (returnUrl) {
|
||||
const decodedUrl = decodeURIComponent(returnUrl);
|
||||
// 检查重定向URL是否为登录页面,避免无限重定向
|
||||
if (isLoginPage(decodedUrl)) {
|
||||
navigate('/');
|
||||
} else {
|
||||
window.location.href = decodedUrl;
|
||||
}
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.msg || '登录失败');
|
||||
}
|
||||
} else {
|
||||
// 验证码登录
|
||||
const response = await authApi.loginWithCode(form.phone, form.verificationCode);
|
||||
|
||||
if (response.code === 200 && response.data) {
|
||||
// 保存登录信息
|
||||
localStorage.setItem('token', response.data.token);
|
||||
localStorage.setItem('token_expired', response.data.token_expired);
|
||||
localStorage.setItem('s2_accountId', response.data.member.s2_accountId);
|
||||
|
||||
// 保存用户信息
|
||||
localStorage.setItem('userInfo', JSON.stringify(response.data.member));
|
||||
|
||||
// 调用认证上下文的登录方法
|
||||
login(response.data.token, response.data.member);
|
||||
|
||||
// 显示成功提示
|
||||
toast({
|
||||
title: '登录成功',
|
||||
description: '欢迎回来!',
|
||||
});
|
||||
|
||||
// 跳转到首页或重定向URL
|
||||
const returnUrl = searchParams.get('returnUrl');
|
||||
if (returnUrl) {
|
||||
const decodedUrl = decodeURIComponent(returnUrl);
|
||||
// 检查重定向URL是否为登录页面,避免无限重定向
|
||||
if (isLoginPage(decodedUrl)) {
|
||||
navigate('/');
|
||||
} else {
|
||||
window.location.href = decodedUrl;
|
||||
}
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.msg || '登录失败');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: '登录失败',
|
||||
description: error instanceof Error ? error.message : '请稍后重试',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendVerificationCode = async () => {
|
||||
if (!form.phone) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: '请输入手机号',
|
||||
description: '发送验证码需要手机号',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 手机号格式验证
|
||||
const phoneRegex = /^1[3-9]\d{9}$/;
|
||||
if (!phoneRegex.test(form.phone)) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: '手机号格式错误',
|
||||
description: '请输入正确的11位手机号',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await authApi.sendVerificationCode(form.phone);
|
||||
|
||||
if (response.code === 200) {
|
||||
toast({
|
||||
title: '验证码已发送',
|
||||
description: '请查收短信验证码',
|
||||
});
|
||||
setCountdown(60); // 开始60秒倒计时
|
||||
} else {
|
||||
throw new Error(response.msg || '发送失败');
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: '发送失败',
|
||||
description: error instanceof Error ? error.message : '请稍后重试',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWechatLogin = () => {
|
||||
// 微信登录逻辑
|
||||
toast({
|
||||
title: '功能开发中',
|
||||
description: '微信登录功能正在开发中,请使用其他方式登录',
|
||||
});
|
||||
};
|
||||
|
||||
const handleAppleLogin = () => {
|
||||
// Apple登录逻辑
|
||||
toast({
|
||||
title: '功能开发中',
|
||||
description: 'Apple登录功能正在开发中,请使用其他方式登录',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex items-center justify-center px-4 py-8">
|
||||
<div className="max-w-md w-full">
|
||||
{/* 标题 */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">欢迎登录</h1>
|
||||
<p className="text-gray-600 text-sm">你所在地区仅支持 手机号 / 微信 / Apple 登录</p>
|
||||
</div>
|
||||
|
||||
{/* 标签页切换 */}
|
||||
<div className="flex border-b border-gray-200 mb-6">
|
||||
<button
|
||||
onClick={() => setActiveTab('password')}
|
||||
className={`flex-1 py-3 text-center border-b-2 transition-colors font-medium ${
|
||||
activeTab === 'password'
|
||||
? 'border-blue-500 text-blue-500'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
密码登录
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('verification')}
|
||||
className={`flex-1 py-3 text-center border-b-2 transition-colors font-medium ${
|
||||
activeTab === 'verification'
|
||||
? 'border-blue-500 text-blue-500'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
验证码登录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-6">
|
||||
{/* 手机号输入 */}
|
||||
<div className="relative">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
手机号
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="tel"
|
||||
name="phone"
|
||||
value={form.phone}
|
||||
onChange={handleInputChange}
|
||||
placeholder="请输入手机号"
|
||||
className="w-full pl-16 pr-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900 transition-colors"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500 flex items-center gap-1 text-sm">
|
||||
<Phone className="h-4 w-4" />
|
||||
+86
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 密码输入 */}
|
||||
{activeTab === 'password' && (
|
||||
<div className="relative">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
密码
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
name="password"
|
||||
value={form.password}
|
||||
onChange={handleInputChange}
|
||||
placeholder="请输入密码"
|
||||
className="w-full pl-4 pr-12 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900 transition-colors"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 验证码输入 */}
|
||||
{activeTab === 'verification' && (
|
||||
<div className="relative">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
验证码
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
name="verificationCode"
|
||||
value={form.verificationCode}
|
||||
onChange={handleInputChange}
|
||||
placeholder="请输入验证码"
|
||||
className="w-full pl-4 pr-32 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-gray-900 transition-colors"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSendVerificationCode}
|
||||
disabled={isLoading || countdown > 0}
|
||||
className={`absolute right-2 top-1/2 -translate-y-1/2 px-4 h-10 rounded-md text-sm font-medium transition-colors ${
|
||||
countdown > 0
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-blue-50 text-blue-500 hover:bg-blue-100'
|
||||
}`}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}s` : '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 用户协议 */}
|
||||
<div className="flex items-start space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="terms"
|
||||
checked={form.agreeToTerms}
|
||||
onChange={handleCheckboxChange}
|
||||
disabled={isLoading}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded mt-0.5"
|
||||
/>
|
||||
<label
|
||||
htmlFor="terms"
|
||||
className="text-sm text-gray-600 leading-relaxed cursor-pointer"
|
||||
>
|
||||
我已阅读并同意
|
||||
<button type="button" className="text-blue-500 hover:text-blue-600 mx-1">《存客宝用户协议》</button>
|
||||
和
|
||||
<button type="button" className="text-blue-500 hover:text-blue-600 mx-1">《隐私政策》</button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 登录按钮 */}
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-3 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium transition-colors disabled:bg-gray-300 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
登录中...
|
||||
</div>
|
||||
) : (
|
||||
'登录'
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 分割线 */}
|
||||
<div className="relative my-8">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-200"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-4 bg-white text-gray-500">其他登录方式</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 第三方登录 */}
|
||||
<div className="flex justify-center space-x-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleWechatLogin}
|
||||
className="flex flex-col items-center space-y-2 p-4 text-gray-500 hover:text-gray-700 transition-colors rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<WeChatIcon className="h-8 w-8" />
|
||||
<span className="text-xs">微信</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAppleLogin}
|
||||
className="flex flex-col items-center space-y-2 p-4 text-gray-500 hover:text-gray-700 transition-colors rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<AppleIcon className="h-8 w-8" />
|
||||
<span className="text-xs">Apple</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function Orders() {
|
||||
return <div>订单页</div>;
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Users, TrendingUp, Calendar, Settings, Play, Pause, Edit } from 'lucide-react';
|
||||
|
||||
interface PlanData {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'active' | 'paused' | 'completed';
|
||||
createdAt: string;
|
||||
totalCustomers: number;
|
||||
todayCustomers: number;
|
||||
growth: string;
|
||||
description?: string;
|
||||
scenario: string;
|
||||
}
|
||||
|
||||
export default function PlanDetail() {
|
||||
const { planId } = useParams<{ planId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [plan, setPlan] = useState<PlanData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPlanData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 模拟API调用
|
||||
const mockPlan: PlanData = {
|
||||
id: planId || '',
|
||||
name: '春季营销计划',
|
||||
status: 'active',
|
||||
createdAt: '2024-03-15',
|
||||
totalCustomers: 456,
|
||||
todayCustomers: 23,
|
||||
growth: '+8.2%',
|
||||
description: '针对春季市场的营销推广计划,通过多种渠道获取潜在客户',
|
||||
scenario: 'douyin',
|
||||
};
|
||||
|
||||
setPlan(mockPlan);
|
||||
} catch (error) {
|
||||
setError('获取计划数据失败');
|
||||
console.error('获取计划数据失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPlanData();
|
||||
}, [planId]);
|
||||
|
||||
const handleStatusChange = async (newStatus: 'active' | 'paused') => {
|
||||
if (!plan) return;
|
||||
|
||||
try {
|
||||
// 这里可以调用实际的API
|
||||
// await fetch(`/api/plans/${plan.id}/status`, {
|
||||
// method: 'PATCH',
|
||||
// headers: { 'Content-Type': 'application/json' },
|
||||
// body: JSON.stringify({ status: newStatus }),
|
||||
// });
|
||||
|
||||
setPlan({ ...plan, status: newStatus });
|
||||
} catch (error) {
|
||||
console.error('更新计划状态失败:', error);
|
||||
alert('更新失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto pb-20 bg-gray-50">
|
||||
<div className="flex justify-center items-center h-40">
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !plan) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto pb-20 bg-gray-50">
|
||||
<div className="text-red-500 text-center py-8">{error || '计划不存在'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto pb-20 bg-gray-50">
|
||||
<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">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="mr-3 p-1 hover:bg-gray-100 rounded"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<h1 className="text-xl font-semibold">{plan.name}</h1>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => navigate(`/plans/${plan.id}/edit`)}
|
||||
className="p-2 hover:bg-gray-100 rounded"
|
||||
>
|
||||
<Edit className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate(`/plans/${plan.id}/settings`)}
|
||||
className="p-2 hover:bg-gray-100 rounded"
|
||||
>
|
||||
<Settings className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="p-4">
|
||||
{/* 计划描述 */}
|
||||
{plan.description && (
|
||||
<div className="bg-white rounded-lg p-4 mb-6">
|
||||
<p className="text-gray-600 text-sm">{plan.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 数据统计 */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||
<div className="bg-white rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-500 text-sm">总获客数</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{plan.totalCustomers}</p>
|
||||
</div>
|
||||
<Users className="h-8 w-8 text-blue-500" />
|
||||
</div>
|
||||
<div className="flex items-center mt-2 text-green-500 text-sm">
|
||||
<TrendingUp className="h-4 w-4 mr-1" />
|
||||
<span>{plan.growth}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-gray-500 text-sm">今日获客</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{plan.todayCustomers}</p>
|
||||
</div>
|
||||
<Calendar className="h-8 w-8 text-green-500" />
|
||||
</div>
|
||||
<div className="flex items-center mt-2 text-gray-500 text-sm">
|
||||
<span>创建于 {plan.createdAt}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 状态控制 */}
|
||||
<div className="bg-white rounded-lg p-4 mb-6">
|
||||
<h3 className="text-lg font-medium mb-4">计划状态</h3>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<span className={`px-3 py-1 rounded-full text-sm ${
|
||||
plan.status === 'active'
|
||||
? 'text-green-600 bg-green-50'
|
||||
: plan.status === 'paused'
|
||||
? 'text-yellow-600 bg-yellow-50'
|
||||
: 'text-gray-600 bg-gray-50'
|
||||
}`}>
|
||||
{plan.status === 'active' ? '进行中' : plan.status === 'paused' ? '已暂停' : '已完成'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
{plan.status === 'active' ? (
|
||||
<button
|
||||
onClick={() => handleStatusChange('paused')}
|
||||
className="flex items-center px-3 py-2 bg-yellow-500 text-white rounded-md hover:bg-yellow-600 transition-colors text-sm"
|
||||
>
|
||||
<Pause className="h-4 w-4 mr-1" />
|
||||
暂停
|
||||
</button>
|
||||
) : plan.status === 'paused' ? (
|
||||
<button
|
||||
onClick={() => handleStatusChange('active')}
|
||||
className="flex items-center px-3 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 transition-colors text-sm"
|
||||
>
|
||||
<Play className="h-4 w-4 mr-1" />
|
||||
启动
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 功能区域 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-white rounded-lg p-4 cursor-pointer hover:shadow-md transition-shadow" onClick={() => navigate(`/plans/${plan.id}/customers`)}>
|
||||
<div className="flex items-center">
|
||||
<Users className="h-8 w-8 text-blue-500 mr-3" />
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">客户管理</h3>
|
||||
<p className="text-sm text-gray-500">查看和管理获客客户</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg p-4 cursor-pointer hover:shadow-md transition-shadow" onClick={() => navigate(`/plans/${plan.id}/analytics`)}>
|
||||
<div className="flex items-center">
|
||||
<TrendingUp className="h-8 w-8 text-green-500 mr-3" />
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">数据分析</h3>
|
||||
<p className="text-sm text-gray-500">查看获客数据统计</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg p-4 cursor-pointer hover:shadow-md transition-shadow" onClick={() => navigate(`/plans/${plan.id}/content`)}>
|
||||
<div className="flex items-center">
|
||||
<Calendar className="h-8 w-8 text-purple-500 mr-3" />
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">内容管理</h3>
|
||||
<p className="text-sm text-gray-500">管理营销内容</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg p-4 cursor-pointer hover:shadow-md transition-shadow" onClick={() => navigate(`/plans/${plan.id}/settings`)}>
|
||||
<div className="flex items-center">
|
||||
<Settings className="h-8 w-8 text-gray-500 mr-3" />
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">计划设置</h3>
|
||||
<p className="text-sm text-gray-500">配置计划参数</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Plus, Calendar } from 'lucide-react';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
|
||||
interface Plan {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'active' | 'paused' | 'completed';
|
||||
createdAt: string;
|
||||
totalCustomers: number;
|
||||
todayCustomers: number;
|
||||
growth: string;
|
||||
scenario: string;
|
||||
}
|
||||
|
||||
export default function Plans() {
|
||||
const navigate = useNavigate();
|
||||
const [plans, setPlans] = useState<Plan[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPlans = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 模拟API调用
|
||||
const mockPlans: Plan[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: '春季营销计划',
|
||||
status: 'active',
|
||||
createdAt: '2024-03-15',
|
||||
totalCustomers: 456,
|
||||
todayCustomers: 23,
|
||||
growth: '+8.2%',
|
||||
scenario: 'douyin',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: '新品推广计划',
|
||||
status: 'active',
|
||||
createdAt: '2024-03-10',
|
||||
totalCustomers: 234,
|
||||
todayCustomers: 15,
|
||||
growth: '+5.1%',
|
||||
scenario: 'xiaohongshu',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: '节日活动计划',
|
||||
status: 'paused',
|
||||
createdAt: '2024-02-28',
|
||||
totalCustomers: 789,
|
||||
todayCustomers: 0,
|
||||
growth: '+0%',
|
||||
scenario: 'gongzhonghao',
|
||||
},
|
||||
];
|
||||
|
||||
setPlans(mockPlans);
|
||||
} catch (error) {
|
||||
setError('获取计划数据失败');
|
||||
console.error('获取计划数据失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPlans();
|
||||
}, []);
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'text-green-600 bg-green-50';
|
||||
case 'paused':
|
||||
return 'text-yellow-600 bg-yellow-50';
|
||||
case 'completed':
|
||||
return 'text-gray-600 bg-gray-50';
|
||||
default:
|
||||
return 'text-gray-600 bg-gray-50';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return '进行中';
|
||||
case 'paused':
|
||||
return '已暂停';
|
||||
case 'completed':
|
||||
return '已完成';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto pb-20 bg-gray-50">
|
||||
<PageHeader
|
||||
title="获客计划"
|
||||
showBack={false}
|
||||
/>
|
||||
<div className="flex justify-center items-center h-40">
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto pb-20 bg-gray-50">
|
||||
<PageHeader
|
||||
title="获客计划"
|
||||
showBack={false}
|
||||
/>
|
||||
<div className="text-red-500 text-center py-8">{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto pb-20 bg-gray-50">
|
||||
<PageHeader
|
||||
title="获客计划"
|
||||
showBack={false}
|
||||
rightContent={
|
||||
<button
|
||||
onClick={() => navigate('/scenarios/new')}
|
||||
className="flex items-center px-3 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors text-sm"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
新建计划
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="p-4">
|
||||
{plans.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<p>暂无获客计划</p>
|
||||
<button
|
||||
onClick={() => navigate('/scenarios/new')}
|
||||
className="mt-2 text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
立即创建
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{plans.map((plan) => (
|
||||
<div
|
||||
key={plan.id}
|
||||
className="bg-white rounded-lg p-4 hover:shadow-md transition-shadow cursor-pointer"
|
||||
onClick={() => navigate(`/plans/${plan.id}`)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center mb-2">
|
||||
<h3 className="font-medium text-gray-900">{plan.name}</h3>
|
||||
<span className={`ml-2 px-2 py-1 text-xs rounded-full ${getStatusColor(plan.status)}`}>
|
||||
{getStatusText(plan.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<Calendar className="h-4 w-4 mr-1" />
|
||||
<span>创建于 {plan.createdAt}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div className="flex items-center text-sm">
|
||||
<span className="text-gray-500">总获客:</span>
|
||||
<span className="font-medium ml-1">{plan.totalCustomers}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-sm mt-1">
|
||||
<span className="text-gray-500">今日:</span>
|
||||
<span className="font-medium ml-1">{plan.todayCustomers}</span>
|
||||
<span className="text-green-500 ml-1">({plan.growth})</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ChevronRight, Settings, Bell, LogOut, Smartphone, MessageCircle, Database, FolderOpen } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import Layout from '@/components/Layout';
|
||||
import BottomNav from '@/components/BottomNav';
|
||||
import UnifiedHeader from '@/components/UnifiedHeader';
|
||||
import '@/components/Layout.css';
|
||||
|
||||
export default function Profile() {
|
||||
const navigate = useNavigate();
|
||||
const { user, logout, isAuthenticated } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const [showLogoutDialog, setShowLogoutDialog] = useState(false);
|
||||
const [userInfo, setUserInfo] = useState<any>(null);
|
||||
const [stats, setStats] = useState({
|
||||
devices: 12,
|
||||
wechat: 25,
|
||||
traffic: 8,
|
||||
content: 156,
|
||||
});
|
||||
|
||||
// 从localStorage获取用户信息
|
||||
useEffect(() => {
|
||||
const userInfoStr = localStorage.getItem('userInfo');
|
||||
if (userInfoStr) {
|
||||
setUserInfo(JSON.parse(userInfoStr));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 用户信息
|
||||
const currentUserInfo = {
|
||||
name: userInfo?.username || user?.username || "卡若",
|
||||
email: userInfo?.email || "zhangsan@example.com",
|
||||
role: "管理员",
|
||||
joinDate: "2023-01-15",
|
||||
lastLogin: "2024-01-20 14:30",
|
||||
};
|
||||
|
||||
// 功能模块数据
|
||||
const functionModules = [
|
||||
{
|
||||
id: "devices",
|
||||
title: "设备管理",
|
||||
description: "管理您的设备和微信账号",
|
||||
icon: <Smartphone className="h-5 w-5 text-blue-500" />,
|
||||
count: stats.devices,
|
||||
path: "/devices",
|
||||
bgColor: "bg-blue-50",
|
||||
},
|
||||
{
|
||||
id: "wechat",
|
||||
title: "微信号管理",
|
||||
description: "管理微信账号和好友",
|
||||
icon: <MessageCircle className="h-5 w-5 text-green-500" />,
|
||||
count: stats.wechat,
|
||||
path: "/wechat-accounts",
|
||||
bgColor: "bg-green-50",
|
||||
},
|
||||
{
|
||||
id: "traffic",
|
||||
title: "流量池",
|
||||
description: "管理用户流量池和分组",
|
||||
icon: <Database className="h-5 w-5 text-purple-500" />,
|
||||
count: stats.traffic,
|
||||
path: "/traffic-pool",
|
||||
bgColor: "bg-purple-50",
|
||||
},
|
||||
{
|
||||
id: "content",
|
||||
title: "内容库",
|
||||
description: "管理营销内容和素材",
|
||||
icon: <FolderOpen className="h-5 w-5 text-orange-500" />,
|
||||
count: stats.content,
|
||||
path: "/content",
|
||||
bgColor: "bg-orange-50",
|
||||
},
|
||||
];
|
||||
|
||||
// 加载统计数据
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
// 这里可以调用实际的API
|
||||
// const [deviceStats, wechatStats, trafficStats, contentStats] = await Promise.allSettled([
|
||||
// getDeviceStats(),
|
||||
// getWechatStats(),
|
||||
// getTrafficStats(),
|
||||
// getContentStats(),
|
||||
// ]);
|
||||
|
||||
// 暂时使用模拟数据
|
||||
setStats({
|
||||
devices: 12,
|
||||
wechat: 25,
|
||||
traffic: 8,
|
||||
content: 156,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("加载统计数据失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
}, []);
|
||||
|
||||
const handleLogout = () => {
|
||||
// 清除本地存储的用户信息
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('token_expired');
|
||||
localStorage.removeItem('s2_accountId');
|
||||
localStorage.removeItem('userInfo');
|
||||
setShowLogoutDialog(false);
|
||||
logout();
|
||||
navigate('/login');
|
||||
toast({
|
||||
title: '退出成功',
|
||||
description: '您已安全退出系统',
|
||||
});
|
||||
};
|
||||
|
||||
const handleFunctionClick = (path: string) => {
|
||||
navigate(path);
|
||||
};
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="text-gray-500">请先登录</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<UnifiedHeader
|
||||
title="我的"
|
||||
showBack={false}
|
||||
titleColor="blue"
|
||||
actions={[
|
||||
{
|
||||
type: 'icon',
|
||||
icon: Bell,
|
||||
onClick: () => console.log('Notifications'),
|
||||
},
|
||||
{
|
||||
type: 'icon',
|
||||
icon: Settings,
|
||||
onClick: () => console.log('Settings'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
footer={<BottomNav />}
|
||||
>
|
||||
<div className="bg-gray-50 pb-16">
|
||||
<div className="p-4 space-y-4">
|
||||
{/* 用户信息卡片 */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Avatar className="h-16 w-16">
|
||||
<AvatarImage src={userInfo?.avatar || user?.avatar || ''} />
|
||||
<AvatarFallback className="bg-gray-200 text-gray-600 text-lg font-medium">
|
||||
{currentUserInfo.name.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
<h2 className="text-lg font-medium">{currentUserInfo.name}</h2>
|
||||
<span className="px-2 py-1 text-xs bg-gradient-to-r from-orange-400 to-orange-500 text-white rounded-full font-medium shadow-sm">
|
||||
{currentUserInfo.role}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-2">{currentUserInfo.email}</p>
|
||||
<div className="text-xs text-gray-500">
|
||||
<div>最近登录: {currentUserInfo.lastLogin}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Button variant="ghost" size="icon">
|
||||
<Bell className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Settings className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 我的功能 */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="space-y-2">
|
||||
{functionModules.map((module) => (
|
||||
<div
|
||||
key={module.id}
|
||||
className="flex items-center p-4 rounded-lg border hover:bg-gray-50 cursor-pointer transition-colors w-full"
|
||||
onClick={() => handleFunctionClick(module.path)}
|
||||
>
|
||||
<div className={`p-2 rounded-lg ${module.bgColor} mr-3`}>{module.icon}</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">{module.title}</div>
|
||||
<div className="text-xs text-gray-500">{module.description}</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="px-2 py-1 text-xs bg-gray-50 text-gray-700 rounded-full border border-gray-200 font-medium shadow-sm">
|
||||
{module.count}
|
||||
</span>
|
||||
<ChevronRight className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 退出登录 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full text-red-600 border-red-200 hover:bg-red-50 bg-transparent"
|
||||
onClick={() => setShowLogoutDialog(true)}
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
退出登录
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 退出登录确认对话框 */}
|
||||
<Dialog open={showLogoutDialog} onOpenChange={setShowLogoutDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认退出登录</DialogTitle>
|
||||
<DialogDescription>
|
||||
您确定要退出登录吗?退出后需要重新登录才能使用完整功能。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-end space-x-2 mt-4">
|
||||
<Button variant="outline" onClick={() => setShowLogoutDialog(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleLogout}>
|
||||
确认退出
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,663 +0,0 @@
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { useParams, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import PageHeader from "@/components/PageHeader";
|
||||
import Layout from "@/components/Layout";
|
||||
import BottomNav from "@/components/BottomNav";
|
||||
import {
|
||||
Plus,
|
||||
Users,
|
||||
Calendar,
|
||||
Copy,
|
||||
Trash2,
|
||||
Edit,
|
||||
Settings,
|
||||
Loader2,
|
||||
Code,
|
||||
Search,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
fetchPlanList,
|
||||
fetchPlanDetail,
|
||||
copyPlan,
|
||||
deletePlan,
|
||||
type Task,
|
||||
} from "@/api/scenarios";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import "@/components/Layout.css";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface ScenarioData {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string;
|
||||
description: string;
|
||||
totalPlans: number;
|
||||
totalCustomers: number;
|
||||
todayCustomers: number;
|
||||
growth: string;
|
||||
}
|
||||
|
||||
interface ApiSettings {
|
||||
apiKey: string;
|
||||
webhookUrl: string;
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
export default function ScenarioDetail() {
|
||||
const { scenarioId, scenarioName } = useParams<{
|
||||
scenarioId: string;
|
||||
scenarioName: string;
|
||||
}>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { toast } = useToast();
|
||||
const [scenario, setScenario] = useState<ScenarioData | null>(null);
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [showApiDialog, setShowApiDialog] = useState(false);
|
||||
const [currentApiSettings, setCurrentApiSettings] = useState<ApiSettings>({
|
||||
apiKey: "",
|
||||
webhookUrl: "",
|
||||
taskId: "",
|
||||
});
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [loadingTasks, setLoadingTasks] = useState(false);
|
||||
|
||||
// 获取渠道中文名称
|
||||
const getChannelName = (channel: string) => {
|
||||
const channelMap: Record<string, string> = {
|
||||
douyin: "抖音直播获客",
|
||||
kuaishou: "快手直播获客",
|
||||
xiaohongshu: "小红书种草获客",
|
||||
weibo: "微博话题获客",
|
||||
haibao: "海报扫码获客",
|
||||
phone: "电话号码获客",
|
||||
gongzhonghao: "公众号引流获客",
|
||||
weixinqun: "微信群裂变获客",
|
||||
payment: "付款码获客",
|
||||
api: "API接口获客",
|
||||
};
|
||||
return channelMap[channel] || `${channel}获客`;
|
||||
};
|
||||
|
||||
// 获取场景描述
|
||||
const getScenarioDescription = (channel: string) => {
|
||||
const descriptions: Record<string, string> = {
|
||||
douyin: "通过抖音平台进行精准获客,利用短视频内容吸引目标用户",
|
||||
xiaohongshu: "利用小红书平台进行内容营销获客,通过优质内容建立品牌形象",
|
||||
gongzhonghao: "通过微信公众号进行获客,建立私域流量池",
|
||||
haibao: "通过海报分享进行获客,快速传播品牌信息",
|
||||
phone: "通过电话营销进行获客,直接与客户沟通",
|
||||
weixinqun: "通过微信群进行获客,利用社交裂变效应",
|
||||
payment: "通过付款码进行获客,便捷的支付方式",
|
||||
api: "通过API接口进行获客,支持第三方系统集成",
|
||||
};
|
||||
return descriptions[channel] || "通过该平台进行获客";
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchScenarioData = async () => {
|
||||
if (!scenarioId) return;
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
// 获取计划列表
|
||||
const response = await fetchPlanList(scenarioId, 1, 20);
|
||||
|
||||
// 设置计划列表(可能为空)
|
||||
if (response && response.data && response.data.list) {
|
||||
setTasks(response.data.list);
|
||||
} else {
|
||||
setTasks([]);
|
||||
}
|
||||
|
||||
// 构建场景数据(无论是否有计划都要创建)
|
||||
const scenarioData: ScenarioData = {
|
||||
id: scenarioId,
|
||||
name: scenarioName || "",
|
||||
image: "", // 可以根据需要设置图片
|
||||
description: getScenarioDescription(scenarioId),
|
||||
totalPlans: response?.data?.list?.length || 0,
|
||||
totalCustomers: 0, // 移除统计
|
||||
todayCustomers: 0, // 移除统计
|
||||
growth: "", // 移除增长
|
||||
};
|
||||
|
||||
setScenario(scenarioData);
|
||||
} catch (error) {
|
||||
console.error("获取场景数据失败:", error);
|
||||
// 即使API失败也要创建基本的场景数据
|
||||
const scenarioData: ScenarioData = {
|
||||
id: scenarioId,
|
||||
name: getScenarioName(),
|
||||
image: "",
|
||||
description: getScenarioDescription(scenarioId),
|
||||
totalPlans: 0,
|
||||
totalCustomers: 0,
|
||||
todayCustomers: 0,
|
||||
growth: "",
|
||||
};
|
||||
setScenario(scenarioData);
|
||||
setTasks([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchScenarioData();
|
||||
}, [scenarioId]);
|
||||
|
||||
// 获取场景名称 - 优先使用URL查询参数,其次使用映射
|
||||
const getScenarioName = useCallback(() => {
|
||||
// 优先使用URL查询参数中的name
|
||||
const urlName = searchParams.get("name");
|
||||
if (urlName) {
|
||||
return urlName;
|
||||
}
|
||||
|
||||
// 如果没有URL参数,使用映射
|
||||
return getChannelName(scenarioId || "");
|
||||
}, [searchParams, scenarioId]);
|
||||
|
||||
// 更新场景数据中的名称
|
||||
useEffect(() => {
|
||||
setScenario((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
name: (() => {
|
||||
const urlName = searchParams.get("name");
|
||||
if (urlName) return urlName;
|
||||
return getChannelName(scenarioId || "");
|
||||
})(),
|
||||
}
|
||||
: null
|
||||
);
|
||||
}, [searchParams, scenarioId]);
|
||||
|
||||
const handleCopyPlan = async (taskId: string) => {
|
||||
const taskToCopy = tasks.find((task) => task.id === taskId);
|
||||
if (!taskToCopy) return;
|
||||
|
||||
try {
|
||||
const response = await copyPlan(taskId);
|
||||
if (response && response.code === 200) {
|
||||
toast({
|
||||
title: "计划已复制",
|
||||
description: `已成功复制"${taskToCopy.name}"`,
|
||||
});
|
||||
|
||||
// 重新加载数据
|
||||
const refreshResponse = await fetchPlanList(scenarioId!, 1, 20);
|
||||
if (
|
||||
refreshResponse &&
|
||||
refreshResponse.code === 200 &&
|
||||
refreshResponse.data
|
||||
) {
|
||||
setTasks(refreshResponse.data.list);
|
||||
}
|
||||
} else {
|
||||
throw new Error(response?.msg || "复制失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("复制计划失败:", error);
|
||||
toast({
|
||||
title: "复制失败",
|
||||
description: error instanceof Error ? error.message : "复制计划失败",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePlan = async (taskId: string) => {
|
||||
const taskToDelete = tasks.find((task) => task.id === taskId);
|
||||
if (!taskToDelete) return;
|
||||
|
||||
if (!window.confirm(`确定要删除"${taskToDelete.name}"吗?`)) return;
|
||||
|
||||
try {
|
||||
const response = await deletePlan(taskId);
|
||||
if (response && response.code === 200) {
|
||||
toast({
|
||||
title: "计划已删除",
|
||||
description: `已成功删除"${taskToDelete.name}"`,
|
||||
});
|
||||
|
||||
// 重新加载数据
|
||||
const refreshResponse = await fetchPlanList(scenarioId!, 1, 20);
|
||||
if (
|
||||
refreshResponse &&
|
||||
refreshResponse.code === 200 &&
|
||||
refreshResponse.data
|
||||
) {
|
||||
setTasks(refreshResponse.data.list);
|
||||
}
|
||||
} else {
|
||||
throw new Error(response?.msg || "删除失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("删除计划失败:", error);
|
||||
toast({
|
||||
title: "删除失败",
|
||||
description: error instanceof Error ? error.message : "删除计划失败",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = async (taskId: string, newStatus: 1 | 0) => {
|
||||
try {
|
||||
// 这里应该调用状态切换API,暂时模拟
|
||||
setTasks((prev) =>
|
||||
prev.map((task) =>
|
||||
task.id === taskId ? { ...task, status: newStatus } : task
|
||||
)
|
||||
);
|
||||
|
||||
toast({
|
||||
title: "状态已更新",
|
||||
description: `计划已${newStatus === 1 ? "启动" : "暂停"}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("状态切换失败:", error);
|
||||
toast({
|
||||
title: "状态切换失败",
|
||||
description: "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenApiSettings = async (taskId: string) => {
|
||||
try {
|
||||
const response = await fetchPlanDetail(taskId);
|
||||
if (response && response.code === 200 && response.data) {
|
||||
setCurrentApiSettings({
|
||||
apiKey: response.data.apiKey || "demo-api-key-123456",
|
||||
webhookUrl:
|
||||
response.data.textUrl?.fullUrl ||
|
||||
`https://api.example.com/webhook/${taskId}`,
|
||||
taskId,
|
||||
});
|
||||
setShowApiDialog(true);
|
||||
} else {
|
||||
throw new Error(response?.msg || "获取API设置失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取API设置失败:", error);
|
||||
toast({
|
||||
title: "获取API设置失败",
|
||||
description: "请稍后重试",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyApiUrl = (url: string) => {
|
||||
navigator.clipboard.writeText(url);
|
||||
toast({
|
||||
title: "已复制",
|
||||
description: "接口地址已复制到剪贴板",
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateNewPlan = () => {
|
||||
navigate(`/scenarios/new/${scenarioId}`);
|
||||
};
|
||||
|
||||
const getStatusColor = (status: number) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return "text-green-600 bg-green-50";
|
||||
case 0:
|
||||
return "text-yellow-600 bg-yellow-50";
|
||||
default:
|
||||
return "text-gray-600 bg-gray-50";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: number) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return "进行中";
|
||||
case 0:
|
||||
return "已暂停";
|
||||
default:
|
||||
return "未知";
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<PageHeader
|
||||
title={scenario?.name || "场景详情"}
|
||||
defaultBackPath="/scenarios"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="bg-gray-50 min-h-screen flex items-center justify-center">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
<p className="text-gray-500">加载场景数据中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Layout
|
||||
header={<PageHeader title="场景详情" defaultBackPath="/scenarios" />}
|
||||
footer={<BottomNav />}
|
||||
>
|
||||
<div className="bg-gray-50 min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!scenario) {
|
||||
return (
|
||||
<Layout
|
||||
header={<PageHeader title="场景详情" defaultBackPath="/scenarios" />}
|
||||
footer={<BottomNav />}
|
||||
>
|
||||
<div className="bg-gray-50 min-h-screen flex items-center justify-center">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
<p className="text-gray-500">加载场景数据中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setLoadingTasks(true);
|
||||
await fetchPlanList(scenarioId!, 1, 20);
|
||||
setLoadingTasks(false);
|
||||
};
|
||||
|
||||
const filteredTasks = tasks.filter((task) => task.name.includes(searchTerm));
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<PageHeader
|
||||
title={scenario.name}
|
||||
defaultBackPath="/scenarios"
|
||||
rightContent={
|
||||
<button
|
||||
onClick={handleCreateNewPlan}
|
||||
className="flex items-center px-3 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors text-sm"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
新建计划
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<div className="flex items-center space-x-2 m-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索计划名称"
|
||||
className="pl-9"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleRefresh}
|
||||
disabled={loadingTasks}
|
||||
>
|
||||
{loadingTasks ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="p-4">
|
||||
{/* 计划列表 */}
|
||||
<div className="rounded-lg">
|
||||
{filteredTasks.length === 0 ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="mb-4">
|
||||
<Users className="h-12 w-12 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-gray-500 text-lg font-medium mb-2">
|
||||
暂无获客计划
|
||||
</p>
|
||||
<p className="text-gray-400 text-sm">
|
||||
创建您的第一个获客计划,开始获取客户
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreateNewPlan}
|
||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
创建第一个计划
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{filteredTasks.map((task) => (
|
||||
<div key={task.id} className="p-4 bg-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center mb-2">
|
||||
<h3 className="font-medium text-gray-900">
|
||||
{task.name}
|
||||
</h3>
|
||||
<span
|
||||
className={`ml-2 px-2 py-1 text-xs rounded-full ${getStatusColor(
|
||||
task.status
|
||||
)}`}
|
||||
>
|
||||
{getStatusText(task.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<Calendar className="h-4 w-4 mr-1" />
|
||||
<span>最后更新: {task.lastUpdated}</span>
|
||||
</div>
|
||||
<div className="flex items-center mt-2 text-sm text-gray-500">
|
||||
<span>
|
||||
设备: {task.stats?.devices || 0} | 获客:{" "}
|
||||
{task.stats?.acquired || 0} | 添加:{" "}
|
||||
{task.stats?.added || 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => navigate(`/scenarios/edit/${task.id}`)}
|
||||
className={`p-2 rounded-md ${
|
||||
task.status === 1
|
||||
? "text-yellow-600 hover:bg-yellow-50"
|
||||
: "text-green-600 hover:bg-green-50"
|
||||
}`}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleOpenApiSettings(task.id)}
|
||||
className="p-2 text-blue-600 hover:bg-blue-50 rounded-md"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleCopyPlan(task.id)}
|
||||
className="p-2 text-gray-600 hover:bg-gray-50 rounded-md"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleDeletePlan(task.id)}
|
||||
className="p-2 text-red-600 hover:bg-red-50 rounded-md"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* API接口设置对话框 */}
|
||||
{showApiDialog && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<Code className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold">计划接口配置</h3>
|
||||
<p className="text-gray-500 text-sm">
|
||||
通过API接口直接导入客资到该获客计划
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowApiDialog(false)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<span className="text-2xl">×</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* API密钥配置 */}
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="font-medium">API密钥</h4>
|
||||
<span className="px-2 py-1 bg-green-100 text-green-700 text-xs rounded">
|
||||
安全认证
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<input
|
||||
value={currentApiSettings.apiKey}
|
||||
readOnly
|
||||
className="flex-1 p-2 bg-white border rounded font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(currentApiSettings.apiKey);
|
||||
toast({
|
||||
title: "已复制",
|
||||
description: "API密钥已复制到剪贴板",
|
||||
});
|
||||
}}
|
||||
className="px-3 py-2 border border-gray-300 rounded hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-1" />
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<p className="text-sm text-amber-800">
|
||||
<strong>安全提示:</strong>
|
||||
请妥善保管API密钥,不要在客户端代码中暴露。建议在服务器端使用该密钥。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 接口地址配置 */}
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="font-medium">接口地址</h4>
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-700 text-xs rounded">
|
||||
POST请求
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<input
|
||||
value={currentApiSettings.webhookUrl}
|
||||
readOnly
|
||||
className="flex-1 p-2 bg-white border rounded font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleCopyApiUrl(currentApiSettings.webhookUrl)
|
||||
}
|
||||
className="px-3 py-2 border border-gray-300 rounded hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-1" />
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-3">
|
||||
<h5 className="font-medium text-green-800 mb-2">
|
||||
必要参数
|
||||
</h5>
|
||||
<div className="space-y-1 text-sm text-green-700">
|
||||
<div>
|
||||
<code className="bg-green-100 px-1 rounded">name</code>{" "}
|
||||
- 客户姓名
|
||||
</div>
|
||||
<div>
|
||||
<code className="bg-green-100 px-1 rounded">phone</code>{" "}
|
||||
- 手机号码
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<h5 className="font-medium text-blue-800 mb-2">可选参数</h5>
|
||||
<div className="space-y-1 text-sm text-blue-700">
|
||||
<div>
|
||||
<code className="bg-blue-100 px-1 rounded">source</code>{" "}
|
||||
- 来源标识
|
||||
</div>
|
||||
<div>
|
||||
<code className="bg-blue-100 px-1 rounded">remark</code>{" "}
|
||||
- 备注信息
|
||||
</div>
|
||||
<div>
|
||||
<code className="bg-blue-100 px-1 rounded">tags</code> -
|
||||
客户标签
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
import React, { useEffect, useState, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Plus, TrendingUp, Loader2 } from "lucide-react";
|
||||
import UnifiedHeader from "@/components/UnifiedHeader";
|
||||
import Layout from "@/components/Layout";
|
||||
import BottomNav from "@/components/BottomNav";
|
||||
import { fetchScenes, type SceneItem } from "@/api/scenarios";
|
||||
import "@/components/Layout.css";
|
||||
|
||||
interface Scenario {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string;
|
||||
description?: string;
|
||||
count: number;
|
||||
growth: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export default function Scenarios() {
|
||||
const navigate = useNavigate();
|
||||
const [scenarios, setScenarios] = useState<Scenario[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// 场景描述映射
|
||||
const scenarioDescriptions: Record<string, string> = useMemo(
|
||||
() => ({
|
||||
douyin: "通过抖音平台进行精准获客",
|
||||
xiaohongshu: "利用小红书平台进行内容营销获客",
|
||||
gongzhonghao: "通过微信公众号进行获客",
|
||||
haibao: "通过海报分享进行获客",
|
||||
phone: "通过电话营销进行获客",
|
||||
weixinqun: "通过微信群进行获客",
|
||||
payment: "通过付款码进行获客",
|
||||
api: "通过API接口进行获客",
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchScenarios = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const response = await fetchScenes({ page: 1, limit: 20 });
|
||||
|
||||
if (response && response.code === 200 && response.data) {
|
||||
// 转换API数据为前端需要的格式
|
||||
const transformedScenarios: Scenario[] = response.data.map(
|
||||
(item: SceneItem) => ({
|
||||
id: item.id.toString(),
|
||||
name: item.name,
|
||||
image:
|
||||
item.image ||
|
||||
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-api.png",
|
||||
description:
|
||||
scenarioDescriptions[item.name.toLowerCase()] ||
|
||||
"通过该平台进行获客",
|
||||
count: Math.floor(Math.random() * 200) + 50, // 模拟今日数据
|
||||
growth: `+${Math.floor(Math.random() * 20) + 5}%`, // 模拟增长率
|
||||
status: item.status === 1 ? "active" : "inactive",
|
||||
})
|
||||
);
|
||||
|
||||
setScenarios(transformedScenarios);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取场景数据失败:", error);
|
||||
setError("获取场景数据失败,请稍后重试");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchScenarios();
|
||||
}, [scenarioDescriptions]);
|
||||
|
||||
const handleScenarioClick = (scenarioId: string, scenarioName: string) => {
|
||||
navigate(
|
||||
`/scenarios/list/${scenarioId}/${encodeURIComponent(scenarioName)}`
|
||||
);
|
||||
};
|
||||
|
||||
const handleNewPlan = () => {
|
||||
navigate("/scenarios/new");
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout
|
||||
header={<UnifiedHeader title="场景获客" showBack={false} />}
|
||||
footer={<BottomNav />}
|
||||
>
|
||||
<div className="bg-gray-50 min-h-screen flex items-center justify-center">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
<p className="text-gray-500">加载场景数据中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && scenarios.length === 0) {
|
||||
return (
|
||||
<Layout
|
||||
header={<UnifiedHeader title="场景获客" showBack={false} />}
|
||||
footer={<BottomNav />}
|
||||
>
|
||||
<div className="bg-gray-50 min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<UnifiedHeader
|
||||
title="场景获客"
|
||||
showBack={false}
|
||||
titleColor="blue"
|
||||
actions={[
|
||||
{
|
||||
type: "button",
|
||||
icon: Plus,
|
||||
label: "新建计划",
|
||||
size: "sm",
|
||||
onClick: handleNewPlan,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
footer={<BottomNav />}
|
||||
>
|
||||
<div className="bg-gray-50 min-h-screen pb-20">
|
||||
<div className="p-4">
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md">
|
||||
<p className="text-yellow-800 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{scenarios.map((scenario) => (
|
||||
<div
|
||||
key={scenario.id}
|
||||
className="bg-white rounded-lg shadow overflow-hidden hover:shadow-md transition-shadow cursor-pointer"
|
||||
onClick={() => handleScenarioClick(scenario.id, scenario.name)}
|
||||
>
|
||||
<div className="p-4 flex flex-col items-center">
|
||||
<div className="w-12 h-12 bg-gray-200 rounded-full flex items-center justify-center mb-2">
|
||||
<img
|
||||
src={scenario.image}
|
||||
alt={scenario.name}
|
||||
className="w-8 h-8"
|
||||
onError={(e) => {
|
||||
// 图片加载失败时使用默认图标
|
||||
e.currentTarget.src =
|
||||
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/image-api.png";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-blue-600 font-medium text-center">
|
||||
{scenario.name}
|
||||
</h3>
|
||||
{scenario.description && (
|
||||
<p className="text-xs text-gray-500 text-center mt-1 line-clamp-2">
|
||||
{scenario.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center mt-2 text-gray-500 text-sm">
|
||||
<span>今日: </span>
|
||||
<span className="font-medium ml-1">{scenario.count}</span>
|
||||
</div>
|
||||
<div className="flex items-center mt-1 text-green-500 text-xs">
|
||||
<TrendingUp className="h-3 w-3 mr-1" />
|
||||
<span>{scenario.growth}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Toast } from "tdesign-mobile-react";
|
||||
import { Steps, StepItem } from "tdesign-mobile-react";
|
||||
import { BasicSettings } from "./steps/BasicSettings";
|
||||
import { FriendRequestSettings } from "./steps/FriendRequestSettings";
|
||||
import { MessageSettings } from "./steps/MessageSettings";
|
||||
import Layout from "@/components/Layout";
|
||||
import {
|
||||
getPlanScenes,
|
||||
createScenarioPlan,
|
||||
fetchPlanDetail,
|
||||
PlanDetail,
|
||||
updateScenarioPlan,
|
||||
} from "@/api/scenarios";
|
||||
|
||||
// 步骤定义 - 只保留三个步骤
|
||||
const steps = [
|
||||
{ id: 1, title: "步骤一", subtitle: "基础设置" },
|
||||
{ id: 2, title: "步骤二", subtitle: "好友申请设置" },
|
||||
{ id: 3, title: "步骤三", subtitle: "消息设置" },
|
||||
];
|
||||
|
||||
// 类型定义
|
||||
interface FormData {
|
||||
name: string;
|
||||
scenario: number;
|
||||
posters: any[]; // 后续可替换为具体Poster类型
|
||||
device: string[];
|
||||
remarkType: string;
|
||||
greeting: string;
|
||||
addInterval: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
enabled: boolean;
|
||||
sceneId: string | number;
|
||||
remarkFormat: string;
|
||||
addFriendInterval: number;
|
||||
}
|
||||
|
||||
export default function NewPlan() {
|
||||
const router = useNavigate();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
name: "",
|
||||
scenario: 1,
|
||||
posters: [],
|
||||
device: [],
|
||||
remarkType: "phone",
|
||||
greeting: "你好,请通过",
|
||||
addInterval: 1,
|
||||
startTime: "09:00",
|
||||
endTime: "18:00",
|
||||
enabled: true,
|
||||
sceneId: "",
|
||||
remarkFormat: "",
|
||||
addFriendInterval: 1,
|
||||
});
|
||||
const [sceneList, setSceneList] = useState<any[]>([]);
|
||||
const [sceneLoading, setSceneLoading] = useState(true);
|
||||
const { scenarioId, planId } = useParams<{
|
||||
scenarioId: string;
|
||||
planId: string;
|
||||
}>();
|
||||
const [isEdit, setIsEdit] = useState(false);
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
setSceneLoading(true);
|
||||
//获取场景类型
|
||||
getPlanScenes()
|
||||
.then((res) => {
|
||||
setSceneList(res?.data || []);
|
||||
})
|
||||
.finally(() => setSceneLoading(false));
|
||||
if (planId) {
|
||||
setIsEdit(true);
|
||||
//获取计划详情
|
||||
const res = await fetchPlanDetail(planId);
|
||||
if (res.code === 200 && res.data) {
|
||||
const detail = res.data as PlanDetail;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
name: detail.name ?? "",
|
||||
scenario: Number(detail.scenario) || 1,
|
||||
posters: detail.posters ?? [],
|
||||
device: detail.device ?? [],
|
||||
remarkType: detail.remarkType ?? "phone",
|
||||
greeting: detail.greeting ?? "",
|
||||
addInterval: detail.addInterval ?? 1,
|
||||
startTime: detail.startTime ?? "09:00",
|
||||
endTime: detail.endTime ?? "18:00",
|
||||
enabled: detail.enabled ?? true,
|
||||
sceneId: Number(detail.scenario) || 1,
|
||||
remarkFormat: detail.remarkFormat ?? "",
|
||||
addFriendInterval: detail.addFriendInterval ?? 1,
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
if (scenarioId) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
...{ scenario: Number(scenarioId) || 1 },
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 更新表单数据
|
||||
const onChange = (data: any) => {
|
||||
setFormData((prev) => ({ ...prev, ...data }));
|
||||
};
|
||||
|
||||
// 处理保存
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
let result;
|
||||
if (isEdit && planId) {
|
||||
// 编辑:拼接后端需要的完整参数
|
||||
const editData = {
|
||||
...formData,
|
||||
id: Number(planId),
|
||||
planId: Number(planId),
|
||||
// 兼容后端需要的字段
|
||||
// 你可以根据实际需要补充其它字段
|
||||
};
|
||||
result = await updateScenarioPlan(planId, editData);
|
||||
} else {
|
||||
// 新建
|
||||
result = await createScenarioPlan(formData);
|
||||
}
|
||||
if (result.code === 200) {
|
||||
Toast({
|
||||
message: isEdit ? "计划已更新" : "获客计划已创建",
|
||||
theme: "success",
|
||||
});
|
||||
const sceneItem = sceneList.find((v) => formData.scenario === v.id);
|
||||
router(`/scenarios/list/${formData.sceneId}/${sceneItem.name}`);
|
||||
} else {
|
||||
Toast({ message: result.msg, theme: "error" });
|
||||
}
|
||||
} catch (error) {
|
||||
Toast({
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: typeof error === "string"
|
||||
? error
|
||||
: isEdit
|
||||
? "更新计划失败,请重试"
|
||||
: "创建计划失败,请重试",
|
||||
theme: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 下一步
|
||||
const handleNext = () => {
|
||||
if (currentStep === steps.length) {
|
||||
handleSave();
|
||||
} else {
|
||||
setCurrentStep((prev) => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
// 上一步
|
||||
const handlePrev = () => {
|
||||
setCurrentStep((prev) => Math.max(prev - 1, 1));
|
||||
};
|
||||
|
||||
// 渲染当前步骤内容
|
||||
const renderStepContent = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return (
|
||||
<BasicSettings
|
||||
formData={formData}
|
||||
onChange={onChange}
|
||||
onNext={handleNext}
|
||||
sceneList={sceneList}
|
||||
sceneLoading={sceneLoading}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<FriendRequestSettings
|
||||
formData={formData}
|
||||
onChange={onChange}
|
||||
onNext={handleNext}
|
||||
onPrev={handlePrev}
|
||||
/>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<MessageSettings
|
||||
formData={formData}
|
||||
onChange={onChange}
|
||||
onNext={handleSave}
|
||||
onPrev={handlePrev}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<header className="sticky top-0 z-10 bg-white border-b">
|
||||
<div className="flex items-center justify-between h-14 px-4">
|
||||
<div className="flex items-center">
|
||||
<Button variant="ghost" size="icon" onClick={() => router(-1)}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="px-4 py-6">
|
||||
<Steps current={currentStep - 1}>
|
||||
{steps.map((step) => (
|
||||
<StepItem
|
||||
key={step.id}
|
||||
title={step.title}
|
||||
content={step.subtitle}
|
||||
/>
|
||||
))}
|
||||
</Steps>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="p-4">{renderStepContent()}</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,829 +0,0 @@
|
||||
import type React from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Tag,
|
||||
Grid,
|
||||
ImageViewer,
|
||||
Switch,
|
||||
} from "tdesign-mobile-react";
|
||||
import EyeIcon from "@/components/icons/EyeIcon";
|
||||
import { uploadFile } from "@/api/utils";
|
||||
|
||||
interface BasicSettingsProps {
|
||||
formData: any;
|
||||
onChange: (data: any) => void;
|
||||
onNext?: () => void;
|
||||
sceneList: any[];
|
||||
sceneLoading: boolean;
|
||||
}
|
||||
|
||||
interface Account {
|
||||
id: string;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
interface Material {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
preview: string;
|
||||
}
|
||||
|
||||
const posterTemplates = [
|
||||
{
|
||||
id: "poster-1",
|
||||
name: "点击领取",
|
||||
preview:
|
||||
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E9%A2%86%E5%8F%961-tipd1HI7da6qooY5NkhxQnXBnT5LGU.gif",
|
||||
},
|
||||
{
|
||||
id: "poster-2",
|
||||
name: "点击合作",
|
||||
preview:
|
||||
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E5%90%88%E4%BD%9C-LPlMdgxtvhqCSr4IM1bZFEFDBF3ztI.gif",
|
||||
},
|
||||
{
|
||||
id: "poster-3",
|
||||
name: "点击咨询",
|
||||
preview:
|
||||
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E5%92%A8%E8%AF%A2-FTiyAMAPop2g9LvjLOLDz0VwPg3KVu.gif",
|
||||
},
|
||||
{
|
||||
id: "poster-4",
|
||||
name: "点击签到",
|
||||
preview:
|
||||
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E7%AD%BE%E5%88%B0-94TZIkjLldb4P2jTVlI6MkSDg0NbXi.gif",
|
||||
},
|
||||
{
|
||||
id: "poster-5",
|
||||
name: "点击了解",
|
||||
preview:
|
||||
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E4%BA%86%E8%A7%A3-6GCl7mQVdO4WIiykJyweSubLsTwj71.gif",
|
||||
},
|
||||
{
|
||||
id: "poster-6",
|
||||
name: "点击报名",
|
||||
preview:
|
||||
"https://hebbkx1anhila5yf.public.blob.vercel-storage.com/%E7%82%B9%E5%87%BB%E6%8A%A5%E5%90%8D-Mj0nnva0BiASeDAIhNNaRRAbjPgjEj.gif",
|
||||
},
|
||||
];
|
||||
|
||||
const generateRandomAccounts = (count: number): Account[] => {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: `account-${index + 1}`,
|
||||
nickname: `账号-${Math.random().toString(36).substring(2, 7)}`,
|
||||
avatar: `/placeholder.svg?height=40&width=40&text=${index + 1}`,
|
||||
}));
|
||||
};
|
||||
|
||||
const generatePosterMaterials = (): Material[] => {
|
||||
return posterTemplates.map((template) => ({
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
type: "poster",
|
||||
preview: template.preview,
|
||||
}));
|
||||
};
|
||||
|
||||
export function BasicSettings({
|
||||
formData,
|
||||
onChange,
|
||||
onNext,
|
||||
sceneList,
|
||||
sceneLoading,
|
||||
}: BasicSettingsProps) {
|
||||
const [isAccountDialogOpen, setIsAccountDialogOpen] = useState(false);
|
||||
const [isMaterialDialogOpen, setIsMaterialDialogOpen] = useState(false);
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
const [isPhoneSettingsOpen, setIsPhoneSettingsOpen] = useState(false);
|
||||
const [accounts] = useState<Account[]>(generateRandomAccounts(50));
|
||||
const [materials] = useState<Material[]>(generatePosterMaterials());
|
||||
const [selectedAccounts, setSelectedAccounts] = useState<Account[]>(
|
||||
formData.accounts?.length > 0 ? formData.accounts : []
|
||||
);
|
||||
const [selectedMaterials, setSelectedMaterials] = useState<Material[]>(
|
||||
formData.materials?.length > 0 ? formData.materials : []
|
||||
);
|
||||
// showAllScenarios 默认为 true
|
||||
const [showAllScenarios, setShowAllScenarios] = useState(true);
|
||||
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
|
||||
const [importedTags, setImportedTags] = useState<
|
||||
Array<{
|
||||
phone: string;
|
||||
wechat: string;
|
||||
source?: string;
|
||||
orderAmount?: number;
|
||||
orderDate?: string;
|
||||
}>
|
||||
>(formData.importedTags || []);
|
||||
|
||||
// 自定义标签相关状态
|
||||
const [customTagInput, setCustomTagInput] = useState("");
|
||||
const [customTags, setCustomTags] = useState(formData.customTags || []);
|
||||
const [selectedScenarioTags, setSelectedScenarioTags] = useState(
|
||||
formData.scenarioTags || []
|
||||
);
|
||||
|
||||
// 电话获客相关状态
|
||||
const [phoneSettings, setPhoneSettings] = useState({
|
||||
autoAdd: formData.phoneSettings?.autoAdd ?? true,
|
||||
speechToText: formData.phoneSettings?.speechToText ?? true,
|
||||
questionExtraction: formData.phoneSettings?.questionExtraction ?? true,
|
||||
});
|
||||
|
||||
// 群设置相关状态
|
||||
const [weixinqunName, setWeixinqunName] = useState(
|
||||
formData.weixinqunName || ""
|
||||
);
|
||||
const [weixinqunNotice, setWeixinqunNotice] = useState(
|
||||
formData.weixinqunNotice || ""
|
||||
);
|
||||
|
||||
// 新增:自定义海报相关状态
|
||||
const [customPosters, setCustomPosters] = useState<Material[]>([]);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
|
||||
// 新增:用于文件选择的ref
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||
const uploadOrderInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 更新电话获客设置
|
||||
const handlePhoneSettingsUpdate = () => {
|
||||
onChange({ ...formData, phoneSettings });
|
||||
setIsPhoneSettingsOpen(false);
|
||||
};
|
||||
|
||||
// 处理标签选择
|
||||
const handleTagToggle = (tagId: string) => {
|
||||
const newTags = selectedScenarioTags.includes(tagId)
|
||||
? selectedScenarioTags.filter((id: string) => id !== tagId)
|
||||
: [...selectedScenarioTags, tagId];
|
||||
|
||||
setSelectedScenarioTags(newTags);
|
||||
onChange({ ...formData, scenarioTags: newTags });
|
||||
};
|
||||
|
||||
// 处理通话类型选择
|
||||
const handleCallTypeChange = (type: string) => {
|
||||
// setPhoneCallType(type) // This line was removed as per the edit hint.
|
||||
onChange({ ...formData, phoneCallType: type });
|
||||
};
|
||||
|
||||
// 初始化时,如果没有选择场景,默认选择海报获客
|
||||
useEffect(() => {
|
||||
if (!formData.scenario) {
|
||||
onChange({ ...formData, scenario: "haibao" });
|
||||
}
|
||||
|
||||
// 检查是否已经有上传的订单文件
|
||||
if (formData.orderFileUploaded) {
|
||||
setOrderUploaded(true);
|
||||
}
|
||||
}, [formData, onChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const today = new Date().toLocaleDateString("zh-CN").replace(/\//g, "");
|
||||
const sceneItem = sceneList.find((v) => formData.scenario === v.id);
|
||||
onChange({ ...formData, name: `${sceneItem?.name || "海报"}${today}` });
|
||||
}, [sceneList]);
|
||||
|
||||
// 选中场景
|
||||
const handleScenarioSelect = (sceneId: number) => {
|
||||
onChange({ ...formData, scenario: sceneId });
|
||||
};
|
||||
|
||||
// 选中/取消标签
|
||||
const handleScenarioTagToggle = (tag: string) => {
|
||||
const newTags = selectedScenarioTags.includes(tag)
|
||||
? selectedScenarioTags.filter((t: string) => t !== tag)
|
||||
: [...selectedScenarioTags, tag];
|
||||
setSelectedScenarioTags(newTags);
|
||||
onChange({ ...formData, scenarioTags: newTags });
|
||||
};
|
||||
|
||||
// 添加自定义标签
|
||||
const handleAddCustomTag = () => {
|
||||
if (!customTagInput.trim()) return;
|
||||
const newTag = {
|
||||
id: `custom-${Date.now()}`,
|
||||
name: customTagInput.trim(),
|
||||
};
|
||||
const updatedCustomTags = [...customTags, newTag];
|
||||
setCustomTags(updatedCustomTags);
|
||||
setCustomTagInput("");
|
||||
onChange({ ...formData, customTags: updatedCustomTags });
|
||||
};
|
||||
|
||||
// 删除自定义标签
|
||||
const handleRemoveCustomTag = (tagId: string) => {
|
||||
const updatedCustomTags = customTags.filter((tag: any) => tag.id !== tagId);
|
||||
setCustomTags(updatedCustomTags);
|
||||
onChange({ ...formData, customTags: updatedCustomTags });
|
||||
// 同时从选中标签中移除
|
||||
const updatedSelectedTags = selectedScenarioTags.filter(
|
||||
(t: string) => t !== tagId
|
||||
);
|
||||
setSelectedScenarioTags(updatedSelectedTags);
|
||||
onChange({
|
||||
...formData,
|
||||
scenarioTags: updatedSelectedTags,
|
||||
customTags: updatedCustomTags,
|
||||
});
|
||||
};
|
||||
|
||||
// 新增:自定义上传图片
|
||||
const handleCustomPosterUpload = (urls: string[]) => {
|
||||
if (urls && urls.length > 0) {
|
||||
const newPoster: Material = {
|
||||
id: `custom-${Date.now()}`,
|
||||
name: "自定义海报",
|
||||
type: "poster",
|
||||
preview: urls[0],
|
||||
};
|
||||
setCustomPosters((prev) => [...prev, newPoster]);
|
||||
}
|
||||
};
|
||||
|
||||
// 新增:删除自定义海报
|
||||
const handleRemoveCustomPoster = (id: string) => {
|
||||
setCustomPosters((prev) => prev.filter((p) => p.id !== id));
|
||||
// 如果选中则取消选中
|
||||
if (selectedMaterials.some((m) => m.id === id)) {
|
||||
setSelectedMaterials([]);
|
||||
onChange({ ...formData, materials: [] });
|
||||
}
|
||||
};
|
||||
|
||||
// 修改:选中/取消选中海报
|
||||
const handleMaterialSelect = (material: Material) => {
|
||||
const isSelected = selectedMaterials.some((m) => m.id === material.id);
|
||||
if (isSelected) {
|
||||
setSelectedMaterials([]);
|
||||
onChange({ ...formData, materials: [] });
|
||||
} else {
|
||||
setSelectedMaterials([material]);
|
||||
onChange({ ...formData, materials: [material] });
|
||||
}
|
||||
};
|
||||
|
||||
// 移除已选素材
|
||||
const handleRemoveMaterial = (id: string) => {
|
||||
setSelectedMaterials([]);
|
||||
onChange({ ...formData, materials: [] });
|
||||
};
|
||||
|
||||
// 新增:全屏预览
|
||||
const handlePreviewImage = (url: string) => {
|
||||
setPreviewUrl(url);
|
||||
setIsPreviewOpen(true);
|
||||
};
|
||||
|
||||
// 账号多选切换
|
||||
const handleAccountToggle = (account: Account) => {
|
||||
const isSelected = selectedAccounts.some(
|
||||
(a: Account) => a.id === account.id
|
||||
);
|
||||
let newSelected;
|
||||
if (isSelected) {
|
||||
newSelected = selectedAccounts.filter(
|
||||
(a: Account) => a.id !== account.id
|
||||
);
|
||||
} else {
|
||||
newSelected = [...selectedAccounts, account];
|
||||
}
|
||||
setSelectedAccounts(newSelected);
|
||||
onChange({ ...formData, accounts: newSelected });
|
||||
};
|
||||
|
||||
// 移除已选账号
|
||||
const handleRemoveAccount = (id: string) => {
|
||||
const newSelected = selectedAccounts.filter((a: Account) => a.id !== id);
|
||||
setSelectedAccounts(newSelected);
|
||||
onChange({ ...formData, accounts: newSelected });
|
||||
};
|
||||
|
||||
// 处理文件导入
|
||||
const handleFileImport = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const content = e.target?.result as string;
|
||||
const rows = content.split("\n").filter((row) => row.trim());
|
||||
const tags = rows.slice(1).map((row) => {
|
||||
const [phone, wechat, source, orderAmount, orderDate] =
|
||||
row.split(",");
|
||||
return {
|
||||
phone: phone?.trim(),
|
||||
wechat: wechat?.trim(),
|
||||
source: source?.trim(),
|
||||
orderAmount: orderAmount ? Number(orderAmount) : undefined,
|
||||
orderDate: orderDate?.trim(),
|
||||
};
|
||||
});
|
||||
setImportedTags(tags);
|
||||
onChange({ ...formData, importedTags: tags });
|
||||
} catch (error) {
|
||||
// 可用 toast 提示
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
};
|
||||
|
||||
// 下载模板
|
||||
const handleDownloadTemplate = () => {
|
||||
const template =
|
||||
"电话号码,微信号,来源,订单金额,下单日期\n13800138000,wxid_123,抖音,99.00,2024-03-03";
|
||||
const blob = new Blob([template], { type: "text/csv" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "订单导入模板.csv";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// 修改订单表格上传逻辑,使用 uploadFile 公共方法
|
||||
const [orderUploaded, setOrderUploaded] = useState(false);
|
||||
|
||||
const handleOrderFileUpload = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
try {
|
||||
await uploadFile(file); // 默认接口即可
|
||||
setOrderUploaded(true);
|
||||
onChange({ ...formData, orderFileUploaded: true });
|
||||
// 可用 toast 或其它方式提示成功
|
||||
// alert('上传成功');
|
||||
} catch (err) {
|
||||
// 可用 toast 或其它方式提示失败
|
||||
// alert('上传失败');
|
||||
}
|
||||
event.target.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
// 账号弹窗关闭时清理搜索等状态
|
||||
const handleAccountDialogClose = () => {
|
||||
setIsAccountDialogOpen(false);
|
||||
// 可在此清理账号搜索等临时状态
|
||||
};
|
||||
// 素材弹窗关闭时清理搜索等状态
|
||||
const handleMaterialDialogClose = () => {
|
||||
setIsMaterialDialogOpen(false);
|
||||
// 可在此清理素材搜索等临时状态
|
||||
};
|
||||
// 订单导入弹窗关闭时清理文件输入等状态
|
||||
const handleImportDialogClose = () => {
|
||||
setIsImportDialogOpen(false);
|
||||
// 可在此清理文件输入等临时状态
|
||||
};
|
||||
// 电话获客弹窗关闭
|
||||
const handlePhoneSettingsDialogClose = () => {
|
||||
setIsPhoneSettingsOpen(false);
|
||||
};
|
||||
// 图片预览关闭
|
||||
const handleImagePreviewClose = () => {
|
||||
setIsPreviewOpen(false);
|
||||
};
|
||||
|
||||
// 当前选中的场景对象
|
||||
const currentScene = sceneList.find((s) => s.id === formData.scenario);
|
||||
//打开订单
|
||||
const openOrder =
|
||||
formData.scenario !== 2 ? { display: "none" } : { display: "block" };
|
||||
|
||||
const openPoster =
|
||||
formData.scenario !== 1 ? { display: "none" } : { display: "block" };
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 场景选择区块 */}
|
||||
{sceneLoading ? (
|
||||
<div style={{ padding: 16, textAlign: "center" }}>加载中...</div>
|
||||
) : (
|
||||
<Grid gutter={20} column={3}>
|
||||
{sceneList.map((scene) => (
|
||||
<Button
|
||||
key={scene.id}
|
||||
theme={formData.scenario === scene.id ? "primary" : "light"}
|
||||
onClick={() => handleScenarioSelect(scene.id)}
|
||||
size="small"
|
||||
>
|
||||
{scene.name.replace("获客", "")}
|
||||
</Button>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* 计划名称输入区 */}
|
||||
<div className="mb-4">计划名称</div>
|
||||
<div className="border p-2 mb-4">
|
||||
<input
|
||||
className="w-full"
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) =>
|
||||
onChange({ ...formData, name: String(e.target.value) })
|
||||
}
|
||||
placeholder="请输入计划名称"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">获客标签(可多选)</div>
|
||||
{/* 标签选择区块 */}
|
||||
{formData.scenario && (
|
||||
<div className="flex pb-4" style={{ flexWrap: "wrap", gap: 8 }}>
|
||||
{(currentScene?.scenarioTags || []).map((tag: string) => (
|
||||
<Tag
|
||||
key={tag}
|
||||
shape="round"
|
||||
theme={selectedScenarioTags.includes(tag) ? "primary" : "default"}
|
||||
onClick={() => handleScenarioTagToggle(tag)}
|
||||
style={{ marginBottom: 4 }}
|
||||
size="large"
|
||||
variant="light"
|
||||
>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
{/* 自定义标签 */}
|
||||
{customTags.map((tag: any) => (
|
||||
<Tag
|
||||
key={tag.id}
|
||||
shape="round"
|
||||
theme={
|
||||
selectedScenarioTags.includes(tag.id) ? "primary" : "default"
|
||||
}
|
||||
onClick={() => handleScenarioTagToggle(tag.id)}
|
||||
style={{ marginBottom: 4 }}
|
||||
closable
|
||||
size="large"
|
||||
variant="light"
|
||||
onClose={() => handleRemoveCustomTag(tag.id)}
|
||||
>
|
||||
{tag.name}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 自定义标签输入区 */}
|
||||
<div className="flex p" style={{ gap: 8 }}>
|
||||
<div className="border p-2 flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={customTagInput}
|
||||
onChange={(e) => setCustomTagInput(e.target.value)}
|
||||
placeholder="添加自定义标签"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="pt-1">
|
||||
<Button theme="primary" size="small" onClick={handleAddCustomTag}>
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 选素材 */}
|
||||
<div className="my-4" style={openPoster}>
|
||||
<div className="mb-4">选择海报</div>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(3, 1fr)",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{[...materials, ...customPosters].map((material) => {
|
||||
const isSelected = selectedMaterials.some(
|
||||
(m) => m.id === material.id
|
||||
);
|
||||
const isCustom = material.id.startsWith("custom-");
|
||||
return (
|
||||
<div
|
||||
key={material.id}
|
||||
style={{
|
||||
border: isSelected ? "2px solid #1890ff" : "2px solid #eee",
|
||||
borderRadius: 8,
|
||||
padding: 6,
|
||||
cursor: "pointer",
|
||||
background: isSelected ? "#e6f7ff" : "#fff",
|
||||
transition: "border 0.2s",
|
||||
textAlign: "center",
|
||||
position: "relative",
|
||||
height: 180 + 12, // 图片高度180+上下padding
|
||||
overflow: "hidden",
|
||||
minHeight: 192,
|
||||
}}
|
||||
onClick={() => handleMaterialSelect(material)}
|
||||
>
|
||||
{/* 预览按钮:自定义海报在左上,内置海报在右上 */}
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
left: isCustom ? 8 : "auto",
|
||||
right: isCustom ? "auto" : 8,
|
||||
background: "rgba(0,0,0,0.5)",
|
||||
border: "none",
|
||||
borderRadius: "50%",
|
||||
padding: 2,
|
||||
zIndex: 2,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePreviewImage(material.preview);
|
||||
}}
|
||||
>
|
||||
<EyeIcon style={{ color: "#fff", width: 18, height: 18 }} />
|
||||
</button>
|
||||
{/* 删除自定义海报按钮 */}
|
||||
{isCustom && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
width: 28,
|
||||
height: 28,
|
||||
background: "rgba(0,0,0,0.5)",
|
||||
border: "none",
|
||||
borderRadius: "50%",
|
||||
zIndex: 2,
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
lineHeight: 20,
|
||||
color: "#ffffff",
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveCustomPoster(material.id);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={material.preview}
|
||||
alt={material.name}
|
||||
style={{
|
||||
width: 100,
|
||||
height: 180,
|
||||
objectFit: "cover",
|
||||
borderRadius: 4,
|
||||
marginBottom: 0,
|
||||
display: "block",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
width: "100%",
|
||||
background: "rgba(0,0,0,0.5)",
|
||||
color: "#fff",
|
||||
fontSize: 14,
|
||||
padding: "4px 0",
|
||||
borderBottomLeftRadius: 4,
|
||||
borderBottomRightRadius: 4,
|
||||
textAlign: "center",
|
||||
zIndex: 3,
|
||||
}}
|
||||
>
|
||||
{material.name}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* 添加海报卡片 */}
|
||||
<div
|
||||
style={{
|
||||
border: "2px dashed #bbb",
|
||||
borderRadius: 8,
|
||||
padding: 6,
|
||||
cursor: "pointer",
|
||||
background: "#fafbfc",
|
||||
textAlign: "center",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: 190,
|
||||
}}
|
||||
onClick={() => uploadInputRef.current?.click()}
|
||||
>
|
||||
<span style={{ fontSize: 36, color: "#bbb", marginBottom: 8 }}>
|
||||
+
|
||||
</span>
|
||||
<span style={{ color: "#888" }}>添加海报</span>
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// 直接上传
|
||||
try {
|
||||
const url = await (
|
||||
await import("@/api/upload")
|
||||
).uploadImage(file);
|
||||
const newPoster = {
|
||||
id: `custom-${Date.now()}`,
|
||||
name: "自定义海报",
|
||||
type: "poster",
|
||||
preview: url,
|
||||
};
|
||||
setCustomPosters((prev) => [...prev, newPoster]);
|
||||
} catch (err) {
|
||||
// 可加toast提示
|
||||
}
|
||||
e.target.value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* 全屏图片预览 */}
|
||||
<ImageViewer
|
||||
images={previewUrl ? [previewUrl] : []}
|
||||
visible={isPreviewOpen}
|
||||
onClose={() => {
|
||||
setIsPreviewOpen(false);
|
||||
setPreviewUrl(null);
|
||||
}}
|
||||
index={0}
|
||||
/>
|
||||
</div>
|
||||
{/* 订单导入区块优化 */}
|
||||
<div style={openOrder} className="my-4">
|
||||
<div style={{ fontWeight: 500, marginBottom: 8 }}>订单表格上传</div>
|
||||
<div style={{ display: "flex", gap: 12, marginBottom: 4 }}>
|
||||
<Button
|
||||
type="button"
|
||||
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
||||
theme="default"
|
||||
onClick={handleDownloadTemplate}
|
||||
>
|
||||
<span className="iconfont" style={{ fontSize: 18 }}>
|
||||
↓
|
||||
</span>{" "}
|
||||
下载模板
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
...(orderUploaded && {
|
||||
backgroundColor: "#52c41a",
|
||||
color: "#fff",
|
||||
borderColor: "#52c41a",
|
||||
}),
|
||||
}}
|
||||
theme="default"
|
||||
onClick={() => uploadOrderInputRef.current?.click()}
|
||||
>
|
||||
<span className="iconfont" style={{ fontSize: 18 }}>
|
||||
{orderUploaded ? "✓" : "↑"}
|
||||
</span>{" "}
|
||||
{orderUploaded ? "已上传" : "上传订单表格"}
|
||||
<input
|
||||
ref={uploadOrderInputRef}
|
||||
type="file"
|
||||
accept=".csv, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleOrderFileUpload}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ color: "#888", fontSize: 13, marginBottom: 8 }}>
|
||||
支持 CSV、Excel 格式,上传后将文件保存到服务器
|
||||
</div>
|
||||
</div>
|
||||
{/* 电话获客设置区块,仅在选择电话获客场景时显示 */}
|
||||
{formData.scenario === 5 && (
|
||||
<div style={{ margin: "16px 0" }}>
|
||||
<div
|
||||
style={{
|
||||
background: "#f7f8fa",
|
||||
borderRadius: 10,
|
||||
padding: 20,
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.03)",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, fontSize: 16, marginBottom: 16 }}>
|
||||
电话获客设置
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<span>自动加好友</span>
|
||||
<Switch
|
||||
value={phoneSettings.autoAdd}
|
||||
onChange={(v) =>
|
||||
setPhoneSettings((s) => ({ ...s, autoAdd: v }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<span>语音转文字</span>
|
||||
<Switch
|
||||
value={phoneSettings.speechToText}
|
||||
onChange={(v) =>
|
||||
setPhoneSettings((s) => ({ ...s, speechToText: v }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<span>问题提取</span>
|
||||
<Switch
|
||||
value={phoneSettings.questionExtraction}
|
||||
onChange={(v) =>
|
||||
setPhoneSettings((s) => ({ ...s, questionExtraction: v }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 微信群设置区块,仅在选择微信群场景时显示 */}
|
||||
{formData.scenario === 7 && (
|
||||
<div style={{ margin: "16px 0" }}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Input
|
||||
value={weixinqunName}
|
||||
onChange={setWeixinqunName}
|
||||
placeholder="微信群名称"
|
||||
maxlength={20}
|
||||
onBlur={() => onChange({ ...formData, weixinqunName })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
value={weixinqunNotice}
|
||||
onChange={setWeixinqunNotice}
|
||||
placeholder="群公告/欢迎语"
|
||||
maxlength={50}
|
||||
onBlur={() => onChange({ ...formData, weixinqunNotice })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
margin: "16px 0",
|
||||
}}
|
||||
>
|
||||
<span>是否启用</span>
|
||||
<Switch
|
||||
value={formData.enabled}
|
||||
onChange={(value) => onChange({ ...formData, enabled: value })}
|
||||
/>
|
||||
</div>
|
||||
<Button className="mt-4" block theme="primary" onClick={onNext}>
|
||||
下一步
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
import { HelpCircle, MessageSquare, AlertCircle } from "lucide-react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { ChevronsUpDown } from "lucide-react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import DeviceSelection from "@/components/DeviceSelection";
|
||||
|
||||
interface FriendRequestSettingsProps {
|
||||
formData: any;
|
||||
onChange: (data: any) => void;
|
||||
onNext: () => void;
|
||||
onPrev: () => void;
|
||||
}
|
||||
|
||||
// 招呼语模板
|
||||
const greetingTemplates = [
|
||||
"你好,请通过",
|
||||
"你好,了解XX,请通过",
|
||||
"你好,我是XX产品的客服请通过",
|
||||
"你好,感谢关注我们的产品",
|
||||
"你好,很高兴为您服务",
|
||||
];
|
||||
|
||||
// 备注类型选项
|
||||
const remarkTypes = [
|
||||
{ value: "phone", label: "手机号" },
|
||||
{ value: "nickname", label: "昵称" },
|
||||
{ value: "source", label: "来源" },
|
||||
];
|
||||
|
||||
export function FriendRequestSettings({
|
||||
formData,
|
||||
onChange,
|
||||
onNext,
|
||||
onPrev,
|
||||
}: FriendRequestSettingsProps) {
|
||||
const [isTemplateDialogOpen, setIsTemplateDialogOpen] = useState(false);
|
||||
const [hasWarnings, setHasWarnings] = useState(false);
|
||||
const [selectedDevices, setSelectedDevices] = useState<any[]>(
|
||||
formData.selectedDevices || []
|
||||
);
|
||||
const [showRemarkTip, setShowRemarkTip] = useState(false);
|
||||
|
||||
// 获取场景标题
|
||||
const getScenarioTitle = () => {
|
||||
switch (formData.scenario) {
|
||||
case "douyin":
|
||||
return "抖音直播";
|
||||
case "xiaohongshu":
|
||||
return "小红书";
|
||||
case "weixinqun":
|
||||
return "微信群";
|
||||
case "gongzhonghao":
|
||||
return "公众号";
|
||||
default:
|
||||
return formData.name || "获客计划";
|
||||
}
|
||||
};
|
||||
|
||||
// 使用useEffect设置默认值
|
||||
useEffect(() => {
|
||||
if (!formData.greeting) {
|
||||
onChange({
|
||||
...formData,
|
||||
greeting: "你好,请通过",
|
||||
remarkType: "phone", // 默认选择手机号
|
||||
remarkFormat: `手机号+${getScenarioTitle()}`, // 默认备注格式
|
||||
addFriendInterval: 1,
|
||||
});
|
||||
}
|
||||
}, [formData, formData.greeting, onChange]);
|
||||
|
||||
// 检查是否有未完成的必填项
|
||||
useEffect(() => {
|
||||
const hasIncompleteFields = !formData.greeting?.trim();
|
||||
setHasWarnings(hasIncompleteFields);
|
||||
}, [formData]);
|
||||
|
||||
const handleTemplateSelect = (template: string) => {
|
||||
onChange({ ...formData, greeting: template });
|
||||
setIsTemplateDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
// 即使有警告也允许进入下一步,但会显示提示
|
||||
onNext();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<span className="font-medium text-base">选择设备</span>
|
||||
<div className="mt-2">
|
||||
<DeviceSelection
|
||||
selectedDevices={selectedDevices.map((d) => d.id)}
|
||||
onSelect={(deviceIds) => {
|
||||
const newSelectedDevices = deviceIds.map((id) => ({
|
||||
id,
|
||||
name: `设备 ${id}`,
|
||||
status: "online",
|
||||
}));
|
||||
setSelectedDevices(newSelectedDevices);
|
||||
onChange({ ...formData, device: deviceIds });
|
||||
}}
|
||||
placeholder="选择设备"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center space-x-2 mb-1 relative">
|
||||
<span className="font-medium text-base">好友备注</span>
|
||||
<span
|
||||
className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-gray-200 text-gray-500 text-xs cursor-pointer hover:bg-gray-300 transition-colors"
|
||||
onMouseEnter={() => setShowRemarkTip(true)}
|
||||
onMouseLeave={() => setShowRemarkTip(false)}
|
||||
onClick={() => setShowRemarkTip((v) => !v)}
|
||||
>
|
||||
?
|
||||
</span>
|
||||
{showRemarkTip && (
|
||||
<div className="absolute left-24 top-0 z-20 w-64 p-3 bg-white border border-gray-200 rounded shadow-lg text-sm text-gray-700">
|
||||
<div>设置添加好友时的备注格式</div>
|
||||
<div className="mt-2 text-xs text-gray-500">备注格式预览:</div>
|
||||
<div className="mt-1 text-blue-600">
|
||||
{formData.remarkType === "phone" &&
|
||||
`138****1234+${getScenarioTitle()}`}
|
||||
{formData.remarkType === "nickname" &&
|
||||
`小红书用户2851+${getScenarioTitle()}`}
|
||||
{formData.remarkType === "source" &&
|
||||
`抖音直播+${getScenarioTitle()}`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
value={formData.remarkType || "phone"}
|
||||
onChange={(e) =>
|
||||
onChange({ ...formData, remarkType: e.target.value })
|
||||
}
|
||||
className="w-full mt-2 p-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
{remarkTypes.map((type) => (
|
||||
<option key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-base">招呼语</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsTemplateDialogOpen(true)}
|
||||
className="text-blue-500"
|
||||
>
|
||||
<MessageSquare className="h-4 w-4 mr-2" />
|
||||
参考模板
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
value={formData.greeting}
|
||||
onChange={(e) =>
|
||||
onChange({ ...formData, greeting: e.target.value })
|
||||
}
|
||||
placeholder="请输入招呼语"
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="font-medium text-base">添加间隔</span>
|
||||
<div className="flex items-center space-x-2 mt-2">
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.addFriendInterval || 1}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...formData,
|
||||
addFriendInterval: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="w-10">分钟</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="font-medium text-base">允许加人的时间段</span>
|
||||
<div className="flex items-center space-x-2 mt-2">
|
||||
<Input
|
||||
type="time"
|
||||
value={formData.addFriendTimeStart || "09:00"}
|
||||
onChange={(e) =>
|
||||
onChange({ ...formData, addFriendTimeStart: e.target.value })
|
||||
}
|
||||
className="w-32"
|
||||
/>
|
||||
<span>至</span>
|
||||
<Input
|
||||
type="time"
|
||||
value={formData.addFriendTimeEnd || "18:00"}
|
||||
onChange={(e) =>
|
||||
onChange({ ...formData, addFriendTimeEnd: e.target.value })
|
||||
}
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasWarnings && (
|
||||
<Alert className="bg-amber-50 border-amber-200">
|
||||
<AlertCircle className="h-4 w-4 text-amber-500" />
|
||||
<AlertDescription>
|
||||
您有未完成的设置项,建议完善后再进入下一步。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button variant="outline" onClick={onPrev}>
|
||||
上一步
|
||||
</Button>
|
||||
<Button onClick={handleNext}>下一步</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={isTemplateDialogOpen}
|
||||
onOpenChange={setIsTemplateDialogOpen}
|
||||
>
|
||||
<DialogContent className="bg-white">
|
||||
<DialogHeader>
|
||||
<DialogTitle>招呼语模板</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
{greetingTemplates.map((template, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant="outline"
|
||||
className="w-full justify-start h-auto py-3 px-4"
|
||||
onClick={() => handleTemplateSelect(template)}
|
||||
>
|
||||
{template}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,698 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
MessageSquare,
|
||||
ImageIcon,
|
||||
Video,
|
||||
FileText,
|
||||
Link2,
|
||||
Users,
|
||||
AppWindowIcon as Window,
|
||||
Plus,
|
||||
X,
|
||||
Upload,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
|
||||
interface MessageContent {
|
||||
id: string;
|
||||
type: "text" | "image" | "video" | "file" | "miniprogram" | "link" | "group";
|
||||
content: string;
|
||||
sendInterval?: number;
|
||||
intervalUnit?: "seconds" | "minutes";
|
||||
scheduledTime?: {
|
||||
hour: number;
|
||||
minute: number;
|
||||
second: number;
|
||||
};
|
||||
title?: string;
|
||||
description?: string;
|
||||
address?: string;
|
||||
coverImage?: string;
|
||||
groupId?: string;
|
||||
linkUrl?: string;
|
||||
}
|
||||
|
||||
interface DayPlan {
|
||||
day: number;
|
||||
messages: MessageContent[];
|
||||
}
|
||||
|
||||
interface MessageSettingsProps {
|
||||
formData: any;
|
||||
onChange: (data: any) => void;
|
||||
onNext: () => void;
|
||||
onPrev: () => void;
|
||||
}
|
||||
|
||||
// 消息类型配置
|
||||
const messageTypes = [
|
||||
{ id: "text", icon: MessageSquare, label: "文本" },
|
||||
{ id: "image", icon: ImageIcon, label: "图片" },
|
||||
{ id: "video", icon: Video, label: "视频" },
|
||||
{ id: "file", icon: FileText, label: "文件" },
|
||||
{ id: "miniprogram", icon: Window, label: "小程序" },
|
||||
{ id: "link", icon: Link2, label: "链接" },
|
||||
{ id: "group", icon: Users, label: "邀请入群" },
|
||||
];
|
||||
|
||||
// 模拟群组数据
|
||||
const mockGroups = [
|
||||
{ id: "1", name: "产品交流群1", memberCount: 156 },
|
||||
{ id: "2", name: "产品交流群2", memberCount: 234 },
|
||||
{ id: "3", name: "产品交流群3", memberCount: 89 },
|
||||
];
|
||||
|
||||
export function MessageSettings({
|
||||
formData,
|
||||
onChange,
|
||||
onNext,
|
||||
onPrev,
|
||||
}: MessageSettingsProps) {
|
||||
const [dayPlans, setDayPlans] = useState<DayPlan[]>([
|
||||
{
|
||||
day: 0,
|
||||
messages: [
|
||||
{
|
||||
id: "1",
|
||||
type: "text",
|
||||
content: "",
|
||||
sendInterval: 5,
|
||||
intervalUnit: "seconds", // 默认改为秒
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
const [isAddDayPlanOpen, setIsAddDayPlanOpen] = useState(false);
|
||||
const [isGroupSelectOpen, setIsGroupSelectOpen] = useState(false);
|
||||
const [selectedGroupId, setSelectedGroupId] = useState("");
|
||||
|
||||
// 添加新消息
|
||||
const handleAddMessage = (dayIndex: number, type = "text") => {
|
||||
const updatedPlans = [...dayPlans];
|
||||
const newMessage: MessageContent = {
|
||||
id: Date.now().toString(),
|
||||
type: type as MessageContent["type"],
|
||||
content: "",
|
||||
};
|
||||
|
||||
if (dayPlans[dayIndex].day === 0) {
|
||||
// 即时消息使用间隔设置
|
||||
newMessage.sendInterval = 5;
|
||||
newMessage.intervalUnit = "seconds"; // 默认改为秒
|
||||
} else {
|
||||
// 非即时消息使用具体时间设置
|
||||
newMessage.scheduledTime = {
|
||||
hour: 9,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
};
|
||||
}
|
||||
|
||||
updatedPlans[dayIndex].messages.push(newMessage);
|
||||
setDayPlans(updatedPlans);
|
||||
onChange({ ...formData, messagePlans: updatedPlans });
|
||||
};
|
||||
|
||||
// 更新消息内容
|
||||
const handleUpdateMessage = (
|
||||
dayIndex: number,
|
||||
messageIndex: number,
|
||||
updates: Partial<MessageContent>
|
||||
) => {
|
||||
const updatedPlans = [...dayPlans];
|
||||
updatedPlans[dayIndex].messages[messageIndex] = {
|
||||
...updatedPlans[dayIndex].messages[messageIndex],
|
||||
...updates,
|
||||
};
|
||||
setDayPlans(updatedPlans);
|
||||
onChange({ ...formData, messagePlans: updatedPlans });
|
||||
};
|
||||
|
||||
// 删除消息
|
||||
const handleRemoveMessage = (dayIndex: number, messageIndex: number) => {
|
||||
const updatedPlans = [...dayPlans];
|
||||
updatedPlans[dayIndex].messages.splice(messageIndex, 1);
|
||||
setDayPlans(updatedPlans);
|
||||
onChange({ ...formData, messagePlans: updatedPlans });
|
||||
};
|
||||
|
||||
// 切换时间单位
|
||||
const toggleIntervalUnit = (dayIndex: number, messageIndex: number) => {
|
||||
const message = dayPlans[dayIndex].messages[messageIndex];
|
||||
const newUnit = message.intervalUnit === "minutes" ? "seconds" : "minutes";
|
||||
handleUpdateMessage(dayIndex, messageIndex, { intervalUnit: newUnit });
|
||||
};
|
||||
|
||||
// 添加新的天数计划
|
||||
const handleAddDayPlan = () => {
|
||||
const newDay = dayPlans.length;
|
||||
setDayPlans([
|
||||
...dayPlans,
|
||||
{
|
||||
day: newDay,
|
||||
messages: [
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
type: "text",
|
||||
content: "",
|
||||
scheduledTime: {
|
||||
hour: 9,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
setIsAddDayPlanOpen(false);
|
||||
toast({
|
||||
title: "添加成功",
|
||||
description: `已添加第${newDay}天的消息计划`,
|
||||
});
|
||||
};
|
||||
|
||||
// 选择群组
|
||||
const handleSelectGroup = (groupId: string) => {
|
||||
setSelectedGroupId(groupId);
|
||||
setIsGroupSelectOpen(false);
|
||||
toast({
|
||||
title: "选择成功",
|
||||
description: `已选择群组:${
|
||||
mockGroups.find((g) => g.id === groupId)?.name
|
||||
}`,
|
||||
});
|
||||
};
|
||||
|
||||
// 处理文件上传
|
||||
const handleFileUpload = (
|
||||
dayIndex: number,
|
||||
messageIndex: number,
|
||||
type: "image" | "video" | "file"
|
||||
) => {
|
||||
// 模拟文件上传
|
||||
toast({
|
||||
title: "上传成功",
|
||||
description: `${
|
||||
type === "image" ? "图片" : type === "video" ? "视频" : "文件"
|
||||
}上传成功`,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">消息设置</h2>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setIsAddDayPlanOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="0" className="w-full">
|
||||
<TabsList className="w-full bg-gray-50">
|
||||
{dayPlans.map((plan) => (
|
||||
<TabsTrigger
|
||||
key={plan.day}
|
||||
value={plan.day.toString()}
|
||||
className="flex-1"
|
||||
>
|
||||
{plan.day === 0 ? "即时消息" : `第${plan.day}天`}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{dayPlans.map((plan, dayIndex) => (
|
||||
<TabsContent key={plan.day} value={plan.day.toString()}>
|
||||
<div className="space-y-4">
|
||||
{plan.messages.map((message, messageIndex) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className="space-y-4 p-4 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
{plan.day === 0 ? (
|
||||
<>
|
||||
<div className="w-10">间隔</div>
|
||||
<div className="w-40">
|
||||
<Input
|
||||
type="number"
|
||||
value={String(message.sendInterval)}
|
||||
onChange={(e) =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
sendInterval: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
toggleIntervalUnit(dayIndex, messageIndex)
|
||||
}
|
||||
className="flex items-center space-x-1"
|
||||
>
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>
|
||||
{message.intervalUnit === "minutes"
|
||||
? "分钟"
|
||||
: "秒"}
|
||||
</span>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="font-medium">发送时间</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={23}
|
||||
value={String(message.scheduledTime?.hour || 0)}
|
||||
onChange={(e) =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
scheduledTime: {
|
||||
...(message.scheduledTime || {
|
||||
hour: 0,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
}),
|
||||
hour: Number(e.target.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
className="w-16"
|
||||
/>
|
||||
<span>:</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={59}
|
||||
value={String(
|
||||
message.scheduledTime?.minute || 0
|
||||
)}
|
||||
onChange={(e) =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
scheduledTime: {
|
||||
...(message.scheduledTime || {
|
||||
hour: 0,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
}),
|
||||
minute: Number(e.target.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
className="w-16"
|
||||
/>
|
||||
<span>:</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={59}
|
||||
value={String(
|
||||
message.scheduledTime?.second || 0
|
||||
)}
|
||||
onChange={(e) =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
scheduledTime: {
|
||||
...(message.scheduledTime || {
|
||||
hour: 0,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
}),
|
||||
second: Number(e.target.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
className="w-16"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleRemoveMessage(dayIndex, messageIndex)
|
||||
}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 bg-white p-2 rounded-lg">
|
||||
{messageTypes.map((type) => (
|
||||
<Button
|
||||
key={type.id}
|
||||
variant={
|
||||
message.type === type.id ? "default" : "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
type: type.id as any,
|
||||
})
|
||||
}
|
||||
className="flex flex-col items-center p-2 h-auto"
|
||||
>
|
||||
<type.icon className="h-4 w-4" />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{message.type === "text" && (
|
||||
<Textarea
|
||||
value={message.content}
|
||||
onChange={(e) =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
content: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="请输入消息内容"
|
||||
className="min-h-[100px]"
|
||||
/>
|
||||
)}
|
||||
|
||||
{message.type === "miniprogram" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium">
|
||||
标题<span className="text-red-500">*</span>
|
||||
</div>
|
||||
<Input
|
||||
value={message.title}
|
||||
onChange={(e) =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
title: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="请输入小程序标题"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium">描述</div>
|
||||
<Input
|
||||
value={message.description}
|
||||
onChange={(e) =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
description: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="请输入小程序描述"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium">
|
||||
链接<span className="text-red-500">*</span>
|
||||
</div>
|
||||
<Input
|
||||
value={message.address}
|
||||
onChange={(e) =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
address: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="请输入小程序路径"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium">
|
||||
封面<span className="text-red-500">*</span>
|
||||
</div>
|
||||
<div className="border-2 border-dashed rounded-lg p-4 text-center">
|
||||
{message.coverImage ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={message.coverImage || "/placeholder.svg"}
|
||||
alt="封面"
|
||||
className="max-w-[200px] mx-auto rounded-lg"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="absolute top-2 right-2"
|
||||
onClick={() =>
|
||||
handleUpdateMessage(
|
||||
dayIndex,
|
||||
messageIndex,
|
||||
{ coverImage: undefined }
|
||||
)
|
||||
}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-[120px]"
|
||||
onClick={() =>
|
||||
handleFileUpload(
|
||||
dayIndex,
|
||||
messageIndex,
|
||||
"image"
|
||||
)
|
||||
}
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
上传封面
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.type === "link" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium">
|
||||
标题<span className="text-red-500">*</span>
|
||||
</div>
|
||||
<Input
|
||||
value={message.title}
|
||||
onChange={(e) =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
title: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="请输入链接标题"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium">描述</div>
|
||||
<Input
|
||||
value={message.description}
|
||||
onChange={(e) =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
description: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="请输入链接描述"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium">
|
||||
链接<span className="text-red-500">*</span>
|
||||
</div>
|
||||
<Input
|
||||
value={message.linkUrl}
|
||||
onChange={(e) =>
|
||||
handleUpdateMessage(dayIndex, messageIndex, {
|
||||
linkUrl: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="请输入链接地址"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium">
|
||||
封面<span className="text-red-500">*</span>
|
||||
</div>
|
||||
<div className="border-2 border-dashed rounded-lg p-4 text-center">
|
||||
{message.coverImage ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={message.coverImage || "/placeholder.svg"}
|
||||
alt="封面"
|
||||
className="max-w-[200px] mx-auto rounded-lg"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="absolute top-2 right-2"
|
||||
onClick={() =>
|
||||
handleUpdateMessage(
|
||||
dayIndex,
|
||||
messageIndex,
|
||||
{ coverImage: undefined }
|
||||
)
|
||||
}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-[120px]"
|
||||
onClick={() =>
|
||||
handleFileUpload(
|
||||
dayIndex,
|
||||
messageIndex,
|
||||
"image"
|
||||
)
|
||||
}
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
上传封面
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.type === "group" && (
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium">
|
||||
选择群聊<span className="text-red-500">*</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={() => setIsGroupSelectOpen(true)}
|
||||
>
|
||||
{selectedGroupId
|
||||
? mockGroups.find((g) => g.id === selectedGroupId)
|
||||
?.name
|
||||
: "选择邀请入的群"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(message.type === "image" ||
|
||||
message.type === "video" ||
|
||||
message.type === "file") && (
|
||||
<div className="border-2 border-dashed rounded-lg p-4 text-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full h-[120px]"
|
||||
onClick={() =>
|
||||
handleFileUpload(
|
||||
dayIndex,
|
||||
messageIndex,
|
||||
message.type as any
|
||||
)
|
||||
}
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
上传
|
||||
{message.type === "image"
|
||||
? "图片"
|
||||
: message.type === "video"
|
||||
? "视频"
|
||||
: "文件"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleAddMessage(dayIndex)}
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
添加消息
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button variant="outline" onClick={onPrev}>
|
||||
上一步
|
||||
</Button>
|
||||
<Button onClick={onNext}>下一步</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 添加天数计划弹窗 */}
|
||||
<Dialog open={isAddDayPlanOpen} onOpenChange={setIsAddDayPlanOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加消息计划</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
选择要添加的消息计划类型
|
||||
</p>
|
||||
<Button onClick={handleAddDayPlan} className="w-full">
|
||||
添加第 {dayPlans.length} 天计划
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 选择群聊弹窗 */}
|
||||
<Dialog open={isGroupSelectOpen} onOpenChange={setIsGroupSelectOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>选择群聊</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<div className="space-y-2">
|
||||
{mockGroups.map((group) => (
|
||||
<div
|
||||
key={group.id}
|
||||
className={`p-4 rounded-lg cursor-pointer hover:bg-gray-100 ${
|
||||
selectedGroupId === group.id
|
||||
? "bg-blue-50 border border-blue-200"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => handleSelectGroup(group.id)}
|
||||
>
|
||||
<div className="font-medium">{group.name}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
成员数:{group.memberCount}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsGroupSelectOpen(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={() => setIsGroupSelectOpen(false)}>确定</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||||
import { Plus, X, Edit2, AlertCircle } from "lucide-react"
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||
|
||||
interface TagSettingsProps {
|
||||
formData: any
|
||||
onChange: (data: any) => void
|
||||
onNext?: () => void
|
||||
onPrev?: () => void
|
||||
}
|
||||
|
||||
interface Tag {
|
||||
id: string
|
||||
name: string
|
||||
keywords: string[]
|
||||
}
|
||||
|
||||
export function TagSettings({ formData, onChange, onNext, onPrev }: TagSettingsProps) {
|
||||
const [tags, setTags] = useState<Tag[]>(formData.tags || [])
|
||||
const [isAddTagDialogOpen, setIsAddTagDialogOpen] = useState(false)
|
||||
const [editingTag, setEditingTag] = useState<Tag | null>(null)
|
||||
const [newTagName, setNewTagName] = useState("")
|
||||
const [newTagKeywords, setNewTagKeywords] = useState("")
|
||||
const [hasWarnings, setHasWarnings] = useState(false)
|
||||
|
||||
// 当标签更新时,更新formData
|
||||
useEffect(() => {
|
||||
onChange({ ...formData, tags })
|
||||
}, [tags, onChange])
|
||||
|
||||
// 检查是否有标签
|
||||
useEffect(() => {
|
||||
setHasWarnings(tags.length === 0)
|
||||
}, [tags])
|
||||
|
||||
const handleAddTag = () => {
|
||||
if (!newTagName.trim()) return
|
||||
|
||||
const keywordsArray = newTagKeywords
|
||||
.split("\n")
|
||||
.map((k) => k.trim())
|
||||
.filter((k) => k !== "")
|
||||
|
||||
if (editingTag) {
|
||||
// 编辑现有标签
|
||||
setTags(
|
||||
tags.map((tag) => (tag.id === editingTag.id ? { ...tag, name: newTagName, keywords: keywordsArray } : tag)),
|
||||
)
|
||||
} else {
|
||||
// 添加新标签
|
||||
setTags([
|
||||
...tags,
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
name: newTagName,
|
||||
keywords: keywordsArray,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
setNewTagName("")
|
||||
setNewTagKeywords("")
|
||||
setEditingTag(null)
|
||||
setIsAddTagDialogOpen(false)
|
||||
}
|
||||
|
||||
const handleEditTag = (tag: Tag) => {
|
||||
setEditingTag(tag)
|
||||
setNewTagName(tag.name)
|
||||
setNewTagKeywords(tag.keywords.join("\n"))
|
||||
setIsAddTagDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleDeleteTag = (tagId: string) => {
|
||||
setTags(tags.filter((tag) => tag.id !== tagId))
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
// 确保onNext是一个函数
|
||||
if (typeof onNext === "function") {
|
||||
onNext()
|
||||
}
|
||||
}
|
||||
|
||||
const handlePrev = () => {
|
||||
// 确保onPrev是一个函数
|
||||
if (typeof onPrev === "function") {
|
||||
onPrev()
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setNewTagName("")
|
||||
setNewTagKeywords("")
|
||||
setEditingTag(null)
|
||||
setIsAddTagDialogOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full p-4 bg-gray-50">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Label className="text-base font-medium">标签列表</Label>
|
||||
<Button onClick={() => setIsAddTagDialogOpen(true)} size="sm">
|
||||
<Plus className="h-4 w-4 mr-1" /> 添加标签
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{tags.length === 0 ? (
|
||||
<div className="border rounded-md p-8 text-center text-gray-500">
|
||||
暂无标签,点击"添加标签"按钮来创建标签
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{tags.map((tag) => (
|
||||
<div key={tag.id} className="border rounded-md p-3 flex justify-between items-center">
|
||||
<div>
|
||||
<Badge className="mb-2">{tag.name}</Badge>
|
||||
<div className="text-sm text-gray-500">
|
||||
{tag.keywords.length > 0 ? `关键词: ${tag.keywords.join(", ")}` : "无关键词"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => handleEditTag(tag)}>
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDeleteTag(tag.id)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasWarnings && (
|
||||
<Alert variant="destructive" className="mt-4 bg-amber-50 border-amber-200">
|
||||
<AlertCircle className="h-4 w-4 text-amber-500" />
|
||||
<AlertDescription>建议添加至少一个标签,以便更好地管理客户。</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button variant="outline" onClick={handlePrev}>
|
||||
上一步
|
||||
</Button>
|
||||
<Button onClick={handleNext}>下一步</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={isAddTagDialogOpen} onOpenChange={setIsAddTagDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingTag ? "编辑标签" : "添加标签"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="bg-green-50 p-3 rounded-md text-sm text-green-700">
|
||||
设置关键字后,当评论/私信有涉及到关键字时自动添加标签
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder="请输入标签名称(最长6位字符)"
|
||||
value={newTagName}
|
||||
onChange={(e) => setNewTagName(e.target.value.slice(0, 6))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
placeholder="(非必填项) 请输入关键词,一行代表一个关键词"
|
||||
rows={5}
|
||||
value={newTagKeywords}
|
||||
onChange={(e) => setNewTagKeywords(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="sm:justify-end">
|
||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="button" onClick={handleAddTag}>
|
||||
确定
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,225 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import Layout from '@/components/Layout';
|
||||
import UnifiedHeader from '@/components/UnifiedHeader';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { useState } from 'react';
|
||||
// 复用mock数据生成
|
||||
import {
|
||||
generateMockDevices,
|
||||
generateMockWechatAccounts,
|
||||
generateMockCustomerServices,
|
||||
generateMockTrafficPools,
|
||||
generateMockUsers,
|
||||
RFM_SEGMENTS,
|
||||
TrafficUser,
|
||||
} from './TrafficPool';
|
||||
|
||||
const devices = generateMockDevices();
|
||||
const wechatAccounts = generateMockWechatAccounts(devices);
|
||||
const customerServices = generateMockCustomerServices();
|
||||
const trafficPools = generateMockTrafficPools();
|
||||
const users = generateMockUsers(devices, wechatAccounts, customerServices, trafficPools);
|
||||
|
||||
function getUserById(id: string): TrafficUser | undefined {
|
||||
return users.find((u: TrafficUser) => u.id === id);
|
||||
}
|
||||
|
||||
function getWechatAccount(accountId: string) {
|
||||
return wechatAccounts.find((acc) => acc.id === accountId);
|
||||
}
|
||||
function getCustomerService(csId: string) {
|
||||
return customerServices.find((cs) => cs.id === csId);
|
||||
}
|
||||
function getDevice(deviceId: string) {
|
||||
return devices.find((device) => device.id === deviceId);
|
||||
}
|
||||
function getPoolNames(poolIds: string[]) {
|
||||
return poolIds.map(id => trafficPools.find((pool) => pool.id === id)?.name).filter(Boolean).join(', ');
|
||||
}
|
||||
function formatDate(dateString: string) {
|
||||
if (!dateString) return '--';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('zh-CN');
|
||||
} catch (error) {
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
|
||||
export default function TrafficPoolDetail() {
|
||||
const { id } = useParams();
|
||||
const [activeTab, setActiveTab] = useState<'base' | 'journey' | 'tags'>('base');
|
||||
const user = getUserById(id as string);
|
||||
if (!user) {
|
||||
return <div className="p-8 text-center text-gray-400">未找到该用户</div>;
|
||||
}
|
||||
const wechatAccount = getWechatAccount(user.wechatAccountId);
|
||||
const customerService = getCustomerService(user.customerServiceId);
|
||||
const device = getDevice(user.deviceId);
|
||||
// RFM分段
|
||||
const rfmSegment = Object.values(RFM_SEGMENTS).find((seg: any) => seg.name === user.rfmScore.segment) as { name: string; color: string } | undefined;
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<UnifiedHeader title="用户详情" showBack />
|
||||
}
|
||||
>
|
||||
<div className="p-4 space-y-4">
|
||||
{/* 头像与基本信息 */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<Avatar className="h-16 w-16">
|
||||
<AvatarImage src={user.avatar} />
|
||||
<AvatarFallback>{user.nickname?.slice(0, 2) || '用户'}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-lg font-bold">{user.nickname}</span>
|
||||
{user.poolIds.length > 0 && (
|
||||
<Badge className="bg-purple-100 text-purple-700 border-0">{getPoolNames(user.poolIds)}</Badge>
|
||||
)}
|
||||
{user.status === 'added' && <Badge className="bg-green-100 text-green-700 border-0">优先添加</Badge>}
|
||||
</div>
|
||||
<div className="text-blue-600 text-sm font-medium">{user.wechatId}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 重要保持客户/优先添加等 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{rfmSegment && (
|
||||
<Badge className={rfmSegment.color + ' border-0'}>{rfmSegment.name}</Badge>
|
||||
)}
|
||||
{user.status === 'added' && <Badge className="bg-pink-100 text-pink-700 border-0">优先添加</Badge>}
|
||||
</div>
|
||||
{/* Tab栏 */}
|
||||
<div className="flex border-b mb-2">
|
||||
<div
|
||||
className={`px-4 py-2 font-medium cursor-pointer ${activeTab === 'base' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-400'}`}
|
||||
onClick={() => setActiveTab('base')}
|
||||
>基本信息</div>
|
||||
<div
|
||||
className={`px-4 py-2 font-medium cursor-pointer ${activeTab === 'journey' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-400'}`}
|
||||
onClick={() => setActiveTab('journey')}
|
||||
>用户旅程</div>
|
||||
<div
|
||||
className={`px-4 py-2 font-medium cursor-pointer ${activeTab === 'tags' ? 'border-b-2 border-blue-500 text-blue-600' : 'text-gray-400'}`}
|
||||
onClick={() => setActiveTab('tags')}
|
||||
>用户标签</div>
|
||||
</div>
|
||||
|
||||
{/* Tab内容区 */}
|
||||
{activeTab === 'base' && (
|
||||
<>
|
||||
{/* 关键信息卡片 */}
|
||||
<Card className="p-4 space-y-2">
|
||||
<div className="text-sm text-gray-500 font-medium mb-1">关键信息</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div>设备:{device?.name || '--'}</div>
|
||||
<div>微信号:{wechatAccount?.nickname || '--'}</div>
|
||||
<div>客服:{customerService?.name || '--'}</div>
|
||||
<div>添加时间:{formatDate(user.addTime)}</div>
|
||||
<div>最近互动:{formatDate(user.lastInteraction)}</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/* RFM评分卡片 */}
|
||||
<Card className="p-4 space-y-2">
|
||||
<div className="text-sm text-gray-500 font-medium mb-1">RFM评分</div>
|
||||
<div className="flex gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-lg font-bold text-blue-600">{user.rfmScore.recency}</div>
|
||||
<div className="text-xs text-gray-500">最近性(R)</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-green-600">{user.rfmScore.frequency}</div>
|
||||
<div className="text-xs text-gray-500">频率(F)</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-purple-600">{user.rfmScore.monetary}</div>
|
||||
<div className="text-xs text-gray-500">金额(M)</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/* 流量池按钮 */}
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline">潜在客户池</Button>
|
||||
<Button size="sm" variant="outline">流失预警池</Button>
|
||||
</div>
|
||||
{/* 统计数据卡片 */}
|
||||
<Card className="p-4 grid grid-cols-2 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-lg font-bold text-green-600">¥{user.totalSpent}</div>
|
||||
<div className="text-xs text-gray-500">总消费</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-blue-600">{user.interactionCount}</div>
|
||||
<div className="text-xs text-gray-500">互动次数</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-orange-600">{user.conversionRate}%</div>
|
||||
<div className="text-xs text-gray-500">转化率</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-red-600">{user.status === 'failed' ? '添加失败' : user.status === 'added' ? '添加成功' : '未添加'}</div>
|
||||
<div className="text-xs text-gray-500">添加状态</div>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
{activeTab === 'journey' && (
|
||||
<Card className="p-4 space-y-4">
|
||||
<div className="text-sm font-medium mb-2">互动记录</div>
|
||||
{user.interactions && user.interactions.length > 0 ? (
|
||||
user.interactions.slice(0, 4).map((it, idx) => (
|
||||
<div key={it.id} className="flex items-start gap-3 border-b last:border-b-0 pb-3 last:pb-0">
|
||||
<div className="mt-1">
|
||||
{it.type === 'click' && <span className="inline-block w-6 h-6 rounded-full bg-orange-50 text-orange-400 text-center">📱</span>}
|
||||
{it.type === 'message' && <span className="inline-block w-6 h-6 rounded-full bg-blue-50 text-blue-400 text-center">💬</span>}
|
||||
{it.type === 'purchase' && <span className="inline-block w-6 h-6 rounded-full bg-green-50 text-green-400 text-center">💲</span>}
|
||||
{it.type === 'view' && <span className="inline-block w-6 h-6 rounded-full bg-purple-50 text-purple-400 text-center">👁️</span>}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-gray-700">
|
||||
{it.type === 'click' && '点击行为'}
|
||||
{it.type === 'message' && '消息互动'}
|
||||
{it.type === 'purchase' && '购买行为'}
|
||||
{it.type === 'view' && '页面浏览'}
|
||||
</div>
|
||||
<div className="text-gray-500 text-sm mb-1">{it.content}{it.type === 'purchase' && it.value && <span className="text-green-600 font-bold ml-1">¥{it.value}</span>}</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1 whitespace-nowrap">{formatDate(it.timestamp)} {new Date(it.timestamp).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-gray-400 text-center">暂无互动记录</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
{activeTab === 'tags' && (
|
||||
<div className="space-y-4">
|
||||
<Card className="p-4">
|
||||
<div className="text-sm font-medium mb-2">用户标签</div>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{user.tags.map(tag => (
|
||||
<Badge key={tag.id} className="px-3 py-1 text-sm">{tag.name}</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-sm font-medium mb-2">价值标签</div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge className="bg-purple-100 text-purple-700 border-0">重要保持客户</Badge>
|
||||
<span className="text-gray-400 text-xs">RFM总分:{user.rfmScore.recency + user.rfmScore.frequency + user.rfmScore.monetary}/15</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-500 text-sm">价值等级:</span>
|
||||
<Badge className="bg-red-100 text-red-600 border-0">高价值</Badge>
|
||||
</div>
|
||||
</Card>
|
||||
<Button className="w-full mt-2" size="lg" variant="outline">➕ 添加新标签</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,971 +0,0 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import {
|
||||
ChevronLeft,
|
||||
Smartphone,
|
||||
Users,
|
||||
Star,
|
||||
Clock,
|
||||
MessageSquare,
|
||||
Shield,
|
||||
Info,
|
||||
UserPlus,
|
||||
Search,
|
||||
ChevronRight,
|
||||
Loader2
|
||||
} from 'lucide-react';
|
||||
import { useWechatAccount } from '@/contexts/WechatAccountContext';
|
||||
import { fetchWechatAccountSummary, fetchWechatFriends, fetchWechatFriendDetail } from '@/api/wechat-accounts';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import Layout from '@/components/Layout';
|
||||
import '@/components/Layout.css';
|
||||
|
||||
interface WechatAccountSummary {
|
||||
accountAge: string;
|
||||
activityLevel: {
|
||||
allTimes: number;
|
||||
dayTimes: number;
|
||||
};
|
||||
accountWeight: {
|
||||
scope: number;
|
||||
ageWeight: number;
|
||||
activityWeigth: number;
|
||||
restrictWeight: number;
|
||||
realNameWeight: number;
|
||||
};
|
||||
statistics: {
|
||||
todayAdded: number;
|
||||
addLimit: number;
|
||||
};
|
||||
restrictions: {
|
||||
id: number;
|
||||
level: string;
|
||||
reason: string;
|
||||
date: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface Friend {
|
||||
id: string;
|
||||
avatar: string;
|
||||
nickname: string;
|
||||
wechatId: string;
|
||||
remark: string;
|
||||
addTime: string;
|
||||
lastInteraction: string;
|
||||
tags: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
}>;
|
||||
region: string;
|
||||
source: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
interface WechatFriendDetail {
|
||||
id: number;
|
||||
avatar: string;
|
||||
nickname: string;
|
||||
region: string;
|
||||
wechatId: string;
|
||||
addDate: string;
|
||||
tags: string[];
|
||||
memo: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export default function WechatAccountDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { currentAccount } = useWechatAccount();
|
||||
|
||||
const [accountSummary, setAccountSummary] = useState<WechatAccountSummary | null>(null);
|
||||
const [showRestrictions, setShowRestrictions] = useState(false);
|
||||
const [showTransferConfirm, setShowTransferConfirm] = useState(false);
|
||||
const [showFriendDetail, setShowFriendDetail] = useState(false);
|
||||
const [selectedFriend, setSelectedFriend] = useState<Friend | null>(null);
|
||||
const [friendDetail, setFriendDetail] = useState<WechatFriendDetail | null>(null);
|
||||
const [isLoadingFriendDetail, setIsLoadingFriendDetail] = useState(false);
|
||||
const [friendDetailError, setFriendDetailError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
const [, setIsLoading] = useState(false);
|
||||
|
||||
// 好友列表相关状态
|
||||
const [friends, setFriends] = useState<Friend[]>([]);
|
||||
const [friendsPage, setFriendsPage] = useState(1);
|
||||
const [friendsTotal, setFriendsTotal] = useState(0);
|
||||
const [hasMoreFriends, setHasMoreFriends] = useState(true);
|
||||
const [isFetchingFriends, setIsFetchingFriends] = useState(false);
|
||||
const [hasFriendLoadError, setHasFriendLoadError] = useState(false);
|
||||
const [isFriendsEmpty, setIsFriendsEmpty] = useState(false);
|
||||
const friendsObserver = useRef<IntersectionObserver | null>(null);
|
||||
const friendsLoadingRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// 如果没有账号数据,返回上一页
|
||||
useEffect(() => {
|
||||
if (!currentAccount) {
|
||||
toast({
|
||||
title: "数据错误",
|
||||
description: "未找到账号信息,请重新选择",
|
||||
variant: "destructive"
|
||||
});
|
||||
navigate('/wechat-accounts');
|
||||
return;
|
||||
}
|
||||
}, [currentAccount, navigate, toast]);
|
||||
|
||||
// 获取账号概览信息
|
||||
const fetchAccountSummary = useCallback(async () => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetchWechatAccountSummary(id);
|
||||
|
||||
if (response && response.code === 200 && response.data) {
|
||||
setAccountSummary(response.data);
|
||||
} else {
|
||||
toast({
|
||||
title: "获取账号概览失败",
|
||||
description: response?.msg || "请稍后重试",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取账号概览失败:", error);
|
||||
toast({
|
||||
title: "获取账号概览失败",
|
||||
description: "请检查网络连接后重试",
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [id, toast]);
|
||||
|
||||
// 获取好友列表
|
||||
const fetchFriends = useCallback(async (page: number = 1, isNewSearch: boolean = false) => {
|
||||
console.log('fetchFriends called:', { page, isNewSearch, isFetchingFriends, id, searchQuery });
|
||||
if (!id || isFetchingFriends) {
|
||||
console.log('fetchFriends early return:', { id, isFetchingFriends });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsFetchingFriends(true);
|
||||
setHasFriendLoadError(false);
|
||||
console.log('Making API request for friends:', { id, page, searchQuery });
|
||||
const response = await fetchWechatFriends(id, page, 20, searchQuery);
|
||||
|
||||
console.log('API response:', response);
|
||||
|
||||
if (response && response.code === 200 && response.data) {
|
||||
const newFriends = response.data.list.map((friend: any) => ({
|
||||
id: friend.id.toString(),
|
||||
avatar: friend.avatar || "/placeholder.svg",
|
||||
nickname: friend.nickname || "未知用户",
|
||||
wechatId: friend.wechatId || "",
|
||||
remark: friend.memo || "",
|
||||
addTime: friend.createTime || new Date().toISOString().split('T')[0],
|
||||
lastInteraction: friend.lastInteraction || new Date().toISOString().split('T')[0],
|
||||
tags: friend.tags ? friend.tags.map((tag: string, index: number) => ({
|
||||
id: `tag-${index}`,
|
||||
name: tag,
|
||||
color: getRandomTagColor()
|
||||
})) : [],
|
||||
region: friend.region || "未知",
|
||||
source: friend.source || "未知",
|
||||
notes: friend.notes || ""
|
||||
}));
|
||||
|
||||
console.log('Processed friends:', { newFriendsCount: newFriends.length, isNewSearch });
|
||||
|
||||
if (isNewSearch) {
|
||||
setFriends(newFriends);
|
||||
// 如果是新搜索且数据为空,设置空状态
|
||||
if (newFriends.length === 0) {
|
||||
console.log('Setting empty state for new search');
|
||||
setIsFriendsEmpty(true);
|
||||
setHasMoreFriends(false);
|
||||
} else {
|
||||
console.log('Setting normal state for new search');
|
||||
setIsFriendsEmpty(false);
|
||||
setHasMoreFriends(newFriends.length === 20);
|
||||
}
|
||||
} else {
|
||||
setFriends(prev => [...prev, ...newFriends]);
|
||||
setHasMoreFriends(newFriends.length === 20);
|
||||
}
|
||||
|
||||
setFriendsTotal(response.data.total);
|
||||
setFriendsPage(page);
|
||||
} else {
|
||||
console.log('API response error:', response);
|
||||
setHasFriendLoadError(true);
|
||||
if (isNewSearch) {
|
||||
setFriends([]);
|
||||
setIsFriendsEmpty(true);
|
||||
setHasMoreFriends(false);
|
||||
}
|
||||
toast({
|
||||
title: "获取好友列表失败",
|
||||
description: response?.msg || "请稍后重试",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取好友列表失败:", error);
|
||||
setHasFriendLoadError(true);
|
||||
if (isNewSearch) {
|
||||
setFriends([]);
|
||||
setIsFriendsEmpty(true);
|
||||
setHasMoreFriends(false);
|
||||
}
|
||||
toast({
|
||||
title: "获取好友列表失败",
|
||||
description: "请检查网络连接后重试",
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
console.log('Setting isFetchingFriends to false');
|
||||
setIsFetchingFriends(false);
|
||||
}
|
||||
}, [id, searchQuery, toast, isFetchingFriends]);
|
||||
|
||||
// 初始化数据
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
fetchAccountSummary();
|
||||
if (activeTab === "friends") {
|
||||
fetchFriends(1, true);
|
||||
}
|
||||
}
|
||||
}, [id, fetchAccountSummary]);
|
||||
|
||||
// 监听标签切换
|
||||
useEffect(() => {
|
||||
if (activeTab === "friends" && id) {
|
||||
// 重置空状态,允许重新加载
|
||||
setIsFriendsEmpty(false);
|
||||
setHasFriendLoadError(false);
|
||||
fetchFriends(1, true);
|
||||
}
|
||||
}, [activeTab, id, fetchFriends]);
|
||||
|
||||
// 无限滚动加载好友
|
||||
useEffect(() => {
|
||||
if (!friendsLoadingRef.current || !hasMoreFriends || isFetchingFriends || isFriendsEmpty) return;
|
||||
|
||||
friendsObserver.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasMoreFriends && !isFetchingFriends && !isFriendsEmpty) {
|
||||
fetchFriends(friendsPage + 1, false);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
friendsObserver.current.observe(friendsLoadingRef.current);
|
||||
|
||||
return () => {
|
||||
if (friendsObserver.current) {
|
||||
friendsObserver.current.disconnect();
|
||||
}
|
||||
};
|
||||
}, [hasMoreFriends, isFetchingFriends, friendsPage, fetchFriends, isFriendsEmpty]);
|
||||
|
||||
// 工具函数
|
||||
const getRandomTagColor = (): string => {
|
||||
const colors = [
|
||||
"bg-blue-100 text-blue-800",
|
||||
"bg-green-100 text-green-800",
|
||||
"bg-red-100 text-red-800",
|
||||
"bg-pink-100 text-pink-800",
|
||||
"bg-emerald-100 text-emerald-800",
|
||||
"bg-amber-100 text-amber-800",
|
||||
];
|
||||
return colors[Math.floor(Math.random() * colors.length)];
|
||||
};
|
||||
|
||||
const calculateAccountAge = (registerTime: string) => {
|
||||
const registerDate = new Date(registerTime);
|
||||
const now = new Date();
|
||||
const diffTime = Math.abs(now.getTime() - registerDate.getTime());
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
const years = Math.floor(diffDays / 365);
|
||||
const months = Math.floor((diffDays % 365) / 30);
|
||||
return { years, months };
|
||||
};
|
||||
|
||||
const formatAccountAge = (age: { years: number; months: number }) => {
|
||||
if (age.years > 0) {
|
||||
return `${age.years}年${age.months}个月`;
|
||||
}
|
||||
return `${age.months}个月`;
|
||||
};
|
||||
|
||||
const getWeightColor = (weight: number) => {
|
||||
if (weight >= 80) return "text-green-600";
|
||||
if (weight >= 60) return "text-yellow-600";
|
||||
return "text-red-600";
|
||||
};
|
||||
|
||||
const getWeightDescription = (weight: number) => {
|
||||
if (weight >= 80) return "账号质量优秀,可以正常使用";
|
||||
if (weight >= 60) return "账号质量良好,需要注意使用频率";
|
||||
return "账号质量较差,建议谨慎使用";
|
||||
};
|
||||
|
||||
const handleTransferFriends = () => {
|
||||
setShowTransferConfirm(true);
|
||||
};
|
||||
|
||||
const confirmTransferFriends = () => {
|
||||
toast({
|
||||
title: "好友转移计划已创建",
|
||||
description: "请在场景获客中查看详情",
|
||||
});
|
||||
setShowTransferConfirm(false);
|
||||
navigate("/scenarios");
|
||||
};
|
||||
|
||||
// const handleBack = () => {
|
||||
// clearCurrentAccount();
|
||||
// navigate('/wechat-accounts');
|
||||
// };
|
||||
|
||||
const handleFriendClick = async (friend: Friend) => {
|
||||
setSelectedFriend(friend);
|
||||
setShowFriendDetail(true);
|
||||
setIsLoadingFriendDetail(true);
|
||||
setFriendDetailError(null);
|
||||
|
||||
try {
|
||||
const response = await fetchWechatFriendDetail(friend.id);
|
||||
if (response && response.code === 200 && response.data) {
|
||||
setFriendDetail(response.data);
|
||||
} else {
|
||||
setFriendDetailError(response?.msg || "获取好友详情失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取好友详情失败:", error);
|
||||
setFriendDetailError("网络错误,请稍后重试");
|
||||
} finally {
|
||||
setIsLoadingFriendDetail(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getRestrictionLevelColor = (level: string) => {
|
||||
switch (level) {
|
||||
case "high":
|
||||
return "text-red-600";
|
||||
case "medium":
|
||||
return "text-yellow-600";
|
||||
default:
|
||||
return "text-gray-600";
|
||||
}
|
||||
};
|
||||
|
||||
const formatDateTime = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
}).replace(/\//g, '-');
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
// 搜索时重置空状态
|
||||
setIsFriendsEmpty(false);
|
||||
setHasFriendLoadError(false);
|
||||
fetchFriends(1, true);
|
||||
};
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value);
|
||||
};
|
||||
|
||||
if (!currentAccount) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-20">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<PageHeader
|
||||
title="账号详情"
|
||||
defaultBackPath="/wechat-accounts"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="bg-gradient-to-b from-blue-50 to-white">
|
||||
<div className="p-4 space-y-4">
|
||||
{/* 账号基本信息卡片 */}
|
||||
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="relative">
|
||||
<img
|
||||
src={currentAccount.avatar || "/placeholder.svg"}
|
||||
alt={currentAccount.nickname}
|
||||
className="w-16 h-16 rounded-full ring-4 ring-offset-2 ring-blue-500/20"
|
||||
/>
|
||||
<div className={`absolute -bottom-1 -right-1 w-4 h-4 rounded-full border-2 border-white ${
|
||||
currentAccount.status === "normal" ? "bg-green-500" : "bg-red-500"
|
||||
}`}></div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h2 className="text-xl font-semibold truncate max-w-[200px]">{currentAccount.nickname}</h2>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
currentAccount.status === "normal"
|
||||
? "bg-green-500 text-white"
|
||||
: "bg-red-500 text-white"
|
||||
}`}>
|
||||
{currentAccount.status === "normal" ? "正常" : "异常"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">微信号:{currentAccount.wechatAccount}</p>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
className="px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors text-sm flex items-center"
|
||||
onClick={() => navigate(`/devices/${currentAccount.deviceId}`)}
|
||||
>
|
||||
<Smartphone className="w-4 h-4 mr-2" />
|
||||
{currentAccount.deviceName || '未命名设备'}
|
||||
</button>
|
||||
<button
|
||||
className="px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors text-sm flex items-center"
|
||||
onClick={handleTransferFriends}
|
||||
>
|
||||
<UserPlus className="w-4 h-4 mr-2" />
|
||||
好友转移
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 标签页 */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-100">
|
||||
<div className="flex border-b border-gray-200">
|
||||
<button
|
||||
className={`flex-1 py-3 px-4 text-sm font-medium transition-colors ${
|
||||
activeTab === "overview"
|
||||
? "text-blue-600 border-b-2 border-blue-600"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
onClick={() => handleTabChange("overview")}
|
||||
>
|
||||
账号概览
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 py-3 px-4 text-sm font-medium transition-colors ${
|
||||
activeTab === "friends"
|
||||
? "text-blue-600 border-b-2 border-blue-600"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
onClick={() => handleTabChange("friends")}
|
||||
>
|
||||
好友列表{activeTab === "friends" && friendsTotal > 0 ? ` (${friendsTotal.toLocaleString()})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
{activeTab === "overview" ? (
|
||||
<div className="space-y-4">
|
||||
{/* 账号基础信息 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="bg-gradient-to-br from-blue-50 to-indigo-50 p-3 rounded-xl border border-blue-100 shadow-sm hover:shadow-md transition-all duration-300">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="p-1.5 bg-blue-100 rounded-lg">
|
||||
<Clock className="w-4 h-4 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-blue-700">账号年龄</div>
|
||||
{accountSummary && (
|
||||
<div className="text-xs text-blue-600">
|
||||
注册于 {new Date(accountSummary.accountAge).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{accountSummary && (
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-blue-800">
|
||||
{formatAccountAge(calculateAccountAge(accountSummary.accountAge))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-green-50 to-emerald-50 p-3 rounded-xl border border-green-100 shadow-sm hover:shadow-md transition-all duration-300">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="p-1.5 bg-green-100 rounded-lg">
|
||||
<MessageSquare className="w-4 h-4 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-green-700">活跃程度</div>
|
||||
{accountSummary && (
|
||||
<div className="text-xs text-green-600">
|
||||
总聊天 {accountSummary.activityLevel.allTimes.toLocaleString()} 次
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{accountSummary && (
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-green-800">
|
||||
{accountSummary.activityLevel.dayTimes.toLocaleString()}
|
||||
<span className="text-sm text-green-600 ml-1">次/天</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 账号权重评估 */}
|
||||
{accountSummary && (
|
||||
<div className="bg-gradient-to-br from-amber-50 to-yellow-50 p-4 rounded-xl border border-amber-100 shadow-sm hover:shadow-md transition-all duration-300">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="p-1.5 bg-amber-100 rounded-lg">
|
||||
<Star className="w-4 h-4 text-amber-600" />
|
||||
</div>
|
||||
<span className="font-semibold text-amber-800 text-base">账号权重评估</span>
|
||||
</div>
|
||||
<div className={`flex items-center space-x-2 px-3 py-1.5 rounded-full ${getWeightColor(accountSummary.accountWeight.scope).includes('green') ? 'bg-green-100 text-green-700' : getWeightColor(accountSummary.accountWeight.scope).includes('yellow') ? 'bg-yellow-100 text-yellow-700' : 'bg-red-100 text-red-700'}`}>
|
||||
<span className="text-xl font-bold">{accountSummary.accountWeight.scope}</span>
|
||||
<span className="text-xs font-medium">分</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-amber-700 mb-4 bg-amber-100 px-3 py-2 rounded-lg border border-amber-200">
|
||||
{getWeightDescription(accountSummary.accountWeight.scope)}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center">
|
||||
<span className="flex-shrink-0 w-16 text-xs font-medium text-amber-700">账号年龄</span>
|
||||
<div className="flex-1 mx-3 bg-amber-200 rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
className="bg-gradient-to-r from-amber-400 to-amber-600 h-2 rounded-full transition-all duration-500"
|
||||
style={{ width: `${accountSummary.accountWeight.ageWeight}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="flex-shrink-0 w-10 text-xs font-medium text-amber-700 text-right">{accountSummary.accountWeight.ageWeight}%</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span className="flex-shrink-0 w-16 text-xs font-medium text-amber-700">活跃度</span>
|
||||
<div className="flex-1 mx-3 bg-amber-200 rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
className="bg-gradient-to-r from-amber-400 to-amber-600 h-2 rounded-full transition-all duration-500"
|
||||
style={{ width: `${accountSummary.accountWeight.activityWeigth}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="flex-shrink-0 w-10 text-xs font-medium text-amber-700 text-right">{accountSummary.accountWeight.activityWeigth}%</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span className="flex-shrink-0 w-16 text-xs font-medium text-amber-700">限制影响</span>
|
||||
<div className="flex-1 mx-3 bg-amber-200 rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
className="bg-gradient-to-r from-amber-400 to-amber-600 h-2 rounded-full transition-all duration-500"
|
||||
style={{ width: `${accountSummary.accountWeight.restrictWeight}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="flex-shrink-0 w-10 text-xs font-medium text-amber-700 text-right">{accountSummary.accountWeight.restrictWeight}%</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span className="flex-shrink-0 w-16 text-xs font-medium text-amber-700">实名认证</span>
|
||||
<div className="flex-1 mx-3 bg-amber-200 rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
className="bg-gradient-to-r from-amber-400 to-amber-600 h-2 rounded-full transition-all duration-500"
|
||||
style={{ width: `${accountSummary.accountWeight.realNameWeight}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="flex-shrink-0 w-10 text-xs font-medium text-amber-700 text-right">{accountSummary.accountWeight.realNameWeight}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 添加好友统计 */}
|
||||
{accountSummary && (
|
||||
<div className="bg-gradient-to-br from-purple-50 to-indigo-50 p-4 rounded-xl border border-purple-100 shadow-sm hover:shadow-md transition-all duration-300">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="p-1.5 bg-purple-100 rounded-lg">
|
||||
<Users className="w-4 h-4 text-purple-600" />
|
||||
</div>
|
||||
<span className="font-semibold text-purple-800 text-base">添加好友统计</span>
|
||||
</div>
|
||||
<div className="relative group">
|
||||
<div className="p-1.5 bg-purple-100 rounded-lg cursor-help">
|
||||
<Info className="w-3 h-3 text-purple-600" />
|
||||
</div>
|
||||
<div className="absolute bottom-full right-0 mb-2 px-2 py-1.5 text-xs bg-purple-800 text-white rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap shadow-lg z-10">
|
||||
根据账号权重计算每日可添加好友数量
|
||||
<div className="absolute top-full right-4 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-purple-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between bg-white p-3 rounded-lg border border-purple-200">
|
||||
<span className="text-xs font-medium text-purple-700">今日已添加</span>
|
||||
<span className="text-xl font-bold text-purple-800">{accountSummary.statistics.todayAdded}</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-purple-700 font-medium">添加进度</span>
|
||||
<span className="text-purple-800 font-semibold">
|
||||
{accountSummary.statistics.todayAdded}/{accountSummary.statistics.addLimit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-purple-200 rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
className="bg-gradient-to-r from-purple-400 to-purple-600 h-2 rounded-full transition-all duration-500"
|
||||
style={{ width: `${Math.min((accountSummary.statistics.todayAdded / accountSummary.statistics.addLimit) * 100, 100)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-purple-700 bg-purple-100 px-3 py-2 rounded-lg border border-purple-200">
|
||||
根据当前账号权重 <span className="font-semibold text-purple-800">({accountSummary.accountWeight.scope}分)</span>,每日最多可添加{" "}
|
||||
<span className="font-bold text-purple-800">{accountSummary.statistics.addLimit.toLocaleString()}</span>{" "}
|
||||
个好友
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 限制记录 */}
|
||||
{accountSummary && (
|
||||
<div className="bg-gradient-to-br from-red-50 to-pink-50 p-4 rounded-xl border border-red-100 shadow-sm hover:shadow-md transition-all duration-300">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="p-1.5 bg-red-100 rounded-lg">
|
||||
<Shield className="w-4 h-4 text-red-600" />
|
||||
</div>
|
||||
<span className="font-semibold text-red-800 text-base">限制记录</span>
|
||||
</div>
|
||||
{accountSummary.restrictions.length > 0 && (
|
||||
<button
|
||||
className="px-3 py-1.5 bg-red-100 hover:bg-red-200 text-red-700 rounded-full text-xs font-medium transition-colors duration-200 border border-red-200"
|
||||
onClick={() => setShowRestrictions(true)}
|
||||
>
|
||||
共 {accountSummary.restrictions.length} 次
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{accountSummary.restrictions.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{accountSummary.restrictions.slice(0, 2).map((record) => (
|
||||
<div key={record.id} className="bg-white p-3 rounded-lg border border-red-200 hover:border-red-300 transition-colors">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className={`text-xs font-medium ${getRestrictionLevelColor(record.level)}`}>
|
||||
{record.reason}
|
||||
</span>
|
||||
<span className="text-xs text-red-600 bg-red-50 px-2 py-0.5 rounded-full">
|
||||
{formatDateTime(record.date)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-red-500">
|
||||
限制时间:{formatDateTime(record.date)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-6">
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<div className="p-2 bg-green-100 rounded-full">
|
||||
<Shield className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
<div className="text-green-700 font-medium text-sm">暂无风险记录</div>
|
||||
<div className="text-xs text-green-600">请继续保持良好的使用习惯</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* 搜索栏 */}
|
||||
<div className="flex items-center space-x-2 bg-white rounded-lg">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
placeholder="搜索好友昵称/微信号/备注/标签"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
className="w-full pl-9 pr-3 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 好友列表 */}
|
||||
<div className="space-y-2 min-h-[200px]">
|
||||
{isFetchingFriends && friends.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
</div>
|
||||
) : hasFriendLoadError ? (
|
||||
<div className="text-center py-8 text-red-500">
|
||||
<p>加载失败,请稍后重试</p>
|
||||
<button
|
||||
className="mt-4 px-4 py-2 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors text-sm"
|
||||
onClick={() => {
|
||||
setHasFriendLoadError(false);
|
||||
fetchFriends(1, true);
|
||||
}}
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
) : isFriendsEmpty ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<p>未找到匹配的好友</p>
|
||||
{searchQuery && (
|
||||
<button
|
||||
className="mt-4 px-4 py-2 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors text-sm"
|
||||
onClick={() => {
|
||||
setSearchQuery("");
|
||||
setIsFriendsEmpty(false);
|
||||
fetchFriends(1, true);
|
||||
}}
|
||||
>
|
||||
清除搜索条件
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : friends.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">暂无好友数据</div>
|
||||
) : (
|
||||
<>
|
||||
{friends.map((friend) => (
|
||||
<div
|
||||
key={friend.id}
|
||||
className="flex items-center p-3 bg-white border rounded-lg hover:bg-gray-50 cursor-pointer transition-colors duration-200"
|
||||
onClick={() => handleFriendClick(friend)}
|
||||
>
|
||||
<img
|
||||
src={friend.avatar}
|
||||
alt={friend.nickname}
|
||||
className="w-10 h-10 rounded-full mr-3"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-medium truncate max-w-[180px]">
|
||||
{friend.nickname}
|
||||
{friend.remark && <span className="text-gray-500 ml-1 truncate">({friend.remark})</span>}
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 truncate">{friend.wechatId}</div>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{friend.tags?.map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-blue-100 text-blue-800"
|
||||
>
|
||||
{typeof tag === 'string' ? tag : tag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{hasMoreFriends && !isFriendsEmpty && (
|
||||
<div ref={friendsLoadingRef} className="flex justify-center py-4">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-blue-500" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> {/* 这里补上,闭合415行的<div className='p-4 space-y-4'> */}
|
||||
|
||||
{/* 限制记录详情弹窗 */}
|
||||
{showRestrictions && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl p-6 max-w-md w-full max-h-[80vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">限制记录详情</h3>
|
||||
<button
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
onClick={() => setShowRestrictions(false)}
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-4">每次限制恢复时间为24小时</p>
|
||||
<div className="space-y-4">
|
||||
{(accountSummary?.restrictions && accountSummary.restrictions.length > 0) ? (
|
||||
accountSummary.restrictions.map((record) => (
|
||||
<div key={record.id} className="border-b pb-4 last:border-0">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className={`text-sm ${getRestrictionLevelColor(record.level)}`}>
|
||||
{record.reason}
|
||||
</div>
|
||||
<span className="px-2 py-1 border border-gray-200 rounded text-xs">{formatDateTime(record.date)}</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">恢复时间:{formatDateTime(record.date)}</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8 text-green-500">
|
||||
暂无风险记录,请继续保持
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 好友转移确认对话框 */}
|
||||
{showTransferConfirm && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl p-6 max-w-md w-full">
|
||||
<h3 className="text-lg font-semibold mb-2">好友转移确认</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">即将导出该微信号的好友列表,用于创建新的获客计划</p>
|
||||
<div className="py-4">
|
||||
<div className="flex items-center space-x-3 p-3 bg-blue-50 rounded-lg">
|
||||
<img
|
||||
src={currentAccount.avatar}
|
||||
alt={currentAccount.nickname}
|
||||
className="w-10 h-10 rounded-full"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium">{currentAccount.nickname}</div>
|
||||
<div className="text-sm text-gray-500">{currentAccount.wechatId}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 text-sm text-gray-500">
|
||||
<p>• 将导出该账号下的所有好友信息</p>
|
||||
<p>• 好友信息将用于创建新的订单获客计划</p>
|
||||
<p>• 导出过程中请勿关闭页面</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
className="flex-1 px-4 py-2 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
onClick={() => setShowTransferConfirm(false)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
onClick={confirmTransferFriends}
|
||||
>
|
||||
确认转移
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 好友详情对话框 */}
|
||||
{showFriendDetail && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl p-6 max-w-md w-full max-h-[80vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">好友详情</h3>
|
||||
<button
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
onClick={() => setShowFriendDetail(false)}
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoadingFriendDetail ? (
|
||||
<div className="flex justify-center items-center py-10">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
</div>
|
||||
) : friendDetailError ? (
|
||||
<div className="text-center py-8 text-red-500">
|
||||
<p>{friendDetailError}</p>
|
||||
<button
|
||||
className="mt-4 px-4 py-2 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors text-sm"
|
||||
onClick={() => handleFriendClick(selectedFriend!)}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : friendDetail && selectedFriend ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<img
|
||||
src={selectedFriend.avatar}
|
||||
alt={selectedFriend.nickname}
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
<div>
|
||||
<h4 className="font-medium">{selectedFriend.nickname}</h4>
|
||||
<p className="text-sm text-gray-500">微信号:{selectedFriend.wechatId}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">地区</span>
|
||||
<span className="text-sm">{friendDetail.region || "未知"}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">添加时间</span>
|
||||
<span className="text-sm">{friendDetail.addDate}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">来源</span>
|
||||
<span className="text-sm">{friendDetail.source || "未知"}</span>
|
||||
</div>
|
||||
{friendDetail.memo && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">备注</span>
|
||||
<span className="text-sm">{friendDetail.memo}</span>
|
||||
</div>
|
||||
)}
|
||||
{friendDetail.tags && friendDetail.tags.length > 0 && (
|
||||
<div>
|
||||
<span className="text-sm text-gray-500 block mb-2">标签</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{friendDetail.tags.map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,383 +0,0 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Search, RefreshCw, Loader2 } from 'lucide-react';
|
||||
import { fetchWechatAccountList, transformWechatAccount } from '@/api/wechat-accounts';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import { useWechatAccount } from '@/contexts/WechatAccountContext';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import Layout from '@/components/Layout';
|
||||
import '@/components/Layout.css';
|
||||
|
||||
interface WechatAccount {
|
||||
id: string;
|
||||
wechatId: string;
|
||||
wechatAccount: string;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
remainingAdds: number;
|
||||
todayAdded: number;
|
||||
status: "normal" | "abnormal";
|
||||
friendCount: number;
|
||||
deviceName: string;
|
||||
lastActive: string;
|
||||
maxDailyAdds: number;
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
export default function WechatAccounts() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { setCurrentAccount } = useWechatAccount();
|
||||
const [accounts, setAccounts] = useState<WechatAccount[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isTransferDialogOpen, setIsTransferDialogOpen] = useState(false);
|
||||
const [selectedAccount, setSelectedAccount] = useState<WechatAccount | null>(null);
|
||||
const [totalAccounts, setTotalAccounts] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const accountsPerPage = 10;
|
||||
const mounted = useRef(false);
|
||||
|
||||
// 获取微信账号列表
|
||||
const fetchAccounts = useCallback(async (page: number = 1, keyword: string = "") => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetchWechatAccountList({
|
||||
page,
|
||||
limit: accountsPerPage,
|
||||
keyword,
|
||||
sort: 'id',
|
||||
order: 'desc'
|
||||
});
|
||||
|
||||
if (response && response.code === 200 && response.data) {
|
||||
// 转换数据格式
|
||||
const wechatAccounts = response.data.list.map((item: any) => transformWechatAccount(item));
|
||||
setAccounts(wechatAccounts);
|
||||
setTotalAccounts(response.data.total);
|
||||
} else {
|
||||
toast({
|
||||
title: "获取微信账号失败",
|
||||
description: response?.msg || "请稍后再试",
|
||||
variant: "destructive"
|
||||
});
|
||||
setAccounts([]);
|
||||
setTotalAccounts(0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取微信账号列表失败:", error);
|
||||
toast({
|
||||
title: "获取微信账号失败",
|
||||
description: "请检查网络连接或稍后再试",
|
||||
variant: "destructive"
|
||||
});
|
||||
setAccounts([]);
|
||||
setTotalAccounts(0);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [accountsPerPage, toast]);
|
||||
|
||||
// 初始化数据加载
|
||||
useEffect(() => {
|
||||
if (!mounted.current) {
|
||||
mounted.current = true;
|
||||
fetchAccounts(currentPage, searchQuery);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 处理页码和搜索变化
|
||||
useEffect(() => {
|
||||
if (mounted.current) {
|
||||
fetchAccounts(currentPage, searchQuery);
|
||||
}
|
||||
}, [currentPage, searchQuery, fetchAccounts]);
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = () => {
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// 刷新处理
|
||||
const handleRefresh = async () => {
|
||||
try {
|
||||
setIsRefreshing(true);
|
||||
// 重新获取微信账号列表数据
|
||||
await fetchAccounts(currentPage, searchQuery);
|
||||
toast({
|
||||
title: "刷新成功",
|
||||
description: "微信账号列表已更新"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("刷新微信账号状态失败:", error);
|
||||
toast({
|
||||
title: "刷新失败",
|
||||
description: "请检查网络连接或稍后再试",
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(totalAccounts / accountsPerPage);
|
||||
|
||||
const handleTransferFriends = (account: WechatAccount) => {
|
||||
setSelectedAccount(account);
|
||||
setIsTransferDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmTransfer = async () => {
|
||||
if (!selectedAccount) return;
|
||||
|
||||
try {
|
||||
// 实际实现好友转移功能,这里需要另一个账号作为目标
|
||||
// 现在只是模拟效果
|
||||
toast({
|
||||
title: "好友转移计划已创建",
|
||||
description: "请在场景获客中查看详情",
|
||||
});
|
||||
setIsTransferDialogOpen(false);
|
||||
navigate("/scenarios");
|
||||
} catch (error) {
|
||||
console.error("好友转移失败:", error);
|
||||
toast({
|
||||
title: "好友转移失败",
|
||||
description: "请稍后再试",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<PageHeader
|
||||
title="微信号"
|
||||
defaultBackPath="/"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="bg-gray-50">
|
||||
<div className="p-4">
|
||||
{/* 搜索和操作栏 */}
|
||||
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100 mb-4 ">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="w-4 h-4 absolute left-3 top-3 text-gray-400" />
|
||||
<input
|
||||
className="w-full pl-9 pr-3 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="搜索微信号/昵称"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="p-2 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 账号列表 */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center items-center py-20">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
|
||||
</div>
|
||||
) : accounts.length === 0 ? (
|
||||
<div className="text-center py-20 text-gray-500">
|
||||
<p>暂无微信账号数据</p>
|
||||
<button
|
||||
className="mt-4 px-4 py-2 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{accounts.map((account) => (
|
||||
<div
|
||||
key={account.id}
|
||||
className="bg-white p-4 rounded-xl shadow-sm border border-gray-100 hover:shadow-lg transition-all cursor-pointer"
|
||||
onClick={() => {
|
||||
// 使用Context存储数据,而不是URL参数
|
||||
setCurrentAccount({
|
||||
id: account.id,
|
||||
avatar: account.avatar,
|
||||
nickname: account.nickname,
|
||||
status: account.status,
|
||||
wechatId: account.wechatId,
|
||||
wechatAccount: account.wechatAccount,
|
||||
deviceName: account.deviceName,
|
||||
deviceId: account.deviceId,
|
||||
});
|
||||
navigate(`/wechat-accounts/${account.id}`);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="relative">
|
||||
<img
|
||||
src={account.avatar || "/placeholder.svg"}
|
||||
alt={account.nickname}
|
||||
className="w-12 h-12 rounded-full ring-2 ring-offset-2 ring-blue-500/20"
|
||||
/>
|
||||
<div className={`absolute -bottom-1 -right-1 w-4 h-4 rounded-full border-2 border-white ${
|
||||
account.status === "normal" ? "bg-green-500" : "bg-red-500"
|
||||
}`}></div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h3 className="font-medium truncate max-w-[180px]">{account.nickname}</h3>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
account.status === "normal"
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-red-100 text-red-700"
|
||||
}`}>
|
||||
{account.status === "normal" ? "正常" : "异常"}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleTransferFriends(account);
|
||||
}}
|
||||
>
|
||||
{/* ArrowRightLeft className="h-4 w-4" */}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-gray-500 space-y-1">
|
||||
<div className="truncate">微信号:{account.wechatAccount}</div>
|
||||
<div className="flex items-center justify-between flex-wrap gap-1">
|
||||
<div>好友数量:{account.friendCount}</div>
|
||||
<div className="text-green-600">今日新增:+{account.todayAdded}</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>今日可添加:</span>
|
||||
<span className="font-medium">{account.remainingAdds}</span>
|
||||
<div className="relative group">
|
||||
{/* AlertCircle className="h-4 w-4 text-gray-400" */}
|
||||
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-2 py-1 text-xs bg-gray-800 text-white rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap">
|
||||
每日最多添加 {account.maxDailyAdds} 个好友
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">
|
||||
{account.todayAdded}/{account.maxDailyAdds}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all"
|
||||
style={{ width: `${(account.todayAdded / account.maxDailyAdds) * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-gray-500 pt-2 flex-wrap gap-1">
|
||||
<div className="truncate max-w-[150px]">所属设备:{account.deviceName || '未知设备'}</div>
|
||||
<div className="whitespace-nowrap">最后活跃:{account.lastActive}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分页 */}
|
||||
{!isLoading && accounts.length > 0 && totalPages > 1 && (
|
||||
<div className="mt-6 flex justify-center">
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
className="px-3 py-2 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<div className="flex items-center space-x-1">
|
||||
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
|
||||
let pageToShow = i + 1;
|
||||
if (currentPage > 3 && totalPages > 5) {
|
||||
pageToShow = Math.min(currentPage - 2 + i, totalPages);
|
||||
if (pageToShow > totalPages - 4) {
|
||||
pageToShow = totalPages - 4 + i;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={pageToShow}
|
||||
className={`px-3 py-2 rounded-lg transition-colors ${
|
||||
currentPage === pageToShow
|
||||
? "bg-blue-600 text-white"
|
||||
: "border border-gray-200 hover:bg-gray-50"
|
||||
}`}
|
||||
onClick={() => setCurrentPage(pageToShow)}
|
||||
>
|
||||
{pageToShow}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
className="px-3 py-2 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 好友转移确认对话框 */}
|
||||
{isTransferDialogOpen && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl p-6 max-w-md w-full">
|
||||
<h3 className="text-lg font-semibold mb-4">好友转移确认</h3>
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
确认要将 {selectedAccount?.nickname} 的 {selectedAccount?.friendCount}{" "}
|
||||
个好友转移到场景获客吗?系统将自动创建一个获客计划。
|
||||
</p>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
className="flex-1 px-4 py-2 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
onClick={() => setIsTransferDialogOpen(false)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
onClick={handleConfirmTransfer}
|
||||
>
|
||||
确认转移
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ThumbsUp, MessageSquare, Send, Users, Share2, Brain, BarChart2, LineChart, Clock } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import Layout from '@/components/Layout';
|
||||
import UnifiedHeader from '@/components/UnifiedHeader';
|
||||
import BottomNav from '@/components/BottomNav';
|
||||
import '@/components/Layout.css';
|
||||
|
||||
export default function Workspace() {
|
||||
// 模拟任务数据
|
||||
const taskStats = {
|
||||
total: 42,
|
||||
inProgress: 12,
|
||||
completed: 30,
|
||||
todayTasks: 12,
|
||||
activityRate: 98,
|
||||
};
|
||||
|
||||
// 常用功能 - 保持原有排列
|
||||
const commonFeatures = [
|
||||
{
|
||||
id: "auto-like",
|
||||
name: "自动点赞",
|
||||
description: "智能自动点赞互动",
|
||||
icon: <ThumbsUp className="h-5 w-5 text-red-500" />,
|
||||
path: "/workspace/auto-like",
|
||||
bgColor: "bg-red-100",
|
||||
isNew: true,
|
||||
},
|
||||
{
|
||||
id: "moments-sync",
|
||||
name: "朋友圈同步",
|
||||
description: "自动同步朋友圈内容",
|
||||
icon: <Clock className="h-5 w-5 text-purple-500" />,
|
||||
path: "/workspace/moments-sync",
|
||||
bgColor: "bg-purple-100",
|
||||
},
|
||||
{
|
||||
id: "group-push",
|
||||
name: "群消息推送",
|
||||
description: "智能群发助手",
|
||||
icon: <Send className="h-5 w-5 text-orange-500" />,
|
||||
path: "/workspace/group-push",
|
||||
bgColor: "bg-orange-100",
|
||||
},
|
||||
{
|
||||
id: "auto-group",
|
||||
name: "自动建群",
|
||||
description: "智能拉好友建群",
|
||||
icon: <Users className="h-5 w-5 text-green-500" />,
|
||||
path: "/workspace/auto-group",
|
||||
bgColor: "bg-green-100",
|
||||
},
|
||||
{
|
||||
id: "traffic-distribution",
|
||||
name: "流量分发",
|
||||
description: "管理流量分发和分配",
|
||||
icon: <Share2 className="h-5 w-5 text-blue-500" />,
|
||||
path: "/workspace/traffic-distribution",
|
||||
bgColor: "bg-blue-100",
|
||||
},
|
||||
{
|
||||
id: "ai-assistant",
|
||||
name: "AI对话助手",
|
||||
description: "智能回复,提高互动质量",
|
||||
icon: <MessageSquare className="h-5 w-5 text-blue-500" />,
|
||||
path: "/workspace/ai-assistant",
|
||||
bgColor: "bg-blue-100",
|
||||
isNew: true,
|
||||
},
|
||||
];
|
||||
|
||||
// AI智能助手
|
||||
const aiFeatures = [
|
||||
{
|
||||
id: "ai-analyzer",
|
||||
name: "AI数据分析",
|
||||
description: "智能分析客户行为特征",
|
||||
icon: <BarChart2 className="h-5 w-5 text-indigo-500" />,
|
||||
path: "/workspace/ai-analyzer",
|
||||
bgColor: "bg-indigo-100",
|
||||
isNew: true,
|
||||
},
|
||||
{
|
||||
id: "ai-strategy",
|
||||
name: "AI策略优化",
|
||||
description: "智能优化获客策略",
|
||||
icon: <Brain className="h-5 w-5 text-cyan-500" />,
|
||||
path: "/workspace/ai-strategy",
|
||||
bgColor: "bg-cyan-100",
|
||||
isNew: true,
|
||||
},
|
||||
{
|
||||
id: "ai-forecast",
|
||||
name: "AI销售预测",
|
||||
description: "智能预测销售趋势",
|
||||
icon: <LineChart className="h-5 w-5 text-amber-500" />,
|
||||
path: "/workspace/ai-forecast",
|
||||
bgColor: "bg-amber-100",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<UnifiedHeader
|
||||
title="工作台"
|
||||
titleColor="blue"
|
||||
defaultBackPath="/"
|
||||
/>
|
||||
}
|
||||
footer={<BottomNav />}
|
||||
>
|
||||
<div className="bg-gray-50 min-h-screen pb-20">
|
||||
<div className="p-4">
|
||||
<div className="max-w-md mx-auto">
|
||||
{/* 任务统计卡片 */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-6">
|
||||
<Card className="overflow-hidden">
|
||||
<CardContent className="p-4">
|
||||
<div className="text-sm text-gray-500">总任务数</div>
|
||||
<div className="text-3xl font-bold text-blue-500 mt-1">{taskStats.total}</div>
|
||||
<Progress value={(taskStats.inProgress / taskStats.total) * 100} className="h-2 mt-2 bg-blue-100" />
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
进行中: {taskStats.inProgress} / 已完成: {taskStats.completed}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<CardContent className="p-4">
|
||||
<div className="text-sm text-gray-500">今日任务</div>
|
||||
<div className="text-3xl font-bold text-green-500 mt-1">{taskStats.todayTasks}</div>
|
||||
<div className="flex items-center mt-2">
|
||||
<svg
|
||||
className="w-4 h-4 text-green-500 mr-1"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M3 12H7L10 19L14 5L17 12H21"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm">活跃度 {taskStats.activityRate}%</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 常用功能 */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-medium mb-3">常用功能</h2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{commonFeatures.map((feature) => (
|
||||
<Link to={feature.path} key={feature.id}>
|
||||
<Card className="overflow-hidden hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-4">
|
||||
<div className={`w-10 h-10 rounded-lg ${feature.bgColor} flex items-center justify-center mb-3`}>
|
||||
{feature.icon}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="font-medium">{feature.name}</div>
|
||||
{feature.isNew && (
|
||||
<Badge className="ml-2 bg-blue-100 text-blue-600 hover:bg-blue-100 border-0">New</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">{feature.description}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI智能助手 */}
|
||||
<div>
|
||||
<h2 className="text-lg font-medium mb-3">AI 智能助手</h2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{aiFeatures.map((feature) => (
|
||||
<Link to={feature.path} key={feature.id}>
|
||||
<Card className="overflow-hidden hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-4">
|
||||
<div className={`w-10 h-10 rounded-lg ${feature.bgColor} flex items-center justify-center mb-3`}>
|
||||
{feature.icon}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="font-medium">{feature.name}</div>
|
||||
{feature.isNew && (
|
||||
<Badge className="ml-2 bg-blue-100 text-blue-600 hover:bg-blue-100 border-0">New</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">{feature.description}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,386 +0,0 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
|
||||
import {
|
||||
Send,
|
||||
Settings,
|
||||
Trash2,
|
||||
Copy,
|
||||
MoreVertical,
|
||||
Bot,
|
||||
User,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
// import { Input } from '@/components/ui/input';
|
||||
// import { Badge } from '@/components/ui/badge';
|
||||
// import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import Layout from '@/components/Layout';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import BottomNav from '@/components/BottomNav';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import '@/components/Layout.css';
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
type: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
isTyping?: boolean;
|
||||
}
|
||||
|
||||
interface Conversation {
|
||||
id: string;
|
||||
title: string;
|
||||
messages: Message[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export default function AIAssistant() {
|
||||
const { toast } = useToast();
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [currentConversation, setCurrentConversation] = useState<Conversation | null>(null);
|
||||
const [inputMessage, setInputMessage] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 模拟初始对话
|
||||
useEffect(() => {
|
||||
const initialConversation: Conversation = {
|
||||
id: '1',
|
||||
title: '新对话',
|
||||
messages: [
|
||||
{
|
||||
id: '1',
|
||||
type: 'assistant',
|
||||
content: '您好!我是AI助手,可以帮助您处理各种问题。请问有什么可以帮您的吗?',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
setConversations([initialConversation]);
|
||||
setCurrentConversation(initialConversation);
|
||||
}, []);
|
||||
|
||||
// 自动滚动到底部
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [currentConversation?.messages]);
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if (!inputMessage.trim() || !currentConversation) return;
|
||||
|
||||
const userMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
type: 'user',
|
||||
content: inputMessage,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
// 添加用户消息
|
||||
const updatedConversation = {
|
||||
...currentConversation,
|
||||
messages: [...currentConversation.messages, userMessage],
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
setCurrentConversation(updatedConversation);
|
||||
setConversations(prev =>
|
||||
prev.map(conv =>
|
||||
conv.id === currentConversation.id ? updatedConversation : conv
|
||||
)
|
||||
);
|
||||
|
||||
setInputMessage('');
|
||||
setIsLoading(true);
|
||||
|
||||
// 模拟AI回复
|
||||
setTimeout(() => {
|
||||
const assistantMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
type: 'assistant',
|
||||
content: generateAIResponse(inputMessage),
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
const finalConversation = {
|
||||
...updatedConversation,
|
||||
messages: [...updatedConversation.messages, assistantMessage],
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
setCurrentConversation(finalConversation);
|
||||
setConversations(prev =>
|
||||
prev.map(conv =>
|
||||
conv.id === currentConversation.id ? finalConversation : conv
|
||||
)
|
||||
);
|
||||
setIsLoading(false);
|
||||
}, 1000 + Math.random() * 2000);
|
||||
};
|
||||
|
||||
const generateAIResponse = (userMessage: string): string => {
|
||||
const responses = [
|
||||
'我理解您的问题,让我为您详细解答...',
|
||||
'这是一个很好的问题!根据我的分析...',
|
||||
'我可以帮您处理这个问题,建议您...',
|
||||
'基于您提供的信息,我认为...',
|
||||
'让我为您提供一些建议和解决方案...',
|
||||
];
|
||||
return responses[Math.floor(Math.random() * responses.length)];
|
||||
};
|
||||
|
||||
const handleNewConversation = () => {
|
||||
const newConversation: Conversation = {
|
||||
id: Date.now().toString(),
|
||||
title: '新对话',
|
||||
messages: [
|
||||
{
|
||||
id: '1',
|
||||
type: 'assistant',
|
||||
content: '您好!我是AI助手,可以帮助您处理各种问题。请问有什么可以帮您的吗?',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
setConversations(prev => [newConversation, ...prev]);
|
||||
setCurrentConversation(newConversation);
|
||||
};
|
||||
|
||||
const handleDeleteConversation = (conversationId: string) => {
|
||||
if (!window.confirm('确定要删除这个对话吗?')) return;
|
||||
|
||||
setConversations(prev => prev.filter(conv => conv.id !== conversationId));
|
||||
if (currentConversation?.id === conversationId) {
|
||||
const remainingConversations = conversations.filter(conv => conv.id !== conversationId);
|
||||
setCurrentConversation(remainingConversations[0] || null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyMessage = (content: string) => {
|
||||
navigator.clipboard.writeText(content);
|
||||
toast({
|
||||
title: '已复制',
|
||||
description: '消息内容已复制到剪贴板',
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (date: Date) => {
|
||||
return date.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<PageHeader
|
||||
title="AI对话助手"
|
||||
defaultBackPath="/workspace"
|
||||
rightContent={
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setShowSettings(!showSettings)}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button onClick={handleNewConversation}>
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
新对话
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
}
|
||||
footer={<BottomNav />}
|
||||
>
|
||||
<div className="bg-gray-50 min-h-screen pb-20">
|
||||
<div className="flex h-full">
|
||||
{/* 侧边栏 - 对话列表 */}
|
||||
<div className="w-80 bg-white border-r border-gray-200 flex flex-col">
|
||||
<div className="p-4 border-b">
|
||||
<h2 className="text-lg font-medium">对话历史</h2>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{conversations.map((conversation) => (
|
||||
<div
|
||||
key={conversation.id}
|
||||
className={`p-4 border-b cursor-pointer hover:bg-gray-50 ${
|
||||
currentConversation?.id === conversation.id ? 'bg-blue-50 border-blue-200' : ''
|
||||
}`}
|
||||
onClick={() => setCurrentConversation(conversation)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-sm truncate">{conversation.title}</h3>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{conversation.messages.length} 条消息
|
||||
</p>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
|
||||
<MoreVertical className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleDeleteConversation(conversation.id)}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主聊天区域 */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{currentConversation ? (
|
||||
<>
|
||||
{/* 消息列表 */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{currentConversation.messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex ${message.type === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div className={`max-w-xs lg:max-w-md ${message.type === 'user' ? 'order-2' : 'order-1'}`}>
|
||||
<div className={`flex items-start space-x-2 ${message.type === 'user' ? 'flex-row-reverse space-x-reverse' : ''}`}>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||
message.type === 'user'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 text-gray-600'
|
||||
}`}>
|
||||
{message.type === 'user' ? (
|
||||
<User className="h-4 w-4" />
|
||||
) : (
|
||||
<Bot className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<div className={`flex-1 ${message.type === 'user' ? 'text-right' : ''}`}>
|
||||
<Card className={`inline-block ${message.type === 'user' ? 'bg-blue-500 text-white' : 'bg-white'}`}>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 ml-2 opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<MoreVertical className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleCopyMessage(message.content)}>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
复制
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className={`text-xs mt-2 ${message.type === 'user' ? 'text-blue-100' : 'text-gray-500'}`}>
|
||||
{formatTime(message.timestamp)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-xs lg:max-w-md">
|
||||
<div className="flex items-start space-x-2">
|
||||
<div className="w-8 h-8 rounded-full bg-gray-200 text-gray-600 flex items-center justify-center">
|
||||
<Bot className="h-4 w-4" />
|
||||
</div>
|
||||
<Card className="bg-white">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex space-x-1">
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">AI正在思考...</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* 输入区域 */}
|
||||
<div className="border-t bg-white p-4">
|
||||
<div className="flex items-end space-x-2">
|
||||
<div className="flex-1">
|
||||
<Textarea
|
||||
placeholder="输入您的问题..."
|
||||
value={inputMessage}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setInputMessage(e.target.value)}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
}}
|
||||
className="min-h-[60px] max-h-[120px] resize-none"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSendMessage}
|
||||
disabled={!inputMessage.trim() || isLoading}
|
||||
className="h-[60px] px-4"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Bot className="h-16 w-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">开始新的对话</h3>
|
||||
<p className="text-gray-500 mb-4">AI助手将帮助您解决各种问题</p>
|
||||
<Button onClick={handleNewConversation}>
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
开始对话
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,454 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Plus,
|
||||
Filter,
|
||||
Search,
|
||||
RefreshCw,
|
||||
MoreVertical,
|
||||
Clock,
|
||||
Edit,
|
||||
Trash2,
|
||||
Eye,
|
||||
Copy,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Settings,
|
||||
Calendar,
|
||||
Users,
|
||||
UserPlus,
|
||||
// CheckCircle,
|
||||
// XCircle,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import Layout from '@/components/Layout';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import BottomNav from '@/components/BottomNav';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import '@/components/Layout.css';
|
||||
|
||||
interface GroupTask {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'running' | 'paused' | 'completed';
|
||||
deviceCount: number;
|
||||
targetFriends: number;
|
||||
createdGroups: number;
|
||||
lastCreateTime: string;
|
||||
createTime: string;
|
||||
creator: string;
|
||||
createInterval: number;
|
||||
maxGroupsPerDay: number;
|
||||
timeRange: { start: string; end: string };
|
||||
groupSize: { min: number; max: number };
|
||||
targetTags: string[];
|
||||
groupNameTemplate: string;
|
||||
groupDescription: string;
|
||||
}
|
||||
|
||||
// CardMenu组件,参考AutoLike实现
|
||||
function CardMenu({ onView, onEdit, onCopy, onDelete }: { onView: () => void; onEdit: () => void; onCopy: () => void; onDelete: () => void; }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const menuRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
if (open) document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<button onClick={() => setOpen((v) => !v)} style={{ background: "none", border: "none", padding: 0, margin: 0, cursor: "pointer" }}>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: 28,
|
||||
background: "#fff",
|
||||
borderRadius: 8,
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
|
||||
zIndex: 100,
|
||||
minWidth: 120,
|
||||
padding: 4,
|
||||
}}
|
||||
>
|
||||
<div onClick={() => { onView(); setOpen(false); }} style={{ padding: 8, cursor: "pointer", display: "flex", alignItems: "center", borderRadius: 6, fontSize: 14, gap: 6, transition: "background .2s" }} onMouseOver={e => (e.currentTarget as HTMLDivElement).style.background="#f5f5f5"} onMouseOut={e => (e.currentTarget as HTMLDivElement).style.background=""}>
|
||||
<Eye className="h-4 w-4 mr-2" />查看
|
||||
</div>
|
||||
<div onClick={() => { onEdit(); setOpen(false); }} style={{ padding: 8, cursor: "pointer", display: "flex", alignItems: "center", borderRadius: 6, fontSize: 14, gap: 6, transition: "background .2s" }} onMouseOver={e => (e.currentTarget as HTMLDivElement).style.background="#f5f5f5"} onMouseOut={e => (e.currentTarget as HTMLDivElement).style.background=""}>
|
||||
<Edit className="h-4 w-4 mr-2" />编辑
|
||||
</div>
|
||||
<div onClick={() => { onCopy(); setOpen(false); }} style={{ padding: 8, cursor: "pointer", display: "flex", alignItems: "center", borderRadius: 6, fontSize: 14, gap: 6, transition: "background .2s" }} onMouseOver={e => (e.currentTarget as HTMLDivElement).style.background="#f5f5f5"} onMouseOut={e => (e.currentTarget as HTMLDivElement).style.background=""}>
|
||||
<Copy className="h-4 w-4 mr-2" />复制
|
||||
</div>
|
||||
<div onClick={() => { onDelete(); setOpen(false); }} style={{ padding: 8, cursor: "pointer", display: "flex", alignItems: "center", borderRadius: 6, fontSize: 14, gap: 6, color: "#e53e3e", transition: "background .2s" }} onMouseOver={e => (e.currentTarget as HTMLDivElement).style.background="#f5f5f5"} onMouseOut={e => (e.currentTarget as HTMLDivElement).style.background=""}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />删除
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AutoGroup() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const [expandedTaskId, setExpandedTaskId] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [tasks, setTasks] = useState<GroupTask[]>([
|
||||
{
|
||||
id: '1',
|
||||
name: 'VIP客户建群',
|
||||
deviceCount: 2,
|
||||
targetFriends: 156,
|
||||
createdGroups: 12,
|
||||
lastCreateTime: '2025-02-06 13:12:35',
|
||||
createTime: '2024-11-20 19:04:14',
|
||||
creator: 'admin',
|
||||
status: 'running',
|
||||
createInterval: 300,
|
||||
maxGroupsPerDay: 20,
|
||||
timeRange: { start: '09:00', end: '21:00' },
|
||||
groupSize: { min: 20, max: 50 },
|
||||
targetTags: ['VIP客户', '高价值'],
|
||||
groupNameTemplate: 'VIP客户交流群{序号}',
|
||||
groupDescription: 'VIP客户专属交流群,提供优质服务',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: '产品推广建群',
|
||||
deviceCount: 1,
|
||||
targetFriends: 89,
|
||||
createdGroups: 8,
|
||||
lastCreateTime: '2024-03-04 14:09:35',
|
||||
createTime: '2024-03-04 14:29:04',
|
||||
creator: 'manager',
|
||||
status: 'paused',
|
||||
createInterval: 600,
|
||||
maxGroupsPerDay: 10,
|
||||
timeRange: { start: '10:00', end: '20:00' },
|
||||
groupSize: { min: 15, max: 30 },
|
||||
targetTags: ['潜在客户', '中意向'],
|
||||
groupNameTemplate: '产品推广群{序号}',
|
||||
groupDescription: '产品推广交流群,了解最新产品信息',
|
||||
},
|
||||
]);
|
||||
|
||||
const toggleExpand = (taskId: string) => {
|
||||
setExpandedTaskId(expandedTaskId === taskId ? null : taskId);
|
||||
};
|
||||
|
||||
const handleDelete = (taskId: string) => {
|
||||
const taskToDelete = tasks.find((task) => task.id === taskId);
|
||||
if (!taskToDelete) return;
|
||||
|
||||
if (!window.confirm(`确定要删除"${taskToDelete.name}"吗?`)) return;
|
||||
|
||||
setTasks(tasks.filter((task) => task.id !== taskId));
|
||||
toast({
|
||||
title: '删除成功',
|
||||
description: '已成功删除建群任务',
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdit = (taskId: string) => {
|
||||
navigate(`/workspace/auto-group/${taskId}/edit`);
|
||||
};
|
||||
|
||||
const handleView = (taskId: string) => {
|
||||
navigate(`/workspace/auto-group/${taskId}`);
|
||||
};
|
||||
|
||||
const handleCopy = (taskId: string) => {
|
||||
const taskToCopy = tasks.find((task) => task.id === taskId);
|
||||
if (taskToCopy) {
|
||||
const newTask = {
|
||||
...taskToCopy,
|
||||
id: `${Date.now()}`,
|
||||
name: `${taskToCopy.name} (复制)`,
|
||||
createTime: new Date().toISOString().replace('T', ' ').substring(0, 19),
|
||||
};
|
||||
setTasks([...tasks, newTask]);
|
||||
toast({
|
||||
title: '复制成功',
|
||||
description: '已成功复制建群任务',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTaskStatus = (taskId: string) => {
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
if (!task) return;
|
||||
|
||||
setTasks(
|
||||
tasks.map((task) =>
|
||||
task.id === taskId ? { ...task, status: task.status === 'running' ? 'paused' : 'running' } : task,
|
||||
),
|
||||
);
|
||||
|
||||
toast({
|
||||
title: task.status === 'running' ? '已暂停' : '已启动',
|
||||
description: `${task.name}任务${task.status === 'running' ? '已暂停' : '已启动'}`,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateNew = () => {
|
||||
navigate('/workspace/auto-group/new');
|
||||
};
|
||||
|
||||
const filteredTasks = tasks.filter((task) =>
|
||||
task.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'paused':
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
case 'completed':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return '进行中';
|
||||
case 'paused':
|
||||
return '已暂停';
|
||||
case 'completed':
|
||||
return '已完成';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<PageHeader
|
||||
title="自动建群"
|
||||
defaultBackPath="/workspace"
|
||||
rightContent={
|
||||
<Button onClick={handleCreateNew}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建任务
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
footer={<BottomNav />}
|
||||
>
|
||||
<div className="bg-gray-50 min-h-screen pb-20">
|
||||
<div className="p-4">
|
||||
{/* 搜索和筛选 */}
|
||||
<Card className="p-4 mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索任务名称"
|
||||
className="pl-9"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="icon">
|
||||
<Filter className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 任务列表 */}
|
||||
<div className="space-y-4">
|
||||
{filteredTasks.length === 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<UserPlus className="h-12 w-12 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-gray-500 text-lg font-medium mb-2">暂无建群任务</p>
|
||||
<p className="text-gray-400 text-sm mb-4">创建您的第一个自动建群任务</p>
|
||||
<Button onClick={handleCreateNew}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
创建第一个任务
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
filteredTasks.map((task) => (
|
||||
<Card key={task.id} className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h3 className="font-medium">{task.name}</h3>
|
||||
<Badge className={getStatusColor(task.status)}>
|
||||
{getStatusText(task.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
checked={task.status === 'running'}
|
||||
onCheckedChange={() => toggleTaskStatus(task.id)}
|
||||
disabled={task.status === 'completed'}
|
||||
/>
|
||||
<CardMenu
|
||||
onView={() => handleView(task.id)}
|
||||
onEdit={() => handleEdit(task.id)}
|
||||
onCopy={() => handleCopy(task.id)}
|
||||
onDelete={() => handleDelete(task.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="text-sm text-gray-500">
|
||||
<div>执行设备:{task.deviceCount} 个</div>
|
||||
<div>目标好友:{task.targetFriends} 个</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
<div>已建群:{task.createdGroups} 个</div>
|
||||
<div>创建人:{task.creator}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-gray-500 border-t pt-4">
|
||||
<div className="flex items-center">
|
||||
<Clock className="w-4 h-4 mr-1" />
|
||||
上次建群:{task.lastCreateTime}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span>创建时间:{task.createTime}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-2 p-0 h-6 w-6"
|
||||
onClick={() => toggleExpand(task.id)}
|
||||
>
|
||||
{expandedTaskId === task.id ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedTaskId === task.id && (
|
||||
<div className="mt-4 pt-4 border-t border-dashed">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center">
|
||||
<Settings className="h-5 w-5 mr-2 text-gray-500" />
|
||||
<h4 className="font-medium">基本设置</h4>
|
||||
</div>
|
||||
<div className="space-y-2 pl-7">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">建群间隔:</span>
|
||||
<span>{task.createInterval} 秒</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">每日最大建群数:</span>
|
||||
<span>{task.maxGroupsPerDay} 个</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">执行时间段:</span>
|
||||
<span>
|
||||
{task.timeRange.start} - {task.timeRange.end}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">群组规模:</span>
|
||||
<span>{task.groupSize.min}-{task.groupSize.max} 人</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center">
|
||||
<Users className="h-5 w-5 mr-2 text-gray-500" />
|
||||
<h4 className="font-medium">目标人群</h4>
|
||||
</div>
|
||||
<div className="space-y-2 pl-7">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{task.targetTags.map((tag) => (
|
||||
<Badge key={tag} variant="outline" className="bg-gray-50">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center">
|
||||
<UserPlus className="h-5 w-5 mr-2 text-gray-500" />
|
||||
<h4 className="font-medium">群组设置</h4>
|
||||
</div>
|
||||
<div className="space-y-2 pl-7">
|
||||
<div className="text-sm">
|
||||
<div className="text-gray-500 mb-1">群名称模板:</div>
|
||||
<div className="bg-gray-50 p-2 rounded text-xs">
|
||||
{task.groupNameTemplate}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<div className="text-gray-500 mb-1">群描述:</div>
|
||||
<div className="bg-gray-50 p-2 rounded text-xs">
|
||||
{task.groupDescription}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center">
|
||||
<Calendar className="h-5 w-5 mr-2 text-gray-500" />
|
||||
<h4 className="font-medium">执行进度</h4>
|
||||
</div>
|
||||
<div className="space-y-2 pl-7">
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-gray-500">今日已建群:</span>
|
||||
<span>
|
||||
{task.createdGroups} / {task.maxGroupsPerDay}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={(task.createdGroups / task.maxGroupsPerDay) * 100}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,414 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ChevronLeft,
|
||||
Search,
|
||||
Filter,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import Layout from '@/components/Layout';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import '@/components/Layout.css';
|
||||
|
||||
// 群组成员接口
|
||||
interface GroupMember {
|
||||
id: string;
|
||||
nickname: string;
|
||||
wechatId: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
// 群组接口
|
||||
interface Group {
|
||||
id: string;
|
||||
members: GroupMember[];
|
||||
}
|
||||
|
||||
// 建群任务详情接口
|
||||
interface GroupTaskDetail {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'preparing' | 'creating' | 'completed' | 'paused';
|
||||
totalGroups: number;
|
||||
currentGroupIndex: number;
|
||||
groups: Group[];
|
||||
createTime: string;
|
||||
lastUpdateTime: string;
|
||||
creator: string;
|
||||
deviceCount: number;
|
||||
targetFriends: number;
|
||||
groupSize: { min: number; max: number };
|
||||
timeRange: { start: string; end: string };
|
||||
targetTags: string[];
|
||||
groupNameTemplate: string;
|
||||
groupDescription: string;
|
||||
}
|
||||
|
||||
// 群组预览组件
|
||||
function GroupPreview({
|
||||
groupIndex,
|
||||
members,
|
||||
isCreating,
|
||||
isCompleted,
|
||||
onRetry
|
||||
}: {
|
||||
groupIndex: number;
|
||||
members: GroupMember[];
|
||||
isCreating: boolean;
|
||||
isCompleted: boolean;
|
||||
onRetry?: () => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const targetSize = 38; // 微信群人数固定为38人
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg font-medium">
|
||||
群 {groupIndex + 1}
|
||||
<Badge
|
||||
variant={isCompleted ? "default" : isCreating ? "secondary" : "outline"}
|
||||
className="ml-2"
|
||||
>
|
||||
{isCompleted ? "已完成" : isCreating ? "创建中" : "等待中"}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Users className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-sm text-gray-500">{members.length}/{targetSize}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isCreating && !isCompleted && (
|
||||
<div className="mb-4">
|
||||
<Progress value={Math.round((members.length / targetSize) * 100)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expanded ? (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{members.map((member) => (
|
||||
<div key={member.id} className="text-sm flex items-center space-x-2 bg-gray-50 p-2 rounded">
|
||||
<span className="truncate">{member.nickname}</span>
|
||||
{member.tags.length > 0 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{member.tags[0]}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="w-full mt-2" onClick={() => setExpanded(false)}>
|
||||
收起
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" className="w-full" onClick={() => setExpanded(true)}>
|
||||
查看成员 ({members.length})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!isCompleted && members.length < targetSize && (
|
||||
<div className="mt-4 flex items-center text-amber-500 text-sm">
|
||||
<AlertCircle className="w-4 h-4 mr-2" />
|
||||
群人数不足{targetSize}人
|
||||
{onRetry && (
|
||||
<Button variant="ghost" size="sm" className="ml-2 text-blue-500" onClick={onRetry}>
|
||||
继续拉人
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCompleted && (
|
||||
<div className="mt-4 flex items-center text-green-500 text-sm">
|
||||
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||
群创建完成
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 建群进度组件
|
||||
function GroupCreationProgress({
|
||||
taskDetail,
|
||||
onComplete
|
||||
}: {
|
||||
taskDetail: GroupTaskDetail;
|
||||
onComplete: () => void;
|
||||
}) {
|
||||
const [groups, setGroups] = useState<Group[]>(taskDetail.groups);
|
||||
const [currentGroupIndex, setCurrentGroupIndex] = useState(taskDetail.currentGroupIndex);
|
||||
const [status, setStatus] = useState<'preparing' | 'creating' | 'completed'>(taskDetail.status as any);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// 模拟建群进度更新
|
||||
if (status === 'creating' && currentGroupIndex < groups.length) {
|
||||
const timer = setTimeout(() => {
|
||||
if (currentGroupIndex === groups.length - 1) {
|
||||
setStatus('completed');
|
||||
onComplete();
|
||||
} else {
|
||||
setCurrentGroupIndex((prev) => prev + 1);
|
||||
}
|
||||
}, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [status, currentGroupIndex, groups.length, onComplete]);
|
||||
|
||||
const handleRetryGroup = (groupIndex: number) => {
|
||||
// 模拟重试逻辑
|
||||
setGroups((prev) =>
|
||||
prev.map((group, index) => {
|
||||
if (index === groupIndex) {
|
||||
return {
|
||||
...group,
|
||||
members: [
|
||||
...group.members,
|
||||
{
|
||||
id: `retry-member-${Date.now()}`,
|
||||
nickname: `补充用户${group.members.length + 1}`,
|
||||
wechatId: `wx_retry_${Date.now()}`,
|
||||
tags: ['新加入'],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return group;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg font-medium">
|
||||
建群进度
|
||||
<Badge className="ml-2">
|
||||
{status === "preparing" ? "准备中" : status === "creating" ? "创建中" : "已完成"}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<div className="text-sm text-gray-500">
|
||||
{currentGroupIndex + 1}/{groups.length}组
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Progress value={Math.round(((currentGroupIndex + 1) / groups.length) * 100)} className="h-2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<ScrollArea className="h-[calc(100vh-400px)]">
|
||||
<div className="space-y-4">
|
||||
{groups.map((group, index) => (
|
||||
<GroupPreview
|
||||
key={group.id}
|
||||
groupIndex={index}
|
||||
members={group.members}
|
||||
isCreating={status === "creating" && index === currentGroupIndex}
|
||||
isCompleted={status === "completed" || index < currentGroupIndex}
|
||||
onRetry={() => handleRetryGroup(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{status === "completed" && (
|
||||
<Alert>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
<AlertDescription>所有群组已创建完成</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AutoGroupDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [taskDetail, setTaskDetail] = useState<GroupTaskDetail | null>(null);
|
||||
|
||||
// 模拟获取任务详情
|
||||
useEffect(() => {
|
||||
const fetchTaskDetail = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 模拟API调用
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// 模拟数据
|
||||
const mockTaskDetail: GroupTaskDetail = {
|
||||
id: id || '1',
|
||||
name: 'VIP客户建群',
|
||||
status: 'creating',
|
||||
totalGroups: 5,
|
||||
currentGroupIndex: 2,
|
||||
groups: Array.from({ length: 5 }).map((_, index) => ({
|
||||
id: `group-${index}`,
|
||||
members: Array.from({ length: Math.floor(Math.random() * 10) + 30 }).map((_, mIndex) => ({
|
||||
id: `member-${index}-${mIndex}`,
|
||||
nickname: `用户${mIndex + 1}`,
|
||||
wechatId: `wx_${mIndex}`,
|
||||
tags: [`标签${(mIndex % 3) + 1}`],
|
||||
})),
|
||||
})),
|
||||
createTime: '2024-11-20 19:04:14',
|
||||
lastUpdateTime: '2025-02-06 13:12:35',
|
||||
creator: 'admin',
|
||||
deviceCount: 2,
|
||||
targetFriends: 156,
|
||||
groupSize: { min: 20, max: 50 },
|
||||
timeRange: { start: '09:00', end: '21:00' },
|
||||
targetTags: ['VIP客户', '高价值'],
|
||||
groupNameTemplate: 'VIP客户交流群{序号}',
|
||||
groupDescription: 'VIP客户专属交流群,提供优质服务',
|
||||
};
|
||||
|
||||
setTaskDetail(mockTaskDetail);
|
||||
} catch (error) {
|
||||
console.error('获取任务详情失败:', error);
|
||||
toast({
|
||||
title: '获取任务详情失败',
|
||||
description: '请稍后重试',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (id) {
|
||||
fetchTaskDetail();
|
||||
}
|
||||
}, [id, toast]);
|
||||
|
||||
const handleComplete = () => {
|
||||
toast({
|
||||
title: '建群完成',
|
||||
description: '所有群组已创建完成',
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<PageHeader
|
||||
title="建群详情"
|
||||
defaultBackPath="/workspace/auto-group"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="bg-gray-50 min-h-screen pb-20">
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!taskDetail) {
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<PageHeader
|
||||
title="建群详情"
|
||||
defaultBackPath="/workspace/auto-group"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="bg-gray-50 min-h-screen pb-20">
|
||||
<div className="p-4">
|
||||
<Card className="p-8 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-gray-500 text-lg font-medium mb-2">任务不存在</p>
|
||||
<p className="text-gray-400 text-sm mb-4">请检查任务ID是否正确</p>
|
||||
<Button onClick={() => navigate('/workspace/auto-group')}>
|
||||
返回列表
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<PageHeader
|
||||
title={`${taskDetail.name} - 建群详情`}
|
||||
defaultBackPath="/workspace/auto-group"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="bg-gray-50 min-h-screen pb-20">
|
||||
<div className="p-4">
|
||||
{/* 任务基本信息 */}
|
||||
<Card className="p-4 mb-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">基本信息</h3>
|
||||
<div className="space-y-1 text-sm text-gray-600">
|
||||
<div>任务名称:{taskDetail.name}</div>
|
||||
<div>创建时间:{taskDetail.createTime}</div>
|
||||
<div>创建人:{taskDetail.creator}</div>
|
||||
<div>执行设备:{taskDetail.deviceCount} 个</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium mb-2">建群配置</h3>
|
||||
<div className="space-y-1 text-sm text-gray-600">
|
||||
<div>群组规模:{taskDetail.groupSize.min}-{taskDetail.groupSize.max} 人</div>
|
||||
<div>执行时间:{taskDetail.timeRange.start} - {taskDetail.timeRange.end}</div>
|
||||
<div>目标标签:{taskDetail.targetTags.join(', ')}</div>
|
||||
<div>群名称模板:{taskDetail.groupNameTemplate}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 建群进度 */}
|
||||
<GroupCreationProgress
|
||||
taskDetail={taskDetail}
|
||||
onComplete={handleComplete}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
MoreVertical,
|
||||
Eye,
|
||||
Edit,
|
||||
Copy,
|
||||
Trash2,
|
||||
Plus,
|
||||
Search,
|
||||
RefreshCw,
|
||||
ThumbsUp,
|
||||
ChevronLeft,
|
||||
} from "lucide-react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
|
||||
import { fetchAutoLikeTasks, deleteAutoLikeTask, toggleAutoLikeTask, copyAutoLikeTask, LikeTask } from '@/api/autoLike';
|
||||
|
||||
type CardMenuProps = {
|
||||
onView: () => void;
|
||||
onEdit: () => void;
|
||||
onCopy: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
function CardMenu({ onView, onEdit, onCopy, onDelete }: CardMenuProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const menuRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
if (open) document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<button onClick={() => setOpen((v) => !v)} style={{ background: "none", border: "none", padding: 0, margin: 0, cursor: "pointer" }}>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: 28,
|
||||
background: "#fff",
|
||||
borderRadius: 8,
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
|
||||
zIndex: 100,
|
||||
minWidth: 120,
|
||||
padding: 4,
|
||||
}}
|
||||
>
|
||||
<div onClick={() => { onView(); setOpen(false); }} style={{ padding: 8, cursor: "pointer", display: "flex", alignItems: "center", borderRadius: 6, fontSize: 14, gap: 6, transition: "background .2s" }} onMouseOver={e => (e.currentTarget as HTMLDivElement).style.background="#f5f5f5"} onMouseOut={e => (e.currentTarget as HTMLDivElement).style.background=""}>
|
||||
<Eye className="h-4 w-4 mr-2" />查看
|
||||
</div>
|
||||
<div onClick={() => { onEdit(); setOpen(false); }} style={{ padding: 8, cursor: "pointer", display: "flex", alignItems: "center", borderRadius: 6, fontSize: 14, gap: 6, transition: "background .2s" }} onMouseOver={e => (e.currentTarget as HTMLDivElement).style.background="#f5f5f5"} onMouseOut={e => (e.currentTarget as HTMLDivElement).style.background=""}>
|
||||
<Edit className="h-4 w-4 mr-2" />编辑
|
||||
</div>
|
||||
<div onClick={() => { onCopy(); setOpen(false); }} style={{ padding: 8, cursor: "pointer", display: "flex", alignItems: "center", borderRadius: 6, fontSize: 14, gap: 6, transition: "background .2s" }} onMouseOver={e => (e.currentTarget as HTMLDivElement).style.background="#f5f5f5"} onMouseOut={e => (e.currentTarget as HTMLDivElement).style.background=""}>
|
||||
<Copy className="h-4 w-4 mr-2" />复制
|
||||
</div>
|
||||
<div onClick={() => { onDelete(); setOpen(false); }} style={{ padding: 8, cursor: "pointer", display: "flex", alignItems: "center", borderRadius: 6, fontSize: 14, gap: 6, color: "#e53e3e", transition: "background .2s" }} onMouseOver={e => (e.currentTarget as HTMLDivElement).style.background="#f5f5f5"} onMouseOut={e => (e.currentTarget as HTMLDivElement).style.background=""}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />删除
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AutoLike() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const [tasks, setTasks] = React.useState<LikeTask[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 1. fetchTasks 不用 useCallback,直接定义
|
||||
async function fetchTasks() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const list = await fetchAutoLikeTasks();
|
||||
// 确保数据字段与旧项目一致
|
||||
const mappedTasks = list.map(task => ({
|
||||
...task,
|
||||
// 确保字段名称和格式与旧项目一致
|
||||
status: task.status || 2, // 默认为关闭状态
|
||||
deviceCount: task.deviceCount || 0,
|
||||
targetGroup: task.targetGroup || '默认人群',
|
||||
likeCount: task.todayLikeCount || task.likeCount || 0,
|
||||
creator: task.creator || '未知',
|
||||
lastLikeTime: task.lastLikeTime || '暂无',
|
||||
createTime: task.createTime || '未知',
|
||||
likeInterval: task.likeInterval || 5,
|
||||
maxLikesPerDay: task.maxLikesPerDay || 200,
|
||||
timeRange: task.timeRange || { start: '08:00', end: '22:00' },
|
||||
contentTypes: task.contentTypes || ['text', 'image', 'video'],
|
||||
targetTags: task.targetTags || []
|
||||
}));
|
||||
setTasks(mappedTasks);
|
||||
} catch (error) {
|
||||
toast({ title: "获取任务失败", variant: "destructive" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. useEffect 里直接调用
|
||||
React.useEffect(() => {
|
||||
fetchTasks();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!window.confirm("确定要删除该任务吗?")) return;
|
||||
try {
|
||||
const response = await deleteAutoLikeTask(id);
|
||||
if (response.code === 200) {
|
||||
toast({ title: "删除成功" });
|
||||
fetchTasks();
|
||||
} else {
|
||||
toast({ title: "删除失败", description: response.msg || "请稍后重试", variant: "destructive" });
|
||||
}
|
||||
} catch (error) {
|
||||
toast({ title: "删除失败", description: "请稍后重试", variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (taskId: string) => {
|
||||
navigate(`/workspace/auto-like/${taskId}/edit`);
|
||||
};
|
||||
|
||||
const handleView = (taskId: string) => {
|
||||
navigate(`/workspace/auto-like/${taskId}`);
|
||||
};
|
||||
|
||||
const handleCopy = async (id: string) => {
|
||||
try {
|
||||
const response = await copyAutoLikeTask(id);
|
||||
if (response.code === 200) {
|
||||
toast({ title: "复制成功" });
|
||||
fetchTasks();
|
||||
} else {
|
||||
toast({ title: "复制失败", description: response.msg || "请稍后重试", variant: "destructive" });
|
||||
}
|
||||
} catch (error) {
|
||||
toast({ title: "复制失败", description: "请稍后重试", variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTaskStatus = async (id: string, status: number) => {
|
||||
// 先更新本地状态
|
||||
const newStatus = (status === 1 ? 2 : 1) as 1 | 2;
|
||||
setTasks(prevTasks =>
|
||||
prevTasks.map(task =>
|
||||
task.id === id ? { ...task, status: newStatus } : task
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await toggleAutoLikeTask(id, String(newStatus));
|
||||
if (response.code === 200) {
|
||||
toast({ title: "操作成功" });
|
||||
// 成功时不刷新列表,保持本地状态
|
||||
} else {
|
||||
// 请求失败,回退本地状态
|
||||
setTasks(prevTasks =>
|
||||
prevTasks.map(task =>
|
||||
task.id === id ? { ...task, status: status as 1 | 2 } : task
|
||||
)
|
||||
);
|
||||
toast({ title: "操作失败", description: response.msg || "请稍后重试", variant: "destructive" });
|
||||
}
|
||||
} catch (error) {
|
||||
// 请求异常,回退本地状态
|
||||
setTasks(prevTasks =>
|
||||
prevTasks.map(task =>
|
||||
task.id === id ? { ...task, status: status as 1 | 2 } : task
|
||||
)
|
||||
);
|
||||
toast({ title: "操作失败", description: "请稍后重试", variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateNew = () => {
|
||||
navigate("/workspace/auto-like/new");
|
||||
};
|
||||
|
||||
const filteredTasks = tasks.filter((task) =>
|
||||
task.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gray-50 min-h-screen pb-20">
|
||||
<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">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-lg font-medium">自动点赞</h1>
|
||||
</div>
|
||||
<Button onClick={handleCreateNew}>
|
||||
<Plus className="h-4 w-4 mr-2" />新建任务
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="p-4">
|
||||
<Card className="p-4 mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
|
||||
<Input placeholder="搜索任务名称" className="pl-9" value={searchTerm} onChange={e => setSearchTerm(e.target.value)} />
|
||||
</div>
|
||||
<Button variant="outline" size="icon" onClick={fetchTasks} disabled={loading}>
|
||||
{loading ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
<div className="space-y-4">
|
||||
{filteredTasks.map((task) => (
|
||||
<Card key={task.id} className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h3 className="font-medium">{task.name}</h3>
|
||||
<Badge variant={Number(task.status) === 1 ? "success" : "secondary"}>
|
||||
{Number(task.status) === 1 ? "进行中" : "已暂停"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch checked={Number(task.status) === 1} onCheckedChange={() => toggleTaskStatus(task.id, Number(task.status))} />
|
||||
<CardMenu
|
||||
onView={() => handleView(task.id)}
|
||||
onEdit={() => handleEdit(task.id)}
|
||||
onCopy={() => handleCopy(task.id)}
|
||||
onDelete={() => handleDelete(task.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500 font-medium">执行设备</span>
|
||||
<span className="text-sm text-gray-900 font-semibold">{task.deviceCount} 个</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500 font-medium">目标人群</span>
|
||||
<span className="text-sm text-gray-900 font-semibold">{task.targetGroup}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500 font-medium">更新时间</span>
|
||||
<span className="text-sm text-gray-900 font-semibold">{task.updateTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500 font-medium">点赞间隔</span>
|
||||
<span className="text-sm text-gray-900 font-semibold">{task.likeInterval} 秒</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500 font-medium">每日上限</span>
|
||||
<span className="text-sm text-gray-900 font-semibold">{task.maxLikesPerDay} 次</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500 font-medium">创建时间</span>
|
||||
<span className="text-sm text-gray-900 font-semibold">{task.createTime}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm text-gray-500 border-t pt-5">
|
||||
<div className="flex items-center space-x-2">
|
||||
<ThumbsUp className="w-4 h-4 text-blue-500" />
|
||||
<span className="font-medium">今日点赞:</span>
|
||||
<span className="text-gray-900 font-semibold">{task.lastLikeTime}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<ThumbsUp className="w-4 h-4 text-green-500" />
|
||||
<span className="font-medium">总点赞数:</span>
|
||||
<span className="text-gray-900 font-semibold">{task.totalLikeCount || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
ThumbsUp,
|
||||
RefreshCw,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { Card, } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Avatar } from '@/components/ui/avatar';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import Layout from '@/components/Layout';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import '@/components/Layout.css';
|
||||
import {
|
||||
fetchLikeRecords,
|
||||
LikeRecord,
|
||||
} from '@/api/autoLike';
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString: string) => {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch (error) {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
export default function AutoLikeDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { toast } = useToast();
|
||||
const [records, setRecords] = useState<LikeRecord[]>([]);
|
||||
const [recordsLoading, setRecordsLoading] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const pageSize = 10;
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setRecordsLoading(true);
|
||||
fetchLikeRecords(id, 1, pageSize)
|
||||
.then(response => {
|
||||
setRecords(response.list || []);
|
||||
setTotal(response.total || 0);
|
||||
setCurrentPage(1);
|
||||
})
|
||||
.catch(() => {
|
||||
toast({
|
||||
title: '获取点赞记录失败',
|
||||
description: '请稍后重试',
|
||||
variant: 'destructive',
|
||||
});
|
||||
})
|
||||
.finally(() => setRecordsLoading(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id]);
|
||||
|
||||
const handleSearch = () => {
|
||||
setCurrentPage(1);
|
||||
fetchLikeRecords(id!, 1, pageSize, searchTerm)
|
||||
.then(response => {
|
||||
setRecords(response.list || []);
|
||||
setTotal(response.total || 0);
|
||||
setCurrentPage(1);
|
||||
})
|
||||
.catch(() => {
|
||||
toast({
|
||||
title: '获取点赞记录失败',
|
||||
description: '请稍后重试',
|
||||
variant: 'destructive',
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchLikeRecords(id!, currentPage, pageSize, searchTerm)
|
||||
.then(response => {
|
||||
setRecords(response.list || []);
|
||||
setTotal(response.total || 0);
|
||||
})
|
||||
.catch(() => {
|
||||
toast({
|
||||
title: '获取点赞记录失败',
|
||||
description: '请稍后重试',
|
||||
variant: 'destructive',
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
fetchLikeRecords(id!, newPage, pageSize, searchTerm)
|
||||
.then(response => {
|
||||
setRecords(response.list || []);
|
||||
setTotal(response.total || 0);
|
||||
setCurrentPage(newPage);
|
||||
})
|
||||
.catch(() => {
|
||||
toast({
|
||||
title: '获取点赞记录失败',
|
||||
description: '请稍后重试',
|
||||
variant: 'destructive',
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<>
|
||||
<PageHeader
|
||||
title="点赞记录"
|
||||
defaultBackPath="/workspace/auto-like"
|
||||
/>
|
||||
<div className="flex items-center space-x-2 px-4 py-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索好友昵称或内容"
|
||||
className="pl-9"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="icon" onClick={handleRefresh} disabled={recordsLoading}>
|
||||
<RefreshCw className={`h-4 w-4 ${recordsLoading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
{records.length > 0 && total > pageSize && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
className="mx-1"
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="mx-4 py-2 text-sm text-gray-500">
|
||||
第 {currentPage} 页,共 {Math.ceil(total / pageSize)} 页
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage >= Math.ceil(total / pageSize)}
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
className="mx-1"
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
}
|
||||
>
|
||||
<div className="bg-gray-50 min-h-screen pb-20">
|
||||
<div className="p-4 space-y-4">
|
||||
|
||||
{recordsLoading ? (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<Card key={index} className="p-4">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
<Skeleton className="h-10 w-10 rounded-full" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="my-3" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<div className="flex space-x-2 mt-3">
|
||||
<Skeleton className="h-20 w-20" />
|
||||
<Skeleton className="h-20 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : records.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<ThumbsUp className="h-12 w-12 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-gray-500">暂无点赞记录</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{records.map((record) => (
|
||||
<div key={record.id} className="p-4 mb-4 bg-white rounded-2xl shadow-sm">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-3 max-w-[65%]">
|
||||
<Avatar>
|
||||
<img
|
||||
src={record.friendAvatar || "https://api.dicebear.com/7.x/avataaars/svg?seed=fallback"}
|
||||
alt={record.friendName}
|
||||
className="w-10 h-10 rounded-full"
|
||||
/>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate" title={record.friendName}>
|
||||
{record.friendName}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">内容发布者</div>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="bg-blue-50 whitespace-nowrap shrink-0">
|
||||
{formatDate(record.momentTime || record.likeTime)}
|
||||
</Badge>
|
||||
</div>
|
||||
<Separator className="my-3" />
|
||||
<div className="mb-3">
|
||||
{record.content && (
|
||||
<p className="text-gray-700 mb-3 whitespace-pre-line">
|
||||
{record.content}
|
||||
</p>
|
||||
)}
|
||||
{Array.isArray(record.resUrls) && record.resUrls.length > 0 && (
|
||||
<div className={`grid gap-2 ${
|
||||
record.resUrls.length === 1 ? "grid-cols-1" :
|
||||
record.resUrls.length === 2 ? "grid-cols-2" :
|
||||
record.resUrls.length <= 3 ? "grid-cols-3" :
|
||||
record.resUrls.length <= 6 ? "grid-cols-3 grid-rows-2" :
|
||||
"grid-cols-3 grid-rows-3"
|
||||
}`}>
|
||||
{record.resUrls.slice(0, 9).map((image: string, idx: number) => (
|
||||
<div key={idx} className="relative aspect-square rounded-md overflow-hidden">
|
||||
<img
|
||||
src={image}
|
||||
alt={`内容图片 ${idx + 1}`}
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center mt-4 p-2 bg-gray-50 rounded-md">
|
||||
<Avatar className="h-8 w-8 mr-2 shrink-0">
|
||||
<img
|
||||
src={record.operatorAvatar || "https://api.dicebear.com/7.x/avataaars/svg?seed=operator"}
|
||||
alt={record.operatorName}
|
||||
className="w-8 h-8 rounded-full"
|
||||
/>
|
||||
</Avatar>
|
||||
<div className="text-sm min-w-0">
|
||||
<span className="font-medium truncate inline-block max-w-full" title={record.operatorName}>
|
||||
{record.operatorName}
|
||||
</span>
|
||||
<span className="text-gray-500 ml-2">点赞了这条内容</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,552 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { ChevronLeft,Plus, Minus, Check, X, Tag as TagIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { createAutoLikeTask, updateAutoLikeTask, fetchAutoLikeTaskDetail } from '@/api/autoLike';
|
||||
import { ContentType } from '@/types/auto-like';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import Layout from '@/components/Layout';
|
||||
import DeviceSelection from '@/components/DeviceSelection';
|
||||
import FriendSelection from '@/components/FriendSelection';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// 修改CreateLikeTaskData接口,确保friends字段不是可选的
|
||||
interface CreateLikeTaskDataLocal {
|
||||
name: string;
|
||||
interval: number;
|
||||
maxLikes: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
contentTypes: ContentType[];
|
||||
devices: string[];
|
||||
friends: string[];
|
||||
friendMaxLikes: number;
|
||||
friendTags: string;
|
||||
enableFriendTags: boolean;
|
||||
targetTags: string[];
|
||||
}
|
||||
|
||||
export default function NewAutoLike() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const isEditMode = !!id;
|
||||
const { toast } = useToast();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(isEditMode);
|
||||
const [formData, setFormData] = useState<CreateLikeTaskDataLocal>({
|
||||
name: '',
|
||||
interval: 5,
|
||||
maxLikes: 200,
|
||||
startTime: '08:00',
|
||||
endTime: '22:00',
|
||||
contentTypes: ['text', 'image', 'video'],
|
||||
devices: [],
|
||||
friends: [], // 确保初始化为空数组而不是undefined
|
||||
targetTags: [],
|
||||
friendMaxLikes: 10,
|
||||
enableFriendTags: false,
|
||||
friendTags: '',
|
||||
});
|
||||
// 新增自动开启的独立状态
|
||||
const [autoEnabled, setAutoEnabled] = useState(false);
|
||||
|
||||
// 如果是编辑模式,获取任务详情
|
||||
useEffect(() => {
|
||||
if (isEditMode && id) {
|
||||
fetchTaskDetail();
|
||||
}
|
||||
}, [id, isEditMode]);
|
||||
|
||||
// 获取任务详情
|
||||
const fetchTaskDetail = async () => {
|
||||
try {
|
||||
const taskDetail = await fetchAutoLikeTaskDetail(id!);
|
||||
console.log('Task detail response:', taskDetail); // 添加日志用于调试
|
||||
|
||||
if (taskDetail) {
|
||||
// 使用类型断言处理可能的字段名称差异
|
||||
const taskAny = taskDetail as any;
|
||||
// 处理可能的嵌套结构
|
||||
const config = taskAny.config || taskAny;
|
||||
|
||||
setFormData({
|
||||
name: taskDetail.name || '',
|
||||
interval: config.likeInterval || config.interval || 5,
|
||||
maxLikes: config.maxLikesPerDay || config.maxLikes || 200,
|
||||
startTime: config.timeRange?.start || config.startTime || '08:00',
|
||||
endTime: config.timeRange?.end || config.endTime || '22:00',
|
||||
contentTypes: config.contentTypes || ['text', 'image', 'video'],
|
||||
devices: config.devices || [],
|
||||
friends: config.friends || [],
|
||||
targetTags: config.targetTags || [],
|
||||
friendMaxLikes: config.friendMaxLikes || 10,
|
||||
enableFriendTags: config.enableFriendTags || false,
|
||||
friendTags: config.friendTags || '',
|
||||
});
|
||||
|
||||
// 处理状态字段,使用双等号允许类型自动转换
|
||||
const status = taskAny.status;
|
||||
setAutoEnabled(status === 1 || status === 'running');
|
||||
} else {
|
||||
toast({
|
||||
title: '获取任务详情失败',
|
||||
description: '无法找到该任务',
|
||||
variant: 'destructive',
|
||||
});
|
||||
navigate('/workspace/auto-like');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取任务详情出错:', error); // 添加错误日志
|
||||
toast({
|
||||
title: '获取任务详情失败',
|
||||
description: '请检查网络连接后重试',
|
||||
variant: 'destructive',
|
||||
});
|
||||
navigate('/workspace/auto-like');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handleUpdateFormData = (data: Partial<CreateLikeTaskDataLocal>) => {
|
||||
setFormData((prev) => ({ ...prev, ...data }));
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
setCurrentStep((prev) => Math.min(prev + 1, 3));
|
||||
// 滚动到顶部
|
||||
const mainElement = document.querySelector('main');
|
||||
if (mainElement) {
|
||||
mainElement.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
setCurrentStep((prev) => Math.max(prev - 1, 1));
|
||||
// 滚动到顶部
|
||||
const mainElement = document.querySelector('main');
|
||||
if (mainElement) {
|
||||
mainElement.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleComplete = async () => {
|
||||
if (isSubmitting) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// 转换为API需要的格式
|
||||
const apiFormData = {
|
||||
...formData,
|
||||
// 如果API需要其他转换,可以在这里添加
|
||||
};
|
||||
|
||||
let response;
|
||||
if (isEditMode) {
|
||||
// 编辑模式,调用更新API
|
||||
response = await updateAutoLikeTask({
|
||||
...apiFormData,
|
||||
id: id!
|
||||
});
|
||||
} else {
|
||||
// 新建模式,调用创建API
|
||||
response = await createAutoLikeTask(apiFormData);
|
||||
}
|
||||
|
||||
if (response.code === 200) {
|
||||
toast({
|
||||
title: isEditMode ? '更新成功' : '创建成功',
|
||||
description: isEditMode ? '自动点赞任务已更新' : '自动点赞任务已创建并开始执行',
|
||||
});
|
||||
navigate('/workspace/auto-like');
|
||||
} else {
|
||||
toast({
|
||||
title: isEditMode ? '更新失败' : '创建失败',
|
||||
description: response.msg || '请稍后重试',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: isEditMode ? '更新失败' : '创建失败',
|
||||
description: '请检查网络连接后重试',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const header = (
|
||||
<div className="sticky top-0 z-10 bg-white pb-4">
|
||||
<div className="flex items-center h-14 px-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)} className="hover:bg-gray-50">
|
||||
<ChevronLeft className="h-6 w-6" />
|
||||
</Button>
|
||||
<h1 className="ml-2 text-lg font-medium">{isEditMode ? '编辑自动点赞' : '新建自动点赞'}</h1>
|
||||
</div>
|
||||
<StepIndicator currentStep={currentStep} />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Layout header={header}>
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-500">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout header={header}>
|
||||
<div className="min-h-screen bg-[#F8F9FA]">
|
||||
<div className="pt-4">
|
||||
|
||||
<div className="mt-8">
|
||||
{currentStep === 1 && (
|
||||
<BasicSettings
|
||||
formData={formData}
|
||||
onChange={handleUpdateFormData}
|
||||
onNext={handleNext}
|
||||
autoEnabled={autoEnabled}
|
||||
setAutoEnabled={setAutoEnabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 2 && (
|
||||
<div className="space-y-6 px-6">
|
||||
<DeviceSelection
|
||||
selectedDevices={formData.devices}
|
||||
onSelect={(devices) => handleUpdateFormData({ devices })}
|
||||
placeholder="选择设备"
|
||||
/>
|
||||
|
||||
<div className="flex space-x-4">
|
||||
<Button variant="outline" className="flex-1 h-12 rounded-xl text-base" onClick={handlePrev}>
|
||||
上一步
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 h-12 bg-blue-600 hover:bg-blue-700 rounded-xl text-base shadow-sm"
|
||||
onClick={handleNext}
|
||||
disabled={formData.devices.length === 0}
|
||||
>
|
||||
下一步
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 3 && (
|
||||
<div className="px-6 space-y-6">
|
||||
<FriendSelection
|
||||
selectedFriends={formData.friends || []}
|
||||
onSelect={(friends) => handleUpdateFormData({ friends })}
|
||||
deviceIds={formData.devices}
|
||||
placeholder="选择微信好友"
|
||||
/>
|
||||
|
||||
<div className="flex space-x-4">
|
||||
<Button variant="outline" className="flex-1 h-12 rounded-xl text-base" onClick={handlePrev}>
|
||||
上一步
|
||||
</Button>
|
||||
<Button className="flex-1 h-12 bg-blue-600 hover:bg-blue-700 rounded-xl text-base shadow-sm" onClick={handleComplete}>
|
||||
完成
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
// 步骤指示器组件
|
||||
interface StepIndicatorProps {
|
||||
currentStep: number;
|
||||
}
|
||||
|
||||
function StepIndicator({ currentStep }: StepIndicatorProps) {
|
||||
const steps = [
|
||||
{ title: '基础设置', description: '设置点赞规则' },
|
||||
{ title: '设备选择', description: '选择执行设备' },
|
||||
{ title: '人群选择', description: '选择目标人群' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="px-6">
|
||||
<div className="relative">
|
||||
<div className="flex items-center justify-between">
|
||||
{steps.map((step, index) => (
|
||||
<div key={index} className="flex flex-col items-center relative z-10">
|
||||
<div
|
||||
className={`flex items-center justify-center w-8 h-8 rounded-full ${
|
||||
index < currentStep
|
||||
? 'bg-blue-600 text-white'
|
||||
: index === currentStep
|
||||
? 'border-2 border-blue-600 text-blue-600'
|
||||
: 'border-2 border-gray-300 text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{index < currentStep ? <Check className="w-5 h-5" /> : index + 1}
|
||||
</div>
|
||||
<div className="text-center mt-2">
|
||||
<div className={`text-sm font-medium ${index <= currentStep ? 'text-gray-900' : 'text-gray-400'}`}>
|
||||
{step.title}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">{step.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute top-4 left-0 w-full h-0.5 bg-gray-200 -translate-y-1/2 z-0">
|
||||
<div
|
||||
className="absolute top-0 left-0 h-full bg-blue-600 transition-all duration-300"
|
||||
style={{ width: `${((currentStep - 1) / (steps.length - 1)) * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 基础设置组件
|
||||
interface BasicSettingsProps {
|
||||
formData: CreateLikeTaskDataLocal;
|
||||
onChange: (data: Partial<CreateLikeTaskDataLocal>) => void;
|
||||
onNext: () => void;
|
||||
autoEnabled: boolean;
|
||||
setAutoEnabled: (v: boolean) => void;
|
||||
}
|
||||
|
||||
function BasicSettings({ formData, onChange, onNext, autoEnabled, setAutoEnabled }: BasicSettingsProps) {
|
||||
const handleContentTypeChange = (type: ContentType) => {
|
||||
const currentTypes = [...formData.contentTypes];
|
||||
if (currentTypes.includes(type)) {
|
||||
onChange({ contentTypes: currentTypes.filter((t) => t !== type) });
|
||||
} else {
|
||||
onChange({ contentTypes: [...currentTypes, type] });
|
||||
}
|
||||
};
|
||||
|
||||
const incrementInterval = () => {
|
||||
onChange({ interval: Math.min(formData.interval + 5, 60) });
|
||||
};
|
||||
|
||||
const decrementInterval = () => {
|
||||
onChange({ interval: Math.max(formData.interval - 5, 5) });
|
||||
};
|
||||
|
||||
const incrementMaxLikes = () => {
|
||||
onChange({ maxLikes: Math.min(formData.maxLikes + 10, 500) });
|
||||
};
|
||||
|
||||
const decrementMaxLikes = () => {
|
||||
onChange({ maxLikes: Math.max(formData.maxLikes - 10, 10) });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="task-name">任务名称</Label>
|
||||
<Input
|
||||
id="task-name"
|
||||
placeholder="请输入任务名称"
|
||||
value={formData.name}
|
||||
onChange={(e) => onChange({ name: e.target.value })}
|
||||
className="h-12 rounded-xl border-gray-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="like-interval">点赞间隔</Label>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-12 w-12 rounded-l-xl border-gray-200 bg-white hover:bg-gray-50"
|
||||
onClick={decrementInterval}
|
||||
>
|
||||
<Minus className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
id="like-interval"
|
||||
type="number"
|
||||
min={5}
|
||||
max={60}
|
||||
value={formData.interval.toString()}
|
||||
onChange={(e) => onChange({ interval: Number.parseInt(e.target.value) || 5 })}
|
||||
className="h-12 rounded-none border-x-0 border-gray-200 text-center"
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-4 pointer-events-none text-gray-500">
|
||||
秒
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-12 w-12 rounded-r-xl border-gray-200 bg-white hover:bg-gray-50"
|
||||
onClick={incrementInterval}
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">设置两次点赞之间的最小时间间隔</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max-likes">每日最大点赞数</Label>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-12 w-12 rounded-l-xl border-gray-200 bg-white hover:bg-gray-50"
|
||||
onClick={decrementMaxLikes}
|
||||
>
|
||||
<Minus className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
id="max-likes"
|
||||
type="number"
|
||||
min={10}
|
||||
max={500}
|
||||
value={formData.maxLikes.toString()}
|
||||
onChange={(e) => onChange({ maxLikes: Number.parseInt(e.target.value) || 10 })}
|
||||
className="h-12 rounded-none border-x-0 border-gray-200 text-center"
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-4 pointer-events-none text-gray-500">
|
||||
次/天
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-12 w-12 rounded-r-xl border-gray-200 bg-white hover:bg-gray-50"
|
||||
onClick={incrementMaxLikes}
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">设置每天最多点赞的次数</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>点赞时间范围</Label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Input
|
||||
type="time"
|
||||
value={formData.startTime}
|
||||
onChange={(e) => onChange({ startTime: e.target.value })}
|
||||
className="h-12 rounded-xl border-gray-200"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
type="time"
|
||||
value={formData.endTime}
|
||||
onChange={(e) => onChange({ endTime: e.target.value })}
|
||||
className="h-12 rounded-xl border-gray-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">设置每天可以点赞的时间段</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>点赞内容类型</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ id: 'text' as ContentType, label: '文字' },
|
||||
{ id: 'image' as ContentType, label: '图片' },
|
||||
{ id: 'video' as ContentType, label: '视频' },
|
||||
].map((type) => (
|
||||
<div
|
||||
key={type.id}
|
||||
className={`flex items-center justify-center h-12 rounded-xl border cursor-pointer ${
|
||||
formData.contentTypes.includes(type.id)
|
||||
? 'border-blue-500 bg-blue-50 text-blue-600'
|
||||
: 'border-gray-200 text-gray-600'
|
||||
}`}
|
||||
onClick={() => handleContentTypeChange(type.id)}
|
||||
>
|
||||
{type.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">选择要点赞的内容类型</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 rounded-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="enable-friend-tags" className="cursor-pointer">
|
||||
启用好友标签
|
||||
</Label>
|
||||
<Switch
|
||||
id="enable-friend-tags"
|
||||
checked={formData.enableFriendTags}
|
||||
onCheckedChange={(checked) => onChange({ enableFriendTags: checked })}
|
||||
/>
|
||||
</div>
|
||||
{formData.enableFriendTags && (
|
||||
<>
|
||||
<div className="space-y-2 mt-4">
|
||||
<Label htmlFor="friend-tags">好友标签</Label>
|
||||
<Input
|
||||
id="friend-tags"
|
||||
placeholder="请输入标签"
|
||||
value={formData.friendTags || ''}
|
||||
onChange={e => onChange({ friendTags: e.target.value })}
|
||||
className="h-12 rounded-xl border-gray-200"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">只给有此标签的好友点赞</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<Label htmlFor="auto-enabled" className="cursor-pointer">
|
||||
自动开启
|
||||
</Label>
|
||||
<Switch
|
||||
id="auto-enabled"
|
||||
checked={autoEnabled}
|
||||
onCheckedChange={setAutoEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button onClick={onNext} className="w-full h-12 bg-blue-600 hover:bg-blue-700 rounded-xl text-base shadow-sm">
|
||||
下一步
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,533 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Plus,
|
||||
Filter,
|
||||
Search,
|
||||
RefreshCw,
|
||||
MoreVertical,
|
||||
Clock,
|
||||
Edit,
|
||||
Trash2,
|
||||
Eye,
|
||||
Copy,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Settings,
|
||||
Calendar,
|
||||
Users,
|
||||
Send,
|
||||
// CheckCircle,
|
||||
// XCircle,
|
||||
MessageSquare,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import Layout from '@/components/Layout';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import BottomNav from '@/components/BottomNav';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import { fetchGroupPushTasks, deleteGroupPushTask, toggleGroupPushTask, copyGroupPushTask, GroupPushTask } from '@/api/groupPush';
|
||||
import '@/components/Layout.css';
|
||||
|
||||
type CardMenuProps = {
|
||||
onView: () => void;
|
||||
onEdit: () => void;
|
||||
onCopy: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
function CardMenu({ onView, onEdit, onCopy, onDelete }: CardMenuProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const menuRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
if (open) document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<button onClick={() => setOpen((v) => !v)} style={{ background: "none", border: "none", padding: 0, margin: 0, cursor: "pointer" }}>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: 28,
|
||||
background: "#fff",
|
||||
borderRadius: 8,
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
|
||||
zIndex: 100,
|
||||
minWidth: 120,
|
||||
padding: 4,
|
||||
}}
|
||||
>
|
||||
<div onClick={() => { onView(); setOpen(false); }} style={{ padding: 8, cursor: "pointer", display: "flex", alignItems: "center", borderRadius: 6, fontSize: 14, gap: 6, transition: "background .2s" }} onMouseOver={e => (e.currentTarget as HTMLDivElement).style.background="#f5f5f5"} onMouseOut={e => (e.currentTarget as HTMLDivElement).style.background=""}>
|
||||
<Eye className="h-4 w-4 mr-2" />查看
|
||||
</div>
|
||||
<div onClick={() => { onEdit(); setOpen(false); }} style={{ padding: 8, cursor: "pointer", display: "flex", alignItems: "center", borderRadius: 6, fontSize: 14, gap: 6, transition: "background .2s" }} onMouseOver={e => (e.currentTarget as HTMLDivElement).style.background="#f5f5f5"} onMouseOut={e => (e.currentTarget as HTMLDivElement).style.background=""}>
|
||||
<Edit className="h-4 w-4 mr-2" />编辑
|
||||
</div>
|
||||
<div onClick={() => { onCopy(); setOpen(false); }} style={{ padding: 8, cursor: "pointer", display: "flex", alignItems: "center", borderRadius: 6, fontSize: 14, gap: 6, transition: "background .2s" }} onMouseOver={e => (e.currentTarget as HTMLDivElement).style.background="#f5f5f5"} onMouseOut={e => (e.currentTarget as HTMLDivElement).style.background=""}>
|
||||
<Copy className="h-4 w-4 mr-2" />复制
|
||||
</div>
|
||||
<div onClick={() => { onDelete(); setOpen(false); }} style={{ padding: 8, cursor: "pointer", display: "flex", alignItems: "center", borderRadius: 6, fontSize: 14, gap: 6, color: "#e53e3e", transition: "background .2s" }} onMouseOver={e => (e.currentTarget as HTMLDivElement).style.background="#f5f5f5"} onMouseOut={e => (e.currentTarget as HTMLDivElement).style.background=""}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />删除
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 移除旧的PushTask接口,使用从API导入的GroupPushTask
|
||||
|
||||
export default function GroupPush() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const [expandedTaskId, setExpandedTaskId] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [tasks, setTasks] = useState<GroupPushTask[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 获取任务列表
|
||||
const fetchTasks = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const list = await fetchGroupPushTasks();
|
||||
setTasks(list);
|
||||
} catch (error) {
|
||||
console.error('获取群发推送任务失败:', error);
|
||||
toast({
|
||||
title: '获取任务失败',
|
||||
description: '请稍后重试',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 组件加载时获取数据
|
||||
useEffect(() => {
|
||||
fetchTasks();
|
||||
}, []);
|
||||
|
||||
const toggleExpand = (taskId: string) => {
|
||||
setExpandedTaskId(expandedTaskId === taskId ? null : taskId);
|
||||
};
|
||||
|
||||
const handleDelete = async (taskId: string) => {
|
||||
const taskToDelete = tasks.find((task) => task.id === taskId);
|
||||
if (!taskToDelete) return;
|
||||
|
||||
if (!window.confirm(`确定要删除"${taskToDelete.name}"吗?`)) return;
|
||||
|
||||
try {
|
||||
const response = await deleteGroupPushTask(taskId);
|
||||
if (response.code === 200) {
|
||||
setTasks(tasks.filter((task) => task.id !== taskId));
|
||||
toast({
|
||||
title: '删除成功',
|
||||
description: '已成功删除推送任务',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: '删除失败',
|
||||
description: response.message || '请稍后重试',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除任务失败:', error);
|
||||
toast({
|
||||
title: '删除失败',
|
||||
description: '请稍后重试',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (taskId: string) => {
|
||||
navigate(`/workspace/group-push/${taskId}/edit`);
|
||||
};
|
||||
|
||||
const handleView = (taskId: string) => {
|
||||
navigate(`/workspace/group-push/${taskId}`);
|
||||
};
|
||||
|
||||
const handleCopy = async (taskId: string) => {
|
||||
try {
|
||||
const response = await copyGroupPushTask(taskId);
|
||||
if (response.code === 200) {
|
||||
toast({
|
||||
title: '复制成功',
|
||||
description: '已成功复制推送任务',
|
||||
});
|
||||
// 重新获取任务列表
|
||||
fetchTasks();
|
||||
} else {
|
||||
toast({
|
||||
title: '复制失败',
|
||||
description: response.message || '请稍后重试',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('复制任务失败:', error);
|
||||
toast({
|
||||
title: '复制失败',
|
||||
description: '请稍后重试',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTaskStatus = async (taskId: string) => {
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
if (!task) return;
|
||||
|
||||
// 先更新本地状态
|
||||
const newStatus = task.status === 1 ? 2 : 1;
|
||||
setTasks(
|
||||
tasks.map((task) =>
|
||||
task.id === taskId ? { ...task, status: newStatus } : task,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await toggleGroupPushTask(taskId, String(newStatus));
|
||||
if (response.code === 200) {
|
||||
toast({
|
||||
title: task.status === 1 ? '已暂停' : '已启动',
|
||||
description: `${task.name}任务${task.status === 1 ? '已暂停' : '已启动'}`,
|
||||
});
|
||||
} else {
|
||||
// 请求失败,回退本地状态
|
||||
setTasks(
|
||||
tasks.map((task) =>
|
||||
task.id === taskId ? { ...task, status: task.status } : task,
|
||||
),
|
||||
);
|
||||
toast({
|
||||
title: '操作失败',
|
||||
description: response.message || '请稍后重试',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// 请求异常,回退本地状态
|
||||
setTasks(
|
||||
tasks.map((task) =>
|
||||
task.id === taskId ? { ...task, status: task.status } : task,
|
||||
),
|
||||
);
|
||||
toast({
|
||||
title: '操作失败',
|
||||
description: '请稍后重试',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateNew = () => {
|
||||
navigate('/workspace/group-push/new');
|
||||
};
|
||||
|
||||
const filteredTasks = tasks.filter((task) =>
|
||||
task.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
const getStatusColor = (status: number) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 2:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: number) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return '进行中';
|
||||
case 2:
|
||||
return '已暂停';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
};
|
||||
|
||||
const getMessageTypeText = (type: string) => {
|
||||
switch (type) {
|
||||
case 'text':
|
||||
return '文字';
|
||||
case 'image':
|
||||
return '图片';
|
||||
case 'video':
|
||||
return '视频';
|
||||
case 'link':
|
||||
return '链接';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
};
|
||||
|
||||
const getSuccessRate = (pushCount: number, successCount: number) => {
|
||||
if (pushCount === 0) return 0;
|
||||
return Math.round((successCount / pushCount) * 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<PageHeader
|
||||
title="群消息推送"
|
||||
defaultBackPath="/workspace"
|
||||
rightContent={
|
||||
<Button onClick={handleCreateNew}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建任务
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
footer={<BottomNav />}
|
||||
>
|
||||
<div className="bg-gray-50 min-h-screen pb-20">
|
||||
<div className="p-4">
|
||||
{/* 搜索和筛选 */}
|
||||
<Card className="p-4 mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索任务名称"
|
||||
className="pl-9"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="icon">
|
||||
<Filter className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="icon" onClick={fetchTasks} disabled={loading}>
|
||||
{loading ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 任务列表 */}
|
||||
<div className="space-y-4">
|
||||
{filteredTasks.length === 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<Send className="h-12 w-12 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-gray-500 text-lg font-medium mb-2">暂无推送任务</p>
|
||||
<p className="text-gray-400 text-sm mb-4">创建您的第一个群消息推送任务</p>
|
||||
<Button onClick={handleCreateNew}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
创建第一个任务
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
filteredTasks.map((task) => (
|
||||
<Card key={task.id} className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h3 className="font-medium">{task.name}</h3>
|
||||
<Badge className={getStatusColor(task.status)}>
|
||||
{getStatusText(task.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
checked={task.status === 1}
|
||||
onCheckedChange={() => toggleTaskStatus(task.id)}
|
||||
disabled={false}
|
||||
/>
|
||||
<CardMenu
|
||||
onView={() => handleView(task.id)}
|
||||
onEdit={() => handleEdit(task.id)}
|
||||
onCopy={() => handleCopy(task.id)}
|
||||
onDelete={() => handleDelete(task.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="text-sm text-gray-500">
|
||||
<div>执行设备:{task.deviceCount} 个</div>
|
||||
<div>目标群组:{task.targetGroups.length} 个</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
<div>推送成功:{task.successCount}/{task.pushCount}</div>
|
||||
<div>创建人:{task.creator}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 成功率进度条 */}
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-gray-500">推送成功率</span>
|
||||
<span className="font-medium">{getSuccessRate(task.pushCount, task.successCount)}%</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={getSuccessRate(task.pushCount, task.successCount)}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-gray-500 border-t pt-4">
|
||||
<div className="flex items-center">
|
||||
<Clock className="w-4 h-4 mr-1" />
|
||||
上次推送:{task.lastPushTime}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span>创建时间:{task.createTime}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-2 p-0 h-6 w-6"
|
||||
onClick={() => toggleExpand(task.id)}
|
||||
>
|
||||
{expandedTaskId === task.id ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedTaskId === task.id && (
|
||||
<div className="mt-4 pt-4 border-t border-dashed">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center">
|
||||
<Settings className="h-5 w-5 mr-2 text-gray-500" />
|
||||
<h4 className="font-medium">基本设置</h4>
|
||||
</div>
|
||||
<div className="space-y-2 pl-7">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">推送间隔:</span>
|
||||
<span>{task.pushInterval} 秒</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">每日最大推送数:</span>
|
||||
<span>{task.maxPushPerDay} 条</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">执行时间段:</span>
|
||||
<span>
|
||||
{task.timeRange.start} - {task.timeRange.end}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">推送模式:</span>
|
||||
<span>{task.pushMode === 'immediate' ? '立即推送' : '定时推送'}</span>
|
||||
</div>
|
||||
{task.scheduledTime && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">定时时间:</span>
|
||||
<span>{task.scheduledTime}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center">
|
||||
<Users className="h-5 w-5 mr-2 text-gray-500" />
|
||||
<h4 className="font-medium">目标群组</h4>
|
||||
</div>
|
||||
<div className="space-y-2 pl-7">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{task.targetGroups.map((group) => (
|
||||
<Badge key={group} variant="outline" className="bg-gray-50">
|
||||
{group}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center">
|
||||
<MessageSquare className="h-5 w-5 mr-2 text-gray-500" />
|
||||
<h4 className="font-medium">消息内容</h4>
|
||||
</div>
|
||||
<div className="space-y-2 pl-7">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">消息类型:</span>
|
||||
<span>{getMessageTypeText(task.messageType)}</span>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<div className="text-gray-500 mb-1">消息内容:</div>
|
||||
<div className="bg-gray-50 p-2 rounded text-xs">
|
||||
{task.messageContent}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center">
|
||||
<Calendar className="h-5 w-5 mr-2 text-gray-500" />
|
||||
<h4 className="font-medium">执行进度</h4>
|
||||
</div>
|
||||
<div className="space-y-2 pl-7">
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-gray-500">今日已推送:</span>
|
||||
<span>
|
||||
{task.pushCount} / {task.maxPushPerDay}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={(task.pushCount / task.maxPushPerDay) * 100}
|
||||
className="h-2"
|
||||
/>
|
||||
{task.targetTags.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-sm text-gray-500 mb-1">目标标签:</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{task.targetTags.map((tag) => (
|
||||
<Badge key={tag} variant="outline" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Minus, Plus } from 'lucide-react';
|
||||
|
||||
interface BasicSettingsProps {
|
||||
defaultValues?: {
|
||||
name: string;
|
||||
pushTimeStart: string;
|
||||
pushTimeEnd: string;
|
||||
dailyPushCount: number;
|
||||
pushOrder: 'earliest' | 'latest';
|
||||
isLoopPush: boolean;
|
||||
isImmediatePush: boolean;
|
||||
isEnabled: boolean;
|
||||
};
|
||||
onNext: (values: any) => void;
|
||||
onSave: (values: any) => void;
|
||||
onCancel: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function BasicSettings({
|
||||
defaultValues = {
|
||||
name: '',
|
||||
pushTimeStart: '06:00',
|
||||
pushTimeEnd: '23:59',
|
||||
dailyPushCount: 20,
|
||||
pushOrder: 'latest',
|
||||
isLoopPush: false,
|
||||
isImmediatePush: false,
|
||||
isEnabled: false,
|
||||
},
|
||||
onNext,
|
||||
onSave,
|
||||
onCancel,
|
||||
loading = false,
|
||||
}: BasicSettingsProps) {
|
||||
const [values, setValues] = useState(defaultValues);
|
||||
|
||||
const handleChange = (field: string, value: any) => {
|
||||
setValues((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleCountChange = (increment: boolean) => {
|
||||
setValues((prev) => ({
|
||||
...prev,
|
||||
dailyPushCount: increment ? prev.dailyPushCount + 1 : Math.max(1, prev.dailyPushCount - 1),
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="p-4 sm:p-6">
|
||||
<div className="space-y-4">
|
||||
{/* 任务名称 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="taskName" className="flex items-center text-sm font-medium">
|
||||
<span className="text-red-500 mr-1">*</span>任务名称:
|
||||
</Label>
|
||||
<Input
|
||||
id="taskName"
|
||||
value={values.name}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
placeholder="请输入任务名称"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 允许推送的时间段 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">允许推送的时间段:</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input
|
||||
type="time"
|
||||
value={values.pushTimeStart}
|
||||
onChange={(e) => handleChange('pushTimeStart', e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
<span className="text-gray-500">至</span>
|
||||
<Input
|
||||
type="time"
|
||||
value={values.pushTimeEnd}
|
||||
onChange={(e) => handleChange('pushTimeEnd', e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 每日推送 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">每日推送:</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => handleCountChange(false)}
|
||||
className="h-9 w-9"
|
||||
disabled={loading}
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Input
|
||||
type="number"
|
||||
value={values.dailyPushCount.toString()}
|
||||
onChange={(e) => handleChange('dailyPushCount', Number.parseInt(e.target.value) || 1)}
|
||||
className="w-20 text-center"
|
||||
min={1}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => handleCountChange(true)}
|
||||
className="h-9 w-9"
|
||||
disabled={loading}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-gray-500">条内容</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 推送顺序 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">推送顺序:</Label>
|
||||
<div className="flex">
|
||||
<Button
|
||||
type="button"
|
||||
variant={values.pushOrder === 'earliest' ? 'default' : 'outline'}
|
||||
className={`rounded-r-none flex-1 ${values.pushOrder === 'earliest' ? '' : 'text-gray-500'}`}
|
||||
onClick={() => handleChange('pushOrder', 'earliest')}
|
||||
disabled={loading}
|
||||
>
|
||||
按最早
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={values.pushOrder === 'latest' ? 'default' : 'outline'}
|
||||
className={`rounded-l-none flex-1 ${values.pushOrder === 'latest' ? '' : 'text-gray-500'}`}
|
||||
onClick={() => handleChange('pushOrder', 'latest')}
|
||||
disabled={loading}
|
||||
>
|
||||
按最新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 是否循环推送 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="isLoopPush" className="flex items-center text-sm font-medium">
|
||||
<span className="text-red-500 mr-1">*</span>是否循环推送:
|
||||
</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className={values.isLoopPush ? 'text-gray-400' : 'text-gray-900'}>否</span>
|
||||
<Switch
|
||||
id="isLoopPush"
|
||||
checked={values.isLoopPush}
|
||||
onCheckedChange={(checked) => handleChange('isLoopPush', checked)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<span className={values.isLoopPush ? 'text-gray-900' : 'text-gray-400'}>是</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 是否立即推送 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="isImmediatePush" className="flex items-center text-sm font-medium">
|
||||
<span className="text-red-500 mr-1">*</span>是否立即推送:
|
||||
</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className={values.isImmediatePush ? 'text-gray-400' : 'text-gray-900'}>否</span>
|
||||
<Switch
|
||||
id="isImmediatePush"
|
||||
checked={values.isImmediatePush}
|
||||
onCheckedChange={(checked) => handleChange('isImmediatePush', checked)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<span className={values.isImmediatePush ? 'text-gray-900' : 'text-gray-400'}>是</span>
|
||||
</div>
|
||||
</div>
|
||||
{values.isImmediatePush && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-3 text-sm text-yellow-700">
|
||||
如果启用,系统会把内容库里所有的内容按顺序推送到指定的社群
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 是否启用 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="isEnabled" className="flex items-center text-sm font-medium">
|
||||
<span className="text-red-500 mr-1">*</span>是否启用:
|
||||
</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className={values.isEnabled ? 'text-gray-400' : 'text-gray-900'}>否</span>
|
||||
<Switch
|
||||
id="isEnabled"
|
||||
checked={values.isEnabled}
|
||||
onCheckedChange={(checked) => handleChange('isEnabled', checked)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<span className={values.isEnabled ? 'text-gray-900' : 'text-gray-400'}>是</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex space-x-2 justify-center sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onNext(values)}
|
||||
className="flex-1 sm:flex-none"
|
||||
disabled={loading}
|
||||
>
|
||||
下一步
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onSave(values)}
|
||||
className="flex-1 sm:flex-none"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
className="flex-1 sm:flex-none"
|
||||
disabled={loading}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Search, FileText } from 'lucide-react';
|
||||
|
||||
interface ContentLibrary {
|
||||
id: string;
|
||||
name: string;
|
||||
targets: Array<{
|
||||
id: string;
|
||||
avatar: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ContentSelectorProps {
|
||||
selectedLibraries: ContentLibrary[];
|
||||
onLibrariesChange: (libraries: ContentLibrary[]) => void;
|
||||
onPrevious: () => void;
|
||||
onNext: () => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
// 模拟内容库数据
|
||||
const mockLibraries: ContentLibrary[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: '产品推广内容库',
|
||||
targets: [
|
||||
{ id: '1', avatar: 'https://via.placeholder.com/32' },
|
||||
{ id: '2', avatar: 'https://via.placeholder.com/32' },
|
||||
{ id: '3', avatar: 'https://via.placeholder.com/32' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: '活动宣传内容库',
|
||||
targets: [
|
||||
{ id: '4', avatar: 'https://via.placeholder.com/32' },
|
||||
{ id: '5', avatar: 'https://via.placeholder.com/32' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: '客户服务内容库',
|
||||
targets: [
|
||||
{ id: '6', avatar: 'https://via.placeholder.com/32' },
|
||||
{ id: '7', avatar: 'https://via.placeholder.com/32' },
|
||||
{ id: '8', avatar: 'https://via.placeholder.com/32' },
|
||||
{ id: '9', avatar: 'https://via.placeholder.com/32' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: '节日问候内容库',
|
||||
targets: [
|
||||
{ id: '10', avatar: 'https://via.placeholder.com/32' },
|
||||
{ id: '11', avatar: 'https://via.placeholder.com/32' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: '新品发布内容库',
|
||||
targets: [
|
||||
{ id: '12', avatar: 'https://via.placeholder.com/32' },
|
||||
{ id: '13', avatar: 'https://via.placeholder.com/32' },
|
||||
{ id: '14', avatar: 'https://via.placeholder.com/32' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function ContentSelector({
|
||||
selectedLibraries,
|
||||
onLibrariesChange,
|
||||
onPrevious,
|
||||
onNext,
|
||||
onSave,
|
||||
onCancel,
|
||||
loading = false,
|
||||
}: ContentSelectorProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [libraries, setLibraries] = useState<ContentLibrary[]>(mockLibraries);
|
||||
|
||||
const filteredLibraries = libraries.filter((library) =>
|
||||
library.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const handleLibraryToggle = (library: ContentLibrary, checked: boolean) => {
|
||||
if (checked) {
|
||||
onLibrariesChange([...selectedLibraries, library]);
|
||||
} else {
|
||||
onLibrariesChange(selectedLibraries.filter((l) => l.id !== library.id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedLibraries.length === filteredLibraries.length) {
|
||||
onLibrariesChange([]);
|
||||
} else {
|
||||
onLibrariesChange(filteredLibraries);
|
||||
}
|
||||
};
|
||||
|
||||
const isLibrarySelected = (libraryId: string) => {
|
||||
return selectedLibraries.some((library) => library.id === libraryId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="p-4 sm:p-6">
|
||||
<div className="space-y-4">
|
||||
{/* 搜索框 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">搜索内容库:</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索内容库名称"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 全选按钮 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="selectAll"
|
||||
checked={selectedLibraries.length === filteredLibraries.length && filteredLibraries.length > 0}
|
||||
onCheckedChange={handleSelectAll}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Label htmlFor="selectAll" className="text-sm font-medium">
|
||||
全选 ({selectedLibraries.length}/{filteredLibraries.length})
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* 内容库列表 */}
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{filteredLibraries.map((library) => (
|
||||
<div
|
||||
key={library.id}
|
||||
className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<Checkbox
|
||||
id={library.id}
|
||||
checked={isLibrarySelected(library.id)}
|
||||
onCheckedChange={(checked) => handleLibraryToggle(library, checked as boolean)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<FileText className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">{library.name}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
包含 {library.targets.length} 条内容
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex -space-x-1">
|
||||
{library.targets.slice(0, 3).map((target) => (
|
||||
<img
|
||||
key={target.id}
|
||||
src={target.avatar}
|
||||
alt=""
|
||||
className="w-6 h-6 rounded-full border border-white"
|
||||
/>
|
||||
))}
|
||||
{library.targets.length > 3 && (
|
||||
<div className="w-6 h-6 rounded-full bg-gray-200 border border-white flex items-center justify-center">
|
||||
<span className="text-xs text-gray-600">+{library.targets.length - 3}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredLibraries.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<FileText className="h-12 w-12 mx-auto mb-2 text-gray-300" />
|
||||
<p>没有找到匹配的内容库</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex space-x-2 justify-center sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onPrevious}
|
||||
className="flex-1 sm:flex-none"
|
||||
disabled={loading}
|
||||
>
|
||||
上一步
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onNext}
|
||||
className="flex-1 sm:flex-none"
|
||||
disabled={loading || selectedLibraries.length === 0}
|
||||
>
|
||||
下一步
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onSave}
|
||||
className="flex-1 sm:flex-none"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
className="flex-1 sm:flex-none"
|
||||
disabled={loading}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Search, Users } from 'lucide-react';
|
||||
|
||||
interface WechatGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
serviceAccount: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface GroupSelectorProps {
|
||||
selectedGroups: WechatGroup[];
|
||||
onGroupsChange: (groups: WechatGroup[]) => void;
|
||||
onPrevious: () => void;
|
||||
onNext: () => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
// 模拟群组数据
|
||||
const mockGroups: WechatGroup[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'VIP客户群',
|
||||
avatar: 'https://via.placeholder.com/40',
|
||||
serviceAccount: {
|
||||
id: '1',
|
||||
name: '客服小美',
|
||||
avatar: 'https://via.placeholder.com/32',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: '潜在客户群',
|
||||
avatar: 'https://via.placeholder.com/40',
|
||||
serviceAccount: {
|
||||
id: '1',
|
||||
name: '客服小美',
|
||||
avatar: 'https://via.placeholder.com/32',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: '活动群',
|
||||
avatar: 'https://via.placeholder.com/40',
|
||||
serviceAccount: {
|
||||
id: '2',
|
||||
name: '推广专员',
|
||||
avatar: 'https://via.placeholder.com/32',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: '推广群',
|
||||
avatar: 'https://via.placeholder.com/40',
|
||||
serviceAccount: {
|
||||
id: '2',
|
||||
name: '推广专员',
|
||||
avatar: 'https://via.placeholder.com/32',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: '新客户群',
|
||||
avatar: 'https://via.placeholder.com/40',
|
||||
serviceAccount: {
|
||||
id: '3',
|
||||
name: '销售小王',
|
||||
avatar: 'https://via.placeholder.com/32',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
name: '体验群',
|
||||
avatar: 'https://via.placeholder.com/40',
|
||||
serviceAccount: {
|
||||
id: '3',
|
||||
name: '销售小王',
|
||||
avatar: 'https://via.placeholder.com/32',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export default function GroupSelector({
|
||||
selectedGroups,
|
||||
onGroupsChange,
|
||||
onPrevious,
|
||||
onNext,
|
||||
onSave,
|
||||
onCancel,
|
||||
loading = false,
|
||||
}: GroupSelectorProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [groups, setGroups] = useState<WechatGroup[]>(mockGroups);
|
||||
|
||||
const filteredGroups = groups.filter((group) =>
|
||||
group.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
group.serviceAccount.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const handleGroupToggle = (group: WechatGroup, checked: boolean) => {
|
||||
if (checked) {
|
||||
onGroupsChange([...selectedGroups, group]);
|
||||
} else {
|
||||
onGroupsChange(selectedGroups.filter((g) => g.id !== group.id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedGroups.length === filteredGroups.length) {
|
||||
onGroupsChange([]);
|
||||
} else {
|
||||
onGroupsChange(filteredGroups);
|
||||
}
|
||||
};
|
||||
|
||||
const isGroupSelected = (groupId: string) => {
|
||||
return selectedGroups.some((group) => group.id === groupId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="p-4 sm:p-6">
|
||||
<div className="space-y-4">
|
||||
{/* 搜索框 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">搜索群组:</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索群组名称或客服名称"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 全选按钮 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="selectAll"
|
||||
checked={selectedGroups.length === filteredGroups.length && filteredGroups.length > 0}
|
||||
onCheckedChange={handleSelectAll}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Label htmlFor="selectAll" className="text-sm font-medium">
|
||||
全选 ({selectedGroups.length}/{filteredGroups.length})
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* 群组列表 */}
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{filteredGroups.map((group) => (
|
||||
<div
|
||||
key={group.id}
|
||||
className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<Checkbox
|
||||
id={group.id}
|
||||
checked={isGroupSelected(group.id)}
|
||||
onCheckedChange={(checked) => handleGroupToggle(group, checked as boolean)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
<img
|
||||
src={group.avatar}
|
||||
alt={group.name}
|
||||
className="w-10 h-10 rounded-full"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">{group.name}</div>
|
||||
<div className="text-xs text-gray-500 flex items-center">
|
||||
<img
|
||||
src={group.serviceAccount.avatar}
|
||||
alt={group.serviceAccount.name}
|
||||
className="w-4 h-4 rounded-full mr-1"
|
||||
/>
|
||||
{group.serviceAccount.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredGroups.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Users className="h-12 w-12 mx-auto mb-2 text-gray-300" />
|
||||
<p>没有找到匹配的群组</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex space-x-2 justify-center sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onPrevious}
|
||||
className="flex-1 sm:flex-none"
|
||||
disabled={loading}
|
||||
>
|
||||
上一步
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onNext}
|
||||
className="flex-1 sm:flex-none"
|
||||
disabled={loading || selectedGroups.length === 0}
|
||||
>
|
||||
下一步
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onSave}
|
||||
className="flex-1 sm:flex-none"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
className="flex-1 sm:flex-none"
|
||||
disabled={loading}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Steps, StepItem } from 'tdesign-mobile-react';
|
||||
|
||||
interface StepIndicatorProps {
|
||||
currentStep: number;
|
||||
steps: { id: number; title: string; subtitle: string }[];
|
||||
}
|
||||
|
||||
export default function StepIndicator({ currentStep, steps }: StepIndicatorProps) {
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<Steps current={currentStep - 1}>
|
||||
{steps.map((step) => (
|
||||
<StepItem key={step.id} title={step.subtitle} />
|
||||
))}
|
||||
</Steps>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import { createGroupPushTask } from '@/api/groupPush';
|
||||
import Layout from '@/components/Layout';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import StepIndicator from './components/StepIndicator';
|
||||
import BasicSettings from './components/BasicSettings';
|
||||
import GroupSelector from './components/GroupSelector';
|
||||
import ContentSelector from './components/ContentSelector';
|
||||
|
||||
// 类型定义
|
||||
interface WechatGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
serviceAccount: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ContentLibrary {
|
||||
id: string;
|
||||
name: string;
|
||||
targets: Array<{
|
||||
id: string;
|
||||
avatar: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
name: string;
|
||||
pushTimeStart: string;
|
||||
pushTimeEnd: string;
|
||||
dailyPushCount: number;
|
||||
pushOrder: 'earliest' | 'latest';
|
||||
isLoopPush: boolean;
|
||||
isImmediatePush: boolean;
|
||||
isEnabled: boolean;
|
||||
groups: WechatGroup[];
|
||||
contentLibraries: ContentLibrary[];
|
||||
}
|
||||
|
||||
const steps = [
|
||||
{ id: 1, title: '步骤 1', subtitle: '基础设置' },
|
||||
{ id: 2, title: '步骤 2', subtitle: '选择社群' },
|
||||
{ id: 3, title: '步骤 3', subtitle: '选择内容库' },
|
||||
{ id: 4, title: '步骤 4', subtitle: '京东联盟' },
|
||||
];
|
||||
|
||||
export default function NewGroupPush() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
name: '',
|
||||
pushTimeStart: '06:00',
|
||||
pushTimeEnd: '23:59',
|
||||
dailyPushCount: 20,
|
||||
pushOrder: 'latest',
|
||||
isLoopPush: false,
|
||||
isImmediatePush: false,
|
||||
isEnabled: false,
|
||||
groups: [],
|
||||
contentLibraries: [],
|
||||
});
|
||||
|
||||
const handleBasicSettingsNext = (values: Partial<FormData>) => {
|
||||
setFormData((prev) => ({ ...prev, ...values }));
|
||||
setCurrentStep(2);
|
||||
};
|
||||
|
||||
const handleGroupsChange = (groups: WechatGroup[]) => {
|
||||
setFormData((prev) => ({ ...prev, groups }));
|
||||
};
|
||||
|
||||
const handleLibrariesChange = (contentLibraries: ContentLibrary[]) => {
|
||||
setFormData((prev) => ({ ...prev, contentLibraries }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.name.trim()) {
|
||||
toast({
|
||||
title: '请输入任务名称',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.groups.length === 0) {
|
||||
toast({
|
||||
title: '请选择至少一个社群',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.contentLibraries.length === 0) {
|
||||
toast({
|
||||
title: '请选择至少一个内容库',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// 转换数据格式以匹配API
|
||||
const apiData = {
|
||||
name: formData.name,
|
||||
timeRange: {
|
||||
start: formData.pushTimeStart,
|
||||
end: formData.pushTimeEnd,
|
||||
},
|
||||
maxPushPerDay: formData.dailyPushCount,
|
||||
pushOrder: formData.pushOrder,
|
||||
isLoopPush: formData.isLoopPush,
|
||||
isImmediatePush: formData.isImmediatePush,
|
||||
isEnabled: formData.isEnabled,
|
||||
targetGroups: formData.groups.map(g => g.name),
|
||||
contentLibraries: formData.contentLibraries.map(c => c.name),
|
||||
pushMode: formData.isImmediatePush ? 'immediate' as const : 'scheduled' as const,
|
||||
messageType: 'text' as const,
|
||||
messageContent: '',
|
||||
targetTags: [],
|
||||
pushInterval: 60,
|
||||
};
|
||||
|
||||
const response = await createGroupPushTask(apiData);
|
||||
if (response.code === 200) {
|
||||
toast({
|
||||
title: '保存成功',
|
||||
description: `社群推送任务"${formData.name}"已保存`,
|
||||
});
|
||||
navigate('/workspace/group-push');
|
||||
} else {
|
||||
toast({
|
||||
title: '保存失败',
|
||||
description: response.message || '请稍后重试',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存任务失败:', error);
|
||||
toast({
|
||||
title: '保存失败',
|
||||
description: '请稍后重试',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
navigate('/workspace/group-push');
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<PageHeader
|
||||
title="新建社群推送任务"
|
||||
defaultBackPath="/workspace/group-push"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="container mx-auto py-4 px-4 sm:px-6 md:py-6">
|
||||
<StepIndicator currentStep={currentStep} steps={steps} />
|
||||
|
||||
<div className="mt-8">
|
||||
{currentStep === 1 && (
|
||||
<BasicSettings
|
||||
defaultValues={{
|
||||
name: formData.name,
|
||||
pushTimeStart: formData.pushTimeStart,
|
||||
pushTimeEnd: formData.pushTimeEnd,
|
||||
dailyPushCount: formData.dailyPushCount,
|
||||
pushOrder: formData.pushOrder,
|
||||
isLoopPush: formData.isLoopPush,
|
||||
isImmediatePush: formData.isImmediatePush,
|
||||
isEnabled: formData.isEnabled,
|
||||
}}
|
||||
onNext={handleBasicSettingsNext}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 2 && (
|
||||
<GroupSelector
|
||||
selectedGroups={formData.groups}
|
||||
onGroupsChange={handleGroupsChange}
|
||||
onPrevious={() => setCurrentStep(1)}
|
||||
onNext={() => setCurrentStep(3)}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 3 && (
|
||||
<ContentSelector
|
||||
selectedLibraries={formData.contentLibraries}
|
||||
onLibrariesChange={handleLibrariesChange}
|
||||
onPrevious={() => setCurrentStep(2)}
|
||||
onNext={() => setCurrentStep(4)}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 4 && (
|
||||
<div className="space-y-6">
|
||||
<div className="border rounded-md p-8 text-center text-gray-500">
|
||||
京东联盟设置(此步骤为占位,实际功能待开发)
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2 justify-center sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setCurrentStep(3)}
|
||||
className="flex-1 sm:flex-none"
|
||||
disabled={loading}
|
||||
>
|
||||
上一步
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
className="flex-1 sm:flex-none"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '保存中...' : '完成'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
className="flex-1 sm:flex-none"
|
||||
disabled={loading}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import {
|
||||
fetchMomentsSyncTaskDetail,
|
||||
toggleMomentsSyncTask,
|
||||
syncMoments
|
||||
} from '@/api/momentsSync';
|
||||
import { MomentsSyncTask } from '@/types/moments-sync';
|
||||
import { ChevronLeft, Edit2, RefreshCw, Clock, Database, Smartphone } from 'lucide-react';
|
||||
import Layout from '@/components/Layout';
|
||||
import BottomNav from '@/components/BottomNav';
|
||||
|
||||
export default function MomentsSyncDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const [task, setTask] = useState<MomentsSyncTask | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchTaskDetail = useCallback(async () => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const taskData = await fetchMomentsSyncTaskDetail(id);
|
||||
if (taskData) {
|
||||
setTask(taskData);
|
||||
}
|
||||
} catch (error) {
|
||||
toast({ title: '获取任务详情失败', variant: 'destructive' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
fetchTaskDetail();
|
||||
}
|
||||
}, [id, fetchTaskDetail]);
|
||||
|
||||
const handleToggleStatus = async () => {
|
||||
if (!task || !id) return;
|
||||
try {
|
||||
const newStatus = task.status === 1 ? 2 : 1;
|
||||
await toggleMomentsSyncTask(id, newStatus.toString());
|
||||
setTask({ ...task, status: newStatus });
|
||||
toast({ title: newStatus === 1 ? '任务已开启' : '任务已暂停' });
|
||||
} catch (error) {
|
||||
toast({ title: '操作失败', variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSync = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
await syncMoments(id);
|
||||
toast({ title: '同步任务已启动' });
|
||||
fetchTaskDetail(); // 刷新任务详情
|
||||
} catch (error) {
|
||||
toast({ title: '同步失败', variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
if (id) {
|
||||
navigate(`/workspace/moments-sync/edit/${id}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="flex-1 bg-gray-50 min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!task) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="flex-1 bg-gray-50 min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-600">任务不存在</p>
|
||||
<Button onClick={() => navigate('/workspace/moments-sync')} className="mt-4">
|
||||
返回列表
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<div className="bg-white border-b">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate('/workspace/moments-sync')}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-lg font-medium">朋友圈同步任务详情</h1>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button variant="outline" size="sm" onClick={handleEdit}>
|
||||
<Edit2 className="h-4 w-4 mr-2" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleSync}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
立即同步
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
footer={<BottomNav />}
|
||||
>
|
||||
<div className="bg-gray-50">
|
||||
<div className="p-4 space-y-6">
|
||||
{/* 基本信息卡片 */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<h2 className="text-2xl font-bold">{task.name}</h2>
|
||||
<Badge variant={task.status === 1 ? "default" : "secondary"}>
|
||||
{task.status === 1 ? "进行中" : "已暂停"}
|
||||
</Badge>
|
||||
</div>
|
||||
<Switch checked={task.status === 1} onCheckedChange={handleToggleStatus} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 任务详情 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold flex items-center">
|
||||
<Database className="h-5 w-5 mr-2 text-blue-600" />
|
||||
任务详情
|
||||
</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">推送设备</span>
|
||||
<span className="font-medium">{task.deviceCount} 个</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">内容库</span>
|
||||
<span className="font-medium">{task.contentLib || '未设置'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">已同步</span>
|
||||
<span className="font-medium">{task.syncCount} 条</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">创建人</span>
|
||||
<span className="font-medium">{task.creatorName}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 时间信息 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold flex items-center">
|
||||
<Clock className="h-5 w-5 mr-2 text-green-600" />
|
||||
时间信息
|
||||
</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">创建时间</span>
|
||||
<span className="font-medium">{task.createTime}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">上次同步</span>
|
||||
<span className="font-medium">{task.lastSyncTime || '暂无'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">更新时间</span>
|
||||
<span className="font-medium">{task.updateTime || '暂无'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 同步设置卡片 */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center">
|
||||
<Smartphone className="h-5 w-5 mr-2 text-purple-600" />
|
||||
同步设置
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">同步间隔</span>
|
||||
<span className="font-medium">{task.syncInterval} 秒</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">每日最大</span>
|
||||
<span className="font-medium">{task.maxSyncPerDay || 100} 条</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">时间范围</span>
|
||||
<span className="font-medium">{task.timeRange.start} - {task.timeRange.end}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">同步模式</span>
|
||||
<span className="font-medium">{task.syncMode === 'auto' ? '自动' : '手动'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">今日同步</span>
|
||||
<span className="font-medium">{task.todaySyncCount || 0} 条</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">总同步数</span>
|
||||
<span className="font-medium">{task.totalSyncCount || task.syncCount || 0} 条</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">目标标签</span>
|
||||
<span className="font-medium">{task.targetTags.length} 个</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">内容类型</span>
|
||||
<span className="font-medium">{task.contentTypes.join(', ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 同步记录卡片 */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">最近同步记录</h3>
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-500">暂无同步记录</p>
|
||||
<p className="text-sm text-gray-400 mt-2">任务开始执行后将显示同步记录</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
RefreshCw,
|
||||
MoreVertical,
|
||||
Clock,
|
||||
Edit,
|
||||
Trash2,
|
||||
Eye,
|
||||
Copy,
|
||||
ChevronLeft,
|
||||
Share2,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import '@/components/Layout.css';
|
||||
import {
|
||||
fetchMomentsSyncTasks,
|
||||
deleteMomentsSyncTask,
|
||||
toggleMomentsSyncTask,
|
||||
copyMomentsSyncTask,
|
||||
MomentsSyncTask
|
||||
} from '@/api/momentsSync';
|
||||
|
||||
type CardMenuProps = {
|
||||
onView: () => void;
|
||||
onEdit: () => void;
|
||||
onCopy: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
function CardMenu({ onView, onEdit, onCopy, onDelete }: CardMenuProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const menuRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
if (open) document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<button onClick={() => setOpen((v) => !v)} style={{ background: "none", border: "none", padding: 0, margin: 0, cursor: "pointer" }}>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: 28,
|
||||
background: "#fff",
|
||||
borderRadius: 8,
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
|
||||
zIndex: 100,
|
||||
minWidth: 120,
|
||||
padding: 4,
|
||||
}}
|
||||
>
|
||||
<div onClick={() => { onView(); setOpen(false); }} style={{ padding: 8, cursor: "pointer", display: "flex", alignItems: "center", borderRadius: 6, fontSize: 14, gap: 6, transition: "background .2s" }} onMouseOver={e => (e.currentTarget as HTMLDivElement).style.background="#f5f5f5"} onMouseOut={e => (e.currentTarget as HTMLDivElement).style.background=""}>
|
||||
<Eye className="h-4 w-4 mr-2" />查看
|
||||
</div>
|
||||
<div onClick={() => { onEdit(); setOpen(false); }} style={{ padding: 8, cursor: "pointer", display: "flex", alignItems: "center", borderRadius: 6, fontSize: 14, gap: 6, transition: "background .2s" }} onMouseOver={e => (e.currentTarget as HTMLDivElement).style.background="#f5f5f5"} onMouseOut={e => (e.currentTarget as HTMLDivElement).style.background=""}>
|
||||
<Edit className="h-4 w-4 mr-2" />编辑
|
||||
</div>
|
||||
<div onClick={() => { onCopy(); setOpen(false); }} style={{ padding: 8, cursor: "pointer", display: "flex", alignItems: "center", borderRadius: 6, fontSize: 14, gap: 6, transition: "background .2s" }} onMouseOver={e => (e.currentTarget as HTMLDivElement).style.background="#f5f5f5"} onMouseOut={e => (e.currentTarget as HTMLDivElement).style.background=""}>
|
||||
<Copy className="h-4 w-4 mr-2" />复制
|
||||
</div>
|
||||
<div onClick={() => { onDelete(); setOpen(false); }} style={{ padding: 8, cursor: "pointer", display: "flex", alignItems: "center", borderRadius: 6, fontSize: 14, gap: 6, color: "#e53e3e", transition: "background .2s" }} onMouseOver={e => (e.currentTarget as HTMLDivElement).style.background="#f5f5f5"} onMouseOut={e => (e.currentTarget as HTMLDivElement).style.background=""}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />删除
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MomentsSync() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [tasks, setTasks] = useState<MomentsSyncTask[]>([]);
|
||||
|
||||
// 获取任务列表
|
||||
const fetchTasks = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const list = await fetchMomentsSyncTasks();
|
||||
// 确保数据字段与界面一致
|
||||
const mappedTasks = list.map(task => ({
|
||||
...task,
|
||||
// 确保字段名称和格式与界面一致
|
||||
status: task.status || 2, // 默认为关闭状态
|
||||
deviceCount: task.deviceCount || 0,
|
||||
targetGroup: task.targetGroup || '默认人群',
|
||||
syncCount: task.todaySyncCount || task.syncCount || 0,
|
||||
creatorName: task.creatorName || '未知',
|
||||
lastSyncTime: task.lastSyncTime || '暂无',
|
||||
createTime: task.createTime || '未知',
|
||||
syncInterval: task.syncInterval || 30,
|
||||
maxSyncPerDay: task.maxSyncPerDay || 100,
|
||||
timeRange: task.timeRange || { start: '08:00', end: '22:00' },
|
||||
contentTypes: task.contentTypes || ['text', 'image', 'video'],
|
||||
targetTags: task.targetTags || [],
|
||||
syncMode: task.syncMode || 'auto',
|
||||
filterKeywords: task.filterKeywords || [],
|
||||
contentLib: task.config?.contentLibraryNames?.join(',') || '默认内容库'
|
||||
}));
|
||||
setTasks(mappedTasks);
|
||||
} catch (error) {
|
||||
toast({ title: "获取任务失败", variant: "destructive" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 页面加载时获取数据
|
||||
useEffect(() => {
|
||||
fetchTasks();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
const taskToDelete = tasks.find((task) => task.id === id);
|
||||
if (!taskToDelete) return;
|
||||
|
||||
if (!window.confirm(`确定要删除"${taskToDelete.name}"吗?`)) return;
|
||||
|
||||
try {
|
||||
const response = await deleteMomentsSyncTask(id);
|
||||
if (response.code === 200) {
|
||||
toast({ title: "删除成功" });
|
||||
fetchTasks();
|
||||
} else {
|
||||
toast({ title: "删除失败", description: response.msg || "请稍后重试", variant: "destructive" });
|
||||
}
|
||||
} catch (error) {
|
||||
toast({ title: "删除失败", description: "请稍后重试", variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (taskId: string) => {
|
||||
navigate(`/workspace/moments-sync/edit/${taskId}`);
|
||||
};
|
||||
|
||||
const handleView = (taskId: string) => {
|
||||
navigate(`/workspace/moments-sync/${taskId}`);
|
||||
};
|
||||
|
||||
const handleCopy = async (id: string) => {
|
||||
try {
|
||||
const response = await copyMomentsSyncTask(id);
|
||||
if (response.code === 200) {
|
||||
toast({ title: "复制成功" });
|
||||
fetchTasks();
|
||||
} else {
|
||||
toast({ title: "复制失败", description: response.msg || "请稍后重试", variant: "destructive" });
|
||||
}
|
||||
} catch (error) {
|
||||
toast({ title: "复制失败", description: "请稍后重试", variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTaskStatus = async (id: string, status: number) => {
|
||||
// 先更新本地状态
|
||||
const newStatus = (status === 1 ? 2 : 1) as 1 | 2;
|
||||
setTasks(prevTasks =>
|
||||
prevTasks.map(task =>
|
||||
task.id === id ? { ...task, status: newStatus } : task
|
||||
)
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await toggleMomentsSyncTask(id, String(newStatus));
|
||||
if (response.code === 200) {
|
||||
toast({ title: "操作成功" });
|
||||
// 成功时不刷新列表,保持本地状态
|
||||
} else {
|
||||
// 请求失败,回退本地状态
|
||||
setTasks(prevTasks =>
|
||||
prevTasks.map(task =>
|
||||
task.id === id ? { ...task, status: status as 1 | 2 } : task
|
||||
)
|
||||
);
|
||||
toast({ title: "操作失败", description: response.msg || "请稍后重试", variant: "destructive" });
|
||||
}
|
||||
} catch (error) {
|
||||
// 请求异常,回退本地状态
|
||||
setTasks(prevTasks =>
|
||||
prevTasks.map(task =>
|
||||
task.id === id ? { ...task, status: status as 1 | 2 } : task
|
||||
)
|
||||
);
|
||||
toast({ title: "操作失败", description: "请稍后重试", variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateNew = () => {
|
||||
navigate('/workspace/moments-sync/new');
|
||||
};
|
||||
|
||||
const filteredTasks = tasks.filter((task) =>
|
||||
task.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
const getStatusText = (status: number) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return '进行中';
|
||||
case 2:
|
||||
return '已暂停';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-gray-50 min-h-screen pb-20">
|
||||
<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">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="text-lg font-medium">朋友圈同步</h1>
|
||||
</div>
|
||||
<Button onClick={handleCreateNew}>
|
||||
<Plus className="h-4 w-4 mr-2" />新建任务
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="p-4">
|
||||
<Card className="p-4 mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索任务名称"
|
||||
className="pl-9"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="icon" onClick={fetchTasks} disabled={loading}>
|
||||
{loading ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-4">
|
||||
{filteredTasks.length === 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<Share2 className="h-12 w-12 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-gray-500 text-lg font-medium mb-2">暂无同步任务</p>
|
||||
<p className="text-gray-400 text-sm mb-4">创建您的第一个朋友圈同步任务</p>
|
||||
<Button onClick={handleCreateNew}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
创建第一个任务
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
filteredTasks.map((task) => (
|
||||
<Card key={task.id} className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h3 className="font-medium">{task.name}</h3>
|
||||
<Badge variant={Number(task.status) === 1 ? "success" : "secondary"}>
|
||||
{getStatusText(task.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
checked={Number(task.status) === 1}
|
||||
onCheckedChange={() => toggleTaskStatus(task.id, Number(task.status))}
|
||||
/>
|
||||
<CardMenu
|
||||
onView={() => handleView(task.id)}
|
||||
onEdit={() => handleEdit(task.id)}
|
||||
onCopy={() => handleCopy(task.id)}
|
||||
onDelete={() => handleDelete(task.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="text-sm text-gray-500">
|
||||
<div>推送设备:{task?.config?.devices.length||0} 个</div>
|
||||
<div className="flex">
|
||||
<span className="flex-shrink-0">内容库:</span>
|
||||
<span className="truncate" title={task.contentLib || '默认内容库'}>
|
||||
{task.contentLib || '默认内容库'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
<div>已同步:{task.syncCount} 条</div>
|
||||
<div>创建人:{task.creatorName}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-gray-500 border-t pt-4">
|
||||
<div className="flex items-center">
|
||||
<Clock className="w-4 h-4 mr-1" />
|
||||
上次同步:{task.lastSyncTime}
|
||||
</div>
|
||||
<div>创建时间:{task.createTime}</div>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Layout from '@/components/Layout';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { RefreshCw, Search, RefreshCw as SyncIcon, Eye } from 'lucide-react';
|
||||
import { fetchMomentsSyncTasks, syncMoments, syncAllMoments, MomentsSyncTask } from '@/api/momentsSync';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
|
||||
export default function MomentsSyncPage() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const [tasks, setTasks] = useState<MomentsSyncTask[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const fetchTasks = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const list = await fetchMomentsSyncTasks();
|
||||
setTasks(list);
|
||||
} catch {
|
||||
toast({ title: '获取任务失败', variant: 'destructive' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { fetchTasks(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleSearch = () => {
|
||||
fetchTasks();
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
setSearch('');
|
||||
fetchTasks();
|
||||
};
|
||||
|
||||
const handleSync = async (id: string) => {
|
||||
try {
|
||||
await syncMoments(id);
|
||||
toast({ title: '同步已发起' });
|
||||
fetchTasks();
|
||||
} catch {
|
||||
toast({ title: '同步失败', variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncAll = async () => {
|
||||
try {
|
||||
await syncAllMoments();
|
||||
toast({ title: '全部同步已发起' });
|
||||
fetchTasks();
|
||||
} catch {
|
||||
toast({ title: '同步失败', variant: 'destructive' });
|
||||
}
|
||||
};
|
||||
|
||||
const filteredTasks = tasks.filter(task =>
|
||||
task.name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={<PageHeader title="朋友圈同步" defaultBackPath="/workspace" />}
|
||||
>
|
||||
<div className="bg-gray-50 min-h-screen pb-20">
|
||||
<div className="p-4 flex items-center space-x-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder="搜索任务名称"
|
||||
className="pl-9"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleSearch(); }}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="icon" onClick={handleRefresh}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button onClick={handleSyncAll} variant="default" size="sm">
|
||||
<SyncIcon className="h-4 w-4 mr-1" />全部同步
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
{loading ? (
|
||||
<Card className="p-8 text-center">加载中...</Card>
|
||||
) : filteredTasks.length === 0 ? (
|
||||
<Card className="p-8 text-center">暂无任务</Card>
|
||||
) : (
|
||||
filteredTasks.map(task => (
|
||||
<Card key={task.id} className="p-4 flex flex-col md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<span className="font-medium text-base">{task.name}</span>
|
||||
<Badge variant={
|
||||
task.status === 1 ? 'success' : 'secondary'
|
||||
}>
|
||||
{task.status === 1 ? '进行中' : '已暂停'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mb-2">上次同步:{task.lastSyncTime || '无'}</div>
|
||||
<div className="text-xs text-gray-500 mb-2">已同步:{task.syncCount || 0} 条</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 mt-2 md:mt-0">
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/workspace/moments-sync/${task.id}`)}>
|
||||
<Eye className="h-4 w-4 mr-1" />查看
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => handleSync(task.id)}>
|
||||
<SyncIcon className="h-4 w-4 mr-1" />同步
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,410 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import { createMomentsSyncTask, updateMomentsSyncTask, fetchMomentsSyncTaskDetail } from '@/api/momentsSync';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { DeviceSelectionDialog } from '@/components/DeviceSelectionDialog';
|
||||
import { ContentLibrarySelectionDialog } from '@/components/ContentLibrarySelectionDialog';
|
||||
import { ContentType } from '@/types/moments-sync';
|
||||
|
||||
// 步骤指示器组件
|
||||
interface StepIndicatorProps {
|
||||
currentStep: number;
|
||||
}
|
||||
|
||||
function StepIndicator({ currentStep }: StepIndicatorProps) {
|
||||
const steps = [
|
||||
{ id: 1, title: "基础设置" },
|
||||
{ id: 2, title: "设备选择" },
|
||||
{ id: 3, title: "选择内容库" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="relative flex justify-between px-6 mb-8">
|
||||
{/* 连线 - 背景线 */}
|
||||
<div className="absolute top-5 left-0 right-0 h-[1px] bg-gray-200" style={{ width: '100%', zIndex: 0 }} />
|
||||
|
||||
{/* 连线 - 已完成线 */}
|
||||
<div
|
||||
className="absolute top-5 left-0 h-[1px] bg-blue-600 transition-all duration-300"
|
||||
style={{
|
||||
width: `${((currentStep - 1) / (steps.length - 1)) * 100}%`,
|
||||
zIndex: 1
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 步骤圆圈 */}
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.id} className="flex flex-col items-center relative z-10">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center mb-2 ${
|
||||
currentStep === index + 1
|
||||
? "bg-blue-600 text-white"
|
||||
: index + 1 < currentStep
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-white text-gray-400 border border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className={`text-sm ${
|
||||
currentStep === index + 1 || index + 1 < currentStep
|
||||
? "text-blue-600"
|
||||
: "text-gray-400"
|
||||
}`}>
|
||||
{step.title}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NewMomentsSyncTask() {
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { toast } = useToast();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [deviceDialogOpen, setDeviceDialogOpen] = useState(false);
|
||||
const [contentLibraryDialogOpen, setContentLibraryDialogOpen] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
taskName: '',
|
||||
startTime: '06:00',
|
||||
endTime: '23:59',
|
||||
syncCount: 5,
|
||||
interval: 30, // 同步间隔,单位:分钟
|
||||
accountType: 'business' as 'business' | 'personal',
|
||||
enabled: true,
|
||||
selectedDevices: [] as string[],
|
||||
selectedLibraries: [] as string[],
|
||||
contentTypes: ['text', 'image', 'video'] as ContentType[],
|
||||
targetTags: [] as string[],
|
||||
filterKeywords: [] as string[],
|
||||
});
|
||||
|
||||
const isEditMode = !!id;
|
||||
|
||||
const handleUpdateFormData = (data: Partial<typeof formData>) => {
|
||||
setFormData((prev) => ({ ...prev, ...data }));
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
setCurrentStep((prev) => Math.min(prev + 1, 3));
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
setCurrentStep((prev) => Math.max(prev - 1, 1));
|
||||
};
|
||||
|
||||
// 获取任务详情
|
||||
const fetchTaskDetail = useCallback(async () => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const taskData = await fetchMomentsSyncTaskDetail(id);
|
||||
if (taskData) {
|
||||
setFormData({
|
||||
taskName: taskData.name,
|
||||
startTime: taskData.timeRange?.start || '06:00',
|
||||
endTime: taskData.timeRange?.end || '23:59',
|
||||
syncCount: taskData.maxSyncPerDay || 5,
|
||||
interval: taskData.syncInterval || 30,
|
||||
accountType: taskData.syncMode === 'auto' ? 'business' : 'personal',
|
||||
enabled: taskData.status === 1,
|
||||
selectedDevices: taskData.devices || [],
|
||||
selectedLibraries: taskData.contentLib ? [taskData.contentLib] : [],
|
||||
contentTypes: taskData.contentTypes || ['text', 'image', 'video'],
|
||||
targetTags: taskData.targetTags || [],
|
||||
filterKeywords: taskData.filterKeywords || [],
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast({ title: '获取任务详情失败', variant: 'destructive' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditMode) {
|
||||
fetchTaskDetail();
|
||||
}
|
||||
}, [isEditMode, fetchTaskDetail]);
|
||||
|
||||
const handleComplete = async () => {
|
||||
if (!formData.taskName.trim()) {
|
||||
toast({ title: '请输入任务名称', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
if (formData.selectedDevices.length === 0) {
|
||||
toast({ title: '请选择设备', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
if (formData.selectedLibraries.length === 0) {
|
||||
toast({ title: '请选择内容库', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const taskData = {
|
||||
name: formData.taskName,
|
||||
devices: formData.selectedDevices,
|
||||
contentLibraries: formData.selectedLibraries,
|
||||
syncInterval: formData.interval,
|
||||
syncCount: formData.syncCount,
|
||||
syncType: formData.accountType === 'business' ? 1 : 2,
|
||||
startTime: formData.startTime,
|
||||
endTime: formData.endTime,
|
||||
accountType: formData.accountType === 'business' ? 1 : 2,
|
||||
contentTypes: formData.contentTypes,
|
||||
targetTags: formData.targetTags,
|
||||
filterKeywords: formData.filterKeywords,
|
||||
};
|
||||
|
||||
if (isEditMode && id) {
|
||||
await updateMomentsSyncTask({
|
||||
id,
|
||||
...taskData,
|
||||
});
|
||||
toast({ title: '更新成功' });
|
||||
navigate(`/workspace/moments-sync/${id}`);
|
||||
} else {
|
||||
await createMomentsSyncTask(taskData);
|
||||
toast({ title: '创建成功' });
|
||||
navigate('/workspace/moments-sync');
|
||||
}
|
||||
} catch (error) {
|
||||
toast({ title: isEditMode ? '更新失败' : '创建失败', variant: 'destructive' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 基础设置步骤内容
|
||||
const renderBasicSettings = () => {
|
||||
return (
|
||||
<div className="px-4">
|
||||
<div className="mb-6">
|
||||
<div className="text-base font-medium mb-2">任务名称</div>
|
||||
<Input
|
||||
value={formData.taskName}
|
||||
onChange={(e) => handleUpdateFormData({ taskName: e.target.value })}
|
||||
placeholder="请输入任务名称"
|
||||
className="h-12 rounded-lg border border-gray-200 focus-visible:ring-0 focus-visible:border-blue-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="text-base font-medium mb-2">每日同步数量</div>
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={() => handleUpdateFormData({ syncCount: Math.max(1, formData.syncCount - 1) })}
|
||||
className="w-12 h-12 rounded-lg bg-white border border-gray-200 flex items-center justify-center text-xl"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<div className="flex-1 text-center text-lg font-medium">
|
||||
{formData.syncCount}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleUpdateFormData({ syncCount: formData.syncCount + 1 })}
|
||||
className="w-12 h-12 rounded-lg bg-white border border-gray-200 flex items-center justify-center text-xl"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<span className="ml-2 text-gray-500">条/天</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="text-base font-medium mb-2">同步间隔</div>
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={() => handleUpdateFormData({ interval: Math.max(1, formData.interval - 1) })}
|
||||
className="w-12 h-12 rounded-lg bg-white border border-gray-200 flex items-center justify-center text-xl"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<div className="flex-1 text-center text-lg font-medium">
|
||||
{formData.interval}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleUpdateFormData({ interval: formData.interval + 1 })}
|
||||
className="w-12 h-12 rounded-lg bg-white border border-gray-200 flex items-center justify-center text-xl"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<span className="ml-2 text-gray-500">分钟</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">设置每次发朋友圈的时间间隔</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="text-base font-medium mb-2">同步时间</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="time"
|
||||
value={formData.startTime}
|
||||
onChange={(e) => handleUpdateFormData({ startTime: e.target.value })}
|
||||
className="h-12 rounded-lg border-gray-200 text-base"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-gray-500">至</span>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="time"
|
||||
value={formData.endTime}
|
||||
onChange={(e) => handleUpdateFormData({ endTime: e.target.value })}
|
||||
className="h-12 rounded-lg border-gray-200 text-base"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="text-base font-medium mb-2">账号类型</div>
|
||||
<div className="flex space-x-4">
|
||||
<button
|
||||
onClick={() => handleUpdateFormData({ accountType: "business" })}
|
||||
className={`flex-1 h-12 rounded-lg flex items-center justify-center ${
|
||||
formData.accountType === "business"
|
||||
? "bg-blue-50 border border-blue-500 text-blue-600"
|
||||
: "bg-white border border-gray-200"
|
||||
}`}
|
||||
>
|
||||
业务号
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleUpdateFormData({ accountType: "personal" })}
|
||||
className={`flex-1 h-12 rounded-lg flex items-center justify-center ${
|
||||
formData.accountType === "personal"
|
||||
? "bg-blue-50 border border-blue-500 text-blue-600"
|
||||
: "bg-white border border-gray-200"
|
||||
}`}
|
||||
>
|
||||
人设号
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<span className="text-base font-medium">是否启用</span>
|
||||
<Switch
|
||||
checked={formData.enabled}
|
||||
onCheckedChange={(checked) => handleUpdateFormData({ enabled: checked })}
|
||||
className="data-[state=checked]:bg-blue-600 h-7 w-12"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
className="w-full h-12 bg-blue-500 hover:bg-blue-600 rounded-lg text-base font-medium text-white"
|
||||
>
|
||||
下一步
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="sticky top-0 z-10 bg-white border-b">
|
||||
<div className="flex items-center h-14 px-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(-1)} className="hover:bg-gray-50">
|
||||
<ChevronLeft className="h-6 w-6" />
|
||||
</Button>
|
||||
<h1 className="ml-2 text-lg font-medium">新建朋友圈同步</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mt-8">
|
||||
<StepIndicator currentStep={currentStep} />
|
||||
|
||||
{currentStep === 1 && renderBasicSettings()}
|
||||
|
||||
{currentStep === 2 && (
|
||||
<div className="px-4 space-y-6">
|
||||
<Input
|
||||
placeholder="选择设备"
|
||||
className="h-12 rounded-lg border-gray-200"
|
||||
onClick={() => setDeviceDialogOpen(true)}
|
||||
readOnly
|
||||
/>
|
||||
|
||||
{formData.selectedDevices.length > 0 && (
|
||||
<div className="text-base text-gray-500">
|
||||
已选设备:{formData.selectedDevices.length} 个
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex space-x-4 pt-4">
|
||||
<Button variant="outline" onClick={handlePrev} className="flex-1 h-12 rounded-lg">
|
||||
上一步
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
className="flex-1 h-12 bg-blue-500 hover:bg-blue-600 rounded-lg text-white"
|
||||
>
|
||||
下一步
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DeviceSelectionDialog
|
||||
open={deviceDialogOpen}
|
||||
onOpenChange={setDeviceDialogOpen}
|
||||
selectedDevices={formData.selectedDevices}
|
||||
onSelect={(devices) => {
|
||||
handleUpdateFormData({ selectedDevices: devices });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 3 && (
|
||||
<div className="px-4 space-y-6">
|
||||
<Input
|
||||
placeholder="选择内容库"
|
||||
className="h-12 rounded-lg border-gray-200"
|
||||
onClick={() => setContentLibraryDialogOpen(true)}
|
||||
readOnly
|
||||
/>
|
||||
|
||||
{formData.selectedLibraries.length > 0 && (
|
||||
<div className="text-base text-gray-500">
|
||||
已选内容库:{formData.selectedLibraries.length} 个
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex space-x-4 pt-4">
|
||||
<Button variant="outline" onClick={handlePrev} className="flex-1 h-12 rounded-lg">
|
||||
上一步
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleComplete}
|
||||
loading={loading}
|
||||
className="flex-1 h-12 bg-blue-500 hover:bg-blue-600 rounded-lg text-white"
|
||||
>
|
||||
{loading ? '创建中...' : '完成'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ContentLibrarySelectionDialog
|
||||
open={contentLibraryDialogOpen}
|
||||
onOpenChange={setContentLibraryDialogOpen}
|
||||
selectedLibraries={formData.selectedLibraries}
|
||||
onSelect={(libraries) => {
|
||||
handleUpdateFormData({ selectedLibraries: libraries });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
export default function TrafficDistributionDetail() {
|
||||
const { id } = useParams();
|
||||
return <div>流量分配详情页,当前ID: {id}</div>;
|
||||
}
|
||||
@@ -1,858 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Steps, StepItem } from 'tdesign-mobile-react';
|
||||
import {
|
||||
Users,
|
||||
Search,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import Layout from '@/components/Layout';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import DeviceSelection from '@/components/DeviceSelection';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import { fetchAccountList, Account, createDistributionRule, updateDistributionRule, fetchDistributionRuleDetail } from '@/api/trafficDistribution';
|
||||
import '@/components/Layout.css';
|
||||
import TrafficPoolSelection from '@/components/TrafficPoolSelection';
|
||||
|
||||
interface BasicInfoData {
|
||||
name: string;
|
||||
distributionMethod: 'equal' | 'priority' | 'ratio';
|
||||
dailyLimit: number;
|
||||
timeRestriction: 'allDay' | 'custom';
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
selectedAccounts: string[];
|
||||
}
|
||||
|
||||
interface TargetSettingsData {
|
||||
selectedDevices: string[];
|
||||
}
|
||||
|
||||
interface TrafficPoolData {
|
||||
selectedPools: string[];
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
basicInfo: Partial<BasicInfoData>;
|
||||
targetSettings: Partial<TargetSettingsData>;
|
||||
trafficPool: Partial<TrafficPoolData>;
|
||||
}
|
||||
|
||||
// 账号选择对话框组件
|
||||
const AccountSelectionDialog = ({
|
||||
open,
|
||||
onClose,
|
||||
selectedAccounts,
|
||||
onConfirm
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
selectedAccounts: string[];
|
||||
onConfirm: (accounts: string[]) => void;
|
||||
}) => {
|
||||
const [tempSelectedAccounts, setTempSelectedAccounts] = useState<string[]>(selectedAccounts);
|
||||
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const { toast } = useToast();
|
||||
|
||||
// 获取账号列表
|
||||
const fetchAccounts = useCallback(async (pageNum: number = 1, reset: boolean = true) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetchAccountList({
|
||||
page: pageNum,
|
||||
limit: 10
|
||||
});
|
||||
|
||||
if (response.code === 200 && response.data) {
|
||||
const accountList = response.data.list || [];
|
||||
const total = response.data.total || 0;
|
||||
|
||||
if (reset) {
|
||||
setAccounts(accountList);
|
||||
} else {
|
||||
setAccounts(prev => [...prev, ...accountList]);
|
||||
}
|
||||
|
||||
// 计算是否还有更多数据
|
||||
const currentTotal = reset ? accountList.length : accounts.length + accountList.length;
|
||||
setHasMore(currentTotal < total);
|
||||
} else {
|
||||
toast({
|
||||
title: "获取账号列表失败",
|
||||
description: response.msg || "请稍后重试",
|
||||
variant: "destructive"
|
||||
});
|
||||
|
||||
// 如果API失败,使用模拟数据作为降级处理
|
||||
const mockData = [
|
||||
{ id: "1", userName: "user_001", realName: "张三", nickname: "游戏", memo: "游戏账号" },
|
||||
{ id: "2", userName: "user_002", realName: "李四", nickname: "商务4", memo: "商务账号" },
|
||||
{ id: "3", userName: "user_003", realName: "王五", nickname: "魔兽客服", memo: "客服账号" },
|
||||
{ id: "4", userName: "user_004", realName: "赵六", nickname: "魔兽世界Kf", memo: "游戏客服" },
|
||||
{ id: "5", userName: "user_005", realName: "孙七", nickname: "小羊网络", memo: "网络账号" },
|
||||
];
|
||||
|
||||
if (reset) {
|
||||
setAccounts(mockData);
|
||||
}
|
||||
setHasMore(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取账号列表失败:', error);
|
||||
toast({
|
||||
title: "网络错误",
|
||||
description: "请检查网络连接后重试",
|
||||
variant: "destructive"
|
||||
});
|
||||
|
||||
// 网络错误时使用模拟数据
|
||||
const mockData = [
|
||||
{ id: "1", userName: "user_001", realName: "张三", nickname: "游戏", memo: "游戏账号" },
|
||||
{ id: "2", userName: "user_002", realName: "李四", nickname: "商务4", memo: "商务账号" },
|
||||
{ id: "3", userName: "user_003", realName: "王五", nickname: "魔兽客服", memo: "客服账号" },
|
||||
{ id: "4", userName: "user_004", realName: "赵六", nickname: "魔兽世界Kf", memo: "游戏客服" },
|
||||
{ id: "5", userName: "user_005", realName: "孙七", nickname: "小羊网络", memo: "网络账号" },
|
||||
];
|
||||
|
||||
if (reset) {
|
||||
setAccounts(mockData);
|
||||
}
|
||||
setHasMore(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [accounts.length, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTempSelectedAccounts(selectedAccounts);
|
||||
fetchAccounts(1, true);
|
||||
setPage(1);
|
||||
}
|
||||
}, [open, selectedAccounts, fetchAccounts]);
|
||||
|
||||
const toggleAccount = (id: string) => {
|
||||
setTempSelectedAccounts(prev =>
|
||||
prev.includes(id) ? prev.filter(accountId => accountId !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(tempSelectedAccounts);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const loadMore = () => {
|
||||
if (!loading && hasMore) {
|
||||
const nextPage = page + 1;
|
||||
setPage(nextPage);
|
||||
fetchAccounts(nextPage, false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-sm w-[90vw] max-h-[80vh] p-0">
|
||||
<DialogHeader className=" pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle>选择账号</DialogTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className=" flex-1 overflow-hidden">
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{accounts.map(account => (
|
||||
<div
|
||||
key={account.id}
|
||||
className={`cursor-pointer border rounded-lg p-4 ${
|
||||
tempSelectedAccounts.includes(account.id)
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "border-gray-200"
|
||||
}`}
|
||||
onClick={() => toggleAccount(account.id)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-12 h-12 rounded-lg bg-blue-100 flex items-center justify-center">
|
||||
<span className="text-base font-medium text-blue-600">
|
||||
{(account.nickname || account.realName || account.userName).charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-base">{account.nickname || account.realName || account.userName}</p>
|
||||
<p className="text-sm text-gray-500">账号: {account.userName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-2">
|
||||
<Checkbox
|
||||
checked={tempSelectedAccounts.includes(account.id)}
|
||||
onCheckedChange={() => toggleAccount(account.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{loading && (
|
||||
<div className="text-center py-4">
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
|
||||
<span className="text-sm text-gray-500">加载中...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && hasMore && (
|
||||
<div className="text-center py-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadMore}
|
||||
>
|
||||
加载更多
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && accounts.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Users className="h-12 w-12 mx-auto mb-2 opacity-30" />
|
||||
<p>暂无账号数据</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className=" pt-4 border-t">
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
className="w-full h-12 text-base"
|
||||
disabled={tempSelectedAccounts.length === 0}
|
||||
>
|
||||
确认
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default function NewDistribution() {
|
||||
const navigate = useNavigate();
|
||||
const { id: ruleId } = useParams<{ id: string }>();
|
||||
const { toast } = useToast();
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
basicInfo: {},
|
||||
targetSettings: {},
|
||||
trafficPool: {},
|
||||
});
|
||||
|
||||
const steps = [
|
||||
{ title: "基本信息", content: "step1" },
|
||||
{ title: "目标设置", content: "step2" },
|
||||
{ title: "流量池选择", content: "step3" },
|
||||
];
|
||||
|
||||
// 加载规则数据
|
||||
const loadRuleData = useCallback(async (id: string) => {
|
||||
console.log('开始加载规则数据,ID:', id);
|
||||
setLoading(true);
|
||||
try {
|
||||
console.log('调用API: /v1/workbench/detail?id=' + id);
|
||||
const response = await fetchDistributionRuleDetail(id);
|
||||
|
||||
if (response.code === 200 && response.data) {
|
||||
const rule = response.data;
|
||||
const config = rule.config as any || {};
|
||||
|
||||
console.log('接收到的详情数据:', rule);
|
||||
console.log('config数据:', config);
|
||||
|
||||
// 转换分配类型:1-均分配, 2-优先级分配, 3-比例分配
|
||||
const getDistributionMethod = (distributeType: number) => {
|
||||
switch (distributeType) {
|
||||
case 2: return 'priority';
|
||||
case 3: return 'ratio';
|
||||
default: return 'equal';
|
||||
}
|
||||
};
|
||||
|
||||
// 转换时间限制类型:1-全天, 2-自定义时间段
|
||||
const timeRestriction = config?.timeType === 1 ? 'allDay' : 'custom';
|
||||
|
||||
// 转换后端数据为前端表单格式
|
||||
setFormData({
|
||||
basicInfo: {
|
||||
name: rule.name || '',
|
||||
distributionMethod: getDistributionMethod(config?.distributeType),
|
||||
dailyLimit: config?.maxPerDay || 50,
|
||||
timeRestriction: timeRestriction,
|
||||
startTime: config?.startTime || '09:00',
|
||||
endTime: config?.endTime || '18:00',
|
||||
selectedAccounts: config?.account || [],
|
||||
},
|
||||
targetSettings: {
|
||||
selectedDevices: config?.devices || [],
|
||||
},
|
||||
trafficPool: {
|
||||
selectedPools: config?.pools || [],
|
||||
},
|
||||
});
|
||||
|
||||
console.log('转换后的表单数据:', {
|
||||
basicInfo: {
|
||||
name: rule.name || '',
|
||||
distributionMethod: getDistributionMethod(config?.distributeType),
|
||||
dailyLimit: config?.maxPerDay || 50,
|
||||
timeRestriction: timeRestriction,
|
||||
startTime: config?.startTime || '09:00',
|
||||
endTime: config?.endTime || '18:00',
|
||||
selectedAccounts: config?.account || [],
|
||||
},
|
||||
targetSettings: {
|
||||
selectedDevices: config?.devices || [],
|
||||
},
|
||||
trafficPool: {
|
||||
selectedPools: config?.pools || [],
|
||||
},
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: "加载失败",
|
||||
description: response.msg || "无法加载规则数据",
|
||||
variant: "destructive"
|
||||
});
|
||||
navigate('/workspace/traffic-distribution');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载规则数据失败:', error);
|
||||
toast({
|
||||
title: "加载失败",
|
||||
description: "网络错误,请稍后重试",
|
||||
variant: "destructive"
|
||||
});
|
||||
navigate('/workspace/traffic-distribution');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [toast, navigate]);
|
||||
|
||||
// 检查是否为编辑模式并加载数据
|
||||
useEffect(() => {
|
||||
console.log('检查编辑模式 - ruleId:', ruleId);
|
||||
if (ruleId) {
|
||||
console.log('进入编辑模式,加载数据...');
|
||||
setIsEditMode(true);
|
||||
loadRuleData(ruleId);
|
||||
}
|
||||
}, [ruleId, loadRuleData]);
|
||||
|
||||
// 生成默认计划名称
|
||||
const generateDefaultName = () => {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
const hour = String(now.getHours()).padStart(2, '0');
|
||||
const minute = String(now.getMinutes()).padStart(2, '0');
|
||||
return `流量分发 ${year}${month}${day} ${hour}${minute}`;
|
||||
};
|
||||
|
||||
// 转换分配方式为后端需要的格式
|
||||
const getDistributeType = (distributionMethod: string | undefined) => {
|
||||
switch (distributionMethod) {
|
||||
case 'equal':
|
||||
return 1; // 均分配
|
||||
case 'priority':
|
||||
return 2; // 优先级分配
|
||||
case 'ratio':
|
||||
return 3; // 比例分配
|
||||
default:
|
||||
return 1; // 默认均分配
|
||||
}
|
||||
};
|
||||
|
||||
const handleBasicInfoNext = (data: BasicInfoData) => {
|
||||
setFormData(prev => ({ ...prev, basicInfo: data }));
|
||||
setCurrentStep(1);
|
||||
};
|
||||
|
||||
const handleTargetSettingsNext = (data: TargetSettingsData) => {
|
||||
setFormData(prev => ({ ...prev, targetSettings: data }));
|
||||
setCurrentStep(2);
|
||||
};
|
||||
|
||||
const handleTargetSettingsBack = () => {
|
||||
setCurrentStep(0);
|
||||
};
|
||||
|
||||
const handleTrafficPoolBack = () => {
|
||||
setCurrentStep(1);
|
||||
};
|
||||
|
||||
const handleSubmit = async (data: TrafficPoolData) => {
|
||||
const finalData = {
|
||||
...formData,
|
||||
trafficPool: data,
|
||||
};
|
||||
|
||||
try {
|
||||
// 构造API请求参数 - 按照后端要求的格式
|
||||
const apiParams = {
|
||||
type: 5, // 流量分发类型
|
||||
name: finalData.basicInfo.name || '',
|
||||
distributeType: getDistributeType(finalData.basicInfo.distributionMethod),
|
||||
maxPerDay: finalData.basicInfo.dailyLimit || 50,
|
||||
timeType: finalData.basicInfo.timeRestriction === 'allDay' ? 1 : 2,
|
||||
startTime: finalData.basicInfo.timeRestriction === 'custom'
|
||||
? (finalData.basicInfo.startTime || "09:00")
|
||||
: "00:00",
|
||||
endTime: finalData.basicInfo.timeRestriction === 'custom'
|
||||
? (finalData.basicInfo.endTime || "18:00")
|
||||
: "23:59",
|
||||
devices: finalData.targetSettings?.selectedDevices || [],
|
||||
account: finalData.basicInfo?.selectedAccounts || [],
|
||||
pools: finalData.trafficPool?.selectedPools || [],
|
||||
enabled: true // 默认启用
|
||||
};
|
||||
|
||||
console.log('提交的数据:', apiParams);
|
||||
|
||||
let response;
|
||||
if (isEditMode && ruleId) {
|
||||
// 编辑模式 - 使用更新接口
|
||||
response = await updateDistributionRule(ruleId, apiParams);
|
||||
} else {
|
||||
// 创建模式 - 使用创建接口
|
||||
response = await createDistributionRule(apiParams);
|
||||
}
|
||||
|
||||
if (response.code === 200) {
|
||||
toast({
|
||||
title: isEditMode ? "更新成功" : "创建成功",
|
||||
description: isEditMode ? "流量分发规则已成功更新" : "流量分发规则已成功创建"
|
||||
});
|
||||
|
||||
navigate('/workspace/traffic-distribution');
|
||||
} else {
|
||||
toast({
|
||||
title: isEditMode ? "更新失败" : "创建失败",
|
||||
description: response.msg || "请稍后重试",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error);
|
||||
toast({
|
||||
title: isEditMode ? "更新失败" : "创建失败",
|
||||
description: "网络错误,请稍后重试",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 基本信息步骤组件
|
||||
const BasicInfoStep = ({ onNext, initialData = {} }: { onNext: (data: BasicInfoData) => void; initialData?: Partial<BasicInfoData> }) => {
|
||||
const [formData, setFormData] = useState<BasicInfoData>({
|
||||
name: initialData.name || (isEditMode ? '' : generateDefaultName()),
|
||||
distributionMethod: initialData.distributionMethod || "equal",
|
||||
dailyLimit: initialData.dailyLimit || 50,
|
||||
timeRestriction: initialData.timeRestriction || "custom",
|
||||
startTime: initialData.startTime || "09:00",
|
||||
endTime: initialData.endTime || "18:00",
|
||||
selectedAccounts: initialData.selectedAccounts || [],
|
||||
});
|
||||
|
||||
const [accountDialogOpen, setAccountDialogOpen] = useState(false);
|
||||
|
||||
// 当编辑模式下initialData变化时,更新表单数据
|
||||
useEffect(() => {
|
||||
if (initialData.name) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
name: initialData.name || '',
|
||||
distributionMethod: initialData.distributionMethod || prev.distributionMethod,
|
||||
dailyLimit: initialData.dailyLimit || prev.dailyLimit,
|
||||
timeRestriction: initialData.timeRestriction || prev.timeRestriction,
|
||||
startTime: initialData.startTime || prev.startTime,
|
||||
endTime: initialData.endTime || prev.endTime,
|
||||
selectedAccounts: initialData.selectedAccounts || prev.selectedAccounts,
|
||||
}));
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
const handleChange = (field: keyof BasicInfoData, value: string | number | string[]) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleAccountConfirm = (selectedAccounts: string[]) => {
|
||||
handleChange("selectedAccounts", selectedAccounts);
|
||||
};
|
||||
|
||||
const getSelectedAccountsText = () => {
|
||||
if (formData.selectedAccounts.length === 0) {
|
||||
return "请选择账号";
|
||||
}
|
||||
return `已选择 ${formData.selectedAccounts.length} 个账号`;
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!formData.name.trim()) {
|
||||
toast({
|
||||
title: "请填写计划名称",
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (formData.selectedAccounts.length === 0) {
|
||||
toast({
|
||||
title: "请选择至少一个账号",
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
onNext(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold mb-6">基本信息</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name" className="flex items-center">
|
||||
计划名称 <span className="text-red-500 ml-1">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange("name", e.target.value)}
|
||||
placeholder="请输入计划名称"
|
||||
className="h-12"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Label className="flex items-center">
|
||||
选择账号 <span className="text-red-500 ml-1">*</span>
|
||||
</Label>
|
||||
|
||||
<div
|
||||
className="relative cursor-pointer"
|
||||
onClick={() => setAccountDialogOpen(true)}
|
||||
>
|
||||
<Input
|
||||
value={getSelectedAccountsText()}
|
||||
placeholder="请选择账号"
|
||||
className="h-12 cursor-pointer"
|
||||
readOnly
|
||||
/>
|
||||
<Search className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={18} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<Users className="h-4 w-4 mr-1" />
|
||||
已选账号:<span className="text-blue-600 font-medium ml-1">{formData.selectedAccounts.length} 个</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>分配方式</Label>
|
||||
<RadioGroup
|
||||
value={formData.distributionMethod}
|
||||
onValueChange={(value) => handleChange("distributionMethod", value as 'equal' | 'priority' | 'ratio')}
|
||||
className="space-y-2"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="equal" id="equal" />
|
||||
<Label htmlFor="equal" className="cursor-pointer">
|
||||
均分配 <span className="text-gray-500 text-sm">(流量将均分配给所有客服)</span>
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="priority" id="priority" />
|
||||
<Label htmlFor="priority" className="cursor-pointer">
|
||||
优先级分配 <span className="text-gray-500 text-sm">(按客服优先级顺序分配)</span>
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="ratio" id="ratio" />
|
||||
<Label htmlFor="ratio" className="cursor-pointer">
|
||||
比例分配 <span className="text-gray-500 text-sm">(按设定比例分配流量)</span>
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Label>分配限制</Label>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span>每日最大分配量</span>
|
||||
<span className="font-medium">{formData.dailyLimit} 人/天</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
value={formData.dailyLimit}
|
||||
min={1}
|
||||
max={200}
|
||||
step={1}
|
||||
onChange={(e) => handleChange("dailyLimit", parseInt(e.target.value))}
|
||||
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer"
|
||||
style={{
|
||||
background: `linear-gradient(to right, #3b82f6 0%, #3b82f6 ${(formData.dailyLimit / 200) * 100}%, #e5e7eb ${(formData.dailyLimit / 200) * 100}%, #e5e7eb 100%)`
|
||||
}}
|
||||
/>
|
||||
<p className="text-sm text-gray-500">限制每天最多分配的流量数量</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-4">
|
||||
<Label>时间限制</Label>
|
||||
<RadioGroup
|
||||
value={formData.timeRestriction}
|
||||
onValueChange={(value) => handleChange("timeRestriction", value as 'allDay' | 'custom')}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="allDay" id="allDay" />
|
||||
<Label htmlFor="allDay" className="cursor-pointer">
|
||||
全天分配
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="custom" id="custom" />
|
||||
<Label htmlFor="custom" className="cursor-pointer">
|
||||
自定义时间段
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
{formData.timeRestriction === "custom" && (
|
||||
<div className="grid grid-cols-2 gap-4 pt-2">
|
||||
<div>
|
||||
<Label htmlFor="startTime" className="mb-2 block">
|
||||
开始时间
|
||||
</Label>
|
||||
<Input
|
||||
id="startTime"
|
||||
type="time"
|
||||
value={formData.startTime}
|
||||
onChange={(e) => handleChange("startTime", e.target.value)}
|
||||
className="h-12"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="endTime" className="mb-2 block">
|
||||
结束时间
|
||||
</Label>
|
||||
<Input
|
||||
id="endTime"
|
||||
type="time"
|
||||
value={formData.endTime}
|
||||
onChange={(e) => handleChange("endTime", e.target.value)}
|
||||
className="h-12"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex justify-end">
|
||||
<Button onClick={handleSubmit} className="px-8">
|
||||
下一步 →
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AccountSelectionDialog
|
||||
open={accountDialogOpen}
|
||||
onClose={() => setAccountDialogOpen(false)}
|
||||
selectedAccounts={formData.selectedAccounts}
|
||||
onConfirm={handleAccountConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 目标设置步骤组件
|
||||
const TargetSettingsStep = ({ onNext, onBack, initialData = {} }: { onNext: (data: TargetSettingsData) => void; onBack: () => void; initialData?: Partial<TargetSettingsData> }) => {
|
||||
const [selectedDevices, setSelectedDevices] = useState<string[]>(initialData.selectedDevices || []);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (selectedDevices.length === 0) {
|
||||
toast({
|
||||
title: "请选择至少一个设备",
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
onNext({
|
||||
selectedDevices,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold mb-6">目标设置</h2>
|
||||
|
||||
<div className="mb-6">
|
||||
<DeviceSelection
|
||||
selectedDevices={selectedDevices}
|
||||
onSelect={setSelectedDevices}
|
||||
placeholder="选择执行设备"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex justify-between">
|
||||
<Button variant="outline" onClick={onBack}>
|
||||
← 上一步
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={selectedDevices.length === 0}>
|
||||
下一步 →
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 流量池选择步骤组件
|
||||
const TrafficPoolStep = ({ onSubmit, onBack, initialData = {} }: { onSubmit: (data: TrafficPoolData) => void; onBack: () => void; initialData?: Partial<TrafficPoolData> }) => {
|
||||
const [selectedPools, setSelectedPools] = useState<string[]>(initialData.selectedPools || []);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
onSubmit({ selectedPools });
|
||||
} catch (error) {
|
||||
console.error("提交失败:", error);
|
||||
toast({
|
||||
title: "创建失败",
|
||||
description: "请稍后重试",
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 从formData中获取选中的设备ID
|
||||
const deviceIds = formData.targetSettings?.selectedDevices || [];
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold mb-6">流量池选择</h2>
|
||||
|
||||
<div className="mb-6">
|
||||
<TrafficPoolSelection
|
||||
selectedPools={selectedPools}
|
||||
onSelect={setSelectedPools}
|
||||
deviceIds={deviceIds}
|
||||
placeholder="选择流量池"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex justify-between">
|
||||
<Button variant="outline" onClick={onBack}>
|
||||
← 上一步
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||
{isSubmitting ? (isEditMode ? "更新中..." : "提交中...") : (isEditMode ? "更新完成" : "完成")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const headerRightContent = (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-gray-500"
|
||||
onClick={() => navigate('/workspace/traffic-distribution')}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<PageHeader
|
||||
title={isEditMode ? "编辑流量分发" : "新建流量分发"}
|
||||
defaultBackPath="/workspace/traffic-distribution"
|
||||
rightContent={headerRightContent}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="bg-gray-50 min-h-screen pb-16">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4">
|
||||
<div className="mb-6">
|
||||
<Steps current={currentStep}>
|
||||
{steps.map((step, index) => (
|
||||
<StepItem key={index} title={step.title} />
|
||||
))}
|
||||
</Steps>
|
||||
</div>
|
||||
|
||||
{currentStep === 0 && (
|
||||
<BasicInfoStep
|
||||
onNext={handleBasicInfoNext}
|
||||
initialData={formData.basicInfo}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 1 && (
|
||||
<TargetSettingsStep
|
||||
onNext={handleTargetSettingsNext}
|
||||
onBack={handleTargetSettingsBack}
|
||||
initialData={formData.targetSettings}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 2 && (
|
||||
<TrafficPoolStep
|
||||
onSubmit={handleSubmit}
|
||||
onBack={handleTrafficPoolBack}
|
||||
initialData={formData.trafficPool}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,412 +0,0 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
RefreshCw,
|
||||
MoreVertical,
|
||||
Clock,
|
||||
Edit,
|
||||
Trash2,
|
||||
Pause,
|
||||
Play,
|
||||
Filter,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import Layout from '@/components/Layout';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import BottomNav from '@/components/BottomNav';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
import '@/components/Layout.css';
|
||||
import {
|
||||
fetchDistributionRules,
|
||||
deleteDistributionRule,
|
||||
toggleDistributionRuleStatus,
|
||||
DistributionRule,
|
||||
WorkbenchTaskStatus
|
||||
} from '@/api/trafficDistribution';
|
||||
|
||||
export default function TrafficDistribution() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const [tasks, setTasks] = useState<DistributionRule[]>([]);
|
||||
|
||||
// 处理删除
|
||||
const handleDelete = async (ruleId: number) => {
|
||||
const ruleToDelete = tasks.find((rule) => rule.id === ruleId);
|
||||
if (!ruleToDelete) return;
|
||||
|
||||
if (!window.confirm(`确定要删除"${ruleToDelete.name}"吗?`)) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await deleteDistributionRule(ruleId.toString());
|
||||
|
||||
if (response.code === 200) {
|
||||
setTasks(tasks.filter((rule) => rule.id !== ruleId));
|
||||
toast({
|
||||
title: '删除成功',
|
||||
description: '已成功删除分发规则',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: '删除失败',
|
||||
description: response.msg || '操作失败,请稍后重试',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除流量分发规则失败:', error);
|
||||
toast({
|
||||
title: '删除失败',
|
||||
description: '操作失败,请稍后重试',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (ruleId: number) => {
|
||||
navigate(`/workspace/traffic-distribution/edit/${ruleId}`);
|
||||
};
|
||||
|
||||
const handleView = (ruleId: number) => {
|
||||
navigate(`/workspace/traffic-distribution/${ruleId}`);
|
||||
};
|
||||
|
||||
const toggleRuleStatus = async (ruleId: number) => {
|
||||
const rule = tasks.find((r) => r.id === ruleId);
|
||||
if (!rule) return;
|
||||
|
||||
try {
|
||||
|
||||
// 根据当前状态决定新状态:1表示开启,0表示关闭
|
||||
const newStatus = rule.status === WorkbenchTaskStatus.RUNNING ? 0 : 1;
|
||||
|
||||
const response = await toggleDistributionRuleStatus(ruleId.toString(), newStatus as 0 | 1);
|
||||
|
||||
if (response.code === 200) {
|
||||
// 更新本地状态:1对应RUNNING,0对应PAUSED
|
||||
const updatedStatus = newStatus === 1 ? WorkbenchTaskStatus.RUNNING : WorkbenchTaskStatus.PAUSED;
|
||||
|
||||
setTasks(
|
||||
tasks.map((task) =>
|
||||
task.id === ruleId ? { ...task, status: updatedStatus } : task
|
||||
)
|
||||
);
|
||||
|
||||
toast({
|
||||
title: newStatus === 1 ? '已启动' : '已暂停',
|
||||
description: `${rule.name}规则${newStatus === 1 ? '已启动' : '已暂停'}`,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: '操作失败',
|
||||
description: response.msg || '操作失败,请稍后重试',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('切换流量分发规则状态失败:', error);
|
||||
toast({
|
||||
title: '操作失败',
|
||||
description: '操作失败,请稍后重试',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateNew = () => {
|
||||
navigate('/workspace/traffic-distribution/new');
|
||||
};
|
||||
|
||||
// 添加卡片菜单组件
|
||||
type CardMenuProps = {
|
||||
rule: DistributionRule;
|
||||
onEdit: () => void;
|
||||
onToggleStatus: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
function CardMenu({ rule, onEdit, onToggleStatus, onDelete }: CardMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const isRunning = rule.status === WorkbenchTaskStatus.RUNNING;
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
if (open) document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<button onClick={() => setOpen((v) => !v)} style={{ background: "none", border: "none", padding: 0, margin: 0, cursor: "pointer" }}>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
top: 28,
|
||||
background: "#fff",
|
||||
borderRadius: 8,
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
|
||||
zIndex: 100,
|
||||
minWidth: 120,
|
||||
padding: 4,
|
||||
}}
|
||||
>
|
||||
<div onClick={() => { onEdit(); setOpen(false); }} style={{ padding: 8, cursor: "pointer", display: "flex", alignItems: "center", borderRadius: 6, fontSize: 14, gap: 6, transition: "background .2s" }} onMouseOver={e => (e.currentTarget as HTMLDivElement).style.background="#f5f5f5"} onMouseOut={e => (e.currentTarget as HTMLDivElement).style.background=""}>
|
||||
<Edit className="h-4 w-4 mr-2" />编辑计划
|
||||
</div>
|
||||
<div onClick={() => { onToggleStatus(); setOpen(false); }} style={{ padding: 8, cursor: "pointer", display: "flex", alignItems: "center", borderRadius: 6, fontSize: 14, gap: 6, transition: "background .2s" }} onMouseOver={e => (e.currentTarget as HTMLDivElement).style.background="#f5f5f5"} onMouseOut={e => (e.currentTarget as HTMLDivElement).style.background=""}>
|
||||
{isRunning ? <Pause className="h-4 w-4 mr-2" /> : <Play className="h-4 w-4 mr-2" />}
|
||||
{isRunning ? '暂停计划' : '启动计划'}
|
||||
</div>
|
||||
<div onClick={() => { onDelete(); setOpen(false); }} style={{ padding: 8, cursor: "pointer", display: "flex", alignItems: "center", borderRadius: 6, fontSize: 14, gap: 6, color: "#e53e3e", transition: "background .2s" }} onMouseOver={e => (e.currentTarget as HTMLDivElement).style.background="#f5f5f5"} onMouseOut={e => (e.currentTarget as HTMLDivElement).style.background=""}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />删除计划
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filteredRules = tasks.filter((rule) =>
|
||||
rule.name.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
const getStatusText = (status: number) => {
|
||||
switch (status) {
|
||||
case WorkbenchTaskStatus.RUNNING:
|
||||
return '进行中';
|
||||
case WorkbenchTaskStatus.PAUSED:
|
||||
return '已暂停';
|
||||
case WorkbenchTaskStatus.COMPLETED:
|
||||
return '已完成';
|
||||
case WorkbenchTaskStatus.FAILED:
|
||||
return '已失败';
|
||||
case WorkbenchTaskStatus.PENDING:
|
||||
return '待处理';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
};
|
||||
|
||||
// 加载数据
|
||||
const fetchData = async (page = currentPage, keyword = searchTerm) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetchDistributionRules({
|
||||
page,
|
||||
limit: 10,
|
||||
keyword
|
||||
});
|
||||
|
||||
if (response.code === 200 && response.data) {
|
||||
setTasks(response.data.list);
|
||||
setTotalItems(response.data.total);
|
||||
setCurrentPage(response.data.page);
|
||||
} else {
|
||||
toast({
|
||||
title: '获取数据失败',
|
||||
description: response.msg || '无法获取流量分发数据,请稍后重试',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取流量分发数据失败:', error);
|
||||
toast({
|
||||
title: '获取数据失败',
|
||||
description: '无法获取流量分发数据,请稍后重试',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 初始加载和搜索
|
||||
useEffect(() => {
|
||||
fetchData(1, searchTerm);
|
||||
}, []); // 初始加载时只执行一次
|
||||
|
||||
// 处理搜索
|
||||
const handleSearch = () => {
|
||||
fetchData(1, searchTerm);
|
||||
};
|
||||
|
||||
// 处理刷新
|
||||
const handleRefresh = () => {
|
||||
fetchData();
|
||||
};
|
||||
|
||||
// 页面头部右侧内容
|
||||
const headerRightContent = (
|
||||
<Button onClick={handleCreateNew}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
新建分发
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
header={
|
||||
<PageHeader
|
||||
title="流量分发"
|
||||
defaultBackPath="/workspace"
|
||||
rightContent={headerRightContent}
|
||||
/>
|
||||
}
|
||||
footer={<BottomNav activeTab="workspace" />}
|
||||
>
|
||||
<div className="bg-gray-50 min-h-screen pb-16">
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="flex items-center space-x-2 bg-white p-3 rounded-lg shadow-sm">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={16} />
|
||||
<Input
|
||||
placeholder="搜索计划名称"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9 h-10"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleSearch} className="h-10">
|
||||
<Filter size={16} className="mr-1" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} className="h-10">
|
||||
<RefreshCw size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i} className="p-4 animate-pulse">
|
||||
<div className="h-6 bg-gray-200 rounded w-3/4 mb-2"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-1/2 mb-4"></div>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="h-5 bg-gray-200 rounded w-1/4"></div>
|
||||
<div className="h-5 bg-gray-200 rounded w-1/4"></div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : filteredRules.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{filteredRules.map((rule) => (
|
||||
<Card key={rule.id} className="overflow-hidden">
|
||||
<div className="p-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="font-medium text-lg">{rule.name || '未命名计划'}</h3>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge className={rule.status === WorkbenchTaskStatus.RUNNING ? "bg-blue-100 text-blue-800" : "bg-gray-100 text-gray-800"}>
|
||||
{getStatusText(rule.status)}
|
||||
</Badge>
|
||||
<Switch
|
||||
checked={rule.status === WorkbenchTaskStatus.RUNNING}
|
||||
onCheckedChange={() => toggleRuleStatus(rule.id)}
|
||||
/>
|
||||
<CardMenu
|
||||
rule={rule}
|
||||
onEdit={() => handleEdit(rule.id)}
|
||||
onToggleStatus={() => toggleRuleStatus(rule.id)}
|
||||
onDelete={() => handleDelete(rule.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 mt-4 border-b pb-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-semibold">{rule.config?.total?.totalAccounts || 2}</div>
|
||||
<div className="text-xs text-gray-500">分发账号</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-semibold">{rule.config?.total?.deviceCount || 7}</div>
|
||||
<div className="text-xs text-gray-500">分发设备</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-semibold">{rule.config?.total?.totalAccounts || "ALL"}</div>
|
||||
<div className="text-xs text-gray-500">流量池</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mt-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-semibold">{rule.config?.total?.dailyAverage || 119}</div>
|
||||
<div className="text-xs text-gray-500">日均分发量</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-semibold">{rule.config?.total?.totalUsers || 2}</div>
|
||||
<div className="text-xs text-gray-500">总流量池数量</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-sm text-gray-500 flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Clock className="h-4 w-4 mr-1" />
|
||||
上次执行: {rule.config?.lastUpdated?.substring(0, 16) || '2025-07-02 09:00'}
|
||||
</div>
|
||||
<div>创建时间: {rule.createTime || '2025-07-02'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-20 bg-white rounded-lg shadow-sm">
|
||||
<div className="text-gray-400 mb-2">暂无流量分发规则</div>
|
||||
<Button variant="outline" onClick={handleCreateNew}>
|
||||
<Plus size={16} className="mr-1" />
|
||||
创建新规则
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totalItems > 10 && (
|
||||
<div className="flex justify-center mt-6">
|
||||
<div className="flex space-x-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => fetchData(currentPage - 1)}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<div className="flex items-center px-3 text-sm">
|
||||
第 {currentPage} 页,共 {Math.ceil(totalItems / 10)} 页
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage >= Math.ceil(totalItems / 10)}
|
||||
onClick={() => fetchData(currentPage + 1)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user