init
This commit is contained in:
3
web/.bolt/config.json
Normal file
3
web/.bolt/config.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"template": "bolt-vite-react-ts"
|
||||
}
|
||||
8
web/.bolt/prompt
Normal file
8
web/.bolt/prompt
Normal 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
24
web/.gitignore
vendored
Normal 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
28
web/eslint.config.js
Normal 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
13
web/index.html
Normal 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
4061
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
web/package.json
Normal file
39
web/package.json
Normal 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
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
6
web/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
114
web/src/App.tsx
Normal file
114
web/src/App.tsx
Normal 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;
|
||||
815
web/src/components/Analytics.tsx
Normal file
815
web/src/components/Analytics.tsx
Normal 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;
|
||||
520
web/src/components/CommentList.tsx
Normal file
520
web/src/components/CommentList.tsx
Normal file
@@ -0,0 +1,520 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Facebook,
|
||||
MessageSquare,
|
||||
Instagram,
|
||||
Linkedin,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
MoreHorizontal,
|
||||
ExternalLink,
|
||||
BookOpen,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
Minus,
|
||||
AlertTriangle,
|
||||
User,
|
||||
Award,
|
||||
Briefcase,
|
||||
Youtube,
|
||||
Hash,
|
||||
Filter,
|
||||
ChevronDown,
|
||||
ArrowLeft
|
||||
} from 'lucide-react';
|
||||
import CommentPreview from './CommentPreview';
|
||||
import { commentsApi, postsApi } from '../utils/api';
|
||||
|
||||
// 定义后端返回的评论类型
|
||||
interface ApiComment {
|
||||
comment_id: string;
|
||||
content: string;
|
||||
sentiment_score: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
post_id: string;
|
||||
user_id: string;
|
||||
user_profile?: {
|
||||
id: string;
|
||||
full_name: string;
|
||||
avatar_url: string;
|
||||
};
|
||||
}
|
||||
|
||||
// 定义前端使用的评论类型
|
||||
interface FrontendComment {
|
||||
id: string;
|
||||
content: string;
|
||||
author: string;
|
||||
authorType: 'user' | 'kol' | 'official';
|
||||
platform: 'facebook' | 'threads' | 'instagram' | 'linkedin' | 'xiaohongshu' | 'youtube';
|
||||
contentType?: 'post' | 'reel' | 'video' | 'short';
|
||||
timestamp: string;
|
||||
sentiment: string;
|
||||
status: string;
|
||||
replyStatus?: string;
|
||||
language?: string;
|
||||
articleTitle?: string;
|
||||
postAuthor?: string;
|
||||
postAuthorType?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
interface CommentListProps {
|
||||
postId?: string; // 可选的帖子 ID,如果提供则只获取该帖子的评论
|
||||
}
|
||||
|
||||
interface PostData {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
platform: string;
|
||||
post_url?: string;
|
||||
}
|
||||
|
||||
const CommentList: React.FC<CommentListProps> = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const postId = searchParams.get('post_id');
|
||||
|
||||
const [comments, setComments] = useState<FrontendComment[]>([]);
|
||||
const [post, setPost] = useState<PostData | null>(null); // Store post data
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [selectedComment, setSelectedComment] = useState<FrontendComment | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 过滤和分页状态
|
||||
const [platformFilter, setPlatformFilter] = useState<string>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [sentimentFilter, setSentimentFilter] = useState<string>('all');
|
||||
const [replyStatusFilter, setReplyStatusFilter] = useState<string>('all');
|
||||
const [languageFilter, setLanguageFilter] = useState<string>('all');
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
const [pageSize, setPageSize] = useState<number>(10);
|
||||
const [totalComments, setTotalComments] = useState<number>(0);
|
||||
const [showFilters, setShowFilters] = useState<boolean>(false);
|
||||
|
||||
// Fetch post data if postId is provided
|
||||
useEffect(() => {
|
||||
const fetchPostData = async () => {
|
||||
if (postId) {
|
||||
try {
|
||||
const response = await postsApi.getPost(postId);
|
||||
setPost(response.data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch post data:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchPostData();
|
||||
}, [postId]);
|
||||
|
||||
// 获取评论数据
|
||||
useEffect(() => {
|
||||
const fetchComments = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Build query parameters
|
||||
const params: Record<string, string | number> = {};
|
||||
|
||||
if (postId) {
|
||||
params.post_id = postId;
|
||||
}
|
||||
|
||||
if (platformFilter !== 'all') {
|
||||
params.platform = platformFilter;
|
||||
}
|
||||
|
||||
if (statusFilter !== 'all') {
|
||||
params.status = statusFilter;
|
||||
}
|
||||
|
||||
if (sentimentFilter !== 'all') {
|
||||
params.sentiment = sentimentFilter;
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
params.query = searchQuery;
|
||||
}
|
||||
|
||||
if (languageFilter !== 'all') {
|
||||
params.language = languageFilter;
|
||||
}
|
||||
|
||||
// Add pagination
|
||||
params.limit = pageSize;
|
||||
params.offset = (currentPage - 1) * pageSize;
|
||||
|
||||
const response = await commentsApi.getComments(params);
|
||||
|
||||
// 处理返回的数据
|
||||
const apiComments: ApiComment[] = response.data.comments || [];
|
||||
const total = response.data.total || apiComments.length;
|
||||
|
||||
// 转换为前端格式
|
||||
const frontendComments: FrontendComment[] = apiComments.map(comment => {
|
||||
// 确定情感
|
||||
let sentiment = 'neutral';
|
||||
if (comment.sentiment_score > 0.3) {
|
||||
sentiment = 'positive';
|
||||
} else if (comment.sentiment_score < -0.3) {
|
||||
sentiment = 'negative';
|
||||
}
|
||||
|
||||
// 检测语言
|
||||
const language = detectLanguage(comment.content);
|
||||
|
||||
return {
|
||||
id: comment.comment_id,
|
||||
content: comment.content,
|
||||
author: comment.user_profile?.full_name || '匿名用户',
|
||||
authorType: 'user', // 默认为普通用户
|
||||
platform: 'facebook', // 假设默认是 Facebook
|
||||
timestamp: comment.created_at,
|
||||
sentiment,
|
||||
status: 'approved', // 假设默认已审核
|
||||
language,
|
||||
// 其他可选字段可以根据 API 返回的数据动态添加
|
||||
};
|
||||
});
|
||||
|
||||
setComments(frontendComments);
|
||||
setTotalComments(total);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch comments:', err);
|
||||
setError('加载评论失败,请稍后再试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchComments();
|
||||
}, [postId, platformFilter, statusFilter, sentimentFilter, searchQuery, languageFilter, currentPage, pageSize]);
|
||||
|
||||
// 简单的语言检测
|
||||
const detectLanguage = (text: string): 'zh-TW' | 'zh-CN' | 'en' => {
|
||||
const traditionalChineseRegex = /[一-龥]/;
|
||||
const simplifiedChineseRegex = /[一-龥]/;
|
||||
const englishRegex = /[a-zA-Z]/;
|
||||
|
||||
if (englishRegex.test(text) && !traditionalChineseRegex.test(text) && !simplifiedChineseRegex.test(text)) {
|
||||
return 'en';
|
||||
} else if (traditionalChineseRegex.test(text)) {
|
||||
// 这里简化了繁体/简体的判断,实际实现应该更复杂
|
||||
return 'zh-TW';
|
||||
} else {
|
||||
return 'zh-CN';
|
||||
}
|
||||
};
|
||||
|
||||
// Function to go back to posts list
|
||||
const handleBackToPosts = () => {
|
||||
navigate('/posts');
|
||||
};
|
||||
|
||||
// 显示加载状态
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 overflow-hidden md:flex-row">
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="w-12 h-12 border-t-2 border-b-2 border-blue-500 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 显示错误信息
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 overflow-hidden md:flex-row">
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center text-red-500">
|
||||
<AlertTriangle className="w-12 h-12 mx-auto mb-4" />
|
||||
<p>{error}</p>
|
||||
<button
|
||||
className="px-4 py-2 mt-4 text-white bg-blue-500 rounded-md hover:bg-blue-600"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="flex flex-col flex-1 overflow-hidden">
|
||||
<div className="bg-white p-4 border-b flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
{postId && (
|
||||
<button
|
||||
onClick={handleBackToPosts}
|
||||
className="mr-4 p-1 hover:bg-gray-100 rounded-full transition-colors duration-200"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5 text-gray-500" />
|
||||
</button>
|
||||
)}
|
||||
<h2 className="text-lg font-semibold">
|
||||
{post ? `${post.title} 的评论` : '所有评论'}
|
||||
</h2>
|
||||
{post && (
|
||||
<span className="ml-2 text-sm text-gray-500">
|
||||
({totalComments} 条评论)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<div className="relative mr-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索评论..."
|
||||
className="px-4 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className={`flex items-center px-3 py-2 rounded-lg text-sm ${
|
||||
showFilters ? 'bg-blue-100 text-blue-700' : 'hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<Filter className="h-4 w-4 mr-1" />
|
||||
筛选
|
||||
<ChevronDown className="h-4 w-4 ml-1" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile filters panel */}
|
||||
{showFilters && (
|
||||
<div className="p-4 mb-4 space-y-3 bg-white rounded-lg shadow-md sm:hidden">
|
||||
<div>
|
||||
<label className="block mb-1 text-sm font-medium text-gray-700">狀態</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">全部狀態</option>
|
||||
<option value="pending">待審核</option>
|
||||
<option value="approved">已核准</option>
|
||||
<option value="rejected">已拒絕</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block mb-1 text-sm font-medium text-gray-700">平台</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
value={platformFilter}
|
||||
onChange={(e) => setPlatformFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">全部平台</option>
|
||||
<option value="facebook">Facebook</option>
|
||||
<option value="threads">Threads</option>
|
||||
<option value="instagram">Instagram</option>
|
||||
<option value="linkedin">LinkedIn</option>
|
||||
<option value="xiaohongshu">小红书</option>
|
||||
<option value="youtube">YouTube</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block mb-1 text-sm font-medium text-gray-700">回覆狀態</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
value={replyStatusFilter}
|
||||
onChange={(e) => setReplyStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">全部回覆狀態</option>
|
||||
<option value="sent">已回覆</option>
|
||||
<option value="draft">草稿</option>
|
||||
<option value="none">未回覆</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block mb-1 text-sm font-medium text-gray-700">語言</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
value={languageFilter}
|
||||
onChange={(e) => setLanguageFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">全部語言</option>
|
||||
<option value="zh-TW">繁體中文</option>
|
||||
<option value="zh-CN">简体中文</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile comment list */}
|
||||
<div className="block md:hidden">
|
||||
<div className="space-y-4">
|
||||
{comments.map((comment) => (
|
||||
<div
|
||||
key={comment.id}
|
||||
className="overflow-hidden bg-white rounded-lg shadow cursor-pointer"
|
||||
onClick={() => setSelectedComment(comment)}
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center">
|
||||
<Facebook className="w-5 h-5 text-blue-600" />
|
||||
<span className="ml-2 text-sm font-medium">Facebook</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mb-2 text-sm text-gray-900">{comment.content}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<span className="mr-2 text-xs font-medium text-gray-700">{comment.author}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{comment.timestamp}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop table */}
|
||||
<div className="hidden overflow-hidden bg-white rounded-lg shadow md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase">平台</th>
|
||||
<th scope="col" className="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase">留言內容</th>
|
||||
<th scope="col" className="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase">留言者</th>
|
||||
<th scope="col" className="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase">時間</th>
|
||||
<th scope="col" className="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase">語言</th>
|
||||
<th scope="col" className="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase">情感</th>
|
||||
<th scope="col" className="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase">回覆狀態</th>
|
||||
<th scope="col" className="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{comments.map((comment) => (
|
||||
<tr
|
||||
key={comment.id}
|
||||
className="cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => setSelectedComment(comment)}
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center">
|
||||
<Facebook className="w-5 h-5 text-blue-600" />
|
||||
<span className="ml-2 text-sm text-gray-900">
|
||||
Facebook
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="max-w-md text-sm text-gray-900 truncate">
|
||||
{comment.content}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="mr-2 text-sm text-gray-900">{comment.author}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-500">{comment.timestamp}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{comment.language === 'zh-TW' && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
繁中
|
||||
</span>
|
||||
)}
|
||||
{comment.language === 'zh-CN' && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||
简中
|
||||
</span>
|
||||
)}
|
||||
{comment.language === 'en' && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
EN
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{comment.sentiment === 'positive' && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
<ThumbsUp className="w-3 h-3 mr-1" />
|
||||
正面
|
||||
</span>
|
||||
)}
|
||||
{comment.sentiment === 'negative' && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||
<ThumbsDown className="w-3 h-3 mr-1" />
|
||||
負面
|
||||
</span>
|
||||
)}
|
||||
{comment.sentiment === 'neutral' && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
<Minus className="w-3 h-3 mr-1" />
|
||||
中性
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{comment.replyStatus === 'sent' && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
<CheckCircle className="w-3 h-3 mr-1" />
|
||||
已回覆
|
||||
</span>
|
||||
)}
|
||||
{comment.replyStatus === 'draft' && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
<MessageSquare className="w-3 h-3 mr-1" />
|
||||
草稿
|
||||
</span>
|
||||
)}
|
||||
{comment.replyStatus === 'none' && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
<XCircle className="w-3 h-3 mr-1" />
|
||||
未回覆
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm font-medium text-right whitespace-nowrap">
|
||||
<button className="mr-3 text-blue-600 hover:text-blue-900">
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedComment && (
|
||||
<div className="overflow-auto bg-white border-t border-gray-200 md:w-96 md:border-t-0 md:border-l">
|
||||
<CommentPreview comment={selectedComment} onClose={() => setSelectedComment(null)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentList;
|
||||
637
web/src/components/CommentPreview.tsx
Normal file
637
web/src/components/CommentPreview.tsx
Normal 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;
|
||||
390
web/src/components/Dashboard.tsx
Normal file
390
web/src/components/Dashboard.tsx
Normal 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;
|
||||
76
web/src/components/Header.tsx
Normal file
76
web/src/components/Header.tsx
Normal 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;
|
||||
208
web/src/components/Login.tsx
Normal file
208
web/src/components/Login.tsx
Normal 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;
|
||||
493
web/src/components/PostList.tsx
Normal file
493
web/src/components/PostList.tsx
Normal 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;
|
||||
23
web/src/components/ProtectedRoute.tsx
Normal file
23
web/src/components/ProtectedRoute.tsx
Normal 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;
|
||||
206
web/src/components/Sidebar.tsx
Normal file
206
web/src/components/Sidebar.tsx
Normal 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;
|
||||
228
web/src/context/AuthContext.tsx
Normal file
228
web/src/context/AuthContext.tsx
Normal 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
3
web/src/index.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
10
web/src/main.tsx
Normal file
10
web/src/main.tsx
Normal 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
65
web/src/types.ts
Normal 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
138
web/src/utils/api.ts
Normal 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
1
web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
8
web/tailwind.config.js
Normal file
8
web/tailwind.config.js
Normal 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
24
web/tsconfig.app.json
Normal 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
7
web/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
22
web/tsconfig.node.json
Normal file
22
web/tsconfig.node.json
Normal 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
10
web/vite.config.ts
Normal 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'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user