morte change
This commit is contained in:
@@ -56,7 +56,7 @@
|
||||
- 条形长度直观反映各平台占比
|
||||
- 帮助团队了解哪些平台效果更好
|
||||
|
||||
# 审核状态分布 [先不做]
|
||||
# 审核状态分布 [已实现]
|
||||
|
||||
- 环形图展示内容审核状态的分布情况
|
||||
- 包括三种状态:已核准、待审核、已拒绝
|
||||
|
||||
@@ -678,6 +678,82 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
|
||||
@@ -47,4 +47,7 @@ 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));
|
||||
|
||||
export default analyticsRouter;
|
||||
@@ -179,6 +179,34 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analytics service for KOL performance data
|
||||
*/
|
||||
@@ -1652,6 +1680,127 @@ 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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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,33 +3036,144 @@ export const openAPISpec = {
|
||||
}
|
||||
},
|
||||
'400': {
|
||||
description: '参数错误',
|
||||
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.' }
|
||||
}
|
||||
$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: 'Failed to fetch popular posts data' },
|
||||
message: { type: 'string', example: 'Internal server error' }
|
||||
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'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -238,6 +257,10 @@ 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 [projects, setProjects] = useState<Project[]>([
|
||||
{ id: '1', name: '项目 1', description: '示例项目 1' },
|
||||
@@ -290,6 +313,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 +499,15 @@ const Analytics: React.FC = () => {
|
||||
// 获取热门文章数据
|
||||
fetchPopularPosts();
|
||||
|
||||
// 获取审核状态分布数据
|
||||
fetchModerationStatus();
|
||||
|
||||
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 {
|
||||
@@ -436,15 +553,15 @@ const Analytics: React.FC = () => {
|
||||
// 如果API返回了模拟数据标志
|
||||
if (result.is_mock_data) {
|
||||
console.info('注意: 使用的是模拟漏斗数据');
|
||||
}
|
||||
} else {
|
||||
console.error('API返回的数据格式不正确:', result);
|
||||
// 使用模拟数据作为后备
|
||||
setFallbackFunnelData();
|
||||
}
|
||||
} else {
|
||||
console.error('API调用失败:', result.error || '未知错误');
|
||||
console.error('API返回的数据格式不正确:', result);
|
||||
// 使用模拟数据作为后备
|
||||
setFallbackFunnelData();
|
||||
}
|
||||
} else {
|
||||
console.error('API调用失败:', result.error || '未知错误');
|
||||
// 使用模拟数据作为后备
|
||||
setFallbackFunnelData();
|
||||
}
|
||||
} else {
|
||||
@@ -469,14 +586,14 @@ const Analytics: React.FC = () => {
|
||||
|
||||
// 添加辅助函数,设置后备漏斗数据
|
||||
const setFallbackFunnelData = () => {
|
||||
setFunnelData([
|
||||
{ stage: 'Awareness', count: 10000, rate: 100 },
|
||||
{ stage: 'Interest', count: 7500, rate: 75 },
|
||||
{ stage: 'Consideration', count: 5000, rate: 50 },
|
||||
{ stage: 'Intent', count: 3000, rate: 30 },
|
||||
{ stage: 'Evaluation', count: 2000, rate: 20 },
|
||||
{ stage: 'Purchase', count: 1000, rate: 10 }
|
||||
]);
|
||||
setFunnelData([
|
||||
{ stage: 'Awareness', count: 10000, rate: 100 },
|
||||
{ stage: 'Interest', count: 7500, rate: 75 },
|
||||
{ stage: 'Consideration', count: 5000, rate: 50 },
|
||||
{ stage: 'Intent', count: 3000, rate: 30 },
|
||||
{ stage: 'Evaluation', count: 2000, rate: 20 },
|
||||
{ stage: 'Purchase', count: 1000, rate: 10 }
|
||||
]);
|
||||
};
|
||||
|
||||
fetchAnalyticsData();
|
||||
@@ -562,12 +679,12 @@ const Analytics: React.FC = () => {
|
||||
}
|
||||
} else {
|
||||
console.error('API返回的数据格式不正确:', result);
|
||||
setFilteredEngagementData([]);
|
||||
setFilteredEngagementData([]);
|
||||
}
|
||||
} else {
|
||||
} else {
|
||||
console.error('API调用失败:', result.error || '未知错误');
|
||||
setFilteredEngagementData([]);
|
||||
}
|
||||
setFilteredEngagementData([]);
|
||||
}
|
||||
} else {
|
||||
console.error('获取贴文表现数据失败,HTTP状态:', response.status);
|
||||
const errorText = await response.text();
|
||||
@@ -888,7 +1005,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 +1440,41 @@ const Analytics: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimeRangeChange = (range: string) => {
|
||||
setTimeRange(range);
|
||||
// 后续刷新数据
|
||||
fetchDashboardCards();
|
||||
fetchCommentTrend();
|
||||
fetchPlatformDistribution();
|
||||
fetchSentimentAnalysis();
|
||||
fetchPopularPosts();
|
||||
fetchModerationStatus(); // 添加刷新审核状态数据
|
||||
};
|
||||
|
||||
// 项目选择变化处理函数
|
||||
const handleProjectChange = (projectId: string) => {
|
||||
setSelectedProject(projectId);
|
||||
// 刷新数据
|
||||
fetchDashboardCards();
|
||||
fetchCommentTrend();
|
||||
fetchPlatformDistribution();
|
||||
fetchSentimentAnalysis();
|
||||
fetchPopularPosts();
|
||||
fetchModerationStatus(); // 添加刷新审核状态数据
|
||||
};
|
||||
|
||||
// 平台选择变化处理函数
|
||||
const handlePlatformChange = (platform: string) => {
|
||||
setSelectedPlatform(platform);
|
||||
// 刷新数据
|
||||
fetchDashboardCards();
|
||||
fetchCommentTrend();
|
||||
fetchPlatformDistribution();
|
||||
fetchSentimentAnalysis();
|
||||
fetchPopularPosts();
|
||||
fetchModerationStatus(); // 添加刷新审核状态数据
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="p-6">
|
||||
@@ -1356,7 +1508,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 +1529,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>
|
||||
@@ -1425,7 +1577,7 @@ const Analytics: React.FC = () => {
|
||||
|
||||
<div className="flex items-center text-sm text-blue-600 cursor-pointer">
|
||||
<span className="mr-1">查看详细报告</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1434,12 +1586,12 @@ const Analytics: React.FC = () => {
|
||||
<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">加载KOL数据中...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : kolError ? (
|
||||
<div className="p-6 text-center border border-red-200 rounded-lg bg-red-50">
|
||||
<div className="mb-4 text-red-500">
|
||||
<AlertTriangle className="inline-block w-12 h-12" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-medium text-red-700">无法加载KOL数据</h3>
|
||||
<p className="text-red-600">{kolError}</p>
|
||||
<button
|
||||
@@ -1482,8 +1634,8 @@ const Analytics: React.FC = () => {
|
||||
{getPlatformIcon(kol.platform)}
|
||||
<span className="ml-1">{kol.platform}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KOL指标 */}
|
||||
<div className="space-y-3">
|
||||
@@ -1650,117 +1802,117 @@ const Analytics: React.FC = () => {
|
||||
<p className="text-gray-500">请尝试更改筛选条件或检查所选项目是否有贴文数据。</p>
|
||||
</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-xs font-medium tracking-wider text-left text-gray-500 uppercase">
|
||||
<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">
|
||||
KOL
|
||||
</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">
|
||||
KOL
|
||||
</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>
|
||||
<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">
|
||||
{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="flex-shrink-0 w-12 h-12 mr-3 overflow-hidden rounded">
|
||||
<img src={post.thumbnail} alt={post.title} className="object-cover w-full h-full" />
|
||||
</div>
|
||||
<div className="max-w-xs text-sm text-gray-900 truncate">{post.title}</div>
|
||||
</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="flex-shrink-0 w-12 h-12 mr-3 overflow-hidden rounded">
|
||||
<img src={post.thumbnail} alt={post.title} className="object-cover w-full h-full" />
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="w-8 h-8 mr-2 overflow-hidden rounded-full">
|
||||
<img
|
||||
<div className="max-w-xs text-sm text-gray-900 truncate">{post.title}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="w-8 h-8 mr-2 overflow-hidden rounded-full">
|
||||
<img
|
||||
src={kolData.find(k => k.influencer_id === post.kolId)?.avatar || ''}
|
||||
alt={kolData.find(k => k.influencer_id === post.kolId)?.name || ''}
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm text-gray-900">
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm text-gray-900">
|
||||
{kolData.find(k => k.influencer_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">
|
||||
</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 text-sm text-gray-500 whitespace-nowrap">
|
||||
{post.date}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center text-sm text-gray-900">
|
||||
<Eye className="w-4 h-4 mr-1 text-gray-500" />
|
||||
{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="w-4 h-4 mr-1 text-red-500" />
|
||||
{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="w-4 h-4 mr-1 text-blue-500" />
|
||||
{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="w-4 h-4 mr-1 text-green-500" />
|
||||
{post.shares.toLocaleString()}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500 whitespace-nowrap">
|
||||
{post.date}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center text-sm text-gray-900">
|
||||
<Eye className="w-4 h-4 mr-1 text-gray-500" />
|
||||
{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="w-4 h-4 mr-1 text-red-500" />
|
||||
{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="w-4 h-4 mr-1 text-blue-500" />
|
||||
{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="w-4 h-4 mr-1 text-green-500" />
|
||||
{post.shares.toLocaleString()}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="w-16 h-2 overflow-hidden bg-gray-200 rounded-full"
|
||||
>
|
||||
<div
|
||||
className="w-16 h-2 overflow-hidden bg-gray-200 rounded-full"
|
||||
>
|
||||
<div
|
||||
className={getSentimentColor(post.sentiment)}
|
||||
style={{ width: `${post.sentimentScore}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="ml-2 text-sm text-gray-900">{post.sentimentScore}%</span>
|
||||
className={getSentimentColor(post.sentiment)}
|
||||
style={{ width: `${post.sentimentScore}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<span className="ml-2 text-sm text-gray-900">{post.sentimentScore}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1780,10 +1932,10 @@ const Analytics: React.FC = () => {
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-2 text-3xl font-bold text-gray-900">{dashboardCards.commentsCount.current.toLocaleString()}</p>
|
||||
<div className="flex items-center text-sm">
|
||||
<div className="flex items-center text-sm">
|
||||
{dashboardCards.commentsCount.changePercentage > 0 ? (
|
||||
<>
|
||||
<TrendingUp className="w-4 h-4 mr-1 text-green-500" />
|
||||
<TrendingUp className="w-4 h-4 mr-1 text-green-500" />
|
||||
<span className="text-green-500">↑ {dashboardCards.commentsCount.changePercentage.toFixed(1)}% 较上周</span>
|
||||
</>
|
||||
) : (
|
||||
@@ -1792,7 +1944,7 @@ const Analytics: React.FC = () => {
|
||||
<span className="text-red-500">↓ {Math.abs(dashboardCards.commentsCount.changePercentage).toFixed(1)}% 较上周</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1811,10 +1963,10 @@ const Analytics: React.FC = () => {
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-2 text-3xl font-bold text-gray-900">{(dashboardCards.engagementRate.current / 100).toFixed(1)}%</p>
|
||||
<div className="flex items-center text-sm">
|
||||
<div className="flex items-center text-sm">
|
||||
{dashboardCards.engagementRate.changePercentage > 0 ? (
|
||||
<>
|
||||
<TrendingUp className="w-4 h-4 mr-1 text-green-500" />
|
||||
<TrendingUp className="w-4 h-4 mr-1 text-green-500" />
|
||||
<span className="text-green-500">↑ {dashboardCards.engagementRate.changePercentage.toFixed(1)}% 较上周</span>
|
||||
</>
|
||||
) : (
|
||||
@@ -1823,7 +1975,7 @@ const Analytics: React.FC = () => {
|
||||
<span className="text-red-500">↓ {Math.abs(dashboardCards.engagementRate.changePercentage).toFixed(1)}% 较上周</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1841,11 +1993,11 @@ const Analytics: React.FC = () => {
|
||||
<div className="text-sm text-red-500">{cardsError}</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-2 text-3xl font-bold text-gray-900">{sentimentData.positive}% 正面</p>
|
||||
<div className="flex items-center text-sm">
|
||||
<p className="mb-2 text-3xl font-bold text-gray-900">{sentimentData.positive}% 正面</p>
|
||||
<div className="flex items-center text-sm">
|
||||
{dashboardCards.sentimentScore.changePercentage > 0 ? (
|
||||
<>
|
||||
<TrendingUp className="w-4 h-4 mr-1 text-green-500" />
|
||||
<TrendingUp className="w-4 h-4 mr-1 text-green-500" />
|
||||
<span className="text-green-500">↑ {dashboardCards.sentimentScore.changePercentage.toFixed(1)}% 较上周</span>
|
||||
</>
|
||||
) : (
|
||||
@@ -1854,7 +2006,7 @@ const Analytics: React.FC = () => {
|
||||
<span className="text-red-500">↓ {Math.abs(dashboardCards.sentimentScore.changePercentage).toFixed(1)}% 较上周</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1889,7 +2041,7 @@ const Analytics: React.FC = () => {
|
||||
<div className="flex flex-col h-64">
|
||||
{/* 完全重构的柱状图实现 */}
|
||||
<div className="flex items-end justify-between w-full h-48">
|
||||
{timelineData.map((item, index) => (
|
||||
{timelineData.map((item, index) => (
|
||||
<div key={index} className="flex flex-col items-center w-full">
|
||||
{/* 留言数标签 */}
|
||||
<div className="mb-1 text-xs text-gray-500">{item.comments}</div>
|
||||
@@ -1897,28 +2049,28 @@ const Analytics: React.FC = () => {
|
||||
{/* 柱状图 - 使用绝对高度值而非百分比 */}
|
||||
<div
|
||||
className="relative w-4/5 transition-all duration-300 bg-blue-500 hover:bg-blue-600 rounded-t-md"
|
||||
style={{
|
||||
style={{
|
||||
height: item.comments === 0 ? '4px' : `${Math.max(8, Math.min(180, item.comments / 5))}px`
|
||||
}}
|
||||
>
|
||||
}}
|
||||
>
|
||||
{/* 悬停提示 */}
|
||||
<div className="absolute hidden px-2 py-1 mb-1 text-xs text-white transform -translate-x-1/2 bg-gray-800 rounded group-hover:block bottom-full left-1/2 whitespace-nowrap">
|
||||
{item.comments} 留言
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 日期标签 */}
|
||||
<div className="mt-2 text-xs text-center text-gray-500">
|
||||
{new Date(item.date).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 图表说明 */}
|
||||
<div className="mt-2 text-xs text-center text-gray-400">
|
||||
柱状图高度代表留言数量
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1943,84 +2095,103 @@ const Analytics: React.FC = () => {
|
||||
<p className="text-center text-gray-600">没有找到平台分布数据</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{platformData.map((item, index) => (
|
||||
<div key={index}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center">
|
||||
{getPlatformIcon(item.name)}
|
||||
<span className="ml-2 text-sm font-medium text-gray-700">
|
||||
<div className="space-y-4">
|
||||
{platformData.map((item, index) => (
|
||||
<div key={index}>
|
||||
<div className="flex items-center justify-between 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 === 'youtube' ? 'YouTube' :
|
||||
item.name === 'tiktok' ? 'TikTok' :
|
||||
item.name.charAt(0).toUpperCase() + item.name.slice(1)}
|
||||
</span>
|
||||
</span>
|
||||
</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?.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-gray-200 rounded-full">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-500 ease-in-out`}
|
||||
style={{
|
||||
width: `${item.percentage}%`,
|
||||
backgroundColor: item.color || getPlatformColorHex(item.name)
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 审核状态分布 */}
|
||||
<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), 0) * 3.6;
|
||||
const endAngle = startAngle + (item.percentage || 0) * 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="mr-2 text-sm text-gray-500">{item.value} 则留言</span>
|
||||
<span className="text-sm font-medium text-gray-700">{item.percentage?.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-gray-200 rounded-full">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-500 ease-in-out`}
|
||||
style={{
|
||||
width: `${item.percentage}%`,
|
||||
backgroundColor: item.color || getPlatformColorHex(item.name)
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 审核状态分布 */}
|
||||
{/* <div className="p-6 bg-white rounded-lg shadow">
|
||||
<h3 className="mb-4 text-lg font-medium text-gray-800">审核状态分布</h3>
|
||||
<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;
|
||||
|
||||
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="mr-2 text-sm text-gray-500">{item.value} 则留言</span>
|
||||
<span className="text-sm font-medium text-gray-700">{item.percentage}%</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div> */}
|
||||
</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>
|
||||
{sentimentLoading ? (
|
||||
<div className="flex flex-col items-center justify-center h-64">
|
||||
@@ -2034,34 +2205,34 @@ const Analytics: React.FC = () => {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="relative w-48 h-12 rounded-lg bg-gradient-to-r from-red-500 via-yellow-400 to-green-500">
|
||||
<div
|
||||
className="absolute top-0 w-1 h-full transform -translate-x-1/2 bg-black border-2 border-white rounded-full"
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="relative w-48 h-12 rounded-lg bg-gradient-to-r from-red-500 via-yellow-400 to-green-500">
|
||||
<div
|
||||
className="absolute top-0 w-1 h-full transform -translate-x-1/2 bg-black border-2 border-white rounded-full"
|
||||
style={{ left: `${sentimentScore * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<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.toFixed(1)}%</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500">中性</p>
|
||||
<p className="text-lg font-bold text-yellow-500">{sentimentData.neutral.toFixed(1)}%</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500">正面</p>
|
||||
<p className="text-lg font-bold text-green-500">{sentimentData.positive.toFixed(1)}%</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500">中性</p>
|
||||
<p className="text-lg font-bold text-yellow-500">{sentimentData.neutral.toFixed(1)}%</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500">正面</p>
|
||||
<p className="text-lg font-bold text-green-500">{sentimentData.positive.toFixed(1)}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</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">
|
||||
@@ -2090,25 +2261,25 @@ const Analytics: React.FC = () => {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center">
|
||||
<Eye className="w-3 h-3 mr-1 text-gray-400" />
|
||||
<span>{article.views.toLocaleString()} 浏览</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<MessageSquare className="w-3 h-3 mr-1 text-gray-400" />
|
||||
<span>{article.engagement.toLocaleString()} 互动</span>
|
||||
</div>
|
||||
</div>
|
||||
{article.isHighEngagement && (
|
||||
<div className="flex items-center">
|
||||
<div className="w-2 h-2 mr-1 bg-green-500 rounded-full"></div>
|
||||
<span>高互动</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user