add forum
This commit is contained in:
@@ -264,15 +264,20 @@ const MobileDrawer = memo(({
|
|||||||
</HStack>
|
</HStack>
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
|
onClick={() => handleNavigate('/value-forum')}
|
||||||
py={1}
|
py={1}
|
||||||
px={3}
|
px={3}
|
||||||
borderRadius="md"
|
borderRadius="md"
|
||||||
_hover={{}}
|
_hover={{ bg: 'gray.50' }}
|
||||||
cursor="not-allowed"
|
bg={location.pathname.includes('/value-forum') ? 'blue.50' : 'transparent'}
|
||||||
color="gray.400"
|
|
||||||
pointerEvents="none"
|
|
||||||
>
|
>
|
||||||
<Text fontSize="sm" color="gray.400">今日热议</Text>
|
<HStack justify="space-between">
|
||||||
|
<Text fontSize="sm">价值论坛</Text>
|
||||||
|
<HStack spacing={1}>
|
||||||
|
<Badge size="xs" colorScheme="yellow">黑金</Badge>
|
||||||
|
<Badge size="xs" colorScheme="red">NEW</Badge>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
py={1}
|
py={1}
|
||||||
|
|||||||
@@ -239,11 +239,23 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
|||||||
</Flex>
|
</Flex>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
isDisabled
|
onClick={() => {
|
||||||
cursor="not-allowed"
|
navEvents.trackMenuItemClicked('价值论坛', 'dropdown', '/value-forum');
|
||||||
color="gray.400"
|
navigate('/value-forum');
|
||||||
|
}}
|
||||||
|
borderRadius="md"
|
||||||
|
bg={location.pathname.includes('/value-forum') ? 'blue.50' : 'transparent'}
|
||||||
|
borderLeft={location.pathname.includes('/value-forum') ? '3px solid' : 'none'}
|
||||||
|
borderColor="blue.600"
|
||||||
|
fontWeight={location.pathname.includes('/value-forum') ? 'bold' : 'normal'}
|
||||||
>
|
>
|
||||||
<Text fontSize="sm" color="gray.400">今日热议</Text>
|
<Flex justify="space-between" align="center" w="100%">
|
||||||
|
<Text fontSize="sm">价值论坛</Text>
|
||||||
|
<HStack spacing={1}>
|
||||||
|
<Badge size="sm" colorScheme="yellow">黑金</Badge>
|
||||||
|
<Badge size="sm" colorScheme="red">NEW</Badge>
|
||||||
|
</HStack>
|
||||||
|
</Flex>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
isDisabled
|
isDisabled
|
||||||
|
|||||||
@@ -155,8 +155,21 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
|
|||||||
</HStack>
|
</HStack>
|
||||||
</Flex>
|
</Flex>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem isDisabled cursor="not-allowed" color="gray.400">
|
<MenuItem
|
||||||
<Text fontSize="sm" color="gray.400">今日热议</Text>
|
onClick={() => {
|
||||||
|
moreMenu.onClose(); // 先关闭菜单
|
||||||
|
navigate('/value-forum');
|
||||||
|
}}
|
||||||
|
borderRadius="md"
|
||||||
|
bg={location.pathname.includes('/value-forum') ? 'blue.50' : 'transparent'}
|
||||||
|
>
|
||||||
|
<Flex justify="space-between" align="center" w="100%">
|
||||||
|
<Text fontSize="sm">价值论坛</Text>
|
||||||
|
<HStack spacing={1}>
|
||||||
|
<Badge size="sm" colorScheme="yellow">黑金</Badge>
|
||||||
|
<Badge size="sm" colorScheme="red">NEW</Badge>
|
||||||
|
</HStack>
|
||||||
|
</Flex>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem isDisabled cursor="not-allowed" color="gray.400">
|
<MenuItem isDisabled cursor="not-allowed" color="gray.400">
|
||||||
<Text fontSize="sm" color="gray.400">个股社区</Text>
|
<Text fontSize="sm" color="gray.400">个股社区</Text>
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ export const lazyComponents = {
|
|||||||
|
|
||||||
// Agent模块
|
// Agent模块
|
||||||
AgentChat: React.lazy(() => import('../views/AgentChat')),
|
AgentChat: React.lazy(() => import('../views/AgentChat')),
|
||||||
|
|
||||||
|
// 价值论坛模块
|
||||||
|
ValueForum: React.lazy(() => import('../views/ValueForum')),
|
||||||
|
ForumPostDetail: React.lazy(() => import('../views/ValueForum/PostDetail')),
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,4 +67,6 @@ export const {
|
|||||||
FinancialPanorama,
|
FinancialPanorama,
|
||||||
MarketDataView,
|
MarketDataView,
|
||||||
AgentChat,
|
AgentChat,
|
||||||
|
ValueForum,
|
||||||
|
ForumPostDetail,
|
||||||
} = lazyComponents;
|
} = lazyComponents;
|
||||||
|
|||||||
@@ -150,6 +150,28 @@ export const routeConfig = [
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ==================== 价值论坛模块 ====================
|
||||||
|
{
|
||||||
|
path: 'value-forum',
|
||||||
|
component: lazyComponents.ValueForum,
|
||||||
|
protection: PROTECTION_MODES.MODAL,
|
||||||
|
layout: 'main',
|
||||||
|
meta: {
|
||||||
|
title: '价值论坛',
|
||||||
|
description: '投资者价值讨论社区'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'value-forum/post/:postId',
|
||||||
|
component: lazyComponents.ForumPostDetail,
|
||||||
|
protection: PROTECTION_MODES.MODAL,
|
||||||
|
layout: 'main',
|
||||||
|
meta: {
|
||||||
|
title: '帖子详情',
|
||||||
|
description: '论坛帖子详细内容'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// ==================== Agent模块 ====================
|
// ==================== Agent模块 ====================
|
||||||
{
|
{
|
||||||
path: 'agent-chat',
|
path: 'agent-chat',
|
||||||
|
|||||||
429
src/services/elasticsearchService.js
Normal file
429
src/services/elasticsearchService.js
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
/**
|
||||||
|
* Elasticsearch 服务层
|
||||||
|
* 用于价值论坛的帖子、评论存储和搜索
|
||||||
|
*/
|
||||||
|
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
// Elasticsearch 配置
|
||||||
|
const ES_CONFIG = {
|
||||||
|
baseURL: 'http://222.128.1.157:19200',
|
||||||
|
timeout: 10000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建 axios 实例
|
||||||
|
const esClient = axios.create(ES_CONFIG);
|
||||||
|
|
||||||
|
// 索引名称
|
||||||
|
const INDICES = {
|
||||||
|
POSTS: 'forum_posts',
|
||||||
|
COMMENTS: 'forum_comments',
|
||||||
|
EVENTS: 'forum_events',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化索引(创建索引和映射)
|
||||||
|
*/
|
||||||
|
export const initializeIndices = async () => {
|
||||||
|
try {
|
||||||
|
// 创建帖子索引
|
||||||
|
await esClient.put(`/${INDICES.POSTS}`, {
|
||||||
|
mappings: {
|
||||||
|
properties: {
|
||||||
|
id: { type: 'keyword' },
|
||||||
|
author_id: { type: 'keyword' },
|
||||||
|
author_name: { type: 'text' },
|
||||||
|
author_avatar: { type: 'keyword' },
|
||||||
|
title: { type: 'text', analyzer: 'ik_max_word' },
|
||||||
|
content: { type: 'text', analyzer: 'ik_max_word' },
|
||||||
|
images: { type: 'keyword' },
|
||||||
|
tags: { type: 'keyword' },
|
||||||
|
category: { type: 'keyword' },
|
||||||
|
likes_count: { type: 'integer' },
|
||||||
|
comments_count: { type: 'integer' },
|
||||||
|
views_count: { type: 'integer' },
|
||||||
|
created_at: { type: 'date' },
|
||||||
|
updated_at: { type: 'date' },
|
||||||
|
is_pinned: { type: 'boolean' },
|
||||||
|
status: { type: 'keyword' }, // active, deleted, hidden
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建评论索引
|
||||||
|
await esClient.put(`/${INDICES.COMMENTS}`, {
|
||||||
|
mappings: {
|
||||||
|
properties: {
|
||||||
|
id: { type: 'keyword' },
|
||||||
|
post_id: { type: 'keyword' },
|
||||||
|
author_id: { type: 'keyword' },
|
||||||
|
author_name: { type: 'text' },
|
||||||
|
author_avatar: { type: 'keyword' },
|
||||||
|
content: { type: 'text', analyzer: 'ik_max_word' },
|
||||||
|
parent_id: { type: 'keyword' }, // 用于嵌套评论
|
||||||
|
likes_count: { type: 'integer' },
|
||||||
|
created_at: { type: 'date' },
|
||||||
|
status: { type: 'keyword' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建事件时间轴索引
|
||||||
|
await esClient.put(`/${INDICES.EVENTS}`, {
|
||||||
|
mappings: {
|
||||||
|
properties: {
|
||||||
|
id: { type: 'keyword' },
|
||||||
|
post_id: { type: 'keyword' },
|
||||||
|
event_type: { type: 'keyword' }, // news, price_change, announcement, etc.
|
||||||
|
title: { type: 'text' },
|
||||||
|
description: { type: 'text', analyzer: 'ik_max_word' },
|
||||||
|
source: { type: 'keyword' },
|
||||||
|
source_url: { type: 'keyword' },
|
||||||
|
related_stocks: { type: 'keyword' },
|
||||||
|
occurred_at: { type: 'date' },
|
||||||
|
created_at: { type: 'date' },
|
||||||
|
importance: { type: 'keyword' }, // high, medium, low
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Elasticsearch 索引初始化成功');
|
||||||
|
} catch (error) {
|
||||||
|
if (error.response?.status === 400 && error.response?.data?.error?.type === 'resource_already_exists_exception') {
|
||||||
|
console.log('索引已存在,跳过创建');
|
||||||
|
} else {
|
||||||
|
console.error('初始化索引失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 帖子相关操作 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建新帖子
|
||||||
|
*/
|
||||||
|
export const createPost = async (postData) => {
|
||||||
|
try {
|
||||||
|
const post = {
|
||||||
|
id: `post_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
...postData,
|
||||||
|
likes_count: 0,
|
||||||
|
comments_count: 0,
|
||||||
|
views_count: 0,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
is_pinned: false,
|
||||||
|
status: 'active',
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await esClient.post(`/${INDICES.POSTS}/_doc/${post.id}`, post);
|
||||||
|
return { ...post, _id: response.data._id };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建帖子失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取帖子列表(支持分页、排序、筛选)
|
||||||
|
*/
|
||||||
|
export const getPosts = async ({ page = 1, size = 20, sort = 'created_at', order = 'desc', category = null, tags = [] }) => {
|
||||||
|
try {
|
||||||
|
const from = (page - 1) * size;
|
||||||
|
|
||||||
|
const query = {
|
||||||
|
bool: {
|
||||||
|
must: [{ match: { status: 'active' } }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (category) {
|
||||||
|
query.bool.must.push({ term: { category } });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tags.length > 0) {
|
||||||
|
query.bool.must.push({ terms: { tags } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await esClient.post(`/${INDICES.POSTS}/_search`, {
|
||||||
|
from,
|
||||||
|
size,
|
||||||
|
query,
|
||||||
|
sort: [
|
||||||
|
{ is_pinned: { order: 'desc' } },
|
||||||
|
{ [sort]: { order } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: response.data.hits.total.value,
|
||||||
|
posts: response.data.hits.hits.map((hit) => ({ ...hit._source, _id: hit._id })),
|
||||||
|
page,
|
||||||
|
size,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取帖子列表失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取单个帖子详情
|
||||||
|
*/
|
||||||
|
export const getPostById = async (postId) => {
|
||||||
|
try {
|
||||||
|
const response = await esClient.get(`/${INDICES.POSTS}/_doc/${postId}`);
|
||||||
|
|
||||||
|
// 增加浏览量
|
||||||
|
await esClient.post(`/${INDICES.POSTS}/_update/${postId}`, {
|
||||||
|
script: {
|
||||||
|
source: 'ctx._source.views_count += 1',
|
||||||
|
lang: 'painless',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ...response.data._source, _id: response.data._id };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取帖子详情失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新帖子
|
||||||
|
*/
|
||||||
|
export const updatePost = async (postId, updateData) => {
|
||||||
|
try {
|
||||||
|
const response = await esClient.post(`/${INDICES.POSTS}/_update/${postId}`, {
|
||||||
|
doc: {
|
||||||
|
...updateData,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新帖子失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除帖子(软删除)
|
||||||
|
*/
|
||||||
|
export const deletePost = async (postId) => {
|
||||||
|
try {
|
||||||
|
await updatePost(postId, { status: 'deleted' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除帖子失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点赞帖子
|
||||||
|
*/
|
||||||
|
export const likePost = async (postId) => {
|
||||||
|
try {
|
||||||
|
await esClient.post(`/${INDICES.POSTS}/_update/${postId}`, {
|
||||||
|
script: {
|
||||||
|
source: 'ctx._source.likes_count += 1',
|
||||||
|
lang: 'painless',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('点赞帖子失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索帖子
|
||||||
|
*/
|
||||||
|
export const searchPosts = async (keyword, { page = 1, size = 20 }) => {
|
||||||
|
try {
|
||||||
|
const from = (page - 1) * size;
|
||||||
|
|
||||||
|
const response = await esClient.post(`/${INDICES.POSTS}/_search`, {
|
||||||
|
from,
|
||||||
|
size,
|
||||||
|
query: {
|
||||||
|
bool: {
|
||||||
|
must: [
|
||||||
|
{
|
||||||
|
multi_match: {
|
||||||
|
query: keyword,
|
||||||
|
fields: ['title^3', 'content', 'tags^2'],
|
||||||
|
type: 'best_fields',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ match: { status: 'active' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
highlight: {
|
||||||
|
fields: {
|
||||||
|
title: {},
|
||||||
|
content: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: response.data.hits.total.value,
|
||||||
|
posts: response.data.hits.hits.map((hit) => ({
|
||||||
|
...hit._source,
|
||||||
|
_id: hit._id,
|
||||||
|
highlight: hit.highlight,
|
||||||
|
})),
|
||||||
|
page,
|
||||||
|
size,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('搜索帖子失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 评论相关操作 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建评论
|
||||||
|
*/
|
||||||
|
export const createComment = async (commentData) => {
|
||||||
|
try {
|
||||||
|
const comment = {
|
||||||
|
id: `comment_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
...commentData,
|
||||||
|
likes_count: 0,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
status: 'active',
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await esClient.post(`/${INDICES.COMMENTS}/_doc/${comment.id}`, comment);
|
||||||
|
|
||||||
|
// 增加帖子评论数
|
||||||
|
await esClient.post(`/${INDICES.POSTS}/_update/${commentData.post_id}`, {
|
||||||
|
script: {
|
||||||
|
source: 'ctx._source.comments_count += 1',
|
||||||
|
lang: 'painless',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ...comment, _id: response.data._id };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建评论失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取帖子的评论列表
|
||||||
|
*/
|
||||||
|
export const getCommentsByPostId = async (postId, { page = 1, size = 50 }) => {
|
||||||
|
try {
|
||||||
|
const from = (page - 1) * size;
|
||||||
|
|
||||||
|
const response = await esClient.post(`/${INDICES.COMMENTS}/_search`, {
|
||||||
|
from,
|
||||||
|
size,
|
||||||
|
query: {
|
||||||
|
bool: {
|
||||||
|
must: [
|
||||||
|
{ term: { post_id: postId } },
|
||||||
|
{ match: { status: 'active' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sort: [{ created_at: { order: 'asc' } }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: response.data.hits.total.value,
|
||||||
|
comments: response.data.hits.hits.map((hit) => ({ ...hit._source, _id: hit._id })),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取评论列表失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点赞评论
|
||||||
|
*/
|
||||||
|
export const likeComment = async (commentId) => {
|
||||||
|
try {
|
||||||
|
await esClient.post(`/${INDICES.COMMENTS}/_update/${commentId}`, {
|
||||||
|
script: {
|
||||||
|
source: 'ctx._source.likes_count += 1',
|
||||||
|
lang: 'painless',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('点赞评论失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 事件时间轴相关操作 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建事件
|
||||||
|
*/
|
||||||
|
export const createEvent = async (eventData) => {
|
||||||
|
try {
|
||||||
|
const event = {
|
||||||
|
id: `event_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
...eventData,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await esClient.post(`/${INDICES.EVENTS}/_doc/${event.id}`, event);
|
||||||
|
return { ...event, _id: response.data._id };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建事件失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取帖子的事件时间轴
|
||||||
|
*/
|
||||||
|
export const getEventsByPostId = async (postId) => {
|
||||||
|
try {
|
||||||
|
const response = await esClient.post(`/${INDICES.EVENTS}/_search`, {
|
||||||
|
size: 100,
|
||||||
|
query: {
|
||||||
|
term: { post_id: postId },
|
||||||
|
},
|
||||||
|
sort: [{ occurred_at: { order: 'desc' } }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.hits.hits.map((hit) => ({ ...hit._source, _id: hit._id }));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取事件时间轴失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
initializeIndices,
|
||||||
|
// 帖子操作
|
||||||
|
createPost,
|
||||||
|
getPosts,
|
||||||
|
getPostById,
|
||||||
|
updatePost,
|
||||||
|
deletePost,
|
||||||
|
likePost,
|
||||||
|
searchPosts,
|
||||||
|
// 评论操作
|
||||||
|
createComment,
|
||||||
|
getCommentsByPostId,
|
||||||
|
likeComment,
|
||||||
|
// 事件操作
|
||||||
|
createEvent,
|
||||||
|
getEventsByPostId,
|
||||||
|
};
|
||||||
227
src/theme/forumTheme.js
Normal file
227
src/theme/forumTheme.js
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
/**
|
||||||
|
* 价值论坛黑金主题配置
|
||||||
|
* 采用深色背景 + 金色点缀的高端配色方案
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const forumColors = {
|
||||||
|
// 主色调 - 黑金渐变
|
||||||
|
primary: {
|
||||||
|
50: '#FFF9E6',
|
||||||
|
100: '#FFEEBA',
|
||||||
|
200: '#FFE38D',
|
||||||
|
300: '#FFD860',
|
||||||
|
400: '#FFCD33',
|
||||||
|
500: '#FFC107', // 主金色
|
||||||
|
600: '#FFB300',
|
||||||
|
700: '#FFA000',
|
||||||
|
800: '#FF8F00',
|
||||||
|
900: '#FF6F00',
|
||||||
|
},
|
||||||
|
|
||||||
|
// 背景色系 - 深黑渐变
|
||||||
|
background: {
|
||||||
|
main: '#0A0A0A', // 主背景 - 极黑
|
||||||
|
secondary: '#121212', // 次级背景
|
||||||
|
card: '#1A1A1A', // 卡片背景
|
||||||
|
hover: '#222222', // 悬停背景
|
||||||
|
elevated: '#2A2A2A', // 提升背景(模态框等)
|
||||||
|
},
|
||||||
|
|
||||||
|
// 文字色系
|
||||||
|
text: {
|
||||||
|
primary: '#FFFFFF', // 主文字 - 纯白
|
||||||
|
secondary: '#B8B8B8', // 次要文字 - 灰色
|
||||||
|
tertiary: '#808080', // 三级文字 - 深灰
|
||||||
|
muted: '#5A5A5A', // 弱化文字
|
||||||
|
gold: '#FFC107', // 金色强调文字
|
||||||
|
goldGradient: 'linear-gradient(135deg, #FFD700 0%, #FFA500 100%)', // 金色渐变
|
||||||
|
},
|
||||||
|
|
||||||
|
// 边框色系
|
||||||
|
border: {
|
||||||
|
default: '#333333',
|
||||||
|
light: '#404040',
|
||||||
|
gold: '#FFC107',
|
||||||
|
goldGlow: 'rgba(255, 193, 7, 0.3)',
|
||||||
|
},
|
||||||
|
|
||||||
|
// 功能色
|
||||||
|
semantic: {
|
||||||
|
success: '#4CAF50',
|
||||||
|
warning: '#FF9800',
|
||||||
|
error: '#F44336',
|
||||||
|
info: '#2196F3',
|
||||||
|
},
|
||||||
|
|
||||||
|
// 金色渐变系列
|
||||||
|
gradients: {
|
||||||
|
goldPrimary: 'linear-gradient(135deg, #FFD700 0%, #FFA500 100%)',
|
||||||
|
goldSecondary: 'linear-gradient(135deg, #FFC107 0%, #FF8F00 100%)',
|
||||||
|
goldSubtle: 'linear-gradient(135deg, rgba(255, 215, 0, 0.1) 0%, rgba(255, 165, 0, 0.05) 100%)',
|
||||||
|
blackGold: 'linear-gradient(135deg, #0A0A0A 0%, #1A1A1A 50%, #2A2020 100%)',
|
||||||
|
cardHover: 'linear-gradient(135deg, #1A1A1A 0%, #252525 100%)',
|
||||||
|
},
|
||||||
|
|
||||||
|
// 阴影色系
|
||||||
|
shadows: {
|
||||||
|
sm: '0 1px 2px 0 rgba(0, 0, 0, 0.5)',
|
||||||
|
md: '0 4px 6px -1px rgba(0, 0, 0, 0.6), 0 2px 4px -1px rgba(0, 0, 0, 0.4)',
|
||||||
|
lg: '0 10px 15px -3px rgba(0, 0, 0, 0.7), 0 4px 6px -2px rgba(0, 0, 0, 0.5)',
|
||||||
|
xl: '0 20px 25px -5px rgba(0, 0, 0, 0.8), 0 10px 10px -5px rgba(0, 0, 0, 0.6)',
|
||||||
|
gold: '0 0 20px rgba(255, 193, 7, 0.3), 0 0 40px rgba(255, 193, 7, 0.1)',
|
||||||
|
goldHover: '0 0 30px rgba(255, 193, 7, 0.5), 0 0 60px rgba(255, 193, 7, 0.2)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 论坛组件样式配置
|
||||||
|
*/
|
||||||
|
export const forumComponentStyles = {
|
||||||
|
// 按钮样式
|
||||||
|
Button: {
|
||||||
|
baseStyle: {
|
||||||
|
fontWeight: '600',
|
||||||
|
borderRadius: 'md',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
gold: {
|
||||||
|
bg: forumColors.gradients.goldPrimary,
|
||||||
|
color: '#0A0A0A',
|
||||||
|
_hover: {
|
||||||
|
transform: 'translateY(-2px)',
|
||||||
|
boxShadow: forumColors.shadows.goldHover,
|
||||||
|
_disabled: {
|
||||||
|
transform: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_active: {
|
||||||
|
transform: 'translateY(0)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
goldOutline: {
|
||||||
|
bg: 'transparent',
|
||||||
|
color: forumColors.primary[500],
|
||||||
|
border: '2px solid',
|
||||||
|
borderColor: forumColors.primary[500],
|
||||||
|
_hover: {
|
||||||
|
bg: forumColors.gradients.goldSubtle,
|
||||||
|
boxShadow: forumColors.shadows.gold,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
bg: forumColors.background.card,
|
||||||
|
color: forumColors.text.primary,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: forumColors.border.default,
|
||||||
|
_hover: {
|
||||||
|
bg: forumColors.background.hover,
|
||||||
|
borderColor: forumColors.border.light,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 卡片样式
|
||||||
|
Card: {
|
||||||
|
baseStyle: {
|
||||||
|
container: {
|
||||||
|
bg: forumColors.background.card,
|
||||||
|
borderRadius: 'lg',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: forumColors.border.default,
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
_hover: {
|
||||||
|
borderColor: forumColors.border.gold,
|
||||||
|
boxShadow: forumColors.shadows.gold,
|
||||||
|
transform: 'translateY(-4px)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 输入框样式
|
||||||
|
Input: {
|
||||||
|
variants: {
|
||||||
|
forum: {
|
||||||
|
field: {
|
||||||
|
bg: forumColors.background.secondary,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: forumColors.border.default,
|
||||||
|
color: forumColors.text.primary,
|
||||||
|
_placeholder: {
|
||||||
|
color: forumColors.text.tertiary,
|
||||||
|
},
|
||||||
|
_hover: {
|
||||||
|
borderColor: forumColors.border.light,
|
||||||
|
},
|
||||||
|
_focus: {
|
||||||
|
borderColor: forumColors.border.gold,
|
||||||
|
boxShadow: `0 0 0 1px ${forumColors.border.goldGlow}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 标签样式
|
||||||
|
Tag: {
|
||||||
|
variants: {
|
||||||
|
gold: {
|
||||||
|
container: {
|
||||||
|
bg: forumColors.gradients.goldSubtle,
|
||||||
|
color: forumColors.primary[500],
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: forumColors.border.gold,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 论坛专用动画配置
|
||||||
|
*/
|
||||||
|
export const forumAnimations = {
|
||||||
|
fadeIn: {
|
||||||
|
initial: { opacity: 0, y: 20 },
|
||||||
|
animate: { opacity: 1, y: 0 },
|
||||||
|
exit: { opacity: 0, y: -20 },
|
||||||
|
transition: { duration: 0.3 },
|
||||||
|
},
|
||||||
|
|
||||||
|
slideIn: {
|
||||||
|
initial: { opacity: 0, x: -20 },
|
||||||
|
animate: { opacity: 1, x: 0 },
|
||||||
|
exit: { opacity: 0, x: 20 },
|
||||||
|
transition: { duration: 0.3 },
|
||||||
|
},
|
||||||
|
|
||||||
|
scaleIn: {
|
||||||
|
initial: { opacity: 0, scale: 0.9 },
|
||||||
|
animate: { opacity: 1, scale: 1 },
|
||||||
|
exit: { opacity: 0, scale: 0.9 },
|
||||||
|
transition: { duration: 0.2 },
|
||||||
|
},
|
||||||
|
|
||||||
|
goldGlow: {
|
||||||
|
animate: {
|
||||||
|
boxShadow: [
|
||||||
|
forumColors.shadows.gold,
|
||||||
|
forumColors.shadows.goldHover,
|
||||||
|
forumColors.shadows.gold,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
transition: {
|
||||||
|
duration: 2,
|
||||||
|
repeat: Infinity,
|
||||||
|
repeatType: 'reverse',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
colors: forumColors,
|
||||||
|
components: forumComponentStyles,
|
||||||
|
animations: forumAnimations,
|
||||||
|
};
|
||||||
370
src/views/ValueForum/PostDetail.js
Normal file
370
src/views/ValueForum/PostDetail.js
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
/**
|
||||||
|
* 帖子详情页
|
||||||
|
* 展示帖子完整内容、事件时间轴、评论区
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Container,
|
||||||
|
Heading,
|
||||||
|
Text,
|
||||||
|
HStack,
|
||||||
|
VStack,
|
||||||
|
Avatar,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Image,
|
||||||
|
SimpleGrid,
|
||||||
|
Spinner,
|
||||||
|
Center,
|
||||||
|
Flex,
|
||||||
|
IconButton,
|
||||||
|
Divider,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Heart,
|
||||||
|
MessageCircle,
|
||||||
|
Eye,
|
||||||
|
Share2,
|
||||||
|
Bookmark,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { forumColors } from '@theme/forumTheme';
|
||||||
|
import {
|
||||||
|
getPostById,
|
||||||
|
likePost,
|
||||||
|
getEventsByPostId,
|
||||||
|
} from '@services/elasticsearchService';
|
||||||
|
import EventTimeline from './components/EventTimeline';
|
||||||
|
import CommentSection from './components/CommentSection';
|
||||||
|
|
||||||
|
const MotionBox = motion(Box);
|
||||||
|
|
||||||
|
const PostDetail = () => {
|
||||||
|
const { postId } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [post, setPost] = useState(null);
|
||||||
|
const [events, setEvents] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [isLiked, setIsLiked] = useState(false);
|
||||||
|
const [likes, setLikes] = useState(0);
|
||||||
|
|
||||||
|
// 加载帖子数据
|
||||||
|
useEffect(() => {
|
||||||
|
const loadPostData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// 并行加载帖子和事件
|
||||||
|
const [postData, eventsData] = await Promise.all([
|
||||||
|
getPostById(postId),
|
||||||
|
getEventsByPostId(postId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setPost(postData);
|
||||||
|
setLikes(postData.likes_count || 0);
|
||||||
|
setEvents(eventsData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载帖子失败:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadPostData();
|
||||||
|
}, [postId]);
|
||||||
|
|
||||||
|
// 处理点赞
|
||||||
|
const handleLike = async () => {
|
||||||
|
try {
|
||||||
|
if (!isLiked) {
|
||||||
|
await likePost(postId);
|
||||||
|
setLikes((prev) => prev + 1);
|
||||||
|
setIsLiked(true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('点赞失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化时间
|
||||||
|
const formatTime = (dateString) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Box minH="100vh" bg={forumColors.background.main} pt="80px">
|
||||||
|
<Center py="20">
|
||||||
|
<VStack spacing="4">
|
||||||
|
<Spinner
|
||||||
|
size="xl"
|
||||||
|
thickness="4px"
|
||||||
|
speed="0.8s"
|
||||||
|
color={forumColors.primary[500]}
|
||||||
|
/>
|
||||||
|
<Text color={forumColors.text.secondary}>加载中...</Text>
|
||||||
|
</VStack>
|
||||||
|
</Center>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
return (
|
||||||
|
<Box minH="100vh" bg={forumColors.background.main} pt="80px">
|
||||||
|
<Center py="20">
|
||||||
|
<VStack spacing="4">
|
||||||
|
<Text color={forumColors.text.secondary} fontSize="lg">
|
||||||
|
帖子不存在或已被删除
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
leftIcon={<ArrowLeft size={18} />}
|
||||||
|
onClick={() => navigate('/value-forum')}
|
||||||
|
bg={forumColors.gradients.goldPrimary}
|
||||||
|
color={forumColors.background.main}
|
||||||
|
_hover={{ opacity: 0.9 }}
|
||||||
|
>
|
||||||
|
返回论坛
|
||||||
|
</Button>
|
||||||
|
</VStack>
|
||||||
|
</Center>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box minH="100vh" bg={forumColors.background.main} pt="80px" pb="20">
|
||||||
|
<Container maxW="container.xl">
|
||||||
|
{/* 返回按钮 */}
|
||||||
|
<Button
|
||||||
|
leftIcon={<ArrowLeft size={18} />}
|
||||||
|
onClick={() => navigate('/value-forum')}
|
||||||
|
variant="ghost"
|
||||||
|
color={forumColors.text.secondary}
|
||||||
|
_hover={{ color: forumColors.text.primary }}
|
||||||
|
mb="6"
|
||||||
|
>
|
||||||
|
返回论坛
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<SimpleGrid columns={{ base: 1, lg: 3 }} spacing="6">
|
||||||
|
{/* 左侧:帖子内容 + 评论 */}
|
||||||
|
<Box gridColumn={{ base: '1', lg: '1 / 3' }}>
|
||||||
|
<VStack spacing="6" align="stretch">
|
||||||
|
{/* 帖子主体 */}
|
||||||
|
<MotionBox
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
bg={forumColors.background.card}
|
||||||
|
borderRadius="xl"
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={forumColors.border.default}
|
||||||
|
overflow="hidden"
|
||||||
|
>
|
||||||
|
{/* 作者信息 */}
|
||||||
|
<Box p="6" borderBottomWidth="1px" borderColor={forumColors.border.default}>
|
||||||
|
<Flex justify="space-between" align="start">
|
||||||
|
<HStack spacing="3">
|
||||||
|
<Avatar
|
||||||
|
size="md"
|
||||||
|
name={post.author_name}
|
||||||
|
src={post.author_avatar}
|
||||||
|
bg={forumColors.gradients.goldPrimary}
|
||||||
|
color={forumColors.background.main}
|
||||||
|
/>
|
||||||
|
<VStack align="start" spacing="1">
|
||||||
|
<Text fontSize="md" fontWeight="600" color={forumColors.text.primary}>
|
||||||
|
{post.author_name}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="xs" color={forumColors.text.muted}>
|
||||||
|
发布于 {formatTime(post.created_at)}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<HStack spacing="2">
|
||||||
|
<IconButton
|
||||||
|
icon={<Share2 size={18} />}
|
||||||
|
variant="ghost"
|
||||||
|
color={forumColors.text.tertiary}
|
||||||
|
_hover={{ color: forumColors.primary[500] }}
|
||||||
|
aria-label="分享"
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={<Bookmark size={18} />}
|
||||||
|
variant="ghost"
|
||||||
|
color={forumColors.text.tertiary}
|
||||||
|
_hover={{ color: forumColors.primary[500] }}
|
||||||
|
aria-label="收藏"
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 帖子内容 */}
|
||||||
|
<Box p="6">
|
||||||
|
{/* 标题 */}
|
||||||
|
<Heading
|
||||||
|
as="h1"
|
||||||
|
fontSize="2xl"
|
||||||
|
fontWeight="bold"
|
||||||
|
color={forumColors.text.primary}
|
||||||
|
mb="4"
|
||||||
|
>
|
||||||
|
{post.title}
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
{/* 标签 */}
|
||||||
|
{post.tags && post.tags.length > 0 && (
|
||||||
|
<HStack spacing="2" mb="6" flexWrap="wrap">
|
||||||
|
{post.tags.map((tag, index) => (
|
||||||
|
<Badge
|
||||||
|
key={index}
|
||||||
|
bg={forumColors.gradients.goldSubtle}
|
||||||
|
color={forumColors.primary[500]}
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={forumColors.border.gold}
|
||||||
|
borderRadius="full"
|
||||||
|
px="3"
|
||||||
|
py="1"
|
||||||
|
fontSize="sm"
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 正文 */}
|
||||||
|
<Text
|
||||||
|
fontSize="md"
|
||||||
|
color={forumColors.text.secondary}
|
||||||
|
lineHeight="1.8"
|
||||||
|
whiteSpace="pre-wrap"
|
||||||
|
mb="6"
|
||||||
|
>
|
||||||
|
{post.content}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* 图片 */}
|
||||||
|
{post.images && post.images.length > 0 && (
|
||||||
|
<SimpleGrid columns={{ base: 1, sm: 2, md: 3 }} spacing="4" mb="6">
|
||||||
|
{post.images.map((img, index) => (
|
||||||
|
<Image
|
||||||
|
key={index}
|
||||||
|
src={img}
|
||||||
|
alt={`图片 ${index + 1}`}
|
||||||
|
borderRadius="md"
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={forumColors.border.default}
|
||||||
|
cursor="pointer"
|
||||||
|
_hover={{
|
||||||
|
transform: 'scale(1.05)',
|
||||||
|
boxShadow: forumColors.shadows.gold,
|
||||||
|
}}
|
||||||
|
transition="all 0.3s"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 互动栏 */}
|
||||||
|
<Box
|
||||||
|
p="4"
|
||||||
|
borderTopWidth="1px"
|
||||||
|
borderColor={forumColors.border.default}
|
||||||
|
bg={forumColors.background.secondary}
|
||||||
|
>
|
||||||
|
<Flex justify="space-between" align="center">
|
||||||
|
<HStack spacing="6">
|
||||||
|
<HStack
|
||||||
|
spacing="2"
|
||||||
|
cursor="pointer"
|
||||||
|
onClick={handleLike}
|
||||||
|
color={isLiked ? forumColors.primary[500] : forumColors.text.tertiary}
|
||||||
|
_hover={{ color: forumColors.primary[500] }}
|
||||||
|
>
|
||||||
|
<Heart size={20} fill={isLiked ? 'currentColor' : 'none'} />
|
||||||
|
<Text fontSize="sm" fontWeight="500">
|
||||||
|
{likes}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<HStack spacing="2" color={forumColors.text.tertiary}>
|
||||||
|
<MessageCircle size={20} />
|
||||||
|
<Text fontSize="sm" fontWeight="500">
|
||||||
|
{post.comments_count || 0}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<HStack spacing="2" color={forumColors.text.tertiary}>
|
||||||
|
<Eye size={20} />
|
||||||
|
<Text fontSize="sm" fontWeight="500">
|
||||||
|
{post.views_count || 0}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
leftIcon={<Heart size={18} />}
|
||||||
|
onClick={handleLike}
|
||||||
|
bg={isLiked ? forumColors.primary[500] : 'transparent'}
|
||||||
|
color={isLiked ? forumColors.background.main : forumColors.text.primary}
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={forumColors.border.gold}
|
||||||
|
_hover={{
|
||||||
|
bg: forumColors.gradients.goldPrimary,
|
||||||
|
color: forumColors.background.main,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLiked ? '已点赞' : '点赞'}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
</MotionBox>
|
||||||
|
|
||||||
|
{/* 评论区 */}
|
||||||
|
<MotionBox
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.2 }}
|
||||||
|
>
|
||||||
|
<CommentSection postId={postId} />
|
||||||
|
</MotionBox>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 右侧:事件时间轴 */}
|
||||||
|
<Box gridColumn={{ base: '1', lg: '3' }}>
|
||||||
|
<MotionBox
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.5, delay: 0.1 }}
|
||||||
|
position="sticky"
|
||||||
|
top="100px"
|
||||||
|
>
|
||||||
|
<EventTimeline events={events} />
|
||||||
|
</MotionBox>
|
||||||
|
</Box>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PostDetail;
|
||||||
318
src/views/ValueForum/components/CommentSection.js
Normal file
318
src/views/ValueForum/components/CommentSection.js
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
/**
|
||||||
|
* 评论区组件
|
||||||
|
* 支持发布评论、嵌套回复、点赞等功能
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
Text,
|
||||||
|
Avatar,
|
||||||
|
Textarea,
|
||||||
|
Button,
|
||||||
|
Flex,
|
||||||
|
IconButton,
|
||||||
|
Divider,
|
||||||
|
useToast,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { Heart, MessageCircle, Send } from 'lucide-react';
|
||||||
|
import { forumColors } from '@theme/forumTheme';
|
||||||
|
import {
|
||||||
|
getCommentsByPostId,
|
||||||
|
createComment,
|
||||||
|
likeComment,
|
||||||
|
} from '@services/elasticsearchService';
|
||||||
|
import { useAuth } from '@contexts/AuthContext';
|
||||||
|
|
||||||
|
const MotionBox = motion(Box);
|
||||||
|
|
||||||
|
const CommentItem = ({ comment, postId, onReply }) => {
|
||||||
|
const [isLiked, setIsLiked] = useState(false);
|
||||||
|
const [likes, setLikes] = useState(comment.likes_count || 0);
|
||||||
|
const [showReply, setShowReply] = useState(false);
|
||||||
|
|
||||||
|
// 处理点赞
|
||||||
|
const handleLike = async () => {
|
||||||
|
try {
|
||||||
|
if (!isLiked) {
|
||||||
|
await likeComment(comment.id);
|
||||||
|
setLikes((prev) => prev + 1);
|
||||||
|
setIsLiked(true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('点赞失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化时间
|
||||||
|
const formatTime = (dateString) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now - date;
|
||||||
|
|
||||||
|
const minutes = Math.floor(diff / 60000);
|
||||||
|
const hours = Math.floor(diff / 3600000);
|
||||||
|
const days = Math.floor(diff / 86400000);
|
||||||
|
|
||||||
|
if (minutes < 1) return '刚刚';
|
||||||
|
if (minutes < 60) return `${minutes}分钟前`;
|
||||||
|
if (hours < 24) return `${hours}小时前`;
|
||||||
|
if (days < 7) return `${days}天前`;
|
||||||
|
|
||||||
|
return date.toLocaleDateString('zh-CN', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MotionBox
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
<Flex gap="3" py="4">
|
||||||
|
{/* 头像 */}
|
||||||
|
<Avatar
|
||||||
|
size="sm"
|
||||||
|
name={comment.author_name}
|
||||||
|
src={comment.author_avatar}
|
||||||
|
bg={forumColors.gradients.goldPrimary}
|
||||||
|
color={forumColors.background.main}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 评论内容 */}
|
||||||
|
<VStack align="stretch" flex="1" spacing="2">
|
||||||
|
{/* 用户名和时间 */}
|
||||||
|
<HStack justify="space-between">
|
||||||
|
<HStack spacing="2">
|
||||||
|
<Text fontSize="sm" fontWeight="600" color={forumColors.text.primary}>
|
||||||
|
{comment.author_name}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="xs" color={forumColors.text.muted}>
|
||||||
|
{formatTime(comment.created_at)}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* 评论正文 */}
|
||||||
|
<Text fontSize="sm" color={forumColors.text.secondary} lineHeight="1.6">
|
||||||
|
{comment.content}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<HStack spacing="4" fontSize="xs" color={forumColors.text.tertiary}>
|
||||||
|
<HStack
|
||||||
|
spacing="1"
|
||||||
|
cursor="pointer"
|
||||||
|
onClick={handleLike}
|
||||||
|
_hover={{ color: forumColors.primary[500] }}
|
||||||
|
color={isLiked ? forumColors.primary[500] : forumColors.text.tertiary}
|
||||||
|
>
|
||||||
|
<Heart size={14} fill={isLiked ? 'currentColor' : 'none'} />
|
||||||
|
<Text>{likes > 0 ? likes : '点赞'}</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<HStack
|
||||||
|
spacing="1"
|
||||||
|
cursor="pointer"
|
||||||
|
onClick={() => setShowReply(!showReply)}
|
||||||
|
_hover={{ color: forumColors.primary[500] }}
|
||||||
|
>
|
||||||
|
<MessageCircle size={14} />
|
||||||
|
<Text>回复</Text>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* 回复输入框 */}
|
||||||
|
{showReply && (
|
||||||
|
<MotionBox
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
mt="2"
|
||||||
|
>
|
||||||
|
<ReplyInput
|
||||||
|
postId={postId}
|
||||||
|
parentId={comment.id}
|
||||||
|
placeholder={`回复 @${comment.author_name}`}
|
||||||
|
onSubmit={() => {
|
||||||
|
setShowReply(false);
|
||||||
|
if (onReply) onReply();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</MotionBox>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</Flex>
|
||||||
|
</MotionBox>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ReplyInput = ({ postId, parentId = null, placeholder, onSubmit }) => {
|
||||||
|
const toast = useToast();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!content.trim()) {
|
||||||
|
toast({
|
||||||
|
title: '请输入评论内容',
|
||||||
|
status: 'warning',
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createComment({
|
||||||
|
post_id: postId,
|
||||||
|
parent_id: parentId,
|
||||||
|
content: content.trim(),
|
||||||
|
author_id: user?.id || 'anonymous',
|
||||||
|
author_name: user?.name || '匿名用户',
|
||||||
|
author_avatar: user?.avatar || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: '评论成功',
|
||||||
|
status: 'success',
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
|
|
||||||
|
setContent('');
|
||||||
|
if (onSubmit) onSubmit();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('评论失败:', error);
|
||||||
|
toast({
|
||||||
|
title: '评论失败',
|
||||||
|
description: error.message,
|
||||||
|
status: 'error',
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex gap="2" align="end">
|
||||||
|
<Textarea
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
placeholder={placeholder || '写下你的评论...'}
|
||||||
|
size="sm"
|
||||||
|
bg={forumColors.background.secondary}
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={forumColors.border.default}
|
||||||
|
color={forumColors.text.primary}
|
||||||
|
_placeholder={{ color: forumColors.text.tertiary }}
|
||||||
|
_hover={{ borderColor: forumColors.border.light }}
|
||||||
|
_focus={{
|
||||||
|
borderColor: forumColors.border.gold,
|
||||||
|
boxShadow: `0 0 0 1px ${forumColors.border.goldGlow}`,
|
||||||
|
}}
|
||||||
|
minH="80px"
|
||||||
|
resize="vertical"
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={<Send size={18} />}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
bg={forumColors.gradients.goldPrimary}
|
||||||
|
color={forumColors.background.main}
|
||||||
|
_hover={{ opacity: 0.9 }}
|
||||||
|
size="sm"
|
||||||
|
h="40px"
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CommentSection = ({ postId }) => {
|
||||||
|
const [comments, setComments] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
|
||||||
|
// 加载评论
|
||||||
|
const loadComments = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const result = await getCommentsByPostId(postId);
|
||||||
|
setComments(result.comments);
|
||||||
|
setTotal(result.total);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载评论失败:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadComments();
|
||||||
|
}, [postId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
bg={forumColors.background.card}
|
||||||
|
borderRadius="lg"
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={forumColors.border.default}
|
||||||
|
p="6"
|
||||||
|
>
|
||||||
|
{/* 标题 */}
|
||||||
|
<Flex justify="space-between" align="center" mb="6">
|
||||||
|
<HStack spacing="2">
|
||||||
|
<MessageCircle size={20} color={forumColors.primary[500]} />
|
||||||
|
<Text fontSize="lg" fontWeight="bold" color={forumColors.text.primary}>
|
||||||
|
评论
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<Text fontSize="sm" color={forumColors.text.tertiary}>
|
||||||
|
共 {total} 条
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* 发表评论 */}
|
||||||
|
<Box mb="6">
|
||||||
|
<ReplyInput postId={postId} onSubmit={loadComments} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider borderColor={forumColors.border.default} mb="4" />
|
||||||
|
|
||||||
|
{/* 评论列表 */}
|
||||||
|
{loading ? (
|
||||||
|
<Text color={forumColors.text.secondary} textAlign="center" py="8">
|
||||||
|
加载中...
|
||||||
|
</Text>
|
||||||
|
) : comments.length === 0 ? (
|
||||||
|
<Text color={forumColors.text.secondary} textAlign="center" py="8">
|
||||||
|
暂无评论,快来抢沙发吧!
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<VStack align="stretch" spacing="0" divider={<Divider borderColor={forumColors.border.default} />}>
|
||||||
|
<AnimatePresence>
|
||||||
|
{comments.map((comment) => (
|
||||||
|
<CommentItem
|
||||||
|
key={comment.id}
|
||||||
|
comment={comment}
|
||||||
|
postId={postId}
|
||||||
|
onReply={loadComments}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</VStack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CommentSection;
|
||||||
419
src/views/ValueForum/components/CreatePostModal.js
Normal file
419
src/views/ValueForum/components/CreatePostModal.js
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
/**
|
||||||
|
* 发帖模态框组件
|
||||||
|
* 用于创建新帖子
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
ModalOverlay,
|
||||||
|
ModalContent,
|
||||||
|
ModalHeader,
|
||||||
|
ModalBody,
|
||||||
|
ModalFooter,
|
||||||
|
ModalCloseButton,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Textarea,
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
Text,
|
||||||
|
Box,
|
||||||
|
Image,
|
||||||
|
IconButton,
|
||||||
|
Tag,
|
||||||
|
TagLabel,
|
||||||
|
TagCloseButton,
|
||||||
|
useToast,
|
||||||
|
FormControl,
|
||||||
|
FormLabel,
|
||||||
|
FormErrorMessage,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { ImagePlus, X, Hash } from 'lucide-react';
|
||||||
|
import { forumColors } from '@theme/forumTheme';
|
||||||
|
import { createPost } from '@services/elasticsearchService';
|
||||||
|
import { useAuth } from '@contexts/AuthContext';
|
||||||
|
|
||||||
|
const CreatePostModal = ({ isOpen, onClose, onPostCreated }) => {
|
||||||
|
const toast = useToast();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
images: [],
|
||||||
|
tags: [],
|
||||||
|
category: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [currentTag, setCurrentTag] = useState('');
|
||||||
|
const [errors, setErrors] = useState({});
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// 表单验证
|
||||||
|
const validateForm = () => {
|
||||||
|
const newErrors = {};
|
||||||
|
|
||||||
|
if (!formData.title.trim()) {
|
||||||
|
newErrors.title = '请输入标题';
|
||||||
|
} else if (formData.title.length > 100) {
|
||||||
|
newErrors.title = '标题不能超过100个字符';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.content.trim()) {
|
||||||
|
newErrors.content = '请输入内容';
|
||||||
|
} else if (formData.content.length > 5000) {
|
||||||
|
newErrors.content = '内容不能超过5000个字符';
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理图片上传
|
||||||
|
const handleImageUpload = (e) => {
|
||||||
|
const files = Array.from(e.target.files);
|
||||||
|
|
||||||
|
files.forEach((file) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
images: [...prev.images, reader.result],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 移除图片
|
||||||
|
const removeImage = (index) => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
images: prev.images.filter((_, i) => i !== index),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加标签
|
||||||
|
const addTag = () => {
|
||||||
|
if (currentTag.trim() && !formData.tags.includes(currentTag.trim())) {
|
||||||
|
if (formData.tags.length >= 5) {
|
||||||
|
toast({
|
||||||
|
title: '标签数量已达上限',
|
||||||
|
description: '最多只能添加5个标签',
|
||||||
|
status: 'warning',
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
tags: [...prev.tags, currentTag.trim()],
|
||||||
|
}));
|
||||||
|
setCurrentTag('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 移除标签
|
||||||
|
const removeTag = (tag) => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
tags: prev.tags.filter((t) => t !== tag),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 提交帖子
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!validateForm()) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const postData = {
|
||||||
|
...formData,
|
||||||
|
author_id: user?.id || 'anonymous',
|
||||||
|
author_name: user?.name || '匿名用户',
|
||||||
|
author_avatar: user?.avatar || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const newPost = await createPost(postData);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: '发布成功',
|
||||||
|
description: '帖子已成功发布到论坛',
|
||||||
|
status: 'success',
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 重置表单
|
||||||
|
setFormData({
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
images: [],
|
||||||
|
tags: [],
|
||||||
|
category: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
|
||||||
|
// 通知父组件刷新
|
||||||
|
if (onPostCreated) {
|
||||||
|
onPostCreated(newPost);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('发布帖子失败:', error);
|
||||||
|
toast({
|
||||||
|
title: '发布失败',
|
||||||
|
description: error.message || '发布帖子时出错,请稍后重试',
|
||||||
|
status: 'error',
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} size="2xl">
|
||||||
|
<ModalOverlay bg="blackAlpha.800" />
|
||||||
|
<ModalContent
|
||||||
|
bg={forumColors.background.elevated}
|
||||||
|
borderColor={forumColors.border.gold}
|
||||||
|
borderWidth="1px"
|
||||||
|
maxH="90vh"
|
||||||
|
>
|
||||||
|
<ModalHeader
|
||||||
|
color={forumColors.text.primary}
|
||||||
|
borderBottomWidth="1px"
|
||||||
|
borderBottomColor={forumColors.border.default}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
bgGradient={forumColors.text.goldGradient}
|
||||||
|
bgClip="text"
|
||||||
|
fontWeight="bold"
|
||||||
|
fontSize="xl"
|
||||||
|
>
|
||||||
|
发布新帖
|
||||||
|
</Text>
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalCloseButton color={forumColors.text.secondary} />
|
||||||
|
|
||||||
|
<ModalBody py="6" overflowY="auto">
|
||||||
|
<VStack spacing="5" align="stretch">
|
||||||
|
{/* 标题输入 */}
|
||||||
|
<FormControl isInvalid={errors.title}>
|
||||||
|
<FormLabel color={forumColors.text.secondary} fontSize="sm">
|
||||||
|
标题
|
||||||
|
</FormLabel>
|
||||||
|
<Input
|
||||||
|
placeholder="给你的帖子起个吸引人的标题..."
|
||||||
|
value={formData.title}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((prev) => ({ ...prev, title: e.target.value }))
|
||||||
|
}
|
||||||
|
bg={forumColors.background.secondary}
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={forumColors.border.default}
|
||||||
|
color={forumColors.text.primary}
|
||||||
|
_placeholder={{ color: forumColors.text.tertiary }}
|
||||||
|
_hover={{ borderColor: forumColors.border.light }}
|
||||||
|
_focus={{
|
||||||
|
borderColor: forumColors.border.gold,
|
||||||
|
boxShadow: `0 0 0 1px ${forumColors.border.goldGlow}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FormErrorMessage>{errors.title}</FormErrorMessage>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{/* 内容输入 */}
|
||||||
|
<FormControl isInvalid={errors.content}>
|
||||||
|
<FormLabel color={forumColors.text.secondary} fontSize="sm">
|
||||||
|
内容
|
||||||
|
</FormLabel>
|
||||||
|
<Textarea
|
||||||
|
placeholder="分享你的投资见解、市场观点或交易心得..."
|
||||||
|
value={formData.content}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((prev) => ({ ...prev, content: e.target.value }))
|
||||||
|
}
|
||||||
|
minH="200px"
|
||||||
|
bg={forumColors.background.secondary}
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={forumColors.border.default}
|
||||||
|
color={forumColors.text.primary}
|
||||||
|
_placeholder={{ color: forumColors.text.tertiary }}
|
||||||
|
_hover={{ borderColor: forumColors.border.light }}
|
||||||
|
_focus={{
|
||||||
|
borderColor: forumColors.border.gold,
|
||||||
|
boxShadow: `0 0 0 1px ${forumColors.border.goldGlow}`,
|
||||||
|
}}
|
||||||
|
resize="vertical"
|
||||||
|
/>
|
||||||
|
<FormErrorMessage>{errors.content}</FormErrorMessage>
|
||||||
|
<Text
|
||||||
|
fontSize="xs"
|
||||||
|
color={forumColors.text.muted}
|
||||||
|
mt="2"
|
||||||
|
textAlign="right"
|
||||||
|
>
|
||||||
|
{formData.content.length} / 5000
|
||||||
|
</Text>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
{/* 图片上传 */}
|
||||||
|
<Box>
|
||||||
|
<FormLabel color={forumColors.text.secondary} fontSize="sm">
|
||||||
|
图片(最多9张)
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<HStack spacing="3" flexWrap="wrap">
|
||||||
|
{formData.images.map((img, index) => (
|
||||||
|
<Box key={index} position="relative" w="100px" h="100px">
|
||||||
|
<Image
|
||||||
|
src={img}
|
||||||
|
alt={`预览 ${index + 1}`}
|
||||||
|
w="100%"
|
||||||
|
h="100%"
|
||||||
|
objectFit="cover"
|
||||||
|
borderRadius="md"
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={forumColors.border.default}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={<X size={14} />}
|
||||||
|
size="xs"
|
||||||
|
position="absolute"
|
||||||
|
top="-2"
|
||||||
|
right="-2"
|
||||||
|
borderRadius="full"
|
||||||
|
bg={forumColors.background.main}
|
||||||
|
color={forumColors.text.primary}
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={forumColors.border.gold}
|
||||||
|
onClick={() => removeImage(index)}
|
||||||
|
_hover={{ bg: forumColors.background.hover }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{formData.images.length < 9 && (
|
||||||
|
<Box
|
||||||
|
as="label"
|
||||||
|
w="100px"
|
||||||
|
h="100px"
|
||||||
|
display="flex"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
bg={forumColors.background.secondary}
|
||||||
|
border="2px dashed"
|
||||||
|
borderColor={forumColors.border.default}
|
||||||
|
borderRadius="md"
|
||||||
|
cursor="pointer"
|
||||||
|
_hover={{ borderColor: forumColors.border.gold }}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
display="none"
|
||||||
|
onChange={handleImageUpload}
|
||||||
|
/>
|
||||||
|
<ImagePlus size={24} color={forumColors.text.tertiary} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 标签输入 */}
|
||||||
|
<Box>
|
||||||
|
<FormLabel color={forumColors.text.secondary} fontSize="sm">
|
||||||
|
标签(最多5个)
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<HStack mb="3">
|
||||||
|
<Input
|
||||||
|
placeholder="输入标签后按回车"
|
||||||
|
value={currentTag}
|
||||||
|
onChange={(e) => setCurrentTag(e.target.value)}
|
||||||
|
onKeyPress={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
addTag();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
bg={forumColors.background.secondary}
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={forumColors.border.default}
|
||||||
|
color={forumColors.text.primary}
|
||||||
|
_placeholder={{ color: forumColors.text.tertiary }}
|
||||||
|
_focus={{
|
||||||
|
borderColor: forumColors.border.gold,
|
||||||
|
boxShadow: `0 0 0 1px ${forumColors.border.goldGlow}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon={<Hash size={18} />}
|
||||||
|
onClick={addTag}
|
||||||
|
bg={forumColors.gradients.goldPrimary}
|
||||||
|
color={forumColors.background.main}
|
||||||
|
_hover={{ opacity: 0.9 }}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<HStack spacing="2" flexWrap="wrap">
|
||||||
|
{formData.tags.map((tag) => (
|
||||||
|
<Tag
|
||||||
|
key={tag}
|
||||||
|
size="md"
|
||||||
|
bg={forumColors.gradients.goldSubtle}
|
||||||
|
color={forumColors.primary[500]}
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={forumColors.border.gold}
|
||||||
|
borderRadius="full"
|
||||||
|
>
|
||||||
|
<TagLabel>#{tag}</TagLabel>
|
||||||
|
<TagCloseButton onClick={() => removeTag(tag)} />
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
</Box>
|
||||||
|
</VStack>
|
||||||
|
</ModalBody>
|
||||||
|
|
||||||
|
<ModalFooter
|
||||||
|
borderTopWidth="1px"
|
||||||
|
borderTopColor={forumColors.border.default}
|
||||||
|
>
|
||||||
|
<HStack spacing="3">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onClose}
|
||||||
|
color={forumColors.text.secondary}
|
||||||
|
_hover={{ bg: forumColors.background.hover }}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
bg={forumColors.gradients.goldPrimary}
|
||||||
|
color={forumColors.background.main}
|
||||||
|
fontWeight="bold"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
loadingText="发布中..."
|
||||||
|
_hover={{
|
||||||
|
transform: 'translateY(-2px)',
|
||||||
|
boxShadow: forumColors.shadows.goldHover,
|
||||||
|
}}
|
||||||
|
_active={{ transform: 'translateY(0)' }}
|
||||||
|
>
|
||||||
|
发布帖子
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreatePostModal;
|
||||||
347
src/views/ValueForum/components/EventTimeline.js
Normal file
347
src/views/ValueForum/components/EventTimeline.js
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
/**
|
||||||
|
* 事件时间轴组件
|
||||||
|
* 展示帖子相关事件的时间线发展
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
VStack,
|
||||||
|
HStack,
|
||||||
|
Text,
|
||||||
|
Flex,
|
||||||
|
Badge,
|
||||||
|
Link,
|
||||||
|
Icon,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
TrendingUp,
|
||||||
|
AlertCircle,
|
||||||
|
FileText,
|
||||||
|
ExternalLink,
|
||||||
|
Clock,
|
||||||
|
Zap,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { forumColors } from '@theme/forumTheme';
|
||||||
|
|
||||||
|
const MotionBox = motion(Box);
|
||||||
|
|
||||||
|
// 事件类型配置
|
||||||
|
const EVENT_TYPES = {
|
||||||
|
news: {
|
||||||
|
label: '新闻',
|
||||||
|
icon: FileText,
|
||||||
|
color: forumColors.semantic.info,
|
||||||
|
},
|
||||||
|
price_change: {
|
||||||
|
label: '价格变动',
|
||||||
|
icon: TrendingUp,
|
||||||
|
color: forumColors.semantic.warning,
|
||||||
|
},
|
||||||
|
announcement: {
|
||||||
|
label: '公告',
|
||||||
|
icon: AlertCircle,
|
||||||
|
color: forumColors.semantic.error,
|
||||||
|
},
|
||||||
|
analysis: {
|
||||||
|
label: '分析',
|
||||||
|
icon: Zap,
|
||||||
|
color: forumColors.primary[500],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 重要性配置
|
||||||
|
const IMPORTANCE_LEVELS = {
|
||||||
|
high: {
|
||||||
|
label: '重要',
|
||||||
|
color: forumColors.semantic.error,
|
||||||
|
dotSize: '16px',
|
||||||
|
},
|
||||||
|
medium: {
|
||||||
|
label: '一般',
|
||||||
|
color: forumColors.semantic.warning,
|
||||||
|
dotSize: '12px',
|
||||||
|
},
|
||||||
|
low: {
|
||||||
|
label: '提示',
|
||||||
|
color: forumColors.text.tertiary,
|
||||||
|
dotSize: '10px',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const EventTimeline = ({ events = [] }) => {
|
||||||
|
// 格式化时间
|
||||||
|
const formatEventTime = (dateString) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now - date;
|
||||||
|
|
||||||
|
const minutes = Math.floor(diff / 60000);
|
||||||
|
const hours = Math.floor(diff / 3600000);
|
||||||
|
const days = Math.floor(diff / 86400000);
|
||||||
|
|
||||||
|
if (minutes < 1) return '刚刚';
|
||||||
|
if (minutes < 60) return `${minutes}分钟前`;
|
||||||
|
if (hours < 24) return `${hours}小时前`;
|
||||||
|
if (days < 7) return `${days}天前`;
|
||||||
|
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!events || events.length === 0) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
bg={forumColors.background.card}
|
||||||
|
borderRadius="lg"
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={forumColors.border.default}
|
||||||
|
p="8"
|
||||||
|
textAlign="center"
|
||||||
|
>
|
||||||
|
<VStack spacing="3">
|
||||||
|
<Clock size={48} color={forumColors.text.tertiary} />
|
||||||
|
<Text color={forumColors.text.secondary} fontSize="md">
|
||||||
|
暂无事件追踪
|
||||||
|
</Text>
|
||||||
|
<Text color={forumColors.text.muted} fontSize="sm">
|
||||||
|
AI 将自动追踪与本帖相关的市场事件
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
bg={forumColors.background.card}
|
||||||
|
borderRadius="lg"
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={forumColors.border.default}
|
||||||
|
p="6"
|
||||||
|
>
|
||||||
|
{/* 标题 */}
|
||||||
|
<Flex justify="space-between" align="center" mb="6">
|
||||||
|
<HStack spacing="2">
|
||||||
|
<Clock size={20} color={forumColors.primary[500]} />
|
||||||
|
<Text
|
||||||
|
fontSize="lg"
|
||||||
|
fontWeight="bold"
|
||||||
|
color={forumColors.text.primary}
|
||||||
|
>
|
||||||
|
事件时间轴
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<Badge
|
||||||
|
bg={forumColors.gradients.goldSubtle}
|
||||||
|
color={forumColors.primary[500]}
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={forumColors.border.gold}
|
||||||
|
borderRadius="full"
|
||||||
|
px="3"
|
||||||
|
py="1"
|
||||||
|
>
|
||||||
|
{events.length} 个事件
|
||||||
|
</Badge>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* 时间轴列表 */}
|
||||||
|
<VStack align="stretch" spacing="4" position="relative">
|
||||||
|
{/* 连接线 */}
|
||||||
|
<Box
|
||||||
|
position="absolute"
|
||||||
|
left="7px"
|
||||||
|
top="20px"
|
||||||
|
bottom="20px"
|
||||||
|
w="2px"
|
||||||
|
bg={forumColors.border.default}
|
||||||
|
zIndex="0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{events.map((event, index) => {
|
||||||
|
const eventType = EVENT_TYPES[event.event_type] || EVENT_TYPES.news;
|
||||||
|
const importance =
|
||||||
|
IMPORTANCE_LEVELS[event.importance] || IMPORTANCE_LEVELS.medium;
|
||||||
|
const EventIcon = eventType.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MotionBox
|
||||||
|
key={event.id}
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: index * 0.1 }}
|
||||||
|
position="relative"
|
||||||
|
zIndex="1"
|
||||||
|
>
|
||||||
|
<Flex gap="4">
|
||||||
|
{/* 时间轴节点 */}
|
||||||
|
<Box position="relative" flexShrink="0">
|
||||||
|
{/* 外圈光晕 */}
|
||||||
|
<Box
|
||||||
|
position="absolute"
|
||||||
|
top="50%"
|
||||||
|
left="50%"
|
||||||
|
transform="translate(-50%, -50%)"
|
||||||
|
w="24px"
|
||||||
|
h="24px"
|
||||||
|
borderRadius="full"
|
||||||
|
bg={importance.color}
|
||||||
|
opacity="0.2"
|
||||||
|
animation={
|
||||||
|
index === 0 ? 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite' : 'none'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 节点圆点 */}
|
||||||
|
<Flex
|
||||||
|
w={importance.dotSize}
|
||||||
|
h={importance.dotSize}
|
||||||
|
borderRadius="full"
|
||||||
|
bg={importance.color}
|
||||||
|
border="3px solid"
|
||||||
|
borderColor={forumColors.background.card}
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
as={EventIcon}
|
||||||
|
boxSize="8px"
|
||||||
|
color={forumColors.background.main}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 事件内容卡片 */}
|
||||||
|
<Box
|
||||||
|
flex="1"
|
||||||
|
bg={forumColors.background.secondary}
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={forumColors.border.default}
|
||||||
|
borderRadius="md"
|
||||||
|
p="4"
|
||||||
|
_hover={{
|
||||||
|
borderColor: forumColors.border.light,
|
||||||
|
bg: forumColors.background.hover,
|
||||||
|
}}
|
||||||
|
transition="all 0.2s"
|
||||||
|
>
|
||||||
|
<VStack align="stretch" spacing="2">
|
||||||
|
{/* 标题和标签 */}
|
||||||
|
<Flex justify="space-between" align="start" gap="2">
|
||||||
|
<Text
|
||||||
|
fontSize="sm"
|
||||||
|
fontWeight="600"
|
||||||
|
color={forumColors.text.primary}
|
||||||
|
flex="1"
|
||||||
|
>
|
||||||
|
{event.title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<HStack spacing="2" flexShrink="0">
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
bg="transparent"
|
||||||
|
color={eventType.color}
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={eventType.color}
|
||||||
|
fontSize="xs"
|
||||||
|
>
|
||||||
|
{eventType.label}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{event.importance === 'high' && (
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
bg={forumColors.semantic.error}
|
||||||
|
color="white"
|
||||||
|
fontSize="xs"
|
||||||
|
>
|
||||||
|
{importance.label}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* 描述 */}
|
||||||
|
{event.description && (
|
||||||
|
<Text
|
||||||
|
fontSize="xs"
|
||||||
|
color={forumColors.text.secondary}
|
||||||
|
lineHeight="1.6"
|
||||||
|
>
|
||||||
|
{event.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 相关股票 */}
|
||||||
|
{event.related_stocks && event.related_stocks.length > 0 && (
|
||||||
|
<HStack spacing="2" flexWrap="wrap">
|
||||||
|
{event.related_stocks.map((stock) => (
|
||||||
|
<Badge
|
||||||
|
key={stock}
|
||||||
|
size="sm"
|
||||||
|
bg={forumColors.gradients.goldSubtle}
|
||||||
|
color={forumColors.primary[500]}
|
||||||
|
fontSize="xs"
|
||||||
|
>
|
||||||
|
{stock}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 底部信息 */}
|
||||||
|
<Flex justify="space-between" align="center" pt="2">
|
||||||
|
<Text fontSize="xs" color={forumColors.text.muted}>
|
||||||
|
{formatEventTime(event.occurred_at)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{event.source_url && (
|
||||||
|
<Link
|
||||||
|
href={event.source_url}
|
||||||
|
isExternal
|
||||||
|
fontSize="xs"
|
||||||
|
color={forumColors.primary[500]}
|
||||||
|
display="flex"
|
||||||
|
alignItems="center"
|
||||||
|
gap="1"
|
||||||
|
_hover={{ textDecoration: 'underline' }}
|
||||||
|
>
|
||||||
|
查看来源
|
||||||
|
<ExternalLink size={12} />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
</Flex>
|
||||||
|
</MotionBox>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
{/* CSS 动画 */}
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EventTimeline;
|
||||||
203
src/views/ValueForum/components/PostCard.js
Normal file
203
src/views/ValueForum/components/PostCard.js
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* 帖子卡片组件 - 类似小红书风格
|
||||||
|
* 用于论坛主页的帖子展示
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Image,
|
||||||
|
Text,
|
||||||
|
HStack,
|
||||||
|
VStack,
|
||||||
|
Avatar,
|
||||||
|
Badge,
|
||||||
|
IconButton,
|
||||||
|
Flex,
|
||||||
|
useColorModeValue,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Heart, MessageCircle, Eye, TrendingUp } from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { forumColors } from '@theme/forumTheme';
|
||||||
|
|
||||||
|
const MotionBox = motion(Box);
|
||||||
|
|
||||||
|
const PostCard = ({ post }) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// 处理卡片点击
|
||||||
|
const handleCardClick = () => {
|
||||||
|
navigate(`/value-forum/post/${post.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化数字(1000 -> 1k)
|
||||||
|
const formatNumber = (num) => {
|
||||||
|
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
|
||||||
|
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`;
|
||||||
|
return num;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化时间
|
||||||
|
const formatTime = (dateString) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now - date;
|
||||||
|
|
||||||
|
const minutes = Math.floor(diff / 60000);
|
||||||
|
const hours = Math.floor(diff / 3600000);
|
||||||
|
const days = Math.floor(diff / 86400000);
|
||||||
|
|
||||||
|
if (minutes < 1) return '刚刚';
|
||||||
|
if (minutes < 60) return `${minutes}分钟前`;
|
||||||
|
if (hours < 24) return `${hours}小时前`;
|
||||||
|
if (days < 7) return `${days}天前`;
|
||||||
|
|
||||||
|
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MotionBox
|
||||||
|
bg={forumColors.background.card}
|
||||||
|
borderRadius="xl"
|
||||||
|
overflow="hidden"
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={forumColors.border.default}
|
||||||
|
cursor="pointer"
|
||||||
|
onClick={handleCardClick}
|
||||||
|
whileHover={{ y: -8, scale: 1.02 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
_hover={{
|
||||||
|
borderColor: forumColors.border.gold,
|
||||||
|
boxShadow: forumColors.shadows.gold,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 封面图片区域 */}
|
||||||
|
{post.images && post.images.length > 0 && (
|
||||||
|
<Box position="relative" overflow="hidden" h="200px">
|
||||||
|
<Image
|
||||||
|
src={post.images[0]}
|
||||||
|
alt={post.title}
|
||||||
|
w="100%"
|
||||||
|
h="100%"
|
||||||
|
objectFit="cover"
|
||||||
|
transition="transform 0.3s"
|
||||||
|
_groupHover={{ transform: 'scale(1.1)' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 置顶标签 */}
|
||||||
|
{post.is_pinned && (
|
||||||
|
<Badge
|
||||||
|
position="absolute"
|
||||||
|
top="12px"
|
||||||
|
right="12px"
|
||||||
|
bg={forumColors.gradients.goldPrimary}
|
||||||
|
color={forumColors.background.main}
|
||||||
|
px="3"
|
||||||
|
py="1"
|
||||||
|
borderRadius="full"
|
||||||
|
fontWeight="bold"
|
||||||
|
fontSize="xs"
|
||||||
|
display="flex"
|
||||||
|
alignItems="center"
|
||||||
|
gap="1"
|
||||||
|
>
|
||||||
|
<TrendingUp size={12} />
|
||||||
|
置顶
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 内容区域 */}
|
||||||
|
<VStack align="stretch" p="4" spacing="3">
|
||||||
|
{/* 标题 */}
|
||||||
|
<Text
|
||||||
|
fontSize="md"
|
||||||
|
fontWeight="600"
|
||||||
|
color={forumColors.text.primary}
|
||||||
|
noOfLines={2}
|
||||||
|
lineHeight="1.4"
|
||||||
|
>
|
||||||
|
{post.title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* 内容预览 */}
|
||||||
|
{post.content && (
|
||||||
|
<Text
|
||||||
|
fontSize="sm"
|
||||||
|
color={forumColors.text.secondary}
|
||||||
|
noOfLines={2}
|
||||||
|
lineHeight="1.6"
|
||||||
|
>
|
||||||
|
{post.content}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 标签 */}
|
||||||
|
{post.tags && post.tags.length > 0 && (
|
||||||
|
<HStack spacing="2" flexWrap="wrap">
|
||||||
|
{post.tags.slice(0, 3).map((tag, index) => (
|
||||||
|
<Badge
|
||||||
|
key={index}
|
||||||
|
bg={forumColors.gradients.goldSubtle}
|
||||||
|
color={forumColors.primary[500]}
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={forumColors.border.gold}
|
||||||
|
borderRadius="full"
|
||||||
|
px="3"
|
||||||
|
py="1"
|
||||||
|
fontSize="xs"
|
||||||
|
fontWeight="500"
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 底部信息栏 */}
|
||||||
|
<Flex justify="space-between" align="center" pt="2">
|
||||||
|
{/* 作者信息 */}
|
||||||
|
<HStack spacing="2">
|
||||||
|
<Avatar
|
||||||
|
size="xs"
|
||||||
|
name={post.author_name}
|
||||||
|
src={post.author_avatar}
|
||||||
|
bg={forumColors.gradients.goldPrimary}
|
||||||
|
color={forumColors.background.main}
|
||||||
|
/>
|
||||||
|
<Text fontSize="xs" color={forumColors.text.tertiary}>
|
||||||
|
{post.author_name}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{/* 互动数据 */}
|
||||||
|
<HStack spacing="4" fontSize="xs" color={forumColors.text.tertiary}>
|
||||||
|
<HStack spacing="1">
|
||||||
|
<Heart size={14} />
|
||||||
|
<Text>{formatNumber(post.likes_count || 0)}</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<HStack spacing="1">
|
||||||
|
<MessageCircle size={14} />
|
||||||
|
<Text>{formatNumber(post.comments_count || 0)}</Text>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<HStack spacing="1">
|
||||||
|
<Eye size={14} />
|
||||||
|
<Text>{formatNumber(post.views_count || 0)}</Text>
|
||||||
|
</HStack>
|
||||||
|
</HStack>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* 时间 */}
|
||||||
|
<Text fontSize="xs" color={forumColors.text.muted} textAlign="right">
|
||||||
|
{formatTime(post.created_at)}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</MotionBox>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PostCard;
|
||||||
311
src/views/ValueForum/index.js
Normal file
311
src/views/ValueForum/index.js
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
/**
|
||||||
|
* 价值论坛主页面
|
||||||
|
* 类似小红书/X的帖子广场
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Container,
|
||||||
|
Heading,
|
||||||
|
Text,
|
||||||
|
Button,
|
||||||
|
HStack,
|
||||||
|
VStack,
|
||||||
|
SimpleGrid,
|
||||||
|
Input,
|
||||||
|
InputGroup,
|
||||||
|
InputLeftElement,
|
||||||
|
Select,
|
||||||
|
Spinner,
|
||||||
|
Center,
|
||||||
|
useDisclosure,
|
||||||
|
Flex,
|
||||||
|
Badge,
|
||||||
|
} from '@chakra-ui/react';
|
||||||
|
import { Search, PenSquare, TrendingUp, Clock, Heart } from 'lucide-react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { forumColors } from '@theme/forumTheme';
|
||||||
|
import { getPosts, searchPosts } from '@services/elasticsearchService';
|
||||||
|
import PostCard from './components/PostCard';
|
||||||
|
import CreatePostModal from './components/CreatePostModal';
|
||||||
|
|
||||||
|
const MotionBox = motion(Box);
|
||||||
|
|
||||||
|
const ValueForum = () => {
|
||||||
|
const [posts, setPosts] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState('');
|
||||||
|
const [sortBy, setSortBy] = useState('created_at');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
|
||||||
|
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||||
|
|
||||||
|
// 获取帖子列表
|
||||||
|
const fetchPosts = async (currentPage = 1, reset = false) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
let result;
|
||||||
|
if (searchKeyword.trim()) {
|
||||||
|
result = await searchPosts(searchKeyword, {
|
||||||
|
page: currentPage,
|
||||||
|
size: 20,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
result = await getPosts({
|
||||||
|
page: currentPage,
|
||||||
|
size: 20,
|
||||||
|
sort: sortBy,
|
||||||
|
order: 'desc',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reset) {
|
||||||
|
setPosts(result.posts);
|
||||||
|
} else {
|
||||||
|
setPosts((prev) => [...prev, ...result.posts]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTotal(result.total);
|
||||||
|
setHasMore(result.posts.length === 20);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取帖子列表失败:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化加载
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPosts(1, true);
|
||||||
|
}, [sortBy]);
|
||||||
|
|
||||||
|
// 搜索处理
|
||||||
|
const handleSearch = () => {
|
||||||
|
setPage(1);
|
||||||
|
fetchPosts(1, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载更多
|
||||||
|
const loadMore = () => {
|
||||||
|
const nextPage = page + 1;
|
||||||
|
setPage(nextPage);
|
||||||
|
fetchPosts(nextPage, false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 发帖成功回调
|
||||||
|
const handlePostCreated = () => {
|
||||||
|
setPage(1);
|
||||||
|
fetchPosts(1, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 排序选项
|
||||||
|
const sortOptions = [
|
||||||
|
{ value: 'created_at', label: '最新发布', icon: Clock },
|
||||||
|
{ value: 'likes_count', label: '最多点赞', icon: Heart },
|
||||||
|
{ value: 'views_count', label: '最多浏览', icon: TrendingUp },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
minH="100vh"
|
||||||
|
bg={forumColors.background.main}
|
||||||
|
pt="80px"
|
||||||
|
pb="20"
|
||||||
|
>
|
||||||
|
<Container maxW="container.xl">
|
||||||
|
{/* 顶部横幅 */}
|
||||||
|
<MotionBox
|
||||||
|
initial={{ opacity: 0, y: -20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
mb="10"
|
||||||
|
>
|
||||||
|
<VStack spacing="4" align="stretch">
|
||||||
|
{/* 标题区域 */}
|
||||||
|
<Flex justify="space-between" align="center">
|
||||||
|
<VStack align="start" spacing="2">
|
||||||
|
<Heading
|
||||||
|
as="h1"
|
||||||
|
fontSize="4xl"
|
||||||
|
fontWeight="bold"
|
||||||
|
bgGradient={forumColors.text.goldGradient}
|
||||||
|
bgClip="text"
|
||||||
|
>
|
||||||
|
价值论坛
|
||||||
|
</Heading>
|
||||||
|
<Text color={forumColors.text.secondary} fontSize="md">
|
||||||
|
分享投资见解,追踪市场热点,共同发现价值
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
{/* 发帖按钮 */}
|
||||||
|
<Button
|
||||||
|
leftIcon={<PenSquare size={18} />}
|
||||||
|
bg={forumColors.gradients.goldPrimary}
|
||||||
|
color={forumColors.background.main}
|
||||||
|
size="lg"
|
||||||
|
fontWeight="bold"
|
||||||
|
onClick={onOpen}
|
||||||
|
_hover={{
|
||||||
|
transform: 'translateY(-2px)',
|
||||||
|
boxShadow: forumColors.shadows.goldHover,
|
||||||
|
}}
|
||||||
|
_active={{ transform: 'translateY(0)' }}
|
||||||
|
>
|
||||||
|
发布帖子
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* 搜索和筛选栏 */}
|
||||||
|
<Flex gap="4" align="center" flexWrap="wrap">
|
||||||
|
{/* 搜索框 */}
|
||||||
|
<InputGroup maxW="400px" flex="1">
|
||||||
|
<InputLeftElement pointerEvents="none">
|
||||||
|
<Search size={18} color={forumColors.text.tertiary} />
|
||||||
|
</InputLeftElement>
|
||||||
|
<Input
|
||||||
|
placeholder="搜索帖子标题、内容、标签..."
|
||||||
|
value={searchKeyword}
|
||||||
|
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||||
|
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
bg={forumColors.background.card}
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={forumColors.border.default}
|
||||||
|
color={forumColors.text.primary}
|
||||||
|
_placeholder={{ color: forumColors.text.tertiary }}
|
||||||
|
_hover={{ borderColor: forumColors.border.light }}
|
||||||
|
_focus={{
|
||||||
|
borderColor: forumColors.border.gold,
|
||||||
|
boxShadow: `0 0 0 1px ${forumColors.border.goldGlow}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
|
||||||
|
{/* 排序选项 */}
|
||||||
|
<HStack spacing="2">
|
||||||
|
{sortOptions.map((option) => {
|
||||||
|
const Icon = option.icon;
|
||||||
|
const isActive = sortBy === option.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={option.value}
|
||||||
|
leftIcon={<Icon size={16} />}
|
||||||
|
size="md"
|
||||||
|
variant={isActive ? 'solid' : 'outline'}
|
||||||
|
bg={isActive ? forumColors.gradients.goldPrimary : 'transparent'}
|
||||||
|
color={isActive ? forumColors.background.main : forumColors.text.secondary}
|
||||||
|
borderColor={forumColors.border.default}
|
||||||
|
onClick={() => setSortBy(option.value)}
|
||||||
|
_hover={{
|
||||||
|
bg: isActive
|
||||||
|
? forumColors.gradients.goldPrimary
|
||||||
|
: forumColors.background.hover,
|
||||||
|
borderColor: forumColors.border.gold,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</HStack>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* 统计信息 */}
|
||||||
|
<HStack spacing="6" color={forumColors.text.tertiary} fontSize="sm">
|
||||||
|
<Text>
|
||||||
|
共 <Text as="span" color={forumColors.primary[500]} fontWeight="bold">{total}</Text> 篇帖子
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
</VStack>
|
||||||
|
</MotionBox>
|
||||||
|
|
||||||
|
{/* 帖子网格 */}
|
||||||
|
{loading && page === 1 ? (
|
||||||
|
<Center py="20">
|
||||||
|
<VStack spacing="4">
|
||||||
|
<Spinner
|
||||||
|
size="xl"
|
||||||
|
thickness="4px"
|
||||||
|
speed="0.8s"
|
||||||
|
color={forumColors.primary[500]}
|
||||||
|
/>
|
||||||
|
<Text color={forumColors.text.secondary}>加载中...</Text>
|
||||||
|
</VStack>
|
||||||
|
</Center>
|
||||||
|
) : posts.length === 0 ? (
|
||||||
|
<Center py="20">
|
||||||
|
<VStack spacing="4">
|
||||||
|
<Text color={forumColors.text.secondary} fontSize="lg">
|
||||||
|
{searchKeyword ? '未找到相关帖子' : '暂无帖子,快来发布第一篇吧!'}
|
||||||
|
</Text>
|
||||||
|
{!searchKeyword && (
|
||||||
|
<Button
|
||||||
|
leftIcon={<PenSquare size={18} />}
|
||||||
|
bg={forumColors.gradients.goldPrimary}
|
||||||
|
color={forumColors.background.main}
|
||||||
|
onClick={onOpen}
|
||||||
|
_hover={{ opacity: 0.9 }}
|
||||||
|
>
|
||||||
|
发布帖子
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</Center>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<SimpleGrid columns={{ base: 1, md: 2, lg: 3, xl: 4 }} spacing="6">
|
||||||
|
<AnimatePresence>
|
||||||
|
{posts.map((post, index) => (
|
||||||
|
<MotionBox
|
||||||
|
key={post.id}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
transition={{ duration: 0.3, delay: index * 0.05 }}
|
||||||
|
>
|
||||||
|
<PostCard post={post} />
|
||||||
|
</MotionBox>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{/* 加载更多按钮 */}
|
||||||
|
{hasMore && (
|
||||||
|
<Center mt="10">
|
||||||
|
<Button
|
||||||
|
onClick={loadMore}
|
||||||
|
isLoading={loading}
|
||||||
|
loadingText="加载中..."
|
||||||
|
bg={forumColors.background.card}
|
||||||
|
color={forumColors.text.primary}
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={forumColors.border.default}
|
||||||
|
_hover={{
|
||||||
|
borderColor: forumColors.border.gold,
|
||||||
|
bg: forumColors.background.hover,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
加载更多
|
||||||
|
</Button>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
{/* 发帖模态框 */}
|
||||||
|
<CreatePostModal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
onPostCreated={handlePostCreated}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ValueForum;
|
||||||
Reference in New Issue
Block a user