faet:列表做好了,开始做其他的

This commit is contained in:
许永平
2025-07-09 16:22:13 +08:00
parent dfc1637d6a
commit 4367d5a5f3
6 changed files with 776 additions and 625 deletions

View File

@@ -58,7 +58,7 @@
"date-fns": "latest",
"embla-carousel-react": "8.5.1",
"input-otp": "1.4.1",
"lucide-react": "^0.454.0",
"lucide-react": "^0.525.0",
"react": "^18.2.0",
"react-day-picker": "latest",
"react-dom": "^18.2.0",
@@ -14297,12 +14297,12 @@
}
},
"node_modules/lucide-react": {
"version": "0.454.0",
"resolved": "https://registry.npmmirror.com/lucide-react/-/lucide-react-0.454.0.tgz",
"integrity": "sha512-hw7zMDwykCLnEzgncEEjHeA6+45aeEzRYuKHuyRSOPkhko+J3ySGjGIzu+mmMfDFG1vazHepMaYFYHbTFAZAAQ==",
"version": "0.525.0",
"resolved": "https://registry.npmmirror.com/lucide-react/-/lucide-react-0.525.0.tgz",
"integrity": "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lz-string": {

View File

@@ -1,26 +1,111 @@
import { get, post } from './request';
import { get, post, del } from './request';
import {
MomentsSyncTask,
CreateMomentsSyncData,
UpdateMomentsSyncData,
SyncRecord,
ApiResponse,
PaginatedResponse
} from '@/types/moments-sync';
export interface MomentsSyncDevice {
id: string;
name: string;
status: 'idle' | 'syncing' | 'success' | 'error';
lastSyncTime: string;
progress: number; // 0-100
log: string;
// 获取朋友圈同步任务列表
export async function fetchMomentsSyncTasks(): Promise<MomentsSyncTask[]> {
try {
const res = await get<ApiResponse<PaginatedResponse<MomentsSyncTask>>>('/v1/workbench/list?type=2&page=1&limit=100');
if (res.code === 200 && res.data) {
return res.data.list || [];
}
return [];
} catch (error) {
console.error('获取朋友圈同步任务失败:', error);
return [];
}
}
export async function fetchMomentsSyncDevices(search?: string) {
return get('/api/moments-sync/list', search ? { params: { search } } : undefined);
// 获取单个任务详情
export async function fetchMomentsSyncTaskDetail(id: string): Promise<MomentsSyncTask | null> {
try {
const res = await get<ApiResponse<MomentsSyncTask>>(`/v1/workbench/detail/${id}`);
if (res.code === 200 && res.data) {
return res.data;
}
return null;
} catch (error) {
console.error('获取任务详情失败:', error);
return null;
}
}
export async function syncMoments(id: string) {
return post('/api/moments-sync/sync', { id });
// 创建朋友圈同步任务
export async function createMomentsSyncTask(data: CreateMomentsSyncData): Promise<ApiResponse> {
return post('/v1/workbench/create', {
...data,
type: 2 // 朋友圈同步类型
});
}
export async function syncAllMoments() {
return post('/api/moments-sync/sync-all');
// 更新朋友圈同步任务
export async function updateMomentsSyncTask(data: UpdateMomentsSyncData): Promise<ApiResponse> {
return post('/v1/workbench/update', {
...data,
type: 2 // 朋友圈同步类型
});
}
export async function fetchMomentsLog(id: string) {
return get('/api/moments-sync/log', { params: { id } });
}
// 删除朋友圈同步任务
export async function deleteMomentsSyncTask(id: string): Promise<ApiResponse> {
return del('/v1/workbench/delete', { params: { id } });
}
// 切换任务状态
export async function toggleMomentsSyncTask(id: string, status: string): Promise<ApiResponse> {
return post('/v1/workbench/update-status', { id, status });
}
// 复制朋友圈同步任务
export async function copyMomentsSyncTask(id: string): Promise<ApiResponse> {
return post('/v1/workbench/copy', { id });
}
// 获取同步记录
export async function fetchSyncRecords(
workbenchId: string,
page: number = 1,
limit: number = 20,
keyword?: string
): Promise<PaginatedResponse<SyncRecord>> {
try {
const params = new URLSearchParams({
workbenchId,
page: page.toString(),
limit: limit.toString()
});
if (keyword) {
params.append('keyword', keyword);
}
const res = await get<ApiResponse<PaginatedResponse<SyncRecord>>>(`/v1/workbench/sync-records?${params.toString()}`);
if (res.code === 200 && res.data) {
return res.data;
}
return { list: [], total: 0, page, limit };
} catch (error) {
console.error('获取同步记录失败:', error);
return { list: [], total: 0, page, limit };
}
}
// 手动同步
export async function syncMoments(id: string): Promise<ApiResponse> {
return post('/v1/workbench/sync', { id });
}
// 同步所有任务
export async function syncAllMoments(): Promise<ApiResponse> {
return post('/v1/workbench/sync-all', { type: 2 });
}
export type { MomentsSyncTask, SyncRecord, CreateMomentsSyncData };

View File

@@ -1,8 +1,7 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Plus,
Filter,
Search,
RefreshCw,
MoreVertical,
@@ -11,111 +10,145 @@ import {
Trash2,
Eye,
Copy,
ChevronDown,
ChevronUp,
Settings,
Calendar,
Users,
ChevronLeft,
Share2,
// 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';
import {
fetchMomentsSyncTasks,
deleteMomentsSyncTask,
toggleMomentsSyncTask,
copyMomentsSyncTask,
MomentsSyncTask
} from '@/api/momentsSync';
interface SyncTask {
id: string;
name: string;
status: 'running' | 'paused' | 'completed';
deviceCount: number;
targetGroup: string;
syncCount: number;
lastSyncTime: string;
createTime: string;
creator: string;
syncInterval: number;
maxSyncPerDay: number;
timeRange: { start: string; end: string };
contentTypes: string[];
targetTags: string[];
syncMode: 'auto' | 'manual';
filterKeywords: string[];
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 [expandedTaskId, setExpandedTaskId] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [tasks, setTasks] = useState<SyncTask[]>([
{
id: '1',
name: '朋友圈自动同步',
deviceCount: 2,
targetGroup: '所有好友',
syncCount: 45,
lastSyncTime: '2025-02-06 13:12:35',
createTime: '2024-11-20 19:04:14',
creator: 'admin',
status: 'running',
syncInterval: 30,
maxSyncPerDay: 100,
timeRange: { start: '08:00', end: '22:00' },
contentTypes: ['text', 'image', 'video'],
targetTags: ['重要客户', '活跃用户'],
syncMode: 'auto',
filterKeywords: ['产品', '服务', '优惠'],
},
{
id: '2',
name: '营销内容同步',
deviceCount: 1,
targetGroup: '目标客户',
syncCount: 23,
lastSyncTime: '2024-03-04 14:09:35',
createTime: '2024-03-04 14:29:04',
creator: 'manager',
status: 'paused',
syncInterval: 60,
maxSyncPerDay: 50,
timeRange: { start: '09:00', end: '21:00' },
contentTypes: ['image', 'video'],
targetTags: ['潜在客户', '中意向'],
syncMode: 'manual',
filterKeywords: ['营销', '推广'],
},
]);
const [loading, setLoading] = useState(false);
const [tasks, setTasks] = useState<MomentsSyncTask[]>([]);
const toggleExpand = (taskId: string) => {
setExpandedTaskId(expandedTaskId === taskId ? null : taskId);
// 获取任务列表
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,
creator: task.creator || '未知',
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.contentLib || '默认内容库'
}));
setTasks(mappedTasks);
} catch (error) {
toast({ title: "获取任务失败", variant: "destructive" });
} finally {
setLoading(false);
}
};
const handleDelete = (taskId: string) => {
const taskToDelete = tasks.find((task) => task.id === taskId);
// 页面加载时获取数据
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;
setTasks(tasks.filter((task) => task.id !== taskId));
toast({
title: '删除成功',
description: '已成功删除同步任务',
});
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) => {
@@ -126,37 +159,52 @@ export default function MomentsSync() {
navigate(`/workspace/moments-sync/${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 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 = (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,
),
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
)
);
toast({
title: task.status === 'running' ? '已暂停' : '已启动',
description: `${task.name}任务${task.status === 'running' ? '已暂停' : '已启动'}`,
});
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 = () => {
@@ -167,64 +215,33 @@ export default function MomentsSync() {
task.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
const getStatusColor = (status: string) => {
const getStatusText = (status: number) => {
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':
case 1:
return '进行中';
case 'paused':
case 2:
return '已暂停';
case 'completed':
return '已完成';
default:
return '未知';
}
};
// const getStatusIcon = (status: string) => {
// switch (status) {
// case 'running':
// return <CheckCircle className="h-4 w-4 text-green-500" />;
// case 'paused':
// return <XCircle className="h-4 w-4 text-gray-500" />;
// case 'completed':
// return <CheckCircle className="h-4 w-4 text-blue-500" />;
// default:
// return <XCircle className="h-4 w-4 text-gray-500" />;
// }
// };
return (
<Layout
header={
<PageHeader
title="朋友圈同步"
defaultBackPath="/workspace"
rightContent={
<Button onClick={handleCreateNew}>
<Plus className="h-4 w-4 mr-2" />
<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>
}
/>
}
footer={<BottomNav />}
>
<div className="bg-gray-50 min-h-screen pb-20">
<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">
@@ -232,209 +249,78 @@ export default function MomentsSync() {
<Input
placeholder="搜索任务名称"
className="pl-9"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
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">
<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.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 className={getStatusColor(task.status)}>
{getStatusText(task.status)}
<Badge variant={Number(task.status) === 1 ? "success" : "secondary"}>
{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'}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleView(task.id)}>
<Eye className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleEdit(task.id)}>
<Edit className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCopy(task.id)}>
<Copy className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDelete(task.id)}>
<Trash2 className="h-4 w-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<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.deviceCount} </div>
<div>{task.targetGroup}</div>
<div>{task.deviceCount} </div>
<div>{task.contentLib || '默认内容库'}</div>
</div>
<div className="text-sm text-gray-500">
<div>{task.syncCount} </div>
<div>{task.creator}</div>
</div>
<div>{task.syncCount} </div>
<div>{task.creator}</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 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 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>
{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.syncInterval} </span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500"></span>
<span>{task.maxSyncPerDay} </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.syncMode === 'auto' ? '自动' : '手动'}</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">
<Share2 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.contentTypes.map((type) => (
<Badge key={type} variant="outline" className="bg-gray-50">
{type === 'text' ? '文字' : type === 'image' ? '图片' : '视频'}
</Badge>
))}
</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.syncCount} / {task.maxSyncPerDay}
</span>
</div>
<Progress
value={(task.syncCount / task.maxSyncPerDay) * 100}
className="h-2"
/>
{task.filterKeywords.length > 0 && (
<div className="mt-2">
<div className="text-sm text-gray-500 mb-1"></div>
<div className="flex flex-wrap gap-1">
{task.filterKeywords.map((keyword) => (
<Badge key={keyword} variant="outline" className="text-xs">
{keyword}
</Badge>
))}
<div>{task.createTime}</div>
</div>
</div>
</Card>
))
)}
</div>
</div>
</div>
</div>
)}
</Card>
))
)}
</div>
</div>
</div>
</Layout>
</div>
);
}

View File

@@ -7,44 +7,43 @@ 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 { fetchMomentsSyncDevices, syncMoments, syncAllMoments, fetchMomentsLog, MomentsSyncDevice } from '@/api/momentsSync';
import { fetchMomentsSyncTasks, syncMoments, syncAllMoments, MomentsSyncTask } from '@/api/momentsSync';
import { useToast } from '@/components/ui/toast';
export default function MomentsSyncPage() {
const { toast } = useToast();
const [devices, setDevices] = useState<MomentsSyncDevice[]>([]);
const [tasks, setTasks] = useState<MomentsSyncTask[]>([]);
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState('');
const [logModal, setLogModal] = useState<{ open: boolean; log: string; name: string }>({ open: false, log: '', name: '' });
const fetchDevices = async () => {
const fetchTasks = async () => {
setLoading(true);
try {
const res = await fetchMomentsSyncDevices(search);
setDevices(res.data?.list || []);
const list = await fetchMomentsSyncTasks();
setTasks(list);
} catch {
toast({ title: '获取设备失败', variant: 'destructive' });
toast({ title: '获取任务失败', variant: 'destructive' });
} finally {
setLoading(false);
}
};
useEffect(() => { fetchDevices(); }, []);
useEffect(() => { fetchTasks(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
const handleSearch = () => {
fetchDevices();
fetchTasks();
};
const handleRefresh = () => {
setSearch('');
fetchDevices();
fetchTasks();
};
const handleSync = async (id: string) => {
try {
await syncMoments(id);
toast({ title: '同步已发起' });
fetchDevices();
fetchTasks();
} catch {
toast({ title: '同步失败', variant: 'destructive' });
}
@@ -54,20 +53,15 @@ export default function MomentsSyncPage() {
try {
await syncAllMoments();
toast({ title: '全部同步已发起' });
fetchDevices();
fetchTasks();
} catch {
toast({ title: '同步失败', variant: 'destructive' });
}
};
const handleViewLog = async (device: MomentsSyncDevice) => {
try {
const res = await fetchMomentsLog(device.id);
setLogModal({ open: true, log: res.data?.log || device.log || '暂无日志', name: device.name });
} catch {
setLogModal({ open: true, log: device.log || '暂无日志', name: device.name });
}
};
const filteredTasks = tasks.filter(task =>
task.name.toLowerCase().includes(search.toLowerCase())
);
return (
<Layout
@@ -78,7 +72,7 @@ export default function MomentsSyncPage() {
<div className="relative flex-1">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
<Input
placeholder="搜索设备/账号"
placeholder="搜索任务名称"
className="pl-9"
value={search}
onChange={e => setSearch(e.target.value)}
@@ -95,49 +89,32 @@ export default function MomentsSyncPage() {
<div className="p-4 space-y-4">
{loading ? (
<Card className="p-8 text-center">...</Card>
) : devices.length === 0 ? (
<Card className="p-8 text-center"></Card>
) : filteredTasks.length === 0 ? (
<Card className="p-8 text-center"></Card>
) : (
devices.map(device => (
<Card key={device.id} className="p-4 flex flex-col md:flex-row md:items-center md:justify-between">
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">{device.name}</span>
<span className="font-medium text-base">{task.name}</span>
<Badge variant={
device.status === 'success' ? 'success' :
device.status === 'syncing' ? 'default' :
device.status === 'error' ? 'destructive' : 'outline'
task.status === 1 ? 'success' : 'secondary'
}>
{device.status === 'success' ? '已完成' :
device.status === 'syncing' ? '同步中' :
device.status === 'error' ? '失败' : '待同步'}
{task.status === 1 ? '进行中' : '已暂停'}
</Badge>
</div>
<div className="text-xs text-gray-500 mb-2">{device.lastSyncTime || '无'}</div>
<Progress value={device.progress} className="h-2 mb-2" />
<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={() => handleSync(device.id)} disabled={device.status === 'syncing'}>
<Button size="sm" variant="outline" onClick={() => handleSync(task.id)}>
<SyncIcon className="h-4 w-4 mr-1" />
</Button>
<Button size="sm" variant="ghost" onClick={() => handleViewLog(device)}>
<Eye className="h-4 w-4 mr-1" />
</Button>
</div>
</Card>
))
)}
</div>
{/* 日志弹窗 */}
{logModal.open && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-40">
<div className="bg-white rounded-lg shadow-lg max-w-lg w-full p-6 relative">
<div className="font-bold mb-2">{logModal.name} - </div>
<pre className="bg-gray-100 rounded p-2 text-xs max-h-60 overflow-auto whitespace-pre-wrap">{logModal.log}</pre>
<Button className="absolute top-2 right-2" size="icon" variant="ghost" onClick={() => setLogModal({ open: false, log: '', name: '' })}></Button>
</div>
</div>
)}
</div>
</Layout>
);

View File

@@ -0,0 +1,88 @@
// 朋友圈同步任务状态
export type MomentsSyncStatus = 1 | 2; // 1: 开启, 2: 关闭
// 内容类型
export type ContentType = 'text' | 'image' | 'video' | 'link';
// 同步模式
export type SyncMode = 'auto' | 'manual';
// 朋友圈同步任务
export interface MomentsSyncTask {
id: string;
name: string;
status: MomentsSyncStatus;
deviceCount: number;
targetGroup: string;
syncCount: number;
lastSyncTime: string;
createTime: string;
creator: string;
syncInterval: number;
maxSyncPerDay: number;
timeRange: { start: string; end: string };
contentTypes: ContentType[];
targetTags: string[];
syncMode: SyncMode;
filterKeywords: string[];
contentLib?: string;
devices: string[];
friends: string[];
todaySyncCount: number;
totalSyncCount: number;
updateTime: string;
}
// 创建任务数据
export interface CreateMomentsSyncData {
name: string;
interval: number;
maxSync: number;
startTime: string;
endTime: string;
contentTypes: ContentType[];
devices: string[];
friends?: string[];
syncMode: SyncMode;
targetTags: string[];
filterKeywords: string[];
contentLib?: string;
}
// 更新任务数据
export interface UpdateMomentsSyncData extends CreateMomentsSyncData {
id: string;
}
// 同步记录
export interface SyncRecord {
id: string;
workbenchId: string;
momentsId: string;
snsId: string;
wechatAccountId: string;
wechatFriendId: string;
syncTime: string;
content: string;
resUrls: string[];
momentTime: string;
userName: string;
operatorName: string;
operatorAvatar: string;
friendName: string;
friendAvatar: string;
}
// API 响应格式
export interface ApiResponse<T = any> {
code: number;
msg: string;
data: T;
}
export interface PaginatedResponse<T> {
list: T[];
total: number;
page: number;
limit: number;
}

File diff suppressed because it is too large Load Diff