init
This commit is contained in:
239
extension/src/App.tsx
Normal file
239
extension/src/App.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { MessageSquare, BarChart2, Send, RefreshCw, Settings as SettingsIcon, AlertCircle } from 'lucide-react';
|
||||
import CommentList from './sidebar/components/CommentList';
|
||||
import Analytics from './sidebar/components/Analytics';
|
||||
import ReplyGenerator from './sidebar/components/ReplyGenerator';
|
||||
import Settings from './sidebar/components/Settings';
|
||||
import { Comment } from './types';
|
||||
import mockComments from './mockData';
|
||||
|
||||
function App() {
|
||||
const [activeTab, setActiveTab] = useState<'comments' | 'analytics' | 'reply' | 'settings'>('comments');
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [selectedComment, setSelectedComment] = useState<Comment | null>(null);
|
||||
const [mockDelay, setMockDelay] = useState<number>(1000);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Simulate loading comments with a delay
|
||||
setError(null);
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
setComments(mockComments);
|
||||
setIsLoading(false);
|
||||
} catch (err) {
|
||||
setError('Error loading comments: ' + (err instanceof Error ? err.message : String(err)));
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, mockDelay);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [mockDelay]);
|
||||
|
||||
const refreshComments = () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setTimeout(() => {
|
||||
try {
|
||||
setComments(mockComments);
|
||||
setIsLoading(false);
|
||||
} catch (err) {
|
||||
setError('Error refreshing comments: ' + (err instanceof Error ? err.message : String(err)));
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, mockDelay);
|
||||
};
|
||||
|
||||
const handleSelectComment = (comment: Comment) => {
|
||||
setSelectedComment(comment);
|
||||
setActiveTab('reply');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen bg-gray-100">
|
||||
<header className="bg-blue-600 text-white p-4">
|
||||
<div className="container mx-auto flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<h1 className="text-2xl font-bold">社群留言助手 - 開發模式</h1>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center">
|
||||
<span className="text-sm mr-2">模擬延遲:</span>
|
||||
<select
|
||||
value={mockDelay}
|
||||
onChange={(e) => setMockDelay(Number(e.target.value))}
|
||||
className="bg-blue-700 text-white rounded px-2 py-1 text-sm"
|
||||
>
|
||||
<option value="0">無延遲</option>
|
||||
<option value="500">0.5 秒</option>
|
||||
<option value="1000">1 秒</option>
|
||||
<option value="2000">2 秒</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onClick={refreshComments}
|
||||
className="p-2 bg-blue-700 rounded-full text-white hover:bg-blue-800 transition-colors"
|
||||
title="重新載入留言"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mx-auto mt-4 container" role="alert">
|
||||
<div className="flex items-center">
|
||||
<AlertCircle className="mr-2" size={20} />
|
||||
<span className="block sm:inline">{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main className="flex-1 container mx-auto p-4 flex flex-col md:flex-row gap-4">
|
||||
{/* Sidebar Preview */}
|
||||
<div className="w-full md:w-80 bg-white rounded-lg shadow-lg overflow-hidden flex flex-col h-[600px] border border-gray-200">
|
||||
{/* Header */}
|
||||
<div className="bg-blue-600 text-white p-4 shadow-md">
|
||||
<h2 className="text-xl font-bold flex items-center">
|
||||
<MessageSquare className="mr-2" size={20} />
|
||||
社群留言助手
|
||||
</h2>
|
||||
<p className="text-sm opacity-80">自動捕獲留言並產生回覆建議</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{activeTab === 'comments' && (
|
||||
<CommentList
|
||||
comments={comments}
|
||||
isLoading={isLoading}
|
||||
onSelectComment={handleSelectComment}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'analytics' && (
|
||||
<Analytics comments={comments} />
|
||||
)}
|
||||
{activeTab === 'reply' && (
|
||||
<ReplyGenerator
|
||||
comment={selectedComment}
|
||||
onBack={() => setActiveTab('comments')}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'settings' && (
|
||||
<Settings />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="bg-white border-t border-gray-200 p-2">
|
||||
<div className="flex justify-around">
|
||||
<button
|
||||
onClick={() => setActiveTab('comments')}
|
||||
className={`flex flex-col items-center p-2 rounded-md ${activeTab === 'comments' ? 'text-blue-600' : 'text-gray-600'}`}
|
||||
>
|
||||
<MessageSquare size={20} />
|
||||
<span className="text-xs mt-1">留言</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('analytics')}
|
||||
className={`flex flex-col items-center p-2 rounded-md ${activeTab === 'analytics' ? 'text-blue-600' : 'text-gray-600'}`}
|
||||
>
|
||||
<BarChart2 size={20} />
|
||||
<span className="text-xs mt-1">數據</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('reply')}
|
||||
className={`flex flex-col items-center p-2 rounded-md ${activeTab === 'reply' ? 'text-blue-600' : 'text-gray-600'}`}
|
||||
>
|
||||
<Send size={20} />
|
||||
<span className="text-xs mt-1">回覆</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('settings')}
|
||||
className={`flex flex-col items-center p-2 rounded-md ${activeTab === 'settings' ? 'text-blue-600' : 'text-gray-600'}`}
|
||||
>
|
||||
<SettingsIcon size={20} />
|
||||
<span className="text-xs mt-1">設置</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Development Info */}
|
||||
<div className="flex-1">
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h2 className="text-xl font-bold mb-4">開發資訊</h2>
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold mb-2">當前狀態</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="bg-gray-50 p-3 rounded border border-gray-200">
|
||||
<p className="text-sm font-medium text-gray-700">當前頁面</p>
|
||||
<p className="text-lg">{activeTab}</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-3 rounded border border-gray-200">
|
||||
<p className="text-sm font-medium text-gray-700">留言數量</p>
|
||||
<p className="text-lg">{comments.length}</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-3 rounded border border-gray-200">
|
||||
<p className="text-sm font-medium text-gray-700">載入狀態</p>
|
||||
<p className="text-lg">{isLoading ? '載入中' : '已載入'}</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-3 rounded border border-gray-200">
|
||||
<p className="text-sm font-medium text-gray-700">選中的留言</p>
|
||||
<p className="text-lg">{selectedComment ? `ID: ${selectedComment.id}` : '無'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold mb-2">開發指南</h3>
|
||||
<div className="bg-blue-50 p-4 rounded border border-blue-200">
|
||||
<p className="mb-2">這是一個開發環境,用於測試 Chrome 擴展的功能。</p>
|
||||
<ul className="list-disc pl-5 space-y-1 text-sm">
|
||||
<li>左側顯示的是擴展的側邊欄界面預覽</li>
|
||||
<li>可以調整模擬延遲來測試不同的載入狀態</li>
|
||||
<li>點擊刷新按鈕可以重新載入模擬數據</li>
|
||||
<li>所有功能都使用模擬數據,不會實際抓取網頁留言</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">構建與測試</h3>
|
||||
<div className="bg-gray-50 p-4 rounded border border-gray-200 space-y-3">
|
||||
<div>
|
||||
<p className="font-medium">構建擴展:</p>
|
||||
<code className="bg-gray-100 px-2 py-1 rounded text-sm">npm run build</code>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">載入擴展:</p>
|
||||
<ol className="list-decimal pl-5 text-sm space-y-1">
|
||||
<li>打開 Chrome 瀏覽器,進入擴展管理頁面 (chrome://extensions/)</li>
|
||||
<li>開啟開發者模式</li>
|
||||
<li>點擊「載入已解壓的擴展」</li>
|
||||
<li>選擇項目的 dist 目錄</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">測試擴展:</p>
|
||||
<ol className="list-decimal pl-5 text-sm space-y-1">
|
||||
<li>在任意網頁點擊擴展圖標</li>
|
||||
<li>側邊欄將會打開,顯示留言助手界面</li>
|
||||
<li>如果沒有自動打開,可以右鍵點擊擴展圖標,選擇「打開側邊欄」</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className="bg-gray-800 text-white p-4 text-center">
|
||||
<p>社群留言助手 - 開發模式 © 2025</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
59
extension/src/ErrorBoundary.tsx
Normal file
59
extension/src/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||
console.error('Error caught by ErrorBoundary:', error, errorInfo);
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-red-100 border border-red-400 text-red-700 rounded-md">
|
||||
<div className="flex items-start">
|
||||
<AlertCircle className="mr-2 mt-0.5" size={20} />
|
||||
<div>
|
||||
<h3 className="font-bold mb-1">Something went wrong</h3>
|
||||
<p className="text-sm mb-2">
|
||||
{this.state.error?.message || 'An unexpected error occurred'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="bg-red-600 text-white px-3 py-1 rounded text-sm hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
3
extension/src/index.css
Normal file
3
extension/src/index.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
55
extension/src/main.tsx
Normal file
55
extension/src/main.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
import ErrorBoundary from './ErrorBoundary';
|
||||
|
||||
// Error boundary for the entire application
|
||||
const renderApp = () => {
|
||||
try {
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
console.error('Root element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</StrictMode>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error rendering application:', error);
|
||||
|
||||
// Render a fallback UI in case of error
|
||||
const rootElement = document.getElementById('root');
|
||||
if (rootElement) {
|
||||
rootElement.innerHTML = `
|
||||
<div style="padding: 20px; color: #721c24; background-color: #f8d7da; border: 1px solid #f5c6cb; border-radius: 5px;">
|
||||
<h2>Application Error</h2>
|
||||
<p>Sorry, something went wrong while loading the application.</p>
|
||||
<p>Error details: ${error instanceof Error ? error.message : String(error)}</p>
|
||||
<button onclick="window.location.reload()" style="background: #dc3545; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer;">
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Disable Vite's error overlay to prevent WebSocket connection attempts
|
||||
window.addEventListener('error', (event) => {
|
||||
event.preventDefault();
|
||||
console.error('Caught error:', event.error);
|
||||
return true;
|
||||
});
|
||||
|
||||
// Disable Vite's HMR client
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.decline();
|
||||
}
|
||||
|
||||
renderApp();
|
||||
224
extension/src/mockData.ts
Normal file
224
extension/src/mockData.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { Comment } from './types';
|
||||
|
||||
const mockComments: Comment[] = [
|
||||
{
|
||||
id: 'comment-1',
|
||||
author: '王小明',
|
||||
content: '這個產品真的很好用!我已經推薦給我的朋友們了。希望未來能有更多顏色選擇。',
|
||||
timestamp: '2小時前',
|
||||
likes: 24,
|
||||
platform: 'facebook',
|
||||
sentiment: 'positive',
|
||||
keywords: ['好用', '推薦', '顏色'],
|
||||
category: '產品讚美',
|
||||
replies: [
|
||||
{
|
||||
id: 'reply-1-1',
|
||||
author: '品牌官方',
|
||||
content: '謝謝您的支持!我們正在開發更多顏色,敬請期待!',
|
||||
timestamp: '1小時前',
|
||||
likes: 5,
|
||||
platform: 'facebook',
|
||||
sentiment: 'positive'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'comment-2',
|
||||
author: '林美玲',
|
||||
content: '請問這個產品適合敏感肌膚使用嗎?我之前用類似的產品會過敏。',
|
||||
timestamp: '3小時前',
|
||||
likes: 7,
|
||||
platform: 'facebook',
|
||||
sentiment: 'neutral',
|
||||
keywords: ['敏感肌膚', '過敏', '適合'],
|
||||
category: '產品詢問'
|
||||
},
|
||||
{
|
||||
id: 'comment-3',
|
||||
author: 'Jason Chen',
|
||||
content: 'The quality is amazing! Worth every penny. Will definitely buy again.',
|
||||
timestamp: '5小時前',
|
||||
likes: 18,
|
||||
platform: 'instagram',
|
||||
sentiment: 'positive',
|
||||
keywords: ['quality', 'worth', 'buy again'],
|
||||
category: '產品讚美'
|
||||
},
|
||||
{
|
||||
id: 'comment-4',
|
||||
author: '陳大華',
|
||||
content: '收到產品了,包裝很精美,但是尺寸比我想像中小一點。總體來說還是很滿意的。',
|
||||
timestamp: '昨天',
|
||||
likes: 12,
|
||||
platform: 'facebook',
|
||||
sentiment: 'neutral',
|
||||
keywords: ['包裝', '尺寸', '滿意'],
|
||||
category: '產品評價'
|
||||
},
|
||||
{
|
||||
id: 'comment-5',
|
||||
author: 'Sarah Wong',
|
||||
content: '我有個問題,請問這個產品可以國際運送嗎?我現在在美國。',
|
||||
timestamp: '昨天',
|
||||
likes: 3,
|
||||
platform: 'instagram',
|
||||
sentiment: 'neutral',
|
||||
keywords: ['國際運送', '美國'],
|
||||
category: '物流詢問'
|
||||
},
|
||||
{
|
||||
id: 'comment-6',
|
||||
author: '黃小琳',
|
||||
content: '價格有點貴,但品質確實不錯。希望能有折扣活動。',
|
||||
timestamp: '2天前',
|
||||
likes: 9,
|
||||
platform: 'facebook',
|
||||
sentiment: 'neutral',
|
||||
keywords: ['價格', '品質', '折扣'],
|
||||
category: '價格評論'
|
||||
},
|
||||
{
|
||||
id: 'comment-7',
|
||||
author: 'Mike Li',
|
||||
content: 'Just received my order. The shipping was super fast! Great service.',
|
||||
timestamp: '2天前',
|
||||
likes: 15,
|
||||
platform: 'twitter',
|
||||
sentiment: 'positive',
|
||||
keywords: ['shipping', 'fast', 'service'],
|
||||
category: '物流評價'
|
||||
},
|
||||
{
|
||||
id: 'comment-8',
|
||||
author: '張小菲',
|
||||
content: '我的訂單顯示已發貨,但追蹤號碼似乎不正確。能幫我確認一下嗎?訂單號:TW20250615001',
|
||||
timestamp: '3天前',
|
||||
likes: 0,
|
||||
platform: 'facebook',
|
||||
sentiment: 'negative',
|
||||
keywords: ['訂單', '追蹤號碼', '不正確'],
|
||||
category: '物流問題'
|
||||
},
|
||||
{
|
||||
id: 'comment-9',
|
||||
author: 'David Wang',
|
||||
content: '這是我第三次購買了,每次都很滿意。客服也很棒!',
|
||||
timestamp: '4天前',
|
||||
likes: 27,
|
||||
platform: 'youtube',
|
||||
sentiment: 'positive',
|
||||
keywords: ['購買', '滿意', '客服'],
|
||||
category: '客戶體驗',
|
||||
replies: [
|
||||
{
|
||||
id: 'reply-9-1',
|
||||
author: '品牌官方',
|
||||
content: '感謝您的持續支持!我們非常重視每一位顧客的體驗。',
|
||||
timestamp: '4天前',
|
||||
likes: 8,
|
||||
platform: 'youtube',
|
||||
sentiment: 'positive'
|
||||
},
|
||||
{
|
||||
id: 'reply-9-2',
|
||||
author: 'Lisa Chen',
|
||||
content: '我也很喜歡他們的客服,總是很有耐心解答問題。',
|
||||
timestamp: '3天前',
|
||||
likes: 5,
|
||||
platform: 'youtube',
|
||||
sentiment: 'positive'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'comment-10',
|
||||
author: '李小明',
|
||||
content: '產品收到了,但有一個小零件好像壞了。請問如何申請售後服務?',
|
||||
timestamp: '5天前',
|
||||
likes: 2,
|
||||
platform: 'facebook',
|
||||
sentiment: 'negative',
|
||||
keywords: ['零件', '壞了', '售後服務'],
|
||||
category: '產品問題'
|
||||
},
|
||||
{
|
||||
id: 'comment-11',
|
||||
author: 'Emma Chang',
|
||||
content: '我很喜歡你們的環保包裝!希望更多品牌能這樣做。',
|
||||
timestamp: '1週前',
|
||||
likes: 42,
|
||||
platform: 'instagram',
|
||||
sentiment: 'positive',
|
||||
keywords: ['環保包裝', '喜歡'],
|
||||
category: '包裝評價',
|
||||
replies: [
|
||||
{
|
||||
id: 'reply-11-1',
|
||||
author: '品牌官方',
|
||||
content: '謝謝您的支持!環保是我們的核心價值之一,我們會繼續努力做得更好。',
|
||||
timestamp: '1週前',
|
||||
likes: 12,
|
||||
platform: 'instagram',
|
||||
sentiment: 'positive'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'comment-12',
|
||||
author: '陳小華',
|
||||
content: '請問有沒有實體店面可以試用產品?',
|
||||
timestamp: '1週前',
|
||||
likes: 5,
|
||||
platform: 'facebook',
|
||||
sentiment: 'neutral',
|
||||
keywords: ['實體店面', '試用'],
|
||||
category: '銷售詢問'
|
||||
},
|
||||
{
|
||||
id: 'comment-13',
|
||||
author: 'Kevin Wu',
|
||||
content: 'Great product but the app needs improvement. Sometimes it crashes when I try to connect to the device.',
|
||||
timestamp: '1週前',
|
||||
likes: 8,
|
||||
platform: 'twitter',
|
||||
sentiment: 'neutral',
|
||||
keywords: ['product', 'app', 'crashes'],
|
||||
category: '應用問題'
|
||||
},
|
||||
{
|
||||
id: 'comment-14',
|
||||
author: '林小芳',
|
||||
content: '我在官網看到的價格和這裡不一樣,為什麼?',
|
||||
timestamp: '2週前',
|
||||
likes: 3,
|
||||
platform: 'youtube',
|
||||
sentiment: 'negative',
|
||||
keywords: ['價格', '官網', '不一樣'],
|
||||
category: '價格問題'
|
||||
},
|
||||
{
|
||||
id: 'comment-15',
|
||||
author: 'Sophia Lin',
|
||||
content: '剛剛在朋友家看到這個產品,效果真的很驚人!請問現在有什麼促銷活動嗎?',
|
||||
timestamp: '2週前',
|
||||
likes: 19,
|
||||
platform: 'facebook',
|
||||
sentiment: 'positive',
|
||||
keywords: ['效果', '驚人', '促銷活動'],
|
||||
category: '產品讚美',
|
||||
replies: [
|
||||
{
|
||||
id: 'reply-15-1',
|
||||
author: '品牌官方',
|
||||
content: '您好!我們目前有限時折扣活動,購買任兩件產品即可享85折優惠。詳情請查看我們的官網。',
|
||||
timestamp: '2週前',
|
||||
likes: 4,
|
||||
platform: 'facebook',
|
||||
sentiment: 'positive'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export default mockComments;
|
||||
196
extension/src/sidebar/Sidebar.tsx
Normal file
196
extension/src/sidebar/Sidebar.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { MessageSquare, BarChart2, Send, RefreshCw, Settings } from 'lucide-react';
|
||||
import CommentList from './components/CommentList';
|
||||
import Analytics from './components/Analytics';
|
||||
import ReplyGenerator from './components/ReplyGenerator';
|
||||
import Settings from './components/Settings';
|
||||
import { Comment } from '../types';
|
||||
|
||||
const Sidebar: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<'comments' | 'analytics' | 'reply' | 'settings'>('comments');
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [selectedComment, setSelectedComment] = useState<Comment | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Setup message listener with error handling
|
||||
const messageListener = (message: any) => {
|
||||
try {
|
||||
if (message.type === 'COMMENTS_CAPTURED') {
|
||||
setComments(message.comments);
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing message:', error);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Register listener if we're in a Chrome extension environment
|
||||
if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.onMessage) {
|
||||
chrome.runtime.onMessage.addListener(messageListener);
|
||||
} else {
|
||||
// We're in development mode - simulate comments loading
|
||||
console.log('Development mode: simulating comment loading');
|
||||
setTimeout(() => {
|
||||
try {
|
||||
// Import mock data dynamically to avoid issues
|
||||
import('../mockData').then(module => {
|
||||
setComments(module.default);
|
||||
setIsLoading(false);
|
||||
}).catch(error => {
|
||||
console.error('Error loading mock data:', error);
|
||||
setIsLoading(false);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in development mode comment simulation:', error);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Request comments from the current page if in extension environment
|
||||
const requestComments = () => {
|
||||
try {
|
||||
if (typeof chrome !== 'undefined' && chrome.tabs && chrome.tabs.query) {
|
||||
setIsLoading(true);
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
if (tabs[0]?.id) {
|
||||
chrome.tabs.sendMessage(tabs[0].id, { type: 'GET_COMMENTS' });
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error requesting comments:', error);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof chrome !== 'undefined' && chrome.tabs) {
|
||||
requestComments();
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.onMessage) {
|
||||
chrome.runtime.onMessage.removeListener(messageListener);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refreshComments = () => {
|
||||
try {
|
||||
if (typeof chrome !== 'undefined' && chrome.tabs && chrome.tabs.query) {
|
||||
setIsLoading(true);
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
if (tabs[0]?.id) {
|
||||
chrome.tabs.sendMessage(tabs[0].id, { type: 'GET_COMMENTS' });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Development mode - reload mock data
|
||||
setIsLoading(true);
|
||||
setTimeout(() => {
|
||||
import('../mockData').then(module => {
|
||||
setComments(module.default);
|
||||
setIsLoading(false);
|
||||
}).catch(error => {
|
||||
console.error('Error reloading mock data:', error);
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error refreshing comments:', error);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectComment = (comment: Comment) => {
|
||||
setSelectedComment(comment);
|
||||
setActiveTab('reply');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<header className="bg-blue-600 text-white p-4 shadow-md">
|
||||
<h1 className="text-xl font-bold flex items-center">
|
||||
<MessageSquare className="mr-2" size={20} />
|
||||
社群留言助手
|
||||
</h1>
|
||||
<p className="text-sm opacity-80">自動捕獲留言並產生回覆建議</p>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 overflow-auto p-4">
|
||||
{activeTab === 'comments' && (
|
||||
<CommentList
|
||||
comments={comments}
|
||||
isLoading={isLoading}
|
||||
onSelectComment={handleSelectComment}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'analytics' && (
|
||||
<Analytics comments={comments} />
|
||||
)}
|
||||
{activeTab === 'reply' && (
|
||||
<ReplyGenerator
|
||||
comment={selectedComment}
|
||||
onBack={() => setActiveTab('comments')}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'settings' && (
|
||||
<Settings />
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Refresh Button */}
|
||||
<div className="absolute top-4 right-4">
|
||||
<button
|
||||
onClick={refreshComments}
|
||||
className="p-2 bg-blue-700 rounded-full text-white hover:bg-blue-800 transition-colors"
|
||||
title="重新捕獲留言"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="bg-white border-t border-gray-200 p-2">
|
||||
<div className="flex justify-around">
|
||||
<button
|
||||
onClick={() => setActiveTab('comments')}
|
||||
className={`flex flex-col items-center p-2 rounded-md ${activeTab === 'comments' ? 'text-blue-600' : 'text-gray-600'}`}
|
||||
>
|
||||
<MessageSquare size={20} />
|
||||
<span className="text-xs mt-1">留言</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('analytics')}
|
||||
className={`flex flex-col items-center p-2 rounded-md ${activeTab === 'analytics' ? 'text-blue-600' : 'text-gray-600'}`}
|
||||
>
|
||||
<BarChart2 size={20} />
|
||||
<span className="text-xs mt-1">數據</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('reply')}
|
||||
className={`flex flex-col items-center p-2 rounded-md ${activeTab === 'reply' ? 'text-blue-600' : 'text-gray-600'}`}
|
||||
>
|
||||
<Send size={20} />
|
||||
<span className="text-xs mt-1">回覆</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('settings')}
|
||||
className={`flex flex-col items-center p-2 rounded-md ${activeTab === 'settings' ? 'text-blue-600' : 'text-gray-600'}`}
|
||||
>
|
||||
<Settings size={20} />
|
||||
<span className="text-xs mt-1">設置</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
371
extension/src/sidebar/components/Analytics.tsx
Normal file
371
extension/src/sidebar/components/Analytics.tsx
Normal file
@@ -0,0 +1,371 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { BarChart2, TrendingUp, Clock, ThumbsUp, MessageSquare, Smile, Meh, Frown, Tag } from 'lucide-react';
|
||||
import { Comment } from '../../types';
|
||||
|
||||
interface AnalyticsProps {
|
||||
comments: Comment[];
|
||||
}
|
||||
|
||||
const Analytics: React.FC<AnalyticsProps> = ({ comments }) => {
|
||||
const stats = useMemo(() => {
|
||||
// Total comments
|
||||
const totalComments = comments.length;
|
||||
|
||||
// Comments by platform
|
||||
const platformCounts: Record<string, number> = {};
|
||||
comments.forEach(comment => {
|
||||
platformCounts[comment.platform] = (platformCounts[comment.platform] || 0) + 1;
|
||||
});
|
||||
|
||||
// Average likes
|
||||
const totalLikes = comments.reduce((sum, comment) => sum + comment.likes, 0);
|
||||
const avgLikes = totalComments > 0 ? (totalLikes / totalComments).toFixed(1) : '0';
|
||||
|
||||
// Comments with replies
|
||||
const commentsWithReplies = comments.filter(comment =>
|
||||
comment.replies && comment.replies.length > 0
|
||||
).length;
|
||||
|
||||
// Total replies
|
||||
const totalReplies = comments.reduce((sum, comment) =>
|
||||
sum + (comment.replies?.length || 0), 0
|
||||
);
|
||||
|
||||
// Sentiment counts
|
||||
const sentimentCounts = {
|
||||
positive: comments.filter(c => c.sentiment === 'positive').length,
|
||||
neutral: comments.filter(c => c.sentiment === 'neutral').length,
|
||||
negative: comments.filter(c => c.sentiment === 'negative').length
|
||||
};
|
||||
|
||||
// Keywords analysis
|
||||
const keywordCounts: Record<string, number> = {};
|
||||
comments.forEach(comment => {
|
||||
if (comment.keywords) {
|
||||
comment.keywords.forEach(keyword => {
|
||||
keywordCounts[keyword] = (keywordCounts[keyword] || 0) + 1;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const topKeywords = Object.entries(keywordCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([keyword, count]) => ({ keyword, count }));
|
||||
|
||||
// Categories analysis
|
||||
const categoryCounts: Record<string, number> = {};
|
||||
comments.forEach(comment => {
|
||||
if (comment.category) {
|
||||
categoryCounts[comment.category] = (categoryCounts[comment.category] || 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
const categories = Object.entries(categoryCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([category, count]) => ({ category, count }));
|
||||
|
||||
// Most active platforms (sorted)
|
||||
const sortedPlatforms = Object.entries(platformCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([platform, count]) => ({ platform, count }));
|
||||
|
||||
return {
|
||||
totalComments,
|
||||
platformCounts,
|
||||
avgLikes,
|
||||
commentsWithReplies,
|
||||
totalReplies,
|
||||
sortedPlatforms,
|
||||
sentimentCounts,
|
||||
topKeywords,
|
||||
categories
|
||||
};
|
||||
}, [comments]);
|
||||
|
||||
if (comments.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-center">
|
||||
<BarChart2 size={48} className="text-gray-400 mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-700">沒有數據可顯示</h3>
|
||||
<p className="text-gray-500 mt-2">
|
||||
請先捕獲留言以查看數據分析。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-4">留言數據分析</h2>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-6">
|
||||
<div className="bg-white rounded-lg shadow p-3">
|
||||
<div className="flex items-center text-blue-600 mb-1">
|
||||
<MessageSquare size={16} className="mr-1" />
|
||||
<span className="text-xs font-medium">總留言數</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-gray-800">{stats.totalComments}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-3">
|
||||
<div className="flex items-center text-green-600 mb-1">
|
||||
<ThumbsUp size={16} className="mr-1" />
|
||||
<span className="text-xs font-medium">平均讚數</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-gray-800">{stats.avgLikes}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-3">
|
||||
<div className="flex items-center text-purple-600 mb-1">
|
||||
<TrendingUp size={16} className="mr-1" />
|
||||
<span className="text-xs font-medium">互動率</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-gray-800">
|
||||
{stats.totalComments > 0
|
||||
? `${Math.round((stats.commentsWithReplies / stats.totalComments) * 100)}%`
|
||||
: '0%'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-3">
|
||||
<div className="flex items-center text-orange-600 mb-1">
|
||||
<MessageSquare size={16} className="mr-1" />
|
||||
<span className="text-xs font-medium">總回覆數</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-gray-800">{stats.totalReplies}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sentiment Analysis */}
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">情緒分析</h3>
|
||||
|
||||
{/* Sentiment Bar */}
|
||||
<div className="flex mb-2">
|
||||
<div
|
||||
className="bg-green-500 h-3 rounded-l-full"
|
||||
style={{ width: `${(stats.sentimentCounts.positive / stats.totalComments) * 100}%` }}
|
||||
></div>
|
||||
<div
|
||||
className="bg-gray-400 h-3"
|
||||
style={{ width: `${(stats.sentimentCounts.neutral / stats.totalComments) * 100}%` }}
|
||||
></div>
|
||||
<div
|
||||
className="bg-red-500 h-3 rounded-r-full"
|
||||
style={{ width: `${(stats.sentimentCounts.negative / stats.totalComments) * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 mt-3">
|
||||
<div className="bg-green-50 p-2 rounded-md">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center text-green-700">
|
||||
<Smile size={14} className="mr-1" />
|
||||
<span className="text-xs font-medium">正面</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-600">{Math.round((stats.sentimentCounts.positive / stats.totalComments) * 100)}%</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-gray-800">{stats.sentimentCounts.positive}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-2 rounded-md">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center text-gray-700">
|
||||
<Meh size={14} className="mr-1" />
|
||||
<span className="text-xs font-medium">中性</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-600">{Math.round((stats.sentimentCounts.neutral / stats.totalComments) * 100)}%</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-gray-800">{stats.sentimentCounts.neutral}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-red-50 p-2 rounded-md">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center text-red-700">
|
||||
<Frown size={14} className="mr-1" />
|
||||
<span className="text-xs font-medium">負面</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-600">{Math.round((stats.sentimentCounts.negative / stats.totalComments) * 100)}%</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-gray-800">{stats.sentimentCounts.negative}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keywords Analysis */}
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">熱門關鍵詞</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
{stats.topKeywords.slice(0, 5).map(({ keyword, count }) => (
|
||||
<div key={keyword} className="flex items-center">
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mr-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full"
|
||||
style={{ width: `${(count / stats.topKeywords[0].count) * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center min-w-[100px]">
|
||||
<span className="text-xs text-gray-700">{keyword}</span>
|
||||
<span className="text-xs text-gray-500">{count}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{stats.topKeywords.slice(5, 15).map(({ keyword, count }) => (
|
||||
<span
|
||||
key={keyword}
|
||||
className="bg-blue-50 text-blue-700 text-xs px-2 py-0.5 rounded-full"
|
||||
title={`出現 ${count} 次`}
|
||||
>
|
||||
{keyword}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categories Analysis */}
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">留言類別分佈</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
{stats.categories.slice(0, 5).map(({ category, count }) => (
|
||||
<div key={category} className="flex items-center">
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mr-2">
|
||||
<div
|
||||
className="bg-purple-600 h-2 rounded-full"
|
||||
style={{ width: `${(count / stats.totalComments) * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center min-w-[120px]">
|
||||
<span className="text-xs text-gray-700">{category}</span>
|
||||
<span className="text-xs text-gray-500">{count} ({Math.round((count / stats.totalComments) * 100)}%)</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Platform Distribution */}
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">平台分佈</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{Object.entries(stats.platformCounts).map(([platform, count]) => (
|
||||
<div key={platform}>
|
||||
<div className="flex justify-between text-xs text-gray-600 mb-1">
|
||||
<span className="capitalize">{platform}</span>
|
||||
<span>{count} ({Math.round((count / stats.totalComments) * 100)}%)</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full ${getPlatformColor(platform)}`}
|
||||
style={{ width: `${(count / stats.totalComments) * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Comments */}
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">熱門留言</h3>
|
||||
|
||||
{comments
|
||||
.sort((a, b) => b.likes - a.likes)
|
||||
.slice(0, 3)
|
||||
.map(comment => (
|
||||
<div key={comment.id} className="border-b border-gray-100 last:border-0 py-2">
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<span className="text-xs font-medium text-gray-800">{comment.author}</span>
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="flex items-center text-xs text-gray-500">
|
||||
<ThumbsUp size={10} className="mr-1" />
|
||||
{comment.likes}
|
||||
</div>
|
||||
{comment.sentiment && (
|
||||
<div className={`flex items-center text-xs px-1 py-0.5 rounded-full ${getSentimentBadgeColor(comment.sentiment)}`}>
|
||||
{comment.sentiment === 'positive' && <Smile size={8} className="mr-0.5" />}
|
||||
{comment.sentiment === 'neutral' && <Meh size={8} className="mr-0.5" />}
|
||||
{comment.sentiment === 'negative' && <Frown size={8} className="mr-0.5" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 line-clamp-2">{comment.content}</p>
|
||||
<div className="flex justify-between items-center mt-1">
|
||||
<span className="text-xs text-gray-500">{comment.timestamp}</span>
|
||||
<div className="flex items-center space-x-1">
|
||||
{comment.category && (
|
||||
<span className="text-xs px-1 py-0.5 rounded-full bg-purple-50 text-purple-700">
|
||||
{comment.category}
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-xs px-1 py-0.5 rounded-full ${getPlatformBadgeColor(comment.platform)}`}>
|
||||
{comment.platform}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to get platform-specific colors
|
||||
function getPlatformColor(platform: string): string {
|
||||
switch (platform) {
|
||||
case 'facebook':
|
||||
return 'bg-blue-600';
|
||||
case 'instagram':
|
||||
return 'bg-pink-600';
|
||||
case 'twitter':
|
||||
return 'bg-blue-400';
|
||||
case 'youtube':
|
||||
return 'bg-red-600';
|
||||
case 'linkedin':
|
||||
return 'bg-blue-800';
|
||||
default:
|
||||
return 'bg-gray-600';
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get platform-specific badge colors
|
||||
function getPlatformBadgeColor(platform: string): string {
|
||||
switch (platform) {
|
||||
case 'facebook':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'instagram':
|
||||
return 'bg-pink-100 text-pink-800';
|
||||
case 'twitter':
|
||||
return 'bg-blue-100 text-blue-600';
|
||||
case 'youtube':
|
||||
return 'bg-red-100 text-red-800';
|
||||
case 'linkedin':
|
||||
return 'bg-blue-100 text-blue-900';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get sentiment badge colors
|
||||
function getSentimentBadgeColor(sentiment: string): string {
|
||||
switch (sentiment) {
|
||||
case 'positive':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'neutral':
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
case 'negative':
|
||||
return 'bg-red-100 text-red-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
}
|
||||
|
||||
export default Analytics;
|
||||
453
extension/src/sidebar/components/CommentList.tsx
Normal file
453
extension/src/sidebar/components/CommentList.tsx
Normal file
@@ -0,0 +1,453 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { MessageSquare, ThumbsUp, Clock, Filter, SortDesc, Search, X, ChevronDown, Smile, Frown, Meh, Tag } from 'lucide-react';
|
||||
import { Comment } from '../../types';
|
||||
|
||||
interface CommentListProps {
|
||||
comments: Comment[];
|
||||
isLoading: boolean;
|
||||
onSelectComment: (comment: Comment) => void;
|
||||
}
|
||||
|
||||
const CommentList: React.FC<CommentListProps> = ({ comments, isLoading, onSelectComment }) => {
|
||||
const [searchTerm, setSearchTerm] = useState<string>('');
|
||||
const [platformFilter, setPlatformFilter] = useState<string>('all');
|
||||
const [sentimentFilter, setSentimentFilter] = useState<'all' | 'positive' | 'neutral' | 'negative'>('all');
|
||||
const [sortBy, setSortBy] = useState<'newest' | 'oldest' | 'likes' | 'replies'>('newest');
|
||||
const [showFilters, setShowFilters] = useState<boolean>(false);
|
||||
const [showAnalytics, setShowAnalytics] = useState<boolean>(true);
|
||||
|
||||
// Get unique platforms from comments
|
||||
const platforms = useMemo(() => {
|
||||
const platformSet = new Set<string>();
|
||||
comments.forEach(comment => platformSet.add(comment.platform));
|
||||
return Array.from(platformSet);
|
||||
}, [comments]);
|
||||
|
||||
// Calculate sentiment statistics
|
||||
const sentimentStats = useMemo(() => {
|
||||
const stats = {
|
||||
positive: 0,
|
||||
neutral: 0,
|
||||
negative: 0,
|
||||
total: comments.length
|
||||
};
|
||||
|
||||
comments.forEach(comment => {
|
||||
if (comment.sentiment === 'positive') stats.positive++;
|
||||
else if (comment.sentiment === 'neutral') stats.neutral++;
|
||||
else if (comment.sentiment === 'negative') stats.negative++;
|
||||
});
|
||||
|
||||
return stats;
|
||||
}, [comments]);
|
||||
|
||||
// Extract top keywords
|
||||
const topKeywords = useMemo(() => {
|
||||
const keywordCounts: Record<string, number> = {};
|
||||
|
||||
comments.forEach(comment => {
|
||||
if (comment.keywords) {
|
||||
comment.keywords.forEach(keyword => {
|
||||
keywordCounts[keyword] = (keywordCounts[keyword] || 0) + 1;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return Object.entries(keywordCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([keyword, count]) => ({ keyword, count }));
|
||||
}, [comments]);
|
||||
|
||||
// Extract categories
|
||||
const categories = useMemo(() => {
|
||||
const categoryCounts: Record<string, number> = {};
|
||||
|
||||
comments.forEach(comment => {
|
||||
if (comment.category) {
|
||||
categoryCounts[comment.category] = (categoryCounts[comment.category] || 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
return Object.entries(categoryCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([category, count]) => ({ category, count }));
|
||||
}, [comments]);
|
||||
|
||||
// Filter and sort comments
|
||||
const filteredAndSortedComments = useMemo(() => {
|
||||
// First filter by search term, platform, and sentiment
|
||||
let filtered = comments.filter(comment => {
|
||||
const matchesSearch = searchTerm === '' ||
|
||||
comment.content.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
comment.author.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesPlatform = platformFilter === 'all' || comment.platform === platformFilter;
|
||||
|
||||
const matchesSentiment = sentimentFilter === 'all' || comment.sentiment === sentimentFilter;
|
||||
|
||||
return matchesSearch && matchesPlatform && matchesSentiment;
|
||||
});
|
||||
|
||||
// Then sort
|
||||
return filtered.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'newest':
|
||||
// Simple string comparison for timestamps (in a real app, parse dates properly)
|
||||
return a.timestamp < b.timestamp ? 1 : -1;
|
||||
case 'oldest':
|
||||
return a.timestamp > b.timestamp ? 1 : -1;
|
||||
case 'likes':
|
||||
return b.likes - a.likes;
|
||||
case 'replies':
|
||||
return (b.replies?.length || 0) - (a.replies?.length || 0);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}, [comments, searchTerm, platformFilter, sentimentFilter, sortBy]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
||||
<p className="mt-4 text-gray-600">正在捕獲留言...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (comments.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-center">
|
||||
<MessageSquare size={48} className="text-gray-400 mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-700">沒有找到留言</h3>
|
||||
<p className="text-gray-500 mt-2">
|
||||
此頁面上沒有檢測到留言,或者留言格式不被支持。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex justify-between items-center">
|
||||
<h2 className="text-lg font-semibold text-gray-800">留言列表</h2>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => setShowAnalytics(!showAnalytics)}
|
||||
className={`p-1.5 rounded-md ${showAnalytics ? 'bg-purple-100 text-purple-600' : 'bg-gray-100 text-gray-600'} hover:bg-purple-100 hover:text-purple-600 transition-colors`}
|
||||
title="顯示/隱藏分析"
|
||||
>
|
||||
<Tag size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className={`p-1.5 rounded-md ${showFilters ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-600'} hover:bg-blue-100 hover:text-blue-600 transition-colors`}
|
||||
title="篩選與排序"
|
||||
>
|
||||
<Filter size={16} />
|
||||
</button>
|
||||
<span className="bg-blue-100 text-blue-800 text-xs font-medium px-2.5 py-0.5 rounded">
|
||||
{filteredAndSortedComments.length} / {comments.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Analytics */}
|
||||
{showAnalytics && (
|
||||
<div className="bg-white rounded-lg shadow p-3 mb-4">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h3 className="text-sm font-medium text-gray-700">情緒分析</h3>
|
||||
<div className="flex items-center space-x-1">
|
||||
<span className="text-xs text-gray-500">總留言: {sentimentStats.total}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sentiment Distribution */}
|
||||
<div className="flex mb-2">
|
||||
<div
|
||||
className="bg-green-500 h-2 rounded-l-full"
|
||||
style={{ width: `${(sentimentStats.positive / sentimentStats.total) * 100}%` }}
|
||||
title={`正面: ${sentimentStats.positive} (${Math.round((sentimentStats.positive / sentimentStats.total) * 100)}%)`}
|
||||
></div>
|
||||
<div
|
||||
className="bg-gray-400 h-2"
|
||||
style={{ width: `${(sentimentStats.neutral / sentimentStats.total) * 100}%` }}
|
||||
title={`中性: ${sentimentStats.neutral} (${Math.round((sentimentStats.neutral / sentimentStats.total) * 100)}%)`}
|
||||
></div>
|
||||
<div
|
||||
className="bg-red-500 h-2 rounded-r-full"
|
||||
style={{ width: `${(sentimentStats.negative / sentimentStats.total) * 100}%` }}
|
||||
title={`負面: ${sentimentStats.negative} (${Math.round((sentimentStats.negative / sentimentStats.total) * 100)}%)`}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-xs text-gray-600 mb-3">
|
||||
<div className="flex items-center">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 mr-1"></div>
|
||||
<span>正面: {sentimentStats.positive}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="w-2 h-2 rounded-full bg-gray-400 mr-1"></div>
|
||||
<span>中性: {sentimentStats.neutral}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="w-2 h-2 rounded-full bg-red-500 mr-1"></div>
|
||||
<span>負面: {sentimentStats.negative}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Keywords */}
|
||||
<div className="mb-3">
|
||||
<h3 className="text-xs font-medium text-gray-700 mb-1">熱門關鍵詞</h3>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{topKeywords.slice(0, 8).map(({ keyword, count }) => (
|
||||
<span
|
||||
key={keyword}
|
||||
className="bg-blue-50 text-blue-700 text-xs px-2 py-0.5 rounded-full"
|
||||
title={`出現 ${count} 次`}
|
||||
>
|
||||
{keyword}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Categories */}
|
||||
<div>
|
||||
<h3 className="text-xs font-medium text-gray-700 mb-1">主要類別</h3>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{categories.slice(0, 5).map(({ category, count }) => (
|
||||
<span
|
||||
key={category}
|
||||
className="bg-purple-50 text-purple-700 text-xs px-2 py-0.5 rounded-full"
|
||||
title={`${count} 則留言`}
|
||||
>
|
||||
{category}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search and Filter Panel */}
|
||||
{showFilters && (
|
||||
<div className="bg-white rounded-lg shadow p-3 mb-4 space-y-3">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||
<Search size={14} className="text-gray-500" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="搜尋留言或作者..."
|
||||
className="w-full pl-9 pr-9 py-2 text-sm border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
onClick={() => setSearchTerm('')}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3"
|
||||
>
|
||||
<X size={14} className="text-gray-500 hover:text-gray-700" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{/* Platform Filter */}
|
||||
<div className="relative">
|
||||
<label className="block text-xs text-gray-700 mb-1">平台</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={platformFilter}
|
||||
onChange={(e) => setPlatformFilter(e.target.value)}
|
||||
className="w-full p-2 text-sm border border-gray-300 rounded-md appearance-none focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="all">全部平台</option>
|
||||
{platforms.map(platform => (
|
||||
<option key={platform} value={platform}>{platform}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<ChevronDown size={14} className="text-gray-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sentiment Filter */}
|
||||
<div className="relative">
|
||||
<label className="block text-xs text-gray-700 mb-1">情緒</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={sentimentFilter}
|
||||
onChange={(e) => setSentimentFilter(e.target.value as 'all' | 'positive' | 'neutral' | 'negative')}
|
||||
className="w-full p-2 text-sm border border-gray-300 rounded-md appearance-none focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="all">全部情緒</option>
|
||||
<option value="positive">正面</option>
|
||||
<option value="neutral">中性</option>
|
||||
<option value="negative">負面</option>
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<ChevronDown size={14} className="text-gray-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sort By */}
|
||||
<div className="relative col-span-2">
|
||||
<label className="block text-xs text-gray-700 mb-1">排序方式</label>
|
||||
<div className="relative">
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as 'newest' | 'oldest' | 'likes' | 'replies')}
|
||||
className="w-full p-2 text-sm border border-gray-300 rounded-md appearance-none focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="newest">最新留言</option>
|
||||
<option value="oldest">最舊留言</option>
|
||||
<option value="likes">讚數最多</option>
|
||||
<option value="replies">回覆最多</option>
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<SortDesc size={14} className="text-gray-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Stats */}
|
||||
{(searchTerm || platformFilter !== 'all' || sentimentFilter !== 'all') && (
|
||||
<div className="flex justify-between items-center pt-1 text-xs text-gray-500">
|
||||
<span>
|
||||
{filteredAndSortedComments.length === comments.length
|
||||
? '顯示全部留言'
|
||||
: `顯示 ${filteredAndSortedComments.length} 個符合條件的留言`}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchTerm('');
|
||||
setPlatformFilter('all');
|
||||
setSentimentFilter('all');
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
清除篩選
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comments List */}
|
||||
{filteredAndSortedComments.length === 0 ? (
|
||||
<div className="bg-gray-50 rounded-lg p-4 text-center">
|
||||
<p className="text-gray-600">沒有符合條件的留言</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchTerm('');
|
||||
setPlatformFilter('all');
|
||||
setSentimentFilter('all');
|
||||
}}
|
||||
className="mt-2 text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
清除篩選條件
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredAndSortedComments.map((comment) => (
|
||||
<div
|
||||
key={comment.id}
|
||||
className="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow cursor-pointer"
|
||||
onClick={() => onSelectComment(comment)}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div className="font-medium text-gray-900">{comment.author}</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center text-gray-500 text-xs">
|
||||
<Clock size={12} className="mr-1" />
|
||||
{comment.timestamp}
|
||||
</div>
|
||||
{comment.sentiment && (
|
||||
<div className={`flex items-center text-xs px-1.5 py-0.5 rounded-full ${getSentimentBadgeColor(comment.sentiment)}`}>
|
||||
{comment.sentiment === 'positive' && <Smile size={10} className="mr-1" />}
|
||||
{comment.sentiment === 'neutral' && <Meh size={10} className="mr-1" />}
|
||||
{comment.sentiment === 'negative' && <Frown size={10} className="mr-1" />}
|
||||
{getSentimentLabel(comment.sentiment)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-700 mb-3 line-clamp-2">{comment.content}</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{comment.keywords?.map(keyword => (
|
||||
<span key={keyword} className="bg-blue-50 text-blue-700 text-xs px-1.5 py-0.5 rounded">
|
||||
{keyword}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||
<div className="flex items-center">
|
||||
<ThumbsUp size={12} className="mr-1" />
|
||||
{comment.likes} 讚
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<MessageSquare size={12} className="mr-1" />
|
||||
{comment.replies?.length || 0} 回覆
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{comment.category && (
|
||||
<span className="px-1.5 py-0.5 rounded bg-purple-50 text-purple-700">
|
||||
{comment.category}
|
||||
</span>
|
||||
)}
|
||||
<span className="px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">
|
||||
{comment.platform}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to get sentiment badge colors
|
||||
function getSentimentBadgeColor(sentiment: string): string {
|
||||
switch (sentiment) {
|
||||
case 'positive':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'neutral':
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
case 'negative':
|
||||
return 'bg-red-100 text-red-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get sentiment labels
|
||||
function getSentimentLabel(sentiment: string): string {
|
||||
switch (sentiment) {
|
||||
case 'positive':
|
||||
return '正面';
|
||||
case 'neutral':
|
||||
return '中性';
|
||||
case 'negative':
|
||||
return '負面';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
}
|
||||
|
||||
export default CommentList;
|
||||
363
extension/src/sidebar/components/ReplyGenerator.tsx
Normal file
363
extension/src/sidebar/components/ReplyGenerator.tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ArrowLeft, Send, Copy, Check, Zap, User, Smile, Meh, Frown, Tag, ThumbsUp } from 'lucide-react';
|
||||
import { Comment, ReplyTone, ReplyPersona } from '../../types';
|
||||
|
||||
interface ReplyGeneratorProps {
|
||||
comment: Comment | null;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const ReplyGenerator: React.FC<ReplyGeneratorProps> = ({ comment, onBack }) => {
|
||||
const [selectedTone, setSelectedTone] = useState<string>('friendly');
|
||||
const [selectedPersona, setSelectedPersona] = useState<string>('brand');
|
||||
const [generatedReplies, setGeneratedReplies] = useState<string[]>([]);
|
||||
const [isGenerating, setIsGenerating] = useState<boolean>(false);
|
||||
const [copied, setCopied] = useState<boolean>(false);
|
||||
|
||||
const tones: ReplyTone[] = [
|
||||
{ id: 'friendly', name: '友善', description: '溫暖親切的語氣' },
|
||||
{ id: 'professional', name: '專業', description: '正式且專業的語氣' },
|
||||
{ id: 'casual', name: '輕鬆', description: '隨意輕鬆的對話風格' },
|
||||
{ id: 'enthusiastic', name: '熱情', description: '充滿活力與熱情' },
|
||||
{ id: 'empathetic', name: '同理心', description: '表達理解與關懷' }
|
||||
];
|
||||
|
||||
const personas: ReplyPersona[] = [
|
||||
{ id: 'brand', name: '品牌代表', description: '以品牌官方身份回覆' },
|
||||
{ id: 'support', name: '客服人員', description: '以客服專員身份回覆' },
|
||||
{ id: 'expert', name: '領域專家', description: '以專業人士身份回覆' },
|
||||
{ id: 'friend', name: '朋友', description: '以朋友身份回覆' }
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
// In development mode, we don't have access to chrome.storage
|
||||
// So we'll use a mock implementation
|
||||
const loadDefaultSettings = () => {
|
||||
try {
|
||||
// Check if we're in a Chrome extension environment
|
||||
if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.sync) {
|
||||
chrome.storage.sync.get(['defaultTone', 'defaultPersona'], (result) => {
|
||||
if (result.defaultTone) setSelectedTone(result.defaultTone);
|
||||
if (result.defaultPersona) setSelectedPersona(result.defaultPersona);
|
||||
});
|
||||
} else {
|
||||
// Mock storage for development environment
|
||||
console.log('Using mock storage for development');
|
||||
// Use default values or load from localStorage if needed
|
||||
const savedTone = localStorage.getItem('defaultTone');
|
||||
const savedPersona = localStorage.getItem('defaultPersona');
|
||||
|
||||
if (savedTone) setSelectedTone(savedTone);
|
||||
if (savedPersona) setSelectedPersona(savedPersona);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading settings:', error);
|
||||
// Continue with default values
|
||||
}
|
||||
};
|
||||
|
||||
loadDefaultSettings();
|
||||
}, []);
|
||||
|
||||
const generateReplies = () => {
|
||||
if (!comment) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
|
||||
// Simulate API call or processing delay
|
||||
setTimeout(() => {
|
||||
// Generate replies based on comment sentiment, category, and selected tone/persona
|
||||
let mockReplies: string[] = [];
|
||||
|
||||
// Base reply templates for different sentiments
|
||||
if (comment.sentiment === 'positive') {
|
||||
mockReplies = [
|
||||
`感謝您的正面評價!我們很高興您喜歡我們的產品/服務。您的支持是我們前進的動力。`,
|
||||
`非常感謝您的讚美!我們團隊一直致力於提供最好的體驗,很開心能得到您的認可。`,
|
||||
`謝謝您的美好評價!您的滿意是我們最大的成就,我們會繼續努力維持這樣的水準。`
|
||||
];
|
||||
} else if (comment.sentiment === 'negative') {
|
||||
mockReplies = [
|
||||
`非常抱歉給您帶來不便。我們非常重視您的反饋,並會立即處理您提到的問題。請問可以提供更多細節,以便我們更好地解決?`,
|
||||
`感謝您的坦誠反饋。我們對您的體驗感到遺憾,並承諾會改進。我們的團隊已經注意到這個問題,正在積極解決中。`,
|
||||
`您的意見對我們非常寶貴。對於您遇到的困難,我們深表歉意。請放心,我們會認真對待每一條反饋,並努力改進我們的產品和服務。`
|
||||
];
|
||||
} else {
|
||||
mockReplies = [
|
||||
`感謝您的留言!我們很樂意回答您的問題。請問還有什麼我們可以幫助您的嗎?`,
|
||||
`謝謝您的關注!您提出的問題很有價值,我們會盡快為您提供所需的信息。`,
|
||||
`感謝您的互動!我們非常重視您的每一個問題,並致力於提供最準確的回答。`
|
||||
];
|
||||
}
|
||||
|
||||
// Customize based on category if available
|
||||
if (comment.category) {
|
||||
// Add category-specific content to the replies
|
||||
mockReplies = mockReplies.map(reply => {
|
||||
switch (comment.category) {
|
||||
case '產品讚美':
|
||||
return reply + ` 我們不斷努力改進產品,您的鼓勵給了我們很大的動力。`;
|
||||
case '產品詢問':
|
||||
return reply + ` 關於產品的具體信息,我們建議您查看官網的產品說明頁面,或直接聯繫我們的客服團隊。`;
|
||||
case '產品問題':
|
||||
return reply + ` 我們的售後團隊將會與您聯繫,協助解決產品問題。您也可以撥打客服熱線獲取即時幫助。`;
|
||||
case '物流問題':
|
||||
return reply + ` 我們會立即與物流部門核實您的訂單狀態,並盡快給您回覆。`;
|
||||
case '價格問題':
|
||||
return reply + ` 關於價格的疑問,我們的銷售團隊將為您提供最詳細的解答和最優惠的方案。`;
|
||||
default:
|
||||
return reply;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Adjust tone based on selection
|
||||
mockReplies = mockReplies.map(reply => {
|
||||
switch (selectedTone) {
|
||||
case 'professional':
|
||||
return reply.replace(/感謝|謝謝/g, '非常感謝').replace(/!/g, '。');
|
||||
case 'casual':
|
||||
return reply.replace(/我們/g, '我們團隊').replace(/。/g, '~');
|
||||
case 'enthusiastic':
|
||||
return reply.replace(/!/g, '!!').replace(/謝謝/g, '非常感謝');
|
||||
case 'empathetic':
|
||||
return reply.replace(/感謝/g, '真誠感謝').replace(/我們理解/g, '我們完全理解');
|
||||
default:
|
||||
return reply;
|
||||
}
|
||||
});
|
||||
|
||||
// Adjust persona based on selection
|
||||
mockReplies = mockReplies.map(reply => {
|
||||
switch (selectedPersona) {
|
||||
case 'support':
|
||||
return `作為客服代表,${reply}`;
|
||||
case 'expert':
|
||||
return `以專業角度來看,${reply}`;
|
||||
case 'friend':
|
||||
return reply.replace(/我們/g, '我們').replace(/非常感謝/g, '超級感謝');
|
||||
default:
|
||||
return reply;
|
||||
}
|
||||
});
|
||||
|
||||
setGeneratedReplies(mockReplies);
|
||||
setIsGenerating(false);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
try {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy text: ', err);
|
||||
// Fallback method
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Copy to clipboard failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (!comment) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-center">
|
||||
<Send size={48} className="text-gray-400 mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-700">請先選擇一條留言</h3>
|
||||
<p className="text-gray-500 mt-2">
|
||||
從留言列表中選擇一條留言來生成回覆建議。
|
||||
</p>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
返回留言列表
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center mb-4">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="mr-2 p-1 rounded-full hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
<h2 className="text-lg font-semibold text-gray-800">生成回覆建議</h2>
|
||||
</div>
|
||||
|
||||
{/* Original Comment */}
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-4">
|
||||
<div className="flex items-start mb-2">
|
||||
<div className="bg-gray-200 rounded-full w-8 h-8 flex items-center justify-center mr-2">
|
||||
<User size={16} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{comment.author}</div>
|
||||
<div className="text-xs text-gray-500">{comment.platform} · {comment.timestamp}</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
{comment.sentiment && (
|
||||
<div className={`flex items-center text-xs px-1.5 py-0.5 rounded-full ${getSentimentBadgeColor(comment.sentiment)}`}>
|
||||
{comment.sentiment === 'positive' && <Smile size={10} className="mr-1" />}
|
||||
{comment.sentiment === 'neutral' && <Meh size={10} className="mr-1" />}
|
||||
{comment.sentiment === 'negative' && <Frown size={10} className="mr-1" />}
|
||||
{getSentimentLabel(comment.sentiment)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-700 mb-2">{comment.content}</p>
|
||||
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{comment.keywords?.map(keyword => (
|
||||
<span key={keyword} className="bg-blue-50 text-blue-700 text-xs px-1.5 py-0.5 rounded">
|
||||
{keyword}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center text-xs text-gray-500">
|
||||
<div className="flex items-center">
|
||||
<ThumbsUp size={12} className="mr-1" />
|
||||
{comment.likes} 讚
|
||||
</div>
|
||||
|
||||
{comment.category && (
|
||||
<span className="px-1.5 py-0.5 rounded bg-purple-50 text-purple-700">
|
||||
{comment.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tone Selection */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">選擇回覆語氣</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{tones.map(tone => (
|
||||
<button
|
||||
key={tone.id}
|
||||
onClick={() => setSelectedTone(tone.id)}
|
||||
className={`p-2 text-xs rounded-md text-center transition-colors ${
|
||||
selectedTone === tone.id
|
||||
? 'bg-blue-100 text-blue-700 border border-blue-300'
|
||||
: 'bg-white text-gray-700 border border-gray-200 hover:bg-gray-50'
|
||||
}`}
|
||||
title={tone.description}
|
||||
>
|
||||
{tone.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Persona Selection */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">選擇回覆身份</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{personas.map(persona => (
|
||||
<button
|
||||
key={persona.id}
|
||||
onClick={() => setSelectedPersona(persona.id)}
|
||||
className={`p-2 text-xs rounded-md text-center transition-colors ${
|
||||
selectedPersona === persona.id
|
||||
? 'bg-blue-100 text-blue-700 border border-blue-300'
|
||||
: 'bg-white text-gray-700 border border-gray-200 hover:bg-gray-50'
|
||||
}`}
|
||||
title={persona.description}
|
||||
>
|
||||
{persona.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Generate Button */}
|
||||
<button
|
||||
onClick={generateReplies}
|
||||
disabled={isGenerating}
|
||||
className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:bg-blue-300 flex items-center justify-center mb-4"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
生成中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap size={16} className="mr-2" />
|
||||
生成回覆建議
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Generated Replies */}
|
||||
{generatedReplies.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">回覆建議</h3>
|
||||
<div className="space-y-3">
|
||||
{generatedReplies.map((reply, index) => (
|
||||
<div key={index} className="bg-white rounded-lg shadow p-4 relative">
|
||||
<p className="text-gray-700 pr-8">{reply}</p>
|
||||
<button
|
||||
onClick={() => copyToClipboard(reply)}
|
||||
className="absolute top-3 right-3 p-1 rounded-full hover:bg-gray-100 transition-colors"
|
||||
title="複製到剪貼板"
|
||||
>
|
||||
{copied ? <Check size={16} className="text-green-600" /> : <Copy size={16} className="text-gray-500" />}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to get sentiment badge colors
|
||||
function getSentimentBadgeColor(sentiment: string): string {
|
||||
switch (sentiment) {
|
||||
case 'positive':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'neutral':
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
case 'negative':
|
||||
return 'bg-red-100 text-red-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get sentiment labels
|
||||
function getSentimentLabel(sentiment: string): string {
|
||||
switch (sentiment) {
|
||||
case 'positive':
|
||||
return '正面';
|
||||
case 'neutral':
|
||||
return '中性';
|
||||
case 'negative':
|
||||
return '負面';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
}
|
||||
|
||||
export default ReplyGenerator;
|
||||
239
extension/src/sidebar/components/Settings.tsx
Normal file
239
extension/src/sidebar/components/Settings.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Save, Settings as SettingsIcon } from 'lucide-react';
|
||||
import { ReplyTone, ReplyPersona, SettingsData } from '../../types';
|
||||
|
||||
const Settings: React.FC = () => {
|
||||
const [settings, setSettings] = useState<SettingsData>({
|
||||
defaultTone: 'friendly',
|
||||
defaultPersona: 'brand',
|
||||
autoDetectPlatform: true,
|
||||
language: 'zh-TW',
|
||||
maxComments: 50
|
||||
});
|
||||
|
||||
const [isSaving, setIsSaving] = useState<boolean>(false);
|
||||
const [saveSuccess, setSaveSuccess] = useState<boolean>(false);
|
||||
|
||||
const tones: ReplyTone[] = [
|
||||
{ id: 'friendly', name: '友善', description: '溫暖親切的語氣' },
|
||||
{ id: 'professional', name: '專業', description: '正式且專業的語氣' },
|
||||
{ id: 'casual', name: '輕鬆', description: '隨意輕鬆的對話風格' },
|
||||
{ id: 'enthusiastic', name: '熱情', description: '充滿活力與熱情' },
|
||||
{ id: 'empathetic', name: '同理心', description: '表達理解與關懷' }
|
||||
];
|
||||
|
||||
const personas: ReplyPersona[] = [
|
||||
{ id: 'brand', name: '品牌代表', description: '以品牌官方身份回覆' },
|
||||
{ id: 'support', name: '客服人員', description: '以客服專員身份回覆' },
|
||||
{ id: 'expert', name: '領域專家', description: '以專業人士身份回覆' },
|
||||
{ id: 'friend', name: '朋友', description: '以朋友身份回覆' }
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
// Load settings - with fallback for development environment
|
||||
const loadSettings = () => {
|
||||
try {
|
||||
if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.sync) {
|
||||
// We're in a Chrome extension environment
|
||||
chrome.storage.sync.get(['defaultTone', 'defaultPersona', 'autoDetectPlatform', 'language', 'maxComments'], (result) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
...result
|
||||
}));
|
||||
});
|
||||
} else {
|
||||
// We're in development mode - use localStorage
|
||||
console.log('Using localStorage for settings in development mode');
|
||||
const savedSettings = localStorage.getItem('commentAssistantSettings');
|
||||
if (savedSettings) {
|
||||
try {
|
||||
const parsedSettings = JSON.parse(savedSettings);
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
...parsedSettings
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Error parsing saved settings:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading settings:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLSelectElement | HTMLInputElement>) => {
|
||||
const { name, value, type } = e.target;
|
||||
|
||||
if (type === 'checkbox') {
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
[name]: checked
|
||||
}));
|
||||
} else {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const saveSettings = () => {
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.sync) {
|
||||
// We're in a Chrome extension environment
|
||||
chrome.storage.sync.set(settings, () => {
|
||||
setIsSaving(false);
|
||||
setSaveSuccess(true);
|
||||
|
||||
setTimeout(() => {
|
||||
setSaveSuccess(false);
|
||||
}, 2000);
|
||||
});
|
||||
} else {
|
||||
// We're in development mode - use localStorage
|
||||
localStorage.setItem('commentAssistantSettings', JSON.stringify(settings));
|
||||
|
||||
// Simulate async operation
|
||||
setTimeout(() => {
|
||||
setIsSaving(false);
|
||||
setSaveSuccess(true);
|
||||
|
||||
setTimeout(() => {
|
||||
setSaveSuccess(false);
|
||||
}, 2000);
|
||||
}, 500);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving settings:', error);
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center mb-4">
|
||||
<SettingsIcon size={20} className="mr-2 text-gray-700" />
|
||||
<h2 className="text-lg font-semibold text-gray-800">設置</h2>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-4">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">回覆設置</h3>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm text-gray-700 mb-1">預設回覆語氣</label>
|
||||
<select
|
||||
name="defaultTone"
|
||||
value={settings.defaultTone}
|
||||
onChange={handleChange}
|
||||
className="w-full p-2 border border-gray-300 rounded-md text-sm"
|
||||
>
|
||||
{tones.map(tone => (
|
||||
<option key={tone.id} value={tone.id}>
|
||||
{tone.name} - {tone.description}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm text-gray-700 mb-1">預設回覆身份</label>
|
||||
<select
|
||||
name="defaultPersona"
|
||||
value={settings.defaultPersona}
|
||||
onChange={handleChange}
|
||||
className="w-full p-2 border border-gray-300 rounded-md text-sm"
|
||||
>
|
||||
{personas.map(persona => (
|
||||
<option key={persona.id} value={persona.id}>
|
||||
{persona.name} - {persona.description}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-4">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">一般設置</h3>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="autoDetectPlatform"
|
||||
checked={settings.autoDetectPlatform}
|
||||
onChange={handleChange}
|
||||
className="h-4 w-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-700">自動檢測社交平台</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm text-gray-700 mb-1">語言</label>
|
||||
<select
|
||||
name="language"
|
||||
value={settings.language}
|
||||
onChange={handleChange}
|
||||
className="w-full p-2 border border-gray-300 rounded-md text-sm"
|
||||
>
|
||||
<option value="zh-TW">繁體中文</option>
|
||||
<option value="en-US">English</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm text-gray-700 mb-1">
|
||||
最大捕獲留言數量 ({settings.maxComments})
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
name="maxComments"
|
||||
min="10"
|
||||
max="100"
|
||||
step="10"
|
||||
value={settings.maxComments}
|
||||
onChange={handleChange}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500">
|
||||
<span>10</span>
|
||||
<span>50</span>
|
||||
<span>100</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={saveSettings}
|
||||
disabled={isSaving}
|
||||
className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:bg-blue-300 flex items-center justify-center"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
儲存中...
|
||||
</>
|
||||
) : saveSuccess ? (
|
||||
<>
|
||||
<Save size={16} className="mr-2" />
|
||||
已儲存!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save size={16} className="mr-2" />
|
||||
儲存設置
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
55
extension/src/sidebar/main.tsx
Normal file
55
extension/src/sidebar/main.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import Sidebar from './Sidebar';
|
||||
import '../index.css';
|
||||
import ErrorBoundary from '../ErrorBoundary';
|
||||
|
||||
// Error boundary for the sidebar
|
||||
const renderSidebar = () => {
|
||||
try {
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
console.error('Root element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
<Sidebar />
|
||||
</ErrorBoundary>
|
||||
</StrictMode>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error rendering sidebar:', error);
|
||||
|
||||
// Render a fallback UI in case of error
|
||||
const rootElement = document.getElementById('root');
|
||||
if (rootElement) {
|
||||
rootElement.innerHTML = `
|
||||
<div style="padding: 20px; color: #721c24; background-color: #f8d7da; border: 1px solid #f5c6cb; border-radius: 5px;">
|
||||
<h2>Sidebar Error</h2>
|
||||
<p>Sorry, something went wrong while loading the sidebar.</p>
|
||||
<p>Error details: ${error instanceof Error ? error.message : String(error)}</p>
|
||||
<button onclick="window.location.reload()" style="background: #dc3545; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer;">
|
||||
Reload Sidebar
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Disable Vite's error overlay to prevent WebSocket connection attempts
|
||||
window.addEventListener('error', (event) => {
|
||||
event.preventDefault();
|
||||
console.error('Caught error:', event.error);
|
||||
return true;
|
||||
});
|
||||
|
||||
// Disable Vite's HMR client
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.decline();
|
||||
}
|
||||
|
||||
renderSidebar();
|
||||
49
extension/src/types.ts
Normal file
49
extension/src/types.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export interface Comment {
|
||||
id: string;
|
||||
author: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
likes: number;
|
||||
replies?: Comment[];
|
||||
platform: 'facebook' | 'instagram' | 'twitter' | 'youtube' | 'linkedin' | 'other';
|
||||
sentiment?: 'positive' | 'neutral' | 'negative';
|
||||
keywords?: string[];
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export interface ReplyTone {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ReplyPersona {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface SettingsData {
|
||||
defaultTone: string;
|
||||
defaultPersona: string;
|
||||
autoDetectPlatform: boolean;
|
||||
language: 'zh-TW' | 'en-US';
|
||||
maxComments: number;
|
||||
}
|
||||
|
||||
export interface CommentFilter {
|
||||
searchTerm: string;
|
||||
platform: string;
|
||||
sortBy: 'newest' | 'oldest' | 'likes' | 'replies';
|
||||
sentiment?: 'positive' | 'neutral' | 'negative' | 'all';
|
||||
}
|
||||
|
||||
export interface CommentAnalytics {
|
||||
sentimentCounts: {
|
||||
positive: number;
|
||||
neutral: number;
|
||||
negative: number;
|
||||
};
|
||||
topKeywords: Array<{keyword: string, count: number}>;
|
||||
categories: Record<string, number>;
|
||||
}
|
||||
1
extension/src/vite-env.d.ts
vendored
Normal file
1
extension/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user