init
This commit is contained in:
15
.env.promopt
Normal file
15
.env.promopt
Normal 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
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
docker/clickhouse/config.xml
|
||||||
|
docker/clickhouse/data
|
||||||
|
docker/clickhouse/users.xml
|
||||||
|
|
||||||
15
README.md
Normal file
15
README.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
本系統是一個社群平台成效管理系統,主要功能包括:
|
||||||
|
|
||||||
|
管理網紅文章、影片的成效數據
|
||||||
|
|
||||||
|
統計各個行銷活動的影響力,計算觀看數、按讚數等
|
||||||
|
|
||||||
|
追蹤留言者(當作網紅),並查詢其影響力數據
|
||||||
|
|
||||||
|
支援多平台(YouTube, Instagram, TikTok, Facebook, Twitter)
|
||||||
|
|
||||||
|
記錄網紅粉絲數、文章觀看數、按讚數變化
|
||||||
|
|
||||||
|
追蹤每篇文章的評論內容,並進行情感分析
|
||||||
|
|
||||||
|
提供 API 讓第三方應用存取分析結果
|
||||||
28
backend/.env
Normal file
28
backend/.env
Normal 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
28
backend/.env.example
Normal 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
47
backend/.gitignore
vendored
Normal 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
111
backend/README.md
Normal 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
51
backend/dist/config/index.js
vendored
Normal 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;
|
||||||
111
backend/dist/controllers/commentsController.js
vendored
Normal file
111
backend/dist/controllers/commentsController.js
vendored
Normal 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;
|
||||||
116
backend/dist/controllers/influencersController.js
vendored
Normal file
116
backend/dist/controllers/influencersController.js
vendored
Normal 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
163
backend/dist/index.js
vendored
Normal 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
85
backend/dist/middlewares/auth.js
vendored
Normal 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
453
backend/dist/routes/analytics.js
vendored
Normal 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
140
backend/dist/routes/auth.js
vendored
Normal 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
12
backend/dist/routes/comments.js
vendored
Normal 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
649
backend/dist/routes/community.js
vendored
Normal 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
10
backend/dist/routes/influencers.js
vendored
Normal 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
584
backend/dist/routes/posts.js
vendored
Normal 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
395
backend/dist/routes/projectComments.js
vendored
Normal 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
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
87
backend/dist/utils/clickhouse.js
vendored
Normal 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
492
backend/dist/utils/initDatabase.js
vendored
Normal 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
158
backend/dist/utils/queue.js
vendored
Normal 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
80
backend/dist/utils/redis.js
vendored
Normal 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
18
backend/dist/utils/supabase.js
vendored
Normal 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
38
backend/package.json
Normal 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
2878
backend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
55
backend/src/config/index.ts
Normal file
55
backend/src/config/index.ts
Normal 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;
|
||||||
120
backend/src/controllers/commentsController.ts
Normal file
120
backend/src/controllers/commentsController.ts
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
136
backend/src/controllers/influencersController.ts
Normal file
136
backend/src/controllers/influencersController.ts
Normal 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
172
backend/src/index.ts
Normal 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();
|
||||||
101
backend/src/middlewares/auth.ts
Normal file
101
backend/src/middlewares/auth.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
522
backend/src/routes/analytics.ts
Normal file
522
backend/src/routes/analytics.ts
Normal 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
159
backend/src/routes/auth.ts
Normal 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;
|
||||||
14
backend/src/routes/comments.ts
Normal file
14
backend/src/routes/comments.ts
Normal 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;
|
||||||
770
backend/src/routes/community.ts
Normal file
770
backend/src/routes/community.ts
Normal 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;
|
||||||
11
backend/src/routes/influencers.ts
Normal file
11
backend/src/routes/influencers.ts
Normal 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
686
backend/src/routes/posts.ts
Normal 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;
|
||||||
489
backend/src/routes/projectComments.ts
Normal file
489
backend/src/routes/projectComments.ts
Normal 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
1866
backend/src/swagger/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
86
backend/src/utils/clickhouse.ts
Normal file
86
backend/src/utils/clickhouse.ts
Normal 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;
|
||||||
538
backend/src/utils/initDatabase.ts
Normal file
538
backend/src/utils/initDatabase.ts
Normal 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
189
backend/src/utils/queue.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
80
backend/src/utils/redis.ts
Normal file
80
backend/src/utils/redis.ts
Normal 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;
|
||||||
97
backend/src/utils/supabase-comments-functions.sql
Normal file
97
backend/src/utils/supabase-comments-functions.sql
Normal 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;
|
||||||
|
|
||||||
|
$$;
|
||||||
738
backend/src/utils/supabase-functions.sql
Normal file
738
backend/src/utils/supabase-functions.sql
Normal 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;
|
||||||
|
|
||||||
|
$$;
|
||||||
19
backend/src/utils/supabase.ts
Normal file
19
backend/src/utils/supabase.ts
Normal 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
26
backend/start-server.sh
Executable 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
19
backend/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
28
docker/clickhouse/docker-compose.yml
Normal file
28
docker/clickhouse/docker-compose.yml
Normal 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
|
||||||
3
extension/.bolt/config.json
Normal file
3
extension/.bolt/config.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"template": "bolt-vite-react-ts"
|
||||||
|
}
|
||||||
8
extension/.bolt/prompt
Normal file
8
extension/.bolt/prompt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
For all designs I ask you to make, have them be beautiful, not cookie cutter. Make webpages that are fully featured and worthy for production.
|
||||||
|
|
||||||
|
By default, this template supports JSX syntax with Tailwind CSS classes, React hooks, and Lucide React for icons. Do not install other packages for UI themes, icons, etc unless absolutely necessary or I request them.
|
||||||
|
|
||||||
|
Use icons from lucide-react for logos.
|
||||||
|
|
||||||
|
Use stock photos from unsplash where appropriate, only valid URLs you know exist. Do not download the images, only link to them in image tags.
|
||||||
|
|
||||||
24
extension/.gitignore
vendored
Normal file
24
extension/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
7
extension/background.js
Normal file
7
extension/background.js
Normal 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
243
extension/content.js
Normal 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);
|
||||||
28
extension/eslint.config.js
Normal file
28
extension/eslint.config.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import globals from 'globals';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['dist'] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
1
extension/icons/icon128.png
Normal file
1
extension/icons/icon128.png
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<!-- This is a placeholder. You'll need to create actual icon files -->
|
||||||
1
extension/icons/icon16.png
Normal file
1
extension/icons/icon16.png
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<!-- This is a placeholder. You'll need to create actual icon files -->
|
||||||
1
extension/icons/icon48.png
Normal file
1
extension/icons/icon48.png
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<!-- This is a placeholder. You'll need to create actual icon files -->
|
||||||
13
extension/index.html
Normal file
13
extension/index.html
Normal 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
41
extension/manifest.json
Normal 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
4517
extension/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
extension/package.json
Normal file
36
extension/package.json
Normal 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
2887
extension/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
extension/postcss.config.js
Normal file
6
extension/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
12
extension/sidebar.html
Normal file
12
extension/sidebar.html
Normal 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
239
extension/src/App.tsx
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { MessageSquare, BarChart2, Send, RefreshCw, Settings as SettingsIcon, AlertCircle } from 'lucide-react';
|
||||||
|
import CommentList from './sidebar/components/CommentList';
|
||||||
|
import Analytics from './sidebar/components/Analytics';
|
||||||
|
import ReplyGenerator from './sidebar/components/ReplyGenerator';
|
||||||
|
import Settings from './sidebar/components/Settings';
|
||||||
|
import { Comment } from './types';
|
||||||
|
import mockComments from './mockData';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [activeTab, setActiveTab] = useState<'comments' | 'analytics' | 'reply' | 'settings'>('comments');
|
||||||
|
const [comments, setComments] = useState<Comment[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
const [selectedComment, setSelectedComment] = useState<Comment | null>(null);
|
||||||
|
const [mockDelay, setMockDelay] = useState<number>(1000);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Simulate loading comments with a delay
|
||||||
|
setError(null);
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
setComments(mockComments);
|
||||||
|
setIsLoading(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Error loading comments: ' + (err instanceof Error ? err.message : String(err)));
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, mockDelay);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [mockDelay]);
|
||||||
|
|
||||||
|
const refreshComments = () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
setComments(mockComments);
|
||||||
|
setIsLoading(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Error refreshing comments: ' + (err instanceof Error ? err.message : String(err)));
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, mockDelay);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectComment = (comment: Comment) => {
|
||||||
|
setSelectedComment(comment);
|
||||||
|
setActiveTab('reply');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col min-h-screen bg-gray-100">
|
||||||
|
<header className="bg-blue-600 text-white p-4">
|
||||||
|
<div className="container mx-auto flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
|
<h1 className="text-2xl font-bold">社群留言助手 - 開發模式</h1>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="text-sm mr-2">模擬延遲:</span>
|
||||||
|
<select
|
||||||
|
value={mockDelay}
|
||||||
|
onChange={(e) => setMockDelay(Number(e.target.value))}
|
||||||
|
className="bg-blue-700 text-white rounded px-2 py-1 text-sm"
|
||||||
|
>
|
||||||
|
<option value="0">無延遲</option>
|
||||||
|
<option value="500">0.5 秒</option>
|
||||||
|
<option value="1000">1 秒</option>
|
||||||
|
<option value="2000">2 秒</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={refreshComments}
|
||||||
|
className="p-2 bg-blue-700 rounded-full text-white hover:bg-blue-800 transition-colors"
|
||||||
|
title="重新載入留言"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mx-auto mt-4 container" role="alert">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<AlertCircle className="mr-2" size={20} />
|
||||||
|
<span className="block sm:inline">{error}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<main className="flex-1 container mx-auto p-4 flex flex-col md:flex-row gap-4">
|
||||||
|
{/* Sidebar Preview */}
|
||||||
|
<div className="w-full md:w-80 bg-white rounded-lg shadow-lg overflow-hidden flex flex-col h-[600px] border border-gray-200">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-blue-600 text-white p-4 shadow-md">
|
||||||
|
<h2 className="text-xl font-bold flex items-center">
|
||||||
|
<MessageSquare className="mr-2" size={20} />
|
||||||
|
社群留言助手
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm opacity-80">自動捕獲留言並產生回覆建議</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-auto p-4">
|
||||||
|
{activeTab === 'comments' && (
|
||||||
|
<CommentList
|
||||||
|
comments={comments}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onSelectComment={handleSelectComment}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeTab === 'analytics' && (
|
||||||
|
<Analytics comments={comments} />
|
||||||
|
)}
|
||||||
|
{activeTab === 'reply' && (
|
||||||
|
<ReplyGenerator
|
||||||
|
comment={selectedComment}
|
||||||
|
onBack={() => setActiveTab('comments')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeTab === 'settings' && (
|
||||||
|
<Settings />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="bg-white border-t border-gray-200 p-2">
|
||||||
|
<div className="flex justify-around">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('comments')}
|
||||||
|
className={`flex flex-col items-center p-2 rounded-md ${activeTab === 'comments' ? 'text-blue-600' : 'text-gray-600'}`}
|
||||||
|
>
|
||||||
|
<MessageSquare size={20} />
|
||||||
|
<span className="text-xs mt-1">留言</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('analytics')}
|
||||||
|
className={`flex flex-col items-center p-2 rounded-md ${activeTab === 'analytics' ? 'text-blue-600' : 'text-gray-600'}`}
|
||||||
|
>
|
||||||
|
<BarChart2 size={20} />
|
||||||
|
<span className="text-xs mt-1">數據</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('reply')}
|
||||||
|
className={`flex flex-col items-center p-2 rounded-md ${activeTab === 'reply' ? 'text-blue-600' : 'text-gray-600'}`}
|
||||||
|
>
|
||||||
|
<Send size={20} />
|
||||||
|
<span className="text-xs mt-1">回覆</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('settings')}
|
||||||
|
className={`flex flex-col items-center p-2 rounded-md ${activeTab === 'settings' ? 'text-blue-600' : 'text-gray-600'}`}
|
||||||
|
>
|
||||||
|
<SettingsIcon size={20} />
|
||||||
|
<span className="text-xs mt-1">設置</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Development Info */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||||
|
<h2 className="text-xl font-bold mb-4">開發資訊</h2>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-2">當前狀態</h3>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="bg-gray-50 p-3 rounded border border-gray-200">
|
||||||
|
<p className="text-sm font-medium text-gray-700">當前頁面</p>
|
||||||
|
<p className="text-lg">{activeTab}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 p-3 rounded border border-gray-200">
|
||||||
|
<p className="text-sm font-medium text-gray-700">留言數量</p>
|
||||||
|
<p className="text-lg">{comments.length}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 p-3 rounded border border-gray-200">
|
||||||
|
<p className="text-sm font-medium text-gray-700">載入狀態</p>
|
||||||
|
<p className="text-lg">{isLoading ? '載入中' : '已載入'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 p-3 rounded border border-gray-200">
|
||||||
|
<p className="text-sm font-medium text-gray-700">選中的留言</p>
|
||||||
|
<p className="text-lg">{selectedComment ? `ID: ${selectedComment.id}` : '無'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-2">開發指南</h3>
|
||||||
|
<div className="bg-blue-50 p-4 rounded border border-blue-200">
|
||||||
|
<p className="mb-2">這是一個開發環境,用於測試 Chrome 擴展的功能。</p>
|
||||||
|
<ul className="list-disc pl-5 space-y-1 text-sm">
|
||||||
|
<li>左側顯示的是擴展的側邊欄界面預覽</li>
|
||||||
|
<li>可以調整模擬延遲來測試不同的載入狀態</li>
|
||||||
|
<li>點擊刷新按鈕可以重新載入模擬數據</li>
|
||||||
|
<li>所有功能都使用模擬數據,不會實際抓取網頁留言</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">構建與測試</h3>
|
||||||
|
<div className="bg-gray-50 p-4 rounded border border-gray-200 space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">構建擴展:</p>
|
||||||
|
<code className="bg-gray-100 px-2 py-1 rounded text-sm">npm run build</code>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">載入擴展:</p>
|
||||||
|
<ol className="list-decimal pl-5 text-sm space-y-1">
|
||||||
|
<li>打開 Chrome 瀏覽器,進入擴展管理頁面 (chrome://extensions/)</li>
|
||||||
|
<li>開啟開發者模式</li>
|
||||||
|
<li>點擊「載入已解壓的擴展」</li>
|
||||||
|
<li>選擇項目的 dist 目錄</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">測試擴展:</p>
|
||||||
|
<ol className="list-decimal pl-5 text-sm space-y-1">
|
||||||
|
<li>在任意網頁點擊擴展圖標</li>
|
||||||
|
<li>側邊欄將會打開,顯示留言助手界面</li>
|
||||||
|
<li>如果沒有自動打開,可以右鍵點擊擴展圖標,選擇「打開側邊欄」</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer className="bg-gray-800 text-white p-4 text-center">
|
||||||
|
<p>社群留言助手 - 開發模式 © 2025</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
59
extension/src/ErrorBoundary.tsx
Normal file
59
extension/src/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
|
import { AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ErrorBoundary extends Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||||
|
console.error('Error caught by ErrorBoundary:', error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(): ReactNode {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
if (this.props.fallback) {
|
||||||
|
return this.props.fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 bg-red-100 border border-red-400 text-red-700 rounded-md">
|
||||||
|
<div className="flex items-start">
|
||||||
|
<AlertCircle className="mr-2 mt-0.5" size={20} />
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold mb-1">Something went wrong</h3>
|
||||||
|
<p className="text-sm mb-2">
|
||||||
|
{this.state.error?.message || 'An unexpected error occurred'}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="bg-red-600 text-white px-3 py-1 rounded text-sm hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
Reload Page
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ErrorBoundary;
|
||||||
3
extension/src/index.css
Normal file
3
extension/src/index.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
55
extension/src/main.tsx
Normal file
55
extension/src/main.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import App from './App.tsx';
|
||||||
|
import './index.css';
|
||||||
|
import ErrorBoundary from './ErrorBoundary';
|
||||||
|
|
||||||
|
// Error boundary for the entire application
|
||||||
|
const renderApp = () => {
|
||||||
|
try {
|
||||||
|
const rootElement = document.getElementById('root');
|
||||||
|
if (!rootElement) {
|
||||||
|
console.error('Root element not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(rootElement).render(
|
||||||
|
<StrictMode>
|
||||||
|
<ErrorBoundary>
|
||||||
|
<App />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error rendering application:', error);
|
||||||
|
|
||||||
|
// Render a fallback UI in case of error
|
||||||
|
const rootElement = document.getElementById('root');
|
||||||
|
if (rootElement) {
|
||||||
|
rootElement.innerHTML = `
|
||||||
|
<div style="padding: 20px; color: #721c24; background-color: #f8d7da; border: 1px solid #f5c6cb; border-radius: 5px;">
|
||||||
|
<h2>Application Error</h2>
|
||||||
|
<p>Sorry, something went wrong while loading the application.</p>
|
||||||
|
<p>Error details: ${error instanceof Error ? error.message : String(error)}</p>
|
||||||
|
<button onclick="window.location.reload()" style="background: #dc3545; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer;">
|
||||||
|
Reload Page
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Disable Vite's error overlay to prevent WebSocket connection attempts
|
||||||
|
window.addEventListener('error', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
console.error('Caught error:', event.error);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Disable Vite's HMR client
|
||||||
|
if (import.meta.hot) {
|
||||||
|
import.meta.hot.decline();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderApp();
|
||||||
224
extension/src/mockData.ts
Normal file
224
extension/src/mockData.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import { Comment } from './types';
|
||||||
|
|
||||||
|
const mockComments: Comment[] = [
|
||||||
|
{
|
||||||
|
id: 'comment-1',
|
||||||
|
author: '王小明',
|
||||||
|
content: '這個產品真的很好用!我已經推薦給我的朋友們了。希望未來能有更多顏色選擇。',
|
||||||
|
timestamp: '2小時前',
|
||||||
|
likes: 24,
|
||||||
|
platform: 'facebook',
|
||||||
|
sentiment: 'positive',
|
||||||
|
keywords: ['好用', '推薦', '顏色'],
|
||||||
|
category: '產品讚美',
|
||||||
|
replies: [
|
||||||
|
{
|
||||||
|
id: 'reply-1-1',
|
||||||
|
author: '品牌官方',
|
||||||
|
content: '謝謝您的支持!我們正在開發更多顏色,敬請期待!',
|
||||||
|
timestamp: '1小時前',
|
||||||
|
likes: 5,
|
||||||
|
platform: 'facebook',
|
||||||
|
sentiment: 'positive'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'comment-2',
|
||||||
|
author: '林美玲',
|
||||||
|
content: '請問這個產品適合敏感肌膚使用嗎?我之前用類似的產品會過敏。',
|
||||||
|
timestamp: '3小時前',
|
||||||
|
likes: 7,
|
||||||
|
platform: 'facebook',
|
||||||
|
sentiment: 'neutral',
|
||||||
|
keywords: ['敏感肌膚', '過敏', '適合'],
|
||||||
|
category: '產品詢問'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'comment-3',
|
||||||
|
author: 'Jason Chen',
|
||||||
|
content: 'The quality is amazing! Worth every penny. Will definitely buy again.',
|
||||||
|
timestamp: '5小時前',
|
||||||
|
likes: 18,
|
||||||
|
platform: 'instagram',
|
||||||
|
sentiment: 'positive',
|
||||||
|
keywords: ['quality', 'worth', 'buy again'],
|
||||||
|
category: '產品讚美'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'comment-4',
|
||||||
|
author: '陳大華',
|
||||||
|
content: '收到產品了,包裝很精美,但是尺寸比我想像中小一點。總體來說還是很滿意的。',
|
||||||
|
timestamp: '昨天',
|
||||||
|
likes: 12,
|
||||||
|
platform: 'facebook',
|
||||||
|
sentiment: 'neutral',
|
||||||
|
keywords: ['包裝', '尺寸', '滿意'],
|
||||||
|
category: '產品評價'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'comment-5',
|
||||||
|
author: 'Sarah Wong',
|
||||||
|
content: '我有個問題,請問這個產品可以國際運送嗎?我現在在美國。',
|
||||||
|
timestamp: '昨天',
|
||||||
|
likes: 3,
|
||||||
|
platform: 'instagram',
|
||||||
|
sentiment: 'neutral',
|
||||||
|
keywords: ['國際運送', '美國'],
|
||||||
|
category: '物流詢問'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'comment-6',
|
||||||
|
author: '黃小琳',
|
||||||
|
content: '價格有點貴,但品質確實不錯。希望能有折扣活動。',
|
||||||
|
timestamp: '2天前',
|
||||||
|
likes: 9,
|
||||||
|
platform: 'facebook',
|
||||||
|
sentiment: 'neutral',
|
||||||
|
keywords: ['價格', '品質', '折扣'],
|
||||||
|
category: '價格評論'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'comment-7',
|
||||||
|
author: 'Mike Li',
|
||||||
|
content: 'Just received my order. The shipping was super fast! Great service.',
|
||||||
|
timestamp: '2天前',
|
||||||
|
likes: 15,
|
||||||
|
platform: 'twitter',
|
||||||
|
sentiment: 'positive',
|
||||||
|
keywords: ['shipping', 'fast', 'service'],
|
||||||
|
category: '物流評價'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'comment-8',
|
||||||
|
author: '張小菲',
|
||||||
|
content: '我的訂單顯示已發貨,但追蹤號碼似乎不正確。能幫我確認一下嗎?訂單號:TW20250615001',
|
||||||
|
timestamp: '3天前',
|
||||||
|
likes: 0,
|
||||||
|
platform: 'facebook',
|
||||||
|
sentiment: 'negative',
|
||||||
|
keywords: ['訂單', '追蹤號碼', '不正確'],
|
||||||
|
category: '物流問題'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'comment-9',
|
||||||
|
author: 'David Wang',
|
||||||
|
content: '這是我第三次購買了,每次都很滿意。客服也很棒!',
|
||||||
|
timestamp: '4天前',
|
||||||
|
likes: 27,
|
||||||
|
platform: 'youtube',
|
||||||
|
sentiment: 'positive',
|
||||||
|
keywords: ['購買', '滿意', '客服'],
|
||||||
|
category: '客戶體驗',
|
||||||
|
replies: [
|
||||||
|
{
|
||||||
|
id: 'reply-9-1',
|
||||||
|
author: '品牌官方',
|
||||||
|
content: '感謝您的持續支持!我們非常重視每一位顧客的體驗。',
|
||||||
|
timestamp: '4天前',
|
||||||
|
likes: 8,
|
||||||
|
platform: 'youtube',
|
||||||
|
sentiment: 'positive'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reply-9-2',
|
||||||
|
author: 'Lisa Chen',
|
||||||
|
content: '我也很喜歡他們的客服,總是很有耐心解答問題。',
|
||||||
|
timestamp: '3天前',
|
||||||
|
likes: 5,
|
||||||
|
platform: 'youtube',
|
||||||
|
sentiment: 'positive'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'comment-10',
|
||||||
|
author: '李小明',
|
||||||
|
content: '產品收到了,但有一個小零件好像壞了。請問如何申請售後服務?',
|
||||||
|
timestamp: '5天前',
|
||||||
|
likes: 2,
|
||||||
|
platform: 'facebook',
|
||||||
|
sentiment: 'negative',
|
||||||
|
keywords: ['零件', '壞了', '售後服務'],
|
||||||
|
category: '產品問題'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'comment-11',
|
||||||
|
author: 'Emma Chang',
|
||||||
|
content: '我很喜歡你們的環保包裝!希望更多品牌能這樣做。',
|
||||||
|
timestamp: '1週前',
|
||||||
|
likes: 42,
|
||||||
|
platform: 'instagram',
|
||||||
|
sentiment: 'positive',
|
||||||
|
keywords: ['環保包裝', '喜歡'],
|
||||||
|
category: '包裝評價',
|
||||||
|
replies: [
|
||||||
|
{
|
||||||
|
id: 'reply-11-1',
|
||||||
|
author: '品牌官方',
|
||||||
|
content: '謝謝您的支持!環保是我們的核心價值之一,我們會繼續努力做得更好。',
|
||||||
|
timestamp: '1週前',
|
||||||
|
likes: 12,
|
||||||
|
platform: 'instagram',
|
||||||
|
sentiment: 'positive'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'comment-12',
|
||||||
|
author: '陳小華',
|
||||||
|
content: '請問有沒有實體店面可以試用產品?',
|
||||||
|
timestamp: '1週前',
|
||||||
|
likes: 5,
|
||||||
|
platform: 'facebook',
|
||||||
|
sentiment: 'neutral',
|
||||||
|
keywords: ['實體店面', '試用'],
|
||||||
|
category: '銷售詢問'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'comment-13',
|
||||||
|
author: 'Kevin Wu',
|
||||||
|
content: 'Great product but the app needs improvement. Sometimes it crashes when I try to connect to the device.',
|
||||||
|
timestamp: '1週前',
|
||||||
|
likes: 8,
|
||||||
|
platform: 'twitter',
|
||||||
|
sentiment: 'neutral',
|
||||||
|
keywords: ['product', 'app', 'crashes'],
|
||||||
|
category: '應用問題'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'comment-14',
|
||||||
|
author: '林小芳',
|
||||||
|
content: '我在官網看到的價格和這裡不一樣,為什麼?',
|
||||||
|
timestamp: '2週前',
|
||||||
|
likes: 3,
|
||||||
|
platform: 'youtube',
|
||||||
|
sentiment: 'negative',
|
||||||
|
keywords: ['價格', '官網', '不一樣'],
|
||||||
|
category: '價格問題'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'comment-15',
|
||||||
|
author: 'Sophia Lin',
|
||||||
|
content: '剛剛在朋友家看到這個產品,效果真的很驚人!請問現在有什麼促銷活動嗎?',
|
||||||
|
timestamp: '2週前',
|
||||||
|
likes: 19,
|
||||||
|
platform: 'facebook',
|
||||||
|
sentiment: 'positive',
|
||||||
|
keywords: ['效果', '驚人', '促銷活動'],
|
||||||
|
category: '產品讚美',
|
||||||
|
replies: [
|
||||||
|
{
|
||||||
|
id: 'reply-15-1',
|
||||||
|
author: '品牌官方',
|
||||||
|
content: '您好!我們目前有限時折扣活動,購買任兩件產品即可享85折優惠。詳情請查看我們的官網。',
|
||||||
|
timestamp: '2週前',
|
||||||
|
likes: 4,
|
||||||
|
platform: 'facebook',
|
||||||
|
sentiment: 'positive'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export default mockComments;
|
||||||
196
extension/src/sidebar/Sidebar.tsx
Normal file
196
extension/src/sidebar/Sidebar.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { MessageSquare, BarChart2, Send, RefreshCw, Settings } from 'lucide-react';
|
||||||
|
import CommentList from './components/CommentList';
|
||||||
|
import Analytics from './components/Analytics';
|
||||||
|
import ReplyGenerator from './components/ReplyGenerator';
|
||||||
|
import Settings from './components/Settings';
|
||||||
|
import { Comment } from '../types';
|
||||||
|
|
||||||
|
const Sidebar: React.FC = () => {
|
||||||
|
const [activeTab, setActiveTab] = useState<'comments' | 'analytics' | 'reply' | 'settings'>('comments');
|
||||||
|
const [comments, setComments] = useState<Comment[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
const [selectedComment, setSelectedComment] = useState<Comment | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Setup message listener with error handling
|
||||||
|
const messageListener = (message: any) => {
|
||||||
|
try {
|
||||||
|
if (message.type === 'COMMENTS_CAPTURED') {
|
||||||
|
setComments(message.comments);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error processing message:', error);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register listener if we're in a Chrome extension environment
|
||||||
|
if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.onMessage) {
|
||||||
|
chrome.runtime.onMessage.addListener(messageListener);
|
||||||
|
} else {
|
||||||
|
// We're in development mode - simulate comments loading
|
||||||
|
console.log('Development mode: simulating comment loading');
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
// Import mock data dynamically to avoid issues
|
||||||
|
import('../mockData').then(module => {
|
||||||
|
setComments(module.default);
|
||||||
|
setIsLoading(false);
|
||||||
|
}).catch(error => {
|
||||||
|
console.error('Error loading mock data:', error);
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in development mode comment simulation:', error);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request comments from the current page if in extension environment
|
||||||
|
const requestComments = () => {
|
||||||
|
try {
|
||||||
|
if (typeof chrome !== 'undefined' && chrome.tabs && chrome.tabs.query) {
|
||||||
|
setIsLoading(true);
|
||||||
|
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||||
|
if (tabs[0]?.id) {
|
||||||
|
chrome.tabs.sendMessage(tabs[0].id, { type: 'GET_COMMENTS' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error requesting comments:', error);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof chrome !== 'undefined' && chrome.tabs) {
|
||||||
|
requestComments();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
|
return () => {
|
||||||
|
if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.onMessage) {
|
||||||
|
chrome.runtime.onMessage.removeListener(messageListener);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshComments = () => {
|
||||||
|
try {
|
||||||
|
if (typeof chrome !== 'undefined' && chrome.tabs && chrome.tabs.query) {
|
||||||
|
setIsLoading(true);
|
||||||
|
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||||
|
if (tabs[0]?.id) {
|
||||||
|
chrome.tabs.sendMessage(tabs[0].id, { type: 'GET_COMMENTS' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Development mode - reload mock data
|
||||||
|
setIsLoading(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
import('../mockData').then(module => {
|
||||||
|
setComments(module.default);
|
||||||
|
setIsLoading(false);
|
||||||
|
}).catch(error => {
|
||||||
|
console.error('Error reloading mock data:', error);
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error refreshing comments:', error);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectComment = (comment: Comment) => {
|
||||||
|
setSelectedComment(comment);
|
||||||
|
setActiveTab('reply');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-screen bg-gray-50">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="bg-blue-600 text-white p-4 shadow-md">
|
||||||
|
<h1 className="text-xl font-bold flex items-center">
|
||||||
|
<MessageSquare className="mr-2" size={20} />
|
||||||
|
社群留言助手
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm opacity-80">自動捕獲留言並產生回覆建議</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="flex-1 overflow-auto p-4">
|
||||||
|
{activeTab === 'comments' && (
|
||||||
|
<CommentList
|
||||||
|
comments={comments}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onSelectComment={handleSelectComment}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeTab === 'analytics' && (
|
||||||
|
<Analytics comments={comments} />
|
||||||
|
)}
|
||||||
|
{activeTab === 'reply' && (
|
||||||
|
<ReplyGenerator
|
||||||
|
comment={selectedComment}
|
||||||
|
onBack={() => setActiveTab('comments')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeTab === 'settings' && (
|
||||||
|
<Settings />
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Refresh Button */}
|
||||||
|
<div className="absolute top-4 right-4">
|
||||||
|
<button
|
||||||
|
onClick={refreshComments}
|
||||||
|
className="p-2 bg-blue-700 rounded-full text-white hover:bg-blue-800 transition-colors"
|
||||||
|
title="重新捕獲留言"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="bg-white border-t border-gray-200 p-2">
|
||||||
|
<div className="flex justify-around">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('comments')}
|
||||||
|
className={`flex flex-col items-center p-2 rounded-md ${activeTab === 'comments' ? 'text-blue-600' : 'text-gray-600'}`}
|
||||||
|
>
|
||||||
|
<MessageSquare size={20} />
|
||||||
|
<span className="text-xs mt-1">留言</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('analytics')}
|
||||||
|
className={`flex flex-col items-center p-2 rounded-md ${activeTab === 'analytics' ? 'text-blue-600' : 'text-gray-600'}`}
|
||||||
|
>
|
||||||
|
<BarChart2 size={20} />
|
||||||
|
<span className="text-xs mt-1">數據</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('reply')}
|
||||||
|
className={`flex flex-col items-center p-2 rounded-md ${activeTab === 'reply' ? 'text-blue-600' : 'text-gray-600'}`}
|
||||||
|
>
|
||||||
|
<Send size={20} />
|
||||||
|
<span className="text-xs mt-1">回覆</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('settings')}
|
||||||
|
className={`flex flex-col items-center p-2 rounded-md ${activeTab === 'settings' ? 'text-blue-600' : 'text-gray-600'}`}
|
||||||
|
>
|
||||||
|
<Settings size={20} />
|
||||||
|
<span className="text-xs mt-1">設置</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Sidebar;
|
||||||
371
extension/src/sidebar/components/Analytics.tsx
Normal file
371
extension/src/sidebar/components/Analytics.tsx
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { BarChart2, TrendingUp, Clock, ThumbsUp, MessageSquare, Smile, Meh, Frown, Tag } from 'lucide-react';
|
||||||
|
import { Comment } from '../../types';
|
||||||
|
|
||||||
|
interface AnalyticsProps {
|
||||||
|
comments: Comment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const Analytics: React.FC<AnalyticsProps> = ({ comments }) => {
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
// Total comments
|
||||||
|
const totalComments = comments.length;
|
||||||
|
|
||||||
|
// Comments by platform
|
||||||
|
const platformCounts: Record<string, number> = {};
|
||||||
|
comments.forEach(comment => {
|
||||||
|
platformCounts[comment.platform] = (platformCounts[comment.platform] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Average likes
|
||||||
|
const totalLikes = comments.reduce((sum, comment) => sum + comment.likes, 0);
|
||||||
|
const avgLikes = totalComments > 0 ? (totalLikes / totalComments).toFixed(1) : '0';
|
||||||
|
|
||||||
|
// Comments with replies
|
||||||
|
const commentsWithReplies = comments.filter(comment =>
|
||||||
|
comment.replies && comment.replies.length > 0
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// Total replies
|
||||||
|
const totalReplies = comments.reduce((sum, comment) =>
|
||||||
|
sum + (comment.replies?.length || 0), 0
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sentiment counts
|
||||||
|
const sentimentCounts = {
|
||||||
|
positive: comments.filter(c => c.sentiment === 'positive').length,
|
||||||
|
neutral: comments.filter(c => c.sentiment === 'neutral').length,
|
||||||
|
negative: comments.filter(c => c.sentiment === 'negative').length
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keywords analysis
|
||||||
|
const keywordCounts: Record<string, number> = {};
|
||||||
|
comments.forEach(comment => {
|
||||||
|
if (comment.keywords) {
|
||||||
|
comment.keywords.forEach(keyword => {
|
||||||
|
keywordCounts[keyword] = (keywordCounts[keyword] || 0) + 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const topKeywords = Object.entries(keywordCounts)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 10)
|
||||||
|
.map(([keyword, count]) => ({ keyword, count }));
|
||||||
|
|
||||||
|
// Categories analysis
|
||||||
|
const categoryCounts: Record<string, number> = {};
|
||||||
|
comments.forEach(comment => {
|
||||||
|
if (comment.category) {
|
||||||
|
categoryCounts[comment.category] = (categoryCounts[comment.category] || 0) + 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const categories = Object.entries(categoryCounts)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map(([category, count]) => ({ category, count }));
|
||||||
|
|
||||||
|
// Most active platforms (sorted)
|
||||||
|
const sortedPlatforms = Object.entries(platformCounts)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map(([platform, count]) => ({ platform, count }));
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalComments,
|
||||||
|
platformCounts,
|
||||||
|
avgLikes,
|
||||||
|
commentsWithReplies,
|
||||||
|
totalReplies,
|
||||||
|
sortedPlatforms,
|
||||||
|
sentimentCounts,
|
||||||
|
topKeywords,
|
||||||
|
categories
|
||||||
|
};
|
||||||
|
}, [comments]);
|
||||||
|
|
||||||
|
if (comments.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-64 text-center">
|
||||||
|
<BarChart2 size={48} className="text-gray-400 mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-700">沒有數據可顯示</h3>
|
||||||
|
<p className="text-gray-500 mt-2">
|
||||||
|
請先捕獲留言以查看數據分析。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 mb-4">留言數據分析</h2>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 mb-6">
|
||||||
|
<div className="bg-white rounded-lg shadow p-3">
|
||||||
|
<div className="flex items-center text-blue-600 mb-1">
|
||||||
|
<MessageSquare size={16} className="mr-1" />
|
||||||
|
<span className="text-xs font-medium">總留言數</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-gray-800">{stats.totalComments}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow p-3">
|
||||||
|
<div className="flex items-center text-green-600 mb-1">
|
||||||
|
<ThumbsUp size={16} className="mr-1" />
|
||||||
|
<span className="text-xs font-medium">平均讚數</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-gray-800">{stats.avgLikes}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow p-3">
|
||||||
|
<div className="flex items-center text-purple-600 mb-1">
|
||||||
|
<TrendingUp size={16} className="mr-1" />
|
||||||
|
<span className="text-xs font-medium">互動率</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-gray-800">
|
||||||
|
{stats.totalComments > 0
|
||||||
|
? `${Math.round((stats.commentsWithReplies / stats.totalComments) * 100)}%`
|
||||||
|
: '0%'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow p-3">
|
||||||
|
<div className="flex items-center text-orange-600 mb-1">
|
||||||
|
<MessageSquare size={16} className="mr-1" />
|
||||||
|
<span className="text-xs font-medium">總回覆數</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-gray-800">{stats.totalReplies}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sentiment Analysis */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-700 mb-3">情緒分析</h3>
|
||||||
|
|
||||||
|
{/* Sentiment Bar */}
|
||||||
|
<div className="flex mb-2">
|
||||||
|
<div
|
||||||
|
className="bg-green-500 h-3 rounded-l-full"
|
||||||
|
style={{ width: `${(stats.sentimentCounts.positive / stats.totalComments) * 100}%` }}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
className="bg-gray-400 h-3"
|
||||||
|
style={{ width: `${(stats.sentimentCounts.neutral / stats.totalComments) * 100}%` }}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
className="bg-red-500 h-3 rounded-r-full"
|
||||||
|
style={{ width: `${(stats.sentimentCounts.negative / stats.totalComments) * 100}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-2 mt-3">
|
||||||
|
<div className="bg-green-50 p-2 rounded-md">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<div className="flex items-center text-green-700">
|
||||||
|
<Smile size={14} className="mr-1" />
|
||||||
|
<span className="text-xs font-medium">正面</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-600">{Math.round((stats.sentimentCounts.positive / stats.totalComments) * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-gray-800">{stats.sentimentCounts.positive}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-50 p-2 rounded-md">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<div className="flex items-center text-gray-700">
|
||||||
|
<Meh size={14} className="mr-1" />
|
||||||
|
<span className="text-xs font-medium">中性</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-600">{Math.round((stats.sentimentCounts.neutral / stats.totalComments) * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-gray-800">{stats.sentimentCounts.neutral}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-red-50 p-2 rounded-md">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<div className="flex items-center text-red-700">
|
||||||
|
<Frown size={14} className="mr-1" />
|
||||||
|
<span className="text-xs font-medium">負面</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-600">{Math.round((stats.sentimentCounts.negative / stats.totalComments) * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-gray-800">{stats.sentimentCounts.negative}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Keywords Analysis */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-700 mb-3">熱門關鍵詞</h3>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{stats.topKeywords.slice(0, 5).map(({ keyword, count }) => (
|
||||||
|
<div key={keyword} className="flex items-center">
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2 mr-2">
|
||||||
|
<div
|
||||||
|
className="bg-blue-600 h-2 rounded-full"
|
||||||
|
style={{ width: `${(count / stats.topKeywords[0].count) * 100}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center min-w-[100px]">
|
||||||
|
<span className="text-xs text-gray-700">{keyword}</span>
|
||||||
|
<span className="text-xs text-gray-500">{count}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap gap-1">
|
||||||
|
{stats.topKeywords.slice(5, 15).map(({ keyword, count }) => (
|
||||||
|
<span
|
||||||
|
key={keyword}
|
||||||
|
className="bg-blue-50 text-blue-700 text-xs px-2 py-0.5 rounded-full"
|
||||||
|
title={`出現 ${count} 次`}
|
||||||
|
>
|
||||||
|
{keyword}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Categories Analysis */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-700 mb-3">留言類別分佈</h3>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{stats.categories.slice(0, 5).map(({ category, count }) => (
|
||||||
|
<div key={category} className="flex items-center">
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2 mr-2">
|
||||||
|
<div
|
||||||
|
className="bg-purple-600 h-2 rounded-full"
|
||||||
|
style={{ width: `${(count / stats.totalComments) * 100}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center min-w-[120px]">
|
||||||
|
<span className="text-xs text-gray-700">{category}</span>
|
||||||
|
<span className="text-xs text-gray-500">{count} ({Math.round((count / stats.totalComments) * 100)}%)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Platform Distribution */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||||
|
<h3 className="text-sm font-medium text-gray-700 mb-3">平台分佈</h3>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Object.entries(stats.platformCounts).map(([platform, count]) => (
|
||||||
|
<div key={platform}>
|
||||||
|
<div className="flex justify-between text-xs text-gray-600 mb-1">
|
||||||
|
<span className="capitalize">{platform}</span>
|
||||||
|
<span>{count} ({Math.round((count / stats.totalComments) * 100)}%)</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className={`h-2 rounded-full ${getPlatformColor(platform)}`}
|
||||||
|
style={{ width: `${(count / stats.totalComments) * 100}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Comments */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-700 mb-3">熱門留言</h3>
|
||||||
|
|
||||||
|
{comments
|
||||||
|
.sort((a, b) => b.likes - a.likes)
|
||||||
|
.slice(0, 3)
|
||||||
|
.map(comment => (
|
||||||
|
<div key={comment.id} className="border-b border-gray-100 last:border-0 py-2">
|
||||||
|
<div className="flex justify-between items-start mb-1">
|
||||||
|
<span className="text-xs font-medium text-gray-800">{comment.author}</span>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<div className="flex items-center text-xs text-gray-500">
|
||||||
|
<ThumbsUp size={10} className="mr-1" />
|
||||||
|
{comment.likes}
|
||||||
|
</div>
|
||||||
|
{comment.sentiment && (
|
||||||
|
<div className={`flex items-center text-xs px-1 py-0.5 rounded-full ${getSentimentBadgeColor(comment.sentiment)}`}>
|
||||||
|
{comment.sentiment === 'positive' && <Smile size={8} className="mr-0.5" />}
|
||||||
|
{comment.sentiment === 'neutral' && <Meh size={8} className="mr-0.5" />}
|
||||||
|
{comment.sentiment === 'negative' && <Frown size={8} className="mr-0.5" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-600 line-clamp-2">{comment.content}</p>
|
||||||
|
<div className="flex justify-between items-center mt-1">
|
||||||
|
<span className="text-xs text-gray-500">{comment.timestamp}</span>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
{comment.category && (
|
||||||
|
<span className="text-xs px-1 py-0.5 rounded-full bg-purple-50 text-purple-700">
|
||||||
|
{comment.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={`text-xs px-1 py-0.5 rounded-full ${getPlatformBadgeColor(comment.platform)}`}>
|
||||||
|
{comment.platform}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to get platform-specific colors
|
||||||
|
function getPlatformColor(platform: string): string {
|
||||||
|
switch (platform) {
|
||||||
|
case 'facebook':
|
||||||
|
return 'bg-blue-600';
|
||||||
|
case 'instagram':
|
||||||
|
return 'bg-pink-600';
|
||||||
|
case 'twitter':
|
||||||
|
return 'bg-blue-400';
|
||||||
|
case 'youtube':
|
||||||
|
return 'bg-red-600';
|
||||||
|
case 'linkedin':
|
||||||
|
return 'bg-blue-800';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-600';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get platform-specific badge colors
|
||||||
|
function getPlatformBadgeColor(platform: string): string {
|
||||||
|
switch (platform) {
|
||||||
|
case 'facebook':
|
||||||
|
return 'bg-blue-100 text-blue-800';
|
||||||
|
case 'instagram':
|
||||||
|
return 'bg-pink-100 text-pink-800';
|
||||||
|
case 'twitter':
|
||||||
|
return 'bg-blue-100 text-blue-600';
|
||||||
|
case 'youtube':
|
||||||
|
return 'bg-red-100 text-red-800';
|
||||||
|
case 'linkedin':
|
||||||
|
return 'bg-blue-100 text-blue-900';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get sentiment badge colors
|
||||||
|
function getSentimentBadgeColor(sentiment: string): string {
|
||||||
|
switch (sentiment) {
|
||||||
|
case 'positive':
|
||||||
|
return 'bg-green-100 text-green-800';
|
||||||
|
case 'neutral':
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
|
case 'negative':
|
||||||
|
return 'bg-red-100 text-red-800';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Analytics;
|
||||||
453
extension/src/sidebar/components/CommentList.tsx
Normal file
453
extension/src/sidebar/components/CommentList.tsx
Normal file
@@ -0,0 +1,453 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { MessageSquare, ThumbsUp, Clock, Filter, SortDesc, Search, X, ChevronDown, Smile, Frown, Meh, Tag } from 'lucide-react';
|
||||||
|
import { Comment } from '../../types';
|
||||||
|
|
||||||
|
interface CommentListProps {
|
||||||
|
comments: Comment[];
|
||||||
|
isLoading: boolean;
|
||||||
|
onSelectComment: (comment: Comment) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CommentList: React.FC<CommentListProps> = ({ comments, isLoading, onSelectComment }) => {
|
||||||
|
const [searchTerm, setSearchTerm] = useState<string>('');
|
||||||
|
const [platformFilter, setPlatformFilter] = useState<string>('all');
|
||||||
|
const [sentimentFilter, setSentimentFilter] = useState<'all' | 'positive' | 'neutral' | 'negative'>('all');
|
||||||
|
const [sortBy, setSortBy] = useState<'newest' | 'oldest' | 'likes' | 'replies'>('newest');
|
||||||
|
const [showFilters, setShowFilters] = useState<boolean>(false);
|
||||||
|
const [showAnalytics, setShowAnalytics] = useState<boolean>(true);
|
||||||
|
|
||||||
|
// Get unique platforms from comments
|
||||||
|
const platforms = useMemo(() => {
|
||||||
|
const platformSet = new Set<string>();
|
||||||
|
comments.forEach(comment => platformSet.add(comment.platform));
|
||||||
|
return Array.from(platformSet);
|
||||||
|
}, [comments]);
|
||||||
|
|
||||||
|
// Calculate sentiment statistics
|
||||||
|
const sentimentStats = useMemo(() => {
|
||||||
|
const stats = {
|
||||||
|
positive: 0,
|
||||||
|
neutral: 0,
|
||||||
|
negative: 0,
|
||||||
|
total: comments.length
|
||||||
|
};
|
||||||
|
|
||||||
|
comments.forEach(comment => {
|
||||||
|
if (comment.sentiment === 'positive') stats.positive++;
|
||||||
|
else if (comment.sentiment === 'neutral') stats.neutral++;
|
||||||
|
else if (comment.sentiment === 'negative') stats.negative++;
|
||||||
|
});
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}, [comments]);
|
||||||
|
|
||||||
|
// Extract top keywords
|
||||||
|
const topKeywords = useMemo(() => {
|
||||||
|
const keywordCounts: Record<string, number> = {};
|
||||||
|
|
||||||
|
comments.forEach(comment => {
|
||||||
|
if (comment.keywords) {
|
||||||
|
comment.keywords.forEach(keyword => {
|
||||||
|
keywordCounts[keyword] = (keywordCounts[keyword] || 0) + 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.entries(keywordCounts)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 10)
|
||||||
|
.map(([keyword, count]) => ({ keyword, count }));
|
||||||
|
}, [comments]);
|
||||||
|
|
||||||
|
// Extract categories
|
||||||
|
const categories = useMemo(() => {
|
||||||
|
const categoryCounts: Record<string, number> = {};
|
||||||
|
|
||||||
|
comments.forEach(comment => {
|
||||||
|
if (comment.category) {
|
||||||
|
categoryCounts[comment.category] = (categoryCounts[comment.category] || 0) + 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.entries(categoryCounts)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map(([category, count]) => ({ category, count }));
|
||||||
|
}, [comments]);
|
||||||
|
|
||||||
|
// Filter and sort comments
|
||||||
|
const filteredAndSortedComments = useMemo(() => {
|
||||||
|
// First filter by search term, platform, and sentiment
|
||||||
|
let filtered = comments.filter(comment => {
|
||||||
|
const matchesSearch = searchTerm === '' ||
|
||||||
|
comment.content.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
comment.author.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
|
||||||
|
const matchesPlatform = platformFilter === 'all' || comment.platform === platformFilter;
|
||||||
|
|
||||||
|
const matchesSentiment = sentimentFilter === 'all' || comment.sentiment === sentimentFilter;
|
||||||
|
|
||||||
|
return matchesSearch && matchesPlatform && matchesSentiment;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then sort
|
||||||
|
return filtered.sort((a, b) => {
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'newest':
|
||||||
|
// Simple string comparison for timestamps (in a real app, parse dates properly)
|
||||||
|
return a.timestamp < b.timestamp ? 1 : -1;
|
||||||
|
case 'oldest':
|
||||||
|
return a.timestamp > b.timestamp ? 1 : -1;
|
||||||
|
case 'likes':
|
||||||
|
return b.likes - a.likes;
|
||||||
|
case 'replies':
|
||||||
|
return (b.replies?.length || 0) - (a.replies?.length || 0);
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [comments, searchTerm, platformFilter, sentimentFilter, sortBy]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
||||||
|
<p className="mt-4 text-gray-600">正在捕獲留言...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (comments.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-64 text-center">
|
||||||
|
<MessageSquare size={48} className="text-gray-400 mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-700">沒有找到留言</h3>
|
||||||
|
<p className="text-gray-500 mt-2">
|
||||||
|
此頁面上沒有檢測到留言,或者留言格式不被支持。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-4 flex justify-between items-center">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800">留言列表</h2>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAnalytics(!showAnalytics)}
|
||||||
|
className={`p-1.5 rounded-md ${showAnalytics ? 'bg-purple-100 text-purple-600' : 'bg-gray-100 text-gray-600'} hover:bg-purple-100 hover:text-purple-600 transition-colors`}
|
||||||
|
title="顯示/隱藏分析"
|
||||||
|
>
|
||||||
|
<Tag size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFilters(!showFilters)}
|
||||||
|
className={`p-1.5 rounded-md ${showFilters ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-600'} hover:bg-blue-100 hover:text-blue-600 transition-colors`}
|
||||||
|
title="篩選與排序"
|
||||||
|
>
|
||||||
|
<Filter size={16} />
|
||||||
|
</button>
|
||||||
|
<span className="bg-blue-100 text-blue-800 text-xs font-medium px-2.5 py-0.5 rounded">
|
||||||
|
{filteredAndSortedComments.length} / {comments.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Analytics */}
|
||||||
|
{showAnalytics && (
|
||||||
|
<div className="bg-white rounded-lg shadow p-3 mb-4">
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<h3 className="text-sm font-medium text-gray-700">情緒分析</h3>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<span className="text-xs text-gray-500">總留言: {sentimentStats.total}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sentiment Distribution */}
|
||||||
|
<div className="flex mb-2">
|
||||||
|
<div
|
||||||
|
className="bg-green-500 h-2 rounded-l-full"
|
||||||
|
style={{ width: `${(sentimentStats.positive / sentimentStats.total) * 100}%` }}
|
||||||
|
title={`正面: ${sentimentStats.positive} (${Math.round((sentimentStats.positive / sentimentStats.total) * 100)}%)`}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
className="bg-gray-400 h-2"
|
||||||
|
style={{ width: `${(sentimentStats.neutral / sentimentStats.total) * 100}%` }}
|
||||||
|
title={`中性: ${sentimentStats.neutral} (${Math.round((sentimentStats.neutral / sentimentStats.total) * 100)}%)`}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
className="bg-red-500 h-2 rounded-r-full"
|
||||||
|
style={{ width: `${(sentimentStats.negative / sentimentStats.total) * 100}%` }}
|
||||||
|
title={`負面: ${sentimentStats.negative} (${Math.round((sentimentStats.negative / sentimentStats.total) * 100)}%)`}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between text-xs text-gray-600 mb-3">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-green-500 mr-1"></div>
|
||||||
|
<span>正面: {sentimentStats.positive}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-gray-400 mr-1"></div>
|
||||||
|
<span>中性: {sentimentStats.neutral}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-red-500 mr-1"></div>
|
||||||
|
<span>負面: {sentimentStats.negative}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Keywords */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<h3 className="text-xs font-medium text-gray-700 mb-1">熱門關鍵詞</h3>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{topKeywords.slice(0, 8).map(({ keyword, count }) => (
|
||||||
|
<span
|
||||||
|
key={keyword}
|
||||||
|
className="bg-blue-50 text-blue-700 text-xs px-2 py-0.5 rounded-full"
|
||||||
|
title={`出現 ${count} 次`}
|
||||||
|
>
|
||||||
|
{keyword}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Categories */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-medium text-gray-700 mb-1">主要類別</h3>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{categories.slice(0, 5).map(({ category, count }) => (
|
||||||
|
<span
|
||||||
|
key={category}
|
||||||
|
className="bg-purple-50 text-purple-700 text-xs px-2 py-0.5 rounded-full"
|
||||||
|
title={`${count} 則留言`}
|
||||||
|
>
|
||||||
|
{category}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search and Filter Panel */}
|
||||||
|
{showFilters && (
|
||||||
|
<div className="bg-white rounded-lg shadow p-3 mb-4 space-y-3">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||||||
|
<Search size={14} className="text-gray-500" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
placeholder="搜尋留言或作者..."
|
||||||
|
className="w-full pl-9 pr-9 py-2 text-sm border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
{searchTerm && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchTerm('')}
|
||||||
|
className="absolute inset-y-0 right-0 flex items-center pr-3"
|
||||||
|
>
|
||||||
|
<X size={14} className="text-gray-500 hover:text-gray-700" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{/* Platform Filter */}
|
||||||
|
<div className="relative">
|
||||||
|
<label className="block text-xs text-gray-700 mb-1">平台</label>
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={platformFilter}
|
||||||
|
onChange={(e) => setPlatformFilter(e.target.value)}
|
||||||
|
className="w-full p-2 text-sm border border-gray-300 rounded-md appearance-none focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="all">全部平台</option>
|
||||||
|
{platforms.map(platform => (
|
||||||
|
<option key={platform} value={platform}>{platform}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||||
|
<ChevronDown size={14} className="text-gray-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sentiment Filter */}
|
||||||
|
<div className="relative">
|
||||||
|
<label className="block text-xs text-gray-700 mb-1">情緒</label>
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={sentimentFilter}
|
||||||
|
onChange={(e) => setSentimentFilter(e.target.value as 'all' | 'positive' | 'neutral' | 'negative')}
|
||||||
|
className="w-full p-2 text-sm border border-gray-300 rounded-md appearance-none focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="all">全部情緒</option>
|
||||||
|
<option value="positive">正面</option>
|
||||||
|
<option value="neutral">中性</option>
|
||||||
|
<option value="negative">負面</option>
|
||||||
|
</select>
|
||||||
|
<div className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||||
|
<ChevronDown size={14} className="text-gray-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort By */}
|
||||||
|
<div className="relative col-span-2">
|
||||||
|
<label className="block text-xs text-gray-700 mb-1">排序方式</label>
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => setSortBy(e.target.value as 'newest' | 'oldest' | 'likes' | 'replies')}
|
||||||
|
className="w-full p-2 text-sm border border-gray-300 rounded-md appearance-none focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="newest">最新留言</option>
|
||||||
|
<option value="oldest">最舊留言</option>
|
||||||
|
<option value="likes">讚數最多</option>
|
||||||
|
<option value="replies">回覆最多</option>
|
||||||
|
</select>
|
||||||
|
<div className="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||||
|
<SortDesc size={14} className="text-gray-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Stats */}
|
||||||
|
{(searchTerm || platformFilter !== 'all' || sentimentFilter !== 'all') && (
|
||||||
|
<div className="flex justify-between items-center pt-1 text-xs text-gray-500">
|
||||||
|
<span>
|
||||||
|
{filteredAndSortedComments.length === comments.length
|
||||||
|
? '顯示全部留言'
|
||||||
|
: `顯示 ${filteredAndSortedComments.length} 個符合條件的留言`}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSearchTerm('');
|
||||||
|
setPlatformFilter('all');
|
||||||
|
setSentimentFilter('all');
|
||||||
|
}}
|
||||||
|
className="text-blue-600 hover:text-blue-800"
|
||||||
|
>
|
||||||
|
清除篩選
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Comments List */}
|
||||||
|
{filteredAndSortedComments.length === 0 ? (
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4 text-center">
|
||||||
|
<p className="text-gray-600">沒有符合條件的留言</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSearchTerm('');
|
||||||
|
setPlatformFilter('all');
|
||||||
|
setSentimentFilter('all');
|
||||||
|
}}
|
||||||
|
className="mt-2 text-sm text-blue-600 hover:text-blue-800"
|
||||||
|
>
|
||||||
|
清除篩選條件
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filteredAndSortedComments.map((comment) => (
|
||||||
|
<div
|
||||||
|
key={comment.id}
|
||||||
|
className="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow cursor-pointer"
|
||||||
|
onClick={() => onSelectComment(comment)}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<div className="font-medium text-gray-900">{comment.author}</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="flex items-center text-gray-500 text-xs">
|
||||||
|
<Clock size={12} className="mr-1" />
|
||||||
|
{comment.timestamp}
|
||||||
|
</div>
|
||||||
|
{comment.sentiment && (
|
||||||
|
<div className={`flex items-center text-xs px-1.5 py-0.5 rounded-full ${getSentimentBadgeColor(comment.sentiment)}`}>
|
||||||
|
{comment.sentiment === 'positive' && <Smile size={10} className="mr-1" />}
|
||||||
|
{comment.sentiment === 'neutral' && <Meh size={10} className="mr-1" />}
|
||||||
|
{comment.sentiment === 'negative' && <Frown size={10} className="mr-1" />}
|
||||||
|
{getSentimentLabel(comment.sentiment)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-gray-700 mb-3 line-clamp-2">{comment.content}</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 mb-3">
|
||||||
|
{comment.keywords?.map(keyword => (
|
||||||
|
<span key={keyword} className="bg-blue-50 text-blue-700 text-xs px-1.5 py-0.5 rounded">
|
||||||
|
{keyword}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<ThumbsUp size={12} className="mr-1" />
|
||||||
|
{comment.likes} 讚
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center">
|
||||||
|
<MessageSquare size={12} className="mr-1" />
|
||||||
|
{comment.replies?.length || 0} 回覆
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{comment.category && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-purple-50 text-purple-700">
|
||||||
|
{comment.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">
|
||||||
|
{comment.platform}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to get sentiment badge colors
|
||||||
|
function getSentimentBadgeColor(sentiment: string): string {
|
||||||
|
switch (sentiment) {
|
||||||
|
case 'positive':
|
||||||
|
return 'bg-green-100 text-green-800';
|
||||||
|
case 'neutral':
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
|
case 'negative':
|
||||||
|
return 'bg-red-100 text-red-800';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get sentiment labels
|
||||||
|
function getSentimentLabel(sentiment: string): string {
|
||||||
|
switch (sentiment) {
|
||||||
|
case 'positive':
|
||||||
|
return '正面';
|
||||||
|
case 'neutral':
|
||||||
|
return '中性';
|
||||||
|
case 'negative':
|
||||||
|
return '負面';
|
||||||
|
default:
|
||||||
|
return '未知';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CommentList;
|
||||||
363
extension/src/sidebar/components/ReplyGenerator.tsx
Normal file
363
extension/src/sidebar/components/ReplyGenerator.tsx
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { ArrowLeft, Send, Copy, Check, Zap, User, Smile, Meh, Frown, Tag, ThumbsUp } from 'lucide-react';
|
||||||
|
import { Comment, ReplyTone, ReplyPersona } from '../../types';
|
||||||
|
|
||||||
|
interface ReplyGeneratorProps {
|
||||||
|
comment: Comment | null;
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReplyGenerator: React.FC<ReplyGeneratorProps> = ({ comment, onBack }) => {
|
||||||
|
const [selectedTone, setSelectedTone] = useState<string>('friendly');
|
||||||
|
const [selectedPersona, setSelectedPersona] = useState<string>('brand');
|
||||||
|
const [generatedReplies, setGeneratedReplies] = useState<string[]>([]);
|
||||||
|
const [isGenerating, setIsGenerating] = useState<boolean>(false);
|
||||||
|
const [copied, setCopied] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const tones: ReplyTone[] = [
|
||||||
|
{ id: 'friendly', name: '友善', description: '溫暖親切的語氣' },
|
||||||
|
{ id: 'professional', name: '專業', description: '正式且專業的語氣' },
|
||||||
|
{ id: 'casual', name: '輕鬆', description: '隨意輕鬆的對話風格' },
|
||||||
|
{ id: 'enthusiastic', name: '熱情', description: '充滿活力與熱情' },
|
||||||
|
{ id: 'empathetic', name: '同理心', description: '表達理解與關懷' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const personas: ReplyPersona[] = [
|
||||||
|
{ id: 'brand', name: '品牌代表', description: '以品牌官方身份回覆' },
|
||||||
|
{ id: 'support', name: '客服人員', description: '以客服專員身份回覆' },
|
||||||
|
{ id: 'expert', name: '領域專家', description: '以專業人士身份回覆' },
|
||||||
|
{ id: 'friend', name: '朋友', description: '以朋友身份回覆' }
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// In development mode, we don't have access to chrome.storage
|
||||||
|
// So we'll use a mock implementation
|
||||||
|
const loadDefaultSettings = () => {
|
||||||
|
try {
|
||||||
|
// Check if we're in a Chrome extension environment
|
||||||
|
if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.sync) {
|
||||||
|
chrome.storage.sync.get(['defaultTone', 'defaultPersona'], (result) => {
|
||||||
|
if (result.defaultTone) setSelectedTone(result.defaultTone);
|
||||||
|
if (result.defaultPersona) setSelectedPersona(result.defaultPersona);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Mock storage for development environment
|
||||||
|
console.log('Using mock storage for development');
|
||||||
|
// Use default values or load from localStorage if needed
|
||||||
|
const savedTone = localStorage.getItem('defaultTone');
|
||||||
|
const savedPersona = localStorage.getItem('defaultPersona');
|
||||||
|
|
||||||
|
if (savedTone) setSelectedTone(savedTone);
|
||||||
|
if (savedPersona) setSelectedPersona(savedPersona);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading settings:', error);
|
||||||
|
// Continue with default values
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadDefaultSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const generateReplies = () => {
|
||||||
|
if (!comment) return;
|
||||||
|
|
||||||
|
setIsGenerating(true);
|
||||||
|
|
||||||
|
// Simulate API call or processing delay
|
||||||
|
setTimeout(() => {
|
||||||
|
// Generate replies based on comment sentiment, category, and selected tone/persona
|
||||||
|
let mockReplies: string[] = [];
|
||||||
|
|
||||||
|
// Base reply templates for different sentiments
|
||||||
|
if (comment.sentiment === 'positive') {
|
||||||
|
mockReplies = [
|
||||||
|
`感謝您的正面評價!我們很高興您喜歡我們的產品/服務。您的支持是我們前進的動力。`,
|
||||||
|
`非常感謝您的讚美!我們團隊一直致力於提供最好的體驗,很開心能得到您的認可。`,
|
||||||
|
`謝謝您的美好評價!您的滿意是我們最大的成就,我們會繼續努力維持這樣的水準。`
|
||||||
|
];
|
||||||
|
} else if (comment.sentiment === 'negative') {
|
||||||
|
mockReplies = [
|
||||||
|
`非常抱歉給您帶來不便。我們非常重視您的反饋,並會立即處理您提到的問題。請問可以提供更多細節,以便我們更好地解決?`,
|
||||||
|
`感謝您的坦誠反饋。我們對您的體驗感到遺憾,並承諾會改進。我們的團隊已經注意到這個問題,正在積極解決中。`,
|
||||||
|
`您的意見對我們非常寶貴。對於您遇到的困難,我們深表歉意。請放心,我們會認真對待每一條反饋,並努力改進我們的產品和服務。`
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
mockReplies = [
|
||||||
|
`感謝您的留言!我們很樂意回答您的問題。請問還有什麼我們可以幫助您的嗎?`,
|
||||||
|
`謝謝您的關注!您提出的問題很有價值,我們會盡快為您提供所需的信息。`,
|
||||||
|
`感謝您的互動!我們非常重視您的每一個問題,並致力於提供最準確的回答。`
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Customize based on category if available
|
||||||
|
if (comment.category) {
|
||||||
|
// Add category-specific content to the replies
|
||||||
|
mockReplies = mockReplies.map(reply => {
|
||||||
|
switch (comment.category) {
|
||||||
|
case '產品讚美':
|
||||||
|
return reply + ` 我們不斷努力改進產品,您的鼓勵給了我們很大的動力。`;
|
||||||
|
case '產品詢問':
|
||||||
|
return reply + ` 關於產品的具體信息,我們建議您查看官網的產品說明頁面,或直接聯繫我們的客服團隊。`;
|
||||||
|
case '產品問題':
|
||||||
|
return reply + ` 我們的售後團隊將會與您聯繫,協助解決產品問題。您也可以撥打客服熱線獲取即時幫助。`;
|
||||||
|
case '物流問題':
|
||||||
|
return reply + ` 我們會立即與物流部門核實您的訂單狀態,並盡快給您回覆。`;
|
||||||
|
case '價格問題':
|
||||||
|
return reply + ` 關於價格的疑問,我們的銷售團隊將為您提供最詳細的解答和最優惠的方案。`;
|
||||||
|
default:
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust tone based on selection
|
||||||
|
mockReplies = mockReplies.map(reply => {
|
||||||
|
switch (selectedTone) {
|
||||||
|
case 'professional':
|
||||||
|
return reply.replace(/感謝|謝謝/g, '非常感謝').replace(/!/g, '。');
|
||||||
|
case 'casual':
|
||||||
|
return reply.replace(/我們/g, '我們團隊').replace(/。/g, '~');
|
||||||
|
case 'enthusiastic':
|
||||||
|
return reply.replace(/!/g, '!!').replace(/謝謝/g, '非常感謝');
|
||||||
|
case 'empathetic':
|
||||||
|
return reply.replace(/感謝/g, '真誠感謝').replace(/我們理解/g, '我們完全理解');
|
||||||
|
default:
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Adjust persona based on selection
|
||||||
|
mockReplies = mockReplies.map(reply => {
|
||||||
|
switch (selectedPersona) {
|
||||||
|
case 'support':
|
||||||
|
return `作為客服代表,${reply}`;
|
||||||
|
case 'expert':
|
||||||
|
return `以專業角度來看,${reply}`;
|
||||||
|
case 'friend':
|
||||||
|
return reply.replace(/我們/g, '我們').replace(/非常感謝/g, '超級感謝');
|
||||||
|
default:
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setGeneratedReplies(mockReplies);
|
||||||
|
setIsGenerating(false);
|
||||||
|
}, 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = (text: string) => {
|
||||||
|
try {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Failed to copy text: ', err);
|
||||||
|
// Fallback method
|
||||||
|
const textArea = document.createElement('textarea');
|
||||||
|
textArea.value = text;
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Copy to clipboard failed:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!comment) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-64 text-center">
|
||||||
|
<Send size={48} className="text-gray-400 mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-700">請先選擇一條留言</h3>
|
||||||
|
<p className="text-gray-500 mt-2">
|
||||||
|
從留言列表中選擇一條留言來生成回覆建議。
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
返回留言列表
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center mb-4">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="mr-2 p-1 rounded-full hover:bg-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={18} />
|
||||||
|
</button>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800">生成回覆建議</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Original Comment */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-4 mb-4">
|
||||||
|
<div className="flex items-start mb-2">
|
||||||
|
<div className="bg-gray-200 rounded-full w-8 h-8 flex items-center justify-center mr-2">
|
||||||
|
<User size={16} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900">{comment.author}</div>
|
||||||
|
<div className="text-xs text-gray-500">{comment.platform} · {comment.timestamp}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
{comment.sentiment && (
|
||||||
|
<div className={`flex items-center text-xs px-1.5 py-0.5 rounded-full ${getSentimentBadgeColor(comment.sentiment)}`}>
|
||||||
|
{comment.sentiment === 'positive' && <Smile size={10} className="mr-1" />}
|
||||||
|
{comment.sentiment === 'neutral' && <Meh size={10} className="mr-1" />}
|
||||||
|
{comment.sentiment === 'negative' && <Frown size={10} className="mr-1" />}
|
||||||
|
{getSentimentLabel(comment.sentiment)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-700 mb-2">{comment.content}</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-1 mb-2">
|
||||||
|
{comment.keywords?.map(keyword => (
|
||||||
|
<span key={keyword} className="bg-blue-50 text-blue-700 text-xs px-1.5 py-0.5 rounded">
|
||||||
|
{keyword}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center text-xs text-gray-500">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<ThumbsUp size={12} className="mr-1" />
|
||||||
|
{comment.likes} 讚
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{comment.category && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-purple-50 text-purple-700">
|
||||||
|
{comment.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tone Selection */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">選擇回覆語氣</label>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{tones.map(tone => (
|
||||||
|
<button
|
||||||
|
key={tone.id}
|
||||||
|
onClick={() => setSelectedTone(tone.id)}
|
||||||
|
className={`p-2 text-xs rounded-md text-center transition-colors ${
|
||||||
|
selectedTone === tone.id
|
||||||
|
? 'bg-blue-100 text-blue-700 border border-blue-300'
|
||||||
|
: 'bg-white text-gray-700 border border-gray-200 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
title={tone.description}
|
||||||
|
>
|
||||||
|
{tone.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Persona Selection */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">選擇回覆身份</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{personas.map(persona => (
|
||||||
|
<button
|
||||||
|
key={persona.id}
|
||||||
|
onClick={() => setSelectedPersona(persona.id)}
|
||||||
|
className={`p-2 text-xs rounded-md text-center transition-colors ${
|
||||||
|
selectedPersona === persona.id
|
||||||
|
? 'bg-blue-100 text-blue-700 border border-blue-300'
|
||||||
|
: 'bg-white text-gray-700 border border-gray-200 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
title={persona.description}
|
||||||
|
>
|
||||||
|
{persona.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Generate Button */}
|
||||||
|
<button
|
||||||
|
onClick={generateReplies}
|
||||||
|
disabled={isGenerating}
|
||||||
|
className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:bg-blue-300 flex items-center justify-center mb-4"
|
||||||
|
>
|
||||||
|
{isGenerating ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||||
|
生成中...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Zap size={16} className="mr-2" />
|
||||||
|
生成回覆建議
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Generated Replies */}
|
||||||
|
{generatedReplies.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-700 mb-2">回覆建議</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{generatedReplies.map((reply, index) => (
|
||||||
|
<div key={index} className="bg-white rounded-lg shadow p-4 relative">
|
||||||
|
<p className="text-gray-700 pr-8">{reply}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(reply)}
|
||||||
|
className="absolute top-3 right-3 p-1 rounded-full hover:bg-gray-100 transition-colors"
|
||||||
|
title="複製到剪貼板"
|
||||||
|
>
|
||||||
|
{copied ? <Check size={16} className="text-green-600" /> : <Copy size={16} className="text-gray-500" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to get sentiment badge colors
|
||||||
|
function getSentimentBadgeColor(sentiment: string): string {
|
||||||
|
switch (sentiment) {
|
||||||
|
case 'positive':
|
||||||
|
return 'bg-green-100 text-green-800';
|
||||||
|
case 'neutral':
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
|
case 'negative':
|
||||||
|
return 'bg-red-100 text-red-800';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get sentiment labels
|
||||||
|
function getSentimentLabel(sentiment: string): string {
|
||||||
|
switch (sentiment) {
|
||||||
|
case 'positive':
|
||||||
|
return '正面';
|
||||||
|
case 'neutral':
|
||||||
|
return '中性';
|
||||||
|
case 'negative':
|
||||||
|
return '負面';
|
||||||
|
default:
|
||||||
|
return '未知';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReplyGenerator;
|
||||||
239
extension/src/sidebar/components/Settings.tsx
Normal file
239
extension/src/sidebar/components/Settings.tsx
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Save, Settings as SettingsIcon } from 'lucide-react';
|
||||||
|
import { ReplyTone, ReplyPersona, SettingsData } from '../../types';
|
||||||
|
|
||||||
|
const Settings: React.FC = () => {
|
||||||
|
const [settings, setSettings] = useState<SettingsData>({
|
||||||
|
defaultTone: 'friendly',
|
||||||
|
defaultPersona: 'brand',
|
||||||
|
autoDetectPlatform: true,
|
||||||
|
language: 'zh-TW',
|
||||||
|
maxComments: 50
|
||||||
|
});
|
||||||
|
|
||||||
|
const [isSaving, setIsSaving] = useState<boolean>(false);
|
||||||
|
const [saveSuccess, setSaveSuccess] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const tones: ReplyTone[] = [
|
||||||
|
{ id: 'friendly', name: '友善', description: '溫暖親切的語氣' },
|
||||||
|
{ id: 'professional', name: '專業', description: '正式且專業的語氣' },
|
||||||
|
{ id: 'casual', name: '輕鬆', description: '隨意輕鬆的對話風格' },
|
||||||
|
{ id: 'enthusiastic', name: '熱情', description: '充滿活力與熱情' },
|
||||||
|
{ id: 'empathetic', name: '同理心', description: '表達理解與關懷' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const personas: ReplyPersona[] = [
|
||||||
|
{ id: 'brand', name: '品牌代表', description: '以品牌官方身份回覆' },
|
||||||
|
{ id: 'support', name: '客服人員', description: '以客服專員身份回覆' },
|
||||||
|
{ id: 'expert', name: '領域專家', description: '以專業人士身份回覆' },
|
||||||
|
{ id: 'friend', name: '朋友', description: '以朋友身份回覆' }
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Load settings - with fallback for development environment
|
||||||
|
const loadSettings = () => {
|
||||||
|
try {
|
||||||
|
if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.sync) {
|
||||||
|
// We're in a Chrome extension environment
|
||||||
|
chrome.storage.sync.get(['defaultTone', 'defaultPersona', 'autoDetectPlatform', 'language', 'maxComments'], (result) => {
|
||||||
|
setSettings(prev => ({
|
||||||
|
...prev,
|
||||||
|
...result
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// We're in development mode - use localStorage
|
||||||
|
console.log('Using localStorage for settings in development mode');
|
||||||
|
const savedSettings = localStorage.getItem('commentAssistantSettings');
|
||||||
|
if (savedSettings) {
|
||||||
|
try {
|
||||||
|
const parsedSettings = JSON.parse(savedSettings);
|
||||||
|
setSettings(prev => ({
|
||||||
|
...prev,
|
||||||
|
...parsedSettings
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing saved settings:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading settings:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLSelectElement | HTMLInputElement>) => {
|
||||||
|
const { name, value, type } = e.target;
|
||||||
|
|
||||||
|
if (type === 'checkbox') {
|
||||||
|
const checked = (e.target as HTMLInputElement).checked;
|
||||||
|
setSettings(prev => ({
|
||||||
|
...prev,
|
||||||
|
[name]: checked
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
setSettings(prev => ({
|
||||||
|
...prev,
|
||||||
|
[name]: value
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveSettings = () => {
|
||||||
|
setIsSaving(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof chrome !== 'undefined' && chrome.storage && chrome.storage.sync) {
|
||||||
|
// We're in a Chrome extension environment
|
||||||
|
chrome.storage.sync.set(settings, () => {
|
||||||
|
setIsSaving(false);
|
||||||
|
setSaveSuccess(true);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setSaveSuccess(false);
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// We're in development mode - use localStorage
|
||||||
|
localStorage.setItem('commentAssistantSettings', JSON.stringify(settings));
|
||||||
|
|
||||||
|
// Simulate async operation
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsSaving(false);
|
||||||
|
setSaveSuccess(true);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setSaveSuccess(false);
|
||||||
|
}, 2000);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving settings:', error);
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center mb-4">
|
||||||
|
<SettingsIcon size={20} className="mr-2 text-gray-700" />
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800">設置</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow p-4 mb-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-700 mb-3">回覆設置</h3>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm text-gray-700 mb-1">預設回覆語氣</label>
|
||||||
|
<select
|
||||||
|
name="defaultTone"
|
||||||
|
value={settings.defaultTone}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-md text-sm"
|
||||||
|
>
|
||||||
|
{tones.map(tone => (
|
||||||
|
<option key={tone.id} value={tone.id}>
|
||||||
|
{tone.name} - {tone.description}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm text-gray-700 mb-1">預設回覆身份</label>
|
||||||
|
<select
|
||||||
|
name="defaultPersona"
|
||||||
|
value={settings.defaultPersona}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-md text-sm"
|
||||||
|
>
|
||||||
|
{personas.map(persona => (
|
||||||
|
<option key={persona.id} value={persona.id}>
|
||||||
|
{persona.name} - {persona.description}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow p-4 mb-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-700 mb-3">一般設置</h3>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="autoDetectPlatform"
|
||||||
|
checked={settings.autoDetectPlatform}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="h-4 w-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="ml-2 text-sm text-gray-700">自動檢測社交平台</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm text-gray-700 mb-1">語言</label>
|
||||||
|
<select
|
||||||
|
name="language"
|
||||||
|
value={settings.language}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full p-2 border border-gray-300 rounded-md text-sm"
|
||||||
|
>
|
||||||
|
<option value="zh-TW">繁體中文</option>
|
||||||
|
<option value="en-US">English</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm text-gray-700 mb-1">
|
||||||
|
最大捕獲留言數量 ({settings.maxComments})
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
name="maxComments"
|
||||||
|
min="10"
|
||||||
|
max="100"
|
||||||
|
step="10"
|
||||||
|
value={settings.maxComments}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs text-gray-500">
|
||||||
|
<span>10</span>
|
||||||
|
<span>50</span>
|
||||||
|
<span>100</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={saveSettings}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="w-full py-2 px-4 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:bg-blue-300 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||||
|
儲存中...
|
||||||
|
</>
|
||||||
|
) : saveSuccess ? (
|
||||||
|
<>
|
||||||
|
<Save size={16} className="mr-2" />
|
||||||
|
已儲存!
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save size={16} className="mr-2" />
|
||||||
|
儲存設置
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Settings;
|
||||||
55
extension/src/sidebar/main.tsx
Normal file
55
extension/src/sidebar/main.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import Sidebar from './Sidebar';
|
||||||
|
import '../index.css';
|
||||||
|
import ErrorBoundary from '../ErrorBoundary';
|
||||||
|
|
||||||
|
// Error boundary for the sidebar
|
||||||
|
const renderSidebar = () => {
|
||||||
|
try {
|
||||||
|
const rootElement = document.getElementById('root');
|
||||||
|
if (!rootElement) {
|
||||||
|
console.error('Root element not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(rootElement).render(
|
||||||
|
<StrictMode>
|
||||||
|
<ErrorBoundary>
|
||||||
|
<Sidebar />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error rendering sidebar:', error);
|
||||||
|
|
||||||
|
// Render a fallback UI in case of error
|
||||||
|
const rootElement = document.getElementById('root');
|
||||||
|
if (rootElement) {
|
||||||
|
rootElement.innerHTML = `
|
||||||
|
<div style="padding: 20px; color: #721c24; background-color: #f8d7da; border: 1px solid #f5c6cb; border-radius: 5px;">
|
||||||
|
<h2>Sidebar Error</h2>
|
||||||
|
<p>Sorry, something went wrong while loading the sidebar.</p>
|
||||||
|
<p>Error details: ${error instanceof Error ? error.message : String(error)}</p>
|
||||||
|
<button onclick="window.location.reload()" style="background: #dc3545; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer;">
|
||||||
|
Reload Sidebar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Disable Vite's error overlay to prevent WebSocket connection attempts
|
||||||
|
window.addEventListener('error', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
console.error('Caught error:', event.error);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Disable Vite's HMR client
|
||||||
|
if (import.meta.hot) {
|
||||||
|
import.meta.hot.decline();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSidebar();
|
||||||
49
extension/src/types.ts
Normal file
49
extension/src/types.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
export interface Comment {
|
||||||
|
id: string;
|
||||||
|
author: string;
|
||||||
|
content: string;
|
||||||
|
timestamp: string;
|
||||||
|
likes: number;
|
||||||
|
replies?: Comment[];
|
||||||
|
platform: 'facebook' | 'instagram' | 'twitter' | 'youtube' | 'linkedin' | 'other';
|
||||||
|
sentiment?: 'positive' | 'neutral' | 'negative';
|
||||||
|
keywords?: string[];
|
||||||
|
category?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplyTone {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplyPersona {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsData {
|
||||||
|
defaultTone: string;
|
||||||
|
defaultPersona: string;
|
||||||
|
autoDetectPlatform: boolean;
|
||||||
|
language: 'zh-TW' | 'en-US';
|
||||||
|
maxComments: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommentFilter {
|
||||||
|
searchTerm: string;
|
||||||
|
platform: string;
|
||||||
|
sortBy: 'newest' | 'oldest' | 'likes' | 'replies';
|
||||||
|
sentiment?: 'positive' | 'neutral' | 'negative' | 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommentAnalytics {
|
||||||
|
sentimentCounts: {
|
||||||
|
positive: number;
|
||||||
|
neutral: number;
|
||||||
|
negative: number;
|
||||||
|
};
|
||||||
|
topKeywords: Array<{keyword: string, count: number}>;
|
||||||
|
categories: Record<string, number>;
|
||||||
|
}
|
||||||
1
extension/src/vite-env.d.ts
vendored
Normal file
1
extension/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
8
extension/tailwind.config.js
Normal file
8
extension/tailwind.config.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
24
extension/tsconfig.app.json
Normal file
24
extension/tsconfig.app.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
extension/tsconfig.json
Normal file
7
extension/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
22
extension/tsconfig.node.json
Normal file
22
extension/tsconfig.node.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
25
extension/vite.config.ts
Normal file
25
extension/vite.config.ts
Normal 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
3
web/.bolt/config.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"template": "bolt-vite-react-ts"
|
||||||
|
}
|
||||||
8
web/.bolt/prompt
Normal file
8
web/.bolt/prompt
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
For all designs I ask you to make, have them be beautiful, not cookie cutter. Make webpages that are fully featured and worthy for production.
|
||||||
|
|
||||||
|
By default, this template supports JSX syntax with Tailwind CSS classes, React hooks, and Lucide React for icons. Do not install other packages for UI themes, icons, etc unless absolutely necessary or I request them.
|
||||||
|
|
||||||
|
Use icons from lucide-react for logos.
|
||||||
|
|
||||||
|
Use stock photos from unsplash where appropriate, only valid URLs you know exist. Do not download the images, only link to them in image tags.
|
||||||
|
|
||||||
24
web/.gitignore
vendored
Normal file
24
web/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
28
web/eslint.config.js
Normal file
28
web/eslint.config.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import globals from 'globals';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['dist'] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
13
web/index.html
Normal file
13
web/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-TW">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>社群留言管理系統</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4061
web/package-lock.json
generated
Normal file
4061
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
web/package.json
Normal file
39
web/package.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "social-media-comment-management",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "^5.6.1",
|
||||||
|
"antd": "^5.24.3",
|
||||||
|
"axios": "^1.8.1",
|
||||||
|
"date-fns": "^3.6.0",
|
||||||
|
"lucide-react": "^0.344.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^7.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.9.1",
|
||||||
|
"@types/react": "^18.3.5",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"autoprefixer": "^10.4.18",
|
||||||
|
"eslint": "^9.9.1",
|
||||||
|
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.11",
|
||||||
|
"globals": "^15.9.0",
|
||||||
|
"postcss": "^8.4.35",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "^5.5.3",
|
||||||
|
"typescript-eslint": "^8.3.0",
|
||||||
|
"vite": "^5.4.2"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@9.15.4+sha512.b2dc20e2fc72b3e18848459b37359a32064663e5627a51e4c74b2c29dd8e8e0491483c3abb40789cfd578bf362fb6ba8261b05f0387d76792ed6e23ea3b1b6a0"
|
||||||
|
}
|
||||||
3709
web/pnpm-lock.yaml
generated
Normal file
3709
web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
web/postcss.config.js
Normal file
6
web/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
114
web/src/App.tsx
Normal file
114
web/src/App.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { BrowserRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||||
|
import { AuthProvider, useAuth, User } from './context/AuthContext';
|
||||||
|
import Login from './components/Login';
|
||||||
|
import Header from './components/Header';
|
||||||
|
import Sidebar from './components/Sidebar';
|
||||||
|
import CommentList from './components/CommentList';
|
||||||
|
import PostList from './components/PostList';
|
||||||
|
import Dashboard from './components/Dashboard';
|
||||||
|
import Analytics from './components/Analytics';
|
||||||
|
import ProtectedRoute from './components/ProtectedRoute';
|
||||||
|
|
||||||
|
const AppContent = () => {
|
||||||
|
const { isAuthenticated, login, loading } = useAuth();
|
||||||
|
const [sidebarOpen, setSidebarOpen] = React.useState<boolean>(false);
|
||||||
|
const [activePage, setActivePage] = React.useState<string>('dashboard');
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
// 添加更多调试信息
|
||||||
|
React.useEffect(() => {
|
||||||
|
console.log('AppContent - Auth state updated:', { isAuthenticated, loading, path: location.pathname });
|
||||||
|
}, [isAuthenticated, loading, location.pathname]);
|
||||||
|
|
||||||
|
// Update active page based on URL
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (location.pathname === '/') {
|
||||||
|
setActivePage('dashboard');
|
||||||
|
} else if (location.pathname === '/comments') {
|
||||||
|
setActivePage('comments');
|
||||||
|
} else if (location.pathname === '/posts') {
|
||||||
|
setActivePage('posts');
|
||||||
|
} else if (location.pathname === '/analytics') {
|
||||||
|
setActivePage('analytics');
|
||||||
|
}
|
||||||
|
}, [location]);
|
||||||
|
|
||||||
|
// Show loading spinner while checking authentication status
|
||||||
|
if (loading) {
|
||||||
|
console.log('AppContent - Still loading auth state...');
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex justify-center items-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle successful login
|
||||||
|
const handleLoginSuccess = (token: string, user: User) => {
|
||||||
|
console.log('AppContent - Login success, calling login function with:', { user });
|
||||||
|
login(token, user);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render main app layout when authenticated
|
||||||
|
const renderAppLayout = () => {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-gray-100 overflow-hidden">
|
||||||
|
<Sidebar
|
||||||
|
activePage={activePage}
|
||||||
|
onPageChange={(page) => {
|
||||||
|
setActivePage(page);
|
||||||
|
setSidebarOpen(false);
|
||||||
|
}}
|
||||||
|
isOpen={sidebarOpen}
|
||||||
|
onClose={() => setSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col flex-1 overflow-hidden">
|
||||||
|
<Header
|
||||||
|
onMenuClick={() => setSidebarOpen(!sidebarOpen)}
|
||||||
|
/>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/comments" element={<CommentList />} />
|
||||||
|
<Route path="/posts" element={<PostList />} />
|
||||||
|
<Route path="/analytics" element={<Analytics />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/login"
|
||||||
|
element={
|
||||||
|
isAuthenticated ?
|
||||||
|
<Navigate to="/" replace /> :
|
||||||
|
<Login onLoginSuccess={handleLoginSuccess} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/*"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
{renderAppLayout()}
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<Router>
|
||||||
|
<AuthProvider>
|
||||||
|
<AppContent />
|
||||||
|
</AuthProvider>
|
||||||
|
</Router>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
815
web/src/components/Analytics.tsx
Normal file
815
web/src/components/Analytics.tsx
Normal file
@@ -0,0 +1,815 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
BarChart2,
|
||||||
|
TrendingUp,
|
||||||
|
PieChart,
|
||||||
|
MessageSquare,
|
||||||
|
Facebook,
|
||||||
|
Twitter,
|
||||||
|
Instagram,
|
||||||
|
Linkedin,
|
||||||
|
BookOpen,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
Clock,
|
||||||
|
ThumbsUp,
|
||||||
|
ThumbsDown,
|
||||||
|
Youtube,
|
||||||
|
Hash,
|
||||||
|
Users,
|
||||||
|
Heart,
|
||||||
|
Share2,
|
||||||
|
Eye,
|
||||||
|
ArrowRight,
|
||||||
|
ChevronDown,
|
||||||
|
Filter,
|
||||||
|
Download,
|
||||||
|
AlertTriangle
|
||||||
|
} from 'lucide-react';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
// Define interfaces for analytics data
|
||||||
|
interface AnalyticsData {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TimelineData {
|
||||||
|
date: string;
|
||||||
|
comments: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SentimentData {
|
||||||
|
positive: number;
|
||||||
|
neutral: number;
|
||||||
|
negative: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Article {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
views: number;
|
||||||
|
engagement: number;
|
||||||
|
platform: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KOLData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
platform: string;
|
||||||
|
followers: number;
|
||||||
|
engagement: number;
|
||||||
|
posts: number;
|
||||||
|
sentiment: SentimentData;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FunnelData {
|
||||||
|
stage: string;
|
||||||
|
count: number;
|
||||||
|
rate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Analytics: React.FC = () => {
|
||||||
|
const [timeRange, setTimeRange] = useState('7days');
|
||||||
|
const [selectedKOL, setSelectedKOL] = useState('all');
|
||||||
|
const [selectedPlatform, setSelectedPlatform] = useState('all');
|
||||||
|
const [platformData, setPlatformData] = useState<AnalyticsData[]>([]);
|
||||||
|
const [timelineData, setTimelineData] = useState<TimelineData[]>([]);
|
||||||
|
const [sentimentData, setSentimentData] = useState<SentimentData>({
|
||||||
|
positive: 0,
|
||||||
|
neutral: 0,
|
||||||
|
negative: 0
|
||||||
|
});
|
||||||
|
const [statusData, setStatusData] = useState<AnalyticsData[]>([]);
|
||||||
|
const [popularArticles, setPopularArticles] = useState<Article[]>([]);
|
||||||
|
const [kolData, setKolData] = useState<KOLData[]>([]);
|
||||||
|
const [funnelData, setFunnelData] = useState<FunnelData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchAnalyticsData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
// Fetch platform distribution
|
||||||
|
const platformResponse = await axios.get(`http://localhost:4000/api/analytics/platforms?timeRange=${timeRange}`);
|
||||||
|
setPlatformData(platformResponse.data || []);
|
||||||
|
|
||||||
|
// Fetch timeline data
|
||||||
|
const timelineResponse = await axios.get(`http://localhost:4000/api/analytics/timeline?timeRange=${timeRange}`);
|
||||||
|
setTimelineData(timelineResponse.data || []);
|
||||||
|
|
||||||
|
// Fetch sentiment data
|
||||||
|
const sentimentResponse = await axios.get(`http://localhost:4000/api/analytics/sentiment?timeRange=${timeRange}`);
|
||||||
|
setSentimentData(sentimentResponse.data || { positive: 0, neutral: 0, negative: 0 });
|
||||||
|
|
||||||
|
// Fetch status data
|
||||||
|
const statusResponse = await axios.get(`http://localhost:4000/api/analytics/status?timeRange=${timeRange}`);
|
||||||
|
setStatusData(statusResponse.data || []);
|
||||||
|
|
||||||
|
// Fetch popular articles
|
||||||
|
const articlesResponse = await axios.get(`http://localhost:4000/api/analytics/popular-content?timeRange=${timeRange}`);
|
||||||
|
setPopularArticles(articlesResponse.data || []);
|
||||||
|
|
||||||
|
// Fetch KOL data
|
||||||
|
const kolResponse = await axios.get(`http://localhost:4000/api/analytics/influencers?timeRange=${timeRange}`);
|
||||||
|
setKolData(kolResponse.data || []);
|
||||||
|
|
||||||
|
// Fetch funnel data
|
||||||
|
const funnelResponse = await axios.get(`http://localhost:4000/api/analytics/conversion?timeRange=${timeRange}`);
|
||||||
|
setFunnelData(funnelResponse.data || []);
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch analytics data:', err);
|
||||||
|
setError('Failed to load analytics data. Please try again later.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchAnalyticsData();
|
||||||
|
}, [timeRange]);
|
||||||
|
|
||||||
|
// 根據選擇的KOL和平台過濾數據
|
||||||
|
const filteredKOLData = selectedKOL === 'all'
|
||||||
|
? kolData
|
||||||
|
: kolData.filter(kol => kol.id === selectedKOL);
|
||||||
|
|
||||||
|
const filteredEngagementData = selectedKOL === 'all'
|
||||||
|
? kolData
|
||||||
|
: kolData.filter(item => item.id === selectedKOL);
|
||||||
|
|
||||||
|
const getPlatformIcon = (platform: string) => {
|
||||||
|
switch (platform) {
|
||||||
|
case 'facebook':
|
||||||
|
return <Facebook className="h-5 w-5 text-blue-600" />;
|
||||||
|
case 'threads':
|
||||||
|
return <Hash className="h-5 w-5 text-black" />;
|
||||||
|
case 'instagram':
|
||||||
|
return <Instagram className="h-5 w-5 text-pink-500" />;
|
||||||
|
case 'linkedin':
|
||||||
|
return <Linkedin className="h-5 w-5 text-blue-700" />;
|
||||||
|
case 'xiaohongshu':
|
||||||
|
return <BookOpen className="h-5 w-5 text-red-500" />;
|
||||||
|
case 'youtube':
|
||||||
|
return <Youtube className="h-5 w-5 text-red-600" />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'approved':
|
||||||
|
return <CheckCircle className="h-5 w-5 text-green-600" />;
|
||||||
|
case 'rejected':
|
||||||
|
return <XCircle className="h-5 w-5 text-red-600" />;
|
||||||
|
case 'pending':
|
||||||
|
return <Clock className="h-5 w-5 text-yellow-600" />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusName = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'approved':
|
||||||
|
return '已核准';
|
||||||
|
case 'rejected':
|
||||||
|
return '已拒絕';
|
||||||
|
case 'pending':
|
||||||
|
return '待審核';
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPlatformColor = (platform: string) => {
|
||||||
|
switch (platform) {
|
||||||
|
case 'facebook':
|
||||||
|
return 'bg-blue-600';
|
||||||
|
case 'threads':
|
||||||
|
return 'bg-black';
|
||||||
|
case 'instagram':
|
||||||
|
return 'bg-pink-500';
|
||||||
|
case 'linkedin':
|
||||||
|
return 'bg-blue-700';
|
||||||
|
case 'xiaohongshu':
|
||||||
|
return 'bg-red-500';
|
||||||
|
case 'youtube':
|
||||||
|
return 'bg-red-600';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-600';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'approved':
|
||||||
|
return 'bg-green-600';
|
||||||
|
case 'rejected':
|
||||||
|
return 'bg-red-600';
|
||||||
|
case 'pending':
|
||||||
|
return 'bg-yellow-600';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-600';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSentimentColor = (sentiment: string) => {
|
||||||
|
switch (sentiment) {
|
||||||
|
case 'positive':
|
||||||
|
return 'bg-green-500';
|
||||||
|
case 'negative':
|
||||||
|
return 'bg-red-500';
|
||||||
|
case 'neutral':
|
||||||
|
return 'bg-gray-500';
|
||||||
|
case 'mixed':
|
||||||
|
return 'bg-yellow-500';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-500';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const maxTimelineCount = Math.max(...timelineData.map(item => item.comments));
|
||||||
|
|
||||||
|
// 計算KOL表現排名
|
||||||
|
const sortedKOLs = [...filteredKOLData].sort((a, b) => b.engagement - a.engagement);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-center mb-6 space-y-4 lg:space-y-0">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-800">數據分析</h2>
|
||||||
|
<div className="flex flex-col sm:flex-row space-y-3 sm:space-y-0 sm:space-x-4">
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<select
|
||||||
|
className="border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={timeRange}
|
||||||
|
onChange={(e) => setTimeRange(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="7days">過去 7 天</option>
|
||||||
|
<option value="30days">過去 30 天</option>
|
||||||
|
<option value="90days">過去 90 天</option>
|
||||||
|
<option value="1year">過去 1 年</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
className="border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={selectedKOL}
|
||||||
|
onChange={(e) => setSelectedKOL(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">全部 KOL</option>
|
||||||
|
{kolData.map(kol => (
|
||||||
|
<option key={kol.id} value={kol.id}>{kol.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
className="border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={selectedPlatform}
|
||||||
|
onChange={(e) => setSelectedPlatform(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">全部平台</option>
|
||||||
|
<option value="facebook">Facebook</option>
|
||||||
|
<option value="instagram">Instagram</option>
|
||||||
|
<option value="threads">Threads</option>
|
||||||
|
<option value="youtube">YouTube</option>
|
||||||
|
<option value="xiaohongshu">小紅書</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 flex items-center">
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
匯出報表
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KOL 表現概覽 */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-6 mb-8">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h3 className="text-lg font-medium text-gray-800">KOL 表現概覽</h3>
|
||||||
|
<div className="flex items-center text-sm text-blue-600">
|
||||||
|
<span className="mr-1">查看詳細報告</span>
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
KOL
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
平台
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
貼文數
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
總讚數
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
總留言數
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
互動率
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
情緒指標
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
官方互動
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{sortedKOLs.map((kol, index) => (
|
||||||
|
<tr key={kol.id} className={index === 0 ? "bg-blue-50" : "hover:bg-gray-50"}>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="h-10 w-10 rounded-full overflow-hidden mr-3">
|
||||||
|
<img src={kol.avatar} alt={kol.name} className="h-full w-full object-cover" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-900">{kol.name}</div>
|
||||||
|
<div className="text-xs text-gray-500">{kol.followers} 粉絲</div>
|
||||||
|
</div>
|
||||||
|
{index === 0 && (
|
||||||
|
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
Top KOL
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex space-x-1">
|
||||||
|
{kol.platforms.map(platform => (
|
||||||
|
<div key={platform} className="flex items-center">
|
||||||
|
{getPlatformIcon(platform)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{kol.postCount}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex items-center text-sm text-gray-900">
|
||||||
|
<Heart className="h-4 w-4 text-red-500 mr-1" />
|
||||||
|
{kol.likeCount.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex items-center text-sm text-gray-900">
|
||||||
|
<MessageSquare className="h-4 w-4 text-blue-500 mr-1" />
|
||||||
|
{kol.commentCount.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="text-sm text-gray-900 font-medium">{(kol.engagementRate * 100).toFixed(1)}%</div>
|
||||||
|
<div className={`ml-2 ${kol.engagementTrend > 0 ? 'text-green-500' : 'text-red-500'} flex items-center text-xs`}>
|
||||||
|
{kol.engagementTrend > 0 ? (
|
||||||
|
<>
|
||||||
|
<TrendingUp className="h-3 w-3 mr-1" />
|
||||||
|
+{kol.engagementTrend}%
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TrendingUp className="h-3 w-3 mr-1 transform rotate-180" />
|
||||||
|
{kol.engagementTrend}%
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div
|
||||||
|
className="h-2 w-24 bg-gray-200 rounded-full overflow-hidden"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full bg-green-500"
|
||||||
|
style={{ width: `${kol.sentimentScore}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span className="ml-2 text-sm text-gray-900">{kol.sentimentScore}%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{kol.officialInteractions}次
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 轉換漏斗 */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-6 mb-8">
|
||||||
|
<h3 className="text-lg font-medium text-gray-800 mb-6">KOL 合作轉換漏斗</h3>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<div className="w-full max-w-3xl">
|
||||||
|
{funnelData.map((stage, index) => (
|
||||||
|
<div key={index} className="relative mb-4">
|
||||||
|
<div
|
||||||
|
className="bg-blue-500 h-16 rounded-lg flex items-center justify-center text-white font-medium"
|
||||||
|
style={{
|
||||||
|
width: `${(stage.count / funnelData[0].count) * 100}%`,
|
||||||
|
opacity: 0.7 + (0.3 * (index / funnelData.length))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{stage.name}: {stage.count.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
{index < funnelData.length - 1 && (
|
||||||
|
<div className="flex justify-center my-1">
|
||||||
|
<div className="flex items-center text-gray-500 text-sm">
|
||||||
|
<ArrowRight className="h-4 w-4 mr-1" />
|
||||||
|
轉換率: {((funnelData[index + 1].count / stage.count) * 100).toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 mb-2">平均轉換率</h4>
|
||||||
|
<p className="text-2xl font-bold text-blue-600">
|
||||||
|
{((funnelData[funnelData.length - 1].count / funnelData[0].count) * 100).toFixed(1)}%
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">從曝光到轉換的整體效率</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 mb-2">最高轉換階段</h4>
|
||||||
|
<p className="text-2xl font-bold text-green-600">互動 → 點擊</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">此階段轉換率高於平均值 15%</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 mb-2">最低轉換階段</h4>
|
||||||
|
<p className="text-2xl font-bold text-red-600">點擊 → 購買</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">此階段需要優化,低於平均值 23%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KOL 貼文表現 */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-6 mb-8">
|
||||||
|
<h3 className="text-lg font-medium text-gray-800 mb-6">KOL 貼文表現</h3>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
貼文
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
KOL
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
平台
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
發布日期
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
觀看數
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
讚數
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
留言數
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
分享數
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
情緒指標
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{filteredEngagementData.map((post, index) => (
|
||||||
|
<tr key={post.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="h-12 w-12 rounded overflow-hidden mr-3 flex-shrink-0">
|
||||||
|
<img src={post.thumbnail} alt={post.title} className="h-full w-full object-cover" />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-900 max-w-xs truncate">{post.title}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="h-8 w-8 rounded-full overflow-hidden mr-2">
|
||||||
|
<img
|
||||||
|
src={kolData.find(k => k.id === post.kolId)?.avatar || ''}
|
||||||
|
alt={kolData.find(k => k.id === post.kolId)?.name || ''}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-900">
|
||||||
|
{kolData.find(k => k.id === post.kolId)?.name || ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex items-center">
|
||||||
|
{getPlatformIcon(post.platform)}
|
||||||
|
<span className="ml-2 text-sm text-gray-900">
|
||||||
|
{post.platform === 'xiaohongshu' ? '小紅書' : post.platform}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{post.date}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex items-center text-sm text-gray-900">
|
||||||
|
<Eye className="h-4 w-4 text-gray-500 mr-1" />
|
||||||
|
{post.views.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex items-center text-sm text-gray-900">
|
||||||
|
<Heart className="h-4 w-4 text-red-500 mr-1" />
|
||||||
|
{post.likes.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex items-center text-sm text-gray-900">
|
||||||
|
<MessageSquare className="h-4 w-4 text-blue-500 mr-1" />
|
||||||
|
{post.comments.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex items-center text-sm text-gray-900">
|
||||||
|
<Share2 className="h-4 w-4 text-green-500 mr-1" />
|
||||||
|
{post.shares.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div
|
||||||
|
className="h-2 w-16 bg-gray-200 rounded-full overflow-hidden"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={getSentimentColor(post.sentiment)}
|
||||||
|
style={{ width: `${post.sentimentScore}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span className="ml-2 text-sm text-gray-900">{post.sentimentScore}%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 概覽卡片 */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-medium text-gray-800">留言總數</h3>
|
||||||
|
<MessageSquare className="h-6 w-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<p className="text-3xl font-bold text-gray-900 mb-2">{platformData.reduce((sum, item) => sum + item.value, 0)}</p>
|
||||||
|
<div className="flex items-center text-sm">
|
||||||
|
<TrendingUp className="h-4 w-4 text-green-500 mr-1" />
|
||||||
|
<span className="text-green-500">↑ 12% 較上週</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-medium text-gray-800">平均互動率</h3>
|
||||||
|
<Users className="h-6 w-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<p className="text-3xl font-bold text-gray-900 mb-2">4.8%</p>
|
||||||
|
<div className="flex items-center text-sm">
|
||||||
|
<TrendingUp className="h-4 w-4 text-green-500 mr-1" />
|
||||||
|
<span className="text-green-500">↑ 0.5% 較上週</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-medium text-gray-800">情感分析</h3>
|
||||||
|
<PieChart className="h-6 w-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<p className="text-3xl font-bold text-gray-900 mb-2">{sentimentData.positive}% 正面</p>
|
||||||
|
<div className="flex items-center text-sm">
|
||||||
|
<TrendingUp className="h-4 w-4 text-green-500 mr-1" />
|
||||||
|
<span className="text-green-500">↑ 5% 較上週</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 留言趨勢圖 */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-6 mb-8">
|
||||||
|
<h3 className="text-lg font-medium text-gray-800 mb-4">留言趨勢</h3>
|
||||||
|
<div className="h-64">
|
||||||
|
<div className="flex items-end h-52 space-x-2">
|
||||||
|
{timelineData.map((item, index) => (
|
||||||
|
<div key={index} className="flex-1 flex flex-col justify-end items-center">
|
||||||
|
<div
|
||||||
|
className="w-full bg-blue-500 rounded-t-md transition-all duration-500 ease-in-out hover:bg-blue-600"
|
||||||
|
style={{
|
||||||
|
height: `${(item.comments / maxTimelineCount) * 100}%`,
|
||||||
|
minHeight: '10%'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="invisible group-hover:visible text-xs text-white text-center py-1">
|
||||||
|
{item.comments}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-center mt-2">{item.date}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||||
|
{/* 平台分佈 */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<h3 className="text-lg font-medium text-gray-800 mb-4">平台分佈</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{platformData.map((item, index) => (
|
||||||
|
<div key={index}>
|
||||||
|
<div className="flex justify-between items-center mb-1">
|
||||||
|
<div className="flex items-center">
|
||||||
|
{getPlatformIcon(item.name)}
|
||||||
|
<span className="ml-2 text-sm font-medium text-gray-700">
|
||||||
|
{item.name === 'xiaohongshu' ? '小紅書' : item.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="text-sm text-gray-500 mr-2">{item.value} 則留言</span>
|
||||||
|
<span className="text-sm font-medium text-gray-700">{item.percentage}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className={`${getPlatformColor(item.name)} h-2 rounded-full transition-all duration-500 ease-in-out`}
|
||||||
|
style={{ width: `${item.percentage}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 審核狀態分佈 */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<h3 className="text-lg font-medium text-gray-800 mb-4">審核狀態分佈</h3>
|
||||||
|
<div className="flex justify-center mb-6">
|
||||||
|
<div className="w-48 h-48 rounded-full relative">
|
||||||
|
{statusData.map((item, index) => {
|
||||||
|
// 計算每個扇形的起始角度和結束角度
|
||||||
|
const startAngle = index === 0 ? 0 : statusData.slice(0, index).reduce((sum, i) => sum + i.percentage, 0) * 3.6;
|
||||||
|
const endAngle = startAngle + item.percentage * 3.6;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
background: `conic-gradient(transparent ${startAngle}deg, ${getStatusColor(item.name)} ${startAngle}deg, ${getStatusColor(item.name)} ${endAngle}deg, transparent ${endAngle}deg)`,
|
||||||
|
borderRadius: '50%'
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="w-32 h-32 bg-white rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{statusData.map((item, index) => (
|
||||||
|
<div key={index} className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center">
|
||||||
|
{getStatusIcon(item.name)}
|
||||||
|
<span className="ml-2 text-sm font-medium text-gray-700">{getStatusName(item.name)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="text-sm text-gray-500 mr-2">{item.value} 則留言</span>
|
||||||
|
<span className="text-sm font-medium text-gray-700">{item.percentage}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||||
|
{/* 情感分析詳情 */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<h3 className="text-lg font-medium text-gray-800 mb-4">情感分析詳情</h3>
|
||||||
|
<div className="flex justify-center mb-6">
|
||||||
|
<div className="relative w-48 h-12 bg-gradient-to-r from-red-500 via-yellow-400 to-green-500 rounded-lg">
|
||||||
|
<div
|
||||||
|
className="absolute top-0 h-full w-1 bg-black border-2 border-white rounded-full transform -translate-x-1/2"
|
||||||
|
style={{ left: `${sentimentData.positive}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-4 text-center">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-500">負面</p>
|
||||||
|
<p className="text-lg font-bold text-red-500">{sentimentData.negative}%</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-500">中性</p>
|
||||||
|
<p className="text-lg font-bold text-yellow-500">{sentimentData.neutral}%</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-500">正面</p>
|
||||||
|
<p className="text-lg font-bold text-green-500">{sentimentData.positive}%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 熱門文章 */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<h3 className="text-lg font-medium text-gray-800 mb-4">熱門文章</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{popularArticles.map((article: any, index: number) => (
|
||||||
|
<div key={index} className="border-b border-gray-200 pb-3 last:border-0 last:pb-0">
|
||||||
|
<p className="text-sm font-medium text-gray-800 mb-1">{article.title}</p>
|
||||||
|
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||||
|
<span>{article.count} 則留言</span>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="h-2 w-2 rounded-full bg-green-500 mr-1"></div>
|
||||||
|
<span>高互動</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 關鍵字雲 */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-6 mb-8">
|
||||||
|
<h3 className="text-lg font-medium text-gray-800 mb-4">熱門關鍵字</h3>
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 py-4">
|
||||||
|
<span className="px-4 py-2 bg-blue-100 text-blue-800 rounded-full text-lg">產品</span>
|
||||||
|
<span className="px-6 py-3 bg-green-100 text-green-800 rounded-full text-xl">推薦</span>
|
||||||
|
<span className="px-3 py-1 bg-yellow-100 text-yellow-800 rounded-full text-base">價格</span>
|
||||||
|
<span className="px-5 py-2 bg-purple-100 text-purple-800 rounded-full text-lg">質感</span>
|
||||||
|
<span className="px-7 py-3 bg-red-100 text-red-800 rounded-full text-2xl">效果</span>
|
||||||
|
<span className="px-3 py-1 bg-indigo-100 text-indigo-800 rounded-full text-base">服務</span>
|
||||||
|
<span className="px-4 py-2 bg-pink-100 text-pink-800 rounded-full text-lg">美觀</span>
|
||||||
|
<span className="px-5 py-2 bg-blue-100 text-blue-800 rounded-full text-lg">環境</span>
|
||||||
|
<span className="px-3 py-1 bg-green-100 text-green-800 rounded-full text-base">便宜</span>
|
||||||
|
<span className="px-6 py-3 bg-yellow-100 text-yellow-800 rounded-full text-xl">好用</span>
|
||||||
|
<span className="px-4 py-2 bg-purple-100 text-purple-800 rounded-full text-lg">設計</span>
|
||||||
|
<span className="px-3 py-1 bg-red-100 text-red-800 rounded-full text-base">功能</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 用戶互動時間分析 */}
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<h3 className="text-lg font-medium text-gray-800 mb-4">用戶互動時間分析</h3>
|
||||||
|
<div className="grid grid-cols-12 gap-1 h-40">
|
||||||
|
{Array.from({ length: 24 }).map((_, hour) => {
|
||||||
|
// 模擬不同時段的活躍度
|
||||||
|
let height = '20%';
|
||||||
|
if (hour >= 9 && hour <= 11) height = '60%';
|
||||||
|
if (hour >= 12 && hour <= 14) height = '40%';
|
||||||
|
if (hour >= 19 && hour <= 22) height = '80%';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={hour} className="flex flex-col items-center justify-end">
|
||||||
|
<div
|
||||||
|
className="w-full bg-blue-500 rounded-t-sm hover:bg-blue-600 transition-all"
|
||||||
|
style={{ height }}
|
||||||
|
></div>
|
||||||
|
<span className="text-xs mt-1">{hour}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="text-center mt-2 text-sm text-gray-500">
|
||||||
|
<p>時間 (24小時制)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Analytics;
|
||||||
520
web/src/components/CommentList.tsx
Normal file
520
web/src/components/CommentList.tsx
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Facebook,
|
||||||
|
MessageSquare,
|
||||||
|
Instagram,
|
||||||
|
Linkedin,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
MoreHorizontal,
|
||||||
|
ExternalLink,
|
||||||
|
BookOpen,
|
||||||
|
ThumbsUp,
|
||||||
|
ThumbsDown,
|
||||||
|
Minus,
|
||||||
|
AlertTriangle,
|
||||||
|
User,
|
||||||
|
Award,
|
||||||
|
Briefcase,
|
||||||
|
Youtube,
|
||||||
|
Hash,
|
||||||
|
Filter,
|
||||||
|
ChevronDown,
|
||||||
|
ArrowLeft
|
||||||
|
} from 'lucide-react';
|
||||||
|
import CommentPreview from './CommentPreview';
|
||||||
|
import { commentsApi, postsApi } from '../utils/api';
|
||||||
|
|
||||||
|
// 定义后端返回的评论类型
|
||||||
|
interface ApiComment {
|
||||||
|
comment_id: string;
|
||||||
|
content: string;
|
||||||
|
sentiment_score: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
post_id: string;
|
||||||
|
user_id: string;
|
||||||
|
user_profile?: {
|
||||||
|
id: string;
|
||||||
|
full_name: string;
|
||||||
|
avatar_url: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定义前端使用的评论类型
|
||||||
|
interface FrontendComment {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
author: string;
|
||||||
|
authorType: 'user' | 'kol' | 'official';
|
||||||
|
platform: 'facebook' | 'threads' | 'instagram' | 'linkedin' | 'xiaohongshu' | 'youtube';
|
||||||
|
contentType?: 'post' | 'reel' | 'video' | 'short';
|
||||||
|
timestamp: string;
|
||||||
|
sentiment: string;
|
||||||
|
status: string;
|
||||||
|
replyStatus?: string;
|
||||||
|
language?: string;
|
||||||
|
articleTitle?: string;
|
||||||
|
postAuthor?: string;
|
||||||
|
postAuthorType?: string;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommentListProps {
|
||||||
|
postId?: string; // 可选的帖子 ID,如果提供则只获取该帖子的评论
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PostData {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
platform: string;
|
||||||
|
post_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CommentList: React.FC<CommentListProps> = () => {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const postId = searchParams.get('post_id');
|
||||||
|
|
||||||
|
const [comments, setComments] = useState<FrontendComment[]>([]);
|
||||||
|
const [post, setPost] = useState<PostData | null>(null); // Store post data
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
const [selectedComment, setSelectedComment] = useState<FrontendComment | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 过滤和分页状态
|
||||||
|
const [platformFilter, setPlatformFilter] = useState<string>('all');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||||
|
const [sentimentFilter, setSentimentFilter] = useState<string>('all');
|
||||||
|
const [replyStatusFilter, setReplyStatusFilter] = useState<string>('all');
|
||||||
|
const [languageFilter, setLanguageFilter] = useState<string>('all');
|
||||||
|
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||||
|
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||||
|
const [pageSize, setPageSize] = useState<number>(10);
|
||||||
|
const [totalComments, setTotalComments] = useState<number>(0);
|
||||||
|
const [showFilters, setShowFilters] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// Fetch post data if postId is provided
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchPostData = async () => {
|
||||||
|
if (postId) {
|
||||||
|
try {
|
||||||
|
const response = await postsApi.getPost(postId);
|
||||||
|
setPost(response.data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch post data:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchPostData();
|
||||||
|
}, [postId]);
|
||||||
|
|
||||||
|
// 获取评论数据
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchComments = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// Build query parameters
|
||||||
|
const params: Record<string, string | number> = {};
|
||||||
|
|
||||||
|
if (postId) {
|
||||||
|
params.post_id = postId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platformFilter !== 'all') {
|
||||||
|
params.platform = platformFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusFilter !== 'all') {
|
||||||
|
params.status = statusFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sentimentFilter !== 'all') {
|
||||||
|
params.sentiment = sentimentFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchQuery) {
|
||||||
|
params.query = searchQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (languageFilter !== 'all') {
|
||||||
|
params.language = languageFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add pagination
|
||||||
|
params.limit = pageSize;
|
||||||
|
params.offset = (currentPage - 1) * pageSize;
|
||||||
|
|
||||||
|
const response = await commentsApi.getComments(params);
|
||||||
|
|
||||||
|
// 处理返回的数据
|
||||||
|
const apiComments: ApiComment[] = response.data.comments || [];
|
||||||
|
const total = response.data.total || apiComments.length;
|
||||||
|
|
||||||
|
// 转换为前端格式
|
||||||
|
const frontendComments: FrontendComment[] = apiComments.map(comment => {
|
||||||
|
// 确定情感
|
||||||
|
let sentiment = 'neutral';
|
||||||
|
if (comment.sentiment_score > 0.3) {
|
||||||
|
sentiment = 'positive';
|
||||||
|
} else if (comment.sentiment_score < -0.3) {
|
||||||
|
sentiment = 'negative';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测语言
|
||||||
|
const language = detectLanguage(comment.content);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: comment.comment_id,
|
||||||
|
content: comment.content,
|
||||||
|
author: comment.user_profile?.full_name || '匿名用户',
|
||||||
|
authorType: 'user', // 默认为普通用户
|
||||||
|
platform: 'facebook', // 假设默认是 Facebook
|
||||||
|
timestamp: comment.created_at,
|
||||||
|
sentiment,
|
||||||
|
status: 'approved', // 假设默认已审核
|
||||||
|
language,
|
||||||
|
// 其他可选字段可以根据 API 返回的数据动态添加
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setComments(frontendComments);
|
||||||
|
setTotalComments(total);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch comments:', err);
|
||||||
|
setError('加载评论失败,请稍后再试');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchComments();
|
||||||
|
}, [postId, platformFilter, statusFilter, sentimentFilter, searchQuery, languageFilter, currentPage, pageSize]);
|
||||||
|
|
||||||
|
// 简单的语言检测
|
||||||
|
const detectLanguage = (text: string): 'zh-TW' | 'zh-CN' | 'en' => {
|
||||||
|
const traditionalChineseRegex = /[一-龥]/;
|
||||||
|
const simplifiedChineseRegex = /[一-龥]/;
|
||||||
|
const englishRegex = /[a-zA-Z]/;
|
||||||
|
|
||||||
|
if (englishRegex.test(text) && !traditionalChineseRegex.test(text) && !simplifiedChineseRegex.test(text)) {
|
||||||
|
return 'en';
|
||||||
|
} else if (traditionalChineseRegex.test(text)) {
|
||||||
|
// 这里简化了繁体/简体的判断,实际实现应该更复杂
|
||||||
|
return 'zh-TW';
|
||||||
|
} else {
|
||||||
|
return 'zh-CN';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to go back to posts list
|
||||||
|
const handleBackToPosts = () => {
|
||||||
|
navigate('/posts');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 显示加载状态
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col flex-1 overflow-hidden md:flex-row">
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<div className="p-4 sm:p-6">
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="w-12 h-12 border-t-2 border-b-2 border-blue-500 rounded-full animate-spin"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示错误信息
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col flex-1 overflow-hidden md:flex-row">
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<div className="p-4 sm:p-6">
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-center text-red-500">
|
||||||
|
<AlertTriangle className="w-12 h-12 mx-auto mb-4" />
|
||||||
|
<p>{error}</p>
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 mt-4 text-white bg-blue-500 rounded-md hover:bg-blue-600"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
<div className="flex flex-col flex-1 overflow-hidden">
|
||||||
|
<div className="bg-white p-4 border-b flex items-center justify-between">
|
||||||
|
<div className="flex items-center">
|
||||||
|
{postId && (
|
||||||
|
<button
|
||||||
|
onClick={handleBackToPosts}
|
||||||
|
className="mr-4 p-1 hover:bg-gray-100 rounded-full transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-5 w-5 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<h2 className="text-lg font-semibold">
|
||||||
|
{post ? `${post.title} 的评论` : '所有评论'}
|
||||||
|
</h2>
|
||||||
|
{post && (
|
||||||
|
<span className="ml-2 text-sm text-gray-500">
|
||||||
|
({totalComments} 条评论)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="relative mr-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索评论..."
|
||||||
|
className="px-4 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFilters(!showFilters)}
|
||||||
|
className={`flex items-center px-3 py-2 rounded-lg text-sm ${
|
||||||
|
showFilters ? 'bg-blue-100 text-blue-700' : 'hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Filter className="h-4 w-4 mr-1" />
|
||||||
|
筛选
|
||||||
|
<ChevronDown className="h-4 w-4 ml-1" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile filters panel */}
|
||||||
|
{showFilters && (
|
||||||
|
<div className="p-4 mb-4 space-y-3 bg-white rounded-lg shadow-md sm:hidden">
|
||||||
|
<div>
|
||||||
|
<label className="block mb-1 text-sm font-medium text-gray-700">狀態</label>
|
||||||
|
<select
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">全部狀態</option>
|
||||||
|
<option value="pending">待審核</option>
|
||||||
|
<option value="approved">已核准</option>
|
||||||
|
<option value="rejected">已拒絕</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block mb-1 text-sm font-medium text-gray-700">平台</label>
|
||||||
|
<select
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={platformFilter}
|
||||||
|
onChange={(e) => setPlatformFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">全部平台</option>
|
||||||
|
<option value="facebook">Facebook</option>
|
||||||
|
<option value="threads">Threads</option>
|
||||||
|
<option value="instagram">Instagram</option>
|
||||||
|
<option value="linkedin">LinkedIn</option>
|
||||||
|
<option value="xiaohongshu">小红书</option>
|
||||||
|
<option value="youtube">YouTube</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block mb-1 text-sm font-medium text-gray-700">回覆狀態</label>
|
||||||
|
<select
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={replyStatusFilter}
|
||||||
|
onChange={(e) => setReplyStatusFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">全部回覆狀態</option>
|
||||||
|
<option value="sent">已回覆</option>
|
||||||
|
<option value="draft">草稿</option>
|
||||||
|
<option value="none">未回覆</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block mb-1 text-sm font-medium text-gray-700">語言</label>
|
||||||
|
<select
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={languageFilter}
|
||||||
|
onChange={(e) => setLanguageFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">全部語言</option>
|
||||||
|
<option value="zh-TW">繁體中文</option>
|
||||||
|
<option value="zh-CN">简体中文</option>
|
||||||
|
<option value="en">English</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile comment list */}
|
||||||
|
<div className="block md:hidden">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{comments.map((comment) => (
|
||||||
|
<div
|
||||||
|
key={comment.id}
|
||||||
|
className="overflow-hidden bg-white rounded-lg shadow cursor-pointer"
|
||||||
|
onClick={() => setSelectedComment(comment)}
|
||||||
|
>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Facebook className="w-5 h-5 text-blue-600" />
|
||||||
|
<span className="ml-2 text-sm font-medium">Facebook</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mb-2 text-sm text-gray-900">{comment.content}</p>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="mr-2 text-xs font-medium text-gray-700">{comment.author}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-500">{comment.timestamp}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop table */}
|
||||||
|
<div className="hidden overflow-hidden bg-white rounded-lg shadow md:block">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase">平台</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase">留言內容</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase">留言者</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase">時間</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase">語言</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase">情感</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase">回覆狀態</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-xs font-medium tracking-wider text-left text-gray-500 uppercase">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{comments.map((comment) => (
|
||||||
|
<tr
|
||||||
|
key={comment.id}
|
||||||
|
className="cursor-pointer hover:bg-gray-50"
|
||||||
|
onClick={() => setSelectedComment(comment)}
|
||||||
|
>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Facebook className="w-5 h-5 text-blue-600" />
|
||||||
|
<span className="ml-2 text-sm text-gray-900">
|
||||||
|
Facebook
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="max-w-md text-sm text-gray-900 truncate">
|
||||||
|
{comment.content}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="mr-2 text-sm text-gray-900">{comment.author}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm text-gray-500">{comment.timestamp}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
{comment.language === 'zh-TW' && (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
繁中
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{comment.language === 'zh-CN' && (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||||
|
简中
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{comment.language === 'en' && (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||||
|
EN
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
{comment.sentiment === 'positive' && (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||||
|
<ThumbsUp className="w-3 h-3 mr-1" />
|
||||||
|
正面
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{comment.sentiment === 'negative' && (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||||
|
<ThumbsDown className="w-3 h-3 mr-1" />
|
||||||
|
負面
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{comment.sentiment === 'neutral' && (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||||
|
<Minus className="w-3 h-3 mr-1" />
|
||||||
|
中性
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
{comment.replyStatus === 'sent' && (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||||
|
<CheckCircle className="w-3 h-3 mr-1" />
|
||||||
|
已回覆
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{comment.replyStatus === 'draft' && (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||||
|
<MessageSquare className="w-3 h-3 mr-1" />
|
||||||
|
草稿
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{comment.replyStatus === 'none' && (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||||
|
<XCircle className="w-3 h-3 mr-1" />
|
||||||
|
未回覆
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm font-medium text-right whitespace-nowrap">
|
||||||
|
<button className="mr-3 text-blue-600 hover:text-blue-900">
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedComment && (
|
||||||
|
<div className="overflow-auto bg-white border-t border-gray-200 md:w-96 md:border-t-0 md:border-l">
|
||||||
|
<CommentPreview comment={selectedComment} onClose={() => setSelectedComment(null)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CommentList;
|
||||||
637
web/src/components/CommentPreview.tsx
Normal file
637
web/src/components/CommentPreview.tsx
Normal file
@@ -0,0 +1,637 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Comment } from '../types';
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
MessageSquare,
|
||||||
|
ExternalLink,
|
||||||
|
ThumbsUp,
|
||||||
|
ThumbsDown,
|
||||||
|
Minus,
|
||||||
|
AlertTriangle,
|
||||||
|
User,
|
||||||
|
Award,
|
||||||
|
Briefcase,
|
||||||
|
Send,
|
||||||
|
Edit,
|
||||||
|
RefreshCw,
|
||||||
|
Facebook,
|
||||||
|
Instagram,
|
||||||
|
Linkedin,
|
||||||
|
BookOpen,
|
||||||
|
Youtube,
|
||||||
|
Hash,
|
||||||
|
List,
|
||||||
|
Copy,
|
||||||
|
Save,
|
||||||
|
Lock
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { templatesApi } from '../utils/api';
|
||||||
|
|
||||||
|
interface ReplyTemplate {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommentPreviewProps {
|
||||||
|
comment: Comment;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CommentPreview: React.FC<CommentPreviewProps> = ({ comment, onClose }) => {
|
||||||
|
const [replyText, setReplyText] = useState(comment.aiReply || '');
|
||||||
|
const [privateMessageText, setPrivateMessageText] = useState('');
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [isGeneratingReply, setIsGeneratingReply] = useState(false);
|
||||||
|
const [showTemplates, setShowTemplates] = useState(false);
|
||||||
|
const [activeMode, setActiveMode] = useState<'reply' | 'private'>('reply');
|
||||||
|
const [templates, setTemplates] = useState<ReplyTemplate[]>([]);
|
||||||
|
const [loadingTemplates, setLoadingTemplates] = useState(false);
|
||||||
|
|
||||||
|
// Fetch templates from API
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchTemplates = async () => {
|
||||||
|
if (showTemplates) {
|
||||||
|
try {
|
||||||
|
setLoadingTemplates(true);
|
||||||
|
const response = await templatesApi.getTemplates();
|
||||||
|
setTemplates(response.data.templates || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch reply templates:', err);
|
||||||
|
} finally {
|
||||||
|
setLoadingTemplates(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchTemplates();
|
||||||
|
}, [showTemplates]);
|
||||||
|
|
||||||
|
const getSentimentIcon = (sentiment: string) => {
|
||||||
|
switch (sentiment) {
|
||||||
|
case 'positive':
|
||||||
|
return <ThumbsUp className="h-5 w-5 text-green-600" />;
|
||||||
|
case 'negative':
|
||||||
|
return <ThumbsDown className="h-5 w-5 text-red-600" />;
|
||||||
|
case 'neutral':
|
||||||
|
return <Minus className="h-5 w-5 text-gray-600" />;
|
||||||
|
case 'mixed':
|
||||||
|
return <AlertTriangle className="h-5 w-5 text-yellow-600" />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSentimentText = (sentiment: string) => {
|
||||||
|
switch (sentiment) {
|
||||||
|
case 'positive':
|
||||||
|
return '正面';
|
||||||
|
case 'negative':
|
||||||
|
return '負面';
|
||||||
|
case 'neutral':
|
||||||
|
return '中性';
|
||||||
|
case 'mixed':
|
||||||
|
return '混合';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAuthorTypeIcon = (authorType: string) => {
|
||||||
|
switch (authorType) {
|
||||||
|
case 'official':
|
||||||
|
return <Briefcase className="h-5 w-5 text-blue-600" />;
|
||||||
|
case 'kol':
|
||||||
|
return <Award className="h-5 w-5 text-purple-600" />;
|
||||||
|
case 'user':
|
||||||
|
return <User className="h-5 w-5 text-gray-600" />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAuthorTypeText = (authorType: string) => {
|
||||||
|
switch (authorType) {
|
||||||
|
case 'official':
|
||||||
|
return '官方';
|
||||||
|
case 'kol':
|
||||||
|
return 'KOL';
|
||||||
|
case 'user':
|
||||||
|
return '一般用戶';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPlatformIcon = (platform: string) => {
|
||||||
|
switch (platform) {
|
||||||
|
case 'facebook':
|
||||||
|
return <Facebook className="h-5 w-5 text-blue-600" />;
|
||||||
|
case 'threads':
|
||||||
|
return <Hash className="h-5 w-5 text-black" />;
|
||||||
|
case 'instagram':
|
||||||
|
return <Instagram className="h-5 w-5 text-pink-500" />;
|
||||||
|
case 'linkedin':
|
||||||
|
return <Linkedin className="h-5 w-5 text-blue-700" />;
|
||||||
|
case 'xiaohongshu':
|
||||||
|
return <BookOpen className="h-5 w-5 text-red-500" />;
|
||||||
|
case 'youtube':
|
||||||
|
return <Youtube className="h-5 w-5 text-red-600" />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPlatformName = (platform: string) => {
|
||||||
|
switch (platform) {
|
||||||
|
case 'facebook':
|
||||||
|
return 'Facebook';
|
||||||
|
case 'threads':
|
||||||
|
return 'Threads';
|
||||||
|
case 'instagram':
|
||||||
|
return 'Instagram';
|
||||||
|
case 'linkedin':
|
||||||
|
return 'LinkedIn';
|
||||||
|
case 'xiaohongshu':
|
||||||
|
return '小红书';
|
||||||
|
case 'youtube':
|
||||||
|
return 'YouTube';
|
||||||
|
default:
|
||||||
|
return platform;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getContentTypeText = (contentType?: string) => {
|
||||||
|
if (!contentType) return '';
|
||||||
|
|
||||||
|
switch (contentType) {
|
||||||
|
case 'reel':
|
||||||
|
return 'Reel';
|
||||||
|
case 'post':
|
||||||
|
return 'Post';
|
||||||
|
case 'video':
|
||||||
|
return 'Video';
|
||||||
|
case 'short':
|
||||||
|
return 'Short';
|
||||||
|
default:
|
||||||
|
return contentType;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLanguageText = (language?: string) => {
|
||||||
|
if (!language) return '';
|
||||||
|
|
||||||
|
switch (language) {
|
||||||
|
case 'zh-TW':
|
||||||
|
return '繁體中文';
|
||||||
|
case 'zh-CN':
|
||||||
|
return '简体中文';
|
||||||
|
case 'en':
|
||||||
|
return 'English';
|
||||||
|
default:
|
||||||
|
return language;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerateReply = () => {
|
||||||
|
setIsGeneratingReply(true);
|
||||||
|
// 模擬 AI 生成回覆的過程
|
||||||
|
setTimeout(() => {
|
||||||
|
const greeting = comment.language === 'zh-CN' ?
|
||||||
|
`${comment.author}您好,感谢您的留言!我们非常重视您的反馈。` :
|
||||||
|
comment.language === 'en' ?
|
||||||
|
`Hello ${comment.author}, thank you for your comment! We greatly value your feedback.` :
|
||||||
|
`${comment.author}您好,感謝您的留言!我們非常重視您的反饋。`;
|
||||||
|
|
||||||
|
let sentiment = '';
|
||||||
|
if (comment.sentiment === 'positive') {
|
||||||
|
sentiment = comment.language === 'zh-CN' ?
|
||||||
|
'很高兴您对我们的产品有正面评价。' :
|
||||||
|
comment.language === 'en' ?
|
||||||
|
'We\'re pleased to hear your positive feedback about our product.' :
|
||||||
|
'很高興您對我們的產品有正面評價。';
|
||||||
|
} else if (comment.sentiment === 'negative') {
|
||||||
|
sentiment = comment.language === 'zh-CN' ?
|
||||||
|
'对于您提出的问题,我们深表歉意并会积极改进。' :
|
||||||
|
comment.language === 'en' ?
|
||||||
|
'We sincerely apologize for the issues you\'ve raised and will actively work to improve.' :
|
||||||
|
'對於您提出的問題,我們深表歉意並會積極改進。';
|
||||||
|
} else {
|
||||||
|
sentiment = comment.language === 'zh-CN' ?
|
||||||
|
'我们会认真考虑您的建议。' :
|
||||||
|
comment.language === 'en' ?
|
||||||
|
'We will carefully consider your suggestions.' :
|
||||||
|
'我們會認真考慮您的建議。';
|
||||||
|
}
|
||||||
|
|
||||||
|
const closing = comment.language === 'zh-CN' ?
|
||||||
|
'我们的团队将进一步跟进这个问题,如有任何疑问,欢迎随时联系我们。' :
|
||||||
|
comment.language === 'en' ?
|
||||||
|
'Our team will follow up on this matter further. If you have any questions, please feel free to contact us anytime.' :
|
||||||
|
'我們的團隊將進一步跟進這個問題,如有任何疑問,歡迎隨時聯繫我們。';
|
||||||
|
|
||||||
|
setReplyText(`${greeting} ${sentiment} ${closing}`);
|
||||||
|
setIsGeneratingReply(false);
|
||||||
|
}, 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGeneratePrivateMessage = () => {
|
||||||
|
setIsGeneratingReply(true);
|
||||||
|
// 模擬 AI 生成私訊的過程
|
||||||
|
setTimeout(() => {
|
||||||
|
const greeting = comment.language === 'zh-CN' ?
|
||||||
|
`${comment.author}您好,我是客服团队的代表。` :
|
||||||
|
comment.language === 'en' ?
|
||||||
|
`Hello ${comment.author}, I'm a representative from our customer service team.` :
|
||||||
|
`${comment.author}您好,我是客服團隊的代表。`;
|
||||||
|
|
||||||
|
const content = comment.language === 'zh-CN' ?
|
||||||
|
'感谢您在我们的平台上留言。为了更好地解决您的问题,我想私下与您沟通一些细节。' :
|
||||||
|
comment.language === 'en' ?
|
||||||
|
'Thank you for your comment on our platform. To better address your concerns, I would like to discuss some details with you privately.' :
|
||||||
|
'感謝您在我們的平台上留言。為了更好地解決您的問題,我想私下與您溝通一些細節。';
|
||||||
|
|
||||||
|
const question = comment.language === 'zh-CN' ?
|
||||||
|
'方便提供您的联系方式吗?或者您可以直接联系我们的客服热线:0800-123-456。' :
|
||||||
|
comment.language === 'en' ?
|
||||||
|
'Would it be convenient for you to provide your contact information? Alternatively, you can reach our customer service hotline at 0800-123-456.' :
|
||||||
|
'方便提供您的聯繫方式嗎?或者您可以直接聯繫我們的客服熱線:0800-123-456。';
|
||||||
|
|
||||||
|
setPrivateMessageText(`${greeting} ${content} ${question}`);
|
||||||
|
setIsGeneratingReply(false);
|
||||||
|
}, 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendMessage = () => {
|
||||||
|
if (activeMode === 'reply') {
|
||||||
|
// 這裡會調用後端 API 來發送公開回覆
|
||||||
|
alert(`公開回覆已發送至 ${getPlatformName(comment.platform)} 平台:\n\n${replyText}`);
|
||||||
|
} else {
|
||||||
|
// 這裡會調用後端 API 來發送私訊
|
||||||
|
alert(`私訊已發送至 ${comment.author}:\n\n${privateMessageText}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTemplateSelect = (template: ReplyTemplate) => {
|
||||||
|
if (activeMode === 'reply') {
|
||||||
|
setReplyText(template.content);
|
||||||
|
} else {
|
||||||
|
setPrivateMessageText(template.content);
|
||||||
|
}
|
||||||
|
setShowTemplates(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveAsDraft = () => {
|
||||||
|
if (activeMode === 'reply') {
|
||||||
|
alert('公開回覆已儲存為草稿');
|
||||||
|
} else {
|
||||||
|
alert('私訊已儲存為草稿');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyToClipboard = () => {
|
||||||
|
const textToCopy = activeMode === 'reply' ? replyText : privateMessageText;
|
||||||
|
navigator.clipboard.writeText(textToCopy);
|
||||||
|
alert('內容已複製到剪貼簿');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex flex-col">
|
||||||
|
<div className="p-4 border-b border-gray-200 flex justify-between items-center">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">留言預覽</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-500"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto p-4">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h4 className="text-sm font-medium text-gray-500 mb-2">文章標題</h4>
|
||||||
|
<p className="text-base font-medium text-gray-900">{comment.articleTitle}</p>
|
||||||
|
<div className="mt-2 flex items-center">
|
||||||
|
<div className="flex items-center mr-4">
|
||||||
|
{getAuthorTypeIcon(comment.postAuthorType)}
|
||||||
|
<span className="ml-1 text-sm text-gray-700">{comment.postAuthor}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-500">{getAuthorTypeText(comment.postAuthorType)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<h4 className="text-sm font-medium text-gray-500">留言內容</h4>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{getPlatformIcon(comment.platform)}
|
||||||
|
<span className="text-xs text-gray-700">{getPlatformName(comment.platform)}</span>
|
||||||
|
{comment.contentType && (
|
||||||
|
<span className="text-xs text-gray-500">({getContentTypeText(comment.contentType)})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 p-3 rounded-lg">
|
||||||
|
<p className="text-base text-gray-900">{comment.content}</p>
|
||||||
|
<div className="mt-2 flex justify-between items-center">
|
||||||
|
<div className="flex items-center">
|
||||||
|
{getAuthorTypeIcon(comment.authorType)}
|
||||||
|
<span className="ml-1 text-sm text-gray-700">{comment.author}</span>
|
||||||
|
<span className="ml-2 text-xs text-gray-500">{getAuthorTypeText(comment.authorType)}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-500">{comment.timestamp}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex justify-between">
|
||||||
|
<div className="flex items-center">
|
||||||
|
{getSentimentIcon(comment.sentiment)}
|
||||||
|
<span className="ml-1 text-sm text-gray-700">{getSentimentText(comment.sentiment)}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-700">{getLanguageText(comment.language)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 回覆模式切換 */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
className={`flex-1 py-2 px-4 text-sm font-medium ${
|
||||||
|
activeMode === 'reply'
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-white text-gray-700 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
onClick={() => setActiveMode('reply')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<MessageSquare className="h-4 w-4 mr-2" />
|
||||||
|
公開回覆
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`flex-1 py-2 px-4 text-sm font-medium ${
|
||||||
|
activeMode === 'private'
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-white text-gray-700 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
onClick={() => setActiveMode('private')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<Lock className="h-4 w-4 mr-2" />
|
||||||
|
私訊
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 回覆內容區域 */}
|
||||||
|
{activeMode === 'reply' ? (
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<h4 className="text-sm font-medium text-gray-500">公開回覆內容</h4>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEditing(!isEditing)}
|
||||||
|
className="text-blue-600 hover:text-blue-800"
|
||||||
|
title="編輯回覆"
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleGenerateReply}
|
||||||
|
className="text-green-600 hover:text-green-800"
|
||||||
|
disabled={isGeneratingReply}
|
||||||
|
title="重新生成回覆"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${isGeneratingReply ? 'animate-spin' : ''}`} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowTemplates(!showTemplates)}
|
||||||
|
className="text-purple-600 hover:text-purple-800"
|
||||||
|
title="使用模板"
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Templates Popup for replies */}
|
||||||
|
{showTemplates && activeMode === 'reply' && (
|
||||||
|
<div className="absolute bottom-full mb-2 left-0 w-full z-10">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl border border-gray-200 p-3">
|
||||||
|
<h4 className="font-medium text-sm mb-2">選擇回覆模板</h4>
|
||||||
|
{loadingTemplates ? (
|
||||||
|
<div className="flex justify-center p-4">
|
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"></div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mb-3 border border-gray-200 rounded-lg bg-white shadow-lg">
|
||||||
|
<div className="max-h-40 overflow-y-auto">
|
||||||
|
{templates.map(template => (
|
||||||
|
<div
|
||||||
|
key={template.id}
|
||||||
|
className="p-2 hover:bg-gray-50 cursor-pointer border-b border-gray-100 last:border-0"
|
||||||
|
onClick={() => handleTemplateSelect(template)}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium">{template.title}</p>
|
||||||
|
<p className="text-xs text-gray-500 truncate">{template.content.substring(0, 60)}...</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isEditing ? (
|
||||||
|
<textarea
|
||||||
|
value={replyText}
|
||||||
|
onChange={(e) => setReplyText(e.target.value)}
|
||||||
|
className="w-full p-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 min-h-[150px]"
|
||||||
|
placeholder="編輯回覆內容..."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="bg-blue-50 p-3 rounded-lg border border-blue-100">
|
||||||
|
<p className="text-sm text-gray-800">{replyText || '尚未生成回覆'}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-2 flex justify-end space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={handleCopyToClipboard}
|
||||||
|
className="text-gray-600 hover:text-gray-800 p-1"
|
||||||
|
title="複製到剪貼簿"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveAsDraft}
|
||||||
|
className="text-gray-600 hover:text-gray-800 p-1"
|
||||||
|
title="儲存為草稿"
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<h4 className="text-sm font-medium text-gray-500">私訊內容</h4>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEditing(!isEditing)}
|
||||||
|
className="text-blue-600 hover:text-blue-800"
|
||||||
|
title="編輯私訊"
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleGeneratePrivateMessage}
|
||||||
|
className="text-green-600 hover:text-green-800"
|
||||||
|
disabled={isGeneratingReply}
|
||||||
|
title="重新生成私訊"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${isGeneratingReply ? 'animate-spin' : ''}`} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowTemplates(!showTemplates)}
|
||||||
|
className="text-purple-600 hover:text-purple-800"
|
||||||
|
title="使用模板"
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Templates Popup for private messages */}
|
||||||
|
{showTemplates && activeMode === 'private' && (
|
||||||
|
<div className="absolute bottom-full mb-2 left-0 w-full z-10">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl border border-gray-200 p-3">
|
||||||
|
<h4 className="font-medium text-sm mb-2">選擇私信模板</h4>
|
||||||
|
{loadingTemplates ? (
|
||||||
|
<div className="flex justify-center p-4">
|
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"></div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mb-3 border border-gray-200 rounded-lg bg-white shadow-lg">
|
||||||
|
<div className="max-h-40 overflow-y-auto">
|
||||||
|
{templates.map(template => (
|
||||||
|
<div
|
||||||
|
key={template.id}
|
||||||
|
className="p-2 hover:bg-gray-50 cursor-pointer border-b border-gray-100 last:border-0"
|
||||||
|
onClick={() => handleTemplateSelect(template)}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium">{template.title}</p>
|
||||||
|
<p className="text-xs text-gray-500 truncate">{template.content.substring(0, 60)}...</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isEditing ? (
|
||||||
|
<textarea
|
||||||
|
value={privateMessageText}
|
||||||
|
onChange={(e) => setPrivateMessageText(e.target.value)}
|
||||||
|
className="w-full p-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 min-h-[150px]"
|
||||||
|
placeholder="編輯私訊內容..."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="bg-purple-50 p-3 rounded-lg border border-purple-100">
|
||||||
|
<p className="text-sm text-gray-800">{privateMessageText || '尚未生成私訊'}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-2 flex justify-end space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={handleCopyToClipboard}
|
||||||
|
className="text-gray-600 hover:text-gray-800 p-1"
|
||||||
|
title="複製到剪貼簿"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveAsDraft}
|
||||||
|
className="text-gray-600 hover:text-gray-800 p-1"
|
||||||
|
title="儲存為草稿"
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<h4 className="text-sm font-medium text-gray-500 mb-2">留言預覽</h4>
|
||||||
|
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<iframe
|
||||||
|
src={comment.url}
|
||||||
|
className="w-full h-96"
|
||||||
|
frameBorder="0"
|
||||||
|
scrolling="no"
|
||||||
|
title="Social Media Post"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<h4 className="text-sm font-medium text-gray-500 mb-2">回覆狀態</h4>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{comment.replyStatus === 'sent' && (
|
||||||
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||||
|
<CheckCircle className="h-3 w-3 mr-1" />
|
||||||
|
已回覆
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{comment.replyStatus === 'draft' && (
|
||||||
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||||
|
<MessageSquare className="h-3 w-3 mr-1" />
|
||||||
|
草稿
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{comment.replyStatus === 'none' && (
|
||||||
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||||
|
<XCircle className="h-3 w-3 mr-1" />
|
||||||
|
未回覆
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-gray-200">
|
||||||
|
<div className="flex space-x-3">
|
||||||
|
<button
|
||||||
|
className={`flex-1 py-2 px-4 rounded-md flex items-center justify-center ${
|
||||||
|
activeMode === 'reply'
|
||||||
|
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||||
|
: 'bg-purple-600 text-white hover:bg-purple-700'
|
||||||
|
}`}
|
||||||
|
onClick={handleSendMessage}
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4 mr-2" />
|
||||||
|
{activeMode === 'reply' ? '發送公開回覆' : '發送私訊'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex-1 bg-gray-100 text-gray-700 py-2 px-4 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-500 flex items-center justify-center"
|
||||||
|
onClick={handleSaveAsDraft}
|
||||||
|
>
|
||||||
|
<Save className="h-4 w-4 mr-2" />
|
||||||
|
儲存草稿
|
||||||
|
</button>
|
||||||
|
<button className="bg-gray-100 text-gray-700 py-2 px-3 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-500">
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CommentPreview;
|
||||||
390
web/src/components/Dashboard.tsx
Normal file
390
web/src/components/Dashboard.tsx
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
MessageSquare,
|
||||||
|
Users,
|
||||||
|
TrendingUp,
|
||||||
|
Clock,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
AlertCircle,
|
||||||
|
Facebook,
|
||||||
|
Twitter,
|
||||||
|
Instagram,
|
||||||
|
Linkedin,
|
||||||
|
BookOpen,
|
||||||
|
Youtube,
|
||||||
|
Hash
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { commentsApi } from '../utils/api';
|
||||||
|
|
||||||
|
interface Comment {
|
||||||
|
id: string;
|
||||||
|
platform: string;
|
||||||
|
content: string;
|
||||||
|
author: string;
|
||||||
|
authorType: string;
|
||||||
|
timestamp: string;
|
||||||
|
status: string;
|
||||||
|
sentiment: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Dashboard: React.FC = () => {
|
||||||
|
const [comments, setComments] = useState<Comment[]>([]);
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchComments = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await commentsApi.getComments();
|
||||||
|
setComments(response.data.comments || []);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch comments:', err);
|
||||||
|
setError('Failed to load dashboard data. Please try again later.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchComments();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Calculate statistics
|
||||||
|
const totalComments = comments.length;
|
||||||
|
const pendingComments = comments.filter(comment => comment.status === 'pending').length;
|
||||||
|
const approvedComments = comments.filter(comment => comment.status === 'approved').length;
|
||||||
|
const rejectedComments = comments.filter(comment => comment.status === 'rejected').length;
|
||||||
|
|
||||||
|
// Calculate platform distribution
|
||||||
|
const platforms = comments.reduce((acc: Record<string, number>, comment) => {
|
||||||
|
acc[comment.platform] = (acc[comment.platform] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
// Get recent comments
|
||||||
|
const recentComments = [...comments]
|
||||||
|
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
const getPlatformIcon = (platform: string) => {
|
||||||
|
switch (platform) {
|
||||||
|
case 'facebook':
|
||||||
|
return <Facebook className="h-5 w-5 text-blue-600" />;
|
||||||
|
case 'twitter':
|
||||||
|
return <Twitter className="h-5 w-5 text-blue-400" />;
|
||||||
|
case 'threads':
|
||||||
|
return <Hash className="h-5 w-5 text-gray-800" />;
|
||||||
|
case 'instagram':
|
||||||
|
return <Instagram className="h-5 w-5 text-pink-500" />;
|
||||||
|
case 'linkedin':
|
||||||
|
return <Linkedin className="h-5 w-5 text-blue-700" />;
|
||||||
|
case 'xiaohongshu':
|
||||||
|
return <BookOpen className="h-5 w-5 text-red-500" />;
|
||||||
|
case 'youtube':
|
||||||
|
return <Youtube className="h-5 w-5 text-red-600" />;
|
||||||
|
default:
|
||||||
|
return <MessageSquare className="h-5 w-5 text-gray-500" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'approved':
|
||||||
|
return <CheckCircle className="h-5 w-5 text-green-600" />;
|
||||||
|
case 'rejected':
|
||||||
|
return <XCircle className="h-5 w-5 text-red-600" />;
|
||||||
|
case 'pending':
|
||||||
|
return <Clock className="h-5 w-5 text-yellow-600" />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusName = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'approved':
|
||||||
|
return '已核准';
|
||||||
|
case 'rejected':
|
||||||
|
return '已拒絕';
|
||||||
|
case 'pending':
|
||||||
|
return '待審核';
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 flex-1 overflow-y-auto">
|
||||||
|
<div className="flex justify-center items-center h-full">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 flex-1 overflow-y-auto">
|
||||||
|
<div className="bg-red-50 border-l-4 border-red-500 p-4 mb-4">
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<AlertCircle className="h-5 w-5 text-red-500" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-3">
|
||||||
|
<p className="text-sm text-red-700">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-800">儀表板</h2>
|
||||||
|
<div className="flex space-x-4">
|
||||||
|
<select
|
||||||
|
className="border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="today">今天</option>
|
||||||
|
<option value="yesterday">昨天</option>
|
||||||
|
<option value="7days">過去 7 天</option>
|
||||||
|
<option value="30days">過去 30 天</option>
|
||||||
|
</select>
|
||||||
|
<button className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
重新整理
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 統計卡片 */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-medium text-gray-800">總留言數</h3>
|
||||||
|
<MessageSquare className="h-6 w-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<p className="text-3xl font-bold text-gray-900 mb-2">{totalComments}</p>
|
||||||
|
<div className="flex items-center text-sm">
|
||||||
|
<TrendingUp className="h-4 w-4 text-green-500 mr-1" />
|
||||||
|
<span className="text-green-500">↑ 12% 較上週</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-medium text-gray-800">待審核</h3>
|
||||||
|
<Clock className="h-6 w-6 text-yellow-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-3xl font-bold text-gray-900 mb-2">{pendingComments}</p>
|
||||||
|
<div className="flex items-center text-sm">
|
||||||
|
<TrendingUp className="h-4 w-4 text-yellow-500 mr-1" />
|
||||||
|
<span className="text-yellow-500">↑ 5% 較上週</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-medium text-gray-800">已核准</h3>
|
||||||
|
<CheckCircle className="h-6 w-6 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<p className="text-3xl font-bold text-gray-900 mb-2">{approvedComments}</p>
|
||||||
|
<div className="flex items-center text-sm">
|
||||||
|
<TrendingUp className="h-4 w-4 text-green-500 mr-1" />
|
||||||
|
<span className="text-green-500">↑ 15% 較上週</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-medium text-gray-800">已拒絕</h3>
|
||||||
|
<XCircle className="h-6 w-6 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<p className="text-3xl font-bold text-gray-900 mb-2">{rejectedComments}</p>
|
||||||
|
<div className="flex items-center text-sm">
|
||||||
|
<TrendingUp className="h-4 w-4 text-red-500 mr-1" />
|
||||||
|
<span className="text-red-500">↑ 3% 較上週</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||||
|
{/* 待處理留言 */}
|
||||||
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 className="text-lg font-medium text-gray-800">待處理留言</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
{pendingComments === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-6">
|
||||||
|
<CheckCircle className="h-12 w-12 text-green-500 mb-4" />
|
||||||
|
<p className="text-gray-500">目前沒有待處理的留言</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{comments
|
||||||
|
.filter(comment => comment.status === 'pending')
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((comment, index) => (
|
||||||
|
<div key={index} className="flex items-start p-3 hover:bg-gray-50 rounded-lg transition-colors">
|
||||||
|
<div className="flex-shrink-0 mr-3">
|
||||||
|
{getPlatformIcon(comment.platform)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-900 truncate">{comment.author}</p>
|
||||||
|
<p className="text-sm text-gray-500 truncate">{comment.content}</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">{comment.timestamp}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button className="p-1 text-green-600 hover:bg-green-100 rounded-full">
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button className="p-1 text-red-600 hover:bg-red-100 rounded-full">
|
||||||
|
<XCircle className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 平台分佈 */}
|
||||||
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 className="text-lg font-medium text-gray-800">平台分佈</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{Object.entries(platforms).map(([platform, count]) => (
|
||||||
|
<div key={platform} className="flex items-center p-3 border border-gray-200 rounded-lg">
|
||||||
|
<div className="flex-shrink-0 mr-3">
|
||||||
|
{getPlatformIcon(platform)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900">
|
||||||
|
{platform === 'xiaohongshu' ? '小红书' : platform}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">{count} 則留言</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 最近留言 */}
|
||||||
|
<div className="bg-white rounded-lg shadow overflow-hidden mb-8">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
|
||||||
|
<h3 className="text-lg font-medium text-gray-800">最近留言</h3>
|
||||||
|
<a href="#" className="text-sm text-blue-600 hover:text-blue-800">查看全部</a>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
平台
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
作者
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
留言內容
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
時間
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
狀態
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{recentComments.map((comment, index) => (
|
||||||
|
<tr key={index} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex items-center">
|
||||||
|
{getPlatformIcon(comment.platform)}
|
||||||
|
<span className="ml-2 text-sm text-gray-900 capitalize">
|
||||||
|
{comment.platform === 'xiaohongshu' ? '小红书' : comment.platform}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm text-gray-900">{comment.author}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="text-sm text-gray-900 max-w-xs truncate">{comment.content}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm text-gray-500">{comment.timestamp}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex items-center">
|
||||||
|
{getStatusIcon(comment.status)}
|
||||||
|
<span className="ml-2 text-sm text-gray-700">{getStatusName(comment.status)}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 系統通知 */}
|
||||||
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 className="text-lg font-medium text-gray-800">系統通知</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-start p-3 bg-blue-50 rounded-lg">
|
||||||
|
<div className="flex-shrink-0 mr-3">
|
||||||
|
<AlertCircle className="h-5 w-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900">系統更新通知</p>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">系統將於今晚 23:00-24:00 進行例行維護,期間可能會有短暫服務中斷。</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-2">2025-05-15 10:30:00</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start p-3 bg-green-50 rounded-lg">
|
||||||
|
<div className="flex-shrink-0 mr-3">
|
||||||
|
<Users className="h-5 w-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900">新增平台整合</p>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">系統已成功整合小红书平台,現在您可以管理來自小红书的留言了。</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-2">2025-05-14 15:45:00</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start p-3 bg-yellow-50 rounded-lg">
|
||||||
|
<div className="flex-shrink-0 mr-3">
|
||||||
|
<TrendingUp className="h-5 w-5 text-yellow-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900">流量異常提醒</p>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">過去 24 小時內,系統檢測到留言量增加了 35%,請注意及時處理。</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-2">2025-05-13 08:15:00</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
76
web/src/components/Header.tsx
Normal file
76
web/src/components/Header.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { MessageSquare, Search, Bell, Settings, Menu, LogOut } from 'lucide-react';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
onMenuClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Header: React.FC<HeaderProps> = ({ onMenuClick }) => {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get user's initial for avatar
|
||||||
|
const userInitial = user?.name
|
||||||
|
? user.name.charAt(0).toUpperCase()
|
||||||
|
: user?.email.charAt(0).toUpperCase() || 'U';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="bg-white border-b border-gray-200 px-4 py-3 sm:px-6 sm:py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
className="md:hidden text-gray-500 hover:text-gray-700 focus:outline-none"
|
||||||
|
onClick={onMenuClick}
|
||||||
|
>
|
||||||
|
<Menu className="h-6 w-6" />
|
||||||
|
</button>
|
||||||
|
<MessageSquare className="h-7 w-7 text-blue-600" />
|
||||||
|
<h1 className="text-lg sm:text-xl font-bold text-gray-800 hidden sm:block">社群留言管理系統</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative w-full max-w-xs sm:max-w-sm md:max-w-md mx-2 sm:mx-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜尋留言..."
|
||||||
|
className="w-full pl-8 pr-4 py-1.5 sm:py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<Search className="absolute left-2.5 top-2 h-4 w-4 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2 sm:space-x-4">
|
||||||
|
<button className="relative p-1.5 sm:p-2 text-gray-500 hover:text-gray-700 focus:outline-none">
|
||||||
|
<Bell className="h-5 w-5 sm:h-6 sm:w-6" />
|
||||||
|
<span className="absolute top-0 right-0 h-3.5 w-3.5 sm:h-4 sm:w-4 bg-red-500 rounded-full text-xs text-white flex items-center justify-center">
|
||||||
|
3
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button className="p-1.5 sm:p-2 text-gray-500 hover:text-gray-700 focus:outline-none hidden sm:block">
|
||||||
|
<Settings className="h-5 w-5 sm:h-6 sm:w-6" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="p-1.5 sm:p-2 text-gray-500 hover:text-red-500 focus:outline-none hidden sm:flex items-center space-x-1"
|
||||||
|
title="登出"
|
||||||
|
>
|
||||||
|
<LogOut className="h-5 w-5 sm:h-6 sm:w-6" />
|
||||||
|
<span className="text-sm">登出</span>
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="h-7 w-7 sm:h-8 sm:w-8 rounded-full bg-blue-500 flex items-center justify-center text-white font-medium">
|
||||||
|
{userInitial}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-gray-700 hidden sm:block">
|
||||||
|
{user?.name || user?.email || '用戶'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Header;
|
||||||
208
web/src/components/Login.tsx
Normal file
208
web/src/components/Login.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { User } from '../context/AuthContext';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { Form, Input, Button, Card, Alert, Checkbox } from 'antd';
|
||||||
|
import { LockOutlined, MailOutlined } from '@ant-design/icons';
|
||||||
|
import { authApi } from '../utils/api';
|
||||||
|
|
||||||
|
interface LoginProps {
|
||||||
|
onLoginSuccess: (token: string, user: User) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Login: React.FC<LoginProps> = ({ onLoginSuccess }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [rememberMe, setRememberMe] = useState(true);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 如果已经认证,则重定向到首页
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
console.log('Login - User already authenticated, redirecting to dashboard');
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, navigate]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authApi.login({ email, password });
|
||||||
|
|
||||||
|
if (response.data.token) {
|
||||||
|
// 直接设置身份验证状态
|
||||||
|
onLoginSuccess(response.data.token, response.data.user);
|
||||||
|
|
||||||
|
// 直接导航到仪表板,不做任何额外的检查或延迟
|
||||||
|
navigate('/dashboard');
|
||||||
|
} else {
|
||||||
|
setError('登录失败:未收到有效令牌');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('登录失败:', error);
|
||||||
|
if (axios.isAxiosError(error) && error.response) {
|
||||||
|
setError(`登录失败:${error.response.data.error || '服务器错误'}`);
|
||||||
|
} else {
|
||||||
|
setError('登录失败:网络错误或服务器无响应');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||||
|
<div className="max-w-md w-full bg-white rounded-lg shadow-lg overflow-hidden">
|
||||||
|
<div className="px-6 py-8">
|
||||||
|
<div className="flex justify-center mb-6">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="bg-blue-600 p-3 rounded-full">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-center text-3xl font-extrabold text-gray-900 mb-2">
|
||||||
|
登錄系統
|
||||||
|
</h2>
|
||||||
|
<p className="text-center text-sm text-gray-600 mb-6">
|
||||||
|
社群留言管理平台
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 bg-red-50 border-l-4 border-red-500 p-4">
|
||||||
|
<div className="flex">
|
||||||
|
<div className="ml-3">
|
||||||
|
<p className="text-sm text-red-700">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||||
|
郵箱地址
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 relative rounded-md shadow-sm">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path>
|
||||||
|
<polyline points="22,6 12,13 2,6"></polyline>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="appearance-none block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||||
|
placeholder="請輸入郵箱地址"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||||
|
密碼
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 relative rounded-md shadow-sm">
|
||||||
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="appearance-none block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||||
|
placeholder="請輸入密碼"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<input
|
||||||
|
id="remember-me"
|
||||||
|
name="remember-me"
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||||
|
checked={rememberMe}
|
||||||
|
onChange={(e) => setRememberMe(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="remember-me" className="ml-2 block text-sm text-gray-900">
|
||||||
|
記住我
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm">
|
||||||
|
<a href="#" className="font-medium text-blue-600 hover:text-blue-500">
|
||||||
|
忘記密碼?
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className={`w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 ${loading ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
登錄中...
|
||||||
|
</div>
|
||||||
|
) : '登錄'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-gray-300"></div>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-sm">
|
||||||
|
<span className="px-2 bg-white text-gray-500">初次使用?</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="w-full flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
註冊帳號
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Login;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user