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

记录列表搞好了
This commit is contained in:
笔记本里的永平
2025-07-08 15:55:41 +08:00
parent 53621e41e7
commit 98bcafda50
8 changed files with 511 additions and 663 deletions

View File

@@ -72,10 +72,21 @@ export async function copyAutoLikeTask(id: string): Promise<ApiResponse> {
export async function fetchLikeRecords( export async function fetchLikeRecords(
workbenchId: string, workbenchId: string,
page: number = 1, page: number = 1,
limit: number = 20 limit: number = 20,
keyword?: string
): Promise<PaginatedResponse<LikeRecord>> { ): Promise<PaginatedResponse<LikeRecord>> {
try { try {
const res = await get<ApiResponse<PaginatedResponse<LikeRecord>>>(`/v1/workbench/like-records?workbenchId=${workbenchId}&page=${page}&limit=${limit}`); const params = new URLSearchParams({
workbenchId,
page: page.toString(),
limit: limit.toString()
});
if (keyword) {
params.append('keyword', keyword);
}
const res = await get<ApiResponse<PaginatedResponse<LikeRecord>>>(`/v1/workbench/like-records?${params.toString()}`);
if (res.code === 200 && res.data) { if (res.code === 200 && res.data) {
return res.data; return res.data;

View File

@@ -4,7 +4,7 @@ import { requestInterceptor, responseInterceptor, errorInterceptor } from './int
// 创建axios实例 // 创建axios实例
const request: AxiosInstance = axios.create({ const request: AxiosInstance = axios.create({
baseURL: process.env.REACT_APP_API_BASE_URL || 'http://www.yishi.com', baseURL: process.env.REACT_APP_API_BASE_URL || 'http://www.yishi.com',
timeout: 10000, timeout: 20000,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },

View File

@@ -18,6 +18,7 @@ const NO_BOTTOM_NAV_PATHS = [
'/workspace/auto-group/', '/workspace/auto-group/',
'/workspace/moments-sync/', '/workspace/moments-sync/',
'/workspace/traffic-distribution/', '/workspace/traffic-distribution/',
'/workspace/auto-like',
'/404', '/404',
'/500' '/500'
]; ];

View File

@@ -0,0 +1,28 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "../../utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@@ -0,0 +1,15 @@
import { cn } from "../../utils";
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -9,7 +9,7 @@ import {
Search, Search,
RefreshCw, RefreshCw,
ThumbsUp, ThumbsUp,
ChevronDown, ChevronLeft,
} from "lucide-react"; } from "lucide-react";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -209,7 +209,7 @@ export default function AutoLike() {
<div className="flex items-center justify-between p-4"> <div className="flex items-center justify-between p-4">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}> <Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ChevronDown className="h-5 w-5" /> <ChevronLeft className="h-5 w-5" />
</Button> </Button>
<h1 className="text-lg font-medium"></h1> <h1 className="text-lg font-medium"></h1>
</div> </div>

View File

@@ -1,423 +1,144 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { import {
Edit,
Trash2,
Copy,
ThumbsUp, ThumbsUp,
Settings,
Calendar,
Eye,
RefreshCw, RefreshCw,
Search,
Filter,
} from 'lucide-react'; } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress'; import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch'; import { Avatar } from '@/components/ui/avatar';
import { import { Skeleton } from '@/components/ui/skeleton';
DropdownMenu, import { Separator } from '@/components/ui/separator';
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import Layout from '@/components/Layout'; import Layout from '@/components/Layout';
import PageHeader from '@/components/PageHeader'; import PageHeader from '@/components/PageHeader';
import BottomNav from '@/components/BottomNav';
import { useToast } from '@/components/ui/toast'; import { useToast } from '@/components/ui/toast';
import '@/components/Layout.css'; import '@/components/Layout.css';
import { import {
fetchAutoLikeTaskDetail,
toggleAutoLikeTask,
deleteAutoLikeTask,
copyAutoLikeTask,
fetchLikeRecords, fetchLikeRecords,
LikeTask,
LikeRecord, LikeRecord,
} from '@/api/autoLike'; } 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() { export default function AutoLikeDetail() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { toast } = useToast(); const { toast } = useToast();
const [task, setTask] = useState<LikeTask | null>(null);
const [records, setRecords] = useState<LikeRecord[]>([]); const [records, setRecords] = useState<LikeRecord[]>([]);
const [loading, setLoading] = useState(true);
const [recordsLoading, setRecordsLoading] = useState(false); const [recordsLoading, setRecordsLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [total, setTotal] = useState(0);
const pageSize = 10;
const fetchTaskDetail = useCallback(async () => { const fetchRecords = useCallback(async (page: number = 1, keyword?: string) => {
if (!id) return; if (!id) return;
setRecordsLoading(true);
try { try {
const taskData = await fetchAutoLikeTaskDetail(id); const response = await fetchLikeRecords(id, page, pageSize, keyword);
if (taskData) { setRecords(response.list || []);
setTask(taskData); setTotal(response.total || 0);
} else { setCurrentPage(page);
toast({
title: '任务不存在',
description: '该任务可能已被删除',
variant: 'destructive',
});
navigate('/workspace/auto-like');
}
} catch (error) { } catch (error) {
console.error('获取任务详情失败:', error); console.error('获取点赞记录失败:', error);
toast({ toast({
title: '获取失败', title: '获取点赞记录失败',
description: '请稍后重试', description: '请稍后重试',
variant: 'destructive', variant: 'destructive',
}); });
} finally {
setLoading(false);
}
}, [id, toast, navigate]);
const fetchRecords = useCallback(async () => {
if (!id) return;
setRecordsLoading(true);
try {
const response = await fetchLikeRecords(id, 1, 20);
setRecords(response.list || []);
} catch (error) {
console.error('获取点赞记录失败:', error);
} finally { } finally {
setRecordsLoading(false); setRecordsLoading(false);
} }
}, [id]); }, [id, toast]);
useEffect(() => { useEffect(() => {
if (id) { if (id) {
fetchTaskDetail(); fetchRecords(1);
fetchRecords();
} }
}, [id, fetchTaskDetail, fetchRecords]); }, [id, fetchRecords]); // 加上 fetchRecords 依赖
const handleToggleStatus = async () => { const handleSearch = () => {
if (!task) return; setCurrentPage(1);
fetchRecords(1, searchTerm);
// 先更新本地状态
const newStatus = (Number(task.status) === 1 ? 2 : 1) as 1 | 2;
const originalStatus = Number(task.status) as 1 | 2;
setTask(prev => prev ? { ...prev, status: newStatus } : null);
try {
const response = await toggleAutoLikeTask(task.id, String(newStatus));
if (response.code === 200) {
toast({ title: '操作成功' });
// 成功时保持本地状态,不重新获取数据
} else {
// 请求失败,回退本地状态
setTask(prev => prev ? { ...prev, status: originalStatus } : null);
toast({
title: '操作失败',
description: response.msg || '请稍后重试',
variant: 'destructive',
});
}
} catch (error) {
console.error('操作失败:', error);
// 请求异常,回退本地状态
setTask(prev => prev ? { ...prev, status: originalStatus } : null);
toast({
title: '操作失败',
description: '请稍后重试',
variant: 'destructive',
});
}
}; };
const handleDelete = async () => { const handleRefresh = () => {
if (!task || !window.confirm('确定要删除该任务吗?')) return; fetchRecords(currentPage, searchTerm);
try {
const response = await deleteAutoLikeTask(task.id);
if (response.code === 200) {
toast({ title: '删除成功' });
navigate('/workspace/auto-like');
} else {
toast({
title: '删除失败',
description: response.msg || '请稍后重试',
variant: 'destructive',
});
}
} catch (error) {
console.error('删除失败:', error);
toast({
title: '删除失败',
description: '请稍后重试',
variant: 'destructive',
});
}
}; };
const handleCopy = async () => { const handlePageChange = (newPage: number) => {
if (!task) return; fetchRecords(newPage, searchTerm);
try {
const response = await copyAutoLikeTask(task.id);
if (response.code === 200) {
toast({ title: '复制成功' });
navigate('/workspace/auto-like');
} else {
toast({
title: '复制失败',
description: response.msg || '请稍后重试',
variant: 'destructive',
});
}
} catch (error) {
console.error('复制失败:', error);
toast({
title: '复制失败',
description: '请稍后重试',
variant: 'destructive',
});
}
}; };
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 '未知';
}
};
if (loading) {
return (
<Layout
header={<PageHeader title="任务详情" defaultBackPath="/workspace/auto-like" />}
footer={<BottomNav />}
>
<div className="bg-gray-50 min-h-screen pb-20">
<div className="p-4">
<Card className="p-8 text-center">
<RefreshCw className="h-12 w-12 text-gray-300 mx-auto mb-3 animate-spin" />
<p className="text-gray-500 text-lg font-medium mb-2">...</p>
<p className="text-gray-400 text-sm"></p>
</Card>
</div>
</div>
</Layout>
);
}
if (!task) {
return null;
}
return ( return (
<Layout <Layout
header={ header={
<PageHeader <PageHeader
title="任务详情" title="点赞记录"
defaultBackPath="/workspace/auto-like" defaultBackPath="/workspace/auto-like"
rightContent={
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Settings className="h-4 w-4 mr-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => navigate(`/workspace/auto-like/${task.id}/edit`)}>
<Edit className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCopy}>
<Copy className="h-4 w-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={handleDelete}>
<Trash2 className="h-4 w-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
}
/> />
} }
footer={<BottomNav />}
> >
<div className="bg-gray-50 min-h-screen pb-20"> <div className="bg-gray-50 min-h-screen pb-20">
<div className="p-4 space-y-4"> <div className="p-4 space-y-4">
{/* 任务基本信息 */}
<Card> <Card>
<CardHeader> <CardContent className="p-4">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center">
<ThumbsUp className="h-5 w-5 mr-2" />
{task.name}
</CardTitle>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Badge className={getStatusColor(Number(task.status))}> <div className="relative flex-1">
{getStatusText(Number(task.status))} <Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
</Badge> <Input
<Switch placeholder="搜索好友昵称或内容"
checked={Number(task.status) === 1} className="pl-9"
onCheckedChange={handleToggleStatus} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/> />
</div> </div>
</div> <Button variant="outline" size="icon" onClick={handleRefresh} disabled={recordsLoading}>
</CardHeader> <RefreshCw className={`h-4 w-4 ${recordsLoading ? 'animate-spin' : ''}`} />
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="text-sm">
<div className="text-gray-500"></div>
<div className="font-medium">{task.createTime}</div>
</div>
<div className="text-sm">
<div className="text-gray-500"></div>
<div className="font-medium">{task.creator}</div>
</div>
<div className="text-sm">
<div className="text-gray-500"></div>
<div className="font-medium">{task.deviceCount} </div>
</div>
<div className="text-sm">
<div className="text-gray-500"></div>
<div className="font-medium">{task.targetGroup}</div>
</div>
</div>
</CardContent>
</Card>
{/* 执行进度 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Calendar className="h-5 w-5 mr-2" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-500"></span>
<span className="font-medium">
{task.todayLikeCount} / {task.maxLikesPerDay}
</span>
</div>
<Progress
value={(task.todayLikeCount / task.maxLikesPerDay) * 100}
className="h-2"
/>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<div className="text-gray-500"></div>
<div className="font-medium">{task.totalLikeCount} </div>
</div>
<div>
<div className="text-gray-500"></div>
<div className="font-medium">{task.lastLikeTime}</div>
</div>
</div>
</CardContent>
</Card>
{/* 任务配置 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Settings className="h-5 w-5 mr-2" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="text-sm">
<div className="text-gray-500"></div>
<div className="font-medium">{task.likeInterval} </div>
</div>
<div className="text-sm">
<div className="text-gray-500"></div>
<div className="font-medium">{task.maxLikesPerDay} </div>
</div>
<div className="text-sm">
<div className="text-gray-500"></div>
<div className="font-medium">{task.friendMaxLikes} </div>
</div>
<div className="text-sm">
<div className="text-gray-500"></div>
<div className="font-medium">
{task.timeRange.start} - {task.timeRange.end}
</div>
</div>
</div>
<div className="space-y-2">
<div className="text-sm text-gray-500"></div>
<div className="flex flex-wrap gap-2">
{task.contentTypes.map((type) => (
<Badge key={type} variant="outline">
{type === 'text' ? '文字' : type === 'image' ? '图片' : type === 'video' ? '视频' : '链接'}
</Badge>
))}
</div>
</div>
{task.targetTags.length > 0 && (
<div className="space-y-2">
<div className="text-sm text-gray-500"></div>
<div className="flex flex-wrap gap-2">
{task.targetTags.map((tag) => (
<Badge key={tag} variant="outline">
{tag}
</Badge>
))}
</div>
</div>
)}
{task.enableFriendTags && task.friendTags && (
<div className="space-y-2">
<div className="text-sm text-gray-500"></div>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">{task.friendTags}</Badge>
</div>
</div>
)}
</CardContent>
</Card>
{/* 点赞记录 */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center">
<Eye className="h-5 w-5 mr-2" />
</CardTitle>
<Button variant="outline" size="sm" onClick={fetchRecords}>
<RefreshCw className="h-4 w-4 mr-2" />
</Button> </Button>
</div> </div>
</CardHeader> </CardContent>
<CardContent> </Card>
{recordsLoading ? ( {recordsLoading ? (
<div className="text-center py-4"> <div className="space-y-4">
<RefreshCw className="h-6 w-6 text-gray-400 mx-auto animate-spin" /> {Array.from({ length: 3 }).map((_, index) => (
<p className="text-gray-500 text-sm mt-2">...</p> <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> </div>
) : records.length === 0 ? ( ) : records.length === 0 ? (
<div className="text-center py-8"> <div className="text-center py-8">
@@ -425,29 +146,101 @@ export default function AutoLikeDetail() {
<p className="text-gray-500"></p> <p className="text-gray-500"></p>
</div> </div>
) : ( ) : (
<div className="space-y-3"> <>
{records.slice(0, 10).map((record) => ( {records.map((record) => (
<div key={record.id} className="flex items-center space-x-3 p-3 border rounded-lg"> <div key={record.id} className="p-4 mb-4 bg-white rounded-2xl shadow-sm">
<div className="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center"> <div className="flex items-start justify-between">
<ThumbsUp className="h-4 w-4 text-blue-600" /> <div className="flex items-center space-x-3 max-w-[65%]">
</div> <Avatar>
<div className="flex-1 min-w-0"> <img
<div className="text-sm font-medium truncate"> 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} {record.friendName}
</div> </div>
<div className="text-xs text-gray-500 truncate"> <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} {record.content}
</div> </p>
</div> )}
<div className="text-xs text-gray-500"> {Array.isArray(record.resUrls) && record.resUrls.length > 0 && (
{record.likeTime} <div className={`grid gap-2 ${
</div> 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>
)} )}
</CardContent> </div>
</Card> <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>
))}
</>
)}
{/* 分页 */}
{records.length > 0 && total > pageSize && (
<div className="flex justify-center mt-6">
<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> </div>
</div> </div>
</Layout> </Layout>

View File

@@ -33,7 +33,7 @@ export interface LikeRecord {
wechatFriendId: string; wechatFriendId: string;
likeTime: string; likeTime: string;
content: string; content: string;
resUrls: string; resUrls: string[];
momentTime: string; momentTime: string;
userName: string; userName: string;
operatorName: string; operatorName: string;