init
This commit is contained in:
520
web/src/components/CommentList.tsx
Normal file
520
web/src/components/CommentList.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user