Compare commits
6 Commits
e0bacfac1a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4056bac3ab | |||
| d0f2ab0620 | |||
| bd1a5ce384 | |||
| 942fb592b5 | |||
| 5a03323c69 | |||
| 853ce79e16 |
@@ -56,7 +56,7 @@
|
||||
- 条形长度直观反映各平台占比
|
||||
- 帮助团队了解哪些平台效果更好
|
||||
|
||||
# 审核状态分布 [先不做]
|
||||
# 审核状态分布 [已实现]
|
||||
|
||||
- 环形图展示内容审核状态的分布情况
|
||||
- 包括三种状态:已核准、待审核、已拒绝
|
||||
|
||||
@@ -134,7 +134,7 @@ CREATE TABLE IF NOT EXISTS posts (
|
||||
PRIMARY KEY (post_id)
|
||||
) ENGINE = MergeTree();
|
||||
|
||||
CREATE TABLE IF NOT EXISTS promote.sync_logs (
|
||||
CREATE TABLE IF NOT EXISTS sync_logs (
|
||||
timestamp DateTime DEFAULT now(),
|
||||
duration_ms UInt64,
|
||||
posts_synced UInt32,
|
||||
@@ -145,4 +145,64 @@ CREATE TABLE IF NOT EXISTS promote.sync_logs (
|
||||
error_messages String
|
||||
) ENGINE = MergeTree()
|
||||
ORDER BY
|
||||
(timestamp)
|
||||
(timestamp);
|
||||
|
||||
-- 创建专门的comments表存储评论数据
|
||||
CREATE TABLE IF NOT EXISTS comments (
|
||||
-- 基本标识信息
|
||||
comment_id String,
|
||||
-- 评论唯一ID
|
||||
post_id String,
|
||||
-- 关联的帖子ID
|
||||
user_id String,
|
||||
-- 发表评论的用户ID
|
||||
-- 时间信息
|
||||
created_at DateTime DEFAULT now(),
|
||||
-- 评论创建时间
|
||||
date Date DEFAULT toDate(created_at),
|
||||
-- 日期(用于分区)
|
||||
-- 评论内容信息
|
||||
content String,
|
||||
-- 评论内容
|
||||
sentiment_score Float64,
|
||||
-- 情感分数(-1到1)
|
||||
sentiment Enum8(
|
||||
-- 情感分类
|
||||
'positive' = 1,
|
||||
'neutral' = 2,
|
||||
'negative' = 3
|
||||
),
|
||||
-- 关联信息
|
||||
project_id String,
|
||||
-- 项目ID
|
||||
influencer_id String,
|
||||
-- 网红ID
|
||||
platform String,
|
||||
-- 评论所在平台
|
||||
-- 互动信息
|
||||
likes_count UInt32 DEFAULT 0,
|
||||
-- 点赞数量
|
||||
replies_count UInt32 DEFAULT 0,
|
||||
-- 回复数量
|
||||
-- 元数据
|
||||
parent_comment_id String DEFAULT '',
|
||||
-- 父评论ID(用于回复)
|
||||
is_reply UInt8 DEFAULT 0,
|
||||
-- 是否为回复(0=否,1=是)
|
||||
-- 同步信息
|
||||
is_synced UInt8 DEFAULT 1,
|
||||
-- 是否已同步(0=否,1=是)
|
||||
last_updated DateTime DEFAULT now(),
|
||||
-- 最后更新时间
|
||||
-- 分析信息
|
||||
keywords Array(String),
|
||||
-- 提取的关键词
|
||||
topics Array(String),
|
||||
-- 关联的话题
|
||||
-- 内部处理信息
|
||||
is_active UInt8 DEFAULT 1,
|
||||
-- 是否活跃(0=已删除/隐藏,1=活跃)
|
||||
is_spam UInt8 DEFAULT 0 -- 是否为垃圾评论(0=否,1=是)
|
||||
) ENGINE = MergeTree() PARTITION BY toYYYYMM(date)
|
||||
ORDER BY
|
||||
(post_id, created_at, comment_id) SETTINGS index_granularity = 8192;
|
||||
@@ -678,6 +678,313 @@ export class AnalyticsController {
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取内容审核状态分布数据
|
||||
* 返回已批准、待审核和已拒绝内容的数量和比例
|
||||
*
|
||||
* @param c Hono Context
|
||||
* @returns Response with moderation status distribution data
|
||||
*/
|
||||
async getModerationStatus(c: Context) {
|
||||
const requestId = `req_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`;
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// 获取查询参数
|
||||
const timeRange = c.req.query('timeRange') || '30'; // 默认30天
|
||||
const projectId = c.req.query('projectId'); // 可选项目过滤
|
||||
const contentType = c.req.query('contentType') || 'all'; // 内容类型:post, comment, all
|
||||
|
||||
logger.info(`[${requestId}] Moderation status distribution request received`, {
|
||||
timeRange,
|
||||
projectId,
|
||||
contentType,
|
||||
userAgent: c.req.header('user-agent'),
|
||||
ip: c.req.header('x-forwarded-for') || 'unknown'
|
||||
});
|
||||
|
||||
// 验证时间范围
|
||||
if (!['7', '30', '90'].includes(timeRange)) {
|
||||
logger.warn(`[${requestId}] Invalid timeRange: ${timeRange}`);
|
||||
return c.json({
|
||||
success: false,
|
||||
error: 'Invalid timeRange. Must be 7, 30, or 90.'
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// 验证内容类型
|
||||
if (!['post', 'comment', 'all'].includes(contentType)) {
|
||||
logger.warn(`[${requestId}] Invalid contentType: ${contentType}`);
|
||||
return c.json({
|
||||
success: false,
|
||||
error: 'Invalid contentType. Must be post, comment, or all.'
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// 获取审核状态分布数据
|
||||
const data = await analyticsService.getModerationStatusDistribution(
|
||||
parseInt(timeRange, 10),
|
||||
projectId,
|
||||
contentType
|
||||
);
|
||||
|
||||
// 返回成功响应
|
||||
const duration = Date.now() - startTime;
|
||||
logger.info(`[${requestId}] Moderation status distribution response sent successfully`, {
|
||||
duration,
|
||||
statuses: Object.keys(data.statuses),
|
||||
total: data.total
|
||||
});
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
data: data
|
||||
});
|
||||
} catch (error) {
|
||||
// 记录错误
|
||||
const duration = Date.now() - startTime;
|
||||
logger.error(`[${requestId}] Error fetching moderation status distribution (${duration}ms)`, error);
|
||||
|
||||
// 返回错误响应
|
||||
return c.json({
|
||||
success: false,
|
||||
error: 'Failed to fetch moderation status distribution data',
|
||||
message: error instanceof Error ? error.message : 'Unknown error'
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取热门关键字数据
|
||||
* 返回按出现频率排序的热门关键字列表
|
||||
*
|
||||
* @param c Hono Context
|
||||
* @returns Response with hot keywords data
|
||||
*/
|
||||
async getHotKeywords(c: Context) {
|
||||
const requestId = `req_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`;
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// 获取查询参数
|
||||
const timeRange = c.req.query('timeRange') || '30'; // 默认30天
|
||||
const projectId = c.req.query('projectId'); // 可选项目过滤
|
||||
const platform = c.req.query('platform'); // 可选平台过滤
|
||||
const limit = parseInt(c.req.query('limit') || '20', 10); // 默认返回20个关键字
|
||||
|
||||
logger.info(`[${requestId}] Hot keywords request received`, {
|
||||
timeRange,
|
||||
projectId,
|
||||
platform,
|
||||
limit,
|
||||
userAgent: c.req.header('user-agent'),
|
||||
ip: c.req.header('x-forwarded-for') || 'unknown'
|
||||
});
|
||||
|
||||
// 验证时间范围
|
||||
if (!['7', '30', '90'].includes(timeRange)) {
|
||||
logger.warn(`[${requestId}] Invalid timeRange: ${timeRange}`);
|
||||
return c.json({
|
||||
success: false,
|
||||
error: 'Invalid timeRange. Must be 7, 30, or 90.'
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// 调用服务获取热门关键词数据
|
||||
const hotKeywordsData = await analyticsService.getHotKeywords(
|
||||
parseInt(timeRange, 10),
|
||||
projectId,
|
||||
platform,
|
||||
limit
|
||||
);
|
||||
|
||||
const response = {
|
||||
success: true,
|
||||
data: hotKeywordsData.data,
|
||||
metadata: {
|
||||
total: hotKeywordsData.total,
|
||||
is_mock_data: false
|
||||
}
|
||||
};
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
logger.info(`[${requestId}] Hot keywords request completed in ${duration}ms`);
|
||||
|
||||
return c.json(response);
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
logger.error(`[${requestId}] Error getting hot keywords: ${error}`, {
|
||||
error: String(error),
|
||||
duration
|
||||
});
|
||||
|
||||
return c.json({
|
||||
success: false,
|
||||
error: 'Failed to fetch hot keywords data'
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户互动时间分析数据
|
||||
* 返回按小时统计的用户互动数量和分布
|
||||
*
|
||||
* @param c Hono Context
|
||||
* @returns Response with interaction time analysis data
|
||||
*/
|
||||
async getInteractionTimeAnalysis(c: Context) {
|
||||
const requestId = `req_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`;
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// 获取查询参数
|
||||
const timeRange = c.req.query('timeRange') || '30'; // 默认30天
|
||||
const projectId = c.req.query('projectId'); // 可选项目过滤
|
||||
const platform = c.req.query('platform'); // 可选平台过滤
|
||||
const eventType = c.req.query('eventType') || 'all'; // 可选事件类型过滤
|
||||
|
||||
logger.info(`[${requestId}] Interaction time analysis request received`, {
|
||||
timeRange,
|
||||
projectId,
|
||||
platform,
|
||||
eventType,
|
||||
userAgent: c.req.header('user-agent'),
|
||||
ip: c.req.header('x-forwarded-for') || 'unknown'
|
||||
});
|
||||
|
||||
// 验证时间范围
|
||||
if (!['7', '30', '90'].includes(timeRange)) {
|
||||
logger.warn(`[${requestId}] Invalid timeRange: ${timeRange}`);
|
||||
return c.json({
|
||||
success: false,
|
||||
error: 'Invalid timeRange. Must be 7, 30, or 90.'
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// 验证事件类型
|
||||
const validEventTypes = ['all', 'comment', 'like', 'view', 'share', 'follow'];
|
||||
if (!validEventTypes.includes(eventType)) {
|
||||
logger.warn(`[${requestId}] Invalid eventType: ${eventType}`);
|
||||
return c.json({
|
||||
success: false,
|
||||
error: `Invalid eventType. Must be one of: ${validEventTypes.join(', ')}`
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// 获取互动时间分析数据
|
||||
const data = await analyticsService.getInteractionTimeAnalysis(
|
||||
parseInt(timeRange, 10),
|
||||
projectId,
|
||||
platform,
|
||||
eventType
|
||||
);
|
||||
|
||||
// 返回成功响应
|
||||
const duration = Date.now() - startTime;
|
||||
logger.info(`[${requestId}] Interaction time analysis response sent successfully`, {
|
||||
duration,
|
||||
total: data.total,
|
||||
peak_hour: data.peak_hour
|
||||
});
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
data: data.data,
|
||||
metadata: {
|
||||
total: data.total,
|
||||
peak_hour: data.peak_hour,
|
||||
lowest_hour: data.lowest_hour
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
// 记录错误
|
||||
const duration = Date.now() - startTime;
|
||||
logger.error(`[${requestId}] Error fetching interaction time analysis (${duration}ms)`, error);
|
||||
|
||||
// 返回错误响应
|
||||
return c.json({
|
||||
success: false,
|
||||
error: 'Failed to fetch interaction time analysis data',
|
||||
message: error instanceof Error ? error.message : 'Unknown error'
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取内容表现分析数据
|
||||
* 提供内容覆盖量、互动率、互动量等散点图数据,用于四象限分析
|
||||
*
|
||||
* @param c Hono Context
|
||||
* @returns Response with content performance data
|
||||
*/
|
||||
async getContentPerformance(c: Context) {
|
||||
const requestId = `req_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`;
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// 获取查询参数
|
||||
const timeRange = c.req.query('timeRange') || '30'; // 默认30天
|
||||
const projectId = c.req.query('projectId'); // 可选项目过滤
|
||||
const platform = c.req.query('platform'); // 可选平台过滤
|
||||
const kolId = c.req.query('kolId'); // 可选KOL过滤
|
||||
const contentType = c.req.query('contentType') || 'all'; // 内容类型过滤(post、video、article或all)
|
||||
const limit = parseInt(c.req.query('limit') || '100', 10); // 默认返回100条内容
|
||||
|
||||
logger.info(`[${requestId}] Content performance analysis request received`, {
|
||||
timeRange,
|
||||
projectId,
|
||||
platform,
|
||||
kolId,
|
||||
contentType,
|
||||
limit,
|
||||
userAgent: c.req.header('user-agent'),
|
||||
ip: c.req.header('x-forwarded-for') || 'unknown'
|
||||
});
|
||||
|
||||
// 验证时间范围
|
||||
if (!['7', '30', '90'].includes(timeRange)) {
|
||||
logger.warn(`[${requestId}] Invalid timeRange: ${timeRange}`);
|
||||
return c.json({
|
||||
success: false,
|
||||
error: 'Invalid timeRange. Must be 7, 30, or 90.'
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// 调用服务获取内容表现分析数据
|
||||
const contentPerformanceData = await analyticsService.getContentPerformance(
|
||||
parseInt(timeRange, 10),
|
||||
projectId,
|
||||
platform,
|
||||
kolId,
|
||||
contentType,
|
||||
limit
|
||||
);
|
||||
|
||||
// 记录请求完成
|
||||
const duration = Date.now() - startTime;
|
||||
logger.info(`[${requestId}] Content performance analysis request completed in ${duration}ms`, {
|
||||
itemCount: contentPerformanceData.data.length,
|
||||
metadata: contentPerformanceData.metadata
|
||||
});
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
data: contentPerformanceData.data,
|
||||
metadata: contentPerformanceData.metadata
|
||||
});
|
||||
} catch (error) {
|
||||
// 记录错误
|
||||
const duration = Date.now() - startTime;
|
||||
logger.error(`[${requestId}] Error fetching content performance data (${duration}ms)`, error);
|
||||
|
||||
return c.json({
|
||||
success: false,
|
||||
error: 'Failed to fetch content performance data',
|
||||
message: error instanceof Error ? error.message : 'Unknown error'
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
|
||||
@@ -47,4 +47,16 @@ analyticsRouter.get('/sentiment-analysis', (c) => analyticsController.getSentime
|
||||
// Add popular posts route
|
||||
analyticsRouter.get('/popular-posts', (c) => analyticsController.getPopularPosts(c));
|
||||
|
||||
// Add moderation status distribution route
|
||||
analyticsRouter.get('/moderation-status', (c) => analyticsController.getModerationStatus(c));
|
||||
|
||||
// Add hot keywords route
|
||||
analyticsRouter.get('/hot-keywords', (c) => analyticsController.getHotKeywords(c));
|
||||
|
||||
// Add interaction time analysis route
|
||||
analyticsRouter.get('/interaction-time', (c) => analyticsController.getInteractionTimeAnalysis(c));
|
||||
|
||||
// Add content performance analysis route
|
||||
analyticsRouter.get('/content-performance', (c) => analyticsController.getContentPerformance(c));
|
||||
|
||||
export default analyticsRouter;
|
||||
@@ -179,6 +179,108 @@ export interface PopularPostsResponse {
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sentiment analysis response
|
||||
*/
|
||||
export interface SentimentAnalysisResponse {
|
||||
positive_percentage: number;
|
||||
neutral_percentage: number;
|
||||
negative_percentage: number;
|
||||
average_score: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moderation status response
|
||||
*/
|
||||
export interface ModerationStatusResponse {
|
||||
statuses: {
|
||||
approved: number;
|
||||
pending: number;
|
||||
rejected: number;
|
||||
};
|
||||
percentages: {
|
||||
approved: number;
|
||||
pending: number;
|
||||
rejected: number;
|
||||
};
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a hot keyword item with count, percentage and sentiment score
|
||||
*/
|
||||
export interface HotKeywordItem {
|
||||
keyword: string; // 关键词
|
||||
count: number; // 出现次数
|
||||
percentage: number; // 占比(%)
|
||||
sentiment_score: number; // 情感分数
|
||||
}
|
||||
|
||||
/**
|
||||
* Response structure for hot keywords
|
||||
*/
|
||||
export interface HotKeywordsResponse {
|
||||
data: HotKeywordItem[]; // 热门关键词数据
|
||||
total: number; // 总数
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户互动时间分布数据项
|
||||
*/
|
||||
export interface InteractionTimeItem {
|
||||
hour: number; // 小时 (0-23)
|
||||
count: number; // 互动数量
|
||||
percentage: number; // 占总数百分比
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户互动时间分析响应
|
||||
*/
|
||||
export interface InteractionTimeResponse {
|
||||
data: InteractionTimeItem[]; // 按小时统计的互动数据
|
||||
peak_hour: number; // 峰值时段
|
||||
lowest_hour: number; // 最低时段
|
||||
total: number; // 总互动数
|
||||
}
|
||||
|
||||
/**
|
||||
* 内容表现分析项目数据
|
||||
*/
|
||||
export interface ContentPerformanceItem {
|
||||
content_id: string;
|
||||
title: string;
|
||||
platform: string;
|
||||
content_type: string;
|
||||
influencer_name: string;
|
||||
publish_date: string;
|
||||
coverage: number; // 内容覆盖量(如阅读量/浏览量)
|
||||
interaction_rate: number; // 互动率(互动总数/覆盖量)
|
||||
interaction_count: number; // 互动总量(点赞+评论+分享)
|
||||
likes: number;
|
||||
comments: number;
|
||||
shares: number;
|
||||
quadrant: 'high_value' | 'high_coverage' | 'high_engagement' | 'low_performance'; // 四象限分类
|
||||
}
|
||||
|
||||
/**
|
||||
* 内容表现分析响应数据
|
||||
*/
|
||||
export interface ContentPerformanceResponse {
|
||||
data: ContentPerformanceItem[];
|
||||
metadata: {
|
||||
total: number;
|
||||
average_coverage: number;
|
||||
average_interaction_rate: number;
|
||||
quadrant_counts: {
|
||||
high_value: number;
|
||||
high_coverage: number;
|
||||
high_engagement: number;
|
||||
low_performance: number;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Analytics service for KOL performance data
|
||||
*/
|
||||
@@ -1652,6 +1754,706 @@ export class AnalyticsService {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取内容审核状态分布数据
|
||||
* @param timeRange 时间范围(天数)
|
||||
* @param projectId 可选项目ID过滤
|
||||
* @param contentType 内容类型过滤(post, comment, all)
|
||||
* @returns 审核状态分布数据
|
||||
*/
|
||||
async getModerationStatusDistribution(
|
||||
timeRange: number,
|
||||
projectId?: string,
|
||||
contentType: string = 'all'
|
||||
): Promise<ModerationStatusResponse> {
|
||||
const startTime = Date.now();
|
||||
logger.info('Fetching moderation status distribution', { timeRange, projectId, contentType });
|
||||
|
||||
try {
|
||||
// 计算时间范围
|
||||
const currentDate = new Date();
|
||||
const pastDate = new Date();
|
||||
pastDate.setDate(currentDate.getDate() - timeRange);
|
||||
|
||||
// 格式化日期
|
||||
const currentDateStr = this.formatDateForClickhouse(currentDate);
|
||||
const pastDateStr = this.formatDateForClickhouse(pastDate);
|
||||
|
||||
// 构建过滤条件
|
||||
const filters: string[] = [];
|
||||
filters.push(`date BETWEEN '${pastDateStr}' AND '${currentDateStr}'`);
|
||||
|
||||
if (projectId) {
|
||||
filters.push(`project_id = '${projectId}'`);
|
||||
}
|
||||
|
||||
// 基于内容类型的过滤
|
||||
let typeFilter = '';
|
||||
if (contentType === 'post') {
|
||||
typeFilter = "AND content_type IN ('video', 'image', 'text', 'story', 'reel', 'live')";
|
||||
} else if (contentType === 'comment') {
|
||||
typeFilter = "AND event_type = 'comment'";
|
||||
}
|
||||
|
||||
const filterCondition = filters.join(' AND ');
|
||||
|
||||
// 查询审核状态分布
|
||||
const query = `
|
||||
SELECT
|
||||
content_status,
|
||||
count() as status_count
|
||||
FROM
|
||||
events
|
||||
WHERE
|
||||
${filterCondition}
|
||||
AND content_status IN ('approved', 'pending', 'rejected')
|
||||
${typeFilter}
|
||||
GROUP BY
|
||||
content_status
|
||||
`;
|
||||
|
||||
logger.debug('Executing ClickHouse query for moderation status distribution', {
|
||||
query: query.replace(/\n\s+/g, ' ').trim()
|
||||
});
|
||||
|
||||
// 执行查询
|
||||
const results = await this.executeClickhouseQuery(query);
|
||||
|
||||
// 初始化结果对象
|
||||
const statusCounts = {
|
||||
approved: 0,
|
||||
pending: 0,
|
||||
rejected: 0
|
||||
};
|
||||
|
||||
// 解析结果
|
||||
results.forEach(row => {
|
||||
const status = row.content_status.toLowerCase();
|
||||
if (status in statusCounts) {
|
||||
statusCounts[status as keyof typeof statusCounts] = Number(row.status_count);
|
||||
}
|
||||
});
|
||||
|
||||
// 计算总数和百分比
|
||||
const total = statusCounts.approved + statusCounts.pending + statusCounts.rejected;
|
||||
|
||||
const calculatePercentage = (count: number): number => {
|
||||
if (total === 0) return 0;
|
||||
return parseFloat(((count / total) * 100).toFixed(1));
|
||||
};
|
||||
|
||||
const statusPercentages = {
|
||||
approved: calculatePercentage(statusCounts.approved),
|
||||
pending: calculatePercentage(statusCounts.pending),
|
||||
rejected: calculatePercentage(statusCounts.rejected)
|
||||
};
|
||||
|
||||
const result: ModerationStatusResponse = {
|
||||
statuses: statusCounts,
|
||||
percentages: statusPercentages,
|
||||
total
|
||||
};
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
logger.info('Moderation status distribution fetched successfully', {
|
||||
duration,
|
||||
total,
|
||||
statusDistribution: statusCounts
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
logger.error(`Error in getModerationStatusDistribution (${duration}ms)`, error);
|
||||
|
||||
// 发生错误时返回默认响应
|
||||
return {
|
||||
statuses: { approved: 0, pending: 0, rejected: 0 },
|
||||
percentages: { approved: 0, pending: 0, rejected: 0 },
|
||||
total: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hot keywords from comments with their frequency and sentiment scores
|
||||
* @param timeRange Time range in days (7, 30, 90)
|
||||
* @param projectId Optional project ID to filter by
|
||||
* @param platform Optional platform to filter by
|
||||
* @param limit Maximum number of keywords to return
|
||||
* @returns Hot keywords with their counts, percentages and sentiment scores
|
||||
*/
|
||||
async getHotKeywords(
|
||||
timeRange: number,
|
||||
projectId?: string,
|
||||
platform?: string,
|
||||
limit: number = 20
|
||||
): Promise<HotKeywordsResponse> {
|
||||
try {
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - timeRange);
|
||||
const formattedStartDate = this.formatDateForClickhouse(startDate);
|
||||
|
||||
// Build the WHERE clause with filters
|
||||
const filters = [`event_time >= '${formattedStartDate}'`];
|
||||
if (projectId) {
|
||||
filters.push(`project_id = '${projectId}'`);
|
||||
}
|
||||
if (platform) {
|
||||
filters.push(`platform = '${platform}'`);
|
||||
}
|
||||
|
||||
// Add filter for comment events only
|
||||
filters.push(`event_type = 'comment'`);
|
||||
|
||||
const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '';
|
||||
|
||||
// Query to extract keywords from comments and count their occurrences
|
||||
const keywordsQuery = `
|
||||
WITH extracted_keywords AS (
|
||||
SELECT
|
||||
arrayJoin(extractAll(comment_text, '[\\\\p{L}\\\\p{N}]{2,}')) AS keyword,
|
||||
sentiment_score
|
||||
FROM events
|
||||
${whereClause}
|
||||
WHERE comment_text != ''
|
||||
)
|
||||
SELECT
|
||||
keyword,
|
||||
count() AS count,
|
||||
avg(sentiment_score) AS avg_sentiment_score
|
||||
FROM extracted_keywords
|
||||
GROUP BY keyword
|
||||
ORDER BY count DESC
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
|
||||
// Execute the query
|
||||
const keywordsData = await this.executeClickhouseQuery(keywordsQuery);
|
||||
|
||||
// Calculate total count for percentages
|
||||
const totalCount = keywordsData.reduce((sum, item) => sum + item.count, 0);
|
||||
|
||||
// Format the response
|
||||
const hotKeywords: HotKeywordItem[] = keywordsData.map(item => ({
|
||||
keyword: item.keyword,
|
||||
count: item.count,
|
||||
percentage: parseFloat(((item.count / totalCount) * 100).toFixed(1)),
|
||||
sentiment_score: parseFloat(item.avg_sentiment_score.toFixed(1))
|
||||
}));
|
||||
|
||||
return {
|
||||
data: hotKeywords,
|
||||
total: totalCount
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Error fetching hot keywords: ${error}`);
|
||||
|
||||
// Return mock data in case of error
|
||||
const mockKeywords: HotKeywordItem[] = [
|
||||
{ keyword: '价格', count: 45, percentage: 22.5, sentiment_score: 0.2 },
|
||||
{ keyword: '质量', count: 38, percentage: 19.0, sentiment_score: 0.7 },
|
||||
{ keyword: '服务', count: 32, percentage: 16.0, sentiment_score: -0.3 },
|
||||
{ keyword: '快递', count: 28, percentage: 14.0, sentiment_score: 0.1 },
|
||||
{ keyword: '推荐', count: 24, percentage: 12.0, sentiment_score: 0.8 },
|
||||
{ keyword: '问题', count: 18, percentage: 9.0, sentiment_score: -0.6 },
|
||||
{ keyword: '体验', count: 15, percentage: 7.5, sentiment_score: 0.4 }
|
||||
];
|
||||
|
||||
return {
|
||||
data: mockKeywords,
|
||||
total: 100
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户互动时间分析数据
|
||||
* @param timeRange 时间范围(天数)
|
||||
* @param projectId 可选项目ID
|
||||
* @param platform 可选平台
|
||||
* @param eventType 互动事件类型
|
||||
* @returns 互动时间分析数据
|
||||
*/
|
||||
async getInteractionTimeAnalysis(
|
||||
timeRange: number,
|
||||
projectId?: string,
|
||||
platform?: string,
|
||||
eventType: string = 'all'
|
||||
): Promise<InteractionTimeResponse> {
|
||||
const startTime = Date.now();
|
||||
logger.info('Fetching interaction time analysis data', { timeRange, projectId, platform, eventType });
|
||||
|
||||
try {
|
||||
// 计算时间范围
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - timeRange);
|
||||
const formattedStartDate = this.formatDateForClickhouse(startDate);
|
||||
|
||||
// 构建查询条件
|
||||
const filters = [`event_time >= '${formattedStartDate}'`];
|
||||
|
||||
if (projectId) {
|
||||
filters.push(`project_id = '${projectId}'`);
|
||||
}
|
||||
|
||||
if (platform) {
|
||||
filters.push(`platform = '${platform}'`);
|
||||
}
|
||||
|
||||
// 根据事件类型过滤
|
||||
if (eventType !== 'all') {
|
||||
filters.push(`event_type = '${eventType}'`);
|
||||
}
|
||||
|
||||
const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '';
|
||||
|
||||
// 按小时分组查询互动数量
|
||||
const query = `
|
||||
SELECT
|
||||
toHour(event_time) AS hour,
|
||||
count() AS count
|
||||
FROM
|
||||
events
|
||||
${whereClause}
|
||||
GROUP BY
|
||||
hour
|
||||
ORDER BY
|
||||
hour ASC
|
||||
`;
|
||||
|
||||
logger.debug('Executing ClickHouse query for interaction time analysis', {
|
||||
query: query.replace(/\n\s+/g, ' ').trim()
|
||||
});
|
||||
|
||||
// 执行查询
|
||||
const result = await this.executeClickhouseQuery(query);
|
||||
|
||||
// 处理结果,确保所有24小时都有数据
|
||||
const hourlyData: { [hour: number]: number } = {};
|
||||
|
||||
// 初始化所有小时为0
|
||||
for (let i = 0; i < 24; i++) {
|
||||
hourlyData[i] = 0;
|
||||
}
|
||||
|
||||
// 填充查询结果
|
||||
result.forEach(item => {
|
||||
hourlyData[item.hour] = item.count;
|
||||
});
|
||||
|
||||
// 计算总数和百分比
|
||||
const total = Object.values(hourlyData).reduce((sum, count) => sum + count, 0);
|
||||
|
||||
// 找出峰值和最低时段
|
||||
let peakHour = 0;
|
||||
let lowestHour = 0;
|
||||
let maxCount = 0;
|
||||
let minCount = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
Object.entries(hourlyData).forEach(([hour, count]) => {
|
||||
const hourNum = parseInt(hour, 10);
|
||||
if (count > maxCount) {
|
||||
maxCount = count;
|
||||
peakHour = hourNum;
|
||||
}
|
||||
if (count < minCount) {
|
||||
minCount = count;
|
||||
lowestHour = hourNum;
|
||||
}
|
||||
});
|
||||
|
||||
// 格式化响应数据
|
||||
const data: InteractionTimeItem[] = Object.entries(hourlyData).map(([hour, count]) => ({
|
||||
hour: parseInt(hour, 10),
|
||||
count,
|
||||
percentage: total > 0 ? parseFloat(((count / total) * 100).toFixed(1)) : 0
|
||||
}));
|
||||
|
||||
const response: InteractionTimeResponse = {
|
||||
data,
|
||||
peak_hour: peakHour,
|
||||
lowest_hour: lowestHour,
|
||||
total
|
||||
};
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
logger.info('Interaction time analysis data fetched successfully', {
|
||||
duration,
|
||||
total,
|
||||
peak_hour: peakHour
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
logger.error(`Error in getInteractionTimeAnalysis (${duration}ms)`, error);
|
||||
|
||||
// 发生错误时返回模拟数据
|
||||
const mockData: InteractionTimeItem[] = Array.from({ length: 24 }, (_, i) => {
|
||||
// 创建合理的模拟数据分布,早晨和晚上的互动较多
|
||||
let count = 0;
|
||||
if (i >= 7 && i <= 10) { // 早晨
|
||||
count = Math.floor(Math.random() * 100) + 150;
|
||||
} else if (i >= 17 && i <= 22) { // 晚上
|
||||
count = Math.floor(Math.random() * 100) + 200;
|
||||
} else { // 其他时间
|
||||
count = Math.floor(Math.random() * 100) + 50;
|
||||
}
|
||||
|
||||
return {
|
||||
hour: i,
|
||||
count,
|
||||
percentage: 0 // 暂时设为0,稍后计算
|
||||
};
|
||||
});
|
||||
|
||||
// 计算总数和百分比
|
||||
const total = mockData.reduce((sum, item) => sum + item.count, 0);
|
||||
mockData.forEach(item => {
|
||||
item.percentage = parseFloat(((item.count / total) * 100).toFixed(1));
|
||||
});
|
||||
|
||||
// 找出峰值和最低时段
|
||||
let peakHour = 0;
|
||||
let lowestHour = 0;
|
||||
let maxCount = 0;
|
||||
let minCount = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
mockData.forEach(item => {
|
||||
if (item.count > maxCount) {
|
||||
maxCount = item.count;
|
||||
peakHour = item.hour;
|
||||
}
|
||||
if (item.count < minCount) {
|
||||
minCount = item.count;
|
||||
lowestHour = item.hour;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
data: mockData,
|
||||
peak_hour: peakHour,
|
||||
lowest_hour: lowestHour,
|
||||
total
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取内容表现分析数据
|
||||
*
|
||||
* @param timeRange 时间范围(天数)
|
||||
* @param projectId 可选项目ID
|
||||
* @param platform 可选平台
|
||||
* @param kolId 可选KOL ID
|
||||
* @param contentType 内容类型过滤
|
||||
* @param limit 最大返回条数
|
||||
* @returns 内容表现分析数据
|
||||
*/
|
||||
async getContentPerformance(
|
||||
timeRange: number,
|
||||
projectId?: string,
|
||||
platform?: string,
|
||||
kolId?: string,
|
||||
contentType: string = 'all',
|
||||
limit: number = 100
|
||||
): Promise<ContentPerformanceResponse> {
|
||||
const startTime = Date.now();
|
||||
logger.info('Fetching content performance data', { timeRange, projectId, platform, kolId, contentType, limit });
|
||||
|
||||
try {
|
||||
// 计算日期范围
|
||||
const currentDate = new Date();
|
||||
const pastDate = new Date();
|
||||
pastDate.setDate(pastDate.getDate() - timeRange);
|
||||
|
||||
const currentDateStr = this.formatDateForClickhouse(currentDate);
|
||||
const pastDateStr = this.formatDateForClickhouse(pastDate);
|
||||
|
||||
// 构建基础过滤条件
|
||||
const filters = [
|
||||
`created_at BETWEEN '${pastDateStr}' AND '${currentDateStr}'`
|
||||
];
|
||||
|
||||
if (projectId) {
|
||||
filters.push(`project_id = '${projectId}'`);
|
||||
}
|
||||
|
||||
if (platform) {
|
||||
filters.push(`platform = '${platform}'`);
|
||||
}
|
||||
|
||||
if (kolId) {
|
||||
filters.push(`influencer_id = '${kolId}'`);
|
||||
}
|
||||
|
||||
if (contentType !== 'all') {
|
||||
filters.push(`content_type = '${contentType}'`);
|
||||
}
|
||||
|
||||
const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '';
|
||||
|
||||
// 查询内容数据及性能指标
|
||||
const contentQuery = `
|
||||
WITH content_metrics AS (
|
||||
SELECT
|
||||
c.content_id,
|
||||
c.title,
|
||||
c.platform,
|
||||
c.content_type,
|
||||
c.influencer_id,
|
||||
i.name AS influencer_name,
|
||||
toString(c.created_at) AS publish_date,
|
||||
c.views AS coverage,
|
||||
countIf(e.event_type = 'like') AS likes,
|
||||
countIf(e.event_type = 'comment') AS comments,
|
||||
countIf(e.event_type = 'share') AS shares,
|
||||
count() AS total_interactions
|
||||
FROM
|
||||
content c
|
||||
LEFT JOIN
|
||||
influencers i ON c.influencer_id = i.influencer_id
|
||||
LEFT JOIN
|
||||
events e ON c.content_id = e.content_id AND e.event_time BETWEEN '${pastDateStr}' AND '${currentDateStr}'
|
||||
${whereClause}
|
||||
GROUP BY
|
||||
c.content_id, c.title, c.platform, c.content_type, c.influencer_id, i.name, c.created_at, c.views
|
||||
)
|
||||
SELECT
|
||||
content_id,
|
||||
title,
|
||||
platform,
|
||||
content_type,
|
||||
influencer_id,
|
||||
influencer_name,
|
||||
publish_date,
|
||||
coverage,
|
||||
likes,
|
||||
comments,
|
||||
shares,
|
||||
total_interactions,
|
||||
coverage > 0 ? total_interactions / coverage : 0 AS interaction_rate
|
||||
FROM
|
||||
content_metrics
|
||||
WHERE
|
||||
coverage > 0
|
||||
ORDER BY
|
||||
total_interactions DESC
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
|
||||
// 执行查询
|
||||
logger.debug('Executing ClickHouse query for content performance', {
|
||||
query: contentQuery.replace(/\n\s+/g, ' ').trim()
|
||||
});
|
||||
|
||||
const contentData = await this.executeClickhouseQuery(contentQuery);
|
||||
|
||||
// 如果没有数据,返回模拟数据
|
||||
if (!Array.isArray(contentData) || contentData.length === 0) {
|
||||
return this.generateMockContentPerformance(limit);
|
||||
}
|
||||
|
||||
// 处理结果,计算平均值和四象限分类
|
||||
const processedData: ContentPerformanceItem[] = [];
|
||||
let totalCoverage = 0;
|
||||
let totalInteractionRate = 0;
|
||||
|
||||
// 处理每个内容项
|
||||
contentData.forEach(item => {
|
||||
const coverage = Number(item.coverage || 0);
|
||||
const likes = Number(item.likes || 0);
|
||||
const comments = Number(item.comments || 0);
|
||||
const shares = Number(item.shares || 0);
|
||||
const interactionCount = likes + comments + shares;
|
||||
const interactionRate = coverage > 0 ? interactionCount / coverage : 0;
|
||||
|
||||
totalCoverage += coverage;
|
||||
totalInteractionRate += interactionRate;
|
||||
|
||||
processedData.push({
|
||||
content_id: item.content_id,
|
||||
title: item.title || '无标题',
|
||||
platform: item.platform || 'unknown',
|
||||
content_type: item.content_type || 'post',
|
||||
influencer_name: item.influencer_name || '未知KOL',
|
||||
publish_date: item.publish_date || '',
|
||||
coverage,
|
||||
interaction_rate: interactionRate,
|
||||
interaction_count: interactionCount,
|
||||
likes,
|
||||
comments,
|
||||
shares,
|
||||
quadrant: 'low_performance' // 临时值,稍后更新
|
||||
});
|
||||
});
|
||||
|
||||
// 计算平均值
|
||||
const averageCoverage = processedData.length > 0 ? totalCoverage / processedData.length : 0;
|
||||
const averageInteractionRate = processedData.length > 0 ? totalInteractionRate / processedData.length : 0;
|
||||
|
||||
// 根据平均值确定每个内容的四象限分类
|
||||
const quadrantCounts = {
|
||||
high_value: 0,
|
||||
high_coverage: 0,
|
||||
high_engagement: 0,
|
||||
low_performance: 0
|
||||
};
|
||||
|
||||
processedData.forEach(item => {
|
||||
if (item.coverage > averageCoverage && item.interaction_rate > averageInteractionRate) {
|
||||
// 高覆盖 + 高互动率 = 高价值
|
||||
item.quadrant = 'high_value';
|
||||
quadrantCounts.high_value++;
|
||||
} else if (item.coverage > averageCoverage) {
|
||||
// 高覆盖 + 低互动率 = 高覆盖
|
||||
item.quadrant = 'high_coverage';
|
||||
quadrantCounts.high_coverage++;
|
||||
} else if (item.interaction_rate > averageInteractionRate) {
|
||||
// 低覆盖 + 高互动率 = 高互动
|
||||
item.quadrant = 'high_engagement';
|
||||
quadrantCounts.high_engagement++;
|
||||
} else {
|
||||
// 低覆盖 + 低互动率 = 低表现
|
||||
item.quadrant = 'low_performance';
|
||||
quadrantCounts.low_performance++;
|
||||
}
|
||||
});
|
||||
|
||||
// 构建返回结果
|
||||
const result: ContentPerformanceResponse = {
|
||||
data: processedData,
|
||||
metadata: {
|
||||
total: processedData.length,
|
||||
average_coverage: parseFloat(averageCoverage.toFixed(2)),
|
||||
average_interaction_rate: parseFloat(averageInteractionRate.toFixed(4)),
|
||||
quadrant_counts: quadrantCounts
|
||||
}
|
||||
};
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
logger.info(`Content performance data fetched successfully in ${duration}ms`, {
|
||||
itemCount: processedData.length,
|
||||
averages: { coverage: averageCoverage, interactionRate: averageInteractionRate },
|
||||
quadrantCounts
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
logger.error(`Error in getContentPerformance (${duration}ms)`, error);
|
||||
|
||||
// 发生错误时返回模拟数据
|
||||
return this.generateMockContentPerformance(limit);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成模拟内容表现分析数据
|
||||
* 当实际数据获取失败时使用
|
||||
*/
|
||||
private generateMockContentPerformance(limit: number = 100): ContentPerformanceResponse {
|
||||
const platforms = ['微博', '微信', '抖音', '小红书', '知乎'];
|
||||
const contentTypes = ['post', 'video', 'article'];
|
||||
const titles = [
|
||||
'为什么越来越多的年轻人选择使用我们的产品',
|
||||
'产品新功能发布,颠覆你的使用体验',
|
||||
'用户分享:我是如何通过这款产品提升效率的',
|
||||
'揭秘:产品背后的设计理念',
|
||||
'五分钟了解产品如何解决你的痛点问题',
|
||||
'实用指南:产品高级功能详解',
|
||||
'权威评测:产品vs竞品全方位对比',
|
||||
'独家:产品未来发展方向预测',
|
||||
'创始人专访:产品诞生的故事',
|
||||
'用户案例:产品如何改变工作方式'
|
||||
];
|
||||
|
||||
const mockData: ContentPerformanceItem[] = [];
|
||||
let totalCoverage = 0;
|
||||
let totalInteractionRate = 0;
|
||||
|
||||
const randomDate = (start: Date, end: Date) => {
|
||||
return new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime()));
|
||||
};
|
||||
|
||||
const endDate = new Date();
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - 90);
|
||||
|
||||
for (let i = 0; i < limit; i++) {
|
||||
// 生成随机数据
|
||||
const coverage = Math.floor(Math.random() * 50000) + 1000;
|
||||
const likes = Math.floor(Math.random() * coverage * 0.2);
|
||||
const comments = Math.floor(Math.random() * coverage * 0.05);
|
||||
const shares = Math.floor(Math.random() * coverage * 0.03);
|
||||
const interactionCount = likes + comments + shares;
|
||||
const interactionRate = interactionCount / coverage;
|
||||
|
||||
totalCoverage += coverage;
|
||||
totalInteractionRate += interactionRate;
|
||||
|
||||
mockData.push({
|
||||
content_id: `content_${i + 1}`,
|
||||
title: titles[i % titles.length],
|
||||
platform: platforms[Math.floor(Math.random() * platforms.length)],
|
||||
content_type: contentTypes[Math.floor(Math.random() * contentTypes.length)],
|
||||
influencer_name: `KOL ${i % 10 + 1}`,
|
||||
publish_date: randomDate(startDate, endDate).toISOString(),
|
||||
coverage,
|
||||
interaction_rate: interactionRate,
|
||||
interaction_count: interactionCount,
|
||||
likes,
|
||||
comments,
|
||||
shares,
|
||||
quadrant: 'low_performance' // 临时值,稍后更新
|
||||
});
|
||||
}
|
||||
|
||||
// 计算平均值
|
||||
const averageCoverage = mockData.length > 0 ? totalCoverage / mockData.length : 0;
|
||||
const averageInteractionRate = mockData.length > 0 ? totalInteractionRate / mockData.length : 0;
|
||||
|
||||
// 根据平均值确定每个内容的四象限分类
|
||||
const quadrantCounts = {
|
||||
high_value: 0,
|
||||
high_coverage: 0,
|
||||
high_engagement: 0,
|
||||
low_performance: 0
|
||||
};
|
||||
|
||||
mockData.forEach(item => {
|
||||
if (item.coverage > averageCoverage && item.interaction_rate > averageInteractionRate) {
|
||||
// 高覆盖 + 高互动率 = 高价值
|
||||
item.quadrant = 'high_value';
|
||||
quadrantCounts.high_value++;
|
||||
} else if (item.coverage > averageCoverage) {
|
||||
// 高覆盖 + 低互动率 = 高覆盖
|
||||
item.quadrant = 'high_coverage';
|
||||
quadrantCounts.high_coverage++;
|
||||
} else if (item.interaction_rate > averageInteractionRate) {
|
||||
// 低覆盖 + 高互动率 = 高互动
|
||||
item.quadrant = 'high_engagement';
|
||||
quadrantCounts.high_engagement++;
|
||||
} else {
|
||||
// 低覆盖 + 低互动率 = 低表现
|
||||
item.quadrant = 'low_performance';
|
||||
quadrantCounts.low_performance++;
|
||||
}
|
||||
});
|
||||
|
||||
// 构建返回结果
|
||||
return {
|
||||
data: mockData,
|
||||
metadata: {
|
||||
total: mockData.length,
|
||||
average_coverage: parseFloat(averageCoverage.toFixed(2)),
|
||||
average_interaction_rate: parseFloat(averageInteractionRate.toFixed(4)),
|
||||
quadrant_counts: quadrantCounts
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
|
||||
@@ -2923,14 +2923,14 @@ export const openAPISpec = {
|
||||
},
|
||||
'/api/analytics/popular-posts': {
|
||||
get: {
|
||||
summary: '获取热门文章数据',
|
||||
description: '返回按互动数量或互动率排序的热门帖文列表',
|
||||
summary: '获取热门帖文数据',
|
||||
description: '返回按互动数量或互动率排序的热门帖文',
|
||||
tags: ['Analytics'],
|
||||
parameters: [
|
||||
{
|
||||
name: 'timeRange',
|
||||
in: 'query',
|
||||
description: '时间范围(天)',
|
||||
description: '时间范围(天)',
|
||||
schema: {
|
||||
type: 'string',
|
||||
enum: ['7', '30', '90'],
|
||||
@@ -2940,7 +2940,7 @@ export const openAPISpec = {
|
||||
{
|
||||
name: 'projectId',
|
||||
in: 'query',
|
||||
description: '项目ID过滤',
|
||||
description: '项目ID',
|
||||
schema: {
|
||||
type: 'string'
|
||||
}
|
||||
@@ -2948,10 +2948,9 @@ export const openAPISpec = {
|
||||
{
|
||||
name: 'platform',
|
||||
in: 'query',
|
||||
description: '平台过滤',
|
||||
description: '平台',
|
||||
schema: {
|
||||
type: 'string',
|
||||
enum: ['Twitter', 'Instagram', 'TikTok', 'Facebook', 'YouTube']
|
||||
type: 'string'
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -2967,7 +2966,7 @@ export const openAPISpec = {
|
||||
{
|
||||
name: 'limit',
|
||||
in: 'query',
|
||||
description: '返回数量',
|
||||
description: '返回数量限制',
|
||||
schema: {
|
||||
type: 'integer',
|
||||
default: 10,
|
||||
@@ -2977,36 +2976,58 @@ export const openAPISpec = {
|
||||
],
|
||||
responses: {
|
||||
'200': {
|
||||
description: '成功响应',
|
||||
description: '热门帖文数据',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', example: true },
|
||||
success: {
|
||||
type: 'boolean'
|
||||
},
|
||||
data: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
post_id: { type: 'string', example: 'post_123' },
|
||||
title: { type: 'string', example: '新产品发布' },
|
||||
platform: { type: 'string', example: 'Instagram' },
|
||||
influencer_id: { type: 'string', example: 'inf_456' },
|
||||
influencer_name: { type: 'string', example: '时尚KOL' },
|
||||
publish_date: { type: 'string', example: '2025-03-10 10:30:00' },
|
||||
engagement_count: { type: 'number', example: 2350 },
|
||||
views_count: { type: 'number', example: 15600 },
|
||||
engagement_rate: { type: 'number', example: 0.1506 },
|
||||
is_high_engagement: { type: 'boolean', example: true }
|
||||
title: {
|
||||
type: 'string'
|
||||
},
|
||||
platform: {
|
||||
type: 'string'
|
||||
},
|
||||
influencer_name: {
|
||||
type: 'string'
|
||||
},
|
||||
publish_date: {
|
||||
type: 'string',
|
||||
format: 'date-time'
|
||||
},
|
||||
engagement_count: {
|
||||
type: 'integer'
|
||||
},
|
||||
views_count: {
|
||||
type: 'integer'
|
||||
},
|
||||
engagement_rate: {
|
||||
type: 'number',
|
||||
format: 'float'
|
||||
},
|
||||
is_high_engagement: {
|
||||
type: 'boolean'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
metadata: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
total: { type: 'number', example: 45 },
|
||||
high_engagement_count: { type: 'number', example: 8 }
|
||||
total: {
|
||||
type: 'integer'
|
||||
},
|
||||
high_engagement_count: {
|
||||
type: 'integer'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3015,14 +3036,262 @@ export const openAPISpec = {
|
||||
}
|
||||
},
|
||||
'400': {
|
||||
description: '参数错误',
|
||||
description: '请求参数错误',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
$ref: '#/components/schemas/Error'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'500': {
|
||||
description: '服务器错误',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
$ref: '#/components/schemas/Error'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'/api/analytics/moderation-status': {
|
||||
get: {
|
||||
summary: '获取内容审核状态分布',
|
||||
description: '返回已批准、待审核和已拒绝内容的数量和比例',
|
||||
tags: ['Analytics'],
|
||||
parameters: [
|
||||
{
|
||||
name: 'timeRange',
|
||||
in: 'query',
|
||||
description: '时间范围(天)',
|
||||
schema: {
|
||||
type: 'string',
|
||||
enum: ['7', '30', '90'],
|
||||
default: '30'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'projectId',
|
||||
in: 'query',
|
||||
description: '项目ID',
|
||||
schema: {
|
||||
type: 'string'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'contentType',
|
||||
in: 'query',
|
||||
description: '内容类型',
|
||||
schema: {
|
||||
type: 'string',
|
||||
enum: ['post', 'comment', 'all'],
|
||||
default: 'all'
|
||||
}
|
||||
}
|
||||
],
|
||||
responses: {
|
||||
'200': {
|
||||
description: '审核状态分布数据',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', example: false },
|
||||
error: { type: 'string', example: 'Invalid sortBy. Must be engagement_count or engagement_rate.' }
|
||||
success: {
|
||||
type: 'boolean'
|
||||
},
|
||||
data: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
statuses: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
approved: {
|
||||
type: 'integer',
|
||||
description: '已批准内容数量'
|
||||
},
|
||||
pending: {
|
||||
type: 'integer',
|
||||
description: '待审核内容数量'
|
||||
},
|
||||
rejected: {
|
||||
type: 'integer',
|
||||
description: '已拒绝内容数量'
|
||||
}
|
||||
}
|
||||
},
|
||||
percentages: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
approved: {
|
||||
type: 'number',
|
||||
format: 'float',
|
||||
description: '已批准内容百分比'
|
||||
},
|
||||
pending: {
|
||||
type: 'number',
|
||||
format: 'float',
|
||||
description: '待审核内容百分比'
|
||||
},
|
||||
rejected: {
|
||||
type: 'number',
|
||||
format: 'float',
|
||||
description: '已拒绝内容百分比'
|
||||
}
|
||||
}
|
||||
},
|
||||
total: {
|
||||
type: 'integer',
|
||||
description: '内容总数'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'400': {
|
||||
description: '请求参数错误',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
$ref: '#/components/schemas/Error'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'500': {
|
||||
description: '服务器错误',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
$ref: '#/components/schemas/Error'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'/api/analytics/hot-keywords': {
|
||||
get: {
|
||||
summary: '获取热门关键词',
|
||||
description: '返回按出现频率排序的热门关键词列表,包含关键词、出现次数、占比和情感分数',
|
||||
tags: ['Analytics'],
|
||||
parameters: [
|
||||
{
|
||||
name: 'timeRange',
|
||||
in: 'query',
|
||||
description: '时间范围(天)',
|
||||
schema: {
|
||||
type: 'integer',
|
||||
enum: [7, 30, 90],
|
||||
default: 30
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'projectId',
|
||||
in: 'query',
|
||||
description: '项目ID(可选)',
|
||||
schema: {
|
||||
type: 'string'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'platform',
|
||||
in: 'query',
|
||||
description: '平台(可选)',
|
||||
schema: {
|
||||
type: 'string',
|
||||
enum: ['weibo', 'xiaohongshu', 'douyin', 'bilibili']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'limit',
|
||||
in: 'query',
|
||||
description: '返回关键词数量上限',
|
||||
schema: {
|
||||
type: 'integer',
|
||||
default: 20
|
||||
}
|
||||
}
|
||||
],
|
||||
responses: {
|
||||
'200': {
|
||||
description: '成功获取热门关键词',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: {
|
||||
type: 'boolean',
|
||||
example: true
|
||||
},
|
||||
data: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
keyword: {
|
||||
type: 'string',
|
||||
example: '质量'
|
||||
},
|
||||
count: {
|
||||
type: 'integer',
|
||||
example: 38
|
||||
},
|
||||
percentage: {
|
||||
type: 'number',
|
||||
format: 'float',
|
||||
example: 19.0
|
||||
},
|
||||
sentiment_score: {
|
||||
type: 'number',
|
||||
format: 'float',
|
||||
example: 0.7
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
metadata: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
total: {
|
||||
type: 'integer',
|
||||
example: 100
|
||||
},
|
||||
is_mock_data: {
|
||||
type: 'boolean',
|
||||
example: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'400': {
|
||||
description: '请求参数错误',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: {
|
||||
type: 'boolean',
|
||||
example: false
|
||||
},
|
||||
error: {
|
||||
type: 'string',
|
||||
example: 'Invalid timeRange. Must be 7, 30, or 90.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3035,9 +3304,13 @@ export const openAPISpec = {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', example: false },
|
||||
error: { type: 'string', example: 'Failed to fetch popular posts data' },
|
||||
message: { type: 'string', example: 'Internal server error' }
|
||||
success: {
|
||||
type: 'boolean',
|
||||
example: false
|
||||
},
|
||||
error: {
|
||||
type: 'string',
|
||||
example: 'Failed to fetch hot keywords data'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3045,6 +3318,350 @@ export const openAPISpec = {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'/api/analytics/interaction-time': {
|
||||
get: {
|
||||
summary: '获取用户互动时间分析',
|
||||
description: '返回按小时统计的用户互动数量和分布,帮助了解用户最活跃的时间段',
|
||||
tags: ['Analytics'],
|
||||
parameters: [
|
||||
{
|
||||
name: 'timeRange',
|
||||
in: 'query',
|
||||
description: '时间范围(天)',
|
||||
schema: {
|
||||
type: 'integer',
|
||||
enum: [7, 30, 90],
|
||||
default: 30
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'projectId',
|
||||
in: 'query',
|
||||
description: '项目ID(可选)',
|
||||
schema: {
|
||||
type: 'string'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'platform',
|
||||
in: 'query',
|
||||
description: '平台(可选)',
|
||||
schema: {
|
||||
type: 'string',
|
||||
enum: ['weibo', 'xiaohongshu', 'douyin', 'bilibili']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'eventType',
|
||||
in: 'query',
|
||||
description: '互动事件类型',
|
||||
schema: {
|
||||
type: 'string',
|
||||
enum: ['all', 'comment', 'like', 'view', 'share', 'follow'],
|
||||
default: 'all'
|
||||
}
|
||||
}
|
||||
],
|
||||
responses: {
|
||||
'200': {
|
||||
description: '成功获取互动时间分析数据',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: {
|
||||
type: 'boolean',
|
||||
example: true
|
||||
},
|
||||
data: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
hour: {
|
||||
type: 'integer',
|
||||
example: 20
|
||||
},
|
||||
count: {
|
||||
type: 'integer',
|
||||
example: 256
|
||||
},
|
||||
percentage: {
|
||||
type: 'number',
|
||||
format: 'float',
|
||||
example: 15.2
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
metadata: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
total: {
|
||||
type: 'integer',
|
||||
example: 1680
|
||||
},
|
||||
peak_hour: {
|
||||
type: 'integer',
|
||||
example: 20
|
||||
},
|
||||
lowest_hour: {
|
||||
type: 'integer',
|
||||
example: 4
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'400': {
|
||||
description: '请求参数错误',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: {
|
||||
type: 'boolean',
|
||||
example: false
|
||||
},
|
||||
error: {
|
||||
type: 'string',
|
||||
example: 'Invalid timeRange. Must be 7, 30, or 90.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'500': {
|
||||
description: '服务器错误',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: {
|
||||
type: 'boolean',
|
||||
example: false
|
||||
},
|
||||
error: {
|
||||
type: 'string',
|
||||
example: 'Failed to fetch interaction time analysis data'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'/api/analytics/content-performance': {
|
||||
get: {
|
||||
summary: '获取内容表现分析数据',
|
||||
description: '提供内容覆盖量、互动率、互动量等散点图数据,用于四象限分析',
|
||||
tags: ['Analytics'],
|
||||
parameters: [
|
||||
{
|
||||
name: 'timeRange',
|
||||
in: 'query',
|
||||
description: '时间范围(天)',
|
||||
schema: {
|
||||
type: 'string',
|
||||
enum: ['7', '30', '90'],
|
||||
default: '30'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'projectId',
|
||||
in: 'query',
|
||||
description: '项目ID',
|
||||
schema: {
|
||||
type: 'string'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'platform',
|
||||
in: 'query',
|
||||
description: '平台',
|
||||
schema: {
|
||||
type: 'string'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'kolId',
|
||||
in: 'query',
|
||||
description: 'KOL ID',
|
||||
schema: {
|
||||
type: 'string'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'contentType',
|
||||
in: 'query',
|
||||
description: '内容类型',
|
||||
schema: {
|
||||
type: 'string',
|
||||
enum: ['post', 'video', 'article', 'all'],
|
||||
default: 'all'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'limit',
|
||||
in: 'query',
|
||||
description: '最大返回条数',
|
||||
schema: {
|
||||
type: 'integer',
|
||||
default: 100
|
||||
}
|
||||
}
|
||||
],
|
||||
responses: {
|
||||
'200': {
|
||||
description: '内容表现分析数据',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: {
|
||||
type: 'boolean'
|
||||
},
|
||||
data: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
content_id: {
|
||||
type: 'string',
|
||||
description: '内容ID'
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
description: '内容标题'
|
||||
},
|
||||
platform: {
|
||||
type: 'string',
|
||||
description: '平台'
|
||||
},
|
||||
content_type: {
|
||||
type: 'string',
|
||||
description: '内容类型'
|
||||
},
|
||||
influencer_name: {
|
||||
type: 'string',
|
||||
description: 'KOL名称'
|
||||
},
|
||||
publish_date: {
|
||||
type: 'string',
|
||||
description: '发布日期'
|
||||
},
|
||||
coverage: {
|
||||
type: 'integer',
|
||||
description: '内容覆盖量(阅读量/浏览量)'
|
||||
},
|
||||
interaction_rate: {
|
||||
type: 'number',
|
||||
format: 'float',
|
||||
description: '互动率(互动总数/覆盖量)'
|
||||
},
|
||||
interaction_count: {
|
||||
type: 'integer',
|
||||
description: '互动总量(点赞+评论+分享)'
|
||||
},
|
||||
likes: {
|
||||
type: 'integer',
|
||||
description: '点赞数'
|
||||
},
|
||||
comments: {
|
||||
type: 'integer',
|
||||
description: '评论数'
|
||||
},
|
||||
shares: {
|
||||
type: 'integer',
|
||||
description: '分享数'
|
||||
},
|
||||
quadrant: {
|
||||
type: 'string',
|
||||
enum: ['high_value', 'high_coverage', 'high_engagement', 'low_performance'],
|
||||
description: '四象限分类'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
metadata: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
total: {
|
||||
type: 'integer',
|
||||
description: '内容总数'
|
||||
},
|
||||
average_coverage: {
|
||||
type: 'number',
|
||||
format: 'float',
|
||||
description: '平均覆盖量'
|
||||
},
|
||||
average_interaction_rate: {
|
||||
type: 'number',
|
||||
format: 'float',
|
||||
description: '平均互动率'
|
||||
},
|
||||
quadrant_counts: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
high_value: {
|
||||
type: 'integer',
|
||||
description: '高价值内容数'
|
||||
},
|
||||
high_coverage: {
|
||||
type: 'integer',
|
||||
description: '高覆盖内容数'
|
||||
},
|
||||
high_engagement: {
|
||||
type: 'integer',
|
||||
description: '高互动内容数'
|
||||
},
|
||||
low_performance: {
|
||||
type: 'integer',
|
||||
description: '低表现内容数'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'400': {
|
||||
description: '无效请求',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
$ref: '#/components/schemas/Error'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'500': {
|
||||
description: '服务器错误',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
$ref: '#/components/schemas/Error'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
components: {
|
||||
|
||||
@@ -193,6 +193,25 @@ interface CommentTrendResponse {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// 添加审核状态分布API响应接口
|
||||
interface ModerationStatusResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
statuses: {
|
||||
approved: number;
|
||||
pending: number;
|
||||
rejected: number;
|
||||
};
|
||||
percentages: {
|
||||
approved: number;
|
||||
pending: number;
|
||||
rejected: number;
|
||||
};
|
||||
total: number;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// 添加热门文章API响应接口
|
||||
interface PopularPostsResponse {
|
||||
success: boolean;
|
||||
@@ -213,6 +232,21 @@ interface PopularPostsResponse {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// 添加热门关键字API响应接口
|
||||
interface HotKeywordsResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
keyword: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
sentiment_score: number;
|
||||
}[];
|
||||
metadata: {
|
||||
total: number;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const Analytics: React.FC = () => {
|
||||
const [timeRange, setTimeRange] = useState('30'); // 修改默认值为'30'与API匹配
|
||||
const [selectedKOL, setSelectedKOL] = useState('all');
|
||||
@@ -238,6 +272,20 @@ const Analytics: React.FC = () => {
|
||||
const [trendError, setTrendError] = useState<string | null>(null);
|
||||
const [maxTimelineCount, setMaxTimelineCount] = useState(1); // 设置默认值为1避免除以零
|
||||
|
||||
// 添加审核状态分布相关状态
|
||||
const [moderationLoading, setModerationLoading] = useState(true);
|
||||
const [moderationError, setModerationError] = useState<string | null>(null);
|
||||
|
||||
// 添加热门关键字相关状态
|
||||
const [hotKeywords, setHotKeywords] = useState<{
|
||||
keyword: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
sentiment_score: number;
|
||||
}[]>([]);
|
||||
const [keywordsLoading, setKeywordsLoading] = useState(true);
|
||||
const [keywordsError, setKeywordsError] = useState<string | null>(null);
|
||||
|
||||
// 添加项目相关状态
|
||||
const [projects, setProjects] = useState<Project[]>([
|
||||
{ id: '1', name: '项目 1', description: '示例项目 1' },
|
||||
@@ -290,6 +338,102 @@ const Analytics: React.FC = () => {
|
||||
const [postsLoading, setPostsLoading] = useState(true);
|
||||
const [postsError, setPostsError] = useState<string | null>(null);
|
||||
|
||||
// 添加获取审核状态分布数据的函数
|
||||
const fetchModerationStatus = async () => {
|
||||
try {
|
||||
setModerationLoading(true);
|
||||
setModerationError(null);
|
||||
|
||||
// 构建API URL
|
||||
const url = `http://localhost:4000/api/analytics/moderation-status?timeRange=${timeRange}`;
|
||||
|
||||
// 添加项目过滤参数(如果选择了特定项目)
|
||||
const urlWithFilters = selectedProject !== 'all'
|
||||
? `${url}&projectId=${selectedProject}`
|
||||
: url;
|
||||
|
||||
// 添加内容类型
|
||||
const finalUrl = `${urlWithFilters}&contentType=all`;
|
||||
|
||||
console.log('请求审核状态分布数据URL:', finalUrl);
|
||||
|
||||
// 添加认证头
|
||||
const authToken = 'eyJhbGciOiJIUzI1NiIsImtpZCI6Inl3blNGYnRBOGtBUnl4UmUiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3h0cWhsdXpvcm5hemxta29udWNyLnN1cGFiYXNlLmNvL2F1dGgvdjEiLCJzdWIiOiI1YjQzMThiZi0yMWE4LTQ3YWMtOGJmYS0yYThmOGVmOWMwZmIiLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNzQxNjI3ODkyLCJpYXQiOjE3NDE2MjQyOTIsImVtYWlsIjoidml0YWxpdHltYWlsZ0BnbWFpbC5jb20iLCJwaG9uZSI6IiIsImFwcF9tZXRhZGF0YSI6eyJwcm92aWRlciI6ImVtYWlsIiwicHJvdmlkZXJzIjpbImVtYWlsIl19LCJ1c2VyX21ldGFkYXRhIjp7ImVtYWlsX3ZlcmlmaWVkIjp0cnVlfSwicm9sZSI6ImF1dGhlbnRpY2F0ZWQiLCJhYWwiOiJhYWwxIiwiYW1yIjpbeyJtZXRob2QiOiJwYXNzd29yZCIsInRpbWVzdGFtcCI6MTc0MTYyNDI5Mn1dLCJzZXNzaW9uX2lkIjoiODlmYjg0YzktZmEzYy00YmVlLTk0MDQtNjI1MjE0OGIyMzVlIiwiaXNfYW5vbnltb3VzIjpmYWxzZX0.VuUX2yhqN-FZseKL8fQG91i1cohfRqW2m1Z8CIWhZuk';
|
||||
|
||||
const response = await fetch(finalUrl, {
|
||||
headers: {
|
||||
'accept': 'application/json',
|
||||
'Authorization': `Bearer ${authToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json() as ModerationStatusResponse;
|
||||
console.log('成功获取审核状态分布数据:', result);
|
||||
|
||||
if (result.success) {
|
||||
// 将API返回的数据映射到AnalyticsData结构
|
||||
const mappedData: AnalyticsData[] = [
|
||||
{
|
||||
name: 'approved',
|
||||
value: result.data.statuses.approved,
|
||||
color: '#10B981',
|
||||
percentage: result.data.percentages.approved
|
||||
},
|
||||
{
|
||||
name: 'pending',
|
||||
value: result.data.statuses.pending,
|
||||
color: '#F59E0B',
|
||||
percentage: result.data.percentages.pending
|
||||
},
|
||||
{
|
||||
name: 'rejected',
|
||||
value: result.data.statuses.rejected,
|
||||
color: '#EF4444',
|
||||
percentage: result.data.percentages.rejected
|
||||
},
|
||||
];
|
||||
|
||||
// 更新状态
|
||||
setStatusData(mappedData);
|
||||
} else {
|
||||
setModerationError(result.error || '获取审核状态分布数据失败');
|
||||
console.error('API调用失败:', result.error || '未知错误');
|
||||
|
||||
// 设置默认数据
|
||||
setStatusData([
|
||||
{ name: 'approved', value: 45, color: '#10B981', percentage: 45 },
|
||||
{ name: 'pending', value: 30, color: '#F59E0B', percentage: 30 },
|
||||
{ name: 'rejected', value: 25, color: '#EF4444', percentage: 25 }
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
setModerationError(`获取失败 (${response.status}): ${errorText}`);
|
||||
console.error('获取审核状态分布数据失败,HTTP状态:', response.status, errorText);
|
||||
|
||||
// 设置默认数据
|
||||
setStatusData([
|
||||
{ name: 'approved', value: 45, color: '#10B981', percentage: 45 },
|
||||
{ name: 'pending', value: 30, color: '#F59E0B', percentage: 30 },
|
||||
{ name: 'rejected', value: 25, color: '#EF4444', percentage: 25 }
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
setModerationError(`获取审核状态分布数据时发生错误: ${error instanceof Error ? error.message : String(error)}`);
|
||||
console.error('获取审核状态分布数据时发生错误:', error);
|
||||
|
||||
// 设置默认数据
|
||||
setStatusData([
|
||||
{ name: 'approved', value: 45, color: '#10B981', percentage: 45 },
|
||||
{ name: 'pending', value: 30, color: '#F59E0B', percentage: 30 },
|
||||
{ name: 'rejected', value: 25, color: '#EF4444', percentage: 25 }
|
||||
]);
|
||||
} finally {
|
||||
setModerationLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取KOL概览数据
|
||||
const fetchKolOverviewData = async () => {
|
||||
setKolLoading(true);
|
||||
@@ -380,17 +524,18 @@ const Analytics: React.FC = () => {
|
||||
// 获取热门文章数据
|
||||
fetchPopularPosts();
|
||||
|
||||
// 获取审核状态分布数据
|
||||
fetchModerationStatus();
|
||||
|
||||
// 获取热门关键字数据
|
||||
fetchHotKeywords();
|
||||
|
||||
const fetchAnalyticsData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Set mock status data
|
||||
setStatusData([
|
||||
{ name: 'approved', value: 45, color: '#10B981' },
|
||||
{ name: 'pending', value: 30, color: '#F59E0B' },
|
||||
{ name: 'rejected', value: 25, color: '#EF4444' }
|
||||
]);
|
||||
|
||||
// 不再使用模拟审核状态数据,通过API获取
|
||||
// 已移至 fetchModerationStatus 函数中
|
||||
|
||||
// 从API获取漏斗数据
|
||||
try {
|
||||
@@ -888,7 +1033,7 @@ const Analytics: React.FC = () => {
|
||||
<select
|
||||
id="project-select"
|
||||
value={selectedProject}
|
||||
onChange={(e) => setSelectedProject(e.target.value)}
|
||||
onChange={(e) => handleProjectChange(e.target.value)}
|
||||
className="block w-full py-2 pl-3 pr-10 text-base border-gray-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
>
|
||||
<option value="all">全部项目</option>
|
||||
@@ -1323,6 +1468,132 @@ const Analytics: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimeRangeChange = (range: string) => {
|
||||
setTimeRange(range);
|
||||
// 后续刷新数据
|
||||
fetchDashboardCards();
|
||||
fetchCommentTrend();
|
||||
fetchPlatformDistribution();
|
||||
fetchSentimentAnalysis();
|
||||
fetchPopularPosts();
|
||||
fetchModerationStatus(); // 添加刷新审核状态数据
|
||||
fetchHotKeywords(); // 添加刷新热门关键字数据
|
||||
};
|
||||
|
||||
// 项目选择变化处理函数
|
||||
const handleProjectChange = (projectId: string) => {
|
||||
setSelectedProject(projectId);
|
||||
// 刷新数据
|
||||
fetchDashboardCards();
|
||||
fetchCommentTrend();
|
||||
fetchPlatformDistribution();
|
||||
fetchSentimentAnalysis();
|
||||
fetchPopularPosts();
|
||||
fetchModerationStatus(); // 添加刷新审核状态数据
|
||||
fetchHotKeywords(); // 添加刷新热门关键字数据
|
||||
};
|
||||
|
||||
// 平台选择变化处理函数
|
||||
const handlePlatformChange = (platform: string) => {
|
||||
setSelectedPlatform(platform);
|
||||
// 刷新数据
|
||||
fetchDashboardCards();
|
||||
fetchCommentTrend();
|
||||
fetchPlatformDistribution();
|
||||
fetchSentimentAnalysis();
|
||||
fetchPopularPosts();
|
||||
fetchModerationStatus(); // 添加刷新审核状态数据
|
||||
fetchHotKeywords(); // 添加刷新热门关键字数据
|
||||
};
|
||||
|
||||
// 获取热门关键字数据
|
||||
const fetchHotKeywords = async () => {
|
||||
try {
|
||||
setKeywordsLoading(true);
|
||||
setKeywordsError(null);
|
||||
|
||||
// 构建API URL
|
||||
const url = `http://localhost:4000/api/analytics/hot-keywords?timeRange=${timeRange}`;
|
||||
|
||||
// 添加项目过滤参数(如果选择了特定项目)
|
||||
const urlWithFilters = selectedProject !== 'all'
|
||||
? `${url}&projectId=${selectedProject}`
|
||||
: url;
|
||||
|
||||
// 添加平台过滤参数(如果选择了特定平台)
|
||||
const finalUrl = selectedPlatform !== 'all'
|
||||
? `${urlWithFilters}&platform=${selectedPlatform}`
|
||||
: urlWithFilters;
|
||||
|
||||
console.log('请求热门关键字数据URL:', finalUrl);
|
||||
|
||||
// 添加认证头
|
||||
const authToken = 'eyJhbGciOiJIUzI1NiIsImtpZCI6Inl3blNGYnRBOGtBUnl4UmUiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3h0cWhsdXpvcm5hemxta29udWNyLnN1cGFiYXNlLmNvL2F1dGgvdjEiLCJzdWIiOiI1YjQzMThiZi0yMWE4LTQ3YWMtOGJmYS0yYThmOGVmOWMwZmIiLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNzQxNjI3ODkyLCJpYXQiOjE3NDE2MjQyOTIsImVtYWlsIjoidml0YWxpdHltYWlsZ0BnbWFpbC5jb20iLCJwaG9uZSI6IiIsImFwcF9tZXRhZGF0YSI6eyJwcm92aWRlciI6ImVtYWlsIiwicHJvdmlkZXJzIjpbImVtYWlsIl19LCJ1c2VyX21ldGFkYXRhIjp7ImVtYWlsX3ZlcmlmaWVkIjp0cnVlfSwicm9sZSI6ImF1dGhlbnRpY2F0ZWQiLCJhYWwiOiJhYWwxIiwiYW1yIjpbeyJtZXRob2QiOiJwYXNzd29yZCIsInRpbWVzdGFtcCI6MTc0MTYyNDI5Mn1dLCJzZXNzaW9uX2lkIjoiODlmYjg0YzktZmEzYy00YmVlLTk0MDQtNjI1MjE0OGIyMzVlIiwiaXNfYW5vbnltb3VzIjpmYWxzZX0.VuUX2yhqN-FZseKL8fQG91i1cohfRqW2m1Z8CIWhZuk';
|
||||
|
||||
const response = await fetch(finalUrl, {
|
||||
headers: {
|
||||
'accept': 'application/json',
|
||||
'Authorization': `Bearer ${authToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json() as HotKeywordsResponse;
|
||||
console.log('成功获取热门关键字数据:', result);
|
||||
|
||||
if (result.success) {
|
||||
// 将API返回的数据设置到状态
|
||||
setHotKeywords(result.data);
|
||||
} else {
|
||||
setKeywordsError(result.error || '获取热门关键字数据失败');
|
||||
console.error('API调用失败:', result.error || '未知错误');
|
||||
|
||||
// 设置默认数据
|
||||
setHotKeywords([
|
||||
{ keyword: '价格', count: 45, percentage: 22.5, sentiment_score: 0.2 },
|
||||
{ keyword: '质量', count: 38, percentage: 19.0, sentiment_score: 0.7 },
|
||||
{ keyword: '服务', count: 32, percentage: 16.0, sentiment_score: -0.3 },
|
||||
{ keyword: '快递', count: 28, percentage: 14.0, sentiment_score: 0.1 },
|
||||
{ keyword: '推荐', count: 24, percentage: 12.0, sentiment_score: 0.8 },
|
||||
{ keyword: '问题', count: 18, percentage: 9.0, sentiment_score: -0.6 },
|
||||
{ keyword: '体验', count: 15, percentage: 7.5, sentiment_score: 0.4 }
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
setKeywordsError(`获取失败 (${response.status}): ${errorText}`);
|
||||
console.error('获取热门关键字数据失败,HTTP状态:', response.status, errorText);
|
||||
|
||||
// 设置默认数据
|
||||
setHotKeywords([
|
||||
{ keyword: '价格', count: 45, percentage: 22.5, sentiment_score: 0.2 },
|
||||
{ keyword: '质量', count: 38, percentage: 19.0, sentiment_score: 0.7 },
|
||||
{ keyword: '服务', count: 32, percentage: 16.0, sentiment_score: -0.3 },
|
||||
{ keyword: '快递', count: 28, percentage: 14.0, sentiment_score: 0.1 },
|
||||
{ keyword: '推荐', count: 24, percentage: 12.0, sentiment_score: 0.8 },
|
||||
{ keyword: '问题', count: 18, percentage: 9.0, sentiment_score: -0.6 },
|
||||
{ keyword: '体验', count: 15, percentage: 7.5, sentiment_score: 0.4 }
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
setKeywordsError(`获取热门关键字数据时发生错误: ${error instanceof Error ? error.message : String(error)}`);
|
||||
console.error('获取热门关键字数据时发生错误:', error);
|
||||
|
||||
// 设置默认数据
|
||||
setHotKeywords([
|
||||
{ keyword: '价格', count: 45, percentage: 22.5, sentiment_score: 0.2 },
|
||||
{ keyword: '质量', count: 38, percentage: 19.0, sentiment_score: 0.7 },
|
||||
{ keyword: '服务', count: 32, percentage: 16.0, sentiment_score: -0.3 },
|
||||
{ keyword: '快递', count: 28, percentage: 14.0, sentiment_score: 0.1 },
|
||||
{ keyword: '推荐', count: 24, percentage: 12.0, sentiment_score: 0.8 },
|
||||
{ keyword: '问题', count: 18, percentage: 9.0, sentiment_score: -0.6 },
|
||||
{ keyword: '体验', count: 15, percentage: 7.5, sentiment_score: 0.4 }
|
||||
]);
|
||||
} finally {
|
||||
setKeywordsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="p-6">
|
||||
@@ -1356,7 +1627,7 @@ const Analytics: React.FC = () => {
|
||||
<select
|
||||
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
value={timeRange}
|
||||
onChange={(e) => setTimeRange(e.target.value)}
|
||||
onChange={(e) => handleTimeRangeChange(e.target.value)}
|
||||
>
|
||||
<option value="7">過去 7 天</option>
|
||||
<option value="30">過去 30 天</option>
|
||||
@@ -1377,7 +1648,7 @@ const Analytics: React.FC = () => {
|
||||
<select
|
||||
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
value={selectedPlatform}
|
||||
onChange={(e) => setSelectedPlatform(e.target.value)}
|
||||
onChange={(e) => handlePlatformChange(e.target.value)}
|
||||
>
|
||||
<option value="all">全部平台</option>
|
||||
<option value="facebook">Facebook</option>
|
||||
@@ -1977,14 +2248,31 @@ const Analytics: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* 审核状态分布 */}
|
||||
{/* <div className="p-6 bg-white rounded-lg shadow">
|
||||
<div className="p-6 bg-white rounded-lg shadow">
|
||||
<h3 className="mb-4 text-lg font-medium text-gray-800">审核状态分布</h3>
|
||||
{moderationLoading ? (
|
||||
<div className="flex flex-col items-center justify-center h-64">
|
||||
<div className="w-12 h-12 border-4 border-blue-500 rounded-full border-t-transparent animate-spin"></div>
|
||||
<p className="mt-4 text-gray-600">加载审核状态数据中...</p>
|
||||
</div>
|
||||
) : moderationError ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 p-4 rounded-lg bg-red-50">
|
||||
<AlertTriangle className="w-10 h-10 mb-2 text-red-500" />
|
||||
<p className="text-center text-red-600">{moderationError}</p>
|
||||
</div>
|
||||
) : statusData.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 p-4 rounded-lg bg-gray-50">
|
||||
<AlertTriangle className="w-10 h-10 mb-2 text-gray-400" />
|
||||
<p className="text-center text-gray-600">没有找到审核状态数据</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="relative w-48 h-48 rounded-full">
|
||||
{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;
|
||||
const startAngle = index === 0 ? 0 : statusData.slice(0, index).reduce((sum, i) => sum + (i.percentage || 0), 0) * 3.6;
|
||||
const endAngle = startAngle + (item.percentage || 0) * 3.6;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -2011,12 +2299,14 @@ const Analytics: React.FC = () => {
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span className="mr-2 text-sm text-gray-500">{item.value} 则留言</span>
|
||||
<span className="text-sm font-medium text-gray-700">{item.percentage}%</span>
|
||||
<span className="text-sm font-medium text-gray-700">{item.percentage?.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div> */}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 情感分析详情 */}
|
||||
@@ -2061,7 +2351,7 @@ const Analytics: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* 热门文章 */}
|
||||
<div className="p-6 mt-8 bg-white rounded-lg shadow">
|
||||
<div className="p-6 mt-8 bg-white rounded-lg shadow" style={{marginTop: '20px'}}>
|
||||
<h3 className="mb-4 text-lg font-medium text-gray-800">热门文章</h3>
|
||||
{postsLoading ? (
|
||||
<div className="flex flex-col items-center justify-center h-64">
|
||||
@@ -2111,6 +2401,57 @@ const Analytics: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 热门关键字 */}
|
||||
<div className="p-6 mb-8 mt-5 bg-white rounded-lg shadow">
|
||||
<h3 className="mb-4 text-lg font-medium text-gray-800">热门关键字</h3>
|
||||
{keywordsLoading ? (
|
||||
<div className="flex items-center justify-center h-48">
|
||||
<div className="w-10 h-10 border-4 border-blue-500 rounded-full border-t-transparent animate-spin"></div>
|
||||
</div>
|
||||
) : keywordsError ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 p-4 rounded-lg bg-red-50">
|
||||
<AlertTriangle className="w-8 h-8 mb-2 text-red-500" />
|
||||
<p className="text-center text-red-600">{keywordsError}</p>
|
||||
</div>
|
||||
) : hotKeywords.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 p-4 rounded-lg bg-gray-50">
|
||||
<MessageSquare className="w-8 h-8 mb-2 text-gray-400" />
|
||||
<p className="text-center text-gray-600">暂无热门关键字数据</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{hotKeywords.slice(0, 10).map((keyword, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
<div className="w-1/2">
|
||||
<div className="flex items-center">
|
||||
<span className="w-8 h-8 mr-2 flex items-center justify-center rounded-full bg-blue-100 text-blue-600 text-sm font-medium">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="font-medium text-gray-700">{keyword.keyword}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-1/2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="text-sm font-medium text-gray-500">
|
||||
{keyword.count}次 ({keyword.percentage.toFixed(1)}%)
|
||||
</div>
|
||||
<div className={`text-sm font-medium ${keyword.sentiment_score > 0 ? 'text-green-500' : keyword.sentiment_score < 0 ? 'text-red-500' : 'text-gray-500'}`}>
|
||||
情感: {keyword.sentiment_score.toFixed(1)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${keyword.sentiment_score > 0.3 ? 'bg-green-500' : keyword.sentiment_score < -0.3 ? 'bg-red-500' : 'bg-yellow-500'}`}
|
||||
style={{ width: `${keyword.percentage}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user