feat: 本次提交更新内容如下

定版本转移2025年7月17日
This commit is contained in:
笔记本里的永平
2025-07-17 10:22:38 +08:00
parent 0f860d01e4
commit 92a3d407a7
645 changed files with 30755 additions and 118800 deletions

View File

@@ -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>
);
}

View File

@@ -1,5 +0,0 @@
import React from 'react';
export default function ContactImport() {
return <div></div>;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -1,5 +0,0 @@
import React from 'react';
export default function Orders() {
return <div></div>;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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">&times;</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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 }}>
CSVExcel
</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>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>;
}

View File

@@ -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>
);
}

View File

@@ -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对应RUNNING0对应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>
);
}