This commit is contained in:
2025-03-07 17:45:17 +08:00
commit 936af0c4ec
114 changed files with 37662 additions and 0 deletions

View File

@@ -0,0 +1,520 @@
import React, { useState, useEffect } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import {
Facebook,
MessageSquare,
Instagram,
Linkedin,
CheckCircle,
XCircle,
MoreHorizontal,
ExternalLink,
BookOpen,
ThumbsUp,
ThumbsDown,
Minus,
AlertTriangle,
User,
Award,
Briefcase,
Youtube,
Hash,
Filter,
ChevronDown,
ArrowLeft
} from 'lucide-react';
import CommentPreview from './CommentPreview';
import { commentsApi, postsApi } from '../utils/api';
// 定义后端返回的评论类型
interface ApiComment {
comment_id: string;
content: string;
sentiment_score: number;
created_at: string;
updated_at: string;
post_id: string;
user_id: string;
user_profile?: {
id: string;
full_name: string;
avatar_url: string;
};
}
// 定义前端使用的评论类型
interface FrontendComment {
id: string;
content: string;
author: string;
authorType: 'user' | 'kol' | 'official';
platform: 'facebook' | 'threads' | 'instagram' | 'linkedin' | 'xiaohongshu' | 'youtube';
contentType?: 'post' | 'reel' | 'video' | 'short';
timestamp: string;
sentiment: string;
status: string;
replyStatus?: string;
language?: string;
articleTitle?: string;
postAuthor?: string;
postAuthorType?: string;
url?: string;
}
interface CommentListProps {
postId?: string; // 可选的帖子 ID如果提供则只获取该帖子的评论
}
interface PostData {
id: string;
title: string;
description?: string;
platform: string;
post_url?: string;
}
const CommentList: React.FC<CommentListProps> = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const postId = searchParams.get('post_id');
const [comments, setComments] = useState<FrontendComment[]>([]);
const [post, setPost] = useState<PostData | null>(null); // Store post data
const [loading, setLoading] = useState<boolean>(true);
const [selectedComment, setSelectedComment] = useState<FrontendComment | null>(null);
const [error, setError] = useState<string | null>(null);
// 过滤和分页状态
const [platformFilter, setPlatformFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [sentimentFilter, setSentimentFilter] = useState<string>('all');
const [replyStatusFilter, setReplyStatusFilter] = useState<string>('all');
const [languageFilter, setLanguageFilter] = useState<string>('all');
const [searchQuery, setSearchQuery] = useState<string>('');
const [currentPage, setCurrentPage] = useState<number>(1);
const [pageSize, setPageSize] = useState<number>(10);
const [totalComments, setTotalComments] = useState<number>(0);
const [showFilters, setShowFilters] = useState<boolean>(false);
// Fetch post data if postId is provided
useEffect(() => {
const fetchPostData = async () => {
if (postId) {
try {
const response = await postsApi.getPost(postId);
setPost(response.data);
} catch (err) {
console.error('Failed to fetch post data:', err);
}
}
};
fetchPostData();
}, [postId]);
// 获取评论数据
useEffect(() => {
const fetchComments = async () => {
try {
setLoading(true);
// Build query parameters
const params: Record<string, string | number> = {};
if (postId) {
params.post_id = postId;
}
if (platformFilter !== 'all') {
params.platform = platformFilter;
}
if (statusFilter !== 'all') {
params.status = statusFilter;
}
if (sentimentFilter !== 'all') {
params.sentiment = sentimentFilter;
}
if (searchQuery) {
params.query = searchQuery;
}
if (languageFilter !== 'all') {
params.language = languageFilter;
}
// Add pagination
params.limit = pageSize;
params.offset = (currentPage - 1) * pageSize;
const response = await commentsApi.getComments(params);
// 处理返回的数据
const apiComments: ApiComment[] = response.data.comments || [];
const total = response.data.total || apiComments.length;
// 转换为前端格式
const frontendComments: FrontendComment[] = apiComments.map(comment => {
// 确定情感
let sentiment = 'neutral';
if (comment.sentiment_score > 0.3) {
sentiment = 'positive';
} else if (comment.sentiment_score < -0.3) {
sentiment = 'negative';
}
// 检测语言
const language = detectLanguage(comment.content);
return {
id: comment.comment_id,
content: comment.content,
author: comment.user_profile?.full_name || '匿名用户',
authorType: 'user', // 默认为普通用户
platform: 'facebook', // 假设默认是 Facebook
timestamp: comment.created_at,
sentiment,
status: 'approved', // 假设默认已审核
language,
// 其他可选字段可以根据 API 返回的数据动态添加
};
});
setComments(frontendComments);
setTotalComments(total);
setError(null);
} catch (err) {
console.error('Failed to fetch comments:', err);
setError('加载评论失败,请稍后再试');
} finally {
setLoading(false);
}
};
fetchComments();
}, [postId, platformFilter, statusFilter, sentimentFilter, searchQuery, languageFilter, currentPage, pageSize]);
// 简单的语言检测
const detectLanguage = (text: string): 'zh-TW' | 'zh-CN' | 'en' => {
const traditionalChineseRegex = /[一-龥]/;
const simplifiedChineseRegex = /[一-龥]/;
const englishRegex = /[a-zA-Z]/;
if (englishRegex.test(text) && !traditionalChineseRegex.test(text) && !simplifiedChineseRegex.test(text)) {
return 'en';
} else if (traditionalChineseRegex.test(text)) {
// 这里简化了繁体/简体的判断,实际实现应该更复杂
return 'zh-TW';
} else {
return 'zh-CN';
}
};
// Function to go back to posts list
const handleBackToPosts = () => {
navigate('/posts');
};
// 显示加载状态
if (loading) {
return (
<div className="flex flex-col flex-1 overflow-hidden md:flex-row">
<div className="flex-1 overflow-auto">
<div className="p-4 sm:p-6">
<div className="flex items-center justify-center h-64">
<div className="w-12 h-12 border-t-2 border-b-2 border-blue-500 rounded-full animate-spin"></div>
</div>
</div>
</div>
</div>
);
}
// 显示错误信息
if (error) {
return (
<div className="flex flex-col flex-1 overflow-hidden md:flex-row">
<div className="flex-1 overflow-auto">
<div className="p-4 sm:p-6">
<div className="flex items-center justify-center h-64">
<div className="text-center text-red-500">
<AlertTriangle className="w-12 h-12 mx-auto mb-4" />
<p>{error}</p>
<button
className="px-4 py-2 mt-4 text-white bg-blue-500 rounded-md hover:bg-blue-600"
onClick={() => window.location.reload()}
>
</button>
</div>
</div>
</div>
</div>
</div>
);
}
return (
<div className="flex flex-1 overflow-hidden">
<div className="flex flex-col flex-1 overflow-hidden">
<div className="bg-white p-4 border-b flex items-center justify-between">
<div className="flex items-center">
{postId && (
<button
onClick={handleBackToPosts}
className="mr-4 p-1 hover:bg-gray-100 rounded-full transition-colors duration-200"
>
<ArrowLeft className="h-5 w-5 text-gray-500" />
</button>
)}
<h2 className="text-lg font-semibold">
{post ? `${post.title} 的评论` : '所有评论'}
</h2>
{post && (
<span className="ml-2 text-sm text-gray-500">
({totalComments} )
</span>
)}
</div>
<div className="flex items-center">
<div className="relative mr-2">
<input
type="text"
placeholder="搜索评论..."
className="px-4 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<button
onClick={() => setShowFilters(!showFilters)}
className={`flex items-center px-3 py-2 rounded-lg text-sm ${
showFilters ? 'bg-blue-100 text-blue-700' : 'hover:bg-gray-100'
}`}
>
<Filter className="h-4 w-4 mr-1" />
<ChevronDown className="h-4 w-4 ml-1" />
</button>
</div>
</div>
{/* Mobile filters panel */}
{showFilters && (
<div className="p-4 mb-4 space-y-3 bg-white rounded-lg shadow-md sm:hidden">
<div>
<label className="block mb-1 text-sm font-medium text-gray-700"></label>
<select
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="all"></option>
<option value="pending"></option>
<option value="approved"></option>
<option value="rejected"></option>
</select>
</div>
<div>
<label className="block mb-1 text-sm font-medium text-gray-700"></label>
<select
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
value={platformFilter}
onChange={(e) => setPlatformFilter(e.target.value)}
>
<option value="all"></option>
<option value="facebook">Facebook</option>
<option value="threads">Threads</option>
<option value="instagram">Instagram</option>
<option value="linkedin">LinkedIn</option>
<option value="xiaohongshu"></option>
<option value="youtube">YouTube</option>
</select>
</div>
<div>
<label className="block mb-1 text-sm font-medium text-gray-700"></label>
<select
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
value={replyStatusFilter}
onChange={(e) => setReplyStatusFilter(e.target.value)}
>
<option value="all"></option>
<option value="sent"></option>
<option value="draft">稿</option>
<option value="none"></option>
</select>
</div>
<div>
<label className="block mb-1 text-sm font-medium text-gray-700"></label>
<select
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
value={languageFilter}
onChange={(e) => setLanguageFilter(e.target.value)}
>
<option value="all"></option>
<option value="zh-TW"></option>
<option value="zh-CN"></option>
<option value="en">English</option>
</select>
</div>
</div>
)}
{/* Mobile comment list */}
<div className="block md:hidden">
<div className="space-y-4">
{comments.map((comment) => (
<div
key={comment.id}
className="overflow-hidden bg-white rounded-lg shadow cursor-pointer"
onClick={() => setSelectedComment(comment)}
>
<div className="p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center">
<Facebook className="w-5 h-5 text-blue-600" />
<span className="ml-2 text-sm font-medium">Facebook</span>
</div>
</div>
<p className="mb-2 text-sm text-gray-900">{comment.content}</p>
<div className="flex items-center justify-between">
<div className="flex items-center">
<span className="mr-2 text-xs font-medium text-gray-700">{comment.author}</span>
</div>
<span className="text-xs text-gray-500">{comment.timestamp}</span>
</div>
</div>
</div>
))}
</div>
</div>
{/* Desktop table */}
<div className="hidden overflow-hidden bg-white rounded-lg shadow md:block">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase"></th>
<th scope="col" className="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase"></th>
<th scope="col" className="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase"></th>
<th scope="col" className="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase"></th>
<th scope="col" className="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase"></th>
<th scope="col" className="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase"></th>
<th scope="col" className="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase"></th>
<th scope="col" className="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase"></th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{comments.map((comment) => (
<tr
key={comment.id}
className="cursor-pointer hover:bg-gray-50"
onClick={() => setSelectedComment(comment)}
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex flex-col">
<div className="flex items-center">
<Facebook className="w-5 h-5 text-blue-600" />
<span className="ml-2 text-sm text-gray-900">
Facebook
</span>
</div>
</div>
</td>
<td className="px-6 py-4">
<div className="max-w-md text-sm text-gray-900 truncate">
{comment.content}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="mr-2 text-sm text-gray-900">{comment.author}</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">{comment.timestamp}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{comment.language === 'zh-TW' && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
</span>
)}
{comment.language === 'zh-CN' && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
</span>
)}
{comment.language === 'en' && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
EN
</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{comment.sentiment === 'positive' && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<ThumbsUp className="w-3 h-3 mr-1" />
</span>
)}
{comment.sentiment === 'negative' && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
<ThumbsDown className="w-3 h-3 mr-1" />
</span>
)}
{comment.sentiment === 'neutral' && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
<Minus className="w-3 h-3 mr-1" />
</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{comment.replyStatus === 'sent' && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<CheckCircle className="w-3 h-3 mr-1" />
</span>
)}
{comment.replyStatus === 'draft' && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
<MessageSquare className="w-3 h-3 mr-1" />
稿
</span>
)}
{comment.replyStatus === 'none' && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
<XCircle className="w-3 h-3 mr-1" />
</span>
)}
</td>
<td className="px-6 py-4 text-sm font-medium text-right whitespace-nowrap">
<button className="mr-3 text-blue-600 hover:text-blue-900">
<ExternalLink className="w-4 h-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
{selectedComment && (
<div className="overflow-auto bg-white border-t border-gray-200 md:w-96 md:border-t-0 md:border-l">
<CommentPreview comment={selectedComment} onClose={() => setSelectedComment(null)} />
</div>
)}
</div>
);
};
export default CommentList;