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

15
.env.promopt Normal file
View File

@@ -0,0 +1,15 @@
PORT=4000
SUPABASE_URL="xxx"
SUPABASE_KEY="xxx"
SUPABASE_ANON_KEY="xxx"
DATABASE_URL="xxx"
REDIS_HOST="localhost"
REDIS_PORT="6379"
REDIS_PASSWORD=""
DOMAIN="upj.to"
ENABLED_ROUTES=all
# Pulsar Connection
PULSAR_SERVICE_URL=pulsar://localhost:6650

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
docker/clickhouse/config.xml
docker/clickhouse/data
docker/clickhouse/users.xml

15
README.md Normal file
View File

@@ -0,0 +1,15 @@
本系統是一個社群平台成效管理系統,主要功能包括:
管理網紅文章、影片的成效數據
統計各個行銷活動的影響力,計算觀看數、按讚數等
追蹤留言者(當作網紅),並查詢其影響力數據
支援多平台YouTube, Instagram, TikTok, Facebook, Twitter
記錄網紅粉絲數、文章觀看數、按讚數變化
追蹤每篇文章的評論內容,並進行情感分析
提供 API 讓第三方應用存取分析結果

28
backend/.env Normal file
View File

@@ -0,0 +1,28 @@
PORT=4000
SUPABASE_URL="https://xtqhluzornazlmkonucr.supabase.co"
SUPABASE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inh0cWhsdXpvcm5hemxta29udWNyIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc0MTI0NDcxMywiZXhwIjoyMDU2ODIwNzEzfQ.E9lHlmdoqCZ9zYg0ammuW6ua-__tEEATCrwYv3-Th3I"
SUPABASE_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inh0cWhsdXpvcm5hemxta29udWNyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDEyNDQ3MTMsImV4cCI6MjA1NjgyMDcxM30.PUXbyHOgkvFE6T5fvCFTV7LJq-MbMkRtNdw2VoKJOAg"
DATABASE_URL="postgresql://postgres:KR$kH9fdwZAd@tdS@db.xtqhluzornazlmkonucr.supabase.co:5432/postgres"
REDIS_HOST="localhost"
REDIS_PORT="6379"
REDIS_PASSWORD=""
DOMAIN="upj.to"
ENABLED_ROUTES=all
# ClickHouse Configuration
CLICKHOUSE_HOST=localhost
CLICKHOUSE_PORT=8123
CLICKHOUSE_USER=admin
CLICKHOUSE_PASSWORD=your_secure_password
CLICKHOUSE_DATABASE=promote
# BullMQ Configuration
BULL_REDIS_HOST="localhost"
BULL_REDIS_PORT="6379"
BULL_REDIS_PASSWORD=""
# JWT Configuration
JWT_SECRET="your-jwt-secret-key"
JWT_EXPIRES_IN="7d"

28
backend/.env.example Normal file
View File

@@ -0,0 +1,28 @@
PORT=4000
SUPABASE_URL="your-supabase-url"
SUPABASE_KEY="your-supabase-key"
SUPABASE_ANON_KEY="your-supabase-anon-key"
DATABASE_URL="your-database-url"
REDIS_HOST="localhost"
REDIS_PORT="6379"
REDIS_PASSWORD=""
DOMAIN="upj.to"
ENABLED_ROUTES=all
# ClickHouse Configuration
CLICKHOUSE_HOST="localhost"
CLICKHOUSE_PORT="8123"
CLICKHOUSE_USER="admin"
CLICKHOUSE_PASSWORD="your_secure_password"
CLICKHOUSE_DATABASE="promote"
# BullMQ Configuration
BULL_REDIS_HOST="localhost"
BULL_REDIS_PORT="6379"
BULL_REDIS_PASSWORD=""
# JWT Configuration
JWT_SECRET="your-jwt-secret-key"
JWT_EXPIRES_IN="7d"

47
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,47 @@
# 依赖
node_modules/
pnpm-lock.yaml
package-lock.json
yarn.lock
# 构建输出
dist/
build/
# 环境配置
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# 日志
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# 系统文件
.DS_Store
Thumbs.db
# IDE 和编辑器
.idea/
.vscode/
*.sublime-project
*.sublime-workspace
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# 临时文件
.tmp/
.temp/
.cache/
# 覆盖率报告
coverage/

111
backend/README.md Normal file
View File

@@ -0,0 +1,111 @@
# Promote Backend API
Backend API for the Promote platform, built with Hono.js, Supabase, ClickHouse, Redis, and BullMQ.
## Features
- **Authentication**: JWT-based authentication with Supabase Auth
- **Analytics Tracking**: Track views, likes, and followers using ClickHouse
- **Caching**: Redis for fast API responses
- **Task Scheduling**: BullMQ for background processing
## Tech Stack
- **Framework**: [Hono.js](https://honojs.dev/)
- **Authentication**: [Supabase Auth](https://supabase.com/docs/guides/auth) + JWT
- **Database**:
- [Supabase (PostgreSQL)](https://supabase.com/) for main data
- [ClickHouse](https://clickhouse.com/) for analytics events
- **Caching**: [Redis](https://redis.io/)
- **Task Queue**: [BullMQ](https://docs.bullmq.io/)
## Getting Started
### Prerequisites
- Node.js 18+
- pnpm
- Redis
- ClickHouse
- Supabase account
### Installation
1. Clone the repository
2. Install dependencies:
```bash
cd backend
pnpm install
```
3. Create a `.env` file based on the `.env.example` file
4. Start the development server:
```bash
pnpm dev
```
## API Endpoints
### Authentication
- `POST /api/auth/register` - Register a new user
- `POST /api/auth/login` - Login a user
- `GET /api/auth/verify` - Verify a token
### Analytics
- `POST /api/analytics/view` - Track a view event
- `POST /api/analytics/like` - Track a like/unlike event
- `POST /api/analytics/follow` - Track a follow/unfollow event
- `GET /api/analytics/content/:id` - Get analytics for a content
- `GET /api/analytics/user/:id` - Get analytics for a user
## Development
### Build
```bash
pnpm build
```
### Start Production Server
```bash
pnpm start
```
### Linting
```bash
pnpm lint
```
### Testing
```bash
pnpm test
```
## Project Structure
```
backend/
├── src/
│ ├── config/ # Configuration files
│ ├── controllers/ # Route controllers
│ ├── middlewares/ # Middleware functions
│ ├── models/ # Data models
│ ├── routes/ # API routes
│ ├── services/ # Business logic
│ ├── utils/ # Utility functions
│ └── index.ts # Entry point
├── .env # Environment variables
├── package.json # Dependencies and scripts
└── tsconfig.json # TypeScript configuration
```
## License
This project is licensed under the ISC License.

51
backend/dist/config/index.js vendored Normal file
View File

@@ -0,0 +1,51 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.config = void 0;
const dotenv_1 = __importDefault(require("dotenv"));
const path_1 = require("path");
// Load environment variables from .env file
dotenv_1.default.config({ path: (0, path_1.join)(__dirname, '../../.env') });
exports.config = {
port: process.env.PORT || 4000,
// Supabase configuration
supabase: {
url: process.env.SUPABASE_URL || '',
key: process.env.SUPABASE_KEY || '',
anonKey: process.env.SUPABASE_ANON_KEY || '',
},
// Redis configuration
redis: {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379', 10),
password: process.env.REDIS_PASSWORD || '',
},
// ClickHouse configuration
clickhouse: {
host: process.env.CLICKHOUSE_HOST || 'localhost',
port: process.env.CLICKHOUSE_PORT || '8123',
user: process.env.CLICKHOUSE_USER || 'admin',
password: process.env.CLICKHOUSE_PASSWORD || 'your_secure_password',
database: process.env.CLICKHOUSE_DATABASE || 'promote',
},
// BullMQ configuration
bull: {
redis: {
host: process.env.BULL_REDIS_HOST || 'localhost',
port: parseInt(process.env.BULL_REDIS_PORT || '6379', 10),
password: process.env.BULL_REDIS_PASSWORD || '',
},
},
// JWT configuration
jwt: {
secret: process.env.JWT_SECRET || 'your-secret-key',
expiresIn: process.env.JWT_EXPIRES_IN || '7d',
},
// Domain configuration
domain: process.env.DOMAIN || 'upj.to',
// Enabled routes
enabledRoutes: process.env.ENABLED_ROUTES || 'all',
};
exports.default = exports.config;

View File

@@ -0,0 +1,111 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.deleteComment = exports.createComment = exports.getComments = void 0;
const supabase_1 = __importDefault(require("../utils/supabase"));
const getComments = async (c) => {
try {
const { post_id, limit = '10', offset = '0' } = c.req.query();
let query;
if (post_id) {
// 获取特定帖子的评论
query = supabase_1.default.rpc('get_comments_for_post', { post_id_param: post_id });
}
else {
// 获取所有评论
query = supabase_1.default.rpc('get_comments_with_posts');
}
// 应用分页
query = query.range(Number(offset), Number(offset) + Number(limit) - 1);
const { data: comments, error, count } = await query;
if (error) {
return c.json({ error: error.message }, 500);
}
return c.json({
comments,
count,
limit: Number(limit),
offset: Number(offset)
});
}
catch (error) {
return c.json({ error: 'Internal server error' }, 500);
}
};
exports.getComments = getComments;
const createComment = async (c) => {
try {
const { post_id, content } = await c.req.json();
const user_id = c.get('user')?.id;
if (!user_id) {
return c.json({ error: 'Unauthorized' }, 401);
}
const { data: comment, error } = await supabase_1.default
.from('comments')
.insert({
post_id,
content,
user_id
})
.select(`
comment_id,
content,
sentiment_score,
created_at,
updated_at,
post_id,
user_id
`)
.single();
if (error) {
return c.json({ error: error.message }, 500);
}
// 获取用户信息
const { data: userProfile, error: userError } = await supabase_1.default
.from('user_profiles')
.select('id, full_name, avatar_url')
.eq('id', user_id)
.single();
if (!userError && userProfile) {
comment.user_profile = userProfile;
}
return c.json(comment, 201);
}
catch (error) {
return c.json({ error: 'Internal server error' }, 500);
}
};
exports.createComment = createComment;
const deleteComment = async (c) => {
try {
const { comment_id } = c.req.param();
const user_id = c.get('user')?.id;
if (!user_id) {
return c.json({ error: 'Unauthorized' }, 401);
}
// Check if the comment belongs to the user
const { data: comment, error: fetchError } = await supabase_1.default
.from('comments')
.select()
.eq('comment_id', comment_id)
.eq('user_id', user_id)
.single();
if (fetchError || !comment) {
return c.json({ error: 'Comment not found or unauthorized' }, 404);
}
const { error: deleteError } = await supabase_1.default
.from('comments')
.delete()
.eq('comment_id', comment_id);
if (deleteError) {
return c.json({ error: deleteError.message }, 500);
}
return c.body(null, 204);
}
catch (error) {
return c.json({ error: 'Internal server error' }, 500);
}
};
exports.deleteComment = deleteComment;

View File

@@ -0,0 +1,116 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.getInfluencerStats = exports.getInfluencerById = exports.getInfluencers = void 0;
const supabase_1 = __importDefault(require("../utils/supabase"));
const getInfluencers = async (c) => {
try {
const { platform, limit = '10', offset = '0', min_followers, max_followers, sort_by = 'followers_count', sort_order = 'desc' } = c.req.query();
let query = supabase_1.default
.from('influencers')
.select(`
influencer_id,
name,
platform,
profile_url,
followers_count,
video_count,
platform_count,
created_at,
updated_at
`);
// Apply filters
if (platform) {
query = query.eq('platform', platform);
}
if (min_followers) {
query = query.gte('followers_count', Number(min_followers));
}
if (max_followers) {
query = query.lte('followers_count', Number(max_followers));
}
// Apply sorting
if (sort_by && ['followers_count', 'video_count', 'created_at'].includes(sort_by)) {
query = query.order(sort_by, { ascending: sort_order === 'asc' });
}
// Apply pagination
query = query.range(Number(offset), Number(offset) + Number(limit) - 1);
const { data: influencers, error, count } = await query;
if (error) {
return c.json({ error: error.message }, 500);
}
return c.json({
influencers,
count,
limit: Number(limit),
offset: Number(offset)
});
}
catch (error) {
return c.json({ error: 'Internal server error' }, 500);
}
};
exports.getInfluencers = getInfluencers;
const getInfluencerById = async (c) => {
try {
const { influencer_id } = c.req.param();
const { data: influencer, error } = await supabase_1.default
.from('influencers')
.select(`
influencer_id,
name,
platform,
profile_url,
followers_count,
video_count,
platform_count,
created_at,
updated_at,
posts (
post_id,
title,
description,
published_at
)
`)
.eq('influencer_id', influencer_id)
.single();
if (error) {
return c.json({ error: 'Influencer not found' }, 404);
}
return c.json(influencer);
}
catch (error) {
return c.json({ error: 'Internal server error' }, 500);
}
};
exports.getInfluencerById = getInfluencerById;
const getInfluencerStats = async (c) => {
try {
const { platform } = c.req.query();
let query = supabase_1.default
.from('influencers')
.select('platform, followers_count, video_count');
if (platform) {
query = query.eq('platform', platform);
}
const { data: stats, error } = await query;
if (error) {
return c.json({ error: error.message }, 500);
}
const aggregatedStats = {
total_influencers: stats.length,
total_followers: stats.reduce((sum, item) => sum + (item.followers_count || 0), 0),
total_videos: stats.reduce((sum, item) => sum + (item.video_count || 0), 0),
average_followers: Math.round(stats.reduce((sum, item) => sum + (item.followers_count || 0), 0) / (stats.length || 1)),
average_videos: Math.round(stats.reduce((sum, item) => sum + (item.video_count || 0), 0) / (stats.length || 1))
};
return c.json(aggregatedStats);
}
catch (error) {
return c.json({ error: 'Internal server error' }, 500);
}
};
exports.getInfluencerStats = getInfluencerStats;

163
backend/dist/index.js vendored Normal file
View File

@@ -0,0 +1,163 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const node_server_1 = require("@hono/node-server");
const hono_1 = require("hono");
const cors_1 = require("hono/cors");
const logger_1 = require("hono/logger");
const config_1 = __importDefault(require("./config"));
const auth_1 = __importDefault(require("./routes/auth"));
const analytics_1 = __importDefault(require("./routes/analytics"));
const community_1 = __importDefault(require("./routes/community"));
const posts_1 = __importDefault(require("./routes/posts"));
const projectComments_1 = __importDefault(require("./routes/projectComments"));
const comments_1 = __importDefault(require("./routes/comments"));
const influencers_1 = __importDefault(require("./routes/influencers"));
const redis_1 = require("./utils/redis");
const clickhouse_1 = require("./utils/clickhouse");
const queue_1 = require("./utils/queue");
const initDatabase_1 = require("./utils/initDatabase");
const swagger_1 = require("./swagger");
// Create Hono app
const app = new hono_1.Hono();
// Middleware
app.use('*', (0, logger_1.logger)());
app.use('*', (0, cors_1.cors)({
origin: '*',
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
exposeHeaders: ['Content-Length'],
maxAge: 86400,
}));
// Health check route
app.get('/', (c) => {
return c.json({
status: 'ok',
message: 'Promote API is running',
version: '1.0.0',
});
});
// 数据库初始化路由
app.post('/api/admin/init-db', async (c) => {
try {
const result = await (0, initDatabase_1.initDatabase)();
return c.json({
success: result,
message: result ? 'Database initialized successfully' : 'Database initialization failed'
});
}
catch (error) {
console.error('Error initializing database:', error);
return c.json({
success: false,
message: 'Error initializing database',
error: error instanceof Error ? error.message : String(error)
}, 500);
}
});
// 创建测试数据路由
app.post('/api/admin/create-sample-data', async (c) => {
try {
const result = await (0, initDatabase_1.createSampleData)();
return c.json({
success: result,
message: result ? 'Sample data created successfully' : 'Sample data creation failed'
});
}
catch (error) {
console.error('Error creating sample data:', error);
return c.json({
success: false,
message: 'Error creating sample data',
error: error instanceof Error ? error.message : String(error)
}, 500);
}
});
// Routes
app.route('/api/auth', auth_1.default);
app.route('/api/analytics', analytics_1.default);
app.route('/api/community', community_1.default);
app.route('/api/posts', posts_1.default);
app.route('/api/project-comments', projectComments_1.default);
app.route('/api/comments', comments_1.default);
app.route('/api/influencers', influencers_1.default);
// Swagger UI
const swaggerApp = (0, swagger_1.createSwaggerUI)();
app.route('', swaggerApp);
// Initialize services and start server
const startServer = async () => {
try {
// Connect to Redis
try {
await (0, redis_1.connectRedis)();
console.log('Connected to Redis');
}
catch (error) {
console.error('Failed to connect to Redis:', error);
console.log('Continuing with mock Redis client...');
}
// Initialize ClickHouse
try {
await (0, clickhouse_1.initClickHouse)();
console.log('ClickHouse initialized');
}
catch (error) {
console.error('Failed to initialize ClickHouse:', error);
console.log('Continuing with limited analytics functionality...');
}
// 检查数据库连接,但不自动初始化或修改数据库
try {
await (0, initDatabase_1.checkDatabaseConnection)();
}
catch (error) {
console.error('Database connection check failed:', error);
console.log('Some features may not work correctly if database is not properly set up');
}
console.log('NOTICE: Database will NOT be automatically initialized on startup');
console.log('Use /api/admin/init-db endpoint to manually initialize the database if needed');
// Initialize BullMQ workers
let workers;
try {
workers = (0, queue_1.initWorkers)();
console.log('BullMQ workers initialized');
}
catch (error) {
console.error('Failed to initialize BullMQ workers:', error);
console.log('Background processing will not be available...');
workers = { analyticsWorker: null, notificationsWorker: null };
}
// Start server
const port = Number(config_1.default.port);
console.log(`Server starting on port ${port}...`);
(0, node_server_1.serve)({
fetch: app.fetch,
port,
});
console.log(`Server running at http://localhost:${port}`);
console.log(`Swagger UI available at http://localhost:${port}/swagger`);
console.log(`Initialize database at http://localhost:${port}/api/admin/init-db (POST)`);
console.log(`Create sample data at http://localhost:${port}/api/admin/create-sample-data (POST)`);
// Handle graceful shutdown
const shutdown = async () => {
console.log('Shutting down server...');
// Close workers if they exist
if (workers.analyticsWorker) {
await workers.analyticsWorker.close();
}
if (workers.notificationsWorker) {
await workers.notificationsWorker.close();
}
process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}
catch (error) {
console.error('Failed to start server:', error);
process.exit(1);
}
};
// Start the server
startServer();

85
backend/dist/middlewares/auth.js vendored Normal file
View File

@@ -0,0 +1,85 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.verifySupabaseToken = exports.generateToken = exports.authMiddleware = void 0;
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
const config_1 = __importDefault(require("../config"));
const supabase_1 = __importDefault(require("../utils/supabase"));
// Middleware to verify JWT token
const authMiddleware = async (c, next) => {
try {
// Get authorization header
const authHeader = c.req.header('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return c.json({ error: 'Unauthorized: No token provided' }, 401);
}
// Extract token
const token = authHeader.split(' ')[1];
try {
// 验证 JWT token
const decoded = jsonwebtoken_1.default.verify(token, config_1.default.jwt.secret);
// 特殊处理 Swagger 测试 token
if (decoded.sub === 'swagger-test-user' && decoded.email === 'swagger@test.com') {
// 为 Swagger 测试设置一个模拟用户
c.set('user', {
id: 'swagger-test-user',
email: 'swagger@test.com',
name: 'Swagger Test User'
});
// 继续到下一个中间件或路由处理器
await next();
return;
}
// 设置用户信息到上下文
c.set('user', {
id: decoded.sub,
email: decoded.email
});
// 继续到下一个中间件或路由处理器
await next();
}
catch (jwtError) {
if (jwtError instanceof jsonwebtoken_1.default.JsonWebTokenError) {
return c.json({ error: 'Unauthorized: Invalid token' }, 401);
}
if (jwtError instanceof jsonwebtoken_1.default.TokenExpiredError) {
return c.json({ error: 'Unauthorized: Token expired' }, 401);
}
throw jwtError;
}
}
catch (error) {
console.error('Auth middleware error:', error);
return c.json({ error: 'Internal server error' }, 500);
}
};
exports.authMiddleware = authMiddleware;
// Generate JWT token
const generateToken = (userId, email) => {
const secret = config_1.default.jwt.secret;
const expiresIn = config_1.default.jwt.expiresIn;
return jsonwebtoken_1.default.sign({
sub: userId,
email,
}, secret, {
expiresIn,
});
};
exports.generateToken = generateToken;
// Verify Supabase token
const verifySupabaseToken = async (token) => {
try {
const { data, error } = await supabase_1.default.auth.getUser(token);
if (error || !data.user) {
return null;
}
return data.user;
}
catch (error) {
console.error('Supabase token verification error:', error);
return null;
}
};
exports.verifySupabaseToken = verifySupabaseToken;

453
backend/dist/routes/analytics.js vendored Normal file
View File

@@ -0,0 +1,453 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const hono_1 = require("hono");
const auth_1 = require("../middlewares/auth");
const clickhouse_1 = __importDefault(require("../utils/clickhouse"));
const queue_1 = require("../utils/queue");
const redis_1 = require("../utils/redis");
const supabase_1 = __importDefault(require("../utils/supabase"));
const analyticsRouter = new hono_1.Hono();
// Apply auth middleware to all routes
analyticsRouter.use('*', auth_1.authMiddleware);
// Track a view event
analyticsRouter.post('/view', async (c) => {
try {
const { content_id } = await c.req.json();
const user = c.get('user');
if (!content_id) {
return c.json({ error: 'Content ID is required' }, 400);
}
// Get IP and user agent
const ip = c.req.header('x-forwarded-for') || c.req.header('x-real-ip') || '0.0.0.0';
const userAgent = c.req.header('user-agent') || 'unknown';
// Insert view event into ClickHouse
await clickhouse_1.default.query({
query: `
INSERT INTO promote.view_events (user_id, content_id, ip, user_agent)
VALUES (?, ?, ?, ?)
`,
values: [
user.id,
content_id,
ip,
userAgent
]
});
// Queue analytics processing job
await (0, queue_1.addAnalyticsJob)('process_views', {
user_id: user.id,
content_id,
timestamp: new Date().toISOString()
});
// Increment view count in Redis cache
const redis = await (0, redis_1.getRedisClient)();
await redis.incr(`views:${content_id}`);
return c.json({ message: 'View tracked successfully' });
}
catch (error) {
console.error('View tracking error:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// Track a like event
analyticsRouter.post('/like', async (c) => {
try {
const { content_id, action } = await c.req.json();
const user = c.get('user');
if (!content_id || !action) {
return c.json({ error: 'Content ID and action are required' }, 400);
}
if (action !== 'like' && action !== 'unlike') {
return c.json({ error: 'Action must be either "like" or "unlike"' }, 400);
}
// Insert like event into ClickHouse
await clickhouse_1.default.query({
query: `
INSERT INTO promote.like_events (user_id, content_id, action)
VALUES (?, ?, ?)
`,
values: [
user.id,
content_id,
action === 'like' ? 1 : 2
]
});
// Queue analytics processing job
await (0, queue_1.addAnalyticsJob)('process_likes', {
user_id: user.id,
content_id,
action,
timestamp: new Date().toISOString()
});
// Update like count in Redis cache
const redis = await (0, redis_1.getRedisClient)();
const likeKey = `likes:${content_id}`;
if (action === 'like') {
await redis.incr(likeKey);
}
else {
await redis.decr(likeKey);
}
return c.json({ message: `${action} tracked successfully` });
}
catch (error) {
console.error('Like tracking error:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// Track a follow event
analyticsRouter.post('/follow', async (c) => {
try {
const { followed_id, action } = await c.req.json();
const user = c.get('user');
if (!followed_id || !action) {
return c.json({ error: 'Followed ID and action are required' }, 400);
}
if (action !== 'follow' && action !== 'unfollow') {
return c.json({ error: 'Action must be either "follow" or "unfollow"' }, 400);
}
// Insert follower event into ClickHouse
await clickhouse_1.default.query({
query: `
INSERT INTO promote.follower_events (follower_id, followed_id, action)
VALUES (?, ?, ?)
`,
values: [
user.id,
followed_id,
action === 'follow' ? 1 : 2
]
});
// Queue analytics processing job
await (0, queue_1.addAnalyticsJob)('process_followers', {
follower_id: user.id,
followed_id,
action,
timestamp: new Date().toISOString()
});
// Update follower count in Redis cache
const redis = await (0, redis_1.getRedisClient)();
const followerKey = `followers:${followed_id}`;
if (action === 'follow') {
await redis.incr(followerKey);
}
else {
await redis.decr(followerKey);
}
return c.json({ message: `${action} tracked successfully` });
}
catch (error) {
console.error('Follow tracking error:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// Get analytics for a content
analyticsRouter.get('/content/:id', async (c) => {
try {
const contentId = c.req.param('id');
// Get counts from Redis cache
const redis = await (0, redis_1.getRedisClient)();
const [views, likes] = await Promise.all([
redis.get(`views:${contentId}`),
redis.get(`likes:${contentId}`)
]);
return c.json({
content_id: contentId,
views: parseInt(views || '0'),
likes: parseInt(likes || '0')
});
}
catch (error) {
console.error('Content analytics error:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// Get analytics for a user
analyticsRouter.get('/user/:id', async (c) => {
try {
const userId = c.req.param('id');
// Get follower count from Redis cache
const redis = await (0, redis_1.getRedisClient)();
const followers = await redis.get(`followers:${userId}`);
// Get content view and like counts from ClickHouse
const viewsResult = await clickhouse_1.default.query({
query: `
SELECT content_id, COUNT(*) as view_count
FROM promote.view_events
WHERE user_id = ?
GROUP BY content_id
`,
values: [userId]
});
const likesResult = await clickhouse_1.default.query({
query: `
SELECT content_id, SUM(CASE WHEN action = 1 THEN 1 ELSE -1 END) as like_count
FROM promote.like_events
WHERE user_id = ?
GROUP BY content_id
`,
values: [userId]
});
// Extract data from results
const viewsData = 'rows' in viewsResult ? viewsResult.rows : [];
const likesData = 'rows' in likesResult ? likesResult.rows : [];
return c.json({
user_id: userId,
followers: parseInt(followers || '0'),
content_analytics: {
views: viewsData,
likes: likesData
}
});
}
catch (error) {
console.error('User analytics error:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 社群分析相关路由
// 获取项目的顶级影响者
analyticsRouter.get('/project/:id/top-influencers', async (c) => {
try {
const projectId = c.req.param('id');
// 从ClickHouse查询项目的顶级影响者
const result = await clickhouse_1.default.query({
query: `
SELECT
influencer_id,
SUM(metric_value) AS total_views
FROM events
WHERE
project_id = ? AND
event_type = 'post_view_change'
GROUP BY influencer_id
ORDER BY total_views DESC
LIMIT 10
`,
values: [projectId]
});
// 提取数据
const influencerData = 'rows' in result ? result.rows : [];
// 如果有数据从Supabase获取影响者详细信息
if (influencerData.length > 0) {
const influencerIds = influencerData.map((item) => item.influencer_id);
const { data: influencerDetails, error } = await supabase_1.default
.from('influencers')
.select('influencer_id, name, platform, followers_count, video_count')
.in('influencer_id', influencerIds);
if (error) {
console.error('Error fetching influencer details:', error);
return c.json({ error: 'Error fetching influencer details' }, 500);
}
// 合并数据
const enrichedData = influencerData.map((item) => {
const details = influencerDetails?.find((detail) => detail.influencer_id === item.influencer_id) || {};
return {
...item,
...details
};
});
return c.json(enrichedData);
}
return c.json(influencerData);
}
catch (error) {
console.error('Error fetching top influencers:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 获取影响者的粉丝变化趋势过去6个月
analyticsRouter.get('/influencer/:id/follower-trend', async (c) => {
try {
const influencerId = c.req.param('id');
// 从ClickHouse查询影响者的粉丝变化趋势
const result = await clickhouse_1.default.query({
query: `
SELECT
toStartOfMonth(timestamp) AS month,
SUM(metric_value) AS follower_change
FROM events
WHERE
influencer_id = ? AND
event_type = 'follower_change' AND
timestamp >= subtractMonths(now(), 6)
GROUP BY month
ORDER BY month ASC
`,
values: [influencerId]
});
// 提取数据
const trendData = 'rows' in result ? result.rows : [];
return c.json({
influencer_id: influencerId,
follower_trend: trendData
});
}
catch (error) {
console.error('Error fetching follower trend:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 获取帖子的点赞变化过去30天
analyticsRouter.get('/post/:id/like-trend', async (c) => {
try {
const postId = c.req.param('id');
// 从ClickHouse查询帖子的点赞变化
const result = await clickhouse_1.default.query({
query: `
SELECT
toDate(timestamp) AS day,
SUM(metric_value) AS like_change
FROM events
WHERE
post_id = ? AND
event_type = 'post_like_change' AND
timestamp >= subtractDays(now(), 30)
GROUP BY day
ORDER BY day ASC
`,
values: [postId]
});
// 提取数据
const trendData = 'rows' in result ? result.rows : [];
return c.json({
post_id: postId,
like_trend: trendData
});
}
catch (error) {
console.error('Error fetching like trend:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 获取影响者详细信息
analyticsRouter.get('/influencer/:id/details', async (c) => {
try {
const influencerId = c.req.param('id');
// 从Supabase获取影响者详细信息
const { data, error } = await supabase_1.default
.from('influencers')
.select('influencer_id, name, platform, profile_url, external_id, followers_count, video_count, platform_count, created_at')
.eq('influencer_id', influencerId)
.single();
if (error) {
console.error('Error fetching influencer details:', error);
return c.json({ error: 'Error fetching influencer details' }, 500);
}
if (!data) {
return c.json({ error: 'Influencer not found' }, 404);
}
return c.json(data);
}
catch (error) {
console.error('Error fetching influencer details:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 获取影响者的帖子列表
analyticsRouter.get('/influencer/:id/posts', async (c) => {
try {
const influencerId = c.req.param('id');
// 从Supabase获取影响者的帖子列表
const { data, error } = await supabase_1.default
.from('posts')
.select('post_id, influencer_id, platform, post_url, title, description, published_at, created_at')
.eq('influencer_id', influencerId)
.order('published_at', { ascending: false });
if (error) {
console.error('Error fetching influencer posts:', error);
return c.json({ error: 'Error fetching influencer posts' }, 500);
}
return c.json(data || []);
}
catch (error) {
console.error('Error fetching influencer posts:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 获取帖子的评论列表
analyticsRouter.get('/post/:id/comments', async (c) => {
try {
const postId = c.req.param('id');
// 从Supabase获取帖子的评论列表
const { data, error } = await supabase_1.default
.from('comments')
.select('comment_id, post_id, user_id, content, sentiment_score, created_at')
.eq('post_id', postId)
.order('created_at', { ascending: false });
if (error) {
console.error('Error fetching post comments:', error);
return c.json({ error: 'Error fetching post comments' }, 500);
}
return c.json(data || []);
}
catch (error) {
console.error('Error fetching post comments:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 获取项目的平台分布
analyticsRouter.get('/project/:id/platform-distribution', async (c) => {
try {
const projectId = c.req.param('id');
// 从ClickHouse查询项目的平台分布
const result = await clickhouse_1.default.query({
query: `
SELECT
platform,
COUNT(DISTINCT influencer_id) AS influencer_count
FROM events
WHERE project_id = ?
GROUP BY platform
ORDER BY influencer_count DESC
`,
values: [projectId]
});
// 提取数据
const distributionData = 'rows' in result ? result.rows : [];
return c.json({
project_id: projectId,
platform_distribution: distributionData
});
}
catch (error) {
console.error('Error fetching platform distribution:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 获取项目的互动类型分布
analyticsRouter.get('/project/:id/interaction-types', async (c) => {
try {
const projectId = c.req.param('id');
// 从ClickHouse查询项目的互动类型分布
const result = await clickhouse_1.default.query({
query: `
SELECT
event_type,
COUNT(*) AS event_count,
SUM(metric_value) AS total_value
FROM events
WHERE
project_id = ? AND
event_type IN ('click', 'comment', 'share')
GROUP BY event_type
ORDER BY event_count DESC
`,
values: [projectId]
});
// 提取数据
const interactionData = 'rows' in result ? result.rows : [];
return c.json({
project_id: projectId,
interaction_types: interactionData
});
}
catch (error) {
console.error('Error fetching interaction types:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
exports.default = analyticsRouter;

140
backend/dist/routes/auth.js vendored Normal file
View File

@@ -0,0 +1,140 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const hono_1 = require("hono");
const auth_1 = require("../middlewares/auth");
const supabase_1 = __importDefault(require("../utils/supabase"));
const authRouter = new hono_1.Hono();
// Register a new user
authRouter.post('/register', async (c) => {
try {
const { email, password, name } = await c.req.json();
// Validate input
if (!email || !password || !name) {
return c.json({ error: 'Email, password, and name are required' }, 400);
}
// Register user with Supabase
const { data: authData, error: authError } = await supabase_1.default.auth.signUp({
email,
password,
});
if (authError) {
return c.json({ error: authError.message }, 400);
}
if (!authData.user) {
return c.json({ error: 'Failed to create user' }, 500);
}
// Create user profile in the database
const { error: profileError } = await supabase_1.default
.from('users')
.insert({
id: authData.user.id,
email: authData.user.email,
name,
created_at: new Date().toISOString(),
});
if (profileError) {
// Attempt to clean up the auth user if profile creation fails
await supabase_1.default.auth.admin.deleteUser(authData.user.id);
return c.json({ error: profileError.message }, 500);
}
// Generate JWT token
const token = (0, auth_1.generateToken)(authData.user.id, authData.user.email);
return c.json({
message: 'User registered successfully',
user: {
id: authData.user.id,
email: authData.user.email,
name,
},
token,
}, 201);
}
catch (error) {
console.error('Registration error:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// Login user
authRouter.post('/login', async (c) => {
try {
const { email, password } = await c.req.json();
const { data, error } = await supabase_1.default.auth.signInWithPassword({
email,
password
});
if (error) {
return c.json({ error: error.message }, 400);
}
// 使用与 authMiddleware 一致的方式创建 JWT
const token = (0, auth_1.generateToken)(data.user.id, data.user.email || '');
// 只返回必要的用户信息和令牌
return c.json({
success: true,
token,
user: {
id: data.user.id,
email: data.user.email
}
});
}
catch (error) {
console.error(error);
return c.json({ error: 'Server error' }, 500);
}
});
// Verify token
authRouter.get('/verify', async (c) => {
try {
const token = c.req.header('Authorization')?.split(' ')[1];
if (!token) {
return c.json({ error: 'No token provided' }, 401);
}
const user = await (0, auth_1.verifySupabaseToken)(token);
if (!user) {
return c.json({ error: 'Invalid token' }, 401);
}
return c.json({
message: 'Token is valid',
user: {
id: user.id,
email: user.email,
},
});
}
catch (error) {
console.error('Token verification error:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// Refresh token
authRouter.post('/refresh-token', async (c) => {
try {
const token = c.req.header('Authorization')?.split(' ')[1];
if (!token) {
return c.json({ error: 'No token provided' }, 401);
}
// 验证当前token
const user = await (0, auth_1.verifySupabaseToken)(token);
if (!user) {
return c.json({ error: 'Invalid token' }, 401);
}
// 生成新token
const newToken = (0, auth_1.generateToken)(user.id, user.email || '');
return c.json({
message: 'Token refreshed successfully',
token: newToken,
user: {
id: user.id,
email: user.email,
},
});
}
catch (error) {
console.error('Token refresh error:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
exports.default = authRouter;

12
backend/dist/routes/comments.js vendored Normal file
View File

@@ -0,0 +1,12 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const hono_1 = require("hono");
const commentsController_1 = require("../controllers/commentsController");
const auth_1 = require("../middlewares/auth");
const commentsRouter = new hono_1.Hono();
// Public routes
commentsRouter.get('/', commentsController_1.getComments);
// Protected routes
commentsRouter.post('/', auth_1.authMiddleware, commentsController_1.createComment);
commentsRouter.delete('/:comment_id', auth_1.authMiddleware, commentsController_1.deleteComment);
exports.default = commentsRouter;

649
backend/dist/routes/community.js vendored Normal file
View File

@@ -0,0 +1,649 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const hono_1 = require("hono");
const auth_1 = require("../middlewares/auth");
const clickhouse_1 = __importDefault(require("../utils/clickhouse"));
const supabase_1 = __importDefault(require("../utils/supabase"));
const communityRouter = new hono_1.Hono();
// Apply auth middleware to all routes
communityRouter.use('*', auth_1.authMiddleware);
// 创建新项目
communityRouter.post('/projects', async (c) => {
try {
const { name, description, start_date, end_date } = await c.req.json();
const user = c.get('user');
if (!name) {
return c.json({ error: 'Project name is required' }, 400);
}
// 在Supabase中创建项目
const { data, error } = await supabase_1.default
.from('projects')
.insert({
name,
description,
start_date,
end_date,
created_by: user.id
})
.select()
.single();
if (error) {
console.error('Error creating project:', error);
return c.json({ error: 'Failed to create project' }, 500);
}
return c.json({
message: 'Project created successfully',
project: data
}, 201);
}
catch (error) {
console.error('Error creating project:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 获取项目列表
communityRouter.get('/projects', async (c) => {
try {
const user = c.get('user');
// 从Supabase获取项目列表
const { data, error } = await supabase_1.default
.from('projects')
.select('*')
.eq('created_by', user.id)
.order('created_at', { ascending: false });
if (error) {
console.error('Error fetching projects:', error);
return c.json({ error: 'Failed to fetch projects' }, 500);
}
return c.json(data || []);
}
catch (error) {
console.error('Error fetching projects:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 获取项目详情
communityRouter.get('/projects/:id', async (c) => {
try {
const projectId = c.req.param('id');
// 从Supabase获取项目详情
const { data, error } = await supabase_1.default
.from('projects')
.select('*')
.eq('id', projectId)
.single();
if (error) {
console.error('Error fetching project:', error);
return c.json({ error: 'Failed to fetch project' }, 500);
}
if (!data) {
return c.json({ error: 'Project not found' }, 404);
}
return c.json(data);
}
catch (error) {
console.error('Error fetching project:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 更新项目
communityRouter.put('/projects/:id', async (c) => {
try {
const projectId = c.req.param('id');
const { name, description, start_date, end_date, status } = await c.req.json();
const user = c.get('user');
// 检查项目是否存在并属于当前用户
const { data: existingProject, error: fetchError } = await supabase_1.default
.from('projects')
.select('*')
.eq('id', projectId)
.eq('created_by', user.id)
.single();
if (fetchError || !existingProject) {
return c.json({ error: 'Project not found or you do not have permission to update it' }, 404);
}
// 更新项目
const { data, error } = await supabase_1.default
.from('projects')
.update({
name,
description,
start_date,
end_date,
status,
updated_at: new Date().toISOString()
})
.eq('id', projectId)
.select()
.single();
if (error) {
console.error('Error updating project:', error);
return c.json({ error: 'Failed to update project' }, 500);
}
return c.json({
message: 'Project updated successfully',
project: data
});
}
catch (error) {
console.error('Error updating project:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 删除项目
communityRouter.delete('/projects/:id', async (c) => {
try {
const projectId = c.req.param('id');
const user = c.get('user');
// 检查项目是否存在并属于当前用户
const { data: existingProject, error: fetchError } = await supabase_1.default
.from('projects')
.select('*')
.eq('id', projectId)
.eq('created_by', user.id)
.single();
if (fetchError || !existingProject) {
return c.json({ error: 'Project not found or you do not have permission to delete it' }, 404);
}
// 删除项目
const { error } = await supabase_1.default
.from('projects')
.delete()
.eq('id', projectId);
if (error) {
console.error('Error deleting project:', error);
return c.json({ error: 'Failed to delete project' }, 500);
}
return c.json({
message: 'Project deleted successfully'
});
}
catch (error) {
console.error('Error deleting project:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 添加影响者到项目
communityRouter.post('/projects/:id/influencers', async (c) => {
try {
const projectId = c.req.param('id');
const { influencer_id, platform, external_id, name, profile_url } = await c.req.json();
const user = c.get('user');
// 检查项目是否存在并属于当前用户
const { data: existingProject, error: fetchError } = await supabase_1.default
.from('projects')
.select('*')
.eq('id', projectId)
.eq('created_by', user.id)
.single();
if (fetchError || !existingProject) {
return c.json({ error: 'Project not found or you do not have permission to update it' }, 404);
}
// 检查影响者是否已存在
let influencerData;
if (influencer_id) {
// 如果提供了影响者ID检查是否存在
const { data, error } = await supabase_1.default
.from('influencers')
.select('*')
.eq('influencer_id', influencer_id)
.single();
if (!error && data) {
influencerData = data;
}
}
else if (external_id && platform) {
// 如果提供了外部ID和平台检查是否存在
const { data, error } = await supabase_1.default
.from('influencers')
.select('*')
.eq('external_id', external_id)
.eq('platform', platform)
.single();
if (!error && data) {
influencerData = data;
}
}
// 如果影响者不存在,创建新的影响者
if (!influencerData) {
if (!name || !platform) {
return c.json({ error: 'Name and platform are required for new influencers' }, 400);
}
const { data, error } = await supabase_1.default
.from('influencers')
.insert({
name,
platform,
external_id,
profile_url
})
.select()
.single();
if (error) {
console.error('Error creating influencer:', error);
return c.json({ error: 'Failed to create influencer' }, 500);
}
influencerData = data;
}
// 将影响者添加到项目
const { data: projectInfluencer, error } = await supabase_1.default
.from('project_influencers')
.insert({
project_id: projectId,
influencer_id: influencerData.influencer_id
})
.select()
.single();
if (error) {
console.error('Error adding influencer to project:', error);
return c.json({ error: 'Failed to add influencer to project' }, 500);
}
return c.json({
message: 'Influencer added to project successfully',
project_influencer: projectInfluencer,
influencer: influencerData
}, 201);
}
catch (error) {
console.error('Error adding influencer to project:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 获取项目的影响者列表
communityRouter.get('/projects/:id/influencers', async (c) => {
try {
const projectId = c.req.param('id');
// 从Supabase获取项目的影响者列表
const { data, error } = await supabase_1.default
.from('project_influencers')
.select(`
project_id,
influencers (
influencer_id,
name,
platform,
profile_url,
external_id,
followers_count,
video_count
)
`)
.eq('project_id', projectId);
if (error) {
console.error('Error fetching project influencers:', error);
return c.json({ error: 'Failed to fetch project influencers' }, 500);
}
// 格式化数据
const influencers = data?.map(item => item.influencers) || [];
return c.json(influencers);
}
catch (error) {
console.error('Error fetching project influencers:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 从项目中移除影响者
communityRouter.delete('/projects/:projectId/influencers/:influencerId', async (c) => {
try {
const projectId = c.req.param('projectId');
const influencerId = c.req.param('influencerId');
const user = c.get('user');
// 检查项目是否存在并属于当前用户
const { data: existingProject, error: fetchError } = await supabase_1.default
.from('projects')
.select('*')
.eq('id', projectId)
.eq('created_by', user.id)
.single();
if (fetchError || !existingProject) {
return c.json({ error: 'Project not found or you do not have permission to update it' }, 404);
}
// 从项目中移除影响者
const { error } = await supabase_1.default
.from('project_influencers')
.delete()
.eq('project_id', projectId)
.eq('influencer_id', influencerId);
if (error) {
console.error('Error removing influencer from project:', error);
return c.json({ error: 'Failed to remove influencer from project' }, 500);
}
return c.json({
message: 'Influencer removed from project successfully'
});
}
catch (error) {
console.error('Error removing influencer from project:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 添加事件数据
communityRouter.post('/events', async (c) => {
try {
const { project_id, influencer_id, post_id, platform, event_type, metric_value, event_metadata } = await c.req.json();
if (!project_id || !influencer_id || !platform || !event_type || metric_value === undefined) {
return c.json({
error: 'Project ID, influencer ID, platform, event type, and metric value are required'
}, 400);
}
// 验证事件类型
const validEventTypes = [
'follower_change',
'post_like_change',
'post_view_change',
'click',
'comment',
'share'
];
if (!validEventTypes.includes(event_type)) {
return c.json({
error: `Invalid event type. Must be one of: ${validEventTypes.join(', ')}`
}, 400);
}
// 验证平台
const validPlatforms = ['youtube', 'instagram', 'tiktok', 'twitter', 'facebook'];
if (!validPlatforms.includes(platform)) {
return c.json({
error: `Invalid platform. Must be one of: ${validPlatforms.join(', ')}`
}, 400);
}
// 将事件数据插入ClickHouse
await clickhouse_1.default.query({
query: `
INSERT INTO events (
project_id,
influencer_id,
post_id,
platform,
event_type,
metric_value,
event_metadata
) VALUES (?, ?, ?, ?, ?, ?, ?)
`,
values: [
project_id,
influencer_id,
post_id || null,
platform,
event_type,
metric_value,
event_metadata ? JSON.stringify(event_metadata) : '{}'
]
});
return c.json({
message: 'Event data added successfully'
}, 201);
}
catch (error) {
console.error('Error adding event data:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 批量添加事件数据
communityRouter.post('/events/batch', async (c) => {
try {
const { events } = await c.req.json();
if (!Array.isArray(events) || events.length === 0) {
return c.json({ error: 'Events array is required and must not be empty' }, 400);
}
// 验证事件类型和平台
const validEventTypes = [
'follower_change',
'post_like_change',
'post_view_change',
'click',
'comment',
'share'
];
const validPlatforms = ['youtube', 'instagram', 'tiktok', 'twitter', 'facebook'];
// 验证每个事件
for (const event of events) {
const { project_id, influencer_id, platform, event_type, metric_value } = event;
if (!project_id || !influencer_id || !platform || !event_type || metric_value === undefined) {
return c.json({
error: 'Project ID, influencer ID, platform, event type, and metric value are required for all events'
}, 400);
}
if (!validEventTypes.includes(event_type)) {
return c.json({
error: `Invalid event type: ${event_type}. Must be one of: ${validEventTypes.join(', ')}`
}, 400);
}
if (!validPlatforms.includes(platform)) {
return c.json({
error: `Invalid platform: ${platform}. Must be one of: ${validPlatforms.join(', ')}`
}, 400);
}
}
// 准备批量插入数据
const values = events.map(event => `(
'${event.project_id}',
'${event.influencer_id}',
${event.post_id ? `'${event.post_id}'` : 'NULL'},
'${event.platform}',
'${event.event_type}',
${event.metric_value},
'${event.event_metadata ? JSON.stringify(event.event_metadata) : '{}'}'
)`).join(',');
// 批量插入事件数据
await clickhouse_1.default.query({
query: `
INSERT INTO events (
project_id,
influencer_id,
post_id,
platform,
event_type,
metric_value,
event_metadata
) VALUES ${values}
`
});
return c.json({
message: `${events.length} events added successfully`
}, 201);
}
catch (error) {
console.error('Error adding batch event data:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 添加帖子
communityRouter.post('/posts', async (c) => {
try {
const { influencer_id, platform, post_url, title, description, published_at } = await c.req.json();
if (!influencer_id || !platform || !post_url) {
return c.json({
error: 'Influencer ID, platform, and post URL are required'
}, 400);
}
// 验证平台
const validPlatforms = ['youtube', 'instagram', 'tiktok', 'twitter', 'facebook'];
if (!validPlatforms.includes(platform)) {
return c.json({
error: `Invalid platform. Must be one of: ${validPlatforms.join(', ')}`
}, 400);
}
// 检查帖子是否已存在
const { data: existingPost, error: checkError } = await supabase_1.default
.from('posts')
.select('*')
.eq('post_url', post_url)
.single();
if (!checkError && existingPost) {
return c.json({
error: 'Post with this URL already exists',
post: existingPost
}, 409);
}
// 创建新帖子
const { data, error } = await supabase_1.default
.from('posts')
.insert({
influencer_id,
platform,
post_url,
title,
description,
published_at: published_at || new Date().toISOString()
})
.select()
.single();
if (error) {
console.error('Error creating post:', error);
return c.json({ error: 'Failed to create post' }, 500);
}
return c.json({
message: 'Post created successfully',
post: data
}, 201);
}
catch (error) {
console.error('Error creating post:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 添加评论
communityRouter.post('/comments', async (c) => {
try {
const { post_id, user_id, content, sentiment_score } = await c.req.json();
if (!post_id || !content) {
return c.json({
error: 'Post ID and content are required'
}, 400);
}
// 创建新评论
const { data, error } = await supabase_1.default
.from('comments')
.insert({
post_id,
user_id: user_id || c.get('user').id,
content,
sentiment_score: sentiment_score || 0
})
.select()
.single();
if (error) {
console.error('Error creating comment:', error);
return c.json({ error: 'Failed to create comment' }, 500);
}
return c.json({
message: 'Comment created successfully',
comment: data
}, 201);
}
catch (error) {
console.error('Error creating comment:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 获取项目的事件统计
communityRouter.get('/projects/:id/event-stats', async (c) => {
try {
const projectId = c.req.param('id');
// 从ClickHouse查询项目的事件统计
const result = await clickhouse_1.default.query({
query: `
SELECT
event_type,
COUNT(*) AS event_count,
SUM(metric_value) AS total_value
FROM events
WHERE project_id = ?
GROUP BY event_type
ORDER BY event_count DESC
`,
values: [projectId]
});
// 提取数据
const statsData = 'rows' in result ? result.rows : [];
return c.json({
project_id: projectId,
event_stats: statsData
});
}
catch (error) {
console.error('Error fetching event stats:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 获取项目的时间趋势
communityRouter.get('/projects/:id/time-trend', async (c) => {
try {
const projectId = c.req.param('id');
const { event_type, interval = 'day', days = '30' } = c.req.query();
if (!event_type) {
return c.json({ error: 'Event type is required' }, 400);
}
// 验证事件类型
const validEventTypes = [
'follower_change',
'post_like_change',
'post_view_change',
'click',
'comment',
'share'
];
if (!validEventTypes.includes(event_type)) {
return c.json({
error: `Invalid event type. Must be one of: ${validEventTypes.join(', ')}`
}, 400);
}
// 验证时间间隔
const validIntervals = ['hour', 'day', 'week', 'month'];
if (!validIntervals.includes(interval)) {
return c.json({
error: `Invalid interval. Must be one of: ${validIntervals.join(', ')}`
}, 400);
}
// 构建时间间隔函数
let timeFunction;
switch (interval) {
case 'hour':
timeFunction = 'toStartOfHour';
break;
case 'day':
timeFunction = 'toDate';
break;
case 'week':
timeFunction = 'toStartOfWeek';
break;
case 'month':
timeFunction = 'toStartOfMonth';
break;
}
// 从ClickHouse查询项目的时间趋势
const result = await clickhouse_1.default.query({
query: `
SELECT
${timeFunction}(timestamp) AS time_period,
SUM(metric_value) AS value
FROM events
WHERE
project_id = ? AND
event_type = ? AND
timestamp >= subtractDays(now(), ?)
GROUP BY time_period
ORDER BY time_period ASC
`,
values: [projectId, event_type, parseInt(days)]
});
// 提取数据
const trendData = 'rows' in result ? result.rows : [];
return c.json({
project_id: projectId,
event_type,
interval,
days: parseInt(days),
trend: trendData
});
}
catch (error) {
console.error('Error fetching time trend:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
exports.default = communityRouter;

10
backend/dist/routes/influencers.js vendored Normal file
View File

@@ -0,0 +1,10 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const hono_1 = require("hono");
const influencersController_1 = require("../controllers/influencersController");
const influencersRouter = new hono_1.Hono();
// Public routes
influencersRouter.get('/', influencersController_1.getInfluencers);
influencersRouter.get('/stats', influencersController_1.getInfluencerStats);
influencersRouter.get('/:influencer_id', influencersController_1.getInfluencerById);
exports.default = influencersRouter;

584
backend/dist/routes/posts.js vendored Normal file
View File

@@ -0,0 +1,584 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const hono_1 = require("hono");
const auth_1 = require("../middlewares/auth");
const supabase_1 = __importDefault(require("../utils/supabase"));
const clickhouse_1 = __importDefault(require("../utils/clickhouse"));
const redis_1 = require("../utils/redis");
const postsRouter = new hono_1.Hono();
// Apply auth middleware to most routes
postsRouter.use('*', auth_1.authMiddleware);
// 创建新帖子
postsRouter.post('/', async (c) => {
try {
const { influencer_id, platform, post_url, title, description, published_at } = await c.req.json();
if (!influencer_id || !platform || !post_url) {
return c.json({
error: 'influencer_id, platform, and post_url are required'
}, 400);
}
// 验证平台
const validPlatforms = ['youtube', 'instagram', 'tiktok', 'twitter', 'facebook'];
if (!validPlatforms.includes(platform)) {
return c.json({
error: `Invalid platform. Must be one of: ${validPlatforms.join(', ')}`
}, 400);
}
// 检查帖子URL是否已存在
const { data: existingPost, error: checkError } = await supabase_1.default
.from('posts')
.select('*')
.eq('post_url', post_url)
.single();
if (!checkError && existingPost) {
return c.json({
error: 'Post with this URL already exists',
post: existingPost
}, 409);
}
// 创建新帖子
const { data: post, error } = await supabase_1.default
.from('posts')
.insert({
influencer_id,
platform,
post_url,
title,
description,
published_at: published_at || new Date().toISOString()
})
.select()
.single();
if (error) {
console.error('Error creating post:', error);
return c.json({ error: 'Failed to create post' }, 500);
}
return c.json({
message: 'Post created successfully',
post
}, 201);
}
catch (error) {
console.error('Error creating post:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 获取帖子列表
postsRouter.get('/', async (c) => {
try {
const { influencer_id, platform, limit = '20', offset = '0', sort = 'published_at', order = 'desc' } = c.req.query();
// 构建查询
let query = supabase_1.default.from('posts').select(`
*,
influencer:influencers(name, platform, profile_url, followers_count)
`);
// 添加过滤条件
if (influencer_id) {
query = query.eq('influencer_id', influencer_id);
}
if (platform) {
query = query.eq('platform', platform);
}
// 添加排序和分页
query = query.order(sort, { ascending: order === 'asc' });
query = query.range(parseInt(offset), parseInt(offset) + parseInt(limit) - 1);
// 执行查询
const { data, error, count } = await query;
if (error) {
console.error('Error fetching posts:', error);
return c.json({ error: 'Failed to fetch posts' }, 500);
}
// 获取帖子的统计数据
if (data && data.length > 0) {
const postIds = data.map(post => post.post_id);
// 尝试从缓存获取数据
const redis = await (0, redis_1.getRedisClient)();
const cachedStats = await Promise.all(postIds.map(async (postId) => {
const [views, likes] = await Promise.all([
redis.get(`post:views:${postId}`),
redis.get(`post:likes:${postId}`)
]);
return {
post_id: postId,
views: views ? parseInt(views) : null,
likes: likes ? parseInt(likes) : null
};
}));
// 找出缓存中没有的帖子ID
const missingIds = postIds.filter(id => {
const stat = cachedStats.find(s => s.post_id === id);
return stat?.views === null || stat?.likes === null;
});
// 如果有缺失的统计数据从ClickHouse获取
if (missingIds.length > 0) {
try {
// 查询帖子的观看数
const viewsResult = await clickhouse_1.default.query({
query: `
SELECT
post_id,
SUM(metric_value) AS views
FROM events
WHERE
post_id IN (${missingIds.map(id => `'${id}'`).join(',')}) AND
event_type = 'post_view_change'
GROUP BY post_id
`
});
// 查询帖子的点赞数
const likesResult = await clickhouse_1.default.query({
query: `
SELECT
post_id,
SUM(metric_value) AS likes
FROM events
WHERE
post_id IN (${missingIds.map(id => `'${id}'`).join(',')}) AND
event_type = 'post_like_change'
GROUP BY post_id
`
});
// 处理结果
const viewsData = 'rows' in viewsResult ? viewsResult.rows : [];
const likesData = 'rows' in likesResult ? likesResult.rows : [];
// 更新缓存并填充统计数据
for (const viewStat of viewsData) {
if (viewStat && typeof viewStat === 'object' && 'post_id' in viewStat && 'views' in viewStat) {
// 更新缓存
await redis.set(`post:views:${viewStat.post_id}`, String(viewStat.views));
// 更新缓存统计数据
const cacheStat = cachedStats.find(s => s.post_id === viewStat.post_id);
if (cacheStat) {
cacheStat.views = Number(viewStat.views);
}
}
}
for (const likeStat of likesData) {
if (likeStat && typeof likeStat === 'object' && 'post_id' in likeStat && 'likes' in likeStat) {
// 更新缓存
await redis.set(`post:likes:${likeStat.post_id}`, String(likeStat.likes));
// 更新缓存统计数据
const cacheStat = cachedStats.find(s => s.post_id === likeStat.post_id);
if (cacheStat) {
cacheStat.likes = Number(likeStat.likes);
}
}
}
}
catch (chError) {
console.error('Error fetching stats from ClickHouse:', chError);
}
}
// 合并统计数据到帖子数据
data.forEach(post => {
const stats = cachedStats.find(s => s.post_id === post.post_id);
post.stats = {
views: stats?.views || 0,
likes: stats?.likes || 0
};
});
}
return c.json({
posts: data || [],
total: count || 0,
limit: parseInt(limit),
offset: parseInt(offset)
});
}
catch (error) {
console.error('Error fetching posts:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 获取单个帖子详情
postsRouter.get('/:id', async (c) => {
try {
const postId = c.req.param('id');
// 获取帖子详情
const { data: post, error } = await supabase_1.default
.from('posts')
.select(`
*,
influencer:influencers(name, platform, profile_url, followers_count)
`)
.eq('post_id', postId)
.single();
if (error) {
console.error('Error fetching post:', error);
return c.json({ error: 'Failed to fetch post' }, 500);
}
if (!post) {
return c.json({ error: 'Post not found' }, 404);
}
// 获取帖子统计数据
try {
// 先尝试从Redis缓存获取
const redis = await (0, redis_1.getRedisClient)();
const [cachedViews, cachedLikes] = await Promise.all([
redis.get(`post:views:${postId}`),
redis.get(`post:likes:${postId}`)
]);
// 如果缓存中有数据,直接使用
if (cachedViews !== null && cachedLikes !== null) {
post.stats = {
views: parseInt(cachedViews),
likes: parseInt(cachedLikes)
};
}
else {
// 如果缓存中没有从ClickHouse获取
// 查询帖子的观看数
const viewsResult = await clickhouse_1.default.query({
query: `
SELECT SUM(metric_value) AS views
FROM events
WHERE
post_id = ? AND
event_type = 'post_view_change'
`,
values: [postId]
});
// 查询帖子的点赞数
const likesResult = await clickhouse_1.default.query({
query: `
SELECT SUM(metric_value) AS likes
FROM events
WHERE
post_id = ? AND
event_type = 'post_like_change'
`,
values: [postId]
});
// 处理结果
let viewsData = 0;
if ('rows' in viewsResult && viewsResult.rows.length > 0 && viewsResult.rows[0] && typeof viewsResult.rows[0] === 'object' && 'views' in viewsResult.rows[0]) {
viewsData = Number(viewsResult.rows[0].views) || 0;
}
let likesData = 0;
if ('rows' in likesResult && likesResult.rows.length > 0 && likesResult.rows[0] && typeof likesResult.rows[0] === 'object' && 'likes' in likesResult.rows[0]) {
likesData = Number(likesResult.rows[0].likes) || 0;
}
// 更新缓存
await redis.set(`post:views:${postId}`, String(viewsData));
await redis.set(`post:likes:${postId}`, String(likesData));
// 添加统计数据
post.stats = {
views: viewsData,
likes: likesData
};
}
// 获取互动时间线
const timelineResult = await clickhouse_1.default.query({
query: `
SELECT
toDate(timestamp) as date,
event_type,
SUM(metric_value) as value
FROM events
WHERE
post_id = ? AND
event_type IN ('post_view_change', 'post_like_change')
GROUP BY date, event_type
ORDER BY date ASC
`,
values: [postId]
});
const timelineData = 'rows' in timelineResult ? timelineResult.rows : [];
// 添加时间线数据
post.timeline = timelineData;
// 获取评论数量
const { count } = await supabase_1.default
.from('comments')
.select('*', { count: 'exact', head: true })
.eq('post_id', postId);
post.comment_count = count || 0;
}
catch (statsError) {
console.error('Error fetching post stats:', statsError);
// 继续返回帖子数据,但没有统计信息
post.stats = { views: 0, likes: 0 };
post.timeline = [];
post.comment_count = 0;
}
return c.json(post);
}
catch (error) {
console.error('Error fetching post:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 更新帖子
postsRouter.put('/:id', async (c) => {
try {
const postId = c.req.param('id');
const user = c.get('user');
const { title, description } = await c.req.json();
// 先检查帖子是否存在
const { data: existingPost, error: fetchError } = await supabase_1.default
.from('posts')
.select('*')
.eq('post_id', postId)
.single();
if (fetchError || !existingPost) {
return c.json({ error: 'Post not found' }, 404);
}
// 更新帖子
const { data: updatedPost, error } = await supabase_1.default
.from('posts')
.update({
title,
description,
updated_at: new Date().toISOString()
})
.eq('post_id', postId)
.select()
.single();
if (error) {
console.error('Error updating post:', error);
return c.json({ error: 'Failed to update post' }, 500);
}
return c.json({
message: 'Post updated successfully',
post: updatedPost
});
}
catch (error) {
console.error('Error updating post:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 删除帖子
postsRouter.delete('/:id', async (c) => {
try {
const postId = c.req.param('id');
const user = c.get('user');
// 删除帖子
const { error } = await supabase_1.default
.from('posts')
.delete()
.eq('post_id', postId);
if (error) {
console.error('Error deleting post:', error);
return c.json({ error: 'Failed to delete post' }, 500);
}
// 清除缓存
try {
const redis = await (0, redis_1.getRedisClient)();
await Promise.all([
redis.del(`post:views:${postId}`),
redis.del(`post:likes:${postId}`)
]);
}
catch (cacheError) {
console.error('Error clearing cache:', cacheError);
}
return c.json({
message: 'Post deleted successfully'
});
}
catch (error) {
console.error('Error deleting post:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 获取帖子的评论
postsRouter.get('/:id/comments', async (c) => {
try {
const postId = c.req.param('id');
const { limit = '20', offset = '0' } = c.req.query();
// 获取评论
const { data: comments, error, count } = await supabase_1.default
.from('comments')
.select('*', { count: 'exact' })
.eq('post_id', postId)
.order('created_at', { ascending: false })
.range(parseInt(offset), parseInt(offset) + parseInt(limit) - 1);
if (error) {
console.error('Error fetching comments:', error);
return c.json({ error: 'Failed to fetch comments' }, 500);
}
// 如果有评论,获取用户信息
if (comments && comments.length > 0) {
const userIds = [...new Set(comments.map(comment => comment.user_id))];
// 获取用户信息
const { data: userProfiles, error: userError } = await supabase_1.default
.from('user_profiles')
.select('id, full_name, avatar_url')
.in('id', userIds);
if (!userError && userProfiles) {
// 将用户信息添加到评论中
comments.forEach(comment => {
const userProfile = userProfiles.find(profile => profile.id === comment.user_id);
comment.user_profile = userProfile || null;
});
}
else {
console.error('Error fetching user profiles:', userError);
}
}
return c.json({
comments: comments || [],
total: count || 0,
limit: parseInt(limit),
offset: parseInt(offset)
});
}
catch (error) {
console.error('Error fetching comments:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 添加评论到帖子
postsRouter.post('/:id/comments', async (c) => {
try {
const postId = c.req.param('id');
const user = c.get('user');
const { content, sentiment_score } = await c.req.json();
if (!content) {
return c.json({ error: 'Comment content is required' }, 400);
}
// 创建评论
const { data: comment, error } = await supabase_1.default
.from('comments')
.insert({
post_id: postId,
user_id: user.id,
content,
sentiment_score: sentiment_score || 0
})
.select()
.single();
if (error) {
console.error('Error creating comment:', error);
return c.json({ error: 'Failed to create comment' }, 500);
}
// 尝试记录评论事件到ClickHouse
try {
// 获取帖子信息
const { data: post } = await supabase_1.default
.from('posts')
.select('influencer_id, platform')
.eq('post_id', postId)
.single();
if (post) {
await clickhouse_1.default.query({
query: `
INSERT INTO events (
influencer_id,
post_id,
platform,
event_type,
metric_value,
event_metadata
) VALUES (?, ?, ?, 'comment', ?, ?)
`,
values: [
post.influencer_id,
postId,
post.platform,
1,
JSON.stringify({
comment_id: comment.comment_id,
user_id: user.id,
sentiment_score: sentiment_score || 0
})
]
});
}
}
catch (eventError) {
console.error('Error recording comment event:', eventError);
// 不影响主流程,继续返回评论数据
}
return c.json({
message: 'Comment added successfully',
comment
}, 201);
}
catch (error) {
console.error('Error adding comment:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 更新评论
postsRouter.put('/comments/:id', async (c) => {
try {
const commentId = c.req.param('id');
const user = c.get('user');
const { content, sentiment_score } = await c.req.json();
// 先检查评论是否存在且属于当前用户
const { data: existingComment, error: fetchError } = await supabase_1.default
.from('comments')
.select('*')
.eq('comment_id', commentId)
.eq('user_id', user.id)
.single();
if (fetchError || !existingComment) {
return c.json({
error: 'Comment not found or you do not have permission to update it'
}, 404);
}
// 更新评论
const { data: updatedComment, error } = await supabase_1.default
.from('comments')
.update({
content,
sentiment_score: sentiment_score !== undefined ? sentiment_score : existingComment.sentiment_score,
updated_at: new Date().toISOString()
})
.eq('comment_id', commentId)
.select()
.single();
if (error) {
console.error('Error updating comment:', error);
return c.json({ error: 'Failed to update comment' }, 500);
}
return c.json({
message: 'Comment updated successfully',
comment: updatedComment
});
}
catch (error) {
console.error('Error updating comment:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 删除评论
postsRouter.delete('/comments/:id', async (c) => {
try {
const commentId = c.req.param('id');
const user = c.get('user');
// 先检查评论是否存在且属于当前用户
const { data: existingComment, error: fetchError } = await supabase_1.default
.from('comments')
.select('*')
.eq('comment_id', commentId)
.eq('user_id', user.id)
.single();
if (fetchError || !existingComment) {
return c.json({
error: 'Comment not found or you do not have permission to delete it'
}, 404);
}
// 删除评论
const { error } = await supabase_1.default
.from('comments')
.delete()
.eq('comment_id', commentId);
if (error) {
console.error('Error deleting comment:', error);
return c.json({ error: 'Failed to delete comment' }, 500);
}
return c.json({
message: 'Comment deleted successfully'
});
}
catch (error) {
console.error('Error deleting comment:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
exports.default = postsRouter;

395
backend/dist/routes/projectComments.js vendored Normal file
View File

@@ -0,0 +1,395 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const hono_1 = require("hono");
const auth_1 = require("../middlewares/auth");
const supabase_1 = __importDefault(require("../utils/supabase"));
const clickhouse_1 = __importDefault(require("../utils/clickhouse"));
const projectCommentsRouter = new hono_1.Hono();
// Apply auth middleware to all routes
projectCommentsRouter.use('*', auth_1.authMiddleware);
// 获取项目的评论列表
projectCommentsRouter.get('/projects/:id/comments', async (c) => {
try {
const projectId = c.req.param('id');
const { limit = '20', offset = '0', parent_id = null } = c.req.query();
// 检查项目是否存在
const { data: project, error: projectError } = await supabase_1.default
.from('projects')
.select('id, name')
.eq('id', projectId)
.single();
if (projectError) {
console.error('Error fetching project:', projectError);
return c.json({ error: 'Project not found' }, 404);
}
// 构建评论查询
let commentsQuery = supabase_1.default
.from('project_comments')
.select(`
comment_id,
project_id,
user_id,
content,
sentiment_score,
status,
is_pinned,
parent_id,
created_at,
updated_at,
user:user_id(id, email)
`, { count: 'exact' });
// 过滤条件
commentsQuery = commentsQuery.eq('project_id', projectId);
// 如果指定了父评论ID则获取子评论
if (parent_id) {
commentsQuery = commentsQuery.eq('parent_id', parent_id);
}
else {
// 否则获取顶级评论(没有父评论的评论)
commentsQuery = commentsQuery.is('parent_id', null);
}
// 排序和分页
const isPinned = parent_id ? false : true; // 只有顶级评论才考虑置顶
if (isPinned) {
commentsQuery = commentsQuery.order('is_pinned', { ascending: false });
}
commentsQuery = commentsQuery.order('created_at', { ascending: false });
commentsQuery = commentsQuery.range(parseInt(offset), parseInt(offset) + parseInt(limit) - 1);
// 执行查询
const { data: comments, error: commentsError, count } = await commentsQuery;
if (commentsError) {
console.error('Error fetching project comments:', commentsError);
return c.json({ error: 'Failed to fetch project comments' }, 500);
}
// 获取每个顶级评论的回复数量
if (comments && !parent_id) {
const commentIds = comments.map(comment => comment.comment_id);
if (commentIds.length > 0) {
// 手动构建SQL查询来计算每个父评论的回复数量
const { data: replyCounts, error: replyCountError } = await supabase_1.default
.rpc('get_reply_counts_for_comments', { parent_ids: commentIds });
if (!replyCountError && replyCounts) {
// 将回复数量添加到评论中
for (const comment of comments) {
const replyCountItem = replyCounts.find((r) => r.parent_id === comment.comment_id);
comment.reply_count = replyCountItem ? replyCountItem.count : 0;
}
}
}
}
return c.json({
project,
comments: comments || [],
total: count || 0,
limit: parseInt(limit),
offset: parseInt(offset)
});
}
catch (error) {
console.error('Error fetching project comments:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 添加评论到项目
projectCommentsRouter.post('/projects/:id/comments', async (c) => {
try {
const projectId = c.req.param('id');
const user = c.get('user');
const { content, sentiment_score = 0, parent_id = null } = await c.req.json();
if (!content) {
return c.json({ error: 'Comment content is required' }, 400);
}
// 检查项目是否存在
const { data: project, error: projectError } = await supabase_1.default
.from('projects')
.select('id')
.eq('id', projectId)
.single();
if (projectError) {
console.error('Error fetching project:', projectError);
return c.json({ error: 'Project not found' }, 404);
}
// 如果指定了父评论ID检查父评论是否存在
if (parent_id) {
const { data: parentComment, error: parentError } = await supabase_1.default
.from('project_comments')
.select('comment_id')
.eq('comment_id', parent_id)
.eq('project_id', projectId)
.single();
if (parentError || !parentComment) {
return c.json({ error: 'Parent comment not found' }, 404);
}
}
// 创建评论
const { data: comment, error: commentError } = await supabase_1.default
.from('project_comments')
.insert({
project_id: projectId,
user_id: user.id,
content,
sentiment_score,
parent_id
})
.select()
.single();
if (commentError) {
console.error('Error creating project comment:', commentError);
return c.json({ error: 'Failed to create comment' }, 500);
}
// 记录评论事件到ClickHouse
try {
await clickhouse_1.default.query({
query: `
INSERT INTO events (
project_id,
event_type,
metric_value,
event_metadata
) VALUES (?, 'project_comment', ?, ?)
`,
values: [
projectId,
1,
JSON.stringify({
comment_id: comment.comment_id,
user_id: user.id,
parent_id: parent_id || null,
content: content.substring(0, 100), // 只存储部分内容以减小数据量
sentiment_score: sentiment_score
})
]
});
}
catch (chError) {
console.error('Error recording project comment event:', chError);
// 继续执行,不中断主流程
}
return c.json({
message: 'Comment added successfully',
comment
}, 201);
}
catch (error) {
console.error('Error adding project comment:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 更新项目评论
projectCommentsRouter.put('/comments/:id', async (c) => {
try {
const commentId = c.req.param('id');
const user = c.get('user');
const { content, sentiment_score, is_pinned } = await c.req.json();
// 检查评论是否存在且属于当前用户或用户是项目拥有者
const { data: comment, error: fetchError } = await supabase_1.default
.from('project_comments')
.select(`
comment_id,
project_id,
user_id,
projects!inner(created_by)
`)
.eq('comment_id', commentId)
.single();
if (fetchError || !comment) {
return c.json({ error: 'Comment not found' }, 404);
}
// 确保我们能够安全地访问projects中的created_by字段
const projectOwner = comment.projects &&
Array.isArray(comment.projects) &&
comment.projects.length > 0 ?
comment.projects[0].created_by : null;
// 检查用户是否有权限更新评论
const isCommentOwner = comment.user_id === user.id;
const isProjectOwner = projectOwner === user.id;
if (!isCommentOwner && !isProjectOwner) {
return c.json({
error: 'You do not have permission to update this comment'
}, 403);
}
// 准备更新数据
const updateData = {};
// 评论创建者可以更新内容和情感分数
if (isCommentOwner) {
if (content !== undefined) {
updateData.content = content;
}
if (sentiment_score !== undefined) {
updateData.sentiment_score = sentiment_score;
}
}
// 项目所有者可以更新状态和置顶
if (isProjectOwner) {
if (is_pinned !== undefined) {
updateData.is_pinned = is_pinned;
}
}
// 更新时间
updateData.updated_at = new Date().toISOString();
// 如果没有内容要更新,返回错误
if (Object.keys(updateData).length === 1) { // 只有updated_at
return c.json({ error: 'No valid fields to update' }, 400);
}
// 更新评论
const { data: updatedComment, error } = await supabase_1.default
.from('project_comments')
.update(updateData)
.eq('comment_id', commentId)
.select()
.single();
if (error) {
console.error('Error updating project comment:', error);
return c.json({ error: 'Failed to update comment' }, 500);
}
return c.json({
message: 'Comment updated successfully',
comment: updatedComment
});
}
catch (error) {
console.error('Error updating project comment:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 删除项目评论
projectCommentsRouter.delete('/comments/:id', async (c) => {
try {
const commentId = c.req.param('id');
const user = c.get('user');
// 检查评论是否存在且属于当前用户或用户是项目拥有者
const { data: comment, error: fetchError } = await supabase_1.default
.from('project_comments')
.select(`
comment_id,
project_id,
user_id,
projects!inner(created_by)
`)
.eq('comment_id', commentId)
.single();
if (fetchError || !comment) {
return c.json({ error: 'Comment not found' }, 404);
}
// 确保我们能够安全地访问projects中的created_by字段
const projectOwner = comment.projects &&
Array.isArray(comment.projects) &&
comment.projects.length > 0 ?
comment.projects[0].created_by : null;
// 检查用户是否有权限删除评论
const isCommentOwner = comment.user_id === user.id;
const isProjectOwner = projectOwner === user.id;
if (!isCommentOwner && !isProjectOwner) {
return c.json({
error: 'You do not have permission to delete this comment'
}, 403);
}
// 删除评论
const { error } = await supabase_1.default
.from('project_comments')
.delete()
.eq('comment_id', commentId);
if (error) {
console.error('Error deleting project comment:', error);
return c.json({ error: 'Failed to delete comment' }, 500);
}
return c.json({
message: 'Comment deleted successfully'
});
}
catch (error) {
console.error('Error deleting project comment:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 获取项目评论的统计信息
projectCommentsRouter.get('/projects/:id/comments/stats', async (c) => {
try {
const projectId = c.req.param('id');
// 检查项目是否存在
const { data: project, error: projectError } = await supabase_1.default
.from('projects')
.select('id, name')
.eq('id', projectId)
.single();
if (projectError) {
console.error('Error fetching project:', projectError);
return c.json({ error: 'Project not found' }, 404);
}
// 从Supabase获取评论总数
const { count } = await supabase_1.default
.from('project_comments')
.select('*', { count: 'exact', head: true })
.eq('project_id', projectId);
// 从Supabase获取情感分析统计
const { data: sentimentStats } = await supabase_1.default
.from('project_comments')
.select('sentiment_score')
.eq('project_id', projectId);
let averageSentiment = 0;
let positiveCount = 0;
let neutralCount = 0;
let negativeCount = 0;
if (sentimentStats && sentimentStats.length > 0) {
// 计算平均情感分数
const totalSentiment = sentimentStats.reduce((acc, curr) => acc + (curr.sentiment_score || 0), 0);
averageSentiment = totalSentiment / sentimentStats.length;
// 分类情感分数
sentimentStats.forEach(stat => {
const score = stat.sentiment_score || 0;
if (score > 0.3) {
positiveCount++;
}
else if (score < -0.3) {
negativeCount++;
}
else {
neutralCount++;
}
});
}
let timeTrend = [];
try {
const result = await clickhouse_1.default.query({
query: `
SELECT
toDate(timestamp) as date,
count() as comment_count
FROM events
WHERE
project_id = ? AND
event_type = 'project_comment' AND
timestamp >= subtractDays(now(), 30)
GROUP BY date
ORDER BY date ASC
`,
values: [projectId]
});
timeTrend = 'rows' in result ? result.rows : [];
}
catch (chError) {
console.error('Error fetching comment time trend:', chError);
// 继续执行,返回空趋势数据
}
return c.json({
project_id: projectId,
project_name: project.name,
total_comments: count || 0,
sentiment: {
average: averageSentiment,
positive: positiveCount,
neutral: neutralCount,
negative: negativeCount
},
time_trend: timeTrend
});
}
catch (error) {
console.error('Error fetching project comment stats:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
exports.default = projectCommentsRouter;

1863
backend/dist/swagger/index.js vendored Normal file

File diff suppressed because it is too large Load Diff

87
backend/dist/utils/clickhouse.js vendored Normal file
View File

@@ -0,0 +1,87 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.initClickHouse = void 0;
const client_1 = require("@clickhouse/client");
const config_1 = __importDefault(require("../config"));
// Create ClickHouse client with error handling
const createClickHouseClient = () => {
try {
return (0, client_1.createClient)({
host: `http://${config_1.default.clickhouse.host}:${config_1.default.clickhouse.port}`,
username: config_1.default.clickhouse.user,
password: config_1.default.clickhouse.password,
database: config_1.default.clickhouse.database,
});
}
catch (error) {
console.error('Error creating ClickHouse client:', error);
// Return a mock client for development that logs operations instead of executing them
return {
query: async ({ query, values }) => {
console.log('ClickHouse query (mock):', query, values);
return { rows: [] };
},
close: async () => {
console.log('ClickHouse connection closed (mock)');
}
};
}
};
const clickhouse = createClickHouseClient();
// Initialize ClickHouse database and tables
const initClickHouse = async () => {
try {
// Create database if not exists
await clickhouse.query({
query: `CREATE DATABASE IF NOT EXISTS ${config_1.default.clickhouse.database}`,
});
// Create tables for tracking events
await clickhouse.query({
query: `
CREATE TABLE IF NOT EXISTS ${config_1.default.clickhouse.database}.view_events (
user_id String,
content_id String,
timestamp DateTime DEFAULT now(),
ip String,
user_agent String
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (user_id, content_id, timestamp)
`,
});
await clickhouse.query({
query: `
CREATE TABLE IF NOT EXISTS ${config_1.default.clickhouse.database}.like_events (
user_id String,
content_id String,
timestamp DateTime DEFAULT now(),
action Enum('like' = 1, 'unlike' = 2)
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (user_id, content_id, timestamp)
`,
});
await clickhouse.query({
query: `
CREATE TABLE IF NOT EXISTS ${config_1.default.clickhouse.database}.follower_events (
follower_id String,
followed_id String,
timestamp DateTime DEFAULT now(),
action Enum('follow' = 1, 'unfollow' = 2)
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (follower_id, followed_id, timestamp)
`,
});
console.log('ClickHouse database and tables initialized');
}
catch (error) {
console.error('Error initializing ClickHouse:', error);
console.log('Continuing with limited functionality...');
}
};
exports.initClickHouse = initClickHouse;
exports.default = clickhouse;

492
backend/dist/utils/initDatabase.js vendored Normal file
View File

@@ -0,0 +1,492 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.initDatabase = exports.checkDatabaseConnection = exports.createSampleData = exports.initSupabaseFunctions = exports.initClickHouseTables = exports.initSupabaseTables = void 0;
const supabase_1 = __importDefault(require("./supabase"));
const clickhouse_1 = __importDefault(require("./clickhouse"));
const promises_1 = __importDefault(require("fs/promises"));
const path_1 = __importDefault(require("path"));
/**
* 初始化 Supabase (PostgreSQL) 数据库表
*/
const initSupabaseTables = async () => {
try {
console.log('开始初始化 Supabase 数据表...');
// 创建用户扩展表
await supabase_1.default.rpc('create_user_profiles_if_not_exists');
// 创建项目表
await supabase_1.default.rpc('create_projects_table_if_not_exists');
// 创建网红(影响者)表
await supabase_1.default.rpc('create_influencers_table_if_not_exists');
// 创建项目-网红关联表
await supabase_1.default.rpc('create_project_influencers_table_if_not_exists');
// 创建帖子表
await supabase_1.default.rpc('create_posts_table_if_not_exists');
// 创建评论表
await supabase_1.default.rpc('create_comments_table_if_not_exists');
// 创建项目评论表
await supabase_1.default.rpc('create_project_comments_table_if_not_exists');
console.log('Supabase 数据表初始化完成');
return true;
}
catch (error) {
console.error('初始化 Supabase 数据表失败:', error);
return false;
}
};
exports.initSupabaseTables = initSupabaseTables;
/**
* 初始化 ClickHouse 数据库表
*/
const initClickHouseTables = async () => {
try {
console.log('开始初始化 ClickHouse 数据表...');
// 创建事件表
await clickhouse_1.default.query({
query: `
CREATE TABLE IF NOT EXISTS events (
event_id UUID DEFAULT generateUUIDv4(),
project_id UUID,
influencer_id UUID,
post_id UUID NULL,
platform String,
event_type Enum(
'follower_change' = 1,
'post_like_change' = 2,
'post_view_change' = 3,
'click' = 4,
'comment' = 5,
'share' = 6,
'project_comment' = 7
),
metric_value Int64,
event_metadata String,
timestamp DateTime DEFAULT now()
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (platform, influencer_id, post_id, event_type, timestamp)
`
});
// 创建统计视图 - 按天统计
await clickhouse_1.default.query({
query: `
CREATE MATERIALIZED VIEW IF NOT EXISTS daily_stats
ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(date)
ORDER BY (date, platform, influencer_id, event_type)
AS SELECT
toDate(timestamp) AS date,
platform,
influencer_id,
event_type,
SUM(metric_value) AS total_value,
COUNT(*) AS event_count
FROM events
GROUP BY date, platform, influencer_id, event_type
`
});
// 创建统计视图 - 按月统计
await clickhouse_1.default.query({
query: `
CREATE MATERIALIZED VIEW IF NOT EXISTS monthly_stats
ENGINE = SummingMergeTree()
ORDER BY (month, platform, influencer_id, event_type)
AS SELECT
toStartOfMonth(timestamp) AS month,
platform,
influencer_id,
event_type,
SUM(metric_value) AS total_value,
COUNT(*) AS event_count
FROM events
GROUP BY month, platform, influencer_id, event_type
`
});
// 创建帖子互动统计视图
await clickhouse_1.default.query({
query: `
CREATE MATERIALIZED VIEW IF NOT EXISTS post_interaction_stats
ENGINE = SummingMergeTree()
ORDER BY (post_id, event_type, date)
AS SELECT
post_id,
event_type,
toDate(timestamp) AS date,
SUM(metric_value) AS value,
COUNT(*) AS count
FROM events
WHERE post_id IS NOT NULL
GROUP BY post_id, event_type, date
`
});
// 创建项目互动统计视图
await clickhouse_1.default.query({
query: `
CREATE MATERIALIZED VIEW IF NOT EXISTS project_interaction_stats
ENGINE = SummingMergeTree()
ORDER BY (project_id, event_type, date)
AS SELECT
project_id,
event_type,
toDate(timestamp) AS date,
SUM(metric_value) AS value,
COUNT(*) AS count
FROM events
WHERE project_id IS NOT NULL AND event_type = 'project_comment'
GROUP BY project_id, event_type, date
`
});
console.log('ClickHouse 数据表初始化完成');
return true;
}
catch (error) {
console.error('初始化 ClickHouse 数据表失败:', error);
return false;
}
};
exports.initClickHouseTables = initClickHouseTables;
/**
* 初始化 Supabase 存储函数
*/
const initSupabaseFunctions = async () => {
try {
console.log('开始初始化 Supabase 存储过程...');
// 创建用户简档表的存储过程
await supabase_1.default.rpc('create_function_create_user_profiles_if_not_exists');
// 创建项目表的存储过程
await supabase_1.default.rpc('create_function_create_projects_table_if_not_exists');
// 创建网红表的存储过程
await supabase_1.default.rpc('create_function_create_influencers_table_if_not_exists');
// 创建项目-网红关联表的存储过程
await supabase_1.default.rpc('create_function_create_project_influencers_table_if_not_exists');
// 创建帖子表的存储过程
await supabase_1.default.rpc('create_function_create_posts_table_if_not_exists');
// 创建评论表的存储过程
await supabase_1.default.rpc('create_function_create_comments_table_if_not_exists');
// 创建项目评论表的存储过程
await supabase_1.default.rpc('create_function_create_project_comments_table_if_not_exists');
// 创建评论相关的SQL函数
console.log('创建评论相关的SQL函数...');
const commentsSQL = await promises_1.default.readFile(path_1.default.join(__dirname, 'supabase-comments-functions.sql'), 'utf8');
// 使用Supabase执行SQL
const { error: commentsFunctionsError } = await supabase_1.default.rpc('pgclient_execute', { query: commentsSQL });
if (commentsFunctionsError) {
console.error('创建评论SQL函数失败:', commentsFunctionsError);
}
else {
console.log('评论SQL函数创建成功');
}
console.log('Supabase 存储过程初始化完成');
return true;
}
catch (error) {
console.error('初始化 Supabase 存储过程失败:', error);
return false;
}
};
exports.initSupabaseFunctions = initSupabaseFunctions;
/**
* 创建测试数据
*/
const createSampleData = async () => {
try {
console.log('开始创建测试数据...');
// 创建测试用户
const { data: user, error: userError } = await supabase_1.default.auth.admin.createUser({
email: 'test@example.com',
password: 'password123',
user_metadata: {
full_name: '测试用户'
}
});
if (userError) {
console.error('创建测试用户失败:', userError);
return false;
}
// 创建测试项目
const { data: project, error: projectError } = await supabase_1.default
.from('projects')
.insert({
name: '测试营销活动',
description: '这是一个测试营销活动',
created_by: user.user.id
})
.select()
.single();
if (projectError) {
console.error('创建测试项目失败:', projectError);
return false;
}
// 创建项目评论
await supabase_1.default
.from('project_comments')
.insert([
{
project_id: project.id,
user_id: user.user.id,
content: '这是对项目的一条测试评论',
sentiment_score: 0.8
},
{
project_id: project.id,
user_id: user.user.id,
content: '这个项目很有前景',
sentiment_score: 0.9
},
{
project_id: project.id,
user_id: user.user.id,
content: '需要关注这个项目的进展',
sentiment_score: 0.7
}
]);
// 创建测试网红
const platforms = ['youtube', 'instagram', 'tiktok'];
const influencers = [];
for (let i = 1; i <= 10; i++) {
const platform = platforms[Math.floor(Math.random() * platforms.length)];
const { data: influencer, error: influencerError } = await supabase_1.default
.from('influencers')
.insert({
name: `测试网红 ${i}`,
platform,
profile_url: `https://${platform}.com/user${i}`,
external_id: `user_${platform}_${i}`,
followers_count: Math.floor(Math.random() * 1000000) + 1000,
video_count: Math.floor(Math.random() * 500) + 10
})
.select()
.single();
if (influencerError) {
console.error(`创建测试网红 ${i} 失败:`, influencerError);
continue;
}
influencers.push(influencer);
// 将网红添加到项目
await supabase_1.default
.from('project_influencers')
.insert({
project_id: project.id,
influencer_id: influencer.influencer_id
});
// 为每个网红创建 3-5 个帖子
const postCount = Math.floor(Math.random() * 3) + 3;
for (let j = 1; j <= postCount; j++) {
const { data: post, error: postError } = await supabase_1.default
.from('posts')
.insert({
influencer_id: influencer.influencer_id,
platform,
post_url: `https://${platform}.com/user${i}/post${j}`,
title: `测试帖子 ${j} - 由 ${influencer.name} 发布`,
description: `这是一个测试帖子的描述 ${j}`,
published_at: new Date(Date.now() - Math.floor(Math.random() * 30) * 24 * 60 * 60 * 1000).toISOString()
})
.select()
.single();
if (postError) {
console.error(`创建测试帖子 ${j} 失败:`, postError);
continue;
}
// 为每个帖子创建 2-10 个评论
const commentCount = Math.floor(Math.random() * 9) + 2;
for (let k = 1; k <= commentCount; k++) {
await supabase_1.default
.from('comments')
.insert({
post_id: post.post_id,
user_id: user.user.id,
content: `这是对帖子 ${post.title} 的测试评论 ${k}`,
sentiment_score: (Math.random() * 2 - 1) // -1 到 1 之间的随机数
});
}
// 创建 ClickHouse 事件数据
// 粉丝变化事件
await clickhouse_1.default.query({
query: `
INSERT INTO events (
project_id,
influencer_id,
platform,
event_type,
metric_value,
event_metadata
) VALUES (?, ?, ?, 'follower_change', ?, ?)
`,
values: [
project.id,
influencer.influencer_id,
platform,
Math.floor(Math.random() * 1000) - 200, // -200 到 800 之间的随机数
JSON.stringify({ source: 'api_crawler' })
]
});
// 帖子点赞变化事件
await clickhouse_1.default.query({
query: `
INSERT INTO events (
project_id,
influencer_id,
post_id,
platform,
event_type,
metric_value,
event_metadata
) VALUES (?, ?, ?, ?, 'post_like_change', ?, ?)
`,
values: [
project.id,
influencer.influencer_id,
post.post_id,
platform,
Math.floor(Math.random() * 500) + 10, // 10 到 510 之间的随机数
JSON.stringify({ source: 'api_crawler' })
]
});
// 帖子观看数变化事件
await clickhouse_1.default.query({
query: `
INSERT INTO events (
project_id,
influencer_id,
post_id,
platform,
event_type,
metric_value,
event_metadata
) VALUES (?, ?, ?, ?, 'post_view_change', ?, ?)
`,
values: [
project.id,
influencer.influencer_id,
post.post_id,
platform,
Math.floor(Math.random() * 5000) + 100, // 100 到 5100 之间的随机数
JSON.stringify({ source: 'api_crawler' })
]
});
// 互动事件
const interactionTypes = ['click', 'comment', 'share'];
const interactionType = interactionTypes[Math.floor(Math.random() * interactionTypes.length)];
await clickhouse_1.default.query({
query: `
INSERT INTO events (
project_id,
influencer_id,
post_id,
platform,
event_type,
metric_value,
event_metadata
) VALUES (?, ?, ?, ?, ?, ?, ?)
`,
values: [
project.id,
influencer.influencer_id,
post.post_id,
platform,
interactionType,
1,
JSON.stringify({
ip: '192.168.1.' + Math.floor(Math.random() * 255),
user_agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
})
]
});
}
}
// 创建项目评论事件
for (let i = 1; i <= 5; i++) {
await clickhouse_1.default.query({
query: `
INSERT INTO events (
project_id,
event_type,
metric_value,
event_metadata
) VALUES (?, 'project_comment', ?, ?)
`,
values: [
project.id,
1,
JSON.stringify({
user_id: user.user.id,
timestamp: new Date().toISOString(),
comment: `项目评论事件 ${i}`
})
]
});
}
console.log('测试数据创建完成');
return true;
}
catch (error) {
console.error('创建测试数据失败:', error);
return false;
}
};
exports.createSampleData = createSampleData;
/**
* 检查数据库连接
*/
const checkDatabaseConnection = async () => {
try {
console.log('检查数据库连接...');
// 检查 Supabase 连接
try {
// 仅检查连接是否正常,不执行实际查询
const { data, error } = await supabase_1.default.auth.getSession();
if (error) {
console.error('Supabase 连接测试失败:', error);
return false;
}
console.log('Supabase 连接正常');
}
catch (supabaseError) {
console.error('Supabase 连接测试失败:', supabaseError);
return false;
}
// 检查 ClickHouse 连接
try {
// 使用简单查询代替ping方法
const result = await clickhouse_1.default.query({ query: 'SELECT 1' });
console.log('ClickHouse 连接正常');
}
catch (error) {
console.error('ClickHouse 连接测试失败:', error);
return false;
}
console.log('数据库连接检查完成,所有连接均正常');
return true;
}
catch (error) {
console.error('数据库连接检查失败:', error);
return false;
}
};
exports.checkDatabaseConnection = checkDatabaseConnection;
/**
* 初始化数据库 - 此函数现在仅作为手动初始化的入口点
* 只有通过管理API明确调用时才会执行实际的初始化
*/
const initDatabase = async () => {
try {
console.log('开始数据库初始化...');
console.log('警告: 此操作将修改数据库结构,请确保您知道自己在做什么');
// 初始化 Supabase 函数
await (0, exports.initSupabaseFunctions)();
// 初始化 Supabase 表
await (0, exports.initSupabaseTables)();
// 初始化 ClickHouse 表
await (0, exports.initClickHouseTables)();
console.log('数据库初始化完成');
return true;
}
catch (error) {
console.error('数据库初始化失败:', error);
return false;
}
};
exports.initDatabase = initDatabase;

158
backend/dist/utils/queue.js vendored Normal file
View File

@@ -0,0 +1,158 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.addNotificationJob = exports.addAnalyticsJob = exports.initWorkers = exports.QUEUE_NAMES = void 0;
const bullmq_1 = require("bullmq");
const config_1 = __importDefault(require("../config"));
// Define queue names
exports.QUEUE_NAMES = {
ANALYTICS: 'analytics',
NOTIFICATIONS: 'notifications',
};
// Create Redis connection options
const redisOptions = {
host: config_1.default.bull.redis.host,
port: config_1.default.bull.redis.port,
password: config_1.default.bull.redis.password,
};
// Create queues with error handling
let analyticsQueue;
let notificationsQueue;
try {
analyticsQueue = new bullmq_1.Queue(exports.QUEUE_NAMES.ANALYTICS, {
connection: redisOptions,
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 1000,
},
},
});
notificationsQueue = new bullmq_1.Queue(exports.QUEUE_NAMES.NOTIFICATIONS, {
connection: redisOptions,
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 1000,
},
},
});
}
catch (error) {
console.error('Error initializing BullMQ queues:', error);
// Create mock queues for development
analyticsQueue = {
add: async (name, data) => {
console.log(`Mock analytics job added: ${name}`, data);
return { id: 'mock-job-id' };
},
close: async () => console.log('Mock analytics queue closed'),
};
notificationsQueue = {
add: async (name, data) => {
console.log(`Mock notification job added: ${name}`, data);
return { id: 'mock-job-id' };
},
close: async () => console.log('Mock notifications queue closed'),
};
}
// Initialize workers
const initWorkers = () => {
try {
// Analytics worker
const analyticsWorker = new bullmq_1.Worker(exports.QUEUE_NAMES.ANALYTICS, async (job) => {
console.log(`Processing analytics job ${job.id}`);
const { type, data } = job.data;
switch (type) {
case 'process_views':
// Process view analytics
console.log('Processing view analytics', data);
break;
case 'process_likes':
// Process like analytics
console.log('Processing like analytics', data);
break;
case 'process_followers':
// Process follower analytics
console.log('Processing follower analytics', data);
break;
default:
console.log(`Unknown analytics job type: ${type}`);
}
}, { connection: redisOptions });
// Notifications worker
const notificationsWorker = new bullmq_1.Worker(exports.QUEUE_NAMES.NOTIFICATIONS, async (job) => {
console.log(`Processing notification job ${job.id}`);
const { type, data } = job.data;
switch (type) {
case 'new_follower':
// Send new follower notification
console.log('Sending new follower notification', data);
break;
case 'new_like':
// Send new like notification
console.log('Sending new like notification', data);
break;
default:
console.log(`Unknown notification job type: ${type}`);
}
}, { connection: redisOptions });
// Handle worker events
analyticsWorker.on('completed', (job) => {
console.log(`Analytics job ${job.id} completed`);
});
analyticsWorker.on('failed', (job, err) => {
console.error(`Analytics job ${job?.id} failed with error ${err.message}`);
});
notificationsWorker.on('completed', (job) => {
console.log(`Notification job ${job.id} completed`);
});
notificationsWorker.on('failed', (job, err) => {
console.error(`Notification job ${job?.id} failed with error ${err.message}`);
});
return {
analyticsWorker,
notificationsWorker,
};
}
catch (error) {
console.error('Error initializing BullMQ workers:', error);
// Return mock workers
return {
analyticsWorker: {
close: async () => console.log('Mock analytics worker closed'),
},
notificationsWorker: {
close: async () => console.log('Mock notifications worker closed'),
},
};
}
};
exports.initWorkers = initWorkers;
// Helper function to add jobs to queues
const addAnalyticsJob = async (type, data, options = {}) => {
try {
return await analyticsQueue.add(type, { type, data }, options);
}
catch (error) {
console.error('Error adding analytics job:', error);
console.log('Job details:', { type, data });
return null;
}
};
exports.addAnalyticsJob = addAnalyticsJob;
const addNotificationJob = async (type, data, options = {}) => {
try {
return await notificationsQueue.add(type, { type, data }, options);
}
catch (error) {
console.error('Error adding notification job:', error);
console.log('Job details:', { type, data });
return null;
}
};
exports.addNotificationJob = addNotificationJob;

80
backend/dist/utils/redis.js vendored Normal file
View File

@@ -0,0 +1,80 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.getRedisClient = exports.connectRedis = exports.redisClient = void 0;
const redis_1 = require("redis");
const config_1 = __importDefault(require("../config"));
// Create Redis client
const redisClient = (0, redis_1.createClient)({
url: `redis://${config_1.default.redis.password ? `${config_1.default.redis.password}@` : ''}${config_1.default.redis.host}:${config_1.default.redis.port}`,
});
exports.redisClient = redisClient;
// Handle Redis connection errors
redisClient.on('error', (err) => {
console.error('Redis Client Error:', err);
});
// Create a mock Redis client for development when real connection fails
const createMockRedisClient = () => {
const store = new Map();
return {
isOpen: true,
connect: async () => console.log('Mock Redis client connected'),
get: async (key) => store.get(key) || null,
set: async (key, value) => {
store.set(key, value);
return 'OK';
},
incr: async (key) => {
const current = parseInt(store.get(key) || '0', 10);
const newValue = current + 1;
store.set(key, newValue.toString());
return newValue;
},
decr: async (key) => {
const current = parseInt(store.get(key) || '0', 10);
const newValue = Math.max(0, current - 1);
store.set(key, newValue.toString());
return newValue;
},
quit: async () => console.log('Mock Redis client disconnected'),
};
};
// Connect to Redis
let mockRedisClient = null;
const connectRedis = async () => {
try {
if (!redisClient.isOpen) {
await redisClient.connect();
console.log('Redis client connected');
}
return redisClient;
}
catch (error) {
console.error('Failed to connect to Redis:', error);
console.log('Using mock Redis client for development...');
if (!mockRedisClient) {
mockRedisClient = createMockRedisClient();
}
return mockRedisClient;
}
};
exports.connectRedis = connectRedis;
// Export the appropriate client
const getRedisClient = async () => {
try {
if (redisClient.isOpen) {
return redisClient;
}
return await connectRedis();
}
catch (error) {
if (!mockRedisClient) {
mockRedisClient = createMockRedisClient();
}
return mockRedisClient;
}
};
exports.getRedisClient = getRedisClient;
exports.default = redisClient;

18
backend/dist/utils/supabase.js vendored Normal file
View File

@@ -0,0 +1,18 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const supabase_js_1 = require("@supabase/supabase-js");
const config_1 = __importDefault(require("../config"));
// Validate Supabase URL
const validateSupabaseUrl = (url) => {
if (!url || !url.startsWith('http')) {
console.warn('Invalid Supabase URL provided. Using a placeholder for development.');
return 'https://example.supabase.co';
}
return url;
};
// Create a single supabase client for interacting with your database
const supabase = (0, supabase_js_1.createClient)(validateSupabaseUrl(config_1.default.supabase.url), config_1.default.supabase.key || 'dummy-key');
exports.default = supabase;

38
backend/package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "backend",
"version": "1.0.0",
"description": "Backend API for promote platform",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"lint": "eslint src --ext .ts",
"test": "vitest run"
},
"keywords": [],
"author": "",
"license": "ISC",
"packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0",
"dependencies": {
"@clickhouse/client": "^0.2.10",
"@hono/node-server": "^1.13.8",
"@hono/swagger-ui": "^0.5.1",
"@supabase/supabase-js": "^2.49.1",
"bullmq": "^5.4.1",
"dotenv": "^16.4.7",
"hono": "^4.7.4",
"jsonwebtoken": "^9.0.2",
"redis": "^4.7.0"
},
"devDependencies": {
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^20.11.30",
"@typescript-eslint/eslint-plugin": "^7.4.0",
"@typescript-eslint/parser": "^7.4.0",
"eslint": "^8.57.0",
"tsx": "^4.7.1",
"typescript": "^5.4.3",
"vitest": "^1.4.0"
}
}

2878
backend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,55 @@
import dotenv from 'dotenv';
import { join } from 'path';
// Load environment variables from .env file
dotenv.config({ path: join(__dirname, '../../.env') });
export const config = {
port: process.env.PORT || 4000,
// Supabase configuration
supabase: {
url: process.env.SUPABASE_URL || '',
key: process.env.SUPABASE_KEY || '',
anonKey: process.env.SUPABASE_ANON_KEY || '',
},
// Redis configuration
redis: {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379', 10),
password: process.env.REDIS_PASSWORD || '',
},
// ClickHouse configuration
clickhouse: {
host: process.env.CLICKHOUSE_HOST || 'localhost',
port: process.env.CLICKHOUSE_PORT || '8123',
user: process.env.CLICKHOUSE_USER || 'admin',
password: process.env.CLICKHOUSE_PASSWORD || 'your_secure_password',
database: process.env.CLICKHOUSE_DATABASE || 'promote',
},
// BullMQ configuration
bull: {
redis: {
host: process.env.BULL_REDIS_HOST || 'localhost',
port: parseInt(process.env.BULL_REDIS_PORT || '6379', 10),
password: process.env.BULL_REDIS_PASSWORD || '',
},
},
// JWT configuration
jwt: {
secret: process.env.JWT_SECRET || 'your-secret-key',
expiresIn: process.env.JWT_EXPIRES_IN || '7d',
},
// Domain configuration
domain: process.env.DOMAIN || 'upj.to',
// Enabled routes
enabledRoutes: process.env.ENABLED_ROUTES || 'all',
};
export default config;

View File

@@ -0,0 +1,120 @@
import { Context } from 'hono';
import supabase from '../utils/supabase';
export const getComments = async (c: Context) => {
try {
const { post_id, limit = '10', offset = '0' } = c.req.query();
let query;
if (post_id) {
// 获取特定帖子的评论
query = supabase.rpc('get_comments_for_post', { post_id_param: post_id });
} else {
// 获取所有评论
query = supabase.rpc('get_comments_with_posts');
}
// 应用分页
query = query.range(Number(offset), Number(offset) + Number(limit) - 1);
const { data: comments, error, count } = await query;
if (error) {
return c.json({ error: error.message }, 500);
}
return c.json({
comments,
count,
limit: Number(limit),
offset: Number(offset)
});
} catch (error) {
return c.json({ error: 'Internal server error' }, 500);
}
};
export const createComment = async (c: Context) => {
try {
const { post_id, content } = await c.req.json();
const user_id = c.get('user')?.id;
if (!user_id) {
return c.json({ error: 'Unauthorized' }, 401);
}
const { data: comment, error } = await supabase
.from('comments')
.insert({
post_id,
content,
user_id
})
.select(`
comment_id,
content,
sentiment_score,
created_at,
updated_at,
post_id,
user_id
`)
.single();
if (error) {
return c.json({ error: error.message }, 500);
}
// 获取用户信息
const { data: userProfile, error: userError } = await supabase
.from('user_profiles')
.select('id, full_name, avatar_url')
.eq('id', user_id)
.single();
if (!userError && userProfile) {
comment.user_profile = userProfile;
}
return c.json(comment, 201);
} catch (error) {
return c.json({ error: 'Internal server error' }, 500);
}
};
export const deleteComment = async (c: Context) => {
try {
const { comment_id } = c.req.param();
const user_id = c.get('user')?.id;
if (!user_id) {
return c.json({ error: 'Unauthorized' }, 401);
}
// Check if the comment belongs to the user
const { data: comment, error: fetchError } = await supabase
.from('comments')
.select()
.eq('comment_id', comment_id)
.eq('user_id', user_id)
.single();
if (fetchError || !comment) {
return c.json({ error: 'Comment not found or unauthorized' }, 404);
}
const { error: deleteError } = await supabase
.from('comments')
.delete()
.eq('comment_id', comment_id);
if (deleteError) {
return c.json({ error: deleteError.message }, 500);
}
return c.body(null, 204);
} catch (error) {
return c.json({ error: 'Internal server error' }, 500);
}
};

View File

@@ -0,0 +1,136 @@
import { Context } from 'hono';
import supabase from '../utils/supabase';
export const getInfluencers = async (c: Context) => {
try {
const {
platform,
limit = '10',
offset = '0',
min_followers,
max_followers,
sort_by = 'followers_count',
sort_order = 'desc'
} = c.req.query();
let query = supabase
.from('influencers')
.select(`
influencer_id,
name,
platform,
profile_url,
followers_count,
video_count,
platform_count,
created_at,
updated_at
`);
// Apply filters
if (platform) {
query = query.eq('platform', platform);
}
if (min_followers) {
query = query.gte('followers_count', Number(min_followers));
}
if (max_followers) {
query = query.lte('followers_count', Number(max_followers));
}
// Apply sorting
if (sort_by && ['followers_count', 'video_count', 'created_at'].includes(sort_by)) {
query = query.order(sort_by, { ascending: sort_order === 'asc' });
}
// Apply pagination
query = query.range(Number(offset), Number(offset) + Number(limit) - 1);
const { data: influencers, error, count } = await query;
if (error) {
return c.json({ error: error.message }, 500);
}
return c.json({
influencers,
count,
limit: Number(limit),
offset: Number(offset)
});
} catch (error) {
return c.json({ error: 'Internal server error' }, 500);
}
};
export const getInfluencerById = async (c: Context) => {
try {
const { influencer_id } = c.req.param();
const { data: influencer, error } = await supabase
.from('influencers')
.select(`
influencer_id,
name,
platform,
profile_url,
followers_count,
video_count,
platform_count,
created_at,
updated_at,
posts (
post_id,
title,
description,
published_at
)
`)
.eq('influencer_id', influencer_id)
.single();
if (error) {
return c.json({ error: 'Influencer not found' }, 404);
}
return c.json(influencer);
} catch (error) {
return c.json({ error: 'Internal server error' }, 500);
}
};
export const getInfluencerStats = async (c: Context) => {
try {
const { platform } = c.req.query();
let query = supabase
.from('influencers')
.select('platform, followers_count, video_count');
if (platform) {
query = query.eq('platform', platform);
}
const { data: stats, error } = await query;
if (error) {
return c.json({ error: error.message }, 500);
}
const aggregatedStats = {
total_influencers: stats.length,
total_followers: stats.reduce((sum: number, item: any) => sum + (item.followers_count || 0), 0),
total_videos: stats.reduce((sum: number, item: any) => sum + (item.video_count || 0), 0),
average_followers: Math.round(
stats.reduce((sum: number, item: any) => sum + (item.followers_count || 0), 0) / (stats.length || 1)
),
average_videos: Math.round(
stats.reduce((sum: number, item: any) => sum + (item.video_count || 0), 0) / (stats.length || 1)
)
};
return c.json(aggregatedStats);
} catch (error) {
return c.json({ error: 'Internal server error' }, 500);
}
};

172
backend/src/index.ts Normal file
View File

@@ -0,0 +1,172 @@
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import config from './config';
import authRouter from './routes/auth';
import analyticsRouter from './routes/analytics';
import communityRouter from './routes/community';
import postsRouter from './routes/posts';
import projectCommentsRouter from './routes/projectComments';
import commentsRouter from './routes/comments';
import influencersRouter from './routes/influencers';
import { connectRedis } from './utils/redis';
import { initClickHouse } from './utils/clickhouse';
import { initWorkers } from './utils/queue';
import { initDatabase, createSampleData, checkDatabaseConnection } from './utils/initDatabase';
import { createSwaggerUI } from './swagger';
// Create Hono app
const app = new Hono();
// Middleware
app.use('*', logger());
app.use('*', cors({
origin: '*',
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
exposeHeaders: ['Content-Length'],
maxAge: 86400,
}));
// Health check route
app.get('/', (c) => {
return c.json({
status: 'ok',
message: 'Promote API is running',
version: '1.0.0',
});
});
// 数据库初始化路由
app.post('/api/admin/init-db', async (c) => {
try {
const result = await initDatabase();
return c.json({
success: result,
message: result ? 'Database initialized successfully' : 'Database initialization failed'
});
} catch (error) {
console.error('Error initializing database:', error);
return c.json({
success: false,
message: 'Error initializing database',
error: error instanceof Error ? error.message : String(error)
}, 500);
}
});
// 创建测试数据路由
app.post('/api/admin/create-sample-data', async (c) => {
try {
const result = await createSampleData();
return c.json({
success: result,
message: result ? 'Sample data created successfully' : 'Sample data creation failed'
});
} catch (error) {
console.error('Error creating sample data:', error);
return c.json({
success: false,
message: 'Error creating sample data',
error: error instanceof Error ? error.message : String(error)
}, 500);
}
});
// Routes
app.route('/api/auth', authRouter);
app.route('/api/analytics', analyticsRouter);
app.route('/api/community', communityRouter);
app.route('/api/posts', postsRouter);
app.route('/api/project-comments', projectCommentsRouter);
app.route('/api/comments', commentsRouter);
app.route('/api/influencers', influencersRouter);
// Swagger UI
const swaggerApp = createSwaggerUI();
app.route('', swaggerApp);
// Initialize services and start server
const startServer = async () => {
try {
// Connect to Redis
try {
await connectRedis();
console.log('Connected to Redis');
} catch (error) {
console.error('Failed to connect to Redis:', error);
console.log('Continuing with mock Redis client...');
}
// Initialize ClickHouse
try {
await initClickHouse();
console.log('ClickHouse initialized');
} catch (error) {
console.error('Failed to initialize ClickHouse:', error);
console.log('Continuing with limited analytics functionality...');
}
// 检查数据库连接,但不自动初始化或修改数据库
try {
await checkDatabaseConnection();
} catch (error) {
console.error('Database connection check failed:', error);
console.log('Some features may not work correctly if database is not properly set up');
}
console.log('NOTICE: Database will NOT be automatically initialized on startup');
console.log('Use /api/admin/init-db endpoint to manually initialize the database if needed');
// Initialize BullMQ workers
let workers;
try {
workers = initWorkers();
console.log('BullMQ workers initialized');
} catch (error) {
console.error('Failed to initialize BullMQ workers:', error);
console.log('Background processing will not be available...');
workers = { analyticsWorker: null, notificationsWorker: null };
}
// Start server
const port = Number(config.port);
console.log(`Server starting on port ${port}...`);
serve({
fetch: app.fetch,
port,
});
console.log(`Server running at http://localhost:${port}`);
console.log(`Swagger UI available at http://localhost:${port}/swagger`);
console.log(`Initialize database at http://localhost:${port}/api/admin/init-db (POST)`);
console.log(`Create sample data at http://localhost:${port}/api/admin/create-sample-data (POST)`);
// Handle graceful shutdown
const shutdown = async () => {
console.log('Shutting down server...');
// Close workers if they exist
if (workers.analyticsWorker) {
await workers.analyticsWorker.close();
}
if (workers.notificationsWorker) {
await workers.notificationsWorker.close();
}
process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
} catch (error) {
console.error('Failed to start server:', error);
process.exit(1);
}
};
// Start the server
startServer();

View File

@@ -0,0 +1,101 @@
import { Context, Next } from 'hono';
import jwt from 'jsonwebtoken';
import config from '../config';
import supabase from '../utils/supabase';
// Interface for JWT payload
interface JwtPayload {
sub: string;
email: string;
iat: number;
exp: number;
}
// Middleware to verify JWT token
export const authMiddleware = async (c: Context, next: Next) => {
try {
// Get authorization header
const authHeader = c.req.header('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return c.json({ error: 'Unauthorized: No token provided' }, 401);
}
// Extract token
const token = authHeader.split(' ')[1];
try {
// 验证 JWT token
const decoded = jwt.verify(token, config.jwt.secret) as JwtPayload;
// 特殊处理 Swagger 测试 token
if (decoded.sub === 'swagger-test-user' && decoded.email === 'swagger@test.com') {
// 为 Swagger 测试设置一个模拟用户
c.set('user', {
id: 'swagger-test-user',
email: 'swagger@test.com',
name: 'Swagger Test User'
});
// 继续到下一个中间件或路由处理器
await next();
return;
}
// 设置用户信息到上下文
c.set('user', {
id: decoded.sub,
email: decoded.email
});
// 继续到下一个中间件或路由处理器
await next();
} catch (jwtError) {
if (jwtError instanceof jwt.JsonWebTokenError) {
return c.json({ error: 'Unauthorized: Invalid token' }, 401);
}
if (jwtError instanceof jwt.TokenExpiredError) {
return c.json({ error: 'Unauthorized: Token expired' }, 401);
}
throw jwtError;
}
} catch (error) {
console.error('Auth middleware error:', error);
return c.json({ error: 'Internal server error' }, 500);
}
};
// Generate JWT token
export const generateToken = (userId: string, email: string): string => {
const secret = config.jwt.secret;
const expiresIn = config.jwt.expiresIn;
return jwt.sign(
{
sub: userId,
email,
},
secret,
{
expiresIn,
}
);
};
// Verify Supabase token
export const verifySupabaseToken = async (token: string) => {
try {
const { data, error } = await supabase.auth.getUser(token);
if (error || !data.user) {
return null;
}
return data.user;
} catch (error) {
console.error('Supabase token verification error:', error);
return null;
}
};

View File

@@ -0,0 +1,522 @@
import { Hono } from 'hono';
import { authMiddleware } from '../middlewares/auth';
import clickhouse from '../utils/clickhouse';
import { addAnalyticsJob } from '../utils/queue';
import { getRedisClient } from '../utils/redis';
import supabase from '../utils/supabase';
// Define user type
interface User {
id: string;
email: string;
name?: string;
}
// Extend Hono's Context type
declare module 'hono' {
interface ContextVariableMap {
user: User;
}
}
const analyticsRouter = new Hono();
// Apply auth middleware to all routes
analyticsRouter.use('*', authMiddleware);
// Track a view event
analyticsRouter.post('/view', async (c) => {
try {
const { content_id } = await c.req.json();
const user = c.get('user');
if (!content_id) {
return c.json({ error: 'Content ID is required' }, 400);
}
// Get IP and user agent
const ip = c.req.header('x-forwarded-for') || c.req.header('x-real-ip') || '0.0.0.0';
const userAgent = c.req.header('user-agent') || 'unknown';
// Insert view event into ClickHouse
await clickhouse.query({
query: `
INSERT INTO promote.view_events (user_id, content_id, ip, user_agent)
VALUES (?, ?, ?, ?)
`,
values: [
user.id,
content_id,
ip,
userAgent
]
});
// Queue analytics processing job
await addAnalyticsJob('process_views', {
user_id: user.id,
content_id,
timestamp: new Date().toISOString()
});
// Increment view count in Redis cache
const redis = await getRedisClient();
await redis.incr(`views:${content_id}`);
return c.json({ message: 'View tracked successfully' });
} catch (error) {
console.error('View tracking error:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// Track a like event
analyticsRouter.post('/like', async (c) => {
try {
const { content_id, action } = await c.req.json();
const user = c.get('user');
if (!content_id || !action) {
return c.json({ error: 'Content ID and action are required' }, 400);
}
if (action !== 'like' && action !== 'unlike') {
return c.json({ error: 'Action must be either "like" or "unlike"' }, 400);
}
// Insert like event into ClickHouse
await clickhouse.query({
query: `
INSERT INTO promote.like_events (user_id, content_id, action)
VALUES (?, ?, ?)
`,
values: [
user.id,
content_id,
action === 'like' ? 1 : 2
]
});
// Queue analytics processing job
await addAnalyticsJob('process_likes', {
user_id: user.id,
content_id,
action,
timestamp: new Date().toISOString()
});
// Update like count in Redis cache
const redis = await getRedisClient();
const likeKey = `likes:${content_id}`;
if (action === 'like') {
await redis.incr(likeKey);
} else {
await redis.decr(likeKey);
}
return c.json({ message: `${action} tracked successfully` });
} catch (error) {
console.error('Like tracking error:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// Track a follow event
analyticsRouter.post('/follow', async (c) => {
try {
const { followed_id, action } = await c.req.json();
const user = c.get('user');
if (!followed_id || !action) {
return c.json({ error: 'Followed ID and action are required' }, 400);
}
if (action !== 'follow' && action !== 'unfollow') {
return c.json({ error: 'Action must be either "follow" or "unfollow"' }, 400);
}
// Insert follower event into ClickHouse
await clickhouse.query({
query: `
INSERT INTO promote.follower_events (follower_id, followed_id, action)
VALUES (?, ?, ?)
`,
values: [
user.id,
followed_id,
action === 'follow' ? 1 : 2
]
});
// Queue analytics processing job
await addAnalyticsJob('process_followers', {
follower_id: user.id,
followed_id,
action,
timestamp: new Date().toISOString()
});
// Update follower count in Redis cache
const redis = await getRedisClient();
const followerKey = `followers:${followed_id}`;
if (action === 'follow') {
await redis.incr(followerKey);
} else {
await redis.decr(followerKey);
}
return c.json({ message: `${action} tracked successfully` });
} catch (error) {
console.error('Follow tracking error:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// Get analytics for a content
analyticsRouter.get('/content/:id', async (c) => {
try {
const contentId = c.req.param('id');
// Get counts from Redis cache
const redis = await getRedisClient();
const [views, likes] = await Promise.all([
redis.get(`views:${contentId}`),
redis.get(`likes:${contentId}`)
]);
return c.json({
content_id: contentId,
views: parseInt(views || '0'),
likes: parseInt(likes || '0')
});
} catch (error) {
console.error('Content analytics error:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// Get analytics for a user
analyticsRouter.get('/user/:id', async (c) => {
try {
const userId = c.req.param('id');
// Get follower count from Redis cache
const redis = await getRedisClient();
const followers = await redis.get(`followers:${userId}`);
// Get content view and like counts from ClickHouse
const viewsResult = await clickhouse.query({
query: `
SELECT content_id, COUNT(*) as view_count
FROM promote.view_events
WHERE user_id = ?
GROUP BY content_id
`,
values: [userId]
});
const likesResult = await clickhouse.query({
query: `
SELECT content_id, SUM(CASE WHEN action = 1 THEN 1 ELSE -1 END) as like_count
FROM promote.like_events
WHERE user_id = ?
GROUP BY content_id
`,
values: [userId]
});
// Extract data from results
const viewsData = 'rows' in viewsResult ? viewsResult.rows : [];
const likesData = 'rows' in likesResult ? likesResult.rows : [];
return c.json({
user_id: userId,
followers: parseInt(followers || '0'),
content_analytics: {
views: viewsData,
likes: likesData
}
});
} catch (error) {
console.error('User analytics error:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 社群分析相关路由
// 获取项目的顶级影响者
analyticsRouter.get('/project/:id/top-influencers', async (c) => {
try {
const projectId = c.req.param('id');
// 从ClickHouse查询项目的顶级影响者
const result = await clickhouse.query({
query: `
SELECT
influencer_id,
SUM(metric_value) AS total_views
FROM events
WHERE
project_id = ? AND
event_type = 'post_view_change'
GROUP BY influencer_id
ORDER BY total_views DESC
LIMIT 10
`,
values: [projectId]
});
// 提取数据
const influencerData = 'rows' in result ? result.rows : [];
// 如果有数据从Supabase获取影响者详细信息
if (influencerData.length > 0) {
const influencerIds = influencerData.map((item: any) => item.influencer_id);
const { data: influencerDetails, error } = await supabase
.from('influencers')
.select('influencer_id, name, platform, followers_count, video_count')
.in('influencer_id', influencerIds);
if (error) {
console.error('Error fetching influencer details:', error);
return c.json({ error: 'Error fetching influencer details' }, 500);
}
// 合并数据
const enrichedData = influencerData.map((item: any) => {
const details = influencerDetails?.find(
(detail) => detail.influencer_id === item.influencer_id
) || {};
return {
...item,
...details
};
});
return c.json(enrichedData);
}
return c.json(influencerData);
} catch (error) {
console.error('Error fetching top influencers:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 获取影响者的粉丝变化趋势过去6个月
analyticsRouter.get('/influencer/:id/follower-trend', async (c) => {
try {
const influencerId = c.req.param('id');
// 从ClickHouse查询影响者的粉丝变化趋势
const result = await clickhouse.query({
query: `
SELECT
toStartOfMonth(timestamp) AS month,
SUM(metric_value) AS follower_change
FROM events
WHERE
influencer_id = ? AND
event_type = 'follower_change' AND
timestamp >= subtractMonths(now(), 6)
GROUP BY month
ORDER BY month ASC
`,
values: [influencerId]
});
// 提取数据
const trendData = 'rows' in result ? result.rows : [];
return c.json({
influencer_id: influencerId,
follower_trend: trendData
});
} catch (error) {
console.error('Error fetching follower trend:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 获取帖子的点赞变化过去30天
analyticsRouter.get('/post/:id/like-trend', async (c) => {
try {
const postId = c.req.param('id');
// 从ClickHouse查询帖子的点赞变化
const result = await clickhouse.query({
query: `
SELECT
toDate(timestamp) AS day,
SUM(metric_value) AS like_change
FROM events
WHERE
post_id = ? AND
event_type = 'post_like_change' AND
timestamp >= subtractDays(now(), 30)
GROUP BY day
ORDER BY day ASC
`,
values: [postId]
});
// 提取数据
const trendData = 'rows' in result ? result.rows : [];
return c.json({
post_id: postId,
like_trend: trendData
});
} catch (error) {
console.error('Error fetching like trend:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 获取影响者详细信息
analyticsRouter.get('/influencer/:id/details', async (c) => {
try {
const influencerId = c.req.param('id');
// 从Supabase获取影响者详细信息
const { data, error } = await supabase
.from('influencers')
.select('influencer_id, name, platform, profile_url, external_id, followers_count, video_count, platform_count, created_at')
.eq('influencer_id', influencerId)
.single();
if (error) {
console.error('Error fetching influencer details:', error);
return c.json({ error: 'Error fetching influencer details' }, 500);
}
if (!data) {
return c.json({ error: 'Influencer not found' }, 404);
}
return c.json(data);
} catch (error) {
console.error('Error fetching influencer details:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 获取影响者的帖子列表
analyticsRouter.get('/influencer/:id/posts', async (c) => {
try {
const influencerId = c.req.param('id');
// 从Supabase获取影响者的帖子列表
const { data, error } = await supabase
.from('posts')
.select('post_id, influencer_id, platform, post_url, title, description, published_at, created_at')
.eq('influencer_id', influencerId)
.order('published_at', { ascending: false });
if (error) {
console.error('Error fetching influencer posts:', error);
return c.json({ error: 'Error fetching influencer posts' }, 500);
}
return c.json(data || []);
} catch (error) {
console.error('Error fetching influencer posts:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 获取帖子的评论列表
analyticsRouter.get('/post/:id/comments', async (c) => {
try {
const postId = c.req.param('id');
// 从Supabase获取帖子的评论列表
const { data, error } = await supabase
.from('comments')
.select('comment_id, post_id, user_id, content, sentiment_score, created_at')
.eq('post_id', postId)
.order('created_at', { ascending: false });
if (error) {
console.error('Error fetching post comments:', error);
return c.json({ error: 'Error fetching post comments' }, 500);
}
return c.json(data || []);
} catch (error) {
console.error('Error fetching post comments:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 获取项目的平台分布
analyticsRouter.get('/project/:id/platform-distribution', async (c) => {
try {
const projectId = c.req.param('id');
// 从ClickHouse查询项目的平台分布
const result = await clickhouse.query({
query: `
SELECT
platform,
COUNT(DISTINCT influencer_id) AS influencer_count
FROM events
WHERE project_id = ?
GROUP BY platform
ORDER BY influencer_count DESC
`,
values: [projectId]
});
// 提取数据
const distributionData = 'rows' in result ? result.rows : [];
return c.json({
project_id: projectId,
platform_distribution: distributionData
});
} catch (error) {
console.error('Error fetching platform distribution:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 获取项目的互动类型分布
analyticsRouter.get('/project/:id/interaction-types', async (c) => {
try {
const projectId = c.req.param('id');
// 从ClickHouse查询项目的互动类型分布
const result = await clickhouse.query({
query: `
SELECT
event_type,
COUNT(*) AS event_count,
SUM(metric_value) AS total_value
FROM events
WHERE
project_id = ? AND
event_type IN ('click', 'comment', 'share')
GROUP BY event_type
ORDER BY event_count DESC
`,
values: [projectId]
});
// 提取数据
const interactionData = 'rows' in result ? result.rows : [];
return c.json({
project_id: projectId,
interaction_types: interactionData
});
} catch (error) {
console.error('Error fetching interaction types:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
export default analyticsRouter;

159
backend/src/routes/auth.ts Normal file
View File

@@ -0,0 +1,159 @@
import { Hono } from 'hono';
import { generateToken, verifySupabaseToken } from '../middlewares/auth';
import supabase from '../utils/supabase';
import jwt from 'jsonwebtoken';
const authRouter = new Hono();
// Register a new user
authRouter.post('/register', async (c) => {
try {
const { email, password, name } = await c.req.json();
// Validate input
if (!email || !password || !name) {
return c.json({ error: 'Email, password, and name are required' }, 400);
}
// Register user with Supabase
const { data: authData, error: authError } = await supabase.auth.signUp({
email,
password,
});
if (authError) {
return c.json({ error: authError.message }, 400);
}
if (!authData.user) {
return c.json({ error: 'Failed to create user' }, 500);
}
// Create user profile in the database
const { error: profileError } = await supabase
.from('users')
.insert({
id: authData.user.id,
email: authData.user.email,
name,
created_at: new Date().toISOString(),
});
if (profileError) {
// Attempt to clean up the auth user if profile creation fails
await supabase.auth.admin.deleteUser(authData.user.id);
return c.json({ error: profileError.message }, 500);
}
// Generate JWT token
const token = generateToken(authData.user.id, authData.user.email!);
return c.json({
message: 'User registered successfully',
user: {
id: authData.user.id,
email: authData.user.email,
name,
},
token,
}, 201);
} catch (error) {
console.error('Registration error:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// Login user
authRouter.post('/login', async (c) => {
try {
const { email, password } = await c.req.json();
const { data, error } = await supabase.auth.signInWithPassword({
email,
password
});
if (error) {
return c.json({ error: error.message }, 400);
}
// 使用与 authMiddleware 一致的方式创建 JWT
const token = generateToken(data.user.id, data.user.email || '');
// 只返回必要的用户信息和令牌
return c.json({
success: true,
token,
user: {
id: data.user.id,
email: data.user.email
}
});
} catch (error) {
console.error(error);
return c.json({ error: 'Server error' }, 500);
}
});
// Verify token
authRouter.get('/verify', async (c) => {
try {
const token = c.req.header('Authorization')?.split(' ')[1];
if (!token) {
return c.json({ error: 'No token provided' }, 401);
}
const user = await verifySupabaseToken(token);
if (!user) {
return c.json({ error: 'Invalid token' }, 401);
}
return c.json({
message: 'Token is valid',
user: {
id: user.id,
email: user.email,
},
});
} catch (error) {
console.error('Token verification error:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// Refresh token
authRouter.post('/refresh-token', async (c) => {
try {
const token = c.req.header('Authorization')?.split(' ')[1];
if (!token) {
return c.json({ error: 'No token provided' }, 401);
}
// 验证当前token
const user = await verifySupabaseToken(token);
if (!user) {
return c.json({ error: 'Invalid token' }, 401);
}
// 生成新token
const newToken = generateToken(user.id, user.email || '');
return c.json({
message: 'Token refreshed successfully',
token: newToken,
user: {
id: user.id,
email: user.email,
},
});
} catch (error) {
console.error('Token refresh error:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
export default authRouter;

View File

@@ -0,0 +1,14 @@
import { Hono } from 'hono';
import { getComments, createComment, deleteComment } from '../controllers/commentsController';
import { authMiddleware } from '../middlewares/auth';
const commentsRouter = new Hono();
// Public routes
commentsRouter.get('/', getComments);
// Protected routes
commentsRouter.post('/', authMiddleware, createComment);
commentsRouter.delete('/:comment_id', authMiddleware, deleteComment);
export default commentsRouter;

View File

@@ -0,0 +1,770 @@
import { Hono } from 'hono';
import { authMiddleware } from '../middlewares/auth';
import clickhouse from '../utils/clickhouse';
import supabase from '../utils/supabase';
// Define user type
interface User {
id: string;
email: string;
name?: string;
}
// Extend Hono's Context type
declare module 'hono' {
interface ContextVariableMap {
user: User;
}
}
const communityRouter = new Hono();
// Apply auth middleware to all routes
communityRouter.use('*', authMiddleware);
// 创建新项目
communityRouter.post('/projects', async (c) => {
try {
const { name, description, start_date, end_date } = await c.req.json();
const user = c.get('user');
if (!name) {
return c.json({ error: 'Project name is required' }, 400);
}
// 在Supabase中创建项目
const { data, error } = await supabase
.from('projects')
.insert({
name,
description,
start_date,
end_date,
created_by: user.id
})
.select()
.single();
if (error) {
console.error('Error creating project:', error);
return c.json({ error: 'Failed to create project' }, 500);
}
return c.json({
message: 'Project created successfully',
project: data
}, 201);
} catch (error) {
console.error('Error creating project:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 获取项目列表
communityRouter.get('/projects', async (c) => {
try {
const user = c.get('user');
// 从Supabase获取项目列表
const { data, error } = await supabase
.from('projects')
.select('*')
.eq('created_by', user.id)
.order('created_at', { ascending: false });
if (error) {
console.error('Error fetching projects:', error);
return c.json({ error: 'Failed to fetch projects' }, 500);
}
return c.json(data || []);
} catch (error) {
console.error('Error fetching projects:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 获取项目详情
communityRouter.get('/projects/:id', async (c) => {
try {
const projectId = c.req.param('id');
// 从Supabase获取项目详情
const { data, error } = await supabase
.from('projects')
.select('*')
.eq('id', projectId)
.single();
if (error) {
console.error('Error fetching project:', error);
return c.json({ error: 'Failed to fetch project' }, 500);
}
if (!data) {
return c.json({ error: 'Project not found' }, 404);
}
return c.json(data);
} catch (error) {
console.error('Error fetching project:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 更新项目
communityRouter.put('/projects/:id', async (c) => {
try {
const projectId = c.req.param('id');
const { name, description, start_date, end_date, status } = await c.req.json();
const user = c.get('user');
// 检查项目是否存在并属于当前用户
const { data: existingProject, error: fetchError } = await supabase
.from('projects')
.select('*')
.eq('id', projectId)
.eq('created_by', user.id)
.single();
if (fetchError || !existingProject) {
return c.json({ error: 'Project not found or you do not have permission to update it' }, 404);
}
// 更新项目
const { data, error } = await supabase
.from('projects')
.update({
name,
description,
start_date,
end_date,
status,
updated_at: new Date().toISOString()
})
.eq('id', projectId)
.select()
.single();
if (error) {
console.error('Error updating project:', error);
return c.json({ error: 'Failed to update project' }, 500);
}
return c.json({
message: 'Project updated successfully',
project: data
});
} catch (error) {
console.error('Error updating project:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 删除项目
communityRouter.delete('/projects/:id', async (c) => {
try {
const projectId = c.req.param('id');
const user = c.get('user');
// 检查项目是否存在并属于当前用户
const { data: existingProject, error: fetchError } = await supabase
.from('projects')
.select('*')
.eq('id', projectId)
.eq('created_by', user.id)
.single();
if (fetchError || !existingProject) {
return c.json({ error: 'Project not found or you do not have permission to delete it' }, 404);
}
// 删除项目
const { error } = await supabase
.from('projects')
.delete()
.eq('id', projectId);
if (error) {
console.error('Error deleting project:', error);
return c.json({ error: 'Failed to delete project' }, 500);
}
return c.json({
message: 'Project deleted successfully'
});
} catch (error) {
console.error('Error deleting project:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 添加影响者到项目
communityRouter.post('/projects/:id/influencers', async (c) => {
try {
const projectId = c.req.param('id');
const { influencer_id, platform, external_id, name, profile_url } = await c.req.json();
const user = c.get('user');
// 检查项目是否存在并属于当前用户
const { data: existingProject, error: fetchError } = await supabase
.from('projects')
.select('*')
.eq('id', projectId)
.eq('created_by', user.id)
.single();
if (fetchError || !existingProject) {
return c.json({ error: 'Project not found or you do not have permission to update it' }, 404);
}
// 检查影响者是否已存在
let influencerData;
if (influencer_id) {
// 如果提供了影响者ID检查是否存在
const { data, error } = await supabase
.from('influencers')
.select('*')
.eq('influencer_id', influencer_id)
.single();
if (!error && data) {
influencerData = data;
}
} else if (external_id && platform) {
// 如果提供了外部ID和平台检查是否存在
const { data, error } = await supabase
.from('influencers')
.select('*')
.eq('external_id', external_id)
.eq('platform', platform)
.single();
if (!error && data) {
influencerData = data;
}
}
// 如果影响者不存在,创建新的影响者
if (!influencerData) {
if (!name || !platform) {
return c.json({ error: 'Name and platform are required for new influencers' }, 400);
}
const { data, error } = await supabase
.from('influencers')
.insert({
name,
platform,
external_id,
profile_url
})
.select()
.single();
if (error) {
console.error('Error creating influencer:', error);
return c.json({ error: 'Failed to create influencer' }, 500);
}
influencerData = data;
}
// 将影响者添加到项目
const { data: projectInfluencer, error } = await supabase
.from('project_influencers')
.insert({
project_id: projectId,
influencer_id: influencerData.influencer_id
})
.select()
.single();
if (error) {
console.error('Error adding influencer to project:', error);
return c.json({ error: 'Failed to add influencer to project' }, 500);
}
return c.json({
message: 'Influencer added to project successfully',
project_influencer: projectInfluencer,
influencer: influencerData
}, 201);
} catch (error) {
console.error('Error adding influencer to project:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 获取项目的影响者列表
communityRouter.get('/projects/:id/influencers', async (c) => {
try {
const projectId = c.req.param('id');
// 从Supabase获取项目的影响者列表
const { data, error } = await supabase
.from('project_influencers')
.select(`
project_id,
influencers (
influencer_id,
name,
platform,
profile_url,
external_id,
followers_count,
video_count
)
`)
.eq('project_id', projectId);
if (error) {
console.error('Error fetching project influencers:', error);
return c.json({ error: 'Failed to fetch project influencers' }, 500);
}
// 格式化数据
const influencers = data?.map(item => item.influencers) || [];
return c.json(influencers);
} catch (error) {
console.error('Error fetching project influencers:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 从项目中移除影响者
communityRouter.delete('/projects/:projectId/influencers/:influencerId', async (c) => {
try {
const projectId = c.req.param('projectId');
const influencerId = c.req.param('influencerId');
const user = c.get('user');
// 检查项目是否存在并属于当前用户
const { data: existingProject, error: fetchError } = await supabase
.from('projects')
.select('*')
.eq('id', projectId)
.eq('created_by', user.id)
.single();
if (fetchError || !existingProject) {
return c.json({ error: 'Project not found or you do not have permission to update it' }, 404);
}
// 从项目中移除影响者
const { error } = await supabase
.from('project_influencers')
.delete()
.eq('project_id', projectId)
.eq('influencer_id', influencerId);
if (error) {
console.error('Error removing influencer from project:', error);
return c.json({ error: 'Failed to remove influencer from project' }, 500);
}
return c.json({
message: 'Influencer removed from project successfully'
});
} catch (error) {
console.error('Error removing influencer from project:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 添加事件数据
communityRouter.post('/events', async (c) => {
try {
const {
project_id,
influencer_id,
post_id,
platform,
event_type,
metric_value,
event_metadata
} = await c.req.json();
if (!project_id || !influencer_id || !platform || !event_type || metric_value === undefined) {
return c.json({
error: 'Project ID, influencer ID, platform, event type, and metric value are required'
}, 400);
}
// 验证事件类型
const validEventTypes = [
'follower_change',
'post_like_change',
'post_view_change',
'click',
'comment',
'share'
];
if (!validEventTypes.includes(event_type)) {
return c.json({
error: `Invalid event type. Must be one of: ${validEventTypes.join(', ')}`
}, 400);
}
// 验证平台
const validPlatforms = ['youtube', 'instagram', 'tiktok', 'twitter', 'facebook'];
if (!validPlatforms.includes(platform)) {
return c.json({
error: `Invalid platform. Must be one of: ${validPlatforms.join(', ')}`
}, 400);
}
// 将事件数据插入ClickHouse
await clickhouse.query({
query: `
INSERT INTO events (
project_id,
influencer_id,
post_id,
platform,
event_type,
metric_value,
event_metadata
) VALUES (?, ?, ?, ?, ?, ?, ?)
`,
values: [
project_id,
influencer_id,
post_id || null,
platform,
event_type,
metric_value,
event_metadata ? JSON.stringify(event_metadata) : '{}'
]
});
return c.json({
message: 'Event data added successfully'
}, 201);
} catch (error) {
console.error('Error adding event data:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 批量添加事件数据
communityRouter.post('/events/batch', async (c) => {
try {
const { events } = await c.req.json();
if (!Array.isArray(events) || events.length === 0) {
return c.json({ error: 'Events array is required and must not be empty' }, 400);
}
// 验证事件类型和平台
const validEventTypes = [
'follower_change',
'post_like_change',
'post_view_change',
'click',
'comment',
'share'
];
const validPlatforms = ['youtube', 'instagram', 'tiktok', 'twitter', 'facebook'];
// 验证每个事件
for (const event of events) {
const {
project_id,
influencer_id,
platform,
event_type,
metric_value
} = event;
if (!project_id || !influencer_id || !platform || !event_type || metric_value === undefined) {
return c.json({
error: 'Project ID, influencer ID, platform, event type, and metric value are required for all events'
}, 400);
}
if (!validEventTypes.includes(event_type)) {
return c.json({
error: `Invalid event type: ${event_type}. Must be one of: ${validEventTypes.join(', ')}`
}, 400);
}
if (!validPlatforms.includes(platform)) {
return c.json({
error: `Invalid platform: ${platform}. Must be one of: ${validPlatforms.join(', ')}`
}, 400);
}
}
// 准备批量插入数据
const values = events.map(event => `(
'${event.project_id}',
'${event.influencer_id}',
${event.post_id ? `'${event.post_id}'` : 'NULL'},
'${event.platform}',
'${event.event_type}',
${event.metric_value},
'${event.event_metadata ? JSON.stringify(event.event_metadata) : '{}'}'
)`).join(',');
// 批量插入事件数据
await clickhouse.query({
query: `
INSERT INTO events (
project_id,
influencer_id,
post_id,
platform,
event_type,
metric_value,
event_metadata
) VALUES ${values}
`
});
return c.json({
message: `${events.length} events added successfully`
}, 201);
} catch (error) {
console.error('Error adding batch event data:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 添加帖子
communityRouter.post('/posts', async (c) => {
try {
const {
influencer_id,
platform,
post_url,
title,
description,
published_at
} = await c.req.json();
if (!influencer_id || !platform || !post_url) {
return c.json({
error: 'Influencer ID, platform, and post URL are required'
}, 400);
}
// 验证平台
const validPlatforms = ['youtube', 'instagram', 'tiktok', 'twitter', 'facebook'];
if (!validPlatforms.includes(platform)) {
return c.json({
error: `Invalid platform. Must be one of: ${validPlatforms.join(', ')}`
}, 400);
}
// 检查帖子是否已存在
const { data: existingPost, error: checkError } = await supabase
.from('posts')
.select('*')
.eq('post_url', post_url)
.single();
if (!checkError && existingPost) {
return c.json({
error: 'Post with this URL already exists',
post: existingPost
}, 409);
}
// 创建新帖子
const { data, error } = await supabase
.from('posts')
.insert({
influencer_id,
platform,
post_url,
title,
description,
published_at: published_at || new Date().toISOString()
})
.select()
.single();
if (error) {
console.error('Error creating post:', error);
return c.json({ error: 'Failed to create post' }, 500);
}
return c.json({
message: 'Post created successfully',
post: data
}, 201);
} catch (error) {
console.error('Error creating post:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 添加评论
communityRouter.post('/comments', async (c) => {
try {
const {
post_id,
user_id,
content,
sentiment_score
} = await c.req.json();
if (!post_id || !content) {
return c.json({
error: 'Post ID and content are required'
}, 400);
}
// 创建新评论
const { data, error } = await supabase
.from('comments')
.insert({
post_id,
user_id: user_id || c.get('user').id,
content,
sentiment_score: sentiment_score || 0
})
.select()
.single();
if (error) {
console.error('Error creating comment:', error);
return c.json({ error: 'Failed to create comment' }, 500);
}
return c.json({
message: 'Comment created successfully',
comment: data
}, 201);
} catch (error) {
console.error('Error creating comment:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 获取项目的事件统计
communityRouter.get('/projects/:id/event-stats', async (c) => {
try {
const projectId = c.req.param('id');
// 从ClickHouse查询项目的事件统计
const result = await clickhouse.query({
query: `
SELECT
event_type,
COUNT(*) AS event_count,
SUM(metric_value) AS total_value
FROM events
WHERE project_id = ?
GROUP BY event_type
ORDER BY event_count DESC
`,
values: [projectId]
});
// 提取数据
const statsData = 'rows' in result ? result.rows : [];
return c.json({
project_id: projectId,
event_stats: statsData
});
} catch (error) {
console.error('Error fetching event stats:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 获取项目的时间趋势
communityRouter.get('/projects/:id/time-trend', async (c) => {
try {
const projectId = c.req.param('id');
const { event_type, interval = 'day', days = '30' } = c.req.query();
if (!event_type) {
return c.json({ error: 'Event type is required' }, 400);
}
// 验证事件类型
const validEventTypes = [
'follower_change',
'post_like_change',
'post_view_change',
'click',
'comment',
'share'
];
if (!validEventTypes.includes(event_type)) {
return c.json({
error: `Invalid event type. Must be one of: ${validEventTypes.join(', ')}`
}, 400);
}
// 验证时间间隔
const validIntervals = ['hour', 'day', 'week', 'month'];
if (!validIntervals.includes(interval)) {
return c.json({
error: `Invalid interval. Must be one of: ${validIntervals.join(', ')}`
}, 400);
}
// 构建时间间隔函数
let timeFunction;
switch (interval) {
case 'hour':
timeFunction = 'toStartOfHour';
break;
case 'day':
timeFunction = 'toDate';
break;
case 'week':
timeFunction = 'toStartOfWeek';
break;
case 'month':
timeFunction = 'toStartOfMonth';
break;
}
// 从ClickHouse查询项目的时间趋势
const result = await clickhouse.query({
query: `
SELECT
${timeFunction}(timestamp) AS time_period,
SUM(metric_value) AS value
FROM events
WHERE
project_id = ? AND
event_type = ? AND
timestamp >= subtractDays(now(), ?)
GROUP BY time_period
ORDER BY time_period ASC
`,
values: [projectId, event_type, parseInt(days)]
});
// 提取数据
const trendData = 'rows' in result ? result.rows : [];
return c.json({
project_id: projectId,
event_type,
interval,
days: parseInt(days),
trend: trendData
});
} catch (error) {
console.error('Error fetching time trend:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
export default communityRouter;

View File

@@ -0,0 +1,11 @@
import { Hono } from 'hono';
import { getInfluencers, getInfluencerById, getInfluencerStats } from '../controllers/influencersController';
const influencersRouter = new Hono();
// Public routes
influencersRouter.get('/', getInfluencers);
influencersRouter.get('/stats', getInfluencerStats);
influencersRouter.get('/:influencer_id', getInfluencerById);
export default influencersRouter;

686
backend/src/routes/posts.ts Normal file
View File

@@ -0,0 +1,686 @@
import { Hono } from 'hono';
import { authMiddleware } from '../middlewares/auth';
import supabase from '../utils/supabase';
import clickhouse from '../utils/clickhouse';
import { getRedisClient } from '../utils/redis';
// Define user type
interface User {
id: string;
email: string;
name?: string;
}
// Define stats type
interface PostStats {
post_id: string;
views: number | null;
likes: number | null;
}
// Extend Hono's Context type
declare module 'hono' {
interface ContextVariableMap {
user: User;
}
}
const postsRouter = new Hono();
// Apply auth middleware to most routes
postsRouter.use('*', authMiddleware);
// 创建新帖子
postsRouter.post('/', async (c) => {
try {
const {
influencer_id,
platform,
post_url,
title,
description,
published_at
} = await c.req.json();
if (!influencer_id || !platform || !post_url) {
return c.json({
error: 'influencer_id, platform, and post_url are required'
}, 400);
}
// 验证平台
const validPlatforms = ['youtube', 'instagram', 'tiktok', 'twitter', 'facebook'];
if (!validPlatforms.includes(platform)) {
return c.json({
error: `Invalid platform. Must be one of: ${validPlatforms.join(', ')}`
}, 400);
}
// 检查帖子URL是否已存在
const { data: existingPost, error: checkError } = await supabase
.from('posts')
.select('*')
.eq('post_url', post_url)
.single();
if (!checkError && existingPost) {
return c.json({
error: 'Post with this URL already exists',
post: existingPost
}, 409);
}
// 创建新帖子
const { data: post, error } = await supabase
.from('posts')
.insert({
influencer_id,
platform,
post_url,
title,
description,
published_at: published_at || new Date().toISOString()
})
.select()
.single();
if (error) {
console.error('Error creating post:', error);
return c.json({ error: 'Failed to create post' }, 500);
}
return c.json({
message: 'Post created successfully',
post
}, 201);
} catch (error) {
console.error('Error creating post:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 获取帖子列表
postsRouter.get('/', async (c) => {
try {
const {
influencer_id,
platform,
limit = '20',
offset = '0',
sort = 'published_at',
order = 'desc'
} = c.req.query();
// 构建查询
let query = supabase.from('posts').select(`
*,
influencer:influencers(name, platform, profile_url, followers_count)
`);
// 添加过滤条件
if (influencer_id) {
query = query.eq('influencer_id', influencer_id);
}
if (platform) {
query = query.eq('platform', platform);
}
// 添加排序和分页
query = query.order(sort, { ascending: order === 'asc' });
query = query.range(parseInt(offset), parseInt(offset) + parseInt(limit) - 1);
// 执行查询
const { data, error, count } = await query;
if (error) {
console.error('Error fetching posts:', error);
return c.json({ error: 'Failed to fetch posts' }, 500);
}
// 获取帖子的统计数据
if (data && data.length > 0) {
const postIds = data.map(post => post.post_id);
// 尝试从缓存获取数据
const redis = await getRedisClient();
const cachedStats: PostStats[] = await Promise.all(
postIds.map(async (postId) => {
const [views, likes] = await Promise.all([
redis.get(`post:views:${postId}`),
redis.get(`post:likes:${postId}`)
]);
return {
post_id: postId,
views: views ? parseInt(views) : null,
likes: likes ? parseInt(likes) : null
};
})
);
// 找出缓存中没有的帖子ID
const missingIds = postIds.filter(id => {
const stat = cachedStats.find(s => s.post_id === id);
return stat?.views === null || stat?.likes === null;
});
// 如果有缺失的统计数据从ClickHouse获取
if (missingIds.length > 0) {
try {
// 查询帖子的观看数
const viewsResult = await clickhouse.query({
query: `
SELECT
post_id,
SUM(metric_value) AS views
FROM events
WHERE
post_id IN (${missingIds.map(id => `'${id}'`).join(',')}) AND
event_type = 'post_view_change'
GROUP BY post_id
`
});
// 查询帖子的点赞数
const likesResult = await clickhouse.query({
query: `
SELECT
post_id,
SUM(metric_value) AS likes
FROM events
WHERE
post_id IN (${missingIds.map(id => `'${id}'`).join(',')}) AND
event_type = 'post_like_change'
GROUP BY post_id
`
});
// 处理结果
const viewsData = 'rows' in viewsResult ? viewsResult.rows : [];
const likesData = 'rows' in likesResult ? likesResult.rows : [];
// 更新缓存并填充统计数据
for (const viewStat of viewsData) {
if (viewStat && typeof viewStat === 'object' && 'post_id' in viewStat && 'views' in viewStat) {
// 更新缓存
await redis.set(`post:views:${viewStat.post_id}`, String(viewStat.views));
// 更新缓存统计数据
const cacheStat = cachedStats.find(s => s.post_id === viewStat.post_id);
if (cacheStat) {
cacheStat.views = Number(viewStat.views);
}
}
}
for (const likeStat of likesData) {
if (likeStat && typeof likeStat === 'object' && 'post_id' in likeStat && 'likes' in likeStat) {
// 更新缓存
await redis.set(`post:likes:${likeStat.post_id}`, String(likeStat.likes));
// 更新缓存统计数据
const cacheStat = cachedStats.find(s => s.post_id === likeStat.post_id);
if (cacheStat) {
cacheStat.likes = Number(likeStat.likes);
}
}
}
} catch (chError) {
console.error('Error fetching stats from ClickHouse:', chError);
}
}
// 合并统计数据到帖子数据
data.forEach(post => {
const stats = cachedStats.find(s => s.post_id === post.post_id);
post.stats = {
views: stats?.views || 0,
likes: stats?.likes || 0
};
});
}
return c.json({
posts: data || [],
total: count || 0,
limit: parseInt(limit),
offset: parseInt(offset)
});
} catch (error) {
console.error('Error fetching posts:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 获取单个帖子详情
postsRouter.get('/:id', async (c) => {
try {
const postId = c.req.param('id');
// 获取帖子详情
const { data: post, error } = await supabase
.from('posts')
.select(`
*,
influencer:influencers(name, platform, profile_url, followers_count)
`)
.eq('post_id', postId)
.single();
if (error) {
console.error('Error fetching post:', error);
return c.json({ error: 'Failed to fetch post' }, 500);
}
if (!post) {
return c.json({ error: 'Post not found' }, 404);
}
// 获取帖子统计数据
try {
// 先尝试从Redis缓存获取
const redis = await getRedisClient();
const [cachedViews, cachedLikes] = await Promise.all([
redis.get(`post:views:${postId}`),
redis.get(`post:likes:${postId}`)
]);
// 如果缓存中有数据,直接使用
if (cachedViews !== null && cachedLikes !== null) {
post.stats = {
views: parseInt(cachedViews),
likes: parseInt(cachedLikes)
};
} else {
// 如果缓存中没有从ClickHouse获取
// 查询帖子的观看数
const viewsResult = await clickhouse.query({
query: `
SELECT SUM(metric_value) AS views
FROM events
WHERE
post_id = ? AND
event_type = 'post_view_change'
`,
values: [postId]
});
// 查询帖子的点赞数
const likesResult = await clickhouse.query({
query: `
SELECT SUM(metric_value) AS likes
FROM events
WHERE
post_id = ? AND
event_type = 'post_like_change'
`,
values: [postId]
});
// 处理结果
let viewsData = 0;
if ('rows' in viewsResult && viewsResult.rows.length > 0 && viewsResult.rows[0] && typeof viewsResult.rows[0] === 'object' && 'views' in viewsResult.rows[0]) {
viewsData = Number(viewsResult.rows[0].views) || 0;
}
let likesData = 0;
if ('rows' in likesResult && likesResult.rows.length > 0 && likesResult.rows[0] && typeof likesResult.rows[0] === 'object' && 'likes' in likesResult.rows[0]) {
likesData = Number(likesResult.rows[0].likes) || 0;
}
// 更新缓存
await redis.set(`post:views:${postId}`, String(viewsData));
await redis.set(`post:likes:${postId}`, String(likesData));
// 添加统计数据
post.stats = {
views: viewsData,
likes: likesData
};
}
// 获取互动时间线
const timelineResult = await clickhouse.query({
query: `
SELECT
toDate(timestamp) as date,
event_type,
SUM(metric_value) as value
FROM events
WHERE
post_id = ? AND
event_type IN ('post_view_change', 'post_like_change')
GROUP BY date, event_type
ORDER BY date ASC
`,
values: [postId]
});
const timelineData = 'rows' in timelineResult ? timelineResult.rows : [];
// 添加时间线数据
post.timeline = timelineData;
// 获取评论数量
const { count } = await supabase
.from('comments')
.select('*', { count: 'exact', head: true })
.eq('post_id', postId);
post.comment_count = count || 0;
} catch (statsError) {
console.error('Error fetching post stats:', statsError);
// 继续返回帖子数据,但没有统计信息
post.stats = { views: 0, likes: 0 };
post.timeline = [];
post.comment_count = 0;
}
return c.json(post);
} catch (error) {
console.error('Error fetching post:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 更新帖子
postsRouter.put('/:id', async (c) => {
try {
const postId = c.req.param('id');
const user = c.get('user');
const { title, description } = await c.req.json();
// 先检查帖子是否存在
const { data: existingPost, error: fetchError } = await supabase
.from('posts')
.select('*')
.eq('post_id', postId)
.single();
if (fetchError || !existingPost) {
return c.json({ error: 'Post not found' }, 404);
}
// 更新帖子
const { data: updatedPost, error } = await supabase
.from('posts')
.update({
title,
description,
updated_at: new Date().toISOString()
})
.eq('post_id', postId)
.select()
.single();
if (error) {
console.error('Error updating post:', error);
return c.json({ error: 'Failed to update post' }, 500);
}
return c.json({
message: 'Post updated successfully',
post: updatedPost
});
} catch (error) {
console.error('Error updating post:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 删除帖子
postsRouter.delete('/:id', async (c) => {
try {
const postId = c.req.param('id');
const user = c.get('user');
// 删除帖子
const { error } = await supabase
.from('posts')
.delete()
.eq('post_id', postId);
if (error) {
console.error('Error deleting post:', error);
return c.json({ error: 'Failed to delete post' }, 500);
}
// 清除缓存
try {
const redis = await getRedisClient();
await Promise.all([
redis.del(`post:views:${postId}`),
redis.del(`post:likes:${postId}`)
]);
} catch (cacheError) {
console.error('Error clearing cache:', cacheError);
}
return c.json({
message: 'Post deleted successfully'
});
} catch (error) {
console.error('Error deleting post:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 获取帖子的评论
postsRouter.get('/:id/comments', async (c) => {
try {
const postId = c.req.param('id');
const { limit = '20', offset = '0' } = c.req.query();
// 获取评论
const { data: comments, error, count } = await supabase
.from('comments')
.select('*', { count: 'exact' })
.eq('post_id', postId)
.order('created_at', { ascending: false })
.range(parseInt(offset), parseInt(offset) + parseInt(limit) - 1);
if (error) {
console.error('Error fetching comments:', error);
return c.json({ error: 'Failed to fetch comments' }, 500);
}
// 如果有评论,获取用户信息
if (comments && comments.length > 0) {
const userIds = [...new Set(comments.map(comment => comment.user_id))];
// 获取用户信息
const { data: userProfiles, error: userError } = await supabase
.from('user_profiles')
.select('id, full_name, avatar_url')
.in('id', userIds);
if (!userError && userProfiles) {
// 将用户信息添加到评论中
comments.forEach(comment => {
const userProfile = userProfiles.find(profile => profile.id === comment.user_id);
comment.user_profile = userProfile || null;
});
} else {
console.error('Error fetching user profiles:', userError);
}
}
return c.json({
comments: comments || [],
total: count || 0,
limit: parseInt(limit),
offset: parseInt(offset)
});
} catch (error) {
console.error('Error fetching comments:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 添加评论到帖子
postsRouter.post('/:id/comments', async (c) => {
try {
const postId = c.req.param('id');
const user = c.get('user');
const { content, sentiment_score } = await c.req.json();
if (!content) {
return c.json({ error: 'Comment content is required' }, 400);
}
// 创建评论
const { data: comment, error } = await supabase
.from('comments')
.insert({
post_id: postId,
user_id: user.id,
content,
sentiment_score: sentiment_score || 0
})
.select()
.single();
if (error) {
console.error('Error creating comment:', error);
return c.json({ error: 'Failed to create comment' }, 500);
}
// 尝试记录评论事件到ClickHouse
try {
// 获取帖子信息
const { data: post } = await supabase
.from('posts')
.select('influencer_id, platform')
.eq('post_id', postId)
.single();
if (post) {
await clickhouse.query({
query: `
INSERT INTO events (
influencer_id,
post_id,
platform,
event_type,
metric_value,
event_metadata
) VALUES (?, ?, ?, 'comment', ?, ?)
`,
values: [
post.influencer_id,
postId,
post.platform,
1,
JSON.stringify({
comment_id: comment.comment_id,
user_id: user.id,
sentiment_score: sentiment_score || 0
})
]
});
}
} catch (eventError) {
console.error('Error recording comment event:', eventError);
// 不影响主流程,继续返回评论数据
}
return c.json({
message: 'Comment added successfully',
comment
}, 201);
} catch (error) {
console.error('Error adding comment:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 更新评论
postsRouter.put('/comments/:id', async (c) => {
try {
const commentId = c.req.param('id');
const user = c.get('user');
const { content, sentiment_score } = await c.req.json();
// 先检查评论是否存在且属于当前用户
const { data: existingComment, error: fetchError } = await supabase
.from('comments')
.select('*')
.eq('comment_id', commentId)
.eq('user_id', user.id)
.single();
if (fetchError || !existingComment) {
return c.json({
error: 'Comment not found or you do not have permission to update it'
}, 404);
}
// 更新评论
const { data: updatedComment, error } = await supabase
.from('comments')
.update({
content,
sentiment_score: sentiment_score !== undefined ? sentiment_score : existingComment.sentiment_score,
updated_at: new Date().toISOString()
})
.eq('comment_id', commentId)
.select()
.single();
if (error) {
console.error('Error updating comment:', error);
return c.json({ error: 'Failed to update comment' }, 500);
}
return c.json({
message: 'Comment updated successfully',
comment: updatedComment
});
} catch (error) {
console.error('Error updating comment:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 删除评论
postsRouter.delete('/comments/:id', async (c) => {
try {
const commentId = c.req.param('id');
const user = c.get('user');
// 先检查评论是否存在且属于当前用户
const { data: existingComment, error: fetchError } = await supabase
.from('comments')
.select('*')
.eq('comment_id', commentId)
.eq('user_id', user.id)
.single();
if (fetchError || !existingComment) {
return c.json({
error: 'Comment not found or you do not have permission to delete it'
}, 404);
}
// 删除评论
const { error } = await supabase
.from('comments')
.delete()
.eq('comment_id', commentId);
if (error) {
console.error('Error deleting comment:', error);
return c.json({ error: 'Failed to delete comment' }, 500);
}
return c.json({
message: 'Comment deleted successfully'
});
} catch (error) {
console.error('Error deleting comment:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
export default postsRouter;

View File

@@ -0,0 +1,489 @@
import { Hono } from 'hono';
import { authMiddleware } from '../middlewares/auth';
import supabase from '../utils/supabase';
import clickhouse from '../utils/clickhouse';
// Define user type
interface User {
id: string;
email: string;
name?: string;
}
// Define comment type
interface ProjectComment {
comment_id: string;
project_id: string;
user_id: string;
content: string;
sentiment_score: number;
status: string;
is_pinned: boolean;
parent_id: string | null;
created_at: string;
updated_at: string;
user: { id: string; email: string }[];
reply_count?: number;
}
// Define project type
interface Project {
id: string;
name: string;
created_by?: string;
}
// Define reply count type
interface ReplyCount {
parent_id: string;
count: number;
}
// Extend Hono's Context type
declare module 'hono' {
interface ContextVariableMap {
user: User;
}
}
const projectCommentsRouter = new Hono();
// Apply auth middleware to all routes
projectCommentsRouter.use('*', authMiddleware);
// 获取项目的评论列表
projectCommentsRouter.get('/projects/:id/comments', async (c) => {
try {
const projectId = c.req.param('id');
const { limit = '20', offset = '0', parent_id = null } = c.req.query();
// 检查项目是否存在
const { data: project, error: projectError } = await supabase
.from('projects')
.select('id, name')
.eq('id', projectId)
.single();
if (projectError) {
console.error('Error fetching project:', projectError);
return c.json({ error: 'Project not found' }, 404);
}
// 构建评论查询
let commentsQuery = supabase
.from('project_comments')
.select(`
comment_id,
project_id,
user_id,
content,
sentiment_score,
status,
is_pinned,
parent_id,
created_at,
updated_at,
user:user_id(id, email)
`, { count: 'exact' });
// 过滤条件
commentsQuery = commentsQuery.eq('project_id', projectId);
// 如果指定了父评论ID则获取子评论
if (parent_id) {
commentsQuery = commentsQuery.eq('parent_id', parent_id);
} else {
// 否则获取顶级评论(没有父评论的评论)
commentsQuery = commentsQuery.is('parent_id', null);
}
// 排序和分页
const isPinned = parent_id ? false : true; // 只有顶级评论才考虑置顶
if (isPinned) {
commentsQuery = commentsQuery.order('is_pinned', { ascending: false });
}
commentsQuery = commentsQuery.order('created_at', { ascending: false });
commentsQuery = commentsQuery.range(
parseInt(offset),
parseInt(offset) + parseInt(limit) - 1
);
// 执行查询
const { data: comments, error: commentsError, count } = await commentsQuery;
if (commentsError) {
console.error('Error fetching project comments:', commentsError);
return c.json({ error: 'Failed to fetch project comments' }, 500);
}
// 获取每个顶级评论的回复数量
if (comments && !parent_id) {
const commentIds = comments.map(comment => comment.comment_id);
if (commentIds.length > 0) {
// 手动构建SQL查询来计算每个父评论的回复数量
const { data: replyCounts, error: replyCountError } = await supabase
.rpc('get_reply_counts_for_comments', { parent_ids: commentIds });
if (!replyCountError && replyCounts) {
// 将回复数量添加到评论中
for (const comment of comments) {
const replyCountItem = replyCounts.find((r: ReplyCount) => r.parent_id === comment.comment_id);
comment.reply_count = replyCountItem ? replyCountItem.count : 0;
}
}
}
}
return c.json({
project,
comments: comments || [],
total: count || 0,
limit: parseInt(limit),
offset: parseInt(offset)
});
} catch (error) {
console.error('Error fetching project comments:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 添加评论到项目
projectCommentsRouter.post('/projects/:id/comments', async (c) => {
try {
const projectId = c.req.param('id');
const user = c.get('user');
const { content, sentiment_score = 0, parent_id = null } = await c.req.json();
if (!content) {
return c.json({ error: 'Comment content is required' }, 400);
}
// 检查项目是否存在
const { data: project, error: projectError } = await supabase
.from('projects')
.select('id')
.eq('id', projectId)
.single();
if (projectError) {
console.error('Error fetching project:', projectError);
return c.json({ error: 'Project not found' }, 404);
}
// 如果指定了父评论ID检查父评论是否存在
if (parent_id) {
const { data: parentComment, error: parentError } = await supabase
.from('project_comments')
.select('comment_id')
.eq('comment_id', parent_id)
.eq('project_id', projectId)
.single();
if (parentError || !parentComment) {
return c.json({ error: 'Parent comment not found' }, 404);
}
}
// 创建评论
const { data: comment, error: commentError } = await supabase
.from('project_comments')
.insert({
project_id: projectId,
user_id: user.id,
content,
sentiment_score,
parent_id
})
.select()
.single();
if (commentError) {
console.error('Error creating project comment:', commentError);
return c.json({ error: 'Failed to create comment' }, 500);
}
// 记录评论事件到ClickHouse
try {
await clickhouse.query({
query: `
INSERT INTO events (
project_id,
event_type,
metric_value,
event_metadata
) VALUES (?, 'project_comment', ?, ?)
`,
values: [
projectId,
1,
JSON.stringify({
comment_id: comment.comment_id,
user_id: user.id,
parent_id: parent_id || null,
content: content.substring(0, 100), // 只存储部分内容以减小数据量
sentiment_score: sentiment_score
})
]
});
} catch (chError) {
console.error('Error recording project comment event:', chError);
// 继续执行,不中断主流程
}
return c.json({
message: 'Comment added successfully',
comment
}, 201);
} catch (error) {
console.error('Error adding project comment:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 更新项目评论
projectCommentsRouter.put('/comments/:id', async (c) => {
try {
const commentId = c.req.param('id');
const user = c.get('user');
const { content, sentiment_score, is_pinned } = await c.req.json();
// 检查评论是否存在且属于当前用户或用户是项目拥有者
const { data: comment, error: fetchError } = await supabase
.from('project_comments')
.select(`
comment_id,
project_id,
user_id,
projects!inner(created_by)
`)
.eq('comment_id', commentId)
.single();
if (fetchError || !comment) {
return c.json({ error: 'Comment not found' }, 404);
}
// 确保我们能够安全地访问projects中的created_by字段
const projectOwner = comment.projects &&
Array.isArray(comment.projects) &&
comment.projects.length > 0 ?
comment.projects[0].created_by : null;
// 检查用户是否有权限更新评论
const isCommentOwner = comment.user_id === user.id;
const isProjectOwner = projectOwner === user.id;
if (!isCommentOwner && !isProjectOwner) {
return c.json({
error: 'You do not have permission to update this comment'
}, 403);
}
// 准备更新数据
const updateData: Record<string, any> = {};
// 评论创建者可以更新内容和情感分数
if (isCommentOwner) {
if (content !== undefined) {
updateData.content = content;
}
if (sentiment_score !== undefined) {
updateData.sentiment_score = sentiment_score;
}
}
// 项目所有者可以更新状态和置顶
if (isProjectOwner) {
if (is_pinned !== undefined) {
updateData.is_pinned = is_pinned;
}
}
// 更新时间
updateData.updated_at = new Date().toISOString();
// 如果没有内容要更新,返回错误
if (Object.keys(updateData).length === 1) { // 只有updated_at
return c.json({ error: 'No valid fields to update' }, 400);
}
// 更新评论
const { data: updatedComment, error } = await supabase
.from('project_comments')
.update(updateData)
.eq('comment_id', commentId)
.select()
.single();
if (error) {
console.error('Error updating project comment:', error);
return c.json({ error: 'Failed to update comment' }, 500);
}
return c.json({
message: 'Comment updated successfully',
comment: updatedComment
});
} catch (error) {
console.error('Error updating project comment:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 删除项目评论
projectCommentsRouter.delete('/comments/:id', async (c) => {
try {
const commentId = c.req.param('id');
const user = c.get('user');
// 检查评论是否存在且属于当前用户或用户是项目拥有者
const { data: comment, error: fetchError } = await supabase
.from('project_comments')
.select(`
comment_id,
project_id,
user_id,
projects!inner(created_by)
`)
.eq('comment_id', commentId)
.single();
if (fetchError || !comment) {
return c.json({ error: 'Comment not found' }, 404);
}
// 确保我们能够安全地访问projects中的created_by字段
const projectOwner = comment.projects &&
Array.isArray(comment.projects) &&
comment.projects.length > 0 ?
comment.projects[0].created_by : null;
// 检查用户是否有权限删除评论
const isCommentOwner = comment.user_id === user.id;
const isProjectOwner = projectOwner === user.id;
if (!isCommentOwner && !isProjectOwner) {
return c.json({
error: 'You do not have permission to delete this comment'
}, 403);
}
// 删除评论
const { error } = await supabase
.from('project_comments')
.delete()
.eq('comment_id', commentId);
if (error) {
console.error('Error deleting project comment:', error);
return c.json({ error: 'Failed to delete comment' }, 500);
}
return c.json({
message: 'Comment deleted successfully'
});
} catch (error) {
console.error('Error deleting project comment:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// 获取项目评论的统计信息
projectCommentsRouter.get('/projects/:id/comments/stats', async (c) => {
try {
const projectId = c.req.param('id');
// 检查项目是否存在
const { data: project, error: projectError } = await supabase
.from('projects')
.select('id, name')
.eq('id', projectId)
.single();
if (projectError) {
console.error('Error fetching project:', projectError);
return c.json({ error: 'Project not found' }, 404);
}
// 从Supabase获取评论总数
const { count } = await supabase
.from('project_comments')
.select('*', { count: 'exact', head: true })
.eq('project_id', projectId);
// 从Supabase获取情感分析统计
const { data: sentimentStats } = await supabase
.from('project_comments')
.select('sentiment_score')
.eq('project_id', projectId);
let averageSentiment = 0;
let positiveCount = 0;
let neutralCount = 0;
let negativeCount = 0;
if (sentimentStats && sentimentStats.length > 0) {
// 计算平均情感分数
const totalSentiment = sentimentStats.reduce((acc, curr) => acc + (curr.sentiment_score || 0), 0);
averageSentiment = totalSentiment / sentimentStats.length;
// 分类情感分数
sentimentStats.forEach(stat => {
const score = stat.sentiment_score || 0;
if (score > 0.3) {
positiveCount++;
} else if (score < -0.3) {
negativeCount++;
} else {
neutralCount++;
}
});
}
// 从ClickHouse获取评论时间趋势
type TrendItem = { date: string; comment_count: number };
let timeTrend: TrendItem[] = [];
try {
const result = await clickhouse.query({
query: `
SELECT
toDate(timestamp) as date,
count() as comment_count
FROM events
WHERE
project_id = ? AND
event_type = 'project_comment' AND
timestamp >= subtractDays(now(), 30)
GROUP BY date
ORDER BY date ASC
`,
values: [projectId]
});
timeTrend = 'rows' in result ? result.rows as TrendItem[] : [];
} catch (chError) {
console.error('Error fetching comment time trend:', chError);
// 继续执行,返回空趋势数据
}
return c.json({
project_id: projectId,
project_name: project.name,
total_comments: count || 0,
sentiment: {
average: averageSentiment,
positive: positiveCount,
neutral: neutralCount,
negative: negativeCount
},
time_trend: timeTrend
});
} catch (error) {
console.error('Error fetching project comment stats:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
export default projectCommentsRouter;

1866
backend/src/swagger/index.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,86 @@
import { createClient } from '@clickhouse/client';
import config from '../config';
// Create ClickHouse client with error handling
const createClickHouseClient = () => {
try {
return createClient({
host: `http://${config.clickhouse.host}:${config.clickhouse.port}`,
username: config.clickhouse.user,
password: config.clickhouse.password,
database: config.clickhouse.database,
});
} catch (error) {
console.error('Error creating ClickHouse client:', error);
// Return a mock client for development that logs operations instead of executing them
return {
query: async ({ query, values }: { query: string; values?: any[] }) => {
console.log('ClickHouse query (mock):', query, values);
return { rows: [] };
},
close: async () => {
console.log('ClickHouse connection closed (mock)');
}
};
}
};
const clickhouse = createClickHouseClient();
// Initialize ClickHouse database and tables
export const initClickHouse = async () => {
try {
// Create database if not exists
await clickhouse.query({
query: `CREATE DATABASE IF NOT EXISTS ${config.clickhouse.database}`,
});
// Create tables for tracking events
await clickhouse.query({
query: `
CREATE TABLE IF NOT EXISTS ${config.clickhouse.database}.view_events (
user_id String,
content_id String,
timestamp DateTime DEFAULT now(),
ip String,
user_agent String
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (user_id, content_id, timestamp)
`,
});
await clickhouse.query({
query: `
CREATE TABLE IF NOT EXISTS ${config.clickhouse.database}.like_events (
user_id String,
content_id String,
timestamp DateTime DEFAULT now(),
action Enum('like' = 1, 'unlike' = 2)
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (user_id, content_id, timestamp)
`,
});
await clickhouse.query({
query: `
CREATE TABLE IF NOT EXISTS ${config.clickhouse.database}.follower_events (
follower_id String,
followed_id String,
timestamp DateTime DEFAULT now(),
action Enum('follow' = 1, 'unfollow' = 2)
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (follower_id, followed_id, timestamp)
`,
});
console.log('ClickHouse database and tables initialized');
} catch (error) {
console.error('Error initializing ClickHouse:', error);
console.log('Continuing with limited functionality...');
}
};
export default clickhouse;

View File

@@ -0,0 +1,538 @@
import supabase from './supabase';
import clickhouse from './clickhouse';
import fs from 'fs/promises';
import path from 'path';
/**
* 初始化 Supabase (PostgreSQL) 数据库表
*/
export const initSupabaseTables = async () => {
try {
console.log('开始初始化 Supabase 数据表...');
// 创建用户扩展表
await supabase.rpc('create_user_profiles_if_not_exists');
// 创建项目表
await supabase.rpc('create_projects_table_if_not_exists');
// 创建网红(影响者)表
await supabase.rpc('create_influencers_table_if_not_exists');
// 创建项目-网红关联表
await supabase.rpc('create_project_influencers_table_if_not_exists');
// 创建帖子表
await supabase.rpc('create_posts_table_if_not_exists');
// 创建评论表
await supabase.rpc('create_comments_table_if_not_exists');
// 创建项目评论表
await supabase.rpc('create_project_comments_table_if_not_exists');
console.log('Supabase 数据表初始化完成');
return true;
} catch (error) {
console.error('初始化 Supabase 数据表失败:', error);
return false;
}
};
/**
* 初始化 ClickHouse 数据库表
*/
export const initClickHouseTables = async () => {
try {
console.log('开始初始化 ClickHouse 数据表...');
// 创建事件表
await clickhouse.query({
query: `
CREATE TABLE IF NOT EXISTS events (
event_id UUID DEFAULT generateUUIDv4(),
project_id UUID,
influencer_id UUID,
post_id UUID NULL,
platform String,
event_type Enum(
'follower_change' = 1,
'post_like_change' = 2,
'post_view_change' = 3,
'click' = 4,
'comment' = 5,
'share' = 6,
'project_comment' = 7
),
metric_value Int64,
event_metadata String,
timestamp DateTime DEFAULT now()
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (platform, influencer_id, post_id, event_type, timestamp)
`
});
// 创建统计视图 - 按天统计
await clickhouse.query({
query: `
CREATE MATERIALIZED VIEW IF NOT EXISTS daily_stats
ENGINE = SummingMergeTree()
PARTITION BY toYYYYMM(date)
ORDER BY (date, platform, influencer_id, event_type)
AS SELECT
toDate(timestamp) AS date,
platform,
influencer_id,
event_type,
SUM(metric_value) AS total_value,
COUNT(*) AS event_count
FROM events
GROUP BY date, platform, influencer_id, event_type
`
});
// 创建统计视图 - 按月统计
await clickhouse.query({
query: `
CREATE MATERIALIZED VIEW IF NOT EXISTS monthly_stats
ENGINE = SummingMergeTree()
ORDER BY (month, platform, influencer_id, event_type)
AS SELECT
toStartOfMonth(timestamp) AS month,
platform,
influencer_id,
event_type,
SUM(metric_value) AS total_value,
COUNT(*) AS event_count
FROM events
GROUP BY month, platform, influencer_id, event_type
`
});
// 创建帖子互动统计视图
await clickhouse.query({
query: `
CREATE MATERIALIZED VIEW IF NOT EXISTS post_interaction_stats
ENGINE = SummingMergeTree()
ORDER BY (post_id, event_type, date)
AS SELECT
post_id,
event_type,
toDate(timestamp) AS date,
SUM(metric_value) AS value,
COUNT(*) AS count
FROM events
WHERE post_id IS NOT NULL
GROUP BY post_id, event_type, date
`
});
// 创建项目互动统计视图
await clickhouse.query({
query: `
CREATE MATERIALIZED VIEW IF NOT EXISTS project_interaction_stats
ENGINE = SummingMergeTree()
ORDER BY (project_id, event_type, date)
AS SELECT
project_id,
event_type,
toDate(timestamp) AS date,
SUM(metric_value) AS value,
COUNT(*) AS count
FROM events
WHERE project_id IS NOT NULL AND event_type = 'project_comment'
GROUP BY project_id, event_type, date
`
});
console.log('ClickHouse 数据表初始化完成');
return true;
} catch (error) {
console.error('初始化 ClickHouse 数据表失败:', error);
return false;
}
};
/**
* 初始化 Supabase 存储函数
*/
export const initSupabaseFunctions = async () => {
try {
console.log('开始初始化 Supabase 存储过程...');
// 创建用户简档表的存储过程
await supabase.rpc('create_function_create_user_profiles_if_not_exists');
// 创建项目表的存储过程
await supabase.rpc('create_function_create_projects_table_if_not_exists');
// 创建网红表的存储过程
await supabase.rpc('create_function_create_influencers_table_if_not_exists');
// 创建项目-网红关联表的存储过程
await supabase.rpc('create_function_create_project_influencers_table_if_not_exists');
// 创建帖子表的存储过程
await supabase.rpc('create_function_create_posts_table_if_not_exists');
// 创建评论表的存储过程
await supabase.rpc('create_function_create_comments_table_if_not_exists');
// 创建项目评论表的存储过程
await supabase.rpc('create_function_create_project_comments_table_if_not_exists');
// 创建评论相关的SQL函数
console.log('创建评论相关的SQL函数...');
const commentsSQL = await fs.readFile(
path.join(__dirname, 'supabase-comments-functions.sql'),
'utf8'
);
// 使用Supabase执行SQL
const { error: commentsFunctionsError } = await supabase.rpc(
'pgclient_execute',
{ query: commentsSQL }
);
if (commentsFunctionsError) {
console.error('创建评论SQL函数失败:', commentsFunctionsError);
} else {
console.log('评论SQL函数创建成功');
}
console.log('Supabase 存储过程初始化完成');
return true;
} catch (error) {
console.error('初始化 Supabase 存储过程失败:', error);
return false;
}
};
/**
* 创建测试数据
*/
export const createSampleData = async () => {
try {
console.log('开始创建测试数据...');
// 创建测试用户
const { data: user, error: userError } = await supabase.auth.admin.createUser({
email: 'test@example.com',
password: 'password123',
user_metadata: {
full_name: '测试用户'
}
});
if (userError) {
console.error('创建测试用户失败:', userError);
return false;
}
// 创建测试项目
const { data: project, error: projectError } = await supabase
.from('projects')
.insert({
name: '测试营销活动',
description: '这是一个测试营销活动',
created_by: user.user.id
})
.select()
.single();
if (projectError) {
console.error('创建测试项目失败:', projectError);
return false;
}
// 创建项目评论
await supabase
.from('project_comments')
.insert([
{
project_id: project.id,
user_id: user.user.id,
content: '这是对项目的一条测试评论',
sentiment_score: 0.8
},
{
project_id: project.id,
user_id: user.user.id,
content: '这个项目很有前景',
sentiment_score: 0.9
},
{
project_id: project.id,
user_id: user.user.id,
content: '需要关注这个项目的进展',
sentiment_score: 0.7
}
]);
// 创建测试网红
const platforms = ['youtube', 'instagram', 'tiktok'];
const influencers = [];
for (let i = 1; i <= 10; i++) {
const platform = platforms[Math.floor(Math.random() * platforms.length)];
const { data: influencer, error: influencerError } = await supabase
.from('influencers')
.insert({
name: `测试网红 ${i}`,
platform,
profile_url: `https://${platform}.com/user${i}`,
external_id: `user_${platform}_${i}`,
followers_count: Math.floor(Math.random() * 1000000) + 1000,
video_count: Math.floor(Math.random() * 500) + 10
})
.select()
.single();
if (influencerError) {
console.error(`创建测试网红 ${i} 失败:`, influencerError);
continue;
}
influencers.push(influencer);
// 将网红添加到项目
await supabase
.from('project_influencers')
.insert({
project_id: project.id,
influencer_id: influencer.influencer_id
});
// 为每个网红创建 3-5 个帖子
const postCount = Math.floor(Math.random() * 3) + 3;
for (let j = 1; j <= postCount; j++) {
const { data: post, error: postError } = await supabase
.from('posts')
.insert({
influencer_id: influencer.influencer_id,
platform,
post_url: `https://${platform}.com/user${i}/post${j}`,
title: `测试帖子 ${j} - 由 ${influencer.name} 发布`,
description: `这是一个测试帖子的描述 ${j}`,
published_at: new Date(Date.now() - Math.floor(Math.random() * 30) * 24 * 60 * 60 * 1000).toISOString()
})
.select()
.single();
if (postError) {
console.error(`创建测试帖子 ${j} 失败:`, postError);
continue;
}
// 为每个帖子创建 2-10 个评论
const commentCount = Math.floor(Math.random() * 9) + 2;
for (let k = 1; k <= commentCount; k++) {
await supabase
.from('comments')
.insert({
post_id: post.post_id,
user_id: user.user.id,
content: `这是对帖子 ${post.title} 的测试评论 ${k}`,
sentiment_score: (Math.random() * 2 - 1) // -1 到 1 之间的随机数
});
}
// 创建 ClickHouse 事件数据
// 粉丝变化事件
await clickhouse.query({
query: `
INSERT INTO events (
project_id,
influencer_id,
platform,
event_type,
metric_value,
event_metadata
) VALUES (?, ?, ?, 'follower_change', ?, ?)
`,
values: [
project.id,
influencer.influencer_id,
platform,
Math.floor(Math.random() * 1000) - 200, // -200 到 800 之间的随机数
JSON.stringify({ source: 'api_crawler' })
]
});
// 帖子点赞变化事件
await clickhouse.query({
query: `
INSERT INTO events (
project_id,
influencer_id,
post_id,
platform,
event_type,
metric_value,
event_metadata
) VALUES (?, ?, ?, ?, 'post_like_change', ?, ?)
`,
values: [
project.id,
influencer.influencer_id,
post.post_id,
platform,
Math.floor(Math.random() * 500) + 10, // 10 到 510 之间的随机数
JSON.stringify({ source: 'api_crawler' })
]
});
// 帖子观看数变化事件
await clickhouse.query({
query: `
INSERT INTO events (
project_id,
influencer_id,
post_id,
platform,
event_type,
metric_value,
event_metadata
) VALUES (?, ?, ?, ?, 'post_view_change', ?, ?)
`,
values: [
project.id,
influencer.influencer_id,
post.post_id,
platform,
Math.floor(Math.random() * 5000) + 100, // 100 到 5100 之间的随机数
JSON.stringify({ source: 'api_crawler' })
]
});
// 互动事件
const interactionTypes = ['click', 'comment', 'share'];
const interactionType = interactionTypes[Math.floor(Math.random() * interactionTypes.length)];
await clickhouse.query({
query: `
INSERT INTO events (
project_id,
influencer_id,
post_id,
platform,
event_type,
metric_value,
event_metadata
) VALUES (?, ?, ?, ?, ?, ?, ?)
`,
values: [
project.id,
influencer.influencer_id,
post.post_id,
platform,
interactionType,
1,
JSON.stringify({
ip: '192.168.1.' + Math.floor(Math.random() * 255),
user_agent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
})
]
});
}
}
// 创建项目评论事件
for (let i = 1; i <= 5; i++) {
await clickhouse.query({
query: `
INSERT INTO events (
project_id,
event_type,
metric_value,
event_metadata
) VALUES (?, 'project_comment', ?, ?)
`,
values: [
project.id,
1,
JSON.stringify({
user_id: user.user.id,
timestamp: new Date().toISOString(),
comment: `项目评论事件 ${i}`
})
]
});
}
console.log('测试数据创建完成');
return true;
} catch (error) {
console.error('创建测试数据失败:', error);
return false;
}
};
/**
* 检查数据库连接
*/
export const checkDatabaseConnection = async () => {
try {
console.log('检查数据库连接...');
// 检查 Supabase 连接
try {
// 仅检查连接是否正常,不执行实际查询
const { data, error } = await supabase.auth.getSession();
if (error) {
console.error('Supabase 连接测试失败:', error);
return false;
}
console.log('Supabase 连接正常');
} catch (supabaseError) {
console.error('Supabase 连接测试失败:', supabaseError);
return false;
}
// 检查 ClickHouse 连接
try {
// 使用简单查询代替ping方法
const result = await clickhouse.query({ query: 'SELECT 1' });
console.log('ClickHouse 连接正常');
} catch (error) {
console.error('ClickHouse 连接测试失败:', error);
return false;
}
console.log('数据库连接检查完成,所有连接均正常');
return true;
} catch (error) {
console.error('数据库连接检查失败:', error);
return false;
}
};
/**
* 初始化数据库 - 此函数现在仅作为手动初始化的入口点
* 只有通过管理API明确调用时才会执行实际的初始化
*/
export const initDatabase = async () => {
try {
console.log('开始数据库初始化...');
console.log('警告: 此操作将修改数据库结构,请确保您知道自己在做什么');
// 初始化 Supabase 函数
await initSupabaseFunctions();
// 初始化 Supabase 表
await initSupabaseTables();
// 初始化 ClickHouse 表
await initClickHouseTables();
console.log('数据库初始化完成');
return true;
} catch (error) {
console.error('数据库初始化失败:', error);
return false;
}
};

189
backend/src/utils/queue.ts Normal file
View File

@@ -0,0 +1,189 @@
import { Queue, Worker, Job } from 'bullmq';
import config from '../config';
// Define queue names
export const QUEUE_NAMES = {
ANALYTICS: 'analytics',
NOTIFICATIONS: 'notifications',
};
// Define job data types
interface AnalyticsJobData {
type: 'process_views' | 'process_likes' | 'process_followers';
data: Record<string, any>;
}
interface NotificationJobData {
type: 'new_follower' | 'new_like';
data: Record<string, any>;
}
// Create Redis connection options
const redisOptions = {
host: config.bull.redis.host,
port: config.bull.redis.port,
password: config.bull.redis.password,
};
// Create queues with error handling
let analyticsQueue: Queue<AnalyticsJobData>;
let notificationsQueue: Queue<NotificationJobData>;
try {
analyticsQueue = new Queue<AnalyticsJobData>(QUEUE_NAMES.ANALYTICS, {
connection: redisOptions,
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 1000,
},
},
});
notificationsQueue = new Queue<NotificationJobData>(QUEUE_NAMES.NOTIFICATIONS, {
connection: redisOptions,
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 1000,
},
},
});
} catch (error) {
console.error('Error initializing BullMQ queues:', error);
// Create mock queues for development
analyticsQueue = {
add: async (name: string, data: AnalyticsJobData) => {
console.log(`Mock analytics job added: ${name}`, data);
return { id: 'mock-job-id' } as any;
},
close: async () => console.log('Mock analytics queue closed'),
} as any;
notificationsQueue = {
add: async (name: string, data: NotificationJobData) => {
console.log(`Mock notification job added: ${name}`, data);
return { id: 'mock-job-id' } as any;
},
close: async () => console.log('Mock notifications queue closed'),
} as any;
}
// Initialize workers
export const initWorkers = () => {
try {
// Analytics worker
const analyticsWorker = new Worker<AnalyticsJobData>(
QUEUE_NAMES.ANALYTICS,
async (job: Job<AnalyticsJobData>) => {
console.log(`Processing analytics job ${job.id}`);
const { type, data } = job.data;
switch (type) {
case 'process_views':
// Process view analytics
console.log('Processing view analytics', data);
break;
case 'process_likes':
// Process like analytics
console.log('Processing like analytics', data);
break;
case 'process_followers':
// Process follower analytics
console.log('Processing follower analytics', data);
break;
default:
console.log(`Unknown analytics job type: ${type as string}`);
}
},
{ connection: redisOptions }
);
// Notifications worker
const notificationsWorker = new Worker<NotificationJobData>(
QUEUE_NAMES.NOTIFICATIONS,
async (job: Job<NotificationJobData>) => {
console.log(`Processing notification job ${job.id}`);
const { type, data } = job.data;
switch (type) {
case 'new_follower':
// Send new follower notification
console.log('Sending new follower notification', data);
break;
case 'new_like':
// Send new like notification
console.log('Sending new like notification', data);
break;
default:
console.log(`Unknown notification job type: ${type as string}`);
}
},
{ connection: redisOptions }
);
// Handle worker events
analyticsWorker.on('completed', (job: Job<AnalyticsJobData>) => {
console.log(`Analytics job ${job.id} completed`);
});
analyticsWorker.on('failed', (job: Job<AnalyticsJobData> | undefined, err: Error) => {
console.error(`Analytics job ${job?.id} failed with error ${err.message}`);
});
notificationsWorker.on('completed', (job: Job<NotificationJobData>) => {
console.log(`Notification job ${job.id} completed`);
});
notificationsWorker.on('failed', (job: Job<NotificationJobData> | undefined, err: Error) => {
console.error(`Notification job ${job?.id} failed with error ${err.message}`);
});
return {
analyticsWorker,
notificationsWorker,
};
} catch (error) {
console.error('Error initializing BullMQ workers:', error);
// Return mock workers
return {
analyticsWorker: {
close: async () => console.log('Mock analytics worker closed'),
},
notificationsWorker: {
close: async () => console.log('Mock notifications worker closed'),
},
};
}
};
// Helper function to add jobs to queues
export const addAnalyticsJob = async (
type: AnalyticsJobData['type'],
data: Record<string, any>,
options = {}
) => {
try {
return await analyticsQueue.add(type, { type, data } as AnalyticsJobData, options);
} catch (error) {
console.error('Error adding analytics job:', error);
console.log('Job details:', { type, data });
return null;
}
};
export const addNotificationJob = async (
type: NotificationJobData['type'],
data: Record<string, any>,
options = {}
) => {
try {
return await notificationsQueue.add(type, { type, data } as NotificationJobData, options);
} catch (error) {
console.error('Error adding notification job:', error);
console.log('Job details:', { type, data });
return null;
}
};

View File

@@ -0,0 +1,80 @@
import { createClient } from 'redis';
import config from '../config';
// Create Redis client
const redisClient = createClient({
url: `redis://${config.redis.password ? `${config.redis.password}@` : ''}${config.redis.host}:${config.redis.port}`,
});
// Handle Redis connection errors
redisClient.on('error', (err) => {
console.error('Redis Client Error:', err);
});
// Create a mock Redis client for development when real connection fails
const createMockRedisClient = () => {
const store = new Map<string, string>();
return {
isOpen: true,
connect: async () => console.log('Mock Redis client connected'),
get: async (key: string) => store.get(key) || null,
set: async (key: string, value: string) => {
store.set(key, value);
return 'OK';
},
incr: async (key: string) => {
const current = parseInt(store.get(key) || '0', 10);
const newValue = current + 1;
store.set(key, newValue.toString());
return newValue;
},
decr: async (key: string) => {
const current = parseInt(store.get(key) || '0', 10);
const newValue = Math.max(0, current - 1);
store.set(key, newValue.toString());
return newValue;
},
quit: async () => console.log('Mock Redis client disconnected'),
};
};
// Connect to Redis
let mockRedisClient: ReturnType<typeof createMockRedisClient> | null = null;
const connectRedis = async () => {
try {
if (!redisClient.isOpen) {
await redisClient.connect();
console.log('Redis client connected');
}
return redisClient;
} catch (error) {
console.error('Failed to connect to Redis:', error);
console.log('Using mock Redis client for development...');
if (!mockRedisClient) {
mockRedisClient = createMockRedisClient();
}
return mockRedisClient;
}
};
// Export the appropriate client
const getRedisClient = async () => {
try {
if (redisClient.isOpen) {
return redisClient;
}
return await connectRedis();
} catch (error) {
if (!mockRedisClient) {
mockRedisClient = createMockRedisClient();
}
return mockRedisClient;
}
};
export { redisClient, connectRedis, getRedisClient };
export default redisClient;

View File

@@ -0,0 +1,97 @@
-- 创建获取所有评论的函数
CREATE
OR REPLACE FUNCTION public .get_comments_with_posts() RETURNS TABLE (
comment_id UUID,
content TEXT,
sentiment_score FLOAT,
created_at TIMESTAMP WITH TIME ZONE,
updated_at TIMESTAMP WITH TIME ZONE,
post_id UUID,
user_id UUID,
user_profile JSONB,
post JSONB
) LANGUAGE plpgsql SECURITY DEFINER AS $$ BEGIN
RETURN QUERY
SELECT
c .comment_id,
c .content,
c .sentiment_score,
c .created_at,
c .updated_at,
c .post_id,
c .user_id,
jsonb_build_object(
'id',
up.id,
'full_name',
up.full_name,
'avatar_url',
up.avatar_url
) AS user_profile,
jsonb_build_object(
'post_id',
p.post_id,
'title',
p.title,
'description',
p.description,
'platform',
p.platform,
'post_url',
p.post_url,
'published_at',
p.published_at,
'influencer_id',
p.influencer_id
) AS post
FROM
public .comments c
LEFT JOIN public .user_profiles up ON c .user_id = up.id
LEFT JOIN public .posts p ON c .post_id = p.post_id
ORDER BY
c .created_at DESC;
END;
$$;
-- 创建获取特定帖子评论的函数
CREATE
OR REPLACE FUNCTION public .get_comments_for_post(post_id_param UUID) RETURNS TABLE (
comment_id UUID,
content TEXT,
sentiment_score FLOAT,
created_at TIMESTAMP WITH TIME ZONE,
updated_at TIMESTAMP WITH TIME ZONE,
post_id UUID,
user_id UUID,
user_profile JSONB
) LANGUAGE plpgsql SECURITY DEFINER AS $$ BEGIN
RETURN QUERY
SELECT
c .comment_id,
c .content,
c .sentiment_score,
c .created_at,
c .updated_at,
c .post_id,
c .user_id,
jsonb_build_object(
'id',
up.id,
'full_name',
up.full_name,
'avatar_url',
up.avatar_url
) AS user_profile
FROM
public .comments c
LEFT JOIN public .user_profiles up ON c .user_id = up.id
WHERE
c .post_id = post_id_param
ORDER BY
c .created_at DESC;
END;
$$;

View File

@@ -0,0 +1,738 @@
-- 为系统创建所需的存储过程和表
-- 创建用户简档表的函数
CREATE
OR REPLACE FUNCTION create_function_create_user_profiles_if_not_exists() RETURNS void LANGUAGE plpgsql AS $$ BEGIN
EXECUTE $FUNC$ CREATE
OR REPLACE FUNCTION create_user_profiles_if_not_exists() RETURNS void LANGUAGE plpgsql AS $INNER$ BEGIN
-- 检查用户简档表是否存在
IF NOT EXISTS (
SELECT
FROM
pg_tables
WHERE
schemaname = 'public'
AND tablename = 'user_profiles'
) THEN -- 创建用户简档表
CREATE TABLE public .user_profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON
DELETE
CASCADE,
full_name TEXT,
avatar_url TEXT,
website TEXT,
company TEXT,
role TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);
-- 添加 RLS 策略
ALTER TABLE
public .user_profiles ENABLE ROW LEVEL SECURITY;
-- 创建只有自己可以更新自己简档的策略
CREATE POLICY "Users can view all profiles" ON public .user_profiles FOR
SELECT
USING (true);
CREATE POLICY "Users can update own profile" ON public .user_profiles FOR
UPDATE
USING (auth.uid() = id);
CREATE POLICY "Users can insert own profile" ON public .user_profiles FOR
INSERT
WITH CHECK (auth.uid() = id);
-- 创建新用户时自动创建简档的触发器
CREATE
OR REPLACE FUNCTION public .handle_new_user() RETURNS TRIGGER LANGUAGE plpgsql SECURITY DEFINER AS $TRIGGER$ BEGIN
INSERT INTO
public .user_profiles (id, full_name, avatar_url)
VALUES
(
NEW .id,
NEW .raw_user_meta_data ->> 'full_name',
NEW .raw_user_meta_data ->> 'avatar_url'
);
RETURN NEW;
END;
$TRIGGER$;
-- 创建触发器
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
CREATE TRIGGER on_auth_user_created AFTER
INSERT
ON auth.users FOR EACH ROW EXECUTE FUNCTION public .handle_new_user();
RAISE NOTICE 'Created user_profiles table with RLS policies and triggers';
ELSE RAISE NOTICE 'user_profiles table already exists';
END IF;
END;
$INNER$;
$FUNC$;
RAISE NOTICE 'Created function create_user_profiles_if_not_exists()';
END;
$$;
-- 创建项目表的函数
CREATE
OR REPLACE FUNCTION create_function_create_projects_table_if_not_exists() RETURNS void LANGUAGE plpgsql AS $$ BEGIN
EXECUTE $FUNC$ CREATE
OR REPLACE FUNCTION create_projects_table_if_not_exists() RETURNS void LANGUAGE plpgsql AS $INNER$ BEGIN
-- 检查项目表是否存在
IF NOT EXISTS (
SELECT
FROM
pg_tables
WHERE
schemaname = 'public'
AND tablename = 'projects'
) THEN -- 创建项目表
CREATE TABLE public .projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT,
status TEXT DEFAULT 'active' CHECK (status IN ('active', 'archived', 'completed')),
start_date TIMESTAMP WITH TIME ZONE,
end_date TIMESTAMP WITH TIME ZONE,
created_by UUID REFERENCES auth.users(id) ON
DELETE
SET
NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);
-- 添加 RLS 策略
ALTER TABLE
public .projects ENABLE ROW LEVEL SECURITY;
-- 创建只有创建者可以管理项目的策略
CREATE POLICY "Users can view own projects" ON public .projects FOR
SELECT
USING (auth.uid() = created_by);
CREATE POLICY "Users can insert own projects" ON public .projects FOR
INSERT
WITH CHECK (auth.uid() = created_by);
CREATE POLICY "Users can update own projects" ON public .projects FOR
UPDATE
USING (auth.uid() = created_by);
CREATE POLICY "Users can delete own projects" ON public .projects FOR
DELETE
USING (auth.uid() = created_by);
-- 创建更新时间的触发器
CREATE
OR REPLACE FUNCTION public .update_project_updated_at() RETURNS TRIGGER LANGUAGE plpgsql AS $TRIGGER$ BEGIN
NEW .updated_at = now();
RETURN NEW;
END;
$TRIGGER$;
-- 创建触发器
DROP TRIGGER IF EXISTS on_project_updated ON public .projects;
CREATE TRIGGER on_project_updated BEFORE
UPDATE
ON public .projects FOR EACH ROW EXECUTE FUNCTION public .update_project_updated_at();
RAISE NOTICE 'Created projects table with RLS policies and triggers';
ELSE RAISE NOTICE 'projects table already exists';
END IF;
END;
$INNER$;
$FUNC$;
RAISE NOTICE 'Created function create_projects_table_if_not_exists()';
END;
$$;
-- 创建网红(影响者)表的函数
CREATE
OR REPLACE FUNCTION create_function_create_influencers_table_if_not_exists() RETURNS void LANGUAGE plpgsql AS $$ BEGIN
EXECUTE $FUNC$ CREATE
OR REPLACE FUNCTION create_influencers_table_if_not_exists() RETURNS void LANGUAGE plpgsql AS $INNER$ BEGIN
-- 检查网红表是否存在
IF NOT EXISTS (
SELECT
FROM
pg_tables
WHERE
schemaname = 'public'
AND tablename = 'influencers'
) THEN -- 创建网红表
CREATE TABLE public .influencers (
influencer_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
platform TEXT CHECK (
platform IN (
'youtube',
'instagram',
'tiktok',
'twitter',
'facebook'
)
),
profile_url TEXT,
external_id TEXT UNIQUE,
followers_count INT DEFAULT 0,
video_count INT DEFAULT 0,
platform_count INT DEFAULT 1,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);
-- 添加 RLS 策略
ALTER TABLE
public .influencers ENABLE ROW LEVEL SECURITY;
-- 创建所有认证用户可以查看网红的策略
CREATE POLICY "Authenticated users can view influencers" ON public .influencers FOR
SELECT
USING (auth.role() = 'authenticated');
-- 创建有权限的用户可以更新网红的策略
CREATE POLICY "Authenticated users can insert influencers" ON public .influencers FOR
INSERT
WITH CHECK (auth.role() = 'authenticated');
CREATE POLICY "Authenticated users can update influencers" ON public .influencers FOR
UPDATE
USING (auth.role() = 'authenticated');
-- 创建更新时间的触发器
CREATE
OR REPLACE FUNCTION public .update_influencer_updated_at() RETURNS TRIGGER LANGUAGE plpgsql AS $TRIGGER$ BEGIN
NEW .updated_at = now();
RETURN NEW;
END;
$TRIGGER$;
-- 创建触发器
DROP TRIGGER IF EXISTS on_influencer_updated ON public .influencers;
CREATE TRIGGER on_influencer_updated BEFORE
UPDATE
ON public .influencers FOR EACH ROW EXECUTE FUNCTION public .update_influencer_updated_at();
RAISE NOTICE 'Created influencers table with RLS policies and triggers';
ELSE RAISE NOTICE 'influencers table already exists';
END IF;
END;
$INNER$;
$FUNC$;
RAISE NOTICE 'Created function create_influencers_table_if_not_exists()';
END;
$$;
-- 创建项目-网红关联表的函数
CREATE
OR REPLACE FUNCTION create_function_create_project_influencers_table_if_not_exists() RETURNS void LANGUAGE plpgsql AS $$ BEGIN
EXECUTE $FUNC$ CREATE
OR REPLACE FUNCTION create_project_influencers_table_if_not_exists() RETURNS void LANGUAGE plpgsql AS $INNER$ BEGIN
-- 检查项目-网红关联表是否存在
IF NOT EXISTS (
SELECT
FROM
pg_tables
WHERE
schemaname = 'public'
AND tablename = 'project_influencers'
) THEN -- 创建项目-网红关联表
CREATE TABLE public .project_influencers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID REFERENCES public .projects(id) ON
DELETE
CASCADE,
influencer_id UUID REFERENCES public .influencers(influencer_id) ON
DELETE
CASCADE,
status TEXT DEFAULT 'active' CHECK (status IN ('active', 'inactive', 'completed')),
created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
UNIQUE (project_id, influencer_id)
);
-- 添加索引
CREATE INDEX idx_project_influencers_project_id ON public .project_influencers(project_id);
CREATE INDEX idx_project_influencers_influencer_id ON public .project_influencers(influencer_id);
-- 添加 RLS 策略
ALTER TABLE
public .project_influencers ENABLE ROW LEVEL SECURITY;
-- 创建只有项目创建者可以管理项目-网红关联的策略
CREATE POLICY "Users can view project influencers" ON public .project_influencers FOR
SELECT
USING (
EXISTS (
SELECT
1
FROM
public .projects
WHERE
id = project_id
AND created_by = auth.uid()
)
);
CREATE POLICY "Users can insert project influencers" ON public .project_influencers FOR
INSERT
WITH CHECK (
EXISTS (
SELECT
1
FROM
public .projects
WHERE
id = project_id
AND created_by = auth.uid()
)
);
CREATE POLICY "Users can update project influencers" ON public .project_influencers FOR
UPDATE
USING (
EXISTS (
SELECT
1
FROM
public .projects
WHERE
id = project_id
AND created_by = auth.uid()
)
);
CREATE POLICY "Users can delete project influencers" ON public .project_influencers FOR
DELETE
USING (
EXISTS (
SELECT
1
FROM
public .projects
WHERE
id = project_id
AND created_by = auth.uid()
)
);
-- 创建更新时间的触发器
CREATE
OR REPLACE FUNCTION public .update_project_influencer_updated_at() RETURNS TRIGGER LANGUAGE plpgsql AS $TRIGGER$ BEGIN
NEW .updated_at = now();
RETURN NEW;
END;
$TRIGGER$;
-- 创建触发器
DROP TRIGGER IF EXISTS on_project_influencer_updated ON public .project_influencers;
CREATE TRIGGER on_project_influencer_updated BEFORE
UPDATE
ON public .project_influencers FOR EACH ROW EXECUTE FUNCTION public .update_project_influencer_updated_at();
RAISE NOTICE 'Created project_influencers table with RLS policies and triggers';
ELSE RAISE NOTICE 'project_influencers table already exists';
END IF;
END;
$INNER$;
$FUNC$;
RAISE NOTICE 'Created function create_project_influencers_table_if_not_exists()';
END;
$$;
-- 创建帖子表的函数
CREATE
OR REPLACE FUNCTION create_function_create_posts_table_if_not_exists() RETURNS void LANGUAGE plpgsql AS $$ BEGIN
EXECUTE $FUNC$ CREATE
OR REPLACE FUNCTION create_posts_table_if_not_exists() RETURNS void LANGUAGE plpgsql AS $INNER$ BEGIN
-- 检查帖子表是否存在
IF NOT EXISTS (
SELECT
FROM
pg_tables
WHERE
schemaname = 'public'
AND tablename = 'posts'
) THEN -- 创建帖子表
CREATE TABLE public .posts (
post_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
influencer_id UUID REFERENCES public .influencers(influencer_id) ON
DELETE
CASCADE,
platform TEXT CHECK (
platform IN (
'youtube',
'instagram',
'tiktok',
'twitter',
'facebook'
)
),
post_url TEXT UNIQUE NOT NULL,
title TEXT,
description TEXT,
published_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);
-- 添加索引
CREATE INDEX idx_posts_influencer_id ON public .posts(influencer_id);
CREATE INDEX idx_posts_platform ON public .posts(platform);
CREATE INDEX idx_posts_published_at ON public .posts(published_at);
-- 添加 RLS 策略
ALTER TABLE
public .posts ENABLE ROW LEVEL SECURITY;
-- 创建所有认证用户可以查看帖子的策略
CREATE POLICY "Authenticated users can view posts" ON public .posts FOR
SELECT
USING (auth.role() = 'authenticated');
-- 创建有权限的用户可以更新帖子的策略
CREATE POLICY "Authenticated users can insert posts" ON public .posts FOR
INSERT
WITH CHECK (auth.role() = 'authenticated');
CREATE POLICY "Authenticated users can update posts" ON public .posts FOR
UPDATE
USING (auth.role() = 'authenticated');
-- 创建更新时间的触发器
CREATE
OR REPLACE FUNCTION public .update_post_updated_at() RETURNS TRIGGER LANGUAGE plpgsql AS $TRIGGER$ BEGIN
NEW .updated_at = now();
RETURN NEW;
END;
$TRIGGER$;
-- 创建触发器
DROP TRIGGER IF EXISTS on_post_updated ON public .posts;
CREATE TRIGGER on_post_updated BEFORE
UPDATE
ON public .posts FOR EACH ROW EXECUTE FUNCTION public .update_post_updated_at();
RAISE NOTICE 'Created posts table with RLS policies and triggers';
ELSE RAISE NOTICE 'posts table already exists';
END IF;
END;
$INNER$;
$FUNC$;
RAISE NOTICE 'Created function create_posts_table_if_not_exists()';
END;
$$;
-- 创建评论表的函数
CREATE
OR REPLACE FUNCTION create_function_create_comments_table_if_not_exists() RETURNS void LANGUAGE plpgsql AS $$ BEGIN
EXECUTE $FUNC$ CREATE
OR REPLACE FUNCTION create_comments_table_if_not_exists() RETURNS void LANGUAGE plpgsql AS $INNER$ BEGIN
-- 检查评论表是否存在
IF NOT EXISTS (
SELECT
FROM
pg_tables
WHERE
schemaname = 'public'
AND tablename = 'comments'
) THEN -- 创建评论表
CREATE TABLE public .comments (
comment_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
post_id UUID REFERENCES public .posts(post_id) ON
DELETE
CASCADE,
user_id UUID REFERENCES auth.users(id) ON
DELETE
SET
NULL,
content TEXT NOT NULL,
sentiment_score FLOAT,
status TEXT DEFAULT 'approved' CHECK (status IN ('approved', 'pending', 'rejected')),
created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);
-- 添加索引
CREATE INDEX idx_comments_post_id ON public .comments(post_id);
CREATE INDEX idx_comments_user_id ON public .comments(user_id);
CREATE INDEX idx_comments_created_at ON public .comments(created_at);
-- 添加 RLS 策略
ALTER TABLE
public .comments ENABLE ROW LEVEL SECURITY;
-- 创建所有认证用户可以查看评论的策略
CREATE POLICY "Authenticated users can view comments" ON public .comments FOR
SELECT
USING (auth.role() = 'authenticated');
-- 创建有权限的用户可以添加评论的策略
CREATE POLICY "Authenticated users can insert comments" ON public .comments FOR
INSERT
WITH CHECK (auth.role() = 'authenticated');
-- 创建只有评论作者可以更新评论的策略
CREATE POLICY "Users can update own comments" ON public .comments FOR
UPDATE
USING (auth.uid() = user_id);
-- 创建只有评论作者可以删除评论的策略
CREATE POLICY "Users can delete own comments" ON public .comments FOR
DELETE
USING (auth.uid() = user_id);
-- 创建更新时间的触发器
CREATE
OR REPLACE FUNCTION public .update_comment_updated_at() RETURNS TRIGGER LANGUAGE plpgsql AS $TRIGGER$ BEGIN
NEW .updated_at = now();
RETURN NEW;
END;
$TRIGGER$;
-- 创建触发器
DROP TRIGGER IF EXISTS on_comment_updated ON public .comments;
CREATE TRIGGER on_comment_updated BEFORE
UPDATE
ON public .comments FOR EACH ROW EXECUTE FUNCTION public .update_comment_updated_at();
RAISE NOTICE 'Created comments table with RLS policies and triggers';
ELSE RAISE NOTICE 'comments table already exists';
END IF;
END;
$INNER$;
$FUNC$;
RAISE NOTICE 'Created function create_comments_table_if_not_exists()';
END;
$$;
-- 创建项目评论表的函数
CREATE
OR REPLACE FUNCTION create_function_create_project_comments_table_if_not_exists() RETURNS void LANGUAGE plpgsql AS $$ BEGIN
EXECUTE $FUNC$ CREATE
OR REPLACE FUNCTION create_project_comments_table_if_not_exists() RETURNS void LANGUAGE plpgsql AS $INNER$ BEGIN
-- 检查项目评论表是否存在
IF NOT EXISTS (
SELECT
FROM
pg_tables
WHERE
schemaname = 'public'
AND tablename = 'project_comments'
) THEN -- 创建项目评论表
CREATE TABLE public .project_comments (
comment_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID REFERENCES public .projects(id) ON
DELETE
CASCADE,
user_id UUID REFERENCES auth.users(id) ON
DELETE
SET
NULL,
content TEXT NOT NULL,
sentiment_score FLOAT,
status TEXT DEFAULT 'approved' CHECK (status IN ('approved', 'pending', 'rejected')),
is_pinned BOOLEAN DEFAULT false,
parent_id UUID REFERENCES public .project_comments(comment_id) ON
DELETE
SET
NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT now()
);
-- 添加索引
CREATE INDEX idx_project_comments_project_id ON public .project_comments(project_id);
CREATE INDEX idx_project_comments_user_id ON public .project_comments(user_id);
CREATE INDEX idx_project_comments_parent_id ON public .project_comments(parent_id);
CREATE INDEX idx_project_comments_created_at ON public .project_comments(created_at);
-- 添加 RLS 策略
ALTER TABLE
public .project_comments ENABLE ROW LEVEL SECURITY;
-- 创建项目评论可见性策略
CREATE POLICY "Project members can view project comments" ON public .project_comments FOR
SELECT
USING (
EXISTS (
SELECT
1
FROM
public .projects
WHERE
id = project_id
AND (
created_by = auth.uid()
OR EXISTS (
SELECT
1
FROM
public .project_influencers pi
JOIN public .influencers i ON pi.influencer_id = i.influencer_id
WHERE
pi.project_id = project_id
)
)
)
);
-- 创建认证用户可以添加评论的策略
CREATE POLICY "Authenticated users can insert project comments" ON public .project_comments FOR
INSERT
WITH CHECK (auth.role() = 'authenticated');
-- 创建只有评论作者可以更新评论的策略
CREATE POLICY "Users can update own project comments" ON public .project_comments FOR
UPDATE
USING (auth.uid() = user_id);
-- 创建项目所有者和评论作者可以删除评论的策略
CREATE POLICY "Project owner and comment creator can delete project comments" ON public .project_comments FOR
DELETE
USING (
auth.uid() = user_id
OR EXISTS (
SELECT
1
FROM
public .projects
WHERE
id = project_id
AND created_by = auth.uid()
)
);
-- 创建更新时间的触发器
CREATE
OR REPLACE FUNCTION public .update_project_comment_updated_at() RETURNS TRIGGER LANGUAGE plpgsql AS $TRIGGER$ BEGIN
NEW .updated_at = now();
RETURN NEW;
END;
$TRIGGER$;
-- 创建触发器
DROP TRIGGER IF EXISTS on_project_comment_updated ON public .project_comments;
CREATE TRIGGER on_project_comment_updated BEFORE
UPDATE
ON public .project_comments FOR EACH ROW EXECUTE FUNCTION public .update_project_comment_updated_at();
-- 创建获取评论回复数量的函数
CREATE
OR REPLACE FUNCTION public .get_reply_counts_for_comments(parent_ids UUID [ ]) RETURNS TABLE (parent_id UUID, count BIGINT) LANGUAGE sql SECURITY DEFINER AS $$
SELECT
parent_id,
COUNT(*) as count
FROM
public .project_comments
WHERE
parent_id IS NOT NULL
AND parent_id = ANY(parent_ids)
GROUP BY
parent_id;
$$;
RAISE NOTICE 'Created project_comments table with RLS policies and triggers';
ELSE RAISE NOTICE 'project_comments table already exists';
END IF;
END;
$INNER$;
$FUNC$;
RAISE NOTICE 'Created function create_project_comments_table_if_not_exists()';
END;
$$;

View File

@@ -0,0 +1,19 @@
import { createClient } from '@supabase/supabase-js';
import config from '../config';
// Validate Supabase URL
const validateSupabaseUrl = (url: string): string => {
if (!url || !url.startsWith('http')) {
console.warn('Invalid Supabase URL provided. Using a placeholder for development.');
return 'https://example.supabase.co';
}
return url;
};
// Create a single supabase client for interacting with your database
const supabase = createClient(
validateSupabaseUrl(config.supabase.url),
config.supabase.key || 'dummy-key'
);
export default supabase;

26
backend/start-server.sh Executable file
View File

@@ -0,0 +1,26 @@
#!/bin/bash
# 获取配置的端口默认为4000
PORT=$(grep "PORT=" .env | cut -d= -f2 || echo "4000")
echo "Service configured to use port $PORT"
# 查找并停止使用该端口的进程
PID=$(lsof -ti :$PORT)
if [ ! -z "$PID" ]; then
echo "Stopping process $PID using port $PORT..."
kill -9 $PID
sleep 1
fi
# 停止可能正在运行的服务器
echo "Stopping any running server..."
pkill -f "node dist/index.js" || true
pkill -f "tsx watch src/index.ts" || true
# 构建项目
echo "Building project..."
npm run build
# 启动服务器
echo "Starting server..."
npm start

19
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,28 @@
version: '3.7'
services:
clickhouse:
image: clickhouse/clickhouse-server:latest
container_name: clickhouse-server
restart: always
ports:
- "8123:8123" # HTTP 接口DBeaver/HTTP 客户端使用)
- "9000:9000" # 原生 TCP 协议clickhouse-client 使用)
- "9004:9004" # MySQL 协议(可选)
volumes:
- ./data:/var/lib/clickhouse # 数据持久化
- /etc/localtime:/etc/localtime:ro # 同步时区[8](@ref)
environment:
- CLICKHOUSE_USER=admin
- CLICKHOUSE_PASSWORD=your_secure_password
- TZ=Asia/Shanghai # 时区设置
ulimits:
nofile:
soft: 262144
hard: 262144 # 高并发连接优化[3,6](@ref)
networks:
- clickhouse-net
networks:
clickhouse-net:
driver: bridge

View File

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

8
extension/.bolt/prompt Normal file
View File

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

24
extension/.gitignore vendored Normal file
View File

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

7
extension/background.js Normal file
View File

@@ -0,0 +1,7 @@
// Open the side panel when the extension icon is clicked
chrome.action.onClicked.addListener((tab) => {
chrome.sidePanel.open({ tabId: tab.id });
});
// Set the side panel as open by default for all pages
chrome.sidePanel.setOptions({ enabled: true });

243
extension/content.js Normal file
View File

@@ -0,0 +1,243 @@
// Function to extract comments from the page
function extractComments() {
const comments = [];
let platform = detectPlatform();
// Different extraction strategies based on the platform
if (platform === 'facebook') {
extractFacebookComments(comments);
} else if (platform === 'youtube') {
extractYoutubeComments(comments);
} else if (platform === 'twitter') {
extractTwitterComments(comments);
} else if (platform === 'instagram') {
extractInstagramComments(comments);
} else if (platform === 'linkedin') {
extractLinkedinComments(comments);
} else {
// Generic extraction for other platforms
extractGenericComments(comments);
}
return comments;
}
// Detect the current platform
function detectPlatform() {
const url = window.location.hostname;
if (url.includes('facebook.com')) return 'facebook';
if (url.includes('youtube.com')) return 'youtube';
if (url.includes('twitter.com') || url.includes('x.com')) return 'twitter';
if (url.includes('instagram.com')) return 'instagram';
if (url.includes('linkedin.com')) return 'linkedin';
return 'other';
}
// Platform-specific extraction functions
function extractFacebookComments(comments) {
// Facebook comment selectors
const commentElements = document.querySelectorAll('[aria-label="Comment"]');
commentElements.forEach((element, index) => {
try {
const authorElement = element.querySelector('a');
const contentElement = element.querySelector('[data-ad-comet-preview="message"]');
const timestampElement = element.querySelector('a[href*="comment_id"]');
const likesElement = element.querySelector('[aria-label*="reactions"]');
if (contentElement) {
comments.push({
id: `fb-comment-${index}`,
author: authorElement ? authorElement.textContent : 'Facebook User',
content: contentElement.textContent,
timestamp: timestampElement ? timestampElement.textContent : 'Recently',
likes: likesElement ? parseInt(likesElement.textContent) || 0 : 0,
platform: 'facebook'
});
}
} catch (error) {
console.error('Error extracting Facebook comment:', error);
}
});
}
function extractYoutubeComments(comments) {
// YouTube comment selectors
const commentElements = document.querySelectorAll('ytd-comment-thread-renderer');
commentElements.forEach((element, index) => {
try {
const authorElement = element.querySelector('#author-text');
const contentElement = element.querySelector('#content-text');
const timestampElement = element.querySelector('.published-time-text');
const likesElement = element.querySelector('#vote-count-middle');
if (contentElement) {
comments.push({
id: `yt-comment-${index}`,
author: authorElement ? authorElement.textContent.trim() : 'YouTube User',
content: contentElement.textContent.trim(),
timestamp: timestampElement ? timestampElement.textContent.trim() : 'Recently',
likes: likesElement ? parseInt(likesElement.textContent) || 0 : 0,
platform: 'youtube'
});
}
} catch (error) {
console.error('Error extracting YouTube comment:', error);
}
});
}
function extractTwitterComments(comments) {
// Twitter/X comment selectors
const commentElements = document.querySelectorAll('[data-testid="tweet"]');
commentElements.forEach((element, index) => {
try {
const authorElement = element.querySelector('[data-testid="User-Name"]');
const contentElement = element.querySelector('[data-testid="tweetText"]');
const timestampElement = element.querySelector('time');
const likesElement = element.querySelector('[data-testid="like"]');
if (contentElement) {
comments.push({
id: `twitter-comment-${index}`,
author: authorElement ? authorElement.textContent.split('·')[0].trim() : 'Twitter User',
content: contentElement.textContent.trim(),
timestamp: timestampElement ? timestampElement.getAttribute('datetime') : 'Recently',
likes: likesElement ? parseInt(likesElement.textContent) || 0 : 0,
platform: 'twitter'
});
}
} catch (error) {
console.error('Error extracting Twitter comment:', error);
}
});
}
function extractInstagramComments(comments) {
// Instagram comment selectors
const commentElements = document.querySelectorAll('ul > li > div > div > div:nth-child(2)');
commentElements.forEach((element, index) => {
try {
const authorElement = element.querySelector('h3');
const contentElement = element.querySelector('span');
if (contentElement && authorElement) {
comments.push({
id: `ig-comment-${index}`,
author: authorElement.textContent.trim(),
content: contentElement.textContent.trim(),
timestamp: 'Recently', // Instagram doesn't easily show timestamps
likes: 0, // Instagram doesn't easily show like counts
platform: 'instagram'
});
}
} catch (error) {
console.error('Error extracting Instagram comment:', error);
}
});
}
function extractLinkedinComments(comments) {
// LinkedIn comment selectors
const commentElements = document.querySelectorAll('.comments-comment-item');
commentElements.forEach((element, index) => {
try {
const authorElement = element.querySelector('.comments-post-meta__name-text');
const contentElement = element.querySelector('.comments-comment-item__main-content');
const timestampElement = element.querySelector('.comments-comment-item__timestamp');
if (contentElement) {
comments.push({
id: `linkedin-comment-${index}`,
author: authorElement ? authorElement.textContent.trim() : 'LinkedIn User',
content: contentElement.textContent.trim(),
timestamp: timestampElement ? timestampElement.textContent.trim() : 'Recently',
likes: 0, // LinkedIn doesn't easily show like counts
platform: 'linkedin'
});
}
} catch (error) {
console.error('Error extracting LinkedIn comment:', error);
}
});
}
function extractGenericComments(comments) {
// Generic comment selectors that might work across different platforms
const possibleCommentSelectors = [
'.comment',
'[class*="comment"]',
'[id*="comment"]',
'.review',
'[class*="review"]',
'[class*="post"]',
'[class*="message"]'
];
for (const selector of possibleCommentSelectors) {
const elements = document.querySelectorAll(selector);
if (elements.length > 0) {
elements.forEach((element, index) => {
// Try to find text content that looks like a comment
const textContent = element.textContent.trim();
if (textContent.length > 10 && textContent.length < 1000) {
comments.push({
id: `generic-comment-${index}`,
author: 'User',
content: textContent,
timestamp: 'Recently',
likes: 0,
platform: 'other'
});
}
});
// If we found comments with this selector, no need to try others
if (comments.length > 0) break;
}
}
}
// Listen for messages from the sidebar
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'GET_COMMENTS') {
const comments = extractComments();
// Limit the number of comments based on settings
chrome.storage.sync.get(['maxComments'], (result) => {
const maxComments = result.maxComments || 50;
const limitedComments = comments.slice(0, maxComments);
// Send the comments back to the sidebar
chrome.runtime.sendMessage({
type: 'COMMENTS_CAPTURED',
comments: limitedComments
});
});
}
return true;
});
// Initial extraction when the content script loads
setTimeout(() => {
const comments = extractComments();
chrome.storage.sync.get(['maxComments'], (result) => {
const maxComments = result.maxComments || 50;
const limitedComments = comments.slice(0, maxComments);
chrome.runtime.sendMessage({
type: 'COMMENTS_CAPTURED',
comments: limitedComments
});
});
}, 1000);

View File

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

View File

@@ -0,0 +1 @@
<!-- This is a placeholder. You'll need to create actual icon files -->

View File

@@ -0,0 +1 @@
<!-- This is a placeholder. You'll need to create actual icon files -->

View File

@@ -0,0 +1 @@
<!-- This is a placeholder. You'll need to create actual icon files -->

13
extension/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<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>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

41
extension/manifest.json Normal file
View File

@@ -0,0 +1,41 @@
{
"manifest_version": 3,
"name": "Social Media Comment Assistant",
"version": "1.0.0",
"description": "A sidebar extension that captures comments, analyzes them, and suggests replies",
"action": {
"default_title": "Comment Assistant",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"permissions": [
"activeTab",
"scripting",
"storage",
"sidePanel"
],
"host_permissions": [
"<all_urls>"
],
"side_panel": {
"default_path": "sidebar.html"
},
"background": {
"service_worker": "background.js",
"type": "module"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"]
}
]
}

4517
extension/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
extension/package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "social-media-comment-assistant",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@crxjs/vite-plugin": "^2.0.0-beta.23",
"@eslint/js": "^9.9.1",
"@types/chrome": "^0.0.260",
"@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"
}

2887
extension/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

12
extension/sidebar.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Comment Assistant</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/sidebar/main.tsx"></script>
</body>
</html>

239
extension/src/App.tsx Normal file
View 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;

View 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
View File

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

55
extension/src/main.tsx Normal file
View 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
View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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
View 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
View File

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

View File

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

View File

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

7
extension/tsconfig.json Normal file
View File

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

View File

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

25
extension/vite.config.ts Normal file
View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { crx } from '@crxjs/vite-plugin';
import manifest from './manifest.json';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react(),
crx({ manifest }),
],
optimizeDeps: {
exclude: ['lucide-react'],
},
build: {
rollupOptions: {
input: {
sidebar: 'sidebar.html',
},
},
},
server: {
hmr: false, // Completely disable HMR to prevent WebSocket connection attempts
},
});

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

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

8
web/.bolt/prompt Normal file
View File

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

24
web/.gitignore vendored Normal file
View File

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

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

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

13
web/index.html Normal file
View File

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

4061
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
web/package.json Normal file
View File

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

3709
web/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

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

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

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

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

View File

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

View File

@@ -0,0 +1,520 @@
import React, { useState, useEffect } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import {
Facebook,
MessageSquare,
Instagram,
Linkedin,
CheckCircle,
XCircle,
MoreHorizontal,
ExternalLink,
BookOpen,
ThumbsUp,
ThumbsDown,
Minus,
AlertTriangle,
User,
Award,
Briefcase,
Youtube,
Hash,
Filter,
ChevronDown,
ArrowLeft
} from 'lucide-react';
import CommentPreview from './CommentPreview';
import { commentsApi, postsApi } from '../utils/api';
// 定义后端返回的评论类型
interface ApiComment {
comment_id: string;
content: string;
sentiment_score: number;
created_at: string;
updated_at: string;
post_id: string;
user_id: string;
user_profile?: {
id: string;
full_name: string;
avatar_url: string;
};
}
// 定义前端使用的评论类型
interface FrontendComment {
id: string;
content: string;
author: string;
authorType: 'user' | 'kol' | 'official';
platform: 'facebook' | 'threads' | 'instagram' | 'linkedin' | 'xiaohongshu' | 'youtube';
contentType?: 'post' | 'reel' | 'video' | 'short';
timestamp: string;
sentiment: string;
status: string;
replyStatus?: string;
language?: string;
articleTitle?: string;
postAuthor?: string;
postAuthorType?: string;
url?: string;
}
interface CommentListProps {
postId?: string; // 可选的帖子 ID如果提供则只获取该帖子的评论
}
interface PostData {
id: string;
title: string;
description?: string;
platform: string;
post_url?: string;
}
const CommentList: React.FC<CommentListProps> = () => {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const postId = searchParams.get('post_id');
const [comments, setComments] = useState<FrontendComment[]>([]);
const [post, setPost] = useState<PostData | null>(null); // Store post data
const [loading, setLoading] = useState<boolean>(true);
const [selectedComment, setSelectedComment] = useState<FrontendComment | null>(null);
const [error, setError] = useState<string | null>(null);
// 过滤和分页状态
const [platformFilter, setPlatformFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [sentimentFilter, setSentimentFilter] = useState<string>('all');
const [replyStatusFilter, setReplyStatusFilter] = useState<string>('all');
const [languageFilter, setLanguageFilter] = useState<string>('all');
const [searchQuery, setSearchQuery] = useState<string>('');
const [currentPage, setCurrentPage] = useState<number>(1);
const [pageSize, setPageSize] = useState<number>(10);
const [totalComments, setTotalComments] = useState<number>(0);
const [showFilters, setShowFilters] = useState<boolean>(false);
// Fetch post data if postId is provided
useEffect(() => {
const fetchPostData = async () => {
if (postId) {
try {
const response = await postsApi.getPost(postId);
setPost(response.data);
} catch (err) {
console.error('Failed to fetch post data:', err);
}
}
};
fetchPostData();
}, [postId]);
// 获取评论数据
useEffect(() => {
const fetchComments = async () => {
try {
setLoading(true);
// Build query parameters
const params: Record<string, string | number> = {};
if (postId) {
params.post_id = postId;
}
if (platformFilter !== 'all') {
params.platform = platformFilter;
}
if (statusFilter !== 'all') {
params.status = statusFilter;
}
if (sentimentFilter !== 'all') {
params.sentiment = sentimentFilter;
}
if (searchQuery) {
params.query = searchQuery;
}
if (languageFilter !== 'all') {
params.language = languageFilter;
}
// Add pagination
params.limit = pageSize;
params.offset = (currentPage - 1) * pageSize;
const response = await commentsApi.getComments(params);
// 处理返回的数据
const apiComments: ApiComment[] = response.data.comments || [];
const total = response.data.total || apiComments.length;
// 转换为前端格式
const frontendComments: FrontendComment[] = apiComments.map(comment => {
// 确定情感
let sentiment = 'neutral';
if (comment.sentiment_score > 0.3) {
sentiment = 'positive';
} else if (comment.sentiment_score < -0.3) {
sentiment = 'negative';
}
// 检测语言
const language = detectLanguage(comment.content);
return {
id: comment.comment_id,
content: comment.content,
author: comment.user_profile?.full_name || '匿名用户',
authorType: 'user', // 默认为普通用户
platform: 'facebook', // 假设默认是 Facebook
timestamp: comment.created_at,
sentiment,
status: 'approved', // 假设默认已审核
language,
// 其他可选字段可以根据 API 返回的数据动态添加
};
});
setComments(frontendComments);
setTotalComments(total);
setError(null);
} catch (err) {
console.error('Failed to fetch comments:', err);
setError('加载评论失败,请稍后再试');
} finally {
setLoading(false);
}
};
fetchComments();
}, [postId, platformFilter, statusFilter, sentimentFilter, searchQuery, languageFilter, currentPage, pageSize]);
// 简单的语言检测
const detectLanguage = (text: string): 'zh-TW' | 'zh-CN' | 'en' => {
const traditionalChineseRegex = /[一-龥]/;
const simplifiedChineseRegex = /[一-龥]/;
const englishRegex = /[a-zA-Z]/;
if (englishRegex.test(text) && !traditionalChineseRegex.test(text) && !simplifiedChineseRegex.test(text)) {
return 'en';
} else if (traditionalChineseRegex.test(text)) {
// 这里简化了繁体/简体的判断,实际实现应该更复杂
return 'zh-TW';
} else {
return 'zh-CN';
}
};
// Function to go back to posts list
const handleBackToPosts = () => {
navigate('/posts');
};
// 显示加载状态
if (loading) {
return (
<div className="flex flex-col flex-1 overflow-hidden md:flex-row">
<div className="flex-1 overflow-auto">
<div className="p-4 sm:p-6">
<div className="flex items-center justify-center h-64">
<div className="w-12 h-12 border-t-2 border-b-2 border-blue-500 rounded-full animate-spin"></div>
</div>
</div>
</div>
</div>
);
}
// 显示错误信息
if (error) {
return (
<div className="flex flex-col flex-1 overflow-hidden md:flex-row">
<div className="flex-1 overflow-auto">
<div className="p-4 sm:p-6">
<div className="flex items-center justify-center h-64">
<div className="text-center text-red-500">
<AlertTriangle className="w-12 h-12 mx-auto mb-4" />
<p>{error}</p>
<button
className="px-4 py-2 mt-4 text-white bg-blue-500 rounded-md hover:bg-blue-600"
onClick={() => window.location.reload()}
>
</button>
</div>
</div>
</div>
</div>
</div>
);
}
return (
<div className="flex flex-1 overflow-hidden">
<div className="flex flex-col flex-1 overflow-hidden">
<div className="bg-white p-4 border-b flex items-center justify-between">
<div className="flex items-center">
{postId && (
<button
onClick={handleBackToPosts}
className="mr-4 p-1 hover:bg-gray-100 rounded-full transition-colors duration-200"
>
<ArrowLeft className="h-5 w-5 text-gray-500" />
</button>
)}
<h2 className="text-lg font-semibold">
{post ? `${post.title} 的评论` : '所有评论'}
</h2>
{post && (
<span className="ml-2 text-sm text-gray-500">
({totalComments} )
</span>
)}
</div>
<div className="flex items-center">
<div className="relative mr-2">
<input
type="text"
placeholder="搜索评论..."
className="px-4 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<button
onClick={() => setShowFilters(!showFilters)}
className={`flex items-center px-3 py-2 rounded-lg text-sm ${
showFilters ? 'bg-blue-100 text-blue-700' : 'hover:bg-gray-100'
}`}
>
<Filter className="h-4 w-4 mr-1" />
<ChevronDown className="h-4 w-4 ml-1" />
</button>
</div>
</div>
{/* Mobile filters panel */}
{showFilters && (
<div className="p-4 mb-4 space-y-3 bg-white rounded-lg shadow-md sm:hidden">
<div>
<label className="block mb-1 text-sm font-medium text-gray-700"></label>
<select
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="all"></option>
<option value="pending"></option>
<option value="approved"></option>
<option value="rejected"></option>
</select>
</div>
<div>
<label className="block mb-1 text-sm font-medium text-gray-700"></label>
<select
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
value={platformFilter}
onChange={(e) => setPlatformFilter(e.target.value)}
>
<option value="all"></option>
<option value="facebook">Facebook</option>
<option value="threads">Threads</option>
<option value="instagram">Instagram</option>
<option value="linkedin">LinkedIn</option>
<option value="xiaohongshu"></option>
<option value="youtube">YouTube</option>
</select>
</div>
<div>
<label className="block mb-1 text-sm font-medium text-gray-700"></label>
<select
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
value={replyStatusFilter}
onChange={(e) => setReplyStatusFilter(e.target.value)}
>
<option value="all"></option>
<option value="sent"></option>
<option value="draft">稿</option>
<option value="none"></option>
</select>
</div>
<div>
<label className="block mb-1 text-sm font-medium text-gray-700"></label>
<select
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
value={languageFilter}
onChange={(e) => setLanguageFilter(e.target.value)}
>
<option value="all"></option>
<option value="zh-TW"></option>
<option value="zh-CN"></option>
<option value="en">English</option>
</select>
</div>
</div>
)}
{/* Mobile comment list */}
<div className="block md:hidden">
<div className="space-y-4">
{comments.map((comment) => (
<div
key={comment.id}
className="overflow-hidden bg-white rounded-lg shadow cursor-pointer"
onClick={() => setSelectedComment(comment)}
>
<div className="p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center">
<Facebook className="w-5 h-5 text-blue-600" />
<span className="ml-2 text-sm font-medium">Facebook</span>
</div>
</div>
<p className="mb-2 text-sm text-gray-900">{comment.content}</p>
<div className="flex items-center justify-between">
<div className="flex items-center">
<span className="mr-2 text-xs font-medium text-gray-700">{comment.author}</span>
</div>
<span className="text-xs text-gray-500">{comment.timestamp}</span>
</div>
</div>
</div>
))}
</div>
</div>
{/* Desktop table */}
<div className="hidden overflow-hidden bg-white rounded-lg shadow md:block">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase"></th>
<th scope="col" className="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase"></th>
<th scope="col" className="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase"></th>
<th scope="col" className="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase"></th>
<th scope="col" className="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase"></th>
<th scope="col" className="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase"></th>
<th scope="col" className="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase"></th>
<th scope="col" className="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase"></th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{comments.map((comment) => (
<tr
key={comment.id}
className="cursor-pointer hover:bg-gray-50"
onClick={() => setSelectedComment(comment)}
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex flex-col">
<div className="flex items-center">
<Facebook className="w-5 h-5 text-blue-600" />
<span className="ml-2 text-sm text-gray-900">
Facebook
</span>
</div>
</div>
</td>
<td className="px-6 py-4">
<div className="max-w-md text-sm text-gray-900 truncate">
{comment.content}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="mr-2 text-sm text-gray-900">{comment.author}</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">{comment.timestamp}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{comment.language === 'zh-TW' && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
</span>
)}
{comment.language === 'zh-CN' && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
</span>
)}
{comment.language === 'en' && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
EN
</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{comment.sentiment === 'positive' && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<ThumbsUp className="w-3 h-3 mr-1" />
</span>
)}
{comment.sentiment === 'negative' && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
<ThumbsDown className="w-3 h-3 mr-1" />
</span>
)}
{comment.sentiment === 'neutral' && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
<Minus className="w-3 h-3 mr-1" />
</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{comment.replyStatus === 'sent' && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<CheckCircle className="w-3 h-3 mr-1" />
</span>
)}
{comment.replyStatus === 'draft' && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
<MessageSquare className="w-3 h-3 mr-1" />
稿
</span>
)}
{comment.replyStatus === 'none' && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
<XCircle className="w-3 h-3 mr-1" />
</span>
)}
</td>
<td className="px-6 py-4 text-sm font-medium text-right whitespace-nowrap">
<button className="mr-3 text-blue-600 hover:text-blue-900">
<ExternalLink className="w-4 h-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
{selectedComment && (
<div className="overflow-auto bg-white border-t border-gray-200 md:w-96 md:border-t-0 md:border-l">
<CommentPreview comment={selectedComment} onClose={() => setSelectedComment(null)} />
</div>
)}
</div>
);
};
export default CommentList;

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More