问题描述: - 用户发表评论时,乐观更新显示用户名 "zdl" - 但 API 返回后,用户名变成 "Anonymous" - 刷新页面后,用户名仍然是 "Anonymous" 根本原因: - 前端代码期望评论对象包含 `author` 字段(src/types/comment.ts) - 后端 API 返回的是 `user` 字段(app.py:7710-7714) - 前端渲染时读取 comment.author?.username(CommentItem.js:72) - 因为 comment.author 不存在,所以显示 'Anonymous' 修复方案: - 在 eventService.getPosts 中添加数据转换逻辑 - 将后端返回的 user 字段映射为前端期望的 author 字段 - 兼容 avatar_url 和 avatar 两种字段名 - 处理 user 为 null 的边界情况(显示 Anonymous) 影响范围: - src/services/eventService.js - getPosts 函数数据转换 - 所有使用 getPosts API 的组件都会受益于此修复 - 保持类型定义不变,符合业务语义 修复效果: - 乐观更新显示:zdl 刚刚 打卡 - API 返回后显示:zdl 刚刚 打卡 ✅(之前会变成 Anonymous) - 刷新页面后显示:zdl XX分钟前 打卡 ✅ 🤖 Generated with Claude Code
415 lines
14 KiB
JavaScript
Executable File
415 lines
14 KiB
JavaScript
Executable File
// src/services/eventService.js
|
|
|
|
import { logger } from '../utils/logger';
|
|
|
|
const apiRequest = async (url, options = {}) => {
|
|
const method = options.method || 'GET';
|
|
const requestData = options.body ? JSON.parse(options.body) : null;
|
|
|
|
logger.api.request(method, url, requestData);
|
|
|
|
try {
|
|
const response = await fetch(url, {
|
|
...options,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...options.headers,
|
|
},
|
|
credentials: 'include',
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
const error = new Error(`HTTP error! status: ${response.status}`);
|
|
logger.api.error(method, url, error, { errorText, ...requestData });
|
|
throw error;
|
|
}
|
|
|
|
const data = await response.json();
|
|
logger.api.response(method, url, response.status, data);
|
|
|
|
return data;
|
|
} catch (error) {
|
|
logger.api.error(method, url, error, requestData);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
export const eventService = {
|
|
getEvents: (params = {}) => {
|
|
// Filter out null, undefined, and empty strings (but keep 0 and false)
|
|
const cleanParams = Object.fromEntries(
|
|
Object.entries(params).filter(([_, v]) => v !== null && v !== undefined && v !== '')
|
|
);
|
|
const query = new URLSearchParams(cleanParams).toString();
|
|
return apiRequest(`/api/events?${query}`);
|
|
},
|
|
getHotEvents: (params = {}) => {
|
|
const query = new URLSearchParams(params).toString();
|
|
return apiRequest(`/api/events/hot?${query}`);
|
|
},
|
|
getPopularKeywords: (limit = 20) => {
|
|
return apiRequest(`/api/events/keywords/popular?limit=${limit}`);
|
|
},
|
|
calendar: {
|
|
/**
|
|
* Fetches event counts for a given month, for display on the calendar grid.
|
|
* @param {number} year - The year to fetch counts for.
|
|
* @param {number} month - The month to fetch counts for (1-12).
|
|
* @returns {Promise<object>} A list of objects with date, count, and className.
|
|
*/
|
|
getEventCounts: (year, month) => {
|
|
// Note: The backend route is `/api/v1/calendar/event-counts`, not `/api/calendar-event-counts`
|
|
return apiRequest(`/api/v1/calendar/event-counts?year=${year}&month=${month}`);
|
|
},
|
|
|
|
/**
|
|
* Fetches a list of all events for a specific date.
|
|
* @param {string} date - The date in 'YYYY-MM-DD' format.
|
|
* @param {string} [type='all'] - The type of events to fetch ('event', 'data', or 'all').
|
|
* @returns {Promise<object>} A list of event objects for the given date.
|
|
*/
|
|
getEventsForDate: (date, type = 'all') => {
|
|
return apiRequest(`/api/v1/calendar/events?date=${date}&type=${type}`);
|
|
},
|
|
|
|
/**
|
|
* Fetches the full details of a single calendar event.
|
|
* @param {number} eventId - The ID of the calendar event.
|
|
* @returns {Promise<object>} The detailed calendar event object.
|
|
*/
|
|
getEventDetail: (eventId) => {
|
|
return apiRequest(`/api/v1/calendar/events/${eventId}`);
|
|
},
|
|
|
|
/**
|
|
* 切换未来事件的关注状态
|
|
* @param {number} eventId - 未来事件ID
|
|
* @returns {Promise<object>} 返回关注状态
|
|
*/
|
|
toggleFollow: async (eventId) => {
|
|
return await apiRequest(`/api/v1/calendar/events/${eventId}/follow`, {
|
|
method: 'POST'
|
|
});
|
|
},
|
|
|
|
/**
|
|
* 获取用户关注的所有未来事件
|
|
* @returns {Promise<object>} 返回关注的未来事件列表
|
|
*/
|
|
getFollowingEvents: async () => {
|
|
return await apiRequest(`/api/account/future-events/following`);
|
|
},
|
|
},
|
|
getEventDetail: async (eventId) => {
|
|
return await apiRequest(`/api/events/${eventId}`);
|
|
},
|
|
|
|
getRelatedStocks: async (eventId) => {
|
|
return await apiRequest(`/api/events/${eventId}/stocks`);
|
|
},
|
|
|
|
addRelatedStock: async (eventId, stockData) => {
|
|
return await apiRequest(`/api/events/${eventId}/stocks`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(stockData),
|
|
});
|
|
},
|
|
|
|
deleteRelatedStock: async (stockId) => {
|
|
return await apiRequest(`/api/stocks/${stockId}`, {
|
|
method: 'DELETE',
|
|
});
|
|
},
|
|
|
|
getRelatedConcepts: async (eventId) => {
|
|
return await apiRequest(`/api/events/${eventId}/concepts`);
|
|
},
|
|
|
|
getHistoricalEvents: async (eventId) => {
|
|
return await apiRequest(`/api/events/${eventId}/historical`);
|
|
},
|
|
|
|
getExpectationScore: async (eventId) => {
|
|
return await apiRequest(`/api/events/${eventId}/expectation-score`);
|
|
},
|
|
|
|
getTransmissionChainAnalysis: async (eventId) => {
|
|
// This API is optimized: it filters isolated nodes and provides a clean data structure.
|
|
return await apiRequest(`/api/events/${eventId}/transmission`);
|
|
},
|
|
|
|
getChainNodeDetail: async (eventId, nodeId) => {
|
|
return await apiRequest(`/api/events/${eventId}/chain-node/${nodeId}`);
|
|
},
|
|
getSankeyData: async (eventId) => {
|
|
return await apiRequest(`/api/events/${eventId}/sankey-data`);
|
|
},
|
|
getSankeyNodeDetail: async (eventId, nodeInfo) => {
|
|
const params = new URLSearchParams({
|
|
node_name: nodeInfo.name,
|
|
node_type: nodeInfo.type,
|
|
});
|
|
if (nodeInfo.level !== undefined && nodeInfo.level !== null) {
|
|
params.append('node_level', nodeInfo.level);
|
|
}
|
|
const url = `/api/events/${eventId}/sankey-data?${params.toString()}`;
|
|
return await apiRequest(url);
|
|
},
|
|
toggleFollow: async (eventId, isFollowing) => {
|
|
return await apiRequest(`/api/events/${eventId}/follow`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ is_following: isFollowing }),
|
|
});
|
|
},
|
|
|
|
// 帖子相关API
|
|
getPosts: async (eventId, sortType = 'latest', page = 1, perPage = 20) => {
|
|
try {
|
|
const result = await apiRequest(`/api/events/${eventId}/posts?sort=${sortType}&page=${page}&per_page=${perPage}`);
|
|
|
|
// ⚡ 数据转换:将后端的 user 字段映射为前端期望的 author 字段
|
|
if (result.success && Array.isArray(result.data)) {
|
|
result.data = result.data.map(post => ({
|
|
...post,
|
|
author: post.user ? {
|
|
id: post.user.id,
|
|
username: post.user.username,
|
|
avatar: post.user.avatar_url || post.user.avatar // 兼容 avatar_url 和 avatar
|
|
} : {
|
|
id: 'anonymous',
|
|
username: 'Anonymous',
|
|
avatar: null
|
|
}
|
|
// 保留原始的 user 字段(如果其他地方需要)
|
|
// user: post.user
|
|
}));
|
|
}
|
|
|
|
return result;
|
|
} catch (error) {
|
|
logger.error('eventService', 'getPosts', error, { eventId, sortType, page });
|
|
return { success: false, data: [], pagination: {} };
|
|
}
|
|
},
|
|
|
|
createPost: async (eventId, postData) => {
|
|
try {
|
|
return await apiRequest(`/api/events/${eventId}/posts`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(postData)
|
|
});
|
|
} catch (error) {
|
|
logger.error('eventService', 'createPost', error, { eventId });
|
|
return { success: false, message: '创建帖子失败' };
|
|
}
|
|
},
|
|
|
|
deletePost: async (postId) => {
|
|
try {
|
|
return await apiRequest(`/api/posts/${postId}`, {
|
|
method: 'DELETE'
|
|
});
|
|
} catch (error) {
|
|
logger.error('eventService', 'deletePost', error, { postId });
|
|
return { success: false, message: '删除帖子失败' };
|
|
}
|
|
},
|
|
|
|
likePost: async (postId) => {
|
|
try {
|
|
return await apiRequest(`/api/posts/${postId}/like`, {
|
|
method: 'POST'
|
|
});
|
|
} catch (error) {
|
|
logger.error('eventService', 'likePost', error, { postId });
|
|
return { success: false, message: '点赞失败' };
|
|
}
|
|
},
|
|
|
|
// 评论相关API
|
|
getPostComments: async (postId, sortType = 'latest') => {
|
|
try {
|
|
return await apiRequest(`/api/posts/${postId}/comments?sort=${sortType}`);
|
|
} catch (error) {
|
|
logger.error('eventService', 'getPostComments', error, { postId, sortType });
|
|
return { success: false, data: [] };
|
|
}
|
|
},
|
|
|
|
addPostComment: async (postId, commentData) => {
|
|
try {
|
|
return await apiRequest(`/api/posts/${postId}/comments`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(commentData)
|
|
});
|
|
} catch (error) {
|
|
logger.error('eventService', 'addPostComment', error, { postId });
|
|
return { success: false, message: '添加评论失败' };
|
|
}
|
|
},
|
|
|
|
deleteComment: async (commentId) => {
|
|
try {
|
|
return await apiRequest(`/api/comments/${commentId}`, {
|
|
method: 'DELETE'
|
|
});
|
|
} catch (error) {
|
|
logger.error('eventService', 'deleteComment', error, { commentId });
|
|
return { success: false, message: '删除评论失败' };
|
|
}
|
|
},
|
|
|
|
likeComment: async (commentId) => {
|
|
try {
|
|
return await apiRequest(`/api/comments/${commentId}/like`, {
|
|
method: 'POST'
|
|
});
|
|
} catch (error) {
|
|
logger.error('eventService', 'likeComment', error, { commentId });
|
|
return { success: false, message: '点赞失败' };
|
|
}
|
|
},
|
|
|
|
// 兼容旧版本的评论API
|
|
getComments: async (eventId, sortType = 'latest') => {
|
|
logger.warn('eventService', 'getComments 已废弃,请使用 getPosts');
|
|
try {
|
|
return await apiRequest(`/api/events/${eventId}/posts?sort=${sortType}&page=1&per_page=20`);
|
|
} catch (error) {
|
|
logger.error('eventService', 'getComments', error, { eventId, sortType });
|
|
return { success: false, data: [], pagination: {} };
|
|
}
|
|
},
|
|
|
|
addComment: async (eventId, commentData) => {
|
|
logger.warn('eventService', 'addComment 已废弃,请使用 createPost');
|
|
try {
|
|
return await apiRequest(`/api/events/${eventId}/posts`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(commentData)
|
|
});
|
|
} catch (error) {
|
|
logger.error('eventService', 'addComment', error, { eventId });
|
|
return { success: false, message: '创建帖子失败' };
|
|
}
|
|
},
|
|
};
|
|
|
|
export const stockService = {
|
|
getQuotes: async (codes, eventTime = null) => {
|
|
try {
|
|
const requestBody = {
|
|
codes: codes
|
|
};
|
|
|
|
if (eventTime) {
|
|
requestBody.event_time = eventTime;
|
|
}
|
|
|
|
logger.debug('stockService', '获取股票报价', requestBody);
|
|
|
|
const response = await apiRequest(`/api/stock/quotes`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(requestBody)
|
|
});
|
|
|
|
logger.debug('stockService', '股票报价响应', {
|
|
success: response.success,
|
|
dataKeys: response.data ? Object.keys(response.data) : []
|
|
});
|
|
|
|
if (response.success && response.data) {
|
|
return response.data;
|
|
} else {
|
|
logger.warn('stockService', '股票报价响应格式异常', response);
|
|
return {};
|
|
}
|
|
} catch (error) {
|
|
logger.error('stockService', 'getQuotes', error, { codes, eventTime });
|
|
throw error;
|
|
}
|
|
},
|
|
getForecastReport: async (stockCode) => {
|
|
return await apiRequest(`/api/stock/${stockCode}/forecast-report`);
|
|
},
|
|
|
|
getKlineData: async (stockCode, chartType = 'timeline', eventTime = null) => {
|
|
try {
|
|
const params = new URLSearchParams();
|
|
params.append('type', chartType);
|
|
if (eventTime) {
|
|
params.append('event_time', eventTime);
|
|
}
|
|
|
|
const url = `/api/stock/${stockCode}/kline?${params.toString()}`;
|
|
logger.debug('stockService', '获取K线数据', { stockCode, chartType, eventTime });
|
|
|
|
const response = await apiRequest(url);
|
|
|
|
if (response.error) {
|
|
logger.warn('stockService', 'K线数据响应包含错误', response.error);
|
|
return response;
|
|
}
|
|
|
|
return response;
|
|
} catch (error) {
|
|
logger.error('stockService', 'getKlineData', error, { stockCode, chartType });
|
|
throw error;
|
|
}
|
|
},
|
|
getTransmissionChainAnalysis: async (eventId) => {
|
|
return await apiRequest(`/api/events/${eventId}/transmission`);
|
|
},
|
|
async getSankeyNodeDetail(eventId, nodeInfo) {
|
|
try {
|
|
const params = new URLSearchParams({
|
|
node_name: nodeInfo.name,
|
|
node_type: nodeInfo.type,
|
|
node_level: nodeInfo.level
|
|
});
|
|
|
|
const url = `/api/events/${eventId}/sankey-node-detail?${params}`;
|
|
return await apiRequest(url);
|
|
} catch (error) {
|
|
logger.error('stockService', 'getSankeyNodeDetail', error, { eventId, nodeInfo });
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
getChainNodeDetail: async (eventId, nodeId) => {
|
|
return await apiRequest(`/api/events/${eventId}/transmission/node/${nodeId}`);
|
|
},
|
|
|
|
getHistoricalEventStocks: async (eventId) => {
|
|
return await apiRequest(`/api/historical-events/${eventId}/stocks`);
|
|
},
|
|
|
|
};
|
|
|
|
export const indexService = {
|
|
getKlineData: async (indexCode, chartType = 'timeline', eventTime = null) => {
|
|
try {
|
|
const params = new URLSearchParams();
|
|
params.append('type', chartType);
|
|
if (eventTime) {
|
|
params.append('event_time', eventTime);
|
|
}
|
|
|
|
const url = `/api/index/${indexCode}/kline?${params.toString()}`;
|
|
logger.debug('indexService', '获取指数K线数据', { indexCode, chartType, eventTime });
|
|
const response = await apiRequest(url);
|
|
return response;
|
|
} catch (error) {
|
|
logger.error('indexService', 'getKlineData', error, { indexCode, chartType });
|
|
throw error;
|
|
}
|
|
},
|
|
};
|
|
|
|
export default {
|
|
eventService,
|
|
stockService,
|
|
indexService,
|
|
};
|