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

3
web/.bolt/config.json Normal file
View File

@@ -0,0 +1,3 @@
{
"template": "bolt-vite-react-ts"
}

8
web/.bolt/prompt Normal file
View File

@@ -0,0 +1,8 @@
For all designs I ask you to make, have them be beautiful, not cookie cutter. Make webpages that are fully featured and worthy for production.
By default, this template supports JSX syntax with Tailwind CSS classes, React hooks, and Lucide React for icons. Do not install other packages for UI themes, icons, etc unless absolutely necessary or I request them.
Use icons from lucide-react for logos.
Use stock photos from unsplash where appropriate, only valid URLs you know exist. Do not download the images, only link to them in image tags.

24
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

28
web/eslint.config.js Normal file
View File

@@ -0,0 +1,28 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}
);

13
web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>社群留言管理系統</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4061
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
web/package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "social-media-comment-management",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^5.6.1",
"antd": "^5.24.3",
"axios": "^1.8.1",
"date-fns": "^3.6.0",
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.3.0"
},
"devDependencies": {
"@eslint/js": "^9.9.1",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.18",
"eslint": "^9.9.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.11",
"globals": "^15.9.0",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.5.3",
"typescript-eslint": "^8.3.0",
"vite": "^5.4.2"
},
"packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0"
}

3709
web/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

6
web/postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

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" />

8
web/tailwind.config.js Normal file
View File

@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [],
};

24
web/tsconfig.app.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
web/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

22
web/tsconfig.node.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

10
web/vite.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
optimizeDeps: {
exclude: ['lucide-react'],
},
});