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

114
web/src/App.tsx Normal file
View File

@@ -0,0 +1,114 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { AuthProvider, useAuth, User } from './context/AuthContext';
import Login from './components/Login';
import Header from './components/Header';
import Sidebar from './components/Sidebar';
import CommentList from './components/CommentList';
import PostList from './components/PostList';
import Dashboard from './components/Dashboard';
import Analytics from './components/Analytics';
import ProtectedRoute from './components/ProtectedRoute';
const AppContent = () => {
const { isAuthenticated, login, loading } = useAuth();
const [sidebarOpen, setSidebarOpen] = React.useState<boolean>(false);
const [activePage, setActivePage] = React.useState<string>('dashboard');
const location = useLocation();
// 添加更多调试信息
React.useEffect(() => {
console.log('AppContent - Auth state updated:', { isAuthenticated, loading, path: location.pathname });
}, [isAuthenticated, loading, location.pathname]);
// Update active page based on URL
React.useEffect(() => {
if (location.pathname === '/') {
setActivePage('dashboard');
} else if (location.pathname === '/comments') {
setActivePage('comments');
} else if (location.pathname === '/posts') {
setActivePage('posts');
} else if (location.pathname === '/analytics') {
setActivePage('analytics');
}
}, [location]);
// Show loading spinner while checking authentication status
if (loading) {
console.log('AppContent - Still loading auth state...');
return (
<div className="min-h-screen flex justify-center items-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
</div>
);
}
// Handle successful login
const handleLoginSuccess = (token: string, user: User) => {
console.log('AppContent - Login success, calling login function with:', { user });
login(token, user);
};
// Render main app layout when authenticated
const renderAppLayout = () => {
return (
<div className="flex h-screen bg-gray-100 overflow-hidden">
<Sidebar
activePage={activePage}
onPageChange={(page) => {
setActivePage(page);
setSidebarOpen(false);
}}
isOpen={sidebarOpen}
onClose={() => setSidebarOpen(false)}
/>
<div className="flex flex-col flex-1 overflow-hidden">
<Header
onMenuClick={() => setSidebarOpen(!sidebarOpen)}
/>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/comments" element={<CommentList />} />
<Route path="/posts" element={<PostList />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</div>
</div>
);
};
return (
<Routes>
<Route
path="/login"
element={
isAuthenticated ?
<Navigate to="/" replace /> :
<Login onLoginSuccess={handleLoginSuccess} />
}
/>
<Route
path="/*"
element={
<ProtectedRoute>
{renderAppLayout()}
</ProtectedRoute>
}
/>
</Routes>
);
};
function App() {
return (
<Router>
<AuthProvider>
<AppContent />
</AuthProvider>
</Router>
);
}
export default App;

View File

@@ -0,0 +1,815 @@
import React, { useState, useEffect } from 'react';
import {
BarChart2,
TrendingUp,
PieChart,
MessageSquare,
Facebook,
Twitter,
Instagram,
Linkedin,
BookOpen,
CheckCircle,
XCircle,
Clock,
ThumbsUp,
ThumbsDown,
Youtube,
Hash,
Users,
Heart,
Share2,
Eye,
ArrowRight,
ChevronDown,
Filter,
Download,
AlertTriangle
} from 'lucide-react';
import axios from 'axios';
// Define interfaces for analytics data
interface AnalyticsData {
name: string;
value: number;
color?: string;
}
interface TimelineData {
date: string;
comments: number;
}
interface SentimentData {
positive: number;
neutral: number;
negative: number;
}
interface Article {
id: string;
title: string;
views: number;
engagement: number;
platform: string;
}
interface KOLData {
id: string;
name: string;
platform: string;
followers: number;
engagement: number;
posts: number;
sentiment: SentimentData;
}
interface FunnelData {
stage: string;
count: number;
rate: number;
}
const Analytics: React.FC = () => {
const [timeRange, setTimeRange] = useState('7days');
const [selectedKOL, setSelectedKOL] = useState('all');
const [selectedPlatform, setSelectedPlatform] = useState('all');
const [platformData, setPlatformData] = useState<AnalyticsData[]>([]);
const [timelineData, setTimelineData] = useState<TimelineData[]>([]);
const [sentimentData, setSentimentData] = useState<SentimentData>({
positive: 0,
neutral: 0,
negative: 0
});
const [statusData, setStatusData] = useState<AnalyticsData[]>([]);
const [popularArticles, setPopularArticles] = useState<Article[]>([]);
const [kolData, setKolData] = useState<KOLData[]>([]);
const [funnelData, setFunnelData] = useState<FunnelData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchAnalyticsData = async () => {
try {
setLoading(true);
// Fetch platform distribution
const platformResponse = await axios.get(`http://localhost:4000/api/analytics/platforms?timeRange=${timeRange}`);
setPlatformData(platformResponse.data || []);
// Fetch timeline data
const timelineResponse = await axios.get(`http://localhost:4000/api/analytics/timeline?timeRange=${timeRange}`);
setTimelineData(timelineResponse.data || []);
// Fetch sentiment data
const sentimentResponse = await axios.get(`http://localhost:4000/api/analytics/sentiment?timeRange=${timeRange}`);
setSentimentData(sentimentResponse.data || { positive: 0, neutral: 0, negative: 0 });
// Fetch status data
const statusResponse = await axios.get(`http://localhost:4000/api/analytics/status?timeRange=${timeRange}`);
setStatusData(statusResponse.data || []);
// Fetch popular articles
const articlesResponse = await axios.get(`http://localhost:4000/api/analytics/popular-content?timeRange=${timeRange}`);
setPopularArticles(articlesResponse.data || []);
// Fetch KOL data
const kolResponse = await axios.get(`http://localhost:4000/api/analytics/influencers?timeRange=${timeRange}`);
setKolData(kolResponse.data || []);
// Fetch funnel data
const funnelResponse = await axios.get(`http://localhost:4000/api/analytics/conversion?timeRange=${timeRange}`);
setFunnelData(funnelResponse.data || []);
setError(null);
} catch (err) {
console.error('Failed to fetch analytics data:', err);
setError('Failed to load analytics data. Please try again later.');
} finally {
setLoading(false);
}
};
fetchAnalyticsData();
}, [timeRange]);
// 根據選擇的KOL和平台過濾數據
const filteredKOLData = selectedKOL === 'all'
? kolData
: kolData.filter(kol => kol.id === selectedKOL);
const filteredEngagementData = selectedKOL === 'all'
? kolData
: kolData.filter(item => item.id === selectedKOL);
const getPlatformIcon = (platform: string) => {
switch (platform) {
case 'facebook':
return <Facebook className="h-5 w-5 text-blue-600" />;
case 'threads':
return <Hash className="h-5 w-5 text-black" />;
case 'instagram':
return <Instagram className="h-5 w-5 text-pink-500" />;
case 'linkedin':
return <Linkedin className="h-5 w-5 text-blue-700" />;
case 'xiaohongshu':
return <BookOpen className="h-5 w-5 text-red-500" />;
case 'youtube':
return <Youtube className="h-5 w-5 text-red-600" />;
default:
return null;
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'approved':
return <CheckCircle className="h-5 w-5 text-green-600" />;
case 'rejected':
return <XCircle className="h-5 w-5 text-red-600" />;
case 'pending':
return <Clock className="h-5 w-5 text-yellow-600" />;
default:
return null;
}
};
const getStatusName = (status: string) => {
switch (status) {
case 'approved':
return '已核准';
case 'rejected':
return '已拒絕';
case 'pending':
return '待審核';
default:
return status;
}
};
const getPlatformColor = (platform: string) => {
switch (platform) {
case 'facebook':
return 'bg-blue-600';
case 'threads':
return 'bg-black';
case 'instagram':
return 'bg-pink-500';
case 'linkedin':
return 'bg-blue-700';
case 'xiaohongshu':
return 'bg-red-500';
case 'youtube':
return 'bg-red-600';
default:
return 'bg-gray-600';
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'approved':
return 'bg-green-600';
case 'rejected':
return 'bg-red-600';
case 'pending':
return 'bg-yellow-600';
default:
return 'bg-gray-600';
}
};
const getSentimentColor = (sentiment: string) => {
switch (sentiment) {
case 'positive':
return 'bg-green-500';
case 'negative':
return 'bg-red-500';
case 'neutral':
return 'bg-gray-500';
case 'mixed':
return 'bg-yellow-500';
default:
return 'bg-gray-500';
}
};
const maxTimelineCount = Math.max(...timelineData.map(item => item.comments));
// 計算KOL表現排名
const sortedKOLs = [...filteredKOLData].sort((a, b) => b.engagement - a.engagement);
return (
<div className="flex-1 overflow-auto">
<div className="p-6">
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center mb-6 space-y-4 lg:space-y-0">
<h2 className="text-2xl font-bold text-gray-800"></h2>
<div className="flex flex-col sm:flex-row space-y-3 sm:space-y-0 sm:space-x-4">
<div className="flex space-x-2">
<select
className="border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
value={timeRange}
onChange={(e) => setTimeRange(e.target.value)}
>
<option value="7days"> 7 </option>
<option value="30days"> 30 </option>
<option value="90days"> 90 </option>
<option value="1year"> 1 </option>
</select>
<select
className="border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
value={selectedKOL}
onChange={(e) => setSelectedKOL(e.target.value)}
>
<option value="all"> KOL</option>
{kolData.map(kol => (
<option key={kol.id} value={kol.id}>{kol.name}</option>
))}
</select>
<select
className="border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
value={selectedPlatform}
onChange={(e) => setSelectedPlatform(e.target.value)}
>
<option value="all"></option>
<option value="facebook">Facebook</option>
<option value="instagram">Instagram</option>
<option value="threads">Threads</option>
<option value="youtube">YouTube</option>
<option value="xiaohongshu"></option>
</select>
</div>
<button className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 flex items-center">
<Download className="h-4 w-4 mr-2" />
</button>
</div>
</div>
{/* KOL 表現概覽 */}
<div className="bg-white rounded-lg shadow p-6 mb-8">
<div className="flex justify-between items-center mb-6">
<h3 className="text-lg font-medium text-gray-800">KOL </h3>
<div className="flex items-center text-sm text-blue-600">
<span className="mr-1"></span>
<ArrowRight className="h-4 w-4" />
</div>
</div>
<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-left text-xs font-medium text-gray-500 uppercase tracking-wider">
KOL
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{sortedKOLs.map((kol, index) => (
<tr key={kol.id} className={index === 0 ? "bg-blue-50" : "hover:bg-gray-50"}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="h-10 w-10 rounded-full overflow-hidden mr-3">
<img src={kol.avatar} alt={kol.name} className="h-full w-full object-cover" />
</div>
<div>
<div className="text-sm font-medium text-gray-900">{kol.name}</div>
<div className="text-xs text-gray-500">{kol.followers} </div>
</div>
{index === 0 && (
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
Top KOL
</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex space-x-1">
{kol.platforms.map(platform => (
<div key={platform} className="flex items-center">
{getPlatformIcon(platform)}
</div>
))}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{kol.postCount}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center text-sm text-gray-900">
<Heart className="h-4 w-4 text-red-500 mr-1" />
{kol.likeCount.toLocaleString()}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center text-sm text-gray-900">
<MessageSquare className="h-4 w-4 text-blue-500 mr-1" />
{kol.commentCount.toLocaleString()}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="text-sm text-gray-900 font-medium">{(kol.engagementRate * 100).toFixed(1)}%</div>
<div className={`ml-2 ${kol.engagementTrend > 0 ? 'text-green-500' : 'text-red-500'} flex items-center text-xs`}>
{kol.engagementTrend > 0 ? (
<>
<TrendingUp className="h-3 w-3 mr-1" />
+{kol.engagementTrend}%
</>
) : (
<>
<TrendingUp className="h-3 w-3 mr-1 transform rotate-180" />
{kol.engagementTrend}%
</>
)}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div
className="h-2 w-24 bg-gray-200 rounded-full overflow-hidden"
>
<div
className="h-full bg-green-500"
style={{ width: `${kol.sentimentScore}%` }}
></div>
</div>
<span className="ml-2 text-sm text-gray-900">{kol.sentimentScore}%</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{kol.officialInteractions}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* 轉換漏斗 */}
<div className="bg-white rounded-lg shadow p-6 mb-8">
<h3 className="text-lg font-medium text-gray-800 mb-6">KOL </h3>
<div className="flex justify-center">
<div className="w-full max-w-3xl">
{funnelData.map((stage, index) => (
<div key={index} className="relative mb-4">
<div
className="bg-blue-500 h-16 rounded-lg flex items-center justify-center text-white font-medium"
style={{
width: `${(stage.count / funnelData[0].count) * 100}%`,
opacity: 0.7 + (0.3 * (index / funnelData.length))
}}
>
{stage.name}: {stage.count.toLocaleString()}
</div>
{index < funnelData.length - 1 && (
<div className="flex justify-center my-1">
<div className="flex items-center text-gray-500 text-sm">
<ArrowRight className="h-4 w-4 mr-1" />
: {((funnelData[index + 1].count / stage.count) * 100).toFixed(1)}%
</div>
</div>
)}
</div>
))}
</div>
</div>
<div className="mt-6 grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-gray-50 p-4 rounded-lg">
<h4 className="text-sm font-medium text-gray-700 mb-2"></h4>
<p className="text-2xl font-bold text-blue-600">
{((funnelData[funnelData.length - 1].count / funnelData[0].count) * 100).toFixed(1)}%
</p>
<p className="text-xs text-gray-500 mt-1"></p>
</div>
<div className="bg-gray-50 p-4 rounded-lg">
<h4 className="text-sm font-medium text-gray-700 mb-2"></h4>
<p className="text-2xl font-bold text-green-600"> </p>
<p className="text-xs text-gray-500 mt-1"> 15%</p>
</div>
<div className="bg-gray-50 p-4 rounded-lg">
<h4 className="text-sm font-medium text-gray-700 mb-2"></h4>
<p className="text-2xl font-bold text-red-600"> </p>
<p className="text-xs text-gray-500 mt-1"> 23%</p>
</div>
</div>
</div>
{/* KOL 貼文表現 */}
<div className="bg-white rounded-lg shadow p-6 mb-8">
<h3 className="text-lg font-medium text-gray-800 mb-6">KOL </h3>
<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-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
KOL
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredEngagementData.map((post, index) => (
<tr key={post.id} className="hover:bg-gray-50">
<td className="px-6 py-4">
<div className="flex items-center">
<div className="h-12 w-12 rounded overflow-hidden mr-3 flex-shrink-0">
<img src={post.thumbnail} alt={post.title} className="h-full w-full object-cover" />
</div>
<div className="text-sm text-gray-900 max-w-xs truncate">{post.title}</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="h-8 w-8 rounded-full overflow-hidden mr-2">
<img
src={kolData.find(k => k.id === post.kolId)?.avatar || ''}
alt={kolData.find(k => k.id === post.kolId)?.name || ''}
className="h-full w-full object-cover"
/>
</div>
<div className="text-sm text-gray-900">
{kolData.find(k => k.id === post.kolId)?.name || ''}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
{getPlatformIcon(post.platform)}
<span className="ml-2 text-sm text-gray-900">
{post.platform === 'xiaohongshu' ? '小紅書' : post.platform}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{post.date}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center text-sm text-gray-900">
<Eye className="h-4 w-4 text-gray-500 mr-1" />
{post.views.toLocaleString()}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center text-sm text-gray-900">
<Heart className="h-4 w-4 text-red-500 mr-1" />
{post.likes.toLocaleString()}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center text-sm text-gray-900">
<MessageSquare className="h-4 w-4 text-blue-500 mr-1" />
{post.comments.toLocaleString()}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center text-sm text-gray-900">
<Share2 className="h-4 w-4 text-green-500 mr-1" />
{post.shares.toLocaleString()}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div
className="h-2 w-16 bg-gray-200 rounded-full overflow-hidden"
>
<div
className={getSentimentColor(post.sentiment)}
style={{ width: `${post.sentimentScore}%` }}
></div>
</div>
<span className="ml-2 text-sm text-gray-900">{post.sentimentScore}%</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* 概覽卡片 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-800"></h3>
<MessageSquare className="h-6 w-6 text-blue-600" />
</div>
<p className="text-3xl font-bold text-gray-900 mb-2">{platformData.reduce((sum, item) => sum + item.value, 0)}</p>
<div className="flex items-center text-sm">
<TrendingUp className="h-4 w-4 text-green-500 mr-1" />
<span className="text-green-500"> 12% </span>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-800"></h3>
<Users className="h-6 w-6 text-blue-600" />
</div>
<p className="text-3xl font-bold text-gray-900 mb-2">4.8%</p>
<div className="flex items-center text-sm">
<TrendingUp className="h-4 w-4 text-green-500 mr-1" />
<span className="text-green-500"> 0.5% </span>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-800"></h3>
<PieChart className="h-6 w-6 text-blue-600" />
</div>
<p className="text-3xl font-bold text-gray-900 mb-2">{sentimentData.positive}% </p>
<div className="flex items-center text-sm">
<TrendingUp className="h-4 w-4 text-green-500 mr-1" />
<span className="text-green-500"> 5% </span>
</div>
</div>
</div>
{/* 留言趨勢圖 */}
<div className="bg-white rounded-lg shadow p-6 mb-8">
<h3 className="text-lg font-medium text-gray-800 mb-4"></h3>
<div className="h-64">
<div className="flex items-end h-52 space-x-2">
{timelineData.map((item, index) => (
<div key={index} className="flex-1 flex flex-col justify-end items-center">
<div
className="w-full bg-blue-500 rounded-t-md transition-all duration-500 ease-in-out hover:bg-blue-600"
style={{
height: `${(item.comments / maxTimelineCount) * 100}%`,
minHeight: '10%'
}}
>
<div className="invisible group-hover:visible text-xs text-white text-center py-1">
{item.comments}
</div>
</div>
<p className="text-xs text-center mt-2">{item.date}</p>
</div>
))}
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
{/* 平台分佈 */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-medium text-gray-800 mb-4"></h3>
<div className="space-y-4">
{platformData.map((item, index) => (
<div key={index}>
<div className="flex justify-between items-center mb-1">
<div className="flex items-center">
{getPlatformIcon(item.name)}
<span className="ml-2 text-sm font-medium text-gray-700">
{item.name === 'xiaohongshu' ? '小紅書' : item.name}
</span>
</div>
<div className="flex items-center">
<span className="text-sm text-gray-500 mr-2">{item.value} </span>
<span className="text-sm font-medium text-gray-700">{item.percentage}%</span>
</div>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={`${getPlatformColor(item.name)} h-2 rounded-full transition-all duration-500 ease-in-out`}
style={{ width: `${item.percentage}%` }}
></div>
</div>
</div>
))}
</div>
</div>
{/* 審核狀態分佈 */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-medium text-gray-800 mb-4"></h3>
<div className="flex justify-center mb-6">
<div className="w-48 h-48 rounded-full relative">
{statusData.map((item, index) => {
// 計算每個扇形的起始角度和結束角度
const startAngle = index === 0 ? 0 : statusData.slice(0, index).reduce((sum, i) => sum + i.percentage, 0) * 3.6;
const endAngle = startAngle + item.percentage * 3.6;
return (
<div
key={index}
className="absolute inset-0"
style={{
background: `conic-gradient(transparent ${startAngle}deg, ${getStatusColor(item.name)} ${startAngle}deg, ${getStatusColor(item.name)} ${endAngle}deg, transparent ${endAngle}deg)`,
borderRadius: '50%'
}}
></div>
);
})}
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-32 h-32 bg-white rounded-full"></div>
</div>
</div>
</div>
<div className="space-y-2">
{statusData.map((item, index) => (
<div key={index} className="flex items-center justify-between">
<div className="flex items-center">
{getStatusIcon(item.name)}
<span className="ml-2 text-sm font-medium text-gray-700">{getStatusName(item.name)}</span>
</div>
<div className="flex items-center">
<span className="text-sm text-gray-500 mr-2">{item.value} </span>
<span className="text-sm font-medium text-gray-700">{item.percentage}%</span>
</div>
</div>
))}
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
{/* 情感分析詳情 */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-medium text-gray-800 mb-4"></h3>
<div className="flex justify-center mb-6">
<div className="relative w-48 h-12 bg-gradient-to-r from-red-500 via-yellow-400 to-green-500 rounded-lg">
<div
className="absolute top-0 h-full w-1 bg-black border-2 border-white rounded-full transform -translate-x-1/2"
style={{ left: `${sentimentData.positive}%` }}
></div>
</div>
</div>
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<p className="text-sm font-medium text-gray-500"></p>
<p className="text-lg font-bold text-red-500">{sentimentData.negative}%</p>
</div>
<div>
<p className="text-sm font-medium text-gray-500"></p>
<p className="text-lg font-bold text-yellow-500">{sentimentData.neutral}%</p>
</div>
<div>
<p className="text-sm font-medium text-gray-500"></p>
<p className="text-lg font-bold text-green-500">{sentimentData.positive}%</p>
</div>
</div>
</div>
{/* 熱門文章 */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-medium text-gray-800 mb-4"></h3>
<div className="space-y-4">
{popularArticles.map((article: any, index: number) => (
<div key={index} className="border-b border-gray-200 pb-3 last:border-0 last:pb-0">
<p className="text-sm font-medium text-gray-800 mb-1">{article.title}</p>
<div className="flex items-center justify-between text-xs text-gray-500">
<span>{article.count} </span>
<div className="flex items-center">
<div className="h-2 w-2 rounded-full bg-green-500 mr-1"></div>
<span></span>
</div>
</div>
</div>
))}
</div>
</div>
</div>
{/* 關鍵字雲 */}
<div className="bg-white rounded-lg shadow p-6 mb-8">
<h3 className="text-lg font-medium text-gray-800 mb-4"></h3>
<div className="flex flex-wrap justify-center gap-3 py-4">
<span className="px-4 py-2 bg-blue-100 text-blue-800 rounded-full text-lg"></span>
<span className="px-6 py-3 bg-green-100 text-green-800 rounded-full text-xl"></span>
<span className="px-3 py-1 bg-yellow-100 text-yellow-800 rounded-full text-base"></span>
<span className="px-5 py-2 bg-purple-100 text-purple-800 rounded-full text-lg"></span>
<span className="px-7 py-3 bg-red-100 text-red-800 rounded-full text-2xl"></span>
<span className="px-3 py-1 bg-indigo-100 text-indigo-800 rounded-full text-base"></span>
<span className="px-4 py-2 bg-pink-100 text-pink-800 rounded-full text-lg"></span>
<span className="px-5 py-2 bg-blue-100 text-blue-800 rounded-full text-lg"></span>
<span className="px-3 py-1 bg-green-100 text-green-800 rounded-full text-base">便</span>
<span className="px-6 py-3 bg-yellow-100 text-yellow-800 rounded-full text-xl"></span>
<span className="px-4 py-2 bg-purple-100 text-purple-800 rounded-full text-lg"></span>
<span className="px-3 py-1 bg-red-100 text-red-800 rounded-full text-base"></span>
</div>
</div>
{/* 用戶互動時間分析 */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-medium text-gray-800 mb-4"></h3>
<div className="grid grid-cols-12 gap-1 h-40">
{Array.from({ length: 24 }).map((_, hour) => {
// 模擬不同時段的活躍度
let height = '20%';
if (hour >= 9 && hour <= 11) height = '60%';
if (hour >= 12 && hour <= 14) height = '40%';
if (hour >= 19 && hour <= 22) height = '80%';
return (
<div key={hour} className="flex flex-col items-center justify-end">
<div
className="w-full bg-blue-500 rounded-t-sm hover:bg-blue-600 transition-all"
style={{ height }}
></div>
<span className="text-xs mt-1">{hour}</span>
</div>
);
})}
</div>
<div className="text-center mt-2 text-sm text-gray-500">
<p> (24)</p>
</div>
</div>
</div>
</div>
);
};
export default Analytics;

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;

View File

@@ -0,0 +1,637 @@
import React, { useState, useEffect } from 'react';
import { Comment } from '../types';
import {
X,
CheckCircle,
XCircle,
MessageSquare,
ExternalLink,
ThumbsUp,
ThumbsDown,
Minus,
AlertTriangle,
User,
Award,
Briefcase,
Send,
Edit,
RefreshCw,
Facebook,
Instagram,
Linkedin,
BookOpen,
Youtube,
Hash,
List,
Copy,
Save,
Lock
} from 'lucide-react';
import { templatesApi } from '../utils/api';
interface ReplyTemplate {
id: string;
title: string;
content: string;
category: string;
}
interface CommentPreviewProps {
comment: Comment;
onClose: () => void;
}
const CommentPreview: React.FC<CommentPreviewProps> = ({ comment, onClose }) => {
const [replyText, setReplyText] = useState(comment.aiReply || '');
const [privateMessageText, setPrivateMessageText] = useState('');
const [isEditing, setIsEditing] = useState(false);
const [isGeneratingReply, setIsGeneratingReply] = useState(false);
const [showTemplates, setShowTemplates] = useState(false);
const [activeMode, setActiveMode] = useState<'reply' | 'private'>('reply');
const [templates, setTemplates] = useState<ReplyTemplate[]>([]);
const [loadingTemplates, setLoadingTemplates] = useState(false);
// Fetch templates from API
useEffect(() => {
const fetchTemplates = async () => {
if (showTemplates) {
try {
setLoadingTemplates(true);
const response = await templatesApi.getTemplates();
setTemplates(response.data.templates || []);
} catch (err) {
console.error('Failed to fetch reply templates:', err);
} finally {
setLoadingTemplates(false);
}
}
};
fetchTemplates();
}, [showTemplates]);
const getSentimentIcon = (sentiment: string) => {
switch (sentiment) {
case 'positive':
return <ThumbsUp className="h-5 w-5 text-green-600" />;
case 'negative':
return <ThumbsDown className="h-5 w-5 text-red-600" />;
case 'neutral':
return <Minus className="h-5 w-5 text-gray-600" />;
case 'mixed':
return <AlertTriangle className="h-5 w-5 text-yellow-600" />;
default:
return null;
}
};
const getSentimentText = (sentiment: string) => {
switch (sentiment) {
case 'positive':
return '正面';
case 'negative':
return '負面';
case 'neutral':
return '中性';
case 'mixed':
return '混合';
default:
return '';
}
};
const getAuthorTypeIcon = (authorType: string) => {
switch (authorType) {
case 'official':
return <Briefcase className="h-5 w-5 text-blue-600" />;
case 'kol':
return <Award className="h-5 w-5 text-purple-600" />;
case 'user':
return <User className="h-5 w-5 text-gray-600" />;
default:
return null;
}
};
const getAuthorTypeText = (authorType: string) => {
switch (authorType) {
case 'official':
return '官方';
case 'kol':
return 'KOL';
case 'user':
return '一般用戶';
default:
return '';
}
};
const getPlatformIcon = (platform: string) => {
switch (platform) {
case 'facebook':
return <Facebook className="h-5 w-5 text-blue-600" />;
case 'threads':
return <Hash className="h-5 w-5 text-black" />;
case 'instagram':
return <Instagram className="h-5 w-5 text-pink-500" />;
case 'linkedin':
return <Linkedin className="h-5 w-5 text-blue-700" />;
case 'xiaohongshu':
return <BookOpen className="h-5 w-5 text-red-500" />;
case 'youtube':
return <Youtube className="h-5 w-5 text-red-600" />;
default:
return null;
}
};
const getPlatformName = (platform: string) => {
switch (platform) {
case 'facebook':
return 'Facebook';
case 'threads':
return 'Threads';
case 'instagram':
return 'Instagram';
case 'linkedin':
return 'LinkedIn';
case 'xiaohongshu':
return '小红书';
case 'youtube':
return 'YouTube';
default:
return platform;
}
};
const getContentTypeText = (contentType?: string) => {
if (!contentType) return '';
switch (contentType) {
case 'reel':
return 'Reel';
case 'post':
return 'Post';
case 'video':
return 'Video';
case 'short':
return 'Short';
default:
return contentType;
}
};
const getLanguageText = (language?: string) => {
if (!language) return '';
switch (language) {
case 'zh-TW':
return '繁體中文';
case 'zh-CN':
return '简体中文';
case 'en':
return 'English';
default:
return language;
}
};
const handleGenerateReply = () => {
setIsGeneratingReply(true);
// 模擬 AI 生成回覆的過程
setTimeout(() => {
const greeting = comment.language === 'zh-CN' ?
`${comment.author}您好,感谢您的留言!我们非常重视您的反馈。` :
comment.language === 'en' ?
`Hello ${comment.author}, thank you for your comment! We greatly value your feedback.` :
`${comment.author}您好,感謝您的留言!我們非常重視您的反饋。`;
let sentiment = '';
if (comment.sentiment === 'positive') {
sentiment = comment.language === 'zh-CN' ?
'很高兴您对我们的产品有正面评价。' :
comment.language === 'en' ?
'We\'re pleased to hear your positive feedback about our product.' :
'很高興您對我們的產品有正面評價。';
} else if (comment.sentiment === 'negative') {
sentiment = comment.language === 'zh-CN' ?
'对于您提出的问题,我们深表歉意并会积极改进。' :
comment.language === 'en' ?
'We sincerely apologize for the issues you\'ve raised and will actively work to improve.' :
'對於您提出的問題,我們深表歉意並會積極改進。';
} else {
sentiment = comment.language === 'zh-CN' ?
'我们会认真考虑您的建议。' :
comment.language === 'en' ?
'We will carefully consider your suggestions.' :
'我們會認真考慮您的建議。';
}
const closing = comment.language === 'zh-CN' ?
'我们的团队将进一步跟进这个问题,如有任何疑问,欢迎随时联系我们。' :
comment.language === 'en' ?
'Our team will follow up on this matter further. If you have any questions, please feel free to contact us anytime.' :
'我們的團隊將進一步跟進這個問題,如有任何疑問,歡迎隨時聯繫我們。';
setReplyText(`${greeting} ${sentiment} ${closing}`);
setIsGeneratingReply(false);
}, 1500);
};
const handleGeneratePrivateMessage = () => {
setIsGeneratingReply(true);
// 模擬 AI 生成私訊的過程
setTimeout(() => {
const greeting = comment.language === 'zh-CN' ?
`${comment.author}您好,我是客服团队的代表。` :
comment.language === 'en' ?
`Hello ${comment.author}, I'm a representative from our customer service team.` :
`${comment.author}您好,我是客服團隊的代表。`;
const content = comment.language === 'zh-CN' ?
'感谢您在我们的平台上留言。为了更好地解决您的问题,我想私下与您沟通一些细节。' :
comment.language === 'en' ?
'Thank you for your comment on our platform. To better address your concerns, I would like to discuss some details with you privately.' :
'感謝您在我們的平台上留言。為了更好地解決您的問題,我想私下與您溝通一些細節。';
const question = comment.language === 'zh-CN' ?
'方便提供您的联系方式吗或者您可以直接联系我们的客服热线0800-123-456。' :
comment.language === 'en' ?
'Would it be convenient for you to provide your contact information? Alternatively, you can reach our customer service hotline at 0800-123-456.' :
'方便提供您的聯繫方式嗎或者您可以直接聯繫我們的客服熱線0800-123-456。';
setPrivateMessageText(`${greeting} ${content} ${question}`);
setIsGeneratingReply(false);
}, 1500);
};
const handleSendMessage = () => {
if (activeMode === 'reply') {
// 這裡會調用後端 API 來發送公開回覆
alert(`公開回覆已發送至 ${getPlatformName(comment.platform)} 平台:\n\n${replyText}`);
} else {
// 這裡會調用後端 API 來發送私訊
alert(`私訊已發送至 ${comment.author}\n\n${privateMessageText}`);
}
};
const handleTemplateSelect = (template: ReplyTemplate) => {
if (activeMode === 'reply') {
setReplyText(template.content);
} else {
setPrivateMessageText(template.content);
}
setShowTemplates(false);
};
const handleSaveAsDraft = () => {
if (activeMode === 'reply') {
alert('公開回覆已儲存為草稿');
} else {
alert('私訊已儲存為草稿');
}
};
const handleCopyToClipboard = () => {
const textToCopy = activeMode === 'reply' ? replyText : privateMessageText;
navigator.clipboard.writeText(textToCopy);
alert('內容已複製到剪貼簿');
};
return (
<div className="w-full h-full flex flex-col">
<div className="p-4 border-b border-gray-200 flex justify-between items-center">
<h3 className="text-lg font-medium text-gray-900"></h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-500"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="flex-1 overflow-auto p-4">
<div className="mb-6">
<h4 className="text-sm font-medium text-gray-500 mb-2"></h4>
<p className="text-base font-medium text-gray-900">{comment.articleTitle}</p>
<div className="mt-2 flex items-center">
<div className="flex items-center mr-4">
{getAuthorTypeIcon(comment.postAuthorType)}
<span className="ml-1 text-sm text-gray-700">{comment.postAuthor}</span>
</div>
<span className="text-xs text-gray-500">{getAuthorTypeText(comment.postAuthorType)}</span>
</div>
</div>
<div className="mb-6">
<div className="flex justify-between items-center mb-2">
<h4 className="text-sm font-medium text-gray-500"></h4>
<div className="flex items-center space-x-2">
{getPlatformIcon(comment.platform)}
<span className="text-xs text-gray-700">{getPlatformName(comment.platform)}</span>
{comment.contentType && (
<span className="text-xs text-gray-500">({getContentTypeText(comment.contentType)})</span>
)}
</div>
</div>
<div className="bg-gray-50 p-3 rounded-lg">
<p className="text-base text-gray-900">{comment.content}</p>
<div className="mt-2 flex justify-between items-center">
<div className="flex items-center">
{getAuthorTypeIcon(comment.authorType)}
<span className="ml-1 text-sm text-gray-700">{comment.author}</span>
<span className="ml-2 text-xs text-gray-500">{getAuthorTypeText(comment.authorType)}</span>
</div>
<span className="text-sm text-gray-500">{comment.timestamp}</span>
</div>
</div>
<div className="mt-2 flex justify-between">
<div className="flex items-center">
{getSentimentIcon(comment.sentiment)}
<span className="ml-1 text-sm text-gray-700">{getSentimentText(comment.sentiment)}</span>
</div>
<span className="text-sm text-gray-700">{getLanguageText(comment.language)}</span>
</div>
</div>
{/* 回覆模式切換 */}
<div className="mb-4">
<div className="flex border border-gray-200 rounded-lg overflow-hidden">
<button
className={`flex-1 py-2 px-4 text-sm font-medium ${
activeMode === 'reply'
? 'bg-blue-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-50'
}`}
onClick={() => setActiveMode('reply')}
>
<div className="flex items-center justify-center">
<MessageSquare className="h-4 w-4 mr-2" />
</div>
</button>
<button
className={`flex-1 py-2 px-4 text-sm font-medium ${
activeMode === 'private'
? 'bg-blue-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-50'
}`}
onClick={() => setActiveMode('private')}
>
<div className="flex items-center justify-center">
<Lock className="h-4 w-4 mr-2" />
</div>
</button>
</div>
</div>
{/* 回覆內容區域 */}
{activeMode === 'reply' ? (
<div className="mb-6">
<div className="flex justify-between items-center mb-2">
<h4 className="text-sm font-medium text-gray-500"></h4>
<div className="flex space-x-2">
<button
onClick={() => setIsEditing(!isEditing)}
className="text-blue-600 hover:text-blue-800"
title="編輯回覆"
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={handleGenerateReply}
className="text-green-600 hover:text-green-800"
disabled={isGeneratingReply}
title="重新生成回覆"
>
<RefreshCw className={`h-4 w-4 ${isGeneratingReply ? 'animate-spin' : ''}`} />
</button>
<button
onClick={() => setShowTemplates(!showTemplates)}
className="text-purple-600 hover:text-purple-800"
title="使用模板"
>
<List className="h-4 w-4" />
</button>
</div>
</div>
{/* Templates Popup for replies */}
{showTemplates && activeMode === 'reply' && (
<div className="absolute bottom-full mb-2 left-0 w-full z-10">
<div className="bg-white rounded-lg shadow-xl border border-gray-200 p-3">
<h4 className="font-medium text-sm mb-2"></h4>
{loadingTemplates ? (
<div className="flex justify-center p-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"></div>
</div>
) : (
<div className="mb-3 border border-gray-200 rounded-lg bg-white shadow-lg">
<div className="max-h-40 overflow-y-auto">
{templates.map(template => (
<div
key={template.id}
className="p-2 hover:bg-gray-50 cursor-pointer border-b border-gray-100 last:border-0"
onClick={() => handleTemplateSelect(template)}
>
<p className="text-sm font-medium">{template.title}</p>
<p className="text-xs text-gray-500 truncate">{template.content.substring(0, 60)}...</p>
</div>
))}
</div>
</div>
)}
</div>
</div>
)}
{isEditing ? (
<textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
className="w-full p-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 min-h-[150px]"
placeholder="編輯回覆內容..."
/>
) : (
<div className="bg-blue-50 p-3 rounded-lg border border-blue-100">
<p className="text-sm text-gray-800">{replyText || '尚未生成回覆'}</p>
</div>
)}
<div className="mt-2 flex justify-end space-x-2">
<button
onClick={handleCopyToClipboard}
className="text-gray-600 hover:text-gray-800 p-1"
title="複製到剪貼簿"
>
<Copy className="h-4 w-4" />
</button>
<button
onClick={handleSaveAsDraft}
className="text-gray-600 hover:text-gray-800 p-1"
title="儲存為草稿"
>
<Save className="h-4 w-4" />
</button>
</div>
</div>
) : (
<div className="mb-6">
<div className="flex justify-between items-center mb-2">
<h4 className="text-sm font-medium text-gray-500"></h4>
<div className="flex space-x-2">
<button
onClick={() => setIsEditing(!isEditing)}
className="text-blue-600 hover:text-blue-800"
title="編輯私訊"
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={handleGeneratePrivateMessage}
className="text-green-600 hover:text-green-800"
disabled={isGeneratingReply}
title="重新生成私訊"
>
<RefreshCw className={`h-4 w-4 ${isGeneratingReply ? 'animate-spin' : ''}`} />
</button>
<button
onClick={() => setShowTemplates(!showTemplates)}
className="text-purple-600 hover:text-purple-800"
title="使用模板"
>
<List className="h-4 w-4" />
</button>
</div>
</div>
{/* Templates Popup for private messages */}
{showTemplates && activeMode === 'private' && (
<div className="absolute bottom-full mb-2 left-0 w-full z-10">
<div className="bg-white rounded-lg shadow-xl border border-gray-200 p-3">
<h4 className="font-medium text-sm mb-2"></h4>
{loadingTemplates ? (
<div className="flex justify-center p-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"></div>
</div>
) : (
<div className="mb-3 border border-gray-200 rounded-lg bg-white shadow-lg">
<div className="max-h-40 overflow-y-auto">
{templates.map(template => (
<div
key={template.id}
className="p-2 hover:bg-gray-50 cursor-pointer border-b border-gray-100 last:border-0"
onClick={() => handleTemplateSelect(template)}
>
<p className="text-sm font-medium">{template.title}</p>
<p className="text-xs text-gray-500 truncate">{template.content.substring(0, 60)}...</p>
</div>
))}
</div>
</div>
)}
</div>
</div>
)}
{isEditing ? (
<textarea
value={privateMessageText}
onChange={(e) => setPrivateMessageText(e.target.value)}
className="w-full p-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 min-h-[150px]"
placeholder="編輯私訊內容..."
/>
) : (
<div className="bg-purple-50 p-3 rounded-lg border border-purple-100">
<p className="text-sm text-gray-800">{privateMessageText || '尚未生成私訊'}</p>
</div>
)}
<div className="mt-2 flex justify-end space-x-2">
<button
onClick={handleCopyToClipboard}
className="text-gray-600 hover:text-gray-800 p-1"
title="複製到剪貼簿"
>
<Copy className="h-4 w-4" />
</button>
<button
onClick={handleSaveAsDraft}
className="text-gray-600 hover:text-gray-800 p-1"
title="儲存為草稿"
>
<Save className="h-4 w-4" />
</button>
</div>
</div>
)}
<div className="mb-6">
<h4 className="text-sm font-medium text-gray-500 mb-2"></h4>
<div className="border border-gray-200 rounded-lg overflow-hidden">
<iframe
src={comment.url}
className="w-full h-96"
frameBorder="0"
scrolling="no"
title="Social Media Post"
></iframe>
</div>
</div>
<div className="mb-6">
<h4 className="text-sm font-medium text-gray-500 mb-2"></h4>
<div className="flex items-center space-x-2">
{comment.replyStatus === 'sent' && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<CheckCircle className="h-3 w-3 mr-1" />
</span>
)}
{comment.replyStatus === 'draft' && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
<MessageSquare className="h-3 w-3 mr-1" />
稿
</span>
)}
{comment.replyStatus === 'none' && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
<XCircle className="h-3 w-3 mr-1" />
</span>
)}
</div>
</div>
</div>
<div className="p-4 border-t border-gray-200">
<div className="flex space-x-3">
<button
className={`flex-1 py-2 px-4 rounded-md flex items-center justify-center ${
activeMode === 'reply'
? 'bg-blue-600 text-white hover:bg-blue-700'
: 'bg-purple-600 text-white hover:bg-purple-700'
}`}
onClick={handleSendMessage}
>
<Send className="h-4 w-4 mr-2" />
{activeMode === 'reply' ? '發送公開回覆' : '發送私訊'}
</button>
<button
className="flex-1 bg-gray-100 text-gray-700 py-2 px-4 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-500 flex items-center justify-center"
onClick={handleSaveAsDraft}
>
<Save className="h-4 w-4 mr-2" />
稿
</button>
<button className="bg-gray-100 text-gray-700 py-2 px-3 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-500">
<ExternalLink className="h-4 w-4" />
</button>
</div>
</div>
</div>
);
};
export default CommentPreview;

View File

@@ -0,0 +1,390 @@
import React, { useState, useEffect } from 'react';
import {
MessageSquare,
Users,
TrendingUp,
Clock,
CheckCircle,
XCircle,
AlertCircle,
Facebook,
Twitter,
Instagram,
Linkedin,
BookOpen,
Youtube,
Hash
} from 'lucide-react';
import { commentsApi } from '../utils/api';
interface Comment {
id: string;
platform: string;
content: string;
author: string;
authorType: string;
timestamp: string;
status: string;
sentiment: string;
}
const Dashboard: React.FC = () => {
const [comments, setComments] = useState<Comment[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchComments = async () => {
try {
setLoading(true);
const response = await commentsApi.getComments();
setComments(response.data.comments || []);
setError(null);
} catch (err) {
console.error('Failed to fetch comments:', err);
setError('Failed to load dashboard data. Please try again later.');
} finally {
setLoading(false);
}
};
fetchComments();
}, []);
// Calculate statistics
const totalComments = comments.length;
const pendingComments = comments.filter(comment => comment.status === 'pending').length;
const approvedComments = comments.filter(comment => comment.status === 'approved').length;
const rejectedComments = comments.filter(comment => comment.status === 'rejected').length;
// Calculate platform distribution
const platforms = comments.reduce((acc: Record<string, number>, comment) => {
acc[comment.platform] = (acc[comment.platform] || 0) + 1;
return acc;
}, {});
// Get recent comments
const recentComments = [...comments]
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
.slice(0, 5);
const getPlatformIcon = (platform: string) => {
switch (platform) {
case 'facebook':
return <Facebook className="h-5 w-5 text-blue-600" />;
case 'twitter':
return <Twitter className="h-5 w-5 text-blue-400" />;
case 'threads':
return <Hash className="h-5 w-5 text-gray-800" />;
case 'instagram':
return <Instagram className="h-5 w-5 text-pink-500" />;
case 'linkedin':
return <Linkedin className="h-5 w-5 text-blue-700" />;
case 'xiaohongshu':
return <BookOpen className="h-5 w-5 text-red-500" />;
case 'youtube':
return <Youtube className="h-5 w-5 text-red-600" />;
default:
return <MessageSquare className="h-5 w-5 text-gray-500" />;
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'approved':
return <CheckCircle className="h-5 w-5 text-green-600" />;
case 'rejected':
return <XCircle className="h-5 w-5 text-red-600" />;
case 'pending':
return <Clock className="h-5 w-5 text-yellow-600" />;
default:
return null;
}
};
const getStatusName = (status: string) => {
switch (status) {
case 'approved':
return '已核准';
case 'rejected':
return '已拒絕';
case 'pending':
return '待審核';
default:
return status;
}
};
if (loading) {
return (
<div className="p-6 flex-1 overflow-y-auto">
<div className="flex justify-center items-center h-full">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
</div>
</div>
);
}
if (error) {
return (
<div className="p-6 flex-1 overflow-y-auto">
<div className="bg-red-50 border-l-4 border-red-500 p-4 mb-4">
<div className="flex">
<div className="flex-shrink-0">
<AlertCircle className="h-5 w-5 text-red-500" />
</div>
<div className="ml-3">
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
</div>
</div>
);
}
return (
<div className="flex-1 overflow-auto">
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-800"></h2>
<div className="flex space-x-4">
<select
className="border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="today"></option>
<option value="yesterday"></option>
<option value="7days"> 7 </option>
<option value="30days"> 30 </option>
</select>
<button className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500">
</button>
</div>
</div>
{/* 統計卡片 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-800"></h3>
<MessageSquare className="h-6 w-6 text-blue-600" />
</div>
<p className="text-3xl font-bold text-gray-900 mb-2">{totalComments}</p>
<div className="flex items-center text-sm">
<TrendingUp className="h-4 w-4 text-green-500 mr-1" />
<span className="text-green-500"> 12% </span>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-800"></h3>
<Clock className="h-6 w-6 text-yellow-500" />
</div>
<p className="text-3xl font-bold text-gray-900 mb-2">{pendingComments}</p>
<div className="flex items-center text-sm">
<TrendingUp className="h-4 w-4 text-yellow-500 mr-1" />
<span className="text-yellow-500"> 5% </span>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-800"></h3>
<CheckCircle className="h-6 w-6 text-green-600" />
</div>
<p className="text-3xl font-bold text-gray-900 mb-2">{approvedComments}</p>
<div className="flex items-center text-sm">
<TrendingUp className="h-4 w-4 text-green-500 mr-1" />
<span className="text-green-500"> 15% </span>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-800"></h3>
<XCircle className="h-6 w-6 text-red-600" />
</div>
<p className="text-3xl font-bold text-gray-900 mb-2">{rejectedComments}</p>
<div className="flex items-center text-sm">
<TrendingUp className="h-4 w-4 text-red-500 mr-1" />
<span className="text-red-500"> 3% </span>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
{/* 待處理留言 */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-800"></h3>
</div>
<div className="p-6">
{pendingComments === 0 ? (
<div className="flex flex-col items-center justify-center py-6">
<CheckCircle className="h-12 w-12 text-green-500 mb-4" />
<p className="text-gray-500"></p>
</div>
) : (
<div className="space-y-4">
{comments
.filter(comment => comment.status === 'pending')
.slice(0, 5)
.map((comment, index) => (
<div key={index} className="flex items-start p-3 hover:bg-gray-50 rounded-lg transition-colors">
<div className="flex-shrink-0 mr-3">
{getPlatformIcon(comment.platform)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{comment.author}</p>
<p className="text-sm text-gray-500 truncate">{comment.content}</p>
<p className="text-xs text-gray-400 mt-1">{comment.timestamp}</p>
</div>
<div className="flex space-x-2">
<button className="p-1 text-green-600 hover:bg-green-100 rounded-full">
<CheckCircle className="h-4 w-4" />
</button>
<button className="p-1 text-red-600 hover:bg-red-100 rounded-full">
<XCircle className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* 平台分佈 */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-800"></h3>
</div>
<div className="p-6">
<div className="grid grid-cols-2 gap-4">
{Object.entries(platforms).map(([platform, count]) => (
<div key={platform} className="flex items-center p-3 border border-gray-200 rounded-lg">
<div className="flex-shrink-0 mr-3">
{getPlatformIcon(platform)}
</div>
<div>
<p className="text-sm font-medium text-gray-900">
{platform === 'xiaohongshu' ? '小红书' : platform}
</p>
<p className="text-xs text-gray-500">{count} </p>
</div>
</div>
))}
</div>
</div>
</div>
</div>
{/* 最近留言 */}
<div className="bg-white rounded-lg shadow overflow-hidden mb-8">
<div className="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
<h3 className="text-lg font-medium text-gray-800"></h3>
<a href="#" className="text-sm text-blue-600 hover:text-blue-800"></a>
</div>
<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-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{recentComments.map((comment, index) => (
<tr key={index} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
{getPlatformIcon(comment.platform)}
<span className="ml-2 text-sm text-gray-900 capitalize">
{comment.platform === 'xiaohongshu' ? '小红书' : comment.platform}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{comment.author}</div>
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-900 max-w-xs truncate">{comment.content}</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">
<div className="flex items-center">
{getStatusIcon(comment.status)}
<span className="ml-2 text-sm text-gray-700">{getStatusName(comment.status)}</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* 系統通知 */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-800"></h3>
</div>
<div className="p-6">
<div className="space-y-4">
<div className="flex items-start p-3 bg-blue-50 rounded-lg">
<div className="flex-shrink-0 mr-3">
<AlertCircle className="h-5 w-5 text-blue-600" />
</div>
<div>
<p className="text-sm font-medium text-gray-900"></p>
<p className="text-sm text-gray-600 mt-1"> 23:00-24:00 </p>
<p className="text-xs text-gray-400 mt-2">2025-05-15 10:30:00</p>
</div>
</div>
<div className="flex items-start p-3 bg-green-50 rounded-lg">
<div className="flex-shrink-0 mr-3">
<Users className="h-5 w-5 text-green-600" />
</div>
<div>
<p className="text-sm font-medium text-gray-900"></p>
<p className="text-sm text-gray-600 mt-1"></p>
<p className="text-xs text-gray-400 mt-2">2025-05-14 15:45:00</p>
</div>
</div>
<div className="flex items-start p-3 bg-yellow-50 rounded-lg">
<div className="flex-shrink-0 mr-3">
<TrendingUp className="h-5 w-5 text-yellow-600" />
</div>
<div>
<p className="text-sm font-medium text-gray-900"></p>
<p className="text-sm text-gray-600 mt-1"> 24 35%</p>
<p className="text-xs text-gray-400 mt-2">2025-05-13 08:15:00</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default Dashboard;

View File

@@ -0,0 +1,76 @@
import React from 'react';
import { MessageSquare, Search, Bell, Settings, Menu, LogOut } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
interface HeaderProps {
onMenuClick: () => void;
}
const Header: React.FC<HeaderProps> = ({ onMenuClick }) => {
const { user, logout } = useAuth();
const handleLogout = () => {
logout();
};
// Get user's initial for avatar
const userInitial = user?.name
? user.name.charAt(0).toUpperCase()
: user?.email.charAt(0).toUpperCase() || 'U';
return (
<header className="bg-white border-b border-gray-200 px-4 py-3 sm:px-6 sm:py-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<button
className="md:hidden text-gray-500 hover:text-gray-700 focus:outline-none"
onClick={onMenuClick}
>
<Menu className="h-6 w-6" />
</button>
<MessageSquare className="h-7 w-7 text-blue-600" />
<h1 className="text-lg sm:text-xl font-bold text-gray-800 hidden sm:block"></h1>
</div>
<div className="relative w-full max-w-xs sm:max-w-sm md:max-w-md mx-2 sm:mx-4">
<input
type="text"
placeholder="搜尋留言..."
className="w-full pl-8 pr-4 py-1.5 sm:py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<Search className="absolute left-2.5 top-2 h-4 w-4 text-gray-400" />
</div>
<div className="flex items-center space-x-2 sm:space-x-4">
<button className="relative p-1.5 sm:p-2 text-gray-500 hover:text-gray-700 focus:outline-none">
<Bell className="h-5 w-5 sm:h-6 sm:w-6" />
<span className="absolute top-0 right-0 h-3.5 w-3.5 sm:h-4 sm:w-4 bg-red-500 rounded-full text-xs text-white flex items-center justify-center">
3
</span>
</button>
<button className="p-1.5 sm:p-2 text-gray-500 hover:text-gray-700 focus:outline-none hidden sm:block">
<Settings className="h-5 w-5 sm:h-6 sm:w-6" />
</button>
<button
onClick={handleLogout}
className="p-1.5 sm:p-2 text-gray-500 hover:text-red-500 focus:outline-none hidden sm:flex items-center space-x-1"
title="登出"
>
<LogOut className="h-5 w-5 sm:h-6 sm:w-6" />
<span className="text-sm"></span>
</button>
<div className="flex items-center space-x-2">
<div className="h-7 w-7 sm:h-8 sm:w-8 rounded-full bg-blue-500 flex items-center justify-center text-white font-medium">
{userInitial}
</div>
<span className="text-sm font-medium text-gray-700 hidden sm:block">
{user?.name || user?.email || '用戶'}
</span>
</div>
</div>
</div>
</header>
);
};
export default Header;

View File

@@ -0,0 +1,208 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import axios from 'axios';
import { User } from '../context/AuthContext';
import { useAuth } from '../context/AuthContext';
import { Form, Input, Button, Card, Alert, Checkbox } from 'antd';
import { LockOutlined, MailOutlined } from '@ant-design/icons';
import { authApi } from '../utils/api';
interface LoginProps {
onLoginSuccess: (token: string, user: User) => void;
}
const Login: React.FC<LoginProps> = ({ onLoginSuccess }) => {
const navigate = useNavigate();
const { isAuthenticated } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [rememberMe, setRememberMe] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 如果已经认证,则重定向到首页
useEffect(() => {
if (isAuthenticated) {
console.log('Login - User already authenticated, redirecting to dashboard');
navigate('/', { replace: true });
}
}, [isAuthenticated, navigate]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const response = await authApi.login({ email, password });
if (response.data.token) {
// 直接设置身份验证状态
onLoginSuccess(response.data.token, response.data.user);
// 直接导航到仪表板,不做任何额外的检查或延迟
navigate('/dashboard');
} else {
setError('登录失败:未收到有效令牌');
}
} catch (error) {
console.error('登录失败:', error);
if (axios.isAxiosError(error) && error.response) {
setError(`登录失败:${error.response.data.error || '服务器错误'}`);
} else {
setError('登录失败:网络错误或服务器无响应');
}
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="max-w-md w-full bg-white rounded-lg shadow-lg overflow-hidden">
<div className="px-6 py-8">
<div className="flex justify-center mb-6">
<div className="flex items-center">
<div className="bg-blue-600 p-3 rounded-full">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
</div>
</div>
</div>
<h2 className="text-center text-3xl font-extrabold text-gray-900 mb-2">
</h2>
<p className="text-center text-sm text-gray-600 mb-6">
</p>
{error && (
<div className="mb-4 bg-red-50 border-l-4 border-red-500 p-4">
<div className="flex">
<div className="ml-3">
<p className="text-sm text-red-700">{error}</p>
</div>
</div>
</div>
)}
<form className="space-y-6" onSubmit={handleSubmit}>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
</label>
<div className="mt-1 relative rounded-md shadow-sm">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path>
<polyline points="22,6 12,13 2,6"></polyline>
</svg>
</div>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="appearance-none block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
placeholder="請輸入郵箱地址"
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
</label>
<div className="mt-1 relative rounded-md shadow-sm">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
</div>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="appearance-none block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
placeholder="請輸入密碼"
/>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center">
<input
id="remember-me"
name="remember-me"
type="checkbox"
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
/>
<label htmlFor="remember-me" className="ml-2 block text-sm text-gray-900">
</label>
</div>
<div className="text-sm">
<a href="#" className="font-medium text-blue-600 hover:text-blue-500">
?
</a>
</div>
</div>
<div>
<button
type="submit"
disabled={loading}
className={`w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 ${loading ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{loading ? (
<div className="flex items-center">
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
...
</div>
) : '登錄'}
</button>
</div>
</form>
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">使?</span>
</div>
</div>
<div className="mt-6">
<a
href="#"
className="w-full flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
</a>
</div>
</div>
</div>
</div>
</div>
);
};
export default Login;

View File

@@ -0,0 +1,493 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Card,
Badge,
Table,
Avatar,
Button,
Space,
Tag,
Typography,
Select,
Drawer,
Form
} from 'antd';
import {
UserOutlined,
VerifiedOutlined,
ShopOutlined,
FilterOutlined,
YoutubeOutlined,
InstagramOutlined,
LinkedinOutlined,
FacebookOutlined,
GlobalOutlined,
MessageOutlined
} from '@ant-design/icons';
import { format } from 'date-fns';
import { postsApi } from '../utils/api';
// API response type definition based on backend structure
interface ApiPost {
post_id: string;
title: string;
description: string;
platform: string;
post_url: string;
published_at: string;
updated_at: string;
influencer_id: string;
views_count?: number;
likes_count?: number;
comments_count?: number;
shares_count?: number;
influencer?: {
influencer_id: string;
name: string;
platform: string;
profile_url: string;
followers_count: number;
};
}
// Frontend representation of a post
interface FrontendPost {
id: string;
title: string;
description: string;
author: string;
authorType: 'influencer' | 'brand' | 'official';
platform: 'youtube' | 'instagram' | 'facebook' | 'linkedin' | 'tiktok';
contentType: 'post' | 'video' | 'reel' | 'short';
timestamp: string;
engagement: {
views?: number;
likes?: number;
comments?: number;
shares?: number;
};
url?: string;
}
interface PostListProps {
influencerId?: string; // Optional influencer ID to filter posts by influencer
projectId?: string; // Optional project ID to filter posts by project
}
const { Text, Title } = Typography;
const { Option } = Select;
const PostList: React.FC<PostListProps> = ({ influencerId, projectId }) => {
const navigate = useNavigate();
const [posts, setPosts] = useState<FrontendPost[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [selectedPost, setSelectedPost] = useState<FrontendPost | null>(null);
const [platformFilter, setPlatformFilter] = useState<string>('all');
const [contentTypeFilter, setContentTypeFilter] = useState<string>('all');
const [showFilters, setShowFilters] = useState<boolean>(false);
// Fetch posts data
useEffect(() => {
const fetchPosts = async () => {
try {
setLoading(true);
// Build query parameters
const params: Record<string, string | number> = {
limit: 50,
offset: 0
};
if (influencerId) {
params.influencer_id = influencerId;
}
if (projectId) {
params.project_id = projectId;
}
const response = await postsApi.getPosts(params);
// Process returned data
const apiPosts: ApiPost[] = response.data.posts || [];
// Transform API posts to frontend format
const processedPosts: FrontendPost[] = apiPosts.map((apiPost) => {
// Determine content type based on post data
let contentType: FrontendPost['contentType'] = 'post';
if (apiPost.platform === 'youtube') {
contentType = 'video';
} else if (
apiPost.platform === 'instagram' &&
apiPost.post_url?.includes('/reels/')
) {
contentType = 'reel';
}
return {
id: apiPost.post_id,
title: apiPost.title || 'Untitled Post',
description: apiPost.description || '',
author: apiPost.influencer?.name || 'Unknown',
authorType: 'influencer',
platform: apiPost.platform as FrontendPost['platform'],
contentType,
timestamp: apiPost.published_at,
engagement: {
views: apiPost.views_count,
likes: apiPost.likes_count,
comments: apiPost.comments_count,
shares: apiPost.shares_count
},
url: apiPost.post_url
};
});
setPosts(processedPosts);
setError(null);
} catch (err) {
console.error('Failed to fetch posts:', err);
setError('Failed to load posts. Please try again later.');
} finally {
setLoading(false);
}
};
fetchPosts();
}, [influencerId, projectId]);
// Filter posts based on selected filters
const filteredPosts = posts.filter((post) => {
if (platformFilter !== 'all' && post.platform !== platformFilter) {
return false;
}
if (contentTypeFilter !== 'all' && post.contentType !== contentTypeFilter) {
return false;
}
return true;
});
// Get platform icon
const getPlatformIcon = (platform: string) => {
switch (platform) {
case 'youtube':
return <YoutubeOutlined style={{ color: '#FF0000' }} />;
case 'instagram':
return <InstagramOutlined style={{ color: '#E1306C' }} />;
case 'facebook':
return <FacebookOutlined style={{ color: '#1877F2' }} />;
case 'linkedin':
return <LinkedinOutlined style={{ color: '#0A66C2' }} />;
default:
return <GlobalOutlined />;
}
};
// Get content type badge
const getContentTypeBadge = (contentType?: string) => {
if (!contentType) return null;
switch (contentType) {
case 'post':
return <Tag color="blue">Post</Tag>;
case 'video':
return <Tag color="green">Video</Tag>;
case 'reel':
return <Tag color="purple">Reel</Tag>;
case 'short':
return <Tag color="orange">Short</Tag>;
default:
return null;
}
};
// Get author type badge
const getAuthorTypeBadge = (authorType: string) => {
switch (authorType) {
case 'influencer':
return <Badge count={<UserOutlined style={{ color: '#1890ff' }} />} />;
case 'brand':
return <Badge count={<ShopOutlined style={{ color: '#52c41a' }} />} />;
case 'official':
return <Badge count={<VerifiedOutlined style={{ color: '#722ed1' }} />} />;
default:
return null;
}
};
// Get platform name
const getPlatformName = (platform: string) => {
switch (platform) {
case 'youtube':
return 'YouTube';
case 'instagram':
return 'Instagram';
case 'facebook':
return 'Facebook';
case 'linkedin':
return 'LinkedIn';
case 'tiktok':
return 'TikTok';
default:
return platform;
}
};
// Handle post click to view comments
const handleViewComments = (postId: string) => {
navigate(`/comments?post_id=${postId}`);
};
if (error) {
return <div>{error}</div>;
}
return (
<div>
<Card
title="Posts"
extra={
<Space>
<Button
icon={<FilterOutlined />}
onClick={() => setShowFilters(true)}
>
Filters
</Button>
</Space>
}
loading={loading}
>
<Table
dataSource={filteredPosts}
rowKey="id"
pagination={{ pageSize: 10 }}
onRow={(post) => ({
onClick: () => handleViewComments(post.id),
style: { cursor: 'pointer' }
})}
columns={[
{
title: 'Post',
dataIndex: 'title',
key: 'title',
render: (_: unknown, post: FrontendPost) => (
<div>
<Space align="start">
{getPlatformIcon(post.platform)}
<div>
<div>
<Text strong>{post.title}</Text> {' '}
{getContentTypeBadge(post.contentType)}
</div>
<div>
<Text type="secondary" ellipsis={{ tooltip: post.description }}>
{post.description?.length > 80
? `${post.description.substring(0, 80)}...`
: post.description}
</Text>
</div>
<div style={{ marginTop: 4 }}>
<Space size={16}>
<Text type="secondary">
{format(new Date(post.timestamp), 'MMM d, yyyy')}
</Text>
{post.url && (
<a
href={post.url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()} // Prevent row click when clicking the link
>
View Post
</a>
)}
</Space>
</div>
</div>
</Space>
</div>
),
},
{
title: 'Author',
dataIndex: 'author',
key: 'author',
render: (_: unknown, post: FrontendPost) => (
<Space>
<Avatar icon={<UserOutlined />} />
<Text>{post.author}</Text>
{getAuthorTypeBadge(post.authorType)}
</Space>
),
},
{
title: 'Platform',
dataIndex: 'platform',
key: 'platform',
render: (platform: string) => getPlatformName(platform),
},
{
title: 'Engagement',
key: 'engagement',
render: (_: unknown, post: FrontendPost) => (
<Space direction="vertical" size={0}>
{post.engagement.views !== undefined && (
<Text>Views: {post.engagement.views.toLocaleString()}</Text>
)}
{post.engagement.likes !== undefined && (
<Text>Likes: {post.engagement.likes.toLocaleString()}</Text>
)}
{post.engagement.comments !== undefined && (
<Text>Comments: {post.engagement.comments.toLocaleString()}</Text>
)}
{post.engagement.shares !== undefined && (
<Text>Shares: {post.engagement.shares.toLocaleString()}</Text>
)}
</Space>
),
},
{
title: 'Actions',
key: 'actions',
render: (_: unknown, post: FrontendPost) => (
<Space>
<Button
type="link"
onClick={(e) => {
e.stopPropagation(); // Prevent row click
setSelectedPost(post);
}}
>
Details
</Button>
<Button
type="link"
icon={<MessageOutlined />}
onClick={(e) => {
e.stopPropagation(); // Prevent row click
handleViewComments(post.id);
}}
>
Comments
</Button>
</Space>
),
},
]}
/>
</Card>
{/* Filters Drawer */}
<Drawer
title="Filters"
placement="right"
onClose={() => setShowFilters(false)}
open={showFilters}
width={300}
>
<Form layout="vertical">
<Form.Item label="Platform">
<Select
value={platformFilter}
onChange={(value: string) => setPlatformFilter(value)}
style={{ width: '100%' }}
>
<Option value="all">All Platforms</Option>
<Option value="youtube">YouTube</Option>
<Option value="instagram">Instagram</Option>
<Option value="facebook">Facebook</Option>
<Option value="linkedin">LinkedIn</Option>
<Option value="tiktok">TikTok</Option>
</Select>
</Form.Item>
<Form.Item label="Content Type">
<Select
value={contentTypeFilter}
onChange={(value: string) => setContentTypeFilter(value)}
style={{ width: '100%' }}
>
<Option value="all">All Types</Option>
<Option value="post">Post</Option>
<Option value="video">Video</Option>
<Option value="reel">Reel</Option>
<Option value="short">Short</Option>
</Select>
</Form.Item>
</Form>
</Drawer>
{/* Post Detail Drawer */}
<Drawer
title="Post Details"
placement="right"
onClose={() => setSelectedPost(null)}
open={!!selectedPost}
width={500}
>
{selectedPost && (
<div>
<Title level={4}>{selectedPost.title}</Title>
<Space>
{getPlatformIcon(selectedPost.platform)}
<Text>{getPlatformName(selectedPost.platform)}</Text>
{getContentTypeBadge(selectedPost.contentType)}
</Space>
<div style={{ margin: '16px 0' }}>
<Text>{selectedPost.description}</Text>
</div>
<div style={{ margin: '16px 0' }}>
<Text strong>Author:</Text> {selectedPost.author} {getAuthorTypeBadge(selectedPost.authorType)}
</div>
<div style={{ margin: '16px 0' }}>
<Text strong>Published:</Text> {format(new Date(selectedPost.timestamp), 'PPP')}
</div>
<Card title="Engagement Statistics">
<Space direction="vertical" size={8} style={{ width: '100%' }}>
{selectedPost.engagement.views !== undefined && (
<div>
<Text strong>Views:</Text> {selectedPost.engagement.views.toLocaleString()}
</div>
)}
{selectedPost.engagement.likes !== undefined && (
<div>
<Text strong>Likes:</Text> {selectedPost.engagement.likes.toLocaleString()}
</div>
)}
{selectedPost.engagement.comments !== undefined && (
<div>
<Text strong>Comments:</Text> {selectedPost.engagement.comments.toLocaleString()}
</div>
)}
{selectedPost.engagement.shares !== undefined && (
<div>
<Text strong>Shares:</Text> {selectedPost.engagement.shares.toLocaleString()}
</div>
)}
</Space>
</Card>
{selectedPost.url && (
<div style={{ marginTop: 16 }}>
<Button type="primary" href={selectedPost.url} target="_blank">
View Original Post
</Button>
</div>
)}
</div>
)}
</Drawer>
</div>
);
};
export default PostList;

View File

@@ -0,0 +1,23 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
interface ProtectedRouteProps {
children: React.ReactNode;
}
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
const { isAuthenticated, loading } = useAuth();
if (loading) {
return <div className="flex items-center justify-center h-screen">Loading...</div>;
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
};
export default ProtectedRoute;

View File

@@ -0,0 +1,206 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import {
LayoutDashboard,
MessageSquare,
BarChart2,
Settings,
Users,
Filter,
Calendar,
Facebook,
Instagram,
Linkedin,
BookOpen,
Youtube,
Hash,
X,
FileText
} from 'lucide-react';
interface SidebarProps {
activePage: string;
onPageChange: (page: string) => void;
isOpen: boolean;
onClose: () => void;
}
const Sidebar: React.FC<SidebarProps> = ({ activePage, onPageChange, isOpen, onClose }) => {
const navigate = useNavigate();
// Handle navigation with both route change and active page update
const handleNavigation = (page: string, path: string) => {
onPageChange(page);
navigate(path);
};
return (
<>
{/* Mobile overlay */}
{isOpen && (
<div
className="fixed inset-0 bg-gray-600 bg-opacity-75 z-20 md:hidden"
onClick={onClose}
></div>
)}
{/* Sidebar */}
<aside
className={`${
isOpen ? 'translate-x-0' : '-translate-x-full'
} fixed inset-y-0 left-0 md:relative md:translate-x-0 w-64 bg-gray-800 text-white flex flex-col z-30 transition-transform duration-300 ease-in-out`}
>
<div className="p-4 border-b border-gray-700 flex justify-between items-center">
<h2 className="text-xl font-bold"></h2>
<button
className="text-gray-400 hover:text-white md:hidden"
onClick={onClose}
>
<X className="h-5 w-5" />
</button>
</div>
<nav className="flex-1 overflow-y-auto py-4">
<ul className="space-y-1">
<li>
<button
onClick={() => handleNavigation('dashboard', '/')}
className={`flex items-center px-5 py-3 w-full text-left ${
activePage === 'dashboard'
? 'bg-gray-700 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
} rounded-lg mx-2`}
>
<LayoutDashboard className="h-5 w-5 mr-3" />
<span></span>
</button>
</li>
<li>
<button
onClick={() => handleNavigation('comments', '/comments')}
className={`flex items-center px-5 py-3 w-full text-left ${
activePage === 'comments'
? 'bg-gray-700 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
} rounded-lg mx-2`}
>
<MessageSquare className="h-5 w-5 mr-3" />
<span></span>
</button>
</li>
<li>
<button
onClick={() => handleNavigation('posts', '/posts')}
className={`flex items-center px-5 py-3 w-full text-left ${
activePage === 'posts'
? 'bg-gray-700 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
} rounded-lg mx-2`}
>
<FileText className="h-5 w-5 mr-3" />
<span></span>
</button>
</li>
<li>
<button
onClick={() => handleNavigation('analytics', '/analytics')}
className={`flex items-center px-5 py-3 w-full text-left ${
activePage === 'analytics'
? 'bg-gray-700 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
} rounded-lg mx-2`}
>
<BarChart2 className="h-5 w-5 mr-3" />
<span></span>
</button>
</li>
<li>
<button
className="flex items-center px-5 py-3 w-full text-left text-gray-300 hover:bg-gray-700 hover:text-white rounded-lg mx-2"
>
<Users className="h-5 w-5 mr-3" />
<span></span>
</button>
</li>
<li>
<button
className="flex items-center px-5 py-3 w-full text-left text-gray-300 hover:bg-gray-700 hover:text-white rounded-lg mx-2"
>
<Settings className="h-5 w-5 mr-3" />
<span></span>
</button>
</li>
</ul>
<div className="mt-8 px-5">
<h3 className="text-xs uppercase tracking-wider text-gray-400 font-semibold mb-2"></h3>
<ul className="space-y-1">
<li>
<a href="#" className="flex items-center px-3 py-2 text-gray-300 hover:bg-gray-700 hover:text-white rounded-lg">
<Filter className="h-4 w-4 mr-2" />
<span></span>
</a>
</li>
<li>
<a href="#" className="flex items-center px-3 py-2 text-gray-300 hover:bg-gray-700 hover:text-white rounded-lg">
<Calendar className="h-4 w-4 mr-2" />
<span></span>
</a>
</li>
<li>
<a href="#" className="flex items-center px-3 py-2 text-gray-300 hover:bg-gray-700 hover:text-white rounded-lg">
<Facebook className="h-4 w-4 mr-2 text-blue-500" />
<span>Facebook</span>
</a>
</li>
<li>
<a href="#" className="flex items-center px-3 py-2 text-gray-300 hover:bg-gray-700 hover:text-white rounded-lg">
<Hash className="h-4 w-4 mr-2 text-gray-300" />
<span>Threads</span>
</a>
</li>
<li>
<a href="#" className="flex items-center px-3 py-2 text-gray-300 hover:bg-gray-700 hover:text-white rounded-lg">
<Instagram className="h-4 w-4 mr-2 text-pink-500" />
<span>Instagram</span>
</a>
</li>
<li>
<a href="#" className="flex items-center px-3 py-2 text-gray-300 hover:bg-gray-700 hover:text-white rounded-lg">
<Linkedin className="h-4 w-4 mr-2 text-blue-600" />
<span>LinkedIn</span>
</a>
</li>
<li>
<a href="#" className="flex items-center px-3 py-2 text-gray-300 hover:bg-gray-700 hover:text-white rounded-lg">
<BookOpen className="h-4 w-4 mr-2 text-red-500" />
<span></span>
</a>
</li>
<li>
<a href="#" className="flex items-center px-3 py-2 text-gray-300 hover:bg-gray-700 hover:text-white rounded-lg">
<Youtube className="h-4 w-4 mr-2 text-red-600" />
<span>YouTube</span>
</a>
</li>
</ul>
</div>
</nav>
<div className="p-4 border-t border-gray-700">
<div className="flex items-center">
<div className="h-8 w-8 rounded-full bg-blue-500 flex items-center justify-center text-white font-medium">
A
</div>
<div className="ml-3">
<p className="text-sm font-medium"></p>
<p className="text-xs text-gray-400">admin@example.com</p>
</div>
</div>
</div>
</aside>
</>
);
};
export default Sidebar;

View File

@@ -0,0 +1,228 @@
import React, { createContext, useState, useContext, useEffect, ReactNode } from 'react';
import { authApi } from '../utils/api';
export interface User {
id: string;
email: string;
name?: string;
}
interface AuthContextType {
isAuthenticated: boolean;
user: User | null;
loading: boolean;
login: (token: string, user: User) => void;
logout: () => void;
checkAuth: () => Promise<boolean>;
}
// Token过期前一小时刷新
const REFRESH_THRESHOLD = 60 * 60 * 1000; // 1小时(毫秒)
// Create the auth context
const AuthContext = createContext<AuthContextType>({
isAuthenticated: false,
user: null,
loading: true,
login: () => {},
logout: () => {},
checkAuth: async () => false,
});
// Auth provider component
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState<boolean>(true);
// 检查是否有保存的登录状态
const loadSavedSession = () => {
try {
const savedAuth = localStorage.getItem('auth_state');
if (savedAuth) {
const { user: savedUser, timestamp } = JSON.parse(savedAuth);
// 检查保存的状态是否在一周内
const now = Date.now();
const validDuration = 7 * 24 * 60 * 60 * 1000; // 一周
if (now - timestamp < validDuration) {
setUser(savedUser);
setIsAuthenticated(true);
// 如果认证状态有效但我们仍需验证token
return true;
}
}
} catch (error) {
console.error('Failed to load saved authentication state:', error);
}
return false;
};
// 保存认证状态到localStorage
const saveAuthState = (userData: User) => {
try {
const authState = {
user: userData,
timestamp: Date.now()
};
localStorage.setItem('auth_state', JSON.stringify(authState));
} catch (error) {
console.error('Failed to save authentication state:', error);
}
};
// Check if the user is authenticated on initial load
useEffect(() => {
const initAuth = async () => {
console.log('Checking authentication status...');
// 先尝试加载保存的会话
const hasSavedSession = loadSavedSession();
// 即使有保存的会话仍然需要验证token
const isValid = await checkAuth();
// 如果验证失败,清除保存的会话
if (!isValid && hasSavedSession) {
setIsAuthenticated(false);
setUser(null);
localStorage.removeItem('auth_state');
}
setLoading(false);
};
initAuth();
// 设置定时刷新token
const refreshInterval = setInterval(() => {
// 检查是否已认证
if (isAuthenticated) {
// 静默尝试刷新token
refreshToken();
}
}, REFRESH_THRESHOLD);
return () => clearInterval(refreshInterval);
}, [isAuthenticated]);
// 尝试刷新token
const refreshToken = async () => {
try {
// 检查是否启用了"记住我"
const rememberMe = localStorage.getItem('remember_me');
// 如果没有启用"记住我",则使用会话存储
if (rememberMe !== 'true') {
// 短期会话不需要刷新token
return;
}
const token = localStorage.getItem('auth_token');
if (!token) return;
// 调用刷新token的API
const response = await authApi.refreshToken();
if (response.data.token) {
// 更新token
localStorage.setItem('auth_token', response.data.token);
console.log('Token refreshed successfully');
}
} catch (error) {
console.error('Failed to refresh token:', error);
// 如果刷新失败不要强制登出用户让下一次API调用来决定
}
};
// Check if the token is valid
const checkAuth = async (): Promise<boolean> => {
// 简单检查本地存储中是否有token不再发送API请求
const savedToken = localStorage.getItem('auth_token');
const savedUser = localStorage.getItem('user');
if (!savedToken || !savedUser) {
setIsAuthenticated(false);
setUser(null);
setLoading(false);
return false;
}
try {
// 直接从localStorage恢复会话状态不设置token状态没有找到setToken函数
setUser(JSON.parse(savedUser));
setIsAuthenticated(true);
setLoading(false);
return true;
} catch (error) {
console.error('Error parsing stored user data:', error);
setIsAuthenticated(false);
setUser(null);
setLoading(false);
return false;
}
};
// Login function
const login = (token: string, userData: User) => {
console.log('Logging in user, setting isAuthenticated=true', userData);
// 检查是否需要记住登录状态
const rememberMe = localStorage.getItem('remember_me');
console.log('Remember me setting:', rememberMe);
if (rememberMe === 'true') {
// 长期存储在localStorage
localStorage.setItem('auth_token', token);
// 保存认证状态
saveAuthState(userData);
console.log('Saved auth state to localStorage (long term)');
} else {
// 短期存储在sessionStorage浏览器关闭后清除
sessionStorage.setItem('auth_token', token);
sessionStorage.setItem('user', JSON.stringify(userData));
console.log('Saved auth state to sessionStorage (session only)');
}
// 先设置用户信息,再设置认证状态(顺序很重要)
setUser(userData);
setIsAuthenticated(true);
};
// Logout function
const logout = () => {
console.log('Logging out user');
// 清除localStorage中的认证数据
localStorage.removeItem('auth_token');
localStorage.removeItem('auth_state');
// 保留remember_me设置以便下次登录时使用相同设置
// 清除sessionStorage中的认证数据
sessionStorage.removeItem('auth_token');
sessionStorage.removeItem('user');
setUser(null);
setIsAuthenticated(false);
};
return (
<AuthContext.Provider
value={{
isAuthenticated,
user,
loading,
login,
logout,
checkAuth,
}}
>
{children}
</AuthContext.Provider>
);
};
// Custom hook to use auth context
export const useAuth = () => useContext(AuthContext);
export default AuthContext;

3
web/src/index.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

10
web/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);

65
web/src/types.ts Normal file
View File

@@ -0,0 +1,65 @@
export interface Comment {
id: string;
platform: 'facebook' | 'threads' | 'instagram' | 'linkedin' | 'xiaohongshu' | 'youtube';
contentType?: 'post' | 'reel' | 'video' | 'short';
content: string;
author: string;
authorType: 'user' | 'kol' | 'official';
timestamp: string;
articleTitle: string;
postAuthor: string;
postAuthorType: 'official' | 'kol';
url: string;
status: 'pending' | 'approved' | 'rejected';
sentiment: 'positive' | 'negative' | 'neutral' | 'mixed';
aiReply?: string;
replyStatus?: 'draft' | 'sent' | 'none';
language?: 'zh-TW' | 'zh-CN' | 'en';
privateMessage?: {
content?: string;
status?: 'draft' | 'sent' | 'none';
timestamp?: string;
};
}
export interface AnalyticsData {
platform: string;
count: number;
percentage: number;
}
export interface TimelineData {
date: string;
count: number;
}
export interface SentimentData {
positive: number;
neutral: number;
negative: number;
mixed?: number;
}
export interface ReplyTemplate {
id: string;
name: string;
content: string;
language: 'zh-TW' | 'zh-CN' | 'en';
category: 'general' | 'support' | 'promotion' | 'inquiry';
type: 'reply' | 'private';
}
export interface ReplyPersona {
id: string;
name: string;
description: string;
tone: 'professional' | 'friendly' | 'formal' | 'casual';
}
export interface ReplyAccount {
id: string;
name: string;
platform: 'facebook' | 'threads' | 'instagram' | 'linkedin' | 'xiaohongshu' | 'youtube';
avatar: string;
role: 'admin' | 'moderator' | 'support';
}

138
web/src/utils/api.ts Normal file
View File

@@ -0,0 +1,138 @@
import axios, { AxiosInstance, AxiosResponse } from 'axios';
// Type definitions
interface LoginCredentials {
email: string;
password: string;
}
interface LoginResponse {
success: boolean;
token: string;
user: {
id: string;
email: string;
name?: string;
};
}
// Create a reusable Axios instance with default configuration
const apiClient: AxiosInstance = axios.create({
baseURL: 'http://localhost:4000',
headers: {
'Content-Type': 'application/json',
},
timeout: 10000, // 10 seconds timeout
});
// Request interceptor for adding auth token
apiClient.interceptors.request.use(
(config) => {
const token = localStorage.getItem('auth_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor for handling common errors
apiClient.interceptors.response.use(
(response) => {
return response;
},
(error) => {
// Handle errors globally
if (error.response) {
// Server responded with error status (4xx, 5xx)
if (error.response.status === 401) {
// Unauthorized - clear local storage
localStorage.removeItem('auth_token');
localStorage.removeItem('user');
// Redirect to login page if not already there
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
}
}
return Promise.reject(error);
}
);
// Auth API
export const authApi = {
login: (credentials: LoginCredentials): Promise<AxiosResponse<LoginResponse>> =>
apiClient.post('/api/auth/login', credentials),
verify: (headers?: Record<string, string>): Promise<AxiosResponse> =>
apiClient.get('/api/auth/verify', headers ? { headers } : undefined),
register: (data: { email: string; password: string; name: string }): Promise<AxiosResponse> =>
apiClient.post('/api/auth/register', data),
refreshToken: (): Promise<AxiosResponse<{token: string}>> =>
apiClient.post('/api/auth/refresh-token'),
};
// Comments API
export const commentsApi = {
getComments: (params?: Record<string, string | number | boolean>): Promise<AxiosResponse> =>
apiClient.get('/api/comments', { params }),
getComment: (id: string): Promise<AxiosResponse> =>
apiClient.get(`/api/comments/${id}`),
createComment: (data: Record<string, unknown>): Promise<AxiosResponse> =>
apiClient.post('/api/comments', data),
updateComment: (id: string, data: Record<string, unknown>): Promise<AxiosResponse> =>
apiClient.put(`/api/comments/${id}`, data),
deleteComment: (id: string): Promise<AxiosResponse> =>
apiClient.delete(`/api/comments/${id}`),
};
// Posts API
export const postsApi = {
getPosts: (params?: Record<string, string | number | boolean>): Promise<AxiosResponse> =>
apiClient.get('/api/posts', { params }),
getPost: (id: string): Promise<AxiosResponse> =>
apiClient.get(`/api/posts/${id}`),
createPost: (data: Record<string, unknown>): Promise<AxiosResponse> =>
apiClient.post('/api/posts', data),
updatePost: (id: string, data: Record<string, unknown>): Promise<AxiosResponse> =>
apiClient.put(`/api/posts/${id}`, data),
deletePost: (id: string): Promise<AxiosResponse> =>
apiClient.delete(`/api/posts/${id}`),
};
// Analytics API
export const analyticsApi = {
getPlatforms: (timeRange: string): Promise<AxiosResponse> =>
apiClient.get(`/api/analytics/platforms?timeRange=${timeRange}`),
getTimeline: (timeRange: string): Promise<AxiosResponse> =>
apiClient.get(`/api/analytics/timeline?timeRange=${timeRange}`),
getSentiment: (timeRange: string): Promise<AxiosResponse> =>
apiClient.get(`/api/analytics/sentiment?timeRange=${timeRange}`),
getStatus: (timeRange: string): Promise<AxiosResponse> =>
apiClient.get(`/api/analytics/status?timeRange=${timeRange}`),
getPopularContent: (timeRange: string): Promise<AxiosResponse> =>
apiClient.get(`/api/analytics/popular-content?timeRange=${timeRange}`),
getInfluencers: (timeRange: string): Promise<AxiosResponse> =>
apiClient.get(`/api/analytics/influencers?timeRange=${timeRange}`),
getConversion: (timeRange: string): Promise<AxiosResponse> =>
apiClient.get(`/api/analytics/conversion?timeRange=${timeRange}`),
};
// Templates API
export const templatesApi = {
getTemplates: (): Promise<AxiosResponse> =>
apiClient.get('/api/reply-templates'),
getTemplate: (id: string): Promise<AxiosResponse> =>
apiClient.get(`/api/reply-templates/${id}`),
createTemplate: (data: Record<string, unknown>): Promise<AxiosResponse> =>
apiClient.post('/api/reply-templates', data),
updateTemplate: (id: string, data: Record<string, unknown>): Promise<AxiosResponse> =>
apiClient.put(`/api/reply-templates/${id}`, data),
deleteTemplate: (id: string): Promise<AxiosResponse> =>
apiClient.delete(`/api/reply-templates/${id}`),
};
export default apiClient;

1
web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />