From b11c9e91d3e425fb4ceca82db2c84543acc19956 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Tue, 13 Jan 2026 10:57:38 +0800 Subject: [PATCH 01/22] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0mock=E6=95=B0?= =?UTF-8?q?=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/mocks/handlers/account.js | 91 + src/mocks/handlers/event.js | 4469 ++++++++++++++++++--------------- 2 files changed, 2585 insertions(+), 1975 deletions(-) diff --git a/src/mocks/handlers/account.js b/src/mocks/handlers/account.js index d7bc881f..15b1aa86 100644 --- a/src/mocks/handlers/account.js +++ b/src/mocks/handlers/account.js @@ -768,6 +768,97 @@ export const accountHandlers = [ }); }), + // ==================== 账号绑定管理 ==================== + + // 手机号发送验证码 + http.post('/api/account/phone/send-code', async ({ request }) => { + await delay(NETWORK_DELAY); + const body = await request.json(); + console.log('[Mock] 发送手机验证码:', body.phone); + return HttpResponse.json({ + success: true, + message: '验证码已发送' + }); + }), + + // 手机号绑定 + http.post('/api/account/phone/bind', async ({ request }) => { + await delay(NETWORK_DELAY); + const body = await request.json(); + console.log('[Mock] 绑定手机号:', body.phone); + return HttpResponse.json({ + success: true, + message: '手机号绑定成功', + data: { phone: body.phone, phone_confirmed: true } + }); + }), + + // 手机号解绑 + http.post('/api/account/phone/unbind', async () => { + await delay(NETWORK_DELAY); + console.log('[Mock] 解绑手机号'); + return HttpResponse.json({ + success: true, + message: '手机号解绑成功' + }); + }), + + // 邮箱发送验证码 + http.post('/api/account/email/send-bind-code', async ({ request }) => { + await delay(NETWORK_DELAY); + const body = await request.json(); + console.log('[Mock] 发送邮箱验证码:', body.email); + return HttpResponse.json({ + success: true, + message: '验证码已发送' + }); + }), + + // 邮箱绑定 + http.post('/api/account/email/bind', async ({ request }) => { + await delay(NETWORK_DELAY); + const body = await request.json(); + console.log('[Mock] 绑定邮箱:', body.email); + return HttpResponse.json({ + success: true, + message: '邮箱绑定成功', + user: { email: body.email, email_confirmed: true } + }); + }), + + // 微信获取二维码 + http.get('/api/account/wechat/qrcode', async () => { + await delay(NETWORK_DELAY); + console.log('[Mock] 获取微信绑定二维码'); + return HttpResponse.json({ + success: true, + auth_url: 'https://open.weixin.qq.com/connect/qrconnect?mock=true', + session_id: 'mock_session_' + Date.now() + }); + }), + + // 微信绑定检查 + http.post('/api/account/wechat/check', async ({ request }) => { + await delay(NETWORK_DELAY); + const body = await request.json(); + console.log('[Mock] 检查微信绑定状态:', body.session_id); + // 模拟绑定成功 + return HttpResponse.json({ + success: true, + status: 'bind_ready' + }); + }), + + // 微信解绑 + http.post('/api/account/wechat/unbind', async () => { + await delay(NETWORK_DELAY); + console.log('[Mock] 解绑微信'); + return HttpResponse.json({ + success: true, + message: '微信解绑成功' + }); + }), + // 21. 获取订阅套餐列表 http.get('/api/subscription/plans', async () => { await delay(NETWORK_DELAY); diff --git a/src/mocks/handlers/event.js b/src/mocks/handlers/event.js index 5b788842..84fbefeb 100644 --- a/src/mocks/handlers/event.js +++ b/src/mocks/handlers/event.js @@ -1,14 +1,25 @@ // src/mocks/handlers/event.js // 事件相关的 Mock API Handlers -import { http, HttpResponse } from 'msw'; -import { getEventRelatedStocks, generateMockEvents, generateHotEvents, generatePopularKeywords, generateDynamicNewsEvents } from '../data/events'; -import { getMockFutureEvents, getMockEventCountsForMonth, toggleEventFollowStatus, isEventFollowed } from '../data/account'; -import { generatePopularConcepts } from './concept'; -import { getCurrentUser } from '../data/users'; +import { http, HttpResponse } from "msw"; +import { + getEventRelatedStocks, + generateMockEvents, + generateHotEvents, + generatePopularKeywords, + generateDynamicNewsEvents, +} from "../data/events"; +import { + getMockFutureEvents, + getMockEventCountsForMonth, + toggleEventFollowStatus, + isEventFollowed, +} from "../data/account"; +import { generatePopularConcepts } from "./concept"; +import { getCurrentUser } from "../data/users"; // 模拟网络延迟 -const delay = (ms = 300) => new Promise(resolve => setTimeout(resolve, ms)); +const delay = (ms = 300) => new Promise((resolve) => setTimeout(resolve, ms)); // ==================== 评论内存存储 ==================== // 用于在 Mock 环境下持久化评论数据(按 eventId 分组) @@ -20,67 +31,67 @@ const commentsStore = new Map(); * @returns {Array} 初始的 15 条 mock 评论 */ const initializeMockComments = (eventId) => { - const comments = []; - const users = [ - { username: '张三', avatar: null }, - { username: '李四', avatar: null }, - { username: '王五', avatar: null }, - { username: '赵六', avatar: null }, - { username: '投资达人', avatar: null }, - { username: '价值投资者', avatar: null }, - { username: '技术分析师', avatar: null }, - { username: '基本面研究员', avatar: null }, - { username: '量化交易员', avatar: null }, - { username: '市场观察者', avatar: null }, - { username: '行业分析师', avatar: null }, - { username: '财经评论员', avatar: null }, - { username: '趋势跟踪者', avatar: null }, - { username: '价值发现者', avatar: null }, - { username: '理性投资人', avatar: null }, - ]; + const comments = []; + const users = [ + { username: "张三", avatar: null }, + { username: "李四", avatar: null }, + { username: "王五", avatar: null }, + { username: "赵六", avatar: null }, + { username: "投资达人", avatar: null }, + { username: "价值投资者", avatar: null }, + { username: "技术分析师", avatar: null }, + { username: "基本面研究员", avatar: null }, + { username: "量化交易员", avatar: null }, + { username: "市场观察者", avatar: null }, + { username: "行业分析师", avatar: null }, + { username: "财经评论员", avatar: null }, + { username: "趋势跟踪者", avatar: null }, + { username: "价值发现者", avatar: null }, + { username: "理性投资人", avatar: null }, + ]; - const commentTemplates = [ - '这个事件对相关板块影响很大,值得关注后续发展', - '相关概念股已经开始异动了,市场反应很快', - '感谢分享,这个事件我之前没注意到', - '从基本面来看,这个事件会带来实质性利好', - '需要观察后续政策落地情况,现在下结论还太早', - '相关产业链的龙头企业值得重点关注', - '这类事件一般都是短期刺激,长期影响有限', - '建议大家理性对待,不要盲目追高', - '这个消息已经在预期之中,股价可能提前反应了', - '关键要看后续的执行力度和落地速度', - '建议关注产业链上下游的投资机会', - '短期可能会有波动,但长期逻辑依然成立', - '市场情绪很高涨,需要警惕追高风险', - '从历史数据来看,类似事件后续表现都不错', - '这是一个结构性机会,需要精选个股', - ]; + const commentTemplates = [ + "这个事件对相关板块影响很大,值得关注后续发展", + "相关概念股已经开始异动了,市场反应很快", + "感谢分享,这个事件我之前没注意到", + "从基本面来看,这个事件会带来实质性利好", + "需要观察后续政策落地情况,现在下结论还太早", + "相关产业链的龙头企业值得重点关注", + "这类事件一般都是短期刺激,长期影响有限", + "建议大家理性对待,不要盲目追高", + "这个消息已经在预期之中,股价可能提前反应了", + "关键要看后续的执行力度和落地速度", + "建议关注产业链上下游的投资机会", + "短期可能会有波动,但长期逻辑依然成立", + "市场情绪很高涨,需要警惕追高风险", + "从历史数据来看,类似事件后续表现都不错", + "这是一个结构性机会,需要精选个股", + ]; - for (let i = 0; i < 15; i++) { - const hoursAgo = Math.floor(Math.random() * 48) + 1; // 1-48 小时前 - const createdAt = new Date(Date.now() - hoursAgo * 60 * 60 * 1000); - const user = users[i % users.length]; + for (let i = 0; i < 15; i++) { + const hoursAgo = Math.floor(Math.random() * 48) + 1; // 1-48 小时前 + const createdAt = new Date(Date.now() - hoursAgo * 60 * 60 * 1000); + const user = users[i % users.length]; - comments.push({ - id: `comment_${eventId}_${i + 1}`, - content: commentTemplates[i % commentTemplates.length], - content_type: 'text', - author: { - id: `user_${i + 1}`, - username: user.username, - avatar: user.avatar, - }, - created_at: createdAt.toISOString(), - likes_count: Math.floor(Math.random() * 20), - is_liked: false, - }); - } + comments.push({ + id: `comment_${eventId}_${i + 1}`, + content: commentTemplates[i % commentTemplates.length], + content_type: "text", + author: { + id: `user_${i + 1}`, + username: user.username, + avatar: user.avatar, + }, + created_at: createdAt.toISOString(), + likes_count: Math.floor(Math.random() * 20), + is_liked: false, + }); + } - // 按时间升序排序(最旧的在前) - comments.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); + // 按时间升序排序(最旧的在前) + comments.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); - return comments; + return comments; }; /** @@ -89,1983 +100,2491 @@ const initializeMockComments = (eventId) => { * @returns {Array} 评论列表 */ const getOrInitComments = (eventId) => { - if (!commentsStore.has(eventId)) { - commentsStore.set(eventId, initializeMockComments(eventId)); - } - return commentsStore.get(eventId); + if (!commentsStore.has(eventId)) { + commentsStore.set(eventId, initializeMockComments(eventId)); + } + return commentsStore.get(eventId); }; export const eventHandlers = [ - // ==================== 事件列表相关 ==================== + // ==================== 事件列表相关 ==================== - // 获取事件列表 - http.get('/api/events', async ({ request }) => { - await delay(500); + // 获取事件列表 + http.get("/api/events", async ({ request }) => { + await delay(500); - const url = new URL(request.url); - const params = { - page: parseInt(url.searchParams.get('page') || '1'), - per_page: parseInt(url.searchParams.get('per_page') || '10'), - sort: url.searchParams.get('sort') || 'new', - importance: url.searchParams.get('importance') || 'all', - date_range: url.searchParams.get('date_range') || '', - q: url.searchParams.get('q') || '', - industry_code: url.searchParams.get('industry_code') || '', - industry_classification: url.searchParams.get('industry_classification') || '', - stock_code: url.searchParams.get('stock_code') || '', + const url = new URL(request.url); + const params = { + page: parseInt(url.searchParams.get("page") || "1"), + per_page: parseInt(url.searchParams.get("per_page") || "10"), + sort: url.searchParams.get("sort") || "new", + importance: url.searchParams.get("importance") || "all", + date_range: url.searchParams.get("date_range") || "", + q: url.searchParams.get("q") || "", + industry_code: url.searchParams.get("industry_code") || "", + industry_classification: + url.searchParams.get("industry_classification") || "", + stock_code: url.searchParams.get("stock_code") || "", + }; + + console.log("[Mock] 获取事件列表:", params); + + try { + const result = generateMockEvents(params); + + // 返回格式兼容 useEventData 期望的结构 + // useEventData 期望: { success, data: { events: [], pagination: {} } } + return HttpResponse.json({ + success: true, + data: { + events: result.events, // 事件数组 + pagination: result.pagination, // 分页信息 + }, + message: "获取成功", + }); + } catch (error) { + console.error("[Mock] 获取事件列表失败:", error); + console.error("[Mock] Error details:", { + message: error.message, + stack: error.stack, + params: params, + }); + return HttpResponse.json( + { + success: false, + error: "获取事件列表失败", + data: [], + pagination: { + page: 1, + per_page: 10, + total: 0, + pages: 0, + has_prev: false, + has_next: false, + }, + }, + { status: 500 } + ); + } + }), + + // 获取热点事件 + http.get("/api/events/hot", async ({ request }) => { + await delay(300); + + const url = new URL(request.url); + const limit = parseInt(url.searchParams.get("limit") || "5"); + + console.log("[Mock] 获取热点事件, limit:", limit); + + try { + const hotEvents = generateHotEvents(limit); + + return HttpResponse.json({ + success: true, + data: hotEvents, + message: "获取成功", + }); + } catch (error) { + console.error("[Mock] 获取热点事件失败:", error); + return HttpResponse.json( + { + success: false, + error: "获取热点事件失败", + data: [], + }, + { status: 500 } + ); + } + }), + + // 获取热门关键词 + http.get("/api/events/keywords/popular", async ({ request }) => { + await delay(300); + + const url = new URL(request.url); + const limit = parseInt(url.searchParams.get("limit") || "20"); + + console.log("[Mock] 获取热门关键词, limit:", limit); + + try { + const keywords = generatePopularKeywords(limit); + + return HttpResponse.json({ + success: true, + data: keywords, + message: "获取成功", + }); + } catch (error) { + console.error("[Mock] 获取热门关键词失败:", error); + return HttpResponse.json( + { + success: false, + error: "获取热门关键词失败", + data: [], + }, + { status: 500 } + ); + } + }), + + // 获取动态新闻(实时要闻·动态追踪专用) + http.get("/api/events/dynamic-news", async ({ request }) => { + await delay(400); + + const url = new URL(request.url); + const count = parseInt(url.searchParams.get("count") || "30"); + const startTime = url.searchParams.get("start_time"); + const endTime = url.searchParams.get("end_time"); + + console.log( + "[Mock] 获取动态新闻, count:", + count, + "startTime:", + startTime, + "endTime:", + endTime + ); + + try { + let timeRange = null; + if (startTime && endTime) { + timeRange = { + startTime: new Date(startTime), + endTime: new Date(endTime), }; + } - console.log('[Mock] 获取事件列表:', params); + const events = generateDynamicNewsEvents(timeRange, count); - try { - const result = generateMockEvents(params); + return HttpResponse.json({ + success: true, + data: events, + total: events.length, + message: "获取成功", + }); + } catch (error) { + console.error("[Mock] 获取动态新闻失败:", error); + return HttpResponse.json( + { + success: false, + error: "获取动态新闻失败", + data: [], + }, + { status: 500 } + ); + } + }), - // 返回格式兼容 useEventData 期望的结构 - // useEventData 期望: { success, data: { events: [], pagination: {} } } - return HttpResponse.json({ - success: true, - data: { - events: result.events, // 事件数组 - pagination: result.pagination // 分页信息 - }, - message: '获取成功' - }); - } catch (error) { - console.error('[Mock] 获取事件列表失败:', error); - console.error('[Mock] Error details:', { - message: error.message, - stack: error.stack, - params: params - }); - return HttpResponse.json( - { - success: false, - error: '获取事件列表失败', - data: [], - pagination: { - page: 1, - per_page: 10, - total: 0, - pages: 0, - has_prev: false, - has_next: false - } - }, - { status: 500 } - ); - } - }), + // ==================== 主线模式相关(必须在 :eventId 之前,否则会被通配符匹配)==================== - // 获取热点事件 - http.get('/api/events/hot', async ({ request }) => { - await delay(300); + // 获取按主线(lv1/lv2/lv3概念)分组的事件列表 + http.get("/api/events/mainline", async ({ request }) => { + await delay(500); - const url = new URL(request.url); - const limit = parseInt(url.searchParams.get('limit') || '5'); + const url = new URL(request.url); + const recentDays = parseInt(url.searchParams.get("recent_days") || "7", 10); + const importance = url.searchParams.get("importance") || "all"; + const limitPerMainline = parseInt( + url.searchParams.get("limit") || "20", + 10 + ); + const groupBy = url.searchParams.get("group_by") || "lv2"; - console.log('[Mock] 获取热点事件, limit:', limit); + try { + // 生成 mock 事件数据 - 第一个参数是 timeRange(null 表示默认24小时),第二个参数是 count + const allEvents = generateDynamicNewsEvents(null, 100); - try { - const hotEvents = generateHotEvents(limit); + const mainlineDefinitions = [ + { + lv3_id: "L3_AI_CHIP", + lv3_name: "AI芯片与算力", + lv2_id: "L2_AI_INFRA", + lv2_name: "AI基础设施 (算力/CPO/PCB)", + lv1_id: "L1_TMT", + lv1_name: "TMT (科技/媒体/通信)", + keywords: ["算力", "AI芯片", "GPU", "英伟达", "华为昇腾", "寒武纪"], + }, + { + lv3_id: "L3_AI_SERVER", + lv3_name: "服务器与数据中心", + lv2_id: "L2_AI_INFRA", + lv2_name: "AI基础设施 (算力/CPO/PCB)", + lv1_id: "L1_TMT", + lv1_name: "TMT (科技/媒体/通信)", + keywords: ["服务器", "数据中心", "智算中心", "液冷"], + }, + { + lv3_id: "L3_OPTICAL", + lv3_name: "光通信与CPO", + lv2_id: "L2_AI_INFRA", + lv2_name: "AI基础设施 (算力/CPO/PCB)", + lv1_id: "L1_TMT", + lv1_name: "TMT (科技/媒体/通信)", + keywords: ["CPO", "光通信", "光模块", "光芯片"], + }, + { + lv3_id: "L3_PCB", + lv3_name: "PCB与封装", + lv2_id: "L2_AI_INFRA", + lv2_name: "AI基础设施 (算力/CPO/PCB)", + lv1_id: "L1_TMT", + lv1_name: "TMT (科技/媒体/通信)", + keywords: ["PCB", "封装", "AI PCB"], + }, + { + lv3_id: "L3_AI_APP", + lv3_name: "AI应用与大模型", + lv2_id: "L2_AI_INFRA", + lv2_name: "AI基础设施 (算力/CPO/PCB)", + lv1_id: "L1_TMT", + lv1_name: "TMT (科技/媒体/通信)", + keywords: [ + "大模型", + "智能体", + "AI", + "人工智能", + "DeepSeek", + "KIMI", + "ChatGPT", + ], + }, + { + lv3_id: "L3_CHIP_DESIGN", + lv3_name: "芯片设计", + lv2_id: "L2_SEMICONDUCTOR", + lv2_name: "半导体 (设计/制造/封测)", + lv1_id: "L1_TMT", + lv1_name: "TMT (科技/媒体/通信)", + keywords: ["芯片设计", "半导体", "IC设计"], + }, + { + lv3_id: "L3_CHIP_MFG", + lv3_name: "芯片制造", + lv2_id: "L2_SEMICONDUCTOR", + lv2_name: "半导体 (设计/制造/封测)", + lv1_id: "L1_TMT", + lv1_name: "TMT (科技/媒体/通信)", + keywords: ["晶圆", "光刻", "芯片制造", "中芯国际"], + }, + { + lv3_id: "L3_HUMANOID", + lv3_name: "人形机器人", + lv2_id: "L2_ROBOT", + lv2_name: "机器人 (人形机器人/工业机器人)", + lv1_id: "L1_TMT", + lv1_name: "TMT (科技/媒体/通信)", + keywords: ["人形机器人", "具身智能", "特斯拉机器人"], + }, + { + lv3_id: "L3_INDUSTRIAL_ROBOT", + lv3_name: "工业机器人", + lv2_id: "L2_ROBOT", + lv2_name: "机器人 (人形机器人/工业机器人)", + lv1_id: "L1_TMT", + lv1_name: "TMT (科技/媒体/通信)", + keywords: ["工业机器人", "自动化", "机器人"], + }, + { + lv3_id: "L3_MOBILE", + lv3_name: "智能手机", + lv2_id: "L2_CONSUMER_ELEC", + lv2_name: "消费电子 (手机/XR/可穿戴)", + lv1_id: "L1_TMT", + lv1_name: "TMT (科技/媒体/通信)", + keywords: ["手机", "华为", "苹果", "小米", "折叠屏"], + }, + { + lv3_id: "L3_XR", + lv3_name: "XR与可穿戴", + lv2_id: "L2_CONSUMER_ELEC", + lv2_name: "消费电子 (手机/XR/可穿戴)", + lv1_id: "L1_TMT", + lv1_name: "TMT (科技/媒体/通信)", + keywords: ["XR", "VR", "AR", "可穿戴", "MR", "Vision Pro"], + }, + { + lv3_id: "L3_5G", + lv3_name: "5G/6G通信", + lv2_id: "L2_TELECOM", + lv2_name: "通信、互联网与软件", + lv1_id: "L1_TMT", + lv1_name: "TMT (科技/媒体/通信)", + keywords: ["5G", "6G", "通信", "基站"], + }, + { + lv3_id: "L3_CLOUD", + lv3_name: "云计算与软件", + lv2_id: "L2_TELECOM", + lv2_name: "通信、互联网与软件", + lv1_id: "L1_TMT", + lv1_name: "TMT (科技/媒体/通信)", + keywords: ["云计算", "软件", "SaaS", "互联网", "数字化"], + }, + { + lv3_id: "L3_PV", + lv3_name: "光伏", + lv2_id: "L2_NEW_ENERGY", + lv2_name: "新能源 (光伏/储能/电池)", + lv1_id: "L1_NEW_ENERGY_ENV", + lv1_name: "新能源与智能汽车", + keywords: ["光伏", "太阳能", "硅片", "组件"], + }, + { + lv3_id: "L3_STORAGE", + lv3_name: "储能与电池", + lv2_id: "L2_NEW_ENERGY", + lv2_name: "新能源 (光伏/储能/电池)", + lv1_id: "L1_NEW_ENERGY_ENV", + lv1_name: "新能源与智能汽车", + keywords: ["储能", "电池", "锂电", "固态电池", "新能源"], + }, + { + lv3_id: "L3_EV_OEM", + lv3_name: "新能源整车", + lv2_id: "L2_EV", + lv2_name: "智能网联汽车", + lv1_id: "L1_NEW_ENERGY_ENV", + lv1_name: "新能源与智能汽车", + keywords: ["新能源汽车", "电动车", "比亚迪", "特斯拉", "整车"], + }, + { + lv3_id: "L3_AUTO_DRIVE", + lv3_name: "智能驾驶", + lv2_id: "L2_EV", + lv2_name: "智能网联汽车", + lv1_id: "L1_NEW_ENERGY_ENV", + lv1_name: "新能源与智能汽车", + keywords: ["智能驾驶", "自动驾驶", "智能网联", "车路协同"], + }, + { + lv3_id: "L3_DRONE", + lv3_name: "无人机", + lv2_id: "L2_LOW_ALTITUDE", + lv2_name: "低空经济 (无人机/eVTOL)", + lv1_id: "L1_ADVANCED_MFG", + lv1_name: "先进制造", + keywords: ["无人机", "低空", "空域"], + }, + { + lv3_id: "L3_EVTOL", + lv3_name: "eVTOL", + lv2_id: "L2_LOW_ALTITUDE", + lv2_name: "低空经济 (无人机/eVTOL)", + lv1_id: "L1_ADVANCED_MFG", + lv1_name: "先进制造", + keywords: ["eVTOL", "飞行汽车", "空中出租车"], + }, + { + lv3_id: "L3_AEROSPACE", + lv3_name: "航空航天", + lv2_id: "L2_MILITARY", + lv2_name: "军工 (航空航天/国防)", + lv1_id: "L1_ADVANCED_MFG", + lv1_name: "先进制造", + keywords: ["航空", "航天", "卫星", "火箭", "军工"], + }, + { + lv3_id: "L3_DEFENSE", + lv3_name: "国防军工", + lv2_id: "L2_MILITARY", + lv2_name: "军工 (航空航天/国防)", + lv1_id: "L1_ADVANCED_MFG", + lv1_name: "先进制造", + keywords: ["国防", "导弹", "军工装备"], + }, + { + lv3_id: "L3_DRUG", + lv3_name: "创新药", + lv2_id: "L2_PHARMA", + lv2_name: "医药医疗 (创新药/器械)", + lv1_id: "L1_PHARMA", + lv1_name: "医药健康", + keywords: ["创新药", "医药", "生物", "CXO"], + }, + { + lv3_id: "L3_DEVICE", + lv3_name: "医疗器械", + lv2_id: "L2_PHARMA", + lv2_name: "医药医疗 (创新药/器械)", + lv1_id: "L1_PHARMA", + lv1_name: "医药健康", + keywords: ["医疗器械", "医疗", "器械"], + }, + { + lv3_id: "L3_BANK", + lv3_name: "银行", + lv2_id: "L2_FINANCE", + lv2_name: "金融 (银行/券商/保险)", + lv1_id: "L1_FINANCE", + lv1_name: "金融", + keywords: ["银行", "金融"], + }, + { + lv3_id: "L3_BROKER", + lv3_name: "券商", + lv2_id: "L2_FINANCE", + lv2_name: "金融 (银行/券商/保险)", + lv1_id: "L1_FINANCE", + lv1_name: "金融", + keywords: ["券商", "证券"], + }, + ]; - return HttpResponse.json({ - success: true, - data: hotEvents, - message: '获取成功' - }); - } catch (error) { - console.error('[Mock] 获取热点事件失败:', error); - return HttpResponse.json( - { - success: false, - error: '获取热点事件失败', - data: [] - }, - { status: 500 } - ); - } - }), + const hierarchyOptions = { + lv1: [ + ...new Map( + mainlineDefinitions.map((m) => [ + m.lv1_id, + { id: m.lv1_id, name: m.lv1_name }, + ]) + ).values(), + ], + lv2: [ + ...new Map( + mainlineDefinitions.map((m) => [ + m.lv2_id, + { + id: m.lv2_id, + name: m.lv2_name, + lv1_id: m.lv1_id, + lv1_name: m.lv1_name, + }, + ]) + ).values(), + ], + lv3: mainlineDefinitions.map((m) => ({ + id: m.lv3_id, + name: m.lv3_name, + lv2_id: m.lv2_id, + lv2_name: m.lv2_name, + lv1_id: m.lv1_id, + lv1_name: m.lv1_name, + })), + }; - // 获取热门关键词 - http.get('/api/events/keywords/popular', async ({ request }) => { - await delay(300); + const mainlineGroups = {}; + const isSpecificId = + groupBy.startsWith("L1_") || + groupBy.startsWith("L2_") || + groupBy.startsWith("L3_"); - const url = new URL(request.url); - const limit = parseInt(url.searchParams.get('limit') || '20'); + allEvents.forEach((event) => { + const keywords = event.keywords || event.related_concepts || []; + const conceptNames = keywords + .map((k) => { + if (typeof k === "string") return k; + if (typeof k === "object" && k !== null) + return k.concept || k.name || ""; + return ""; + }) + .filter(Boolean) + .join(" "); + const titleAndDesc = `${event.title || ""} ${event.description || ""}`; + const textToMatch = `${conceptNames} ${titleAndDesc}`.toLowerCase(); - console.log('[Mock] 获取热门关键词, limit:', limit); + mainlineDefinitions.forEach((mainline) => { + const matched = mainline.keywords.some((kw) => + textToMatch.includes(kw.toLowerCase()) + ); + if (matched) { + let groupKey, groupData; - try { - const keywords = generatePopularKeywords(limit); - - return HttpResponse.json({ - success: true, - data: keywords, - message: '获取成功' - }); - } catch (error) { - console.error('[Mock] 获取热门关键词失败:', error); - return HttpResponse.json( - { - success: false, - error: '获取热门关键词失败', - data: [] - }, - { status: 500 } - ); - } - }), - - // 获取动态新闻(实时要闻·动态追踪专用) - http.get('/api/events/dynamic-news', async ({ request }) => { - await delay(400); - - const url = new URL(request.url); - const count = parseInt(url.searchParams.get('count') || '30'); - const startTime = url.searchParams.get('start_time'); - const endTime = url.searchParams.get('end_time'); - - console.log('[Mock] 获取动态新闻, count:', count, 'startTime:', startTime, 'endTime:', endTime); - - try { - let timeRange = null; - if (startTime && endTime) { - timeRange = { - startTime: new Date(startTime), - endTime: new Date(endTime) + if (isSpecificId) { + if (groupBy.startsWith("L1_") && mainline.lv1_id === groupBy) { + groupKey = mainline.lv2_id; + groupData = { + group_id: mainline.lv2_id, + group_name: mainline.lv2_name, + parent_name: mainline.lv1_name, + events: [], }; - } - - const events = generateDynamicNewsEvents(timeRange, count); - - return HttpResponse.json({ - success: true, - data: events, - total: events.length, - message: '获取成功' - }); - } catch (error) { - console.error('[Mock] 获取动态新闻失败:', error); - return HttpResponse.json( - { - success: false, - error: '获取动态新闻失败', - data: [] - }, - { status: 500 } - ); - } - }), - - // ==================== 主线模式相关(必须在 :eventId 之前,否则会被通配符匹配)==================== - - // 获取按主线(lv1/lv2/lv3概念)分组的事件列表 - http.get('/api/events/mainline', async ({ request }) => { - await delay(500); - - const url = new URL(request.url); - const recentDays = parseInt(url.searchParams.get('recent_days') || '7', 10); - const importance = url.searchParams.get('importance') || 'all'; - const limitPerMainline = parseInt(url.searchParams.get('limit') || '20', 10); - const groupBy = url.searchParams.get('group_by') || 'lv2'; - - try { - // 生成 mock 事件数据 - 第一个参数是 timeRange(null 表示默认24小时),第二个参数是 count - const allEvents = generateDynamicNewsEvents(null, 100); - - const mainlineDefinitions = [ - { lv3_id: 'L3_AI_CHIP', lv3_name: 'AI芯片与算力', lv2_id: 'L2_AI_INFRA', lv2_name: 'AI基础设施 (算力/CPO/PCB)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['算力', 'AI芯片', 'GPU', '英伟达', '华为昇腾', '寒武纪'] }, - { lv3_id: 'L3_AI_SERVER', lv3_name: '服务器与数据中心', lv2_id: 'L2_AI_INFRA', lv2_name: 'AI基础设施 (算力/CPO/PCB)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['服务器', '数据中心', '智算中心', '液冷'] }, - { lv3_id: 'L3_OPTICAL', lv3_name: '光通信与CPO', lv2_id: 'L2_AI_INFRA', lv2_name: 'AI基础设施 (算力/CPO/PCB)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['CPO', '光通信', '光模块', '光芯片'] }, - { lv3_id: 'L3_PCB', lv3_name: 'PCB与封装', lv2_id: 'L2_AI_INFRA', lv2_name: 'AI基础设施 (算力/CPO/PCB)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['PCB', '封装', 'AI PCB'] }, - { lv3_id: 'L3_AI_APP', lv3_name: 'AI应用与大模型', lv2_id: 'L2_AI_INFRA', lv2_name: 'AI基础设施 (算力/CPO/PCB)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['大模型', '智能体', 'AI', '人工智能', 'DeepSeek', 'KIMI', 'ChatGPT'] }, - { lv3_id: 'L3_CHIP_DESIGN', lv3_name: '芯片设计', lv2_id: 'L2_SEMICONDUCTOR', lv2_name: '半导体 (设计/制造/封测)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['芯片设计', '半导体', 'IC设计'] }, - { lv3_id: 'L3_CHIP_MFG', lv3_name: '芯片制造', lv2_id: 'L2_SEMICONDUCTOR', lv2_name: '半导体 (设计/制造/封测)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['晶圆', '光刻', '芯片制造', '中芯国际'] }, - { lv3_id: 'L3_HUMANOID', lv3_name: '人形机器人', lv2_id: 'L2_ROBOT', lv2_name: '机器人 (人形机器人/工业机器人)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['人形机器人', '具身智能', '特斯拉机器人'] }, - { lv3_id: 'L3_INDUSTRIAL_ROBOT', lv3_name: '工业机器人', lv2_id: 'L2_ROBOT', lv2_name: '机器人 (人形机器人/工业机器人)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['工业机器人', '自动化', '机器人'] }, - { lv3_id: 'L3_MOBILE', lv3_name: '智能手机', lv2_id: 'L2_CONSUMER_ELEC', lv2_name: '消费电子 (手机/XR/可穿戴)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['手机', '华为', '苹果', '小米', '折叠屏'] }, - { lv3_id: 'L3_XR', lv3_name: 'XR与可穿戴', lv2_id: 'L2_CONSUMER_ELEC', lv2_name: '消费电子 (手机/XR/可穿戴)', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['XR', 'VR', 'AR', '可穿戴', 'MR', 'Vision Pro'] }, - { lv3_id: 'L3_5G', lv3_name: '5G/6G通信', lv2_id: 'L2_TELECOM', lv2_name: '通信、互联网与软件', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['5G', '6G', '通信', '基站'] }, - { lv3_id: 'L3_CLOUD', lv3_name: '云计算与软件', lv2_id: 'L2_TELECOM', lv2_name: '通信、互联网与软件', lv1_id: 'L1_TMT', lv1_name: 'TMT (科技/媒体/通信)', keywords: ['云计算', '软件', 'SaaS', '互联网', '数字化'] }, - { lv3_id: 'L3_PV', lv3_name: '光伏', lv2_id: 'L2_NEW_ENERGY', lv2_name: '新能源 (光伏/储能/电池)', lv1_id: 'L1_NEW_ENERGY_ENV', lv1_name: '新能源与智能汽车', keywords: ['光伏', '太阳能', '硅片', '组件'] }, - { lv3_id: 'L3_STORAGE', lv3_name: '储能与电池', lv2_id: 'L2_NEW_ENERGY', lv2_name: '新能源 (光伏/储能/电池)', lv1_id: 'L1_NEW_ENERGY_ENV', lv1_name: '新能源与智能汽车', keywords: ['储能', '电池', '锂电', '固态电池', '新能源'] }, - { lv3_id: 'L3_EV_OEM', lv3_name: '新能源整车', lv2_id: 'L2_EV', lv2_name: '智能网联汽车', lv1_id: 'L1_NEW_ENERGY_ENV', lv1_name: '新能源与智能汽车', keywords: ['新能源汽车', '电动车', '比亚迪', '特斯拉', '整车'] }, - { lv3_id: 'L3_AUTO_DRIVE', lv3_name: '智能驾驶', lv2_id: 'L2_EV', lv2_name: '智能网联汽车', lv1_id: 'L1_NEW_ENERGY_ENV', lv1_name: '新能源与智能汽车', keywords: ['智能驾驶', '自动驾驶', '智能网联', '车路协同'] }, - { lv3_id: 'L3_DRONE', lv3_name: '无人机', lv2_id: 'L2_LOW_ALTITUDE', lv2_name: '低空经济 (无人机/eVTOL)', lv1_id: 'L1_ADVANCED_MFG', lv1_name: '先进制造', keywords: ['无人机', '低空', '空域'] }, - { lv3_id: 'L3_EVTOL', lv3_name: 'eVTOL', lv2_id: 'L2_LOW_ALTITUDE', lv2_name: '低空经济 (无人机/eVTOL)', lv1_id: 'L1_ADVANCED_MFG', lv1_name: '先进制造', keywords: ['eVTOL', '飞行汽车', '空中出租车'] }, - { lv3_id: 'L3_AEROSPACE', lv3_name: '航空航天', lv2_id: 'L2_MILITARY', lv2_name: '军工 (航空航天/国防)', lv1_id: 'L1_ADVANCED_MFG', lv1_name: '先进制造', keywords: ['航空', '航天', '卫星', '火箭', '军工'] }, - { lv3_id: 'L3_DEFENSE', lv3_name: '国防军工', lv2_id: 'L2_MILITARY', lv2_name: '军工 (航空航天/国防)', lv1_id: 'L1_ADVANCED_MFG', lv1_name: '先进制造', keywords: ['国防', '导弹', '军工装备'] }, - { lv3_id: 'L3_DRUG', lv3_name: '创新药', lv2_id: 'L2_PHARMA', lv2_name: '医药医疗 (创新药/器械)', lv1_id: 'L1_PHARMA', lv1_name: '医药健康', keywords: ['创新药', '医药', '生物', 'CXO'] }, - { lv3_id: 'L3_DEVICE', lv3_name: '医疗器械', lv2_id: 'L2_PHARMA', lv2_name: '医药医疗 (创新药/器械)', lv1_id: 'L1_PHARMA', lv1_name: '医药健康', keywords: ['医疗器械', '医疗', '器械'] }, - { lv3_id: 'L3_BANK', lv3_name: '银行', lv2_id: 'L2_FINANCE', lv2_name: '金融 (银行/券商/保险)', lv1_id: 'L1_FINANCE', lv1_name: '金融', keywords: ['银行', '金融'] }, - { lv3_id: 'L3_BROKER', lv3_name: '券商', lv2_id: 'L2_FINANCE', lv2_name: '金融 (银行/券商/保险)', lv1_id: 'L1_FINANCE', lv1_name: '金融', keywords: ['券商', '证券'] }, - ]; - - const hierarchyOptions = { - lv1: [...new Map(mainlineDefinitions.map(m => [m.lv1_id, { id: m.lv1_id, name: m.lv1_name }])).values()], - lv2: [...new Map(mainlineDefinitions.map(m => [m.lv2_id, { id: m.lv2_id, name: m.lv2_name, lv1_id: m.lv1_id, lv1_name: m.lv1_name }])).values()], - lv3: mainlineDefinitions.map(m => ({ id: m.lv3_id, name: m.lv3_name, lv2_id: m.lv2_id, lv2_name: m.lv2_name, lv1_id: m.lv1_id, lv1_name: m.lv1_name })), - }; - - const mainlineGroups = {}; - const isSpecificId = groupBy.startsWith('L1_') || groupBy.startsWith('L2_') || groupBy.startsWith('L3_'); - - allEvents.forEach(event => { - const keywords = event.keywords || event.related_concepts || []; - const conceptNames = keywords.map(k => { - if (typeof k === 'string') return k; - if (typeof k === 'object' && k !== null) return k.concept || k.name || ''; - return ''; - }).filter(Boolean).join(' '); - const titleAndDesc = `${event.title || ''} ${event.description || ''}`; - const textToMatch = `${conceptNames} ${titleAndDesc}`.toLowerCase(); - - mainlineDefinitions.forEach(mainline => { - const matched = mainline.keywords.some(kw => textToMatch.includes(kw.toLowerCase())); - if (matched) { - let groupKey, groupData; - - if (isSpecificId) { - if (groupBy.startsWith('L1_') && mainline.lv1_id === groupBy) { - groupKey = mainline.lv2_id; - groupData = { group_id: mainline.lv2_id, group_name: mainline.lv2_name, parent_name: mainline.lv1_name, events: [] }; - } else if (groupBy.startsWith('L2_') && mainline.lv2_id === groupBy) { - groupKey = mainline.lv3_id; - groupData = { group_id: mainline.lv3_id, group_name: mainline.lv3_name, parent_name: mainline.lv2_name, grandparent_name: mainline.lv1_name, events: [] }; - } else if (groupBy.startsWith('L3_') && mainline.lv3_id === groupBy) { - groupKey = mainline.lv3_id; - groupData = { group_id: mainline.lv3_id, group_name: mainline.lv3_name, parent_name: mainline.lv2_name, grandparent_name: mainline.lv1_name, events: [] }; - } else { - return; - } - } else if (groupBy === 'lv1') { - groupKey = mainline.lv1_id; - groupData = { group_id: mainline.lv1_id, group_name: mainline.lv1_name, events: [] }; - } else if (groupBy === 'lv3') { - groupKey = mainline.lv3_id; - groupData = { group_id: mainline.lv3_id, group_name: mainline.lv3_name, parent_name: mainline.lv2_name, grandparent_name: mainline.lv1_name, events: [] }; - } else { - groupKey = mainline.lv2_id; - groupData = { group_id: mainline.lv2_id, group_name: mainline.lv2_name, parent_name: mainline.lv1_name, events: [] }; - } - - if (!mainlineGroups[groupKey]) { - mainlineGroups[groupKey] = groupData; - } - if (!mainlineGroups[groupKey].events.find(e => e.id === event.id)) { - mainlineGroups[groupKey].events.push(event); - } - } - }); - }); - - const generatePriceData = () => parseFloat((Math.random() * 13 - 5).toFixed(2)); - const priceDataMap = { lv1: {}, lv2: {}, lv3: {} }; - - mainlineDefinitions.forEach(def => { - if (!priceDataMap.lv1[def.lv1_name]) priceDataMap.lv1[def.lv1_name] = generatePriceData(); - if (!priceDataMap.lv2[def.lv2_name]) priceDataMap.lv2[def.lv2_name] = generatePriceData(); - if (!priceDataMap.lv3[def.lv3_name]) priceDataMap.lv3[def.lv3_name] = generatePriceData(); - }); - - const mainlines = Object.values(mainlineGroups) - .map(group => { - let avgChangePct = null; - if (groupBy === 'lv1' || groupBy.startsWith('L1_')) { - avgChangePct = groupBy.startsWith('L1_') ? priceDataMap.lv2[group.group_name] : priceDataMap.lv1[group.group_name]; - } else if (groupBy === 'lv3' || groupBy.startsWith('L2_')) { - avgChangePct = priceDataMap.lv3[group.group_name]; - } else { - avgChangePct = priceDataMap.lv2[group.group_name]; - } - return { - ...group, - events: group.events.slice(0, limitPerMainline), - event_count: Math.min(group.events.length, limitPerMainline), - avg_change_pct: avgChangePct ?? null, - price_date: new Date().toISOString().split('T')[0] - }; - }) - .filter(group => group.event_count > 0) - .sort((a, b) => b.event_count - a.event_count); - - const groupedEventIds = new Set(); - mainlines.forEach(m => m.events.forEach(e => groupedEventIds.add(e.id))); - const ungroupedCount = allEvents.filter(e => !groupedEventIds.has(e.id)).length; - - return HttpResponse.json({ - success: true, - data: { - mainlines, - total_events: allEvents.length, - mainline_count: mainlines.length, - ungrouped_count: ungroupedCount, - group_by: groupBy, - hierarchy_options: hierarchyOptions, - } - }); - } catch (error) { - console.error('[Mock Event] 主线数据获取失败:', error); - return HttpResponse.json({ success: false, error: error.message || '获取主线数据失败' }, { status: 500 }); - } - }), - - // ==================== 事件详情相关 ==================== - - // 获取事件详情 - http.get('/api/events/:eventId', async ({ params }) => { - await delay(200); - - const { eventId } = params; - const numericEventId = parseInt(eventId, 10); - - console.log('[Mock] 获取事件详情, eventId:', numericEventId); - - try { - // 检查是否已关注 - const isFollowing = isEventFollowed(numericEventId); - - // 返回模拟的事件详情数据 - return HttpResponse.json({ - success: true, - data: { - id: numericEventId, - title: `测试事件 ${eventId} - 重大政策发布`, - description: '这是一个模拟的事件描述,用于开发测试。该事件涉及重要政策变化,可能对相关板块产生显著影响。建议关注后续发展动态。', - importance: ['S', 'A', 'B', 'C'][Math.floor(Math.random() * 4)], - created_at: new Date().toISOString(), - trading_date: new Date().toISOString().split('T')[0], - event_type: ['政策', '财报', '行业', '宏观'][Math.floor(Math.random() * 4)], - related_avg_chg: parseFloat((Math.random() * 10 - 5).toFixed(2)), - follower_count: Math.floor(Math.random() * 500) + 50, - view_count: Math.floor(Math.random() * 5000) + 100, - is_following: isFollowing, // 使用内存状态 - post_count: Math.floor(Math.random() * 50), - expectation_surprise_score: parseFloat((Math.random() * 100).toFixed(1)), - }, - message: '获取成功' - }); - } catch (error) { - console.error('[Mock] 获取事件详情失败:', error); - return HttpResponse.json( - { - success: false, - error: '获取事件详情失败', - data: null - }, - { status: 500 } - ); - } - }), - - // 获取事件超预期得分 - http.get('/api/events/:eventId/expectation-score', async ({ params }) => { - await delay(200); - - const { eventId } = params; - - console.log('[Mock] 获取事件超预期得分, eventId:', eventId); - - try { - // 生成模拟的超预期得分数据 - const score = parseFloat((Math.random() * 100).toFixed(1)); - const avgChange = parseFloat((Math.random() * 10 - 2).toFixed(2)); - const maxChange = parseFloat((Math.random() * 15).toFixed(2)); - - return HttpResponse.json({ - success: true, - data: { - event_id: parseInt(eventId), - expectation_score: score, - avg_change: avgChange, - max_change: maxChange, - stock_count: Math.floor(Math.random() * 20) + 5, - updated_at: new Date().toISOString(), - }, - message: '获取成功' - }); - } catch (error) { - console.error('[Mock] 获取事件超预期得分失败:', error); - return HttpResponse.json( - { - success: false, - error: '获取事件超预期得分失败', - data: null - }, - { status: 500 } - ); - } - }), - - // 获取事件相关股票 - http.get('/api/events/:eventId/stocks', async ({ params }) => { - await delay(300); - - const { eventId } = params; - - console.log('[Mock] 获取事件相关股票, eventId:', eventId); - - try { - const relatedStocks = getEventRelatedStocks(eventId); - - return HttpResponse.json({ - success: true, - data: relatedStocks, - message: '获取成功' - }); - } catch (error) { - console.error('[Mock] 获取事件相关股票失败:', error); - return HttpResponse.json( - { - success: false, - error: '获取事件相关股票失败', - data: [] - }, - { status: 500 } - ); - } - }), - - // 获取事件相关概念 - http.get('/api/events/:eventId/concepts', async ({ params }) => { - await delay(300); - - const { eventId } = params; - - console.log('[Mock] 获取事件相关概念, eventId:', eventId); - - try { - // 返回热门概念列表(模拟真实场景下根据事件标题搜索的结果) - const concepts = generatePopularConcepts(5); - - return HttpResponse.json({ - success: true, - data: concepts, - message: '获取成功' - }); - } catch (error) { - console.error('[Mock] 获取事件相关概念失败:', error); - return HttpResponse.json( - { - success: false, - error: '获取事件相关概念失败', - data: [] - }, - { status: 500 } - ); - } - }), - - // 切换事件关注状态(使用内存状态管理) - http.post('/api/events/:eventId/follow', async ({ params, request }) => { - await delay(200); - - const { eventId } = params; - const numericEventId = parseInt(eventId, 10); - - console.log('[Mock] 切换事件关注状态, eventId:', numericEventId); - - try { - // 尝试从请求体获取事件数据(用于新关注时保存完整信息) - let eventData = null; - try { - const body = await request.json(); - if (body && body.title) { - eventData = body; - } - } catch { - // 没有请求体或解析失败,忽略 - } - - // 使用内存状态管理切换关注 - const { isFollowing, followerCount } = toggleEventFollowStatus(numericEventId, eventData); - - return HttpResponse.json({ - success: true, - data: { - is_following: isFollowing, - follower_count: followerCount - }, - message: isFollowing ? '关注成功' : '取消关注成功' - }); - } catch (error) { - console.error('[Mock] 切换事件关注状态失败:', error); - return HttpResponse.json( - { - success: false, - error: '切换关注状态失败', - data: null - }, - { status: 500 } - ); - } - }), - - // 事件情绪投票(看多/看空) - http.post('/api/events/:eventId/sentiment-vote', async ({ params, request }) => { - await delay(200); - - const { eventId } = params; - const numericEventId = parseInt(eventId, 10); - - console.log('[Mock] 事件情绪投票, eventId:', numericEventId); - - try { - const body = await request.json(); - const voteType = body.vote_type; // 'bullish', 'bearish', 或 null - - // 使用内存状态管理投票 - // 简单模拟:根据 eventId 生成基础数据 - const baseBullish = (numericEventId * 7) % 50 + 10; - const baseBearish = (numericEventId * 3) % 30 + 5; - - // 根据投票类型调整计数 - let bullishCount = baseBullish; - let bearishCount = baseBearish; - - if (voteType === 'bullish') { - bullishCount += 1; - } else if (voteType === 'bearish') { - bearishCount += 1; - } - - return HttpResponse.json({ - success: true, - data: { - user_vote: voteType || null, - bullish_count: bullishCount, - bearish_count: bearishCount, - }, - message: voteType ? '投票成功' : '取消投票成功' - }); - } catch (error) { - console.error('[Mock] 事件情绪投票失败:', error); - return HttpResponse.json( - { - success: false, - error: '投票失败', - data: null - }, - { status: 500 } - ); - } - }), - - // 获取事件传导链分析数据 - http.get('/api/events/:eventId/transmission', async ({ params }) => { - await delay(500); - - const { eventId } = params; - - console.log('[Mock] 获取事件传导链分析, eventId:', eventId); - - // Mock数据:事件传导链 - const mockTransmissionData = { - success: true, - data: { - nodes: [ - { - id: '1', - name: '主要事件', - category: '事件', - value: 50, - extra: { - node_type: 'event', - description: '这是主要事件节点', - importance_score: 50, - is_main_event: true - } - }, - { - id: '2', - name: '半导体行业', - category: '行业', - value: 40, - extra: { - node_type: 'industry', - description: '受影响的半导体行业', - importance_score: 40, - is_main_event: false - } - }, - { - id: '3', - name: '芯片制造', - category: '行业', - value: 35, - extra: { - node_type: 'industry', - description: '芯片制造产业链', - importance_score: 35, - is_main_event: false - } - }, - { - id: '4', - name: 'A公司', - category: '公司', - value: 30, - extra: { - node_type: 'company', - description: '龙头企业A', - importance_score: 30, - stock_code: '600000', - is_main_event: false - } - }, - { - id: '5', - name: 'B公司', - category: '公司', - value: 25, - extra: { - node_type: 'company', - description: '龙头企业B', - importance_score: 25, - stock_code: '600001', - is_main_event: false - } - }, - { - id: '6', - name: '相关政策', - category: '政策', - value: 30, - extra: { - node_type: 'policy', - description: '国家产业政策支持', - importance_score: 30, - is_main_event: false - } - } - ], - edges: [ - { - source: '1', - target: '2', - value: 0.8, - extra: { - transmission_strength: 0.8, - transmission_type: '直接影响', - description: '主事件对半导体行业的直接影响' - } - }, - { - source: '2', - target: '3', - value: 0.7, - extra: { - transmission_strength: 0.7, - transmission_type: '产业链传导', - description: '半导体到芯片制造的传导' - } - }, - { - source: '3', - target: '4', - value: 0.6, - extra: { - transmission_strength: 0.6, - transmission_type: '企业影响', - description: '对龙头企业A的影响' - } - }, - { - source: '3', - target: '5', - value: 0.5, - extra: { - transmission_strength: 0.5, - transmission_type: '企业影响', - description: '对龙头企业B的影响' - } - }, - { - source: '6', - target: '1', - value: 0.7, - extra: { - transmission_strength: 0.7, - transmission_type: '政策驱动', - description: '政策对主事件的推动作用' - } - }, - { - source: '6', - target: '2', - value: 0.6, - extra: { - transmission_strength: 0.6, - transmission_type: '政策支持', - description: '政策对行业的支持' - } - } - ], - categories: ['事件', '行业', '公司', '政策', '技术', '市场', '其他'] - }, - message: '获取成功' - }; - - return HttpResponse.json(mockTransmissionData); - }), - - // 获取桑基图数据 - http.get('/api/events/:eventId/sankey-data', async ({ params }) => { - await delay(300); - const { eventId } = params; - console.log('[Mock] 获取桑基图数据, eventId:', eventId); - - const mockSankeyData = { - success: true, - data: { - nodes: [ - { - name: '相关政策', - type: 'policy', - level: 0, - color: '#10ac84' - }, - { - name: '主要事件', - type: 'event', - level: 0, - color: '#ff4757' - }, - { - name: '半导体行业', - type: 'industry', - level: 1, - color: '#00d2d3' - }, - { - name: '芯片制造', - type: 'industry', - level: 2, - color: '#00d2d3' - }, - { - name: 'A公司', - type: 'company', - level: 3, - color: '#54a0ff' - }, - { - name: 'B公司', - type: 'company', - level: 3, - color: '#54a0ff' - } - ], - links: [ - { source: 0, target: 1, value: 7 }, // 相关政策 -> 主要事件 - { source: 0, target: 2, value: 6 }, // 相关政策 -> 半导体行业 - { source: 1, target: 2, value: 8 }, // 主要事件 -> 半导体行业 - { source: 2, target: 3, value: 7 }, // 半导体行业 -> 芯片制造 - { source: 3, target: 4, value: 6 }, // 芯片制造 -> A公司 - { source: 3, target: 5, value: 5 } // 芯片制造 -> B公司 - ] - }, - message: '获取成功' - }; - - return HttpResponse.json(mockSankeyData); - }), - - // 获取传导链节点详情 - http.get('/api/events/:eventId/chain-node/:nodeId', async ({ params }) => { - await delay(300); - - const { eventId, nodeId } = params; - - console.log('[Mock] 获取节点详情, eventId:', eventId, 'nodeId:', nodeId); - - // 根据节点ID返回不同的详细信息 - const nodeDetailsMap = { - '1': { - success: true, - data: { - node: { - id: '1', - name: '主要事件', - type: 'event', - description: '这是影响整个产业链的重大事件,涉及政策调整和技术突破,对下游产业产生深远影响。', - importance_score: 50, - total_connections: 2, - incoming_connections: 1, - outgoing_connections: 1 - }, - parents: [ - { - id: '6', - name: '相关政策', - transmission_mechanism: { - data: [ - { - author: "国务院", - sentences: "为加快实施创新驱动发展战略,推动产业转型升级,国家将对重点领域给予财政补贴支持,单个项目最高补贴金额可达5000万元,同时享受研发费用加计扣除175%的税收优惠政策", - query_part: "国家财政补贴最高5000万元,研发费用加计扣除175%", - match_score: "好", - declare_date: "2024-01-15T00:00:00", - report_title: "关于促进产业高质量发展的若干政策措施" - }, - { - author: "工信部", - sentences: "根据《重点产业扶持目录》,对符合条件的企业和项目,将优先纳入政府采购名单,并提供专项资金支持,确保政策红利直接惠及实体经济", - query_part: "政府采购优先支持,专项资金直达企业", - match_score: "好", - declare_date: "2024-01-20T00:00:00", - report_title: "工业和信息化部关于落实产业扶持政策的通知" - } - ] - }, - direction: 'positive', - strength: 70, - is_circular: false - } - ], - children: [ - { - id: '2', - name: '半导体行业(正向影响)', - transmission_mechanism: { - data: [ - { - author: "李明", - organization: "中国电子信息产业发展研究院", - sentences: "在技术突破和应用场景快速扩张的双重驱动下,国内半导体市场呈现爆发式增长态势。据统计,2024年上半年半导体市场规模达到1.2万亿元,同比增长32%,其中新能源汽车和AI算力芯片需求贡献了超过60%的增量", - query_part: "技术突破和需求激增推动半导体市场增长32%", - match_score: "好", - declare_date: "2024-07-10T00:00:00", - report_title: "2024年上半年中国半导体产业发展报告" - } - ] - }, - direction: 'positive', - strength: 80, - is_circular: false - }, - { - id: '7', - name: '传统制造业(负向影响)', - transmission_mechanism: { - data: [ - { - author: "张华", - organization: "经济观察报", - sentences: "随着半导体等高科技产业获得大量政策和资金支持,传统制造业面临融资难、用工成本上升等多重压力。部分劳动密集型企业利润率下降15%,行业整体投资意愿降低", - query_part: "资源向高科技倾斜导致传统制造业承压", - match_score: "好", - declare_date: "2024-06-15T00:00:00", - report_title: "传统制造业转型升级调研报告" - } - ] - }, - direction: 'negative', - strength: 60, - is_circular: false - }, - { - id: '8', - name: '能源行业(中性影响)', - transmission_mechanism: { - data: [ - { - author: "王刚", - organization: "能源研究所", - sentences: "半导体产业扩张带来电力需求增长约8%,但同时推动节能技术应用,整体能源消费结构趋于优化。新建芯片工厂虽增加用电负荷,但智能电网技术应用使能源利用效率提升12%", - query_part: "半导体产业对能源行业影响相对中性", - match_score: "中", - declare_date: "2024-07-01T00:00:00", - report_title: "高科技产业能源消费分析" - } - ] - }, - direction: 'neutral', - strength: 40, - is_circular: false - }, - { - id: '9', - name: '教育培训行业(未明确方向)', - transmission_mechanism: { - data: [ - { - author: "赵敏", - organization: "教育部职业教育司", - sentences: "半导体产业快速发展催生大量专业人才需求,各类培训机构、职业院校纷纷开设相关课程。预计未来三年将新增半导体专业学员超过50万人,带动职业教育市场规模扩大", - query_part: "半导体产业推动职业教育发展", - match_score: "好", - declare_date: "2024-06-20T00:00:00", - report_title: "半导体人才培养白皮书" - } - ] - }, - strength: 50, - is_circular: false - } - ] - } - }, - '2': { - success: true, - data: { - node: { - id: '2', - name: '半导体行业', - type: 'industry', - description: '半导体行业是现代科技产业的基础,受到主事件和政策的双重推动,迎来新一轮发展机遇。', - importance_score: 40, - total_connections: 3, - incoming_connections: 2, - outgoing_connections: 1 - }, - parents: [ - { - id: '1', - name: '主要事件', - transmission_mechanism: { - data: [ - { - author: "刘洋", - organization: "中国半导体行业协会", - sentences: "受益于新能源汽车、5G通信等新兴应用领域的爆发式增长,国内半导体市场需求持续旺盛,2024年Q1市场规模同比增长28%,创历史新高", - query_part: "新兴应用推动半导体需求增长28%", - match_score: "好", - declare_date: "2024-04-05T00:00:00", - report_title: "2024年Q1中国半导体行业景气度报告" - }, - { - author: "刘洋", - organization: "中国半导体行业协会", - sentences: "受益于新能源汽车、5G通信等新兴应用领域的爆发式增长,国内半导体市场需求持续旺盛,2024年Q1市场规模同比增长28%,创历史新高", - query_part: "新兴应用推动半导体需求增长28%", - match_score: "好", - declare_date: "2024-04-05T00:00:00", - report_title: "2024年Q1中国半导体行业景气度报告" - } - ] - }, - direction: 'positive', - strength: 80, - is_circular: false - }, - { - id: '6', - name: '相关政策', - transmission_mechanism: { - data: [ - { - author: "国家发改委", - sentences: "《国家集成电路产业发展推进纲要》明确提出,到2025年半导体产业自给率要达到70%以上,国家将设立专项基金规模超过3000亿元,重点支持半导体设备、材料、设计等关键环节", - query_part: "半导体自给率目标70%,专项基金3000亿", - match_score: "好", - declare_date: "2024-02-01T00:00:00", - report_title: "国家集成电路产业发展推进纲要(2024-2030)" - } - ] - }, - direction: 'positive', - strength: 60, - is_circular: false - } - ], - children: [ - { - id: '3', - name: '芯片制造', - transmission_mechanism: { - data: [ - { - author: "张明", - organization: "中信证券", - sentences: "在半导体行业景气度持续提升的背景下,下游芯片制造企业订单饱满,产能利用率达到历史新高,预计2024年产能扩张将达到30%以上,技术工艺也将从28nm向14nm升级", - query_part: "半导体行业繁荣带动芯片制造产能扩张30%", - match_score: "好", - declare_date: "2024-03-15T00:00:00", - report_title: "半导体行业深度报告:产业链景气度传导分析" - }, - { - author: "李华", - organization: "海通证券", - sentences: "芯片制造环节作为半导体产业链核心,受益于上游材料供应稳定和下游应用需求旺盛,技术迭代速度明显加快,先进制程占比持续提升", - query_part: "技术迭代加快,先进制程占比提升", - match_score: "好", - declare_date: "2024-02-28T00:00:00", - report_title: "芯片制造行业跟踪报告" - } - ] - }, - direction: 'positive', - strength: 70, - is_circular: false - } - ] - } - }, - '3': { - success: true, - data: { - node: { - id: '3', - name: '芯片制造', - type: 'industry', - description: '芯片制造作为半导体产业链的核心环节,在上游需求推动下,产能利用率提升,技术迭代加快。', - importance_score: 35, - total_connections: 3, - incoming_connections: 1, - outgoing_connections: 2 - }, - parents: [ - { - id: '2', - name: '半导体行业', - transmission_mechanism: { - data: [ - { - author: "张明", - sentences: "在半导体行业景气度持续提升的背景下,下游芯片制造企业订单饱满,产能利用率达到历史新高,预计2024年产能扩张将达到30%以上,技术工艺也将从28nm向14nm升级", - query_part: "半导体行业繁荣带动芯片制造产能扩张30%", - match_score: "好", - declare_date: "2024-03-15T00:00:00", - report_title: "半导体行业深度报告:产业链景气度传导分析" - }, - { - author: "李华", - sentences: "芯片制造环节作为半导体产业链核心,受益于上游材料供应稳定和下游应用需求旺盛,技术迭代速度明显加快,先进制程占比持续提升", - query_part: "技术迭代加快,先进制程占比提升", - match_score: "好", - declare_date: "2024-02-28T00:00:00", - report_title: "芯片制造行业跟踪报告" - } - ] - }, - direction: 'positive', - strength: 70, - is_circular: false - } - ], - children: [ - { - id: '4', - name: 'A公司', - transmission_mechanism: { - data: [ - { - author: "王芳", - organization: "国泰君安", - sentences: "A公司作为国内芯片制造龙头企业,在手订单已排至2024年Q4,预计全年营收增长45%,净利润增长60%以上。公司28nm及以下先进制程产能占比已达到40%,技术实力行业领先", - query_part: "A公司在手订单充足,预计营收增长45%", - match_score: "好", - declare_date: "2024-04-10T00:00:00", - report_title: "A公司深度研究:受益芯片制造景气周期" - } - ] - }, - direction: 'positive', - strength: 60, - is_circular: false - }, - { - id: '5', - name: 'B公司', - transmission_mechanism: { - data: [ - { - author: "赵强", - organization: "华泰证券", - sentences: "随着芯片制造产能的大规模扩张,上游设备和材料供应商迎来历史性机遇。B公司作为核心配套企业,订单量同比增长55%,产品供不应求,预计2024年营收将突破百亿大关。公司在封装测试领域的市场份额已提升至国内第二位", - query_part: "B公司订单增长55%,营收将破百亿", - match_score: "好", - declare_date: "2024-05-08T00:00:00", - report_title: "B公司跟踪报告:芯片产业链配套龙头崛起" - }, - { - author: "陈彤", - organization: "国信证券", - sentences: "B公司深度受益于芯片制造产业链的景气度传导。公司凭借先进的封装技术和完善的产能布局,成功绑定多家头部芯片制造企业,形成稳定的供应关系。随着下游客户产能持续扩张,公司业绩增长确定性强", - query_part: "B公司受益产业链景气度,业绩增长确定性强", - match_score: "好", - declare_date: "2024-06-01T00:00:00", - report_title: "半导体封装测试行业专题:产业链景气度传导分析" - } - ] - }, - direction: 'positive', - strength: 50, - is_circular: false - } - ] - } - }, - '4': { - success: true, - data: { - node: { - id: '4', - name: 'A公司', - type: 'company', - description: 'A公司是行业龙头企业,拥有先进的芯片制造技术和完整的产业链布局,在本轮产业升级中占据有利位置。', - importance_score: 30, - stock_code: '600000', - total_connections: 1, - incoming_connections: 1, - outgoing_connections: 0 - }, - parents: [ - { - id: '3', - name: '芯片制造', - transmission_mechanism: { - data: [ - { - author: "王芳", - sentences: "A公司作为国内芯片制造龙头企业,在手订单已排至2024年Q4,预计全年营收增长45%,净利润增长60%以上。公司28nm及以下先进制程产能占比已达到40%,技术实力行业领先", - query_part: "A公司在手订单充足,预计营收增长45%", - match_score: "好", - declare_date: "2024-04-10T00:00:00", - report_title: "A公司深度研究:受益芯片制造景气周期" - } - ] - }, - direction: 'positive', - strength: 60, - is_circular: false - } - ], - children: [] - } - }, - '5': { - success: true, - data: { - node: { - id: '5', - name: 'B公司', - type: 'company', - description: 'B公司专注于芯片封装测试领域,随着上游制造产能释放,公司订单饱满,业绩稳步增长。', - importance_score: 25, - stock_code: '600001', - total_connections: 1, - incoming_connections: 1, - outgoing_connections: 0 - }, - parents: [ - { - id: '3', - name: '芯片制造', - transmission_mechanism: { - data: [ - { - author: "赵强", - organization: "华泰证券", - sentences: "随着芯片制造产能的大规模扩张,上游设备和材料供应商迎来历史性机遇。B公司作为核心配套企业,订单量同比增长55%,产品供不应求,预计2024年营收将突破百亿大关", - query_part: "B公司订单增长55%,营收将破百亿", - match_score: "好", - declare_date: "2024-05-08T00:00:00", - report_title: "B公司跟踪报告:芯片产业链配套龙头崛起" - } - ] - }, - direction: 'positive', - strength: 50, - is_circular: false - } - ], - children: [] - } - }, - '6': { - success: true, - data: { - node: { - id: '6', - name: '相关政策', - type: 'policy', - description: '国家出台了一系列产业扶持政策,包括财政补贴、税收减免和研发支持,旨在推动产业自主创新和进口替代。', - importance_score: 30, - total_connections: 2, - incoming_connections: 0, - outgoing_connections: 2 - }, - parents: [], - children: [ - { - id: '1', - name: '主要事件', - transmission_mechanism: { - data: [ - { - author: "国务院", - sentences: "为加快实施创新驱动发展战略,推动产业转型升级,国家将对重点领域给予财政补贴支持,单个项目最高补贴金额可达5000万元,同时享受研发费用加计扣除175%的税收优惠政策", - query_part: "国家财政补贴最高5000万元,研发费用加计扣除175%", - match_score: "好", - declare_date: "2024-01-15T00:00:00", - report_title: "关于促进产业高质量发展的若干政策措施" - }, - { - author: "工信部", - sentences: "将重点支持关键核心技术攻关和产业化应用,建立产业发展专项基金,规模达到1000亿元,引导社会资本共同参与产业发展", - query_part: "设立1000亿元产业发展专项基金", - match_score: "好", - declare_date: "2024-02-01T00:00:00", - report_title: "产业发展专项基金管理办法" - } - ] - }, - direction: 'positive', - strength: 70, - is_circular: false - }, - { - id: '2', - name: '半导体行业', - transmission_mechanism: { - data: [ - { - author: "国家发改委", - sentences: "《国家集成电路产业发展推进纲要》明确提出,到2025年半导体产业自给率要达到70%以上,国家将设立专项基金规模超过3000亿元,重点支持半导体设备、材料、设计等关键环节。同时,通过进口替代战略,加快培育本土产业链", - query_part: "半导体自给率目标70%,专项基金3000亿", - match_score: "好", - declare_date: "2024-02-01T00:00:00", - report_title: "国家集成电路产业发展推进纲要(2024-2030)" - }, - { - author: "工信部", - sentences: "将重点支持关键核心技术攻关和产业化应用,建立产业发展专项基金,规模达到1000亿元,引导社会资本共同参与产业发展。通过税收优惠、研发补贴等政策工具,为半导体行业创造良好的发展环境", - query_part: "设立1000亿元产业发展专项基金", - match_score: "好", - declare_date: "2024-02-01T00:00:00", - report_title: "产业发展专项基金管理办法" - } - ] - }, - direction: 'positive', - strength: 60, - is_circular: false - } - ] - } - } - }; - - // 返回对应节点的详情,如果不存在则返回默认数据 - const nodeDetail = nodeDetailsMap[nodeId] || { - success: true, - data: { - node: { - id: nodeId, - name: '未知节点', - type: 'other', - description: '该节点暂无详细信息', - importance_score: 0, - total_connections: 0, - incoming_connections: 0, - outgoing_connections: 0 - }, - parents: [], - children: [] - } - }; - - return HttpResponse.json(nodeDetail); - }), - - // ==================== 投资日历相关 ==================== - - // 获取月度事件统计 - http.get('/api/v1/calendar/event-counts', async ({ request }) => { - await delay(300); - - const url = new URL(request.url); - const year = parseInt(url.searchParams.get('year')); - const month = parseInt(url.searchParams.get('month')); - - console.log('[Mock] 获取月度事件统计:', { year, month }); - - const eventCounts = getMockEventCountsForMonth(year, month); - - return HttpResponse.json({ - success: true, - data: eventCounts - }); - }), - - // 获取指定日期的事件列表 - http.get('/api/v1/calendar/events', async ({ request }) => { - await delay(300); - - const url = new URL(request.url); - const dateStr = url.searchParams.get('date'); - const type = url.searchParams.get('type') || 'all'; - - console.log('[Mock] 获取日历事件列表:', { date: dateStr, type }); - - if (!dateStr) { - return HttpResponse.json({ - success: false, - error: 'Date parameter required' - }, { status: 400 }); - } - - const events = getMockFutureEvents(dateStr, type); - - return HttpResponse.json({ - success: true, - data: events - }); - }), - - // 切换未来事件关注状态 - http.post('/api/v1/calendar/events/:eventId/follow', async ({ params }) => { - await delay(300); - - const { eventId } = params; - - console.log('[Mock] 切换事件关注状态, eventId:', eventId); - - // 简单返回成功,实际状态管理可以后续完善 - return HttpResponse.json({ - success: true, - data: { - is_following: true, - message: '关注成功' - } - }); - }), - - // ==================== 历史事件对比相关 ==================== - - // 获取历史事件列表 - http.get('/api/events/:eventId/historical', async ({ params }) => { - await delay(400); - - const { eventId } = params; - - console.log('[Mock] 获取历史事件列表, eventId:', eventId); - - // 生成历史事件数据 - const generateHistoricalEvents = (count = 5) => { - const events = []; - const eventTitles = [ - '芯片产业链政策扶持升级', - '新能源汽车销量创历史新高', - '人工智能大模型技术突破', - '半导体设备国产化加速', - '数字经济政策利好发布', - '新能源产业链整合提速', - '医药创新药获批上市', - '5G应用场景扩展', - '智能驾驶技术迭代升级', - '储能行业景气度上行' - ]; - - const importanceLevels = [1, 2, 3, 4, 5]; - - for (let i = 0; i < count; i++) { - const daysAgo = Math.floor(Math.random() * 180) + 30; // 30-210 天前 - const date = new Date(); - date.setDate(date.getDate() - daysAgo); - - const importance = importanceLevels[Math.floor(Math.random() * importanceLevels.length)]; - const title = eventTitles[i % eventTitles.length]; - - // 带引用来源的研报数据 - const researchReports = [ - { - author: '中信证券', - report_title: `${title}深度研究报告`, - declare_date: new Date(date.getTime() - Math.floor(Math.random() * 10) * 24 * 60 * 60 * 1000).toISOString() - }, - { - author: '国泰君安', - report_title: `行业专题:${title}影响分析`, - declare_date: new Date(date.getTime() - Math.floor(Math.random() * 15) * 24 * 60 * 60 * 1000).toISOString() - }, - { - author: '华泰证券', - report_title: `${title}投资机会深度解析`, - declare_date: new Date(date.getTime() - Math.floor(Math.random() * 20) * 24 * 60 * 60 * 1000).toISOString() - } - ]; - - // 生成带引用标记的content(data结构) - const contentWithCitations = { - data: [ - { - query_part: `${title}的详细描述。该事件对相关产业链产生重要影响【1】,市场关注度高,相关概念股表现活跃。`, - sentences: `根据券商研报分析,${title}将推动相关产业链快速发展【2】。预计未来${Math.floor(Math.random() * 2 + 1)}年内,相关企业营收增速有望达到${Math.floor(Math.random() * 30 + 20)}%以上【3】。该事件的影响范围广泛,涉及多个细分领域,投资机会显著。`, - match_score: importance >= 4 ? '好' : (importance >= 2 ? '中' : '一般'), - author: researchReports[0].author, - declare_date: researchReports[0].declare_date, - report_title: researchReports[0].report_title - }, - { - query_part: `市场分析师认为,该事件将带动产业链上下游企业协同发展【2】,形成良性循环。`, - sentences: `从产业趋势来看,相关板块估值仍处于合理区间,具备较高的安全边际。机构投资者持续加仓相关标的,显示出对长期发展前景的看好。`, - match_score: importance >= 3 ? '好' : '中', - author: researchReports[1].author, - declare_date: researchReports[1].declare_date, - report_title: researchReports[1].report_title - }, - { - query_part: `根据行业数据显示,受此事件影响,相关企业订单量同比增长${Math.floor(Math.random() * 40 + 30)}%【3】。`, - sentences: `行业景气度持续提升,龙头企业凭借技术优势和规模效应,市场份额有望进一步扩大。建议关注产业链核心环节的投资机会。`, - match_score: '好', - author: researchReports[2].author, - declare_date: researchReports[2].declare_date, - report_title: researchReports[2].report_title - } - ] + } else if ( + groupBy.startsWith("L2_") && + mainline.lv2_id === groupBy + ) { + groupKey = mainline.lv3_id; + groupData = { + group_id: mainline.lv3_id, + group_name: mainline.lv3_name, + parent_name: mainline.lv2_name, + grandparent_name: mainline.lv1_name, + events: [], }; - - events.push({ - id: `hist_event_${i + 1}`, - title: title, - content: contentWithCitations, // 升级版本:带引用来源的data结构 - description: `${title}的详细描述。该事件对相关产业链产生重要影响,市场关注度高,相关概念股表现活跃。`, // 降级兼容 - date: date.toISOString().split('T')[0], - importance: importance, - similarity: Math.floor(Math.random() * 10) + 1, // 1-10 - impact_sectors: [ - ['半导体', '芯片设计', 'EDA'], - ['新能源汽车', '锂电池', '充电桩'], - ['人工智能', '算力', '大模型'], - ['半导体设备', '国产替代', '集成电路'], - ['数字经济', '云计算', '大数据'] - ][i % 5], - affected_stocks_count: Math.floor(Math.random() * 30) + 10, // 10-40 只股票 - avg_change_pct: parseFloat((Math.random() * 10 - 2).toFixed(2)), // -2% to +8% - created_at: date.toISOString() - }); - } - - // 按日期降序排序 - return events.sort((a, b) => new Date(b.date) - new Date(a.date)); - }; - - try { - const historicalEvents = generateHistoricalEvents(5); - - return HttpResponse.json({ - success: true, - data: historicalEvents, - total: historicalEvents.length, - message: '获取历史事件列表成功' - }); - } catch (error) { - console.error('[Mock] 获取历史事件列表失败:', error); - return HttpResponse.json( - { - success: false, - error: '获取历史事件列表失败', - data: [] - }, - { status: 500 } - ); - } - }), - - // 获取历史事件相关股票 - http.get('/api/historical-events/:eventId/stocks', async ({ params }) => { - await delay(500); - - const { eventId } = params; - - console.log('[Mock] 获取历史事件相关股票, eventId:', eventId); - - // 生成历史事件相关股票数据 - const generateHistoricalEventStocks = (count = 10) => { - const stocks = []; - const sectors = ['半导体', '新能源', '医药', '消费电子', '人工智能', '5G通信']; - const stockNames = [ - '中芯国际', '长江存储', '华为海思', '紫光国微', '兆易创新', - '宁德时代', '比亚迪', '隆基绿能', '阳光电源', '亿纬锂能', - '恒瑞医药', '迈瑞医疗', '药明康德', '泰格医药', '康龙化成', - '立讯精密', '歌尔声学', '京东方A', 'TCL科技', '海康威视', - '科大讯飞', '商汤科技', '寒武纪', '海光信息', '中兴通讯' - ]; - - for (let i = 0; i < count; i++) { - const stockCode = `${Math.random() > 0.5 ? '6' : '0'}${String(Math.floor(Math.random() * 100000)).padStart(5, '0')}`; - const changePct = (Math.random() * 15 - 3).toFixed(2); // -3% ~ +12% - const correlation = (Math.random() * 0.4 + 0.6).toFixed(2); // 0.6 ~ 1.0 - - stocks.push({ - id: `stock_${i}`, - stock_code: `${stockCode}.${Math.random() > 0.5 ? 'SH' : 'SZ'}`, - stock_name: stockNames[i % stockNames.length], - sector: sectors[Math.floor(Math.random() * sectors.length)], - correlation: parseFloat(correlation), - event_day_change_pct: parseFloat(changePct), - relation_desc: { - data: [ - { - query_part: `该公司是${sectors[Math.floor(Math.random() * sectors.length)]}行业龙头,受事件影响显著,市场关注度高,订单量同比增长${Math.floor(Math.random() * 50 + 20)}%`, - sentences: `根据行业研究报告,该公司在${sectors[Math.floor(Math.random() * sectors.length)]}领域具有核心技术优势,产能利用率达到${Math.floor(Math.random() * 20 + 80)}%,随着事件的深入发展,公司业绩有望持续受益。机构预测未来三年复合增长率将达到${Math.floor(Math.random() * 30 + 15)}%以上`, - match_score: correlation > 0.8 ? '好' : (correlation > 0.6 ? '中' : '一般'), - author: ['中信证券', '国泰君安', '华泰证券', '招商证券'][Math.floor(Math.random() * 4)], - declare_date: new Date(Date.now() - Math.floor(Math.random() * 90) * 24 * 60 * 60 * 1000).toISOString(), - report_title: `${stockNames[i % stockNames.length]}深度研究报告` - } - ] - } - }); - } - - // 按相关度降序排序 - return stocks.sort((a, b) => b.correlation - a.correlation); - }; - - try { - const stocks = generateHistoricalEventStocks(15); - - return HttpResponse.json({ - success: true, - data: stocks, - message: '获取历史事件相关股票成功' - }); - } catch (error) { - console.error('[Mock] 获取历史事件相关股票失败:', error); - return HttpResponse.json( - { - success: false, - error: '获取历史事件相关股票失败', - data: [] - }, - { status: 500 } - ); - } - }), - - // ==================== 评论相关 ==================== - - // 获取事件评论列表 - http.get('/api/events/:eventId/posts', async ({ params, request }) => { - await delay(300); - - const { eventId } = params; - const url = new URL(request.url); - const sort = url.searchParams.get('sort') || 'latest'; - const page = parseInt(url.searchParams.get('page') || '1'); - const perPage = parseInt(url.searchParams.get('per_page') || '20'); - - console.log('[Mock] 获取评论列表, eventId:', eventId, 'sort:', sort); - - try { - // 从内存存储获取评论列表 - const allComments = getOrInitComments(eventId); - - // ✅ 创建副本并排序(避免直接修改原数组) - let sortedComments = [...allComments]; - if (sort === 'hot') { - sortedComments.sort((a, b) => b.likes_count - a.likes_count); + } else if ( + groupBy.startsWith("L3_") && + mainline.lv3_id === groupBy + ) { + groupKey = mainline.lv3_id; + groupData = { + group_id: mainline.lv3_id, + group_name: mainline.lv3_name, + parent_name: mainline.lv2_name, + grandparent_name: mainline.lv1_name, + events: [], + }; + } else { + return; + } + } else if (groupBy === "lv1") { + groupKey = mainline.lv1_id; + groupData = { + group_id: mainline.lv1_id, + group_name: mainline.lv1_name, + events: [], + }; + } else if (groupBy === "lv3") { + groupKey = mainline.lv3_id; + groupData = { + group_id: mainline.lv3_id, + group_name: mainline.lv3_name, + parent_name: mainline.lv2_name, + grandparent_name: mainline.lv1_name, + events: [], + }; } else { - // 默认按时间升序(oldest first)- 最旧评论在前,最新在后 - sortedComments.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); + groupKey = mainline.lv2_id; + groupData = { + group_id: mainline.lv2_id, + group_name: mainline.lv2_name, + parent_name: mainline.lv1_name, + events: [], + }; } - // 分页处理(使用排序后的副本) - const startIndex = (page - 1) * perPage; - const endIndex = startIndex + perPage; - const paginatedComments = sortedComments.slice(startIndex, endIndex); + if (!mainlineGroups[groupKey]) { + mainlineGroups[groupKey] = groupData; + } + if ( + !mainlineGroups[groupKey].events.find((e) => e.id === event.id) + ) { + mainlineGroups[groupKey].events.push(event); + } + } + }); + }); - return HttpResponse.json({ - success: true, - data: paginatedComments, - pagination: { - page: page, - per_page: perPage, - total: allComments.length, - pages: Math.ceil(allComments.length / perPage), - has_prev: page > 1, - has_next: endIndex < allComments.length, - }, - message: '获取评论成功', - }); - } catch (error) { - console.error('[Mock] 获取评论列表失败:', error); - return HttpResponse.json( - { - success: false, - error: '获取评论失败', - data: [], - }, - { status: 500 } - ); - } - }), + const generatePriceData = () => + parseFloat((Math.random() * 13 - 5).toFixed(2)); + const priceDataMap = { lv1: {}, lv2: {}, lv3: {} }; - // 发表评论 - http.post('/api/events/:eventId/posts', async ({ params, request }) => { - await delay(500); + mainlineDefinitions.forEach((def) => { + if (!priceDataMap.lv1[def.lv1_name]) + priceDataMap.lv1[def.lv1_name] = generatePriceData(); + if (!priceDataMap.lv2[def.lv2_name]) + priceDataMap.lv2[def.lv2_name] = generatePriceData(); + if (!priceDataMap.lv3[def.lv3_name]) + priceDataMap.lv3[def.lv3_name] = generatePriceData(); + }); - const { eventId } = params; + const mainlines = Object.values(mainlineGroups) + .map((group) => { + let avgChangePct = null; + if (groupBy === "lv1" || groupBy.startsWith("L1_")) { + avgChangePct = groupBy.startsWith("L1_") + ? priceDataMap.lv2[group.group_name] + : priceDataMap.lv1[group.group_name]; + } else if (groupBy === "lv3" || groupBy.startsWith("L2_")) { + avgChangePct = priceDataMap.lv3[group.group_name]; + } else { + avgChangePct = priceDataMap.lv2[group.group_name]; + } + return { + ...group, + events: group.events.slice(0, limitPerMainline), + event_count: Math.min(group.events.length, limitPerMainline), + avg_change_pct: avgChangePct ?? null, + price_date: new Date().toISOString().split("T")[0], + }; + }) + .filter((group) => group.event_count > 0) + .sort((a, b) => b.event_count - a.event_count); + + const groupedEventIds = new Set(); + mainlines.forEach((m) => + m.events.forEach((e) => groupedEventIds.add(e.id)) + ); + const ungroupedCount = allEvents.filter((e) => !groupedEventIds.has(e.id)) + .length; + + return HttpResponse.json({ + success: true, + data: { + mainlines, + total_events: allEvents.length, + mainline_count: mainlines.length, + ungrouped_count: ungroupedCount, + group_by: groupBy, + hierarchy_options: hierarchyOptions, + }, + }); + } catch (error) { + console.error("[Mock Event] 主线数据获取失败:", error); + return HttpResponse.json( + { success: false, error: error.message || "获取主线数据失败" }, + { status: 500 } + ); + } + }), + + // ==================== 事件详情相关 ==================== + + // 获取事件详情 + http.get("/api/events/:eventId", async ({ params }) => { + await delay(200); + + const { eventId } = params; + const numericEventId = parseInt(eventId, 10); + + console.log("[Mock] 获取事件详情, eventId:", numericEventId); + + try { + // 检查是否已关注 + const isFollowing = isEventFollowed(numericEventId); + + // 返回模拟的事件详情数据 + return HttpResponse.json({ + success: true, + data: { + id: numericEventId, + title: `测试事件 ${eventId} - 重大政策发布`, + description: + "这是一个模拟的事件描述,用于开发测试。该事件涉及重要政策变化,可能对相关板块产生显著影响。建议关注后续发展动态。", + importance: ["S", "A", "B", "C"][Math.floor(Math.random() * 4)], + created_at: new Date().toISOString(), + trading_date: new Date().toISOString().split("T")[0], + event_type: ["政策", "财报", "行业", "宏观"][ + Math.floor(Math.random() * 4) + ], + related_avg_chg: parseFloat((Math.random() * 10 - 5).toFixed(2)), + follower_count: Math.floor(Math.random() * 500) + 50, + view_count: Math.floor(Math.random() * 5000) + 100, + is_following: isFollowing, // 使用内存状态 + post_count: Math.floor(Math.random() * 50), + expectation_surprise_score: parseFloat( + (Math.random() * 100).toFixed(1) + ), + }, + message: "获取成功", + }); + } catch (error) { + console.error("[Mock] 获取事件详情失败:", error); + return HttpResponse.json( + { + success: false, + error: "获取事件详情失败", + data: null, + }, + { status: 500 } + ); + } + }), + + // 获取事件超预期得分 + http.get("/api/events/:eventId/expectation-score", async ({ params }) => { + await delay(200); + + const { eventId } = params; + + console.log("[Mock] 获取事件超预期得分, eventId:", eventId); + + try { + // 生成模拟的超预期得分数据 + const score = parseFloat((Math.random() * 100).toFixed(1)); + const avgChange = parseFloat((Math.random() * 10 - 2).toFixed(2)); + const maxChange = parseFloat((Math.random() * 15).toFixed(2)); + + return HttpResponse.json({ + success: true, + data: { + event_id: parseInt(eventId), + expectation_score: score, + avg_change: avgChange, + max_change: maxChange, + stock_count: Math.floor(Math.random() * 20) + 5, + updated_at: new Date().toISOString(), + }, + message: "获取成功", + }); + } catch (error) { + console.error("[Mock] 获取事件超预期得分失败:", error); + return HttpResponse.json( + { + success: false, + error: "获取事件超预期得分失败", + data: null, + }, + { status: 500 } + ); + } + }), + + // 获取事件相关股票 + http.get("/api/events/:eventId/stocks", async ({ params }) => { + await delay(300); + + const { eventId } = params; + + console.log("[Mock] 获取事件相关股票, eventId:", eventId); + + try { + const relatedStocks = getEventRelatedStocks(eventId); + + return HttpResponse.json({ + success: true, + data: relatedStocks, + message: "获取成功", + }); + } catch (error) { + console.error("[Mock] 获取事件相关股票失败:", error); + return HttpResponse.json( + { + success: false, + error: "获取事件相关股票失败", + data: [], + }, + { status: 500 } + ); + } + }), + + // 获取事件相关概念 + http.get("/api/events/:eventId/concepts", async ({ params }) => { + await delay(300); + + const { eventId } = params; + + console.log("[Mock] 获取事件相关概念, eventId:", eventId); + + try { + // 返回热门概念列表(模拟真实场景下根据事件标题搜索的结果) + const concepts = generatePopularConcepts(5); + + return HttpResponse.json({ + success: true, + data: concepts, + message: "获取成功", + }); + } catch (error) { + console.error("[Mock] 获取事件相关概念失败:", error); + return HttpResponse.json( + { + success: false, + error: "获取事件相关概念失败", + data: [], + }, + { status: 500 } + ); + } + }), + + // 切换事件关注状态(使用内存状态管理) + http.post("/api/events/:eventId/follow", async ({ params, request }) => { + await delay(200); + + const { eventId } = params; + const numericEventId = parseInt(eventId, 10); + + console.log("[Mock] 切换事件关注状态, eventId:", numericEventId); + + try { + // 尝试从请求体获取事件数据(用于新关注时保存完整信息) + let eventData = null; + try { const body = await request.json(); - - console.log('[Mock] 发表评论, eventId:', eventId, 'content:', body.content); - - try { - // 获取当前登录用户信息 - const currentUser = getCurrentUser(); - - // 模拟创建新评论 - const newComment = { - id: `comment_${eventId}_${Date.now()}`, - content: body.content, - content_type: body.content_type || 'text', - author: { - id: currentUser?.id || 'current_user', - // 与导航区保持一致:优先显示昵称 - username: currentUser?.nickname || currentUser?.username || currentUser?.email || '当前用户', - avatar: currentUser?.avatar_url || null, - }, - created_at: new Date().toISOString(), - likes_count: 0, - is_liked: false, - }; - - // 将新评论添加到内存存储(插入到列表开头) - const comments = getOrInitComments(eventId); - comments.unshift(newComment); - - console.log('[Mock] 评论已添加到内存存储, 当前评论总数:', comments.length); - - return HttpResponse.json({ - success: true, - data: newComment, - message: '评论发布成功', - }); - } catch (error) { - console.error('[Mock] 发表评论失败:', error); - return HttpResponse.json( - { - success: false, - error: '评论发布失败', - message: '系统错误,请稍后重试', - }, - { status: 500 } - ); + if (body && body.title) { + eventData = body; } - }), + } catch { + // 没有请求体或解析失败,忽略 + } - // 删除帖子/评论 - http.delete('/api/posts/:postId', async ({ params }) => { - await delay(300); - const { postId } = params; + // 使用内存状态管理切换关注 + const { isFollowing, followerCount } = toggleEventFollowStatus( + numericEventId, + eventData + ); - console.log('[Mock] 删除帖子, postId:', postId); + return HttpResponse.json({ + success: true, + data: { + is_following: isFollowing, + follower_count: followerCount, + }, + message: isFollowing ? "关注成功" : "取消关注成功", + }); + } catch (error) { + console.error("[Mock] 切换事件关注状态失败:", error); + return HttpResponse.json( + { + success: false, + error: "切换关注状态失败", + data: null, + }, + { status: 500 } + ); + } + }), - try { - // 从内存存储中删除评论 - let deleted = false; - for (const [eventId, comments] of commentsStore.entries()) { - const index = comments.findIndex(c => String(c.id) === String(postId)); - if (index !== -1) { - comments.splice(index, 1); - deleted = true; - console.log('[Mock] 评论已从事件', eventId, '中删除'); - break; - } - } + // 事件情绪投票(看多/看空) + http.post( + "/api/events/:eventId/sentiment-vote", + async ({ params, request }) => { + await delay(200); - if (!deleted) { - console.log('[Mock] 未找到评论,但仍返回成功(可能是乐观更新的评论)'); - } + const { eventId } = params; + const numericEventId = parseInt(eventId, 10); - return HttpResponse.json({ - success: true, - message: '删除成功', - }); - } catch (error) { - console.error('[Mock] 删除帖子失败:', error); - return HttpResponse.json( - { - success: false, - error: '删除失败', - message: '系统错误,请稍后重试', - }, - { status: 500 } - ); + console.log("[Mock] 事件情绪投票, eventId:", numericEventId); + + try { + const body = await request.json(); + const voteType = body.vote_type; // 'bullish', 'bearish', 或 null + + // 使用内存状态管理投票 + // 简单模拟:根据 eventId 生成基础数据 + const baseBullish = ((numericEventId * 7) % 50) + 10; + const baseBearish = ((numericEventId * 3) % 30) + 5; + + // 根据投票类型调整计数 + let bullishCount = baseBullish; + let bearishCount = baseBearish; + + if (voteType === "bullish") { + bullishCount += 1; + } else if (voteType === "bearish") { + bearishCount += 1; } - }), - // ==================== 日历综合数据(一次性获取所有数据)==================== + return HttpResponse.json({ + success: true, + data: { + user_vote: voteType || null, + bullish_count: bullishCount, + bearish_count: bearishCount, + }, + message: voteType ? "投票成功" : "取消投票成功", + }); + } catch (error) { + console.error("[Mock] 事件情绪投票失败:", error); + return HttpResponse.json( + { + success: false, + error: "投票失败", + data: null, + }, + { status: 500 } + ); + } + } + ), - // 获取日历综合数据(涨停 + 事件 + 上证涨跌幅) - http.get('/api/v1/calendar/combined-data', async ({ request }) => { - await delay(400); + // 获取事件传导链分析数据 + http.get("/api/events/:eventId/transmission", async ({ params }) => { + await delay(500); - const url = new URL(request.url); - const year = parseInt(url.searchParams.get('year')) || new Date().getFullYear(); - const month = parseInt(url.searchParams.get('month')) || new Date().getMonth() + 1; + const { eventId } = params; - console.log('[Mock] 获取日历综合数据:', { year, month }); + console.log("[Mock] 获取事件传导链分析, eventId:", eventId); - try { - const data = []; - const today = new Date(); - const daysInMonth = new Date(year, month, 0).getDate(); + // Mock数据:事件传导链 + const mockTransmissionData = { + success: true, + data: { + nodes: [ + { + id: "1", + name: "主要事件", + category: "事件", + value: 50, + extra: { + node_type: "event", + description: "这是主要事件节点", + importance_score: 50, + is_main_event: true, + }, + }, + { + id: "2", + name: "半导体行业", + category: "行业", + value: 40, + extra: { + node_type: "industry", + description: "受影响的半导体行业", + importance_score: 40, + is_main_event: false, + }, + }, + { + id: "3", + name: "芯片制造", + category: "行业", + value: 35, + extra: { + node_type: "industry", + description: "芯片制造产业链", + importance_score: 35, + is_main_event: false, + }, + }, + { + id: "4", + name: "A公司", + category: "公司", + value: 30, + extra: { + node_type: "company", + description: "龙头企业A", + importance_score: 30, + stock_code: "600000", + is_main_event: false, + }, + }, + { + id: "5", + name: "B公司", + category: "公司", + value: 25, + extra: { + node_type: "company", + description: "龙头企业B", + importance_score: 25, + stock_code: "600001", + is_main_event: false, + }, + }, + { + id: "6", + name: "相关政策", + category: "政策", + value: 30, + extra: { + node_type: "policy", + description: "国家产业政策支持", + importance_score: 30, + is_main_event: false, + }, + }, + ], + edges: [ + { + source: "1", + target: "2", + value: 0.8, + extra: { + transmission_strength: 0.8, + transmission_type: "直接影响", + description: "主事件对半导体行业的直接影响", + }, + }, + { + source: "2", + target: "3", + value: 0.7, + extra: { + transmission_strength: 0.7, + transmission_type: "产业链传导", + description: "半导体到芯片制造的传导", + }, + }, + { + source: "3", + target: "4", + value: 0.6, + extra: { + transmission_strength: 0.6, + transmission_type: "企业影响", + description: "对龙头企业A的影响", + }, + }, + { + source: "3", + target: "5", + value: 0.5, + extra: { + transmission_strength: 0.5, + transmission_type: "企业影响", + description: "对龙头企业B的影响", + }, + }, + { + source: "6", + target: "1", + value: 0.7, + extra: { + transmission_strength: 0.7, + transmission_type: "政策驱动", + description: "政策对主事件的推动作用", + }, + }, + { + source: "6", + target: "2", + value: 0.6, + extra: { + transmission_strength: 0.6, + transmission_type: "政策支持", + description: "政策对行业的支持", + }, + }, + ], + categories: ["事件", "行业", "公司", "政策", "技术", "市场", "其他"], + }, + message: "获取成功", + }; - // 热门概念列表 - const hotConcepts = [ - '人工智能', '华为鸿蒙', '机器人', '芯片', '算力', '新能源', - '固态电池', '量子计算', '低空经济', '智能驾驶', '光伏', '储能' - ]; + return HttpResponse.json(mockTransmissionData); + }), - // 预生成概念连续段(用于测试跨天连接效果) - // 每段 2-4 天使用相同概念 - let currentConcept = hotConcepts[0]; - let conceptDaysLeft = 0; - let conceptIndex = 0; + // 获取桑基图数据 + http.get("/api/events/:eventId/sankey-data", async ({ params }) => { + await delay(300); + const { eventId } = params; + console.log("[Mock] 获取桑基图数据, eventId:", eventId); - for (let day = 1; day <= daysInMonth; day++) { - const date = new Date(year, month - 1, day); - const dayOfWeek = date.getDay(); + const mockSankeyData = { + success: true, + data: { + nodes: [ + { + name: "相关政策", + type: "policy", + level: 0, + color: "#10ac84", + }, + { + name: "主要事件", + type: "event", + level: 0, + color: "#ff4757", + }, + { + name: "半导体行业", + type: "industry", + level: 1, + color: "#00d2d3", + }, + { + name: "芯片制造", + type: "industry", + level: 2, + color: "#00d2d3", + }, + { + name: "A公司", + type: "company", + level: 3, + color: "#54a0ff", + }, + { + name: "B公司", + type: "company", + level: 3, + color: "#54a0ff", + }, + ], + links: [ + { source: 0, target: 1, value: 7 }, // 相关政策 -> 主要事件 + { source: 0, target: 2, value: 6 }, // 相关政策 -> 半导体行业 + { source: 1, target: 2, value: 8 }, // 主要事件 -> 半导体行业 + { source: 2, target: 3, value: 7 }, // 半导体行业 -> 芯片制造 + { source: 3, target: 4, value: 6 }, // 芯片制造 -> A公司 + { source: 3, target: 5, value: 5 }, // 芯片制造 -> B公司 + ], + }, + message: "获取成功", + }; - // 跳过周末 - if (dayOfWeek === 0 || dayOfWeek === 6) continue; + return HttpResponse.json(mockSankeyData); + }), - const dateStr = `${year}${String(month).padStart(2, '0')}${String(day).padStart(2, '0')}`; - const isPast = date < today; - const isToday = date.toDateString() === today.toDateString(); - const isFuture = date > today; + // 获取传导链节点详情 + http.get("/api/events/:eventId/chain-node/:nodeId", async ({ params }) => { + await delay(300); - // 使用日期作为种子生成一致的随机数 - const dateSeed = year * 10000 + month * 100 + day; - const seededRandom = (seed) => { - const x = Math.sin(seed) * 10000; - return x - Math.floor(x); - }; + const { eventId, nodeId } = params; - // 概念连续段逻辑:每段 2-4 天使用相同概念 - if (conceptDaysLeft <= 0) { - conceptIndex = Math.floor(seededRandom(dateSeed + 100) * hotConcepts.length); - currentConcept = hotConcepts[conceptIndex]; - conceptDaysLeft = Math.floor(seededRandom(dateSeed + 101) * 3) + 2; // 2-4 天 - } - conceptDaysLeft--; + console.log("[Mock] 获取节点详情, eventId:", eventId, "nodeId:", nodeId); - const item = { - date: dateStr, - zt_count: 0, - top_sector: '', - event_count: 0, - index_change: null - }; + // 根据节点ID返回不同的详细信息 + const nodeDetailsMap = { + 1: { + success: true, + data: { + node: { + id: "1", + name: "主要事件", + type: "event", + description: + "这是影响整个产业链的重大事件,涉及政策调整和技术突破,对下游产业产生深远影响。", + importance_score: 50, + total_connections: 2, + incoming_connections: 1, + outgoing_connections: 1, + }, + parents: [ + { + id: "6", + name: "相关政策", + transmission_mechanism: { + data: [ + { + author: "国务院", + sentences: + "为加快实施创新驱动发展战略,推动产业转型升级,国家将对重点领域给予财政补贴支持,单个项目最高补贴金额可达5000万元,同时享受研发费用加计扣除175%的税收优惠政策", + query_part: + "国家财政补贴最高5000万元,研发费用加计扣除175%", + match_score: "好", + declare_date: "2024-01-15T00:00:00", + report_title: "关于促进产业高质量发展的若干政策措施", + }, + { + author: "工信部", + sentences: + "根据《重点产业扶持目录》,对符合条件的企业和项目,将优先纳入政府采购名单,并提供专项资金支持,确保政策红利直接惠及实体经济", + query_part: "政府采购优先支持,专项资金直达企业", + match_score: "好", + declare_date: "2024-01-20T00:00:00", + report_title: "工业和信息化部关于落实产业扶持政策的通知", + }, + ], + }, + direction: "positive", + strength: 70, + is_circular: false, + }, + ], + children: [ + { + id: "2", + name: "半导体行业(正向影响)", + transmission_mechanism: { + data: [ + { + author: "李明", + organization: "中国电子信息产业发展研究院", + sentences: + "在技术突破和应用场景快速扩张的双重驱动下,国内半导体市场呈现爆发式增长态势。据统计,2024年上半年半导体市场规模达到1.2万亿元,同比增长32%,其中新能源汽车和AI算力芯片需求贡献了超过60%的增量", + query_part: "技术突破和需求激增推动半导体市场增长32%", + match_score: "好", + declare_date: "2024-07-10T00:00:00", + report_title: "2024年上半年中国半导体产业发展报告", + }, + ], + }, + direction: "positive", + strength: 80, + is_circular: false, + }, + { + id: "7", + name: "传统制造业(负向影响)", + transmission_mechanism: { + data: [ + { + author: "张华", + organization: "经济观察报", + sentences: + "随着半导体等高科技产业获得大量政策和资金支持,传统制造业面临融资难、用工成本上升等多重压力。部分劳动密集型企业利润率下降15%,行业整体投资意愿降低", + query_part: "资源向高科技倾斜导致传统制造业承压", + match_score: "好", + declare_date: "2024-06-15T00:00:00", + report_title: "传统制造业转型升级调研报告", + }, + ], + }, + direction: "negative", + strength: 60, + is_circular: false, + }, + { + id: "8", + name: "能源行业(中性影响)", + transmission_mechanism: { + data: [ + { + author: "王刚", + organization: "能源研究所", + sentences: + "半导体产业扩张带来电力需求增长约8%,但同时推动节能技术应用,整体能源消费结构趋于优化。新建芯片工厂虽增加用电负荷,但智能电网技术应用使能源利用效率提升12%", + query_part: "半导体产业对能源行业影响相对中性", + match_score: "中", + declare_date: "2024-07-01T00:00:00", + report_title: "高科技产业能源消费分析", + }, + ], + }, + direction: "neutral", + strength: 40, + is_circular: false, + }, + { + id: "9", + name: "教育培训行业(未明确方向)", + transmission_mechanism: { + data: [ + { + author: "赵敏", + organization: "教育部职业教育司", + sentences: + "半导体产业快速发展催生大量专业人才需求,各类培训机构、职业院校纷纷开设相关课程。预计未来三年将新增半导体专业学员超过50万人,带动职业教育市场规模扩大", + query_part: "半导体产业推动职业教育发展", + match_score: "好", + declare_date: "2024-06-20T00:00:00", + report_title: "半导体人才培养白皮书", + }, + ], + }, + strength: 50, + is_circular: false, + }, + ], + }, + }, + 2: { + success: true, + data: { + node: { + id: "2", + name: "半导体行业", + type: "industry", + description: + "半导体行业是现代科技产业的基础,受到主事件和政策的双重推动,迎来新一轮发展机遇。", + importance_score: 40, + total_connections: 3, + incoming_connections: 2, + outgoing_connections: 1, + }, + parents: [ + { + id: "1", + name: "主要事件", + transmission_mechanism: { + data: [ + { + author: "刘洋", + organization: "中国半导体行业协会", + sentences: + "受益于新能源汽车、5G通信等新兴应用领域的爆发式增长,国内半导体市场需求持续旺盛,2024年Q1市场规模同比增长28%,创历史新高", + query_part: "新兴应用推动半导体需求增长28%", + match_score: "好", + declare_date: "2024-04-05T00:00:00", + report_title: "2024年Q1中国半导体行业景气度报告", + }, + { + author: "刘洋", + organization: "中国半导体行业协会", + sentences: + "受益于新能源汽车、5G通信等新兴应用领域的爆发式增长,国内半导体市场需求持续旺盛,2024年Q1市场规模同比增长28%,创历史新高", + query_part: "新兴应用推动半导体需求增长28%", + match_score: "好", + declare_date: "2024-04-05T00:00:00", + report_title: "2024年Q1中国半导体行业景气度报告", + }, + ], + }, + direction: "positive", + strength: 80, + is_circular: false, + }, + { + id: "6", + name: "相关政策", + transmission_mechanism: { + data: [ + { + author: "国家发改委", + sentences: + "《国家集成电路产业发展推进纲要》明确提出,到2025年半导体产业自给率要达到70%以上,国家将设立专项基金规模超过3000亿元,重点支持半导体设备、材料、设计等关键环节", + query_part: "半导体自给率目标70%,专项基金3000亿", + match_score: "好", + declare_date: "2024-02-01T00:00:00", + report_title: "国家集成电路产业发展推进纲要(2024-2030)", + }, + ], + }, + direction: "positive", + strength: 60, + is_circular: false, + }, + ], + children: [ + { + id: "3", + name: "芯片制造", + transmission_mechanism: { + data: [ + { + author: "张明", + organization: "中信证券", + sentences: + "在半导体行业景气度持续提升的背景下,下游芯片制造企业订单饱满,产能利用率达到历史新高,预计2024年产能扩张将达到30%以上,技术工艺也将从28nm向14nm升级", + query_part: "半导体行业繁荣带动芯片制造产能扩张30%", + match_score: "好", + declare_date: "2024-03-15T00:00:00", + report_title: "半导体行业深度报告:产业链景气度传导分析", + }, + { + author: "李华", + organization: "海通证券", + sentences: + "芯片制造环节作为半导体产业链核心,受益于上游材料供应稳定和下游应用需求旺盛,技术迭代速度明显加快,先进制程占比持续提升", + query_part: "技术迭代加快,先进制程占比提升", + match_score: "好", + declare_date: "2024-02-28T00:00:00", + report_title: "芯片制造行业跟踪报告", + }, + ], + }, + direction: "positive", + strength: 70, + is_circular: false, + }, + ], + }, + }, + 3: { + success: true, + data: { + node: { + id: "3", + name: "芯片制造", + type: "industry", + description: + "芯片制造作为半导体产业链的核心环节,在上游需求推动下,产能利用率提升,技术迭代加快。", + importance_score: 35, + total_connections: 3, + incoming_connections: 1, + outgoing_connections: 2, + }, + parents: [ + { + id: "2", + name: "半导体行业", + transmission_mechanism: { + data: [ + { + author: "张明", + sentences: + "在半导体行业景气度持续提升的背景下,下游芯片制造企业订单饱满,产能利用率达到历史新高,预计2024年产能扩张将达到30%以上,技术工艺也将从28nm向14nm升级", + query_part: "半导体行业繁荣带动芯片制造产能扩张30%", + match_score: "好", + declare_date: "2024-03-15T00:00:00", + report_title: "半导体行业深度报告:产业链景气度传导分析", + }, + { + author: "李华", + sentences: + "芯片制造环节作为半导体产业链核心,受益于上游材料供应稳定和下游应用需求旺盛,技术迭代速度明显加快,先进制程占比持续提升", + query_part: "技术迭代加快,先进制程占比提升", + match_score: "好", + declare_date: "2024-02-28T00:00:00", + report_title: "芯片制造行业跟踪报告", + }, + ], + }, + direction: "positive", + strength: 70, + is_circular: false, + }, + ], + children: [ + { + id: "4", + name: "A公司", + transmission_mechanism: { + data: [ + { + author: "王芳", + organization: "国泰君安", + sentences: + "A公司作为国内芯片制造龙头企业,在手订单已排至2024年Q4,预计全年营收增长45%,净利润增长60%以上。公司28nm及以下先进制程产能占比已达到40%,技术实力行业领先", + query_part: "A公司在手订单充足,预计营收增长45%", + match_score: "好", + declare_date: "2024-04-10T00:00:00", + report_title: "A公司深度研究:受益芯片制造景气周期", + }, + ], + }, + direction: "positive", + strength: 60, + is_circular: false, + }, + { + id: "5", + name: "B公司", + transmission_mechanism: { + data: [ + { + author: "赵强", + organization: "华泰证券", + sentences: + "随着芯片制造产能的大规模扩张,上游设备和材料供应商迎来历史性机遇。B公司作为核心配套企业,订单量同比增长55%,产品供不应求,预计2024年营收将突破百亿大关。公司在封装测试领域的市场份额已提升至国内第二位", + query_part: "B公司订单增长55%,营收将破百亿", + match_score: "好", + declare_date: "2024-05-08T00:00:00", + report_title: "B公司跟踪报告:芯片产业链配套龙头崛起", + }, + { + author: "陈彤", + organization: "国信证券", + sentences: + "B公司深度受益于芯片制造产业链的景气度传导。公司凭借先进的封装技术和完善的产能布局,成功绑定多家头部芯片制造企业,形成稳定的供应关系。随着下游客户产能持续扩张,公司业绩增长确定性强", + query_part: "B公司受益产业链景气度,业绩增长确定性强", + match_score: "好", + declare_date: "2024-06-01T00:00:00", + report_title: + "半导体封装测试行业专题:产业链景气度传导分析", + }, + ], + }, + direction: "positive", + strength: 50, + is_circular: false, + }, + ], + }, + }, + 4: { + success: true, + data: { + node: { + id: "4", + name: "A公司", + type: "company", + description: + "A公司是行业龙头企业,拥有先进的芯片制造技术和完整的产业链布局,在本轮产业升级中占据有利位置。", + importance_score: 30, + stock_code: "600000", + total_connections: 1, + incoming_connections: 1, + outgoing_connections: 0, + }, + parents: [ + { + id: "3", + name: "芯片制造", + transmission_mechanism: { + data: [ + { + author: "王芳", + sentences: + "A公司作为国内芯片制造龙头企业,在手订单已排至2024年Q4,预计全年营收增长45%,净利润增长60%以上。公司28nm及以下先进制程产能占比已达到40%,技术实力行业领先", + query_part: "A公司在手订单充足,预计营收增长45%", + match_score: "好", + declare_date: "2024-04-10T00:00:00", + report_title: "A公司深度研究:受益芯片制造景气周期", + }, + ], + }, + direction: "positive", + strength: 60, + is_circular: false, + }, + ], + children: [], + }, + }, + 5: { + success: true, + data: { + node: { + id: "5", + name: "B公司", + type: "company", + description: + "B公司专注于芯片封装测试领域,随着上游制造产能释放,公司订单饱满,业绩稳步增长。", + importance_score: 25, + stock_code: "600001", + total_connections: 1, + incoming_connections: 1, + outgoing_connections: 0, + }, + parents: [ + { + id: "3", + name: "芯片制造", + transmission_mechanism: { + data: [ + { + author: "赵强", + organization: "华泰证券", + sentences: + "随着芯片制造产能的大规模扩张,上游设备和材料供应商迎来历史性机遇。B公司作为核心配套企业,订单量同比增长55%,产品供不应求,预计2024年营收将突破百亿大关", + query_part: "B公司订单增长55%,营收将破百亿", + match_score: "好", + declare_date: "2024-05-08T00:00:00", + report_title: "B公司跟踪报告:芯片产业链配套龙头崛起", + }, + ], + }, + direction: "positive", + strength: 50, + is_circular: false, + }, + ], + children: [], + }, + }, + 6: { + success: true, + data: { + node: { + id: "6", + name: "相关政策", + type: "policy", + description: + "国家出台了一系列产业扶持政策,包括财政补贴、税收减免和研发支持,旨在推动产业自主创新和进口替代。", + importance_score: 30, + total_connections: 2, + incoming_connections: 0, + outgoing_connections: 2, + }, + parents: [], + children: [ + { + id: "1", + name: "主要事件", + transmission_mechanism: { + data: [ + { + author: "国务院", + sentences: + "为加快实施创新驱动发展战略,推动产业转型升级,国家将对重点领域给予财政补贴支持,单个项目最高补贴金额可达5000万元,同时享受研发费用加计扣除175%的税收优惠政策", + query_part: + "国家财政补贴最高5000万元,研发费用加计扣除175%", + match_score: "好", + declare_date: "2024-01-15T00:00:00", + report_title: "关于促进产业高质量发展的若干政策措施", + }, + { + author: "工信部", + sentences: + "将重点支持关键核心技术攻关和产业化应用,建立产业发展专项基金,规模达到1000亿元,引导社会资本共同参与产业发展", + query_part: "设立1000亿元产业发展专项基金", + match_score: "好", + declare_date: "2024-02-01T00:00:00", + report_title: "产业发展专项基金管理办法", + }, + ], + }, + direction: "positive", + strength: 70, + is_circular: false, + }, + { + id: "2", + name: "半导体行业", + transmission_mechanism: { + data: [ + { + author: "国家发改委", + sentences: + "《国家集成电路产业发展推进纲要》明确提出,到2025年半导体产业自给率要达到70%以上,国家将设立专项基金规模超过3000亿元,重点支持半导体设备、材料、设计等关键环节。同时,通过进口替代战略,加快培育本土产业链", + query_part: "半导体自给率目标70%,专项基金3000亿", + match_score: "好", + declare_date: "2024-02-01T00:00:00", + report_title: "国家集成电路产业发展推进纲要(2024-2030)", + }, + { + author: "工信部", + sentences: + "将重点支持关键核心技术攻关和产业化应用,建立产业发展专项基金,规模达到1000亿元,引导社会资本共同参与产业发展。通过税收优惠、研发补贴等政策工具,为半导体行业创造良好的发展环境", + query_part: "设立1000亿元产业发展专项基金", + match_score: "好", + declare_date: "2024-02-01T00:00:00", + report_title: "产业发展专项基金管理办法", + }, + ], + }, + direction: "positive", + strength: 60, + is_circular: false, + }, + ], + }, + }, + }; - // 历史数据:涨停 + 上证涨跌幅 - if (isPast || isToday) { - item.zt_count = Math.floor(seededRandom(dateSeed) * 80 + 30); // 30-110 - item.top_sector = currentConcept; // 使用连续概念 - item.index_change = parseFloat((seededRandom(dateSeed + 2) * 4 - 2).toFixed(2)); // -2% ~ +2% - } + // 返回对应节点的详情,如果不存在则返回默认数据 + const nodeDetail = nodeDetailsMap[nodeId] || { + success: true, + data: { + node: { + id: nodeId, + name: "未知节点", + type: "other", + description: "该节点暂无详细信息", + importance_score: 0, + total_connections: 0, + incoming_connections: 0, + outgoing_connections: 0, + }, + parents: [], + children: [], + }, + }; - // 未来数据:事件数量 - if (isFuture) { - const hasEvents = seededRandom(dateSeed + 3) > 0.4; // 60% 概率有事件 - if (hasEvents) { - item.event_count = Math.floor(seededRandom(dateSeed + 4) * 10 + 1); // 1-10 - } - } + return HttpResponse.json(nodeDetail); + }), - // 今天:同时有涨停和事件 - if (isToday) { - item.event_count = Math.floor(seededRandom(dateSeed + 5) * 8 + 2); // 2-10 - } + // ==================== 投资日历相关 ==================== - data.push(item); - } + // 获取月度事件统计 + http.get("/api/v1/calendar/event-counts", async ({ request }) => { + await delay(300); - return HttpResponse.json({ - success: true, - data: data, - year: year, - month: month - }); - } catch (error) { - console.error('[Mock] 获取日历综合数据失败:', error); - return HttpResponse.json( - { - success: false, - error: '获取日历综合数据失败', - data: [] - }, - { status: 500 } - ); + const url = new URL(request.url); + const year = parseInt(url.searchParams.get("year")); + const month = parseInt(url.searchParams.get("month")); + + console.log("[Mock] 获取月度事件统计:", { year, month }); + + const eventCounts = getMockEventCountsForMonth(year, month); + + return HttpResponse.json({ + success: true, + data: eventCounts, + }); + }), + + // 获取指定日期的事件列表 + http.get("/api/v1/calendar/events", async ({ request }) => { + await delay(300); + + const url = new URL(request.url); + const dateStr = url.searchParams.get("date"); + const type = url.searchParams.get("type") || "all"; + + console.log("[Mock] 获取日历事件列表:", { date: dateStr, type }); + + if (!dateStr) { + return HttpResponse.json( + { + success: false, + error: "Date parameter required", + }, + { status: 400 } + ); + } + + const events = getMockFutureEvents(dateStr, type); + + return HttpResponse.json({ + success: true, + data: events, + }); + }), + + // 切换未来事件关注状态 + http.post("/api/v1/calendar/events/:eventId/follow", async ({ params }) => { + await delay(300); + + const { eventId } = params; + + console.log("[Mock] 切换事件关注状态, eventId:", eventId); + + // 简单返回成功,实际状态管理可以后续完善 + return HttpResponse.json({ + success: true, + data: { + is_following: true, + message: "关注成功", + }, + }); + }), + + // ==================== 历史事件对比相关 ==================== + + // 获取历史事件列表 + http.get("/api/events/:eventId/historical", async ({ params }) => { + await delay(400); + + const { eventId } = params; + + console.log("[Mock] 获取历史事件列表, eventId:", eventId); + + // 生成历史事件数据 + const generateHistoricalEvents = (count = 5) => { + const events = []; + const eventTitles = [ + "芯片产业链政策扶持升级", + "新能源汽车销量创历史新高", + "人工智能大模型技术突破", + "半导体设备国产化加速", + "数字经济政策利好发布", + "新能源产业链整合提速", + "医药创新药获批上市", + "5G应用场景扩展", + "智能驾驶技术迭代升级", + "储能行业景气度上行", + ]; + + const importanceLevels = [1, 2, 3, 4, 5]; + + for (let i = 0; i < count; i++) { + const daysAgo = Math.floor(Math.random() * 180) + 30; // 30-210 天前 + const date = new Date(); + date.setDate(date.getDate() - daysAgo); + + const importance = + importanceLevels[Math.floor(Math.random() * importanceLevels.length)]; + const title = eventTitles[i % eventTitles.length]; + + // 带引用来源的研报数据 + const researchReports = [ + { + author: "中信证券", + report_title: `${title}深度研究报告`, + declare_date: new Date( + date.getTime() - + Math.floor(Math.random() * 10) * 24 * 60 * 60 * 1000 + ).toISOString(), + }, + { + author: "国泰君安", + report_title: `行业专题:${title}影响分析`, + declare_date: new Date( + date.getTime() - + Math.floor(Math.random() * 15) * 24 * 60 * 60 * 1000 + ).toISOString(), + }, + { + author: "华泰证券", + report_title: `${title}投资机会深度解析`, + declare_date: new Date( + date.getTime() - + Math.floor(Math.random() * 20) * 24 * 60 * 60 * 1000 + ).toISOString(), + }, + ]; + + // 生成带引用标记的content(data结构) + const contentWithCitations = { + data: [ + { + query_part: `${title}的详细描述。该事件对相关产业链产生重要影响【1】,市场关注度高,相关概念股表现活跃。`, + sentences: `根据券商研报分析,${title}将推动相关产业链快速发展【2】。预计未来${Math.floor( + Math.random() * 2 + 1 + )}年内,相关企业营收增速有望达到${Math.floor( + Math.random() * 30 + 20 + )}%以上【3】。该事件的影响范围广泛,涉及多个细分领域,投资机会显著。`, + match_score: + importance >= 4 ? "好" : importance >= 2 ? "中" : "一般", + author: researchReports[0].author, + declare_date: researchReports[0].declare_date, + report_title: researchReports[0].report_title, + }, + { + query_part: `市场分析师认为,该事件将带动产业链上下游企业协同发展【2】,形成良性循环。`, + sentences: `从产业趋势来看,相关板块估值仍处于合理区间,具备较高的安全边际。机构投资者持续加仓相关标的,显示出对长期发展前景的看好。`, + match_score: importance >= 3 ? "好" : "中", + author: researchReports[1].author, + declare_date: researchReports[1].declare_date, + report_title: researchReports[1].report_title, + }, + { + query_part: `根据行业数据显示,受此事件影响,相关企业订单量同比增长${Math.floor( + Math.random() * 40 + 30 + )}%【3】。`, + sentences: `行业景气度持续提升,龙头企业凭借技术优势和规模效应,市场份额有望进一步扩大。建议关注产业链核心环节的投资机会。`, + match_score: "好", + author: researchReports[2].author, + declare_date: researchReports[2].declare_date, + report_title: researchReports[2].report_title, + }, + ], + }; + + events.push({ + id: `hist_event_${i + 1}`, + title: title, + content: contentWithCitations, // 升级版本:带引用来源的data结构 + description: `${title}的详细描述。该事件对相关产业链产生重要影响,市场关注度高,相关概念股表现活跃。`, // 降级兼容 + date: date.toISOString().split("T")[0], + importance: importance, + similarity: Math.floor(Math.random() * 10) + 1, // 1-10 + impact_sectors: [ + ["半导体", "芯片设计", "EDA"], + ["新能源汽车", "锂电池", "充电桩"], + ["人工智能", "算力", "大模型"], + ["半导体设备", "国产替代", "集成电路"], + ["数字经济", "云计算", "大数据"], + ][i % 5], + affected_stocks_count: Math.floor(Math.random() * 30) + 10, // 10-40 只股票 + avg_change_pct: parseFloat((Math.random() * 10 - 2).toFixed(2)), // -2% to +8% + created_at: date.toISOString(), + }); + } + + // 按日期降序排序 + return events.sort((a, b) => new Date(b.date) - new Date(a.date)); + }; + + try { + const historicalEvents = generateHistoricalEvents(5); + + return HttpResponse.json({ + success: true, + data: historicalEvents, + total: historicalEvents.length, + message: "获取历史事件列表成功", + }); + } catch (error) { + console.error("[Mock] 获取历史事件列表失败:", error); + return HttpResponse.json( + { + success: false, + error: "获取历史事件列表失败", + data: [], + }, + { status: 500 } + ); + } + }), + + // 获取历史事件相关股票 + http.get("/api/historical-events/:eventId/stocks", async ({ params }) => { + await delay(500); + + const { eventId } = params; + + console.log("[Mock] 获取历史事件相关股票, eventId:", eventId); + + // 生成历史事件相关股票数据 + const generateHistoricalEventStocks = (count = 10) => { + const stocks = []; + const sectors = [ + "半导体", + "新能源", + "医药", + "消费电子", + "人工智能", + "5G通信", + ]; + const stockNames = [ + "中芯国际", + "长江存储", + "华为海思", + "紫光国微", + "兆易创新", + "宁德时代", + "比亚迪", + "隆基绿能", + "阳光电源", + "亿纬锂能", + "恒瑞医药", + "迈瑞医疗", + "药明康德", + "泰格医药", + "康龙化成", + "立讯精密", + "歌尔声学", + "京东方A", + "TCL科技", + "海康威视", + "科大讯飞", + "商汤科技", + "寒武纪", + "海光信息", + "中兴通讯", + ]; + + for (let i = 0; i < count; i++) { + const stockCode = `${Math.random() > 0.5 ? "6" : "0"}${String( + Math.floor(Math.random() * 100000) + ).padStart(5, "0")}`; + const changePct = (Math.random() * 15 - 3).toFixed(2); // -3% ~ +12% + const correlation = (Math.random() * 0.4 + 0.6).toFixed(2); // 0.6 ~ 1.0 + + stocks.push({ + id: `stock_${i}`, + stock_code: `${stockCode}.${Math.random() > 0.5 ? "SH" : "SZ"}`, + stock_name: stockNames[i % stockNames.length], + sector: sectors[Math.floor(Math.random() * sectors.length)], + correlation: parseFloat(correlation), + event_day_change_pct: parseFloat(changePct), + relation_desc: { + data: [ + { + query_part: `该公司是${ + sectors[Math.floor(Math.random() * sectors.length)] + }行业龙头,受事件影响显著,市场关注度高,订单量同比增长${Math.floor( + Math.random() * 50 + 20 + )}%`, + sentences: `根据行业研究报告,该公司在${ + sectors[Math.floor(Math.random() * sectors.length)] + }领域具有核心技术优势,产能利用率达到${Math.floor( + Math.random() * 20 + 80 + )}%,随着事件的深入发展,公司业绩有望持续受益。机构预测未来三年复合增长率将达到${Math.floor( + Math.random() * 30 + 15 + )}%以上`, + match_score: + correlation > 0.8 ? "好" : correlation > 0.6 ? "中" : "一般", + author: ["中信证券", "国泰君安", "华泰证券", "招商证券"][ + Math.floor(Math.random() * 4) + ], + declare_date: new Date( + Date.now() - + Math.floor(Math.random() * 90) * 24 * 60 * 60 * 1000 + ).toISOString(), + report_title: `${ + stockNames[i % stockNames.length] + }深度研究报告`, + }, + ], + }, + }); + } + + // 按相关度降序排序 + return stocks.sort((a, b) => b.correlation - a.correlation); + }; + + try { + const stocks = generateHistoricalEventStocks(15); + + return HttpResponse.json({ + success: true, + data: stocks, + message: "获取历史事件相关股票成功", + }); + } catch (error) { + console.error("[Mock] 获取历史事件相关股票失败:", error); + return HttpResponse.json( + { + success: false, + error: "获取历史事件相关股票失败", + data: [], + }, + { status: 500 } + ); + } + }), + + // ==================== 评论相关 ==================== + + // 获取事件评论列表 + http.get("/api/events/:eventId/posts", async ({ params, request }) => { + await delay(300); + + const { eventId } = params; + const url = new URL(request.url); + const sort = url.searchParams.get("sort") || "latest"; + const page = parseInt(url.searchParams.get("page") || "1"); + const perPage = parseInt(url.searchParams.get("per_page") || "20"); + + console.log("[Mock] 获取评论列表, eventId:", eventId, "sort:", sort); + + try { + // 从内存存储获取评论列表 + const allComments = getOrInitComments(eventId); + + // ✅ 创建副本并排序(避免直接修改原数组) + let sortedComments = [...allComments]; + if (sort === "hot") { + sortedComments.sort((a, b) => b.likes_count - a.likes_count); + } else { + // 默认按时间升序(oldest first)- 最旧评论在前,最新在后 + sortedComments.sort( + (a, b) => new Date(a.created_at) - new Date(b.created_at) + ); + } + + // 分页处理(使用排序后的副本) + const startIndex = (page - 1) * perPage; + const endIndex = startIndex + perPage; + const paginatedComments = sortedComments.slice(startIndex, endIndex); + + return HttpResponse.json({ + success: true, + data: paginatedComments, + pagination: { + page: page, + per_page: perPage, + total: allComments.length, + pages: Math.ceil(allComments.length / perPage), + has_prev: page > 1, + has_next: endIndex < allComments.length, + }, + message: "获取评论成功", + }); + } catch (error) { + console.error("[Mock] 获取评论列表失败:", error); + return HttpResponse.json( + { + success: false, + error: "获取评论失败", + data: [], + }, + { status: 500 } + ); + } + }), + + // 发表评论 + http.post("/api/events/:eventId/posts", async ({ params, request }) => { + await delay(500); + + const { eventId } = params; + const body = await request.json(); + + console.log("[Mock] 发表评论, eventId:", eventId, "content:", body.content); + + try { + // 获取当前登录用户信息 + const currentUser = getCurrentUser(); + + // 模拟创建新评论 + const newComment = { + id: `comment_${eventId}_${Date.now()}`, + content: body.content, + content_type: body.content_type || "text", + author: { + id: currentUser?.id || "current_user", + // 与导航区保持一致:优先显示昵称 + username: + currentUser?.nickname || + currentUser?.username || + currentUser?.email || + "当前用户", + avatar: currentUser?.avatar_url || null, + }, + created_at: new Date().toISOString(), + likes_count: 0, + is_liked: false, + }; + + // 将新评论添加到内存存储(插入到列表开头) + const comments = getOrInitComments(eventId); + comments.unshift(newComment); + + console.log( + "[Mock] 评论已添加到内存存储, 当前评论总数:", + comments.length + ); + + return HttpResponse.json({ + success: true, + data: newComment, + message: "评论发布成功", + }); + } catch (error) { + console.error("[Mock] 发表评论失败:", error); + return HttpResponse.json( + { + success: false, + error: "评论发布失败", + message: "系统错误,请稍后重试", + }, + { status: 500 } + ); + } + }), + + // 删除帖子/评论 + http.delete("/api/posts/:postId", async ({ params }) => { + await delay(300); + const { postId } = params; + + console.log("[Mock] 删除帖子, postId:", postId); + + try { + // 从内存存储中删除评论 + let deleted = false; + for (const [eventId, comments] of commentsStore.entries()) { + const index = comments.findIndex( + (c) => String(c.id) === String(postId) + ); + if (index !== -1) { + comments.splice(index, 1); + deleted = true; + console.log("[Mock] 评论已从事件", eventId, "中删除"); + break; } - }), + } - // ==================== 事件有效性统计 ==================== + if (!deleted) { + console.log("[Mock] 未找到评论,但仍返回成功(可能是乐观更新的评论)"); + } - // 获取事件有效性统计数据 - http.get('/api/v1/events/effectiveness-stats', async ({ request }) => { - await delay(300); + return HttpResponse.json({ + success: true, + message: "删除成功", + }); + } catch (error) { + console.error("[Mock] 删除帖子失败:", error); + return HttpResponse.json( + { + success: false, + error: "删除失败", + message: "系统错误,请稍后重试", + }, + { status: 500 } + ); + } + }), - const url = new URL(request.url); - const days = parseInt(url.searchParams.get('days') || '30'); + // ==================== 日历综合数据(一次性获取所有数据)==================== - console.log('[Mock] 获取事件有效性统计:', { days }); + // 获取日历综合数据(涨停 + 事件 + 上证涨跌幅) + http.get("/api/v1/calendar/combined-data", async ({ request }) => { + await delay(400); - try { - // 模拟统计数据 - const summary = { - totalEvents: Math.floor(Math.random() * 200) + 100, // 总事件数 - effectiveEvents: Math.floor(Math.random() * 150) + 50, // 有效事件数 - effectiveRate: parseFloat((Math.random() * 30 + 60).toFixed(1)), // 有效率 60%-90% - avgReturn: parseFloat((Math.random() * 5 + 2).toFixed(2)), // 平均收益 2%-7% - maxReturn: parseFloat((Math.random() * 15 + 5).toFixed(2)), // 最大收益 5%-20% - hitRate: parseFloat((Math.random() * 20 + 70).toFixed(1)), // 命中率 70%-90% - avgHoldDays: Math.floor(Math.random() * 5) + 3, // 平均持有天数 3-8 - winLossRatio: parseFloat((Math.random() * 2 + 1.5).toFixed(2)), // 盈亏比 1.5-3.5 - }; + const url = new URL(request.url); + const year = + parseInt(url.searchParams.get("year")) || new Date().getFullYear(); + const month = + parseInt(url.searchParams.get("month")) || new Date().getMonth() + 1; - // 模拟表现最佳的事件 - const topPerformers = []; - const eventTitles = [ - '重大政策利好:半导体产业扶持政策出台', - '人工智能板块迎来突破性进展', - '新能源汽车销量创历史新高', - '消费复苏数据超预期', - '央行降准释放流动性', - '科技股龙头财报超预期', - '军工板块获重大订单', - '医药创新药获批上市', - ]; + console.log("[Mock] 获取日历综合数据:", { year, month }); - for (let i = 0; i < 5; i++) { - topPerformers.push({ - id: i + 1, - title: eventTitles[i % eventTitles.length], - importance: ['S', 'A', 'B'][Math.floor(Math.random() * 3)], - created_at: new Date(Date.now() - Math.random() * days * 24 * 60 * 60 * 1000).toISOString(), - avg_chg: parseFloat((Math.random() * 8 + 2).toFixed(2)), - max_chg: parseFloat((Math.random() * 15 + 5).toFixed(2)), - stock_count: Math.floor(Math.random() * 20) + 5, - }); - } + try { + const data = []; + const today = new Date(); + const daysInMonth = new Date(year, month, 0).getDate(); - // 按 max_chg 降序排列 - topPerformers.sort((a, b) => b.max_chg - a.max_chg); + // 热门概念列表 + const hotConcepts = [ + "人工智能", + "华为鸿蒙", + "机器人", + "芯片", + "算力", + "新能源", + "固态电池", + "量子计算", + "低空经济", + "智能驾驶", + "光伏", + "储能", + ]; - return HttpResponse.json({ - code: 200, - data: { - summary, - topPerformers, - }, - message: '获取成功' - }); - } catch (error) { - console.error('[Mock] 获取事件有效性统计失败:', error); - return HttpResponse.json( - { - code: 500, - error: '获取事件有效性统计失败', - data: null - }, - { status: 500 } - ); + // 预生成概念连续段(用于测试跨天连接效果) + // 每段 2-4 天使用相同概念 + let currentConcept = hotConcepts[0]; + let conceptDaysLeft = 0; + let conceptIndex = 0; + + for (let day = 1; day <= daysInMonth; day++) { + const date = new Date(year, month - 1, day); + const dayOfWeek = date.getDay(); + + // 跳过周末 + if (dayOfWeek === 0 || dayOfWeek === 6) continue; + + const dateStr = `${year}${String(month).padStart(2, "0")}${String( + day + ).padStart(2, "0")}`; + const isPast = date < today; + const isToday = date.toDateString() === today.toDateString(); + const isFuture = date > today; + + // 使用日期作为种子生成一致的随机数 + const dateSeed = year * 10000 + month * 100 + day; + const seededRandom = (seed) => { + const x = Math.sin(seed) * 10000; + return x - Math.floor(x); + }; + + // 概念连续段逻辑:每段 2-4 天使用相同概念 + if (conceptDaysLeft <= 0) { + conceptIndex = Math.floor( + seededRandom(dateSeed + 100) * hotConcepts.length + ); + currentConcept = hotConcepts[conceptIndex]; + conceptDaysLeft = Math.floor(seededRandom(dateSeed + 101) * 3) + 2; // 2-4 天 } - }), + conceptDaysLeft--; - // ==================== 涨停题材散点图数据 ==================== + const item = { + date: dateStr, + zt_count: 0, + top_sector: "", + event_count: 0, + index_change: null, + }; - // 获取涨停题材散点图数据 - http.get('/api/v1/zt/theme-scatter', async ({ request }) => { - await delay(400); - - const url = new URL(request.url); - const days = parseInt(url.searchParams.get('days') || '5'); - const date = url.searchParams.get('date'); - - console.log('[Mock] 获取涨停题材散点图:', { days, date }); - - try { - // 生成可用日期列表(最近5个交易日) - const availableDates = []; - const today = new Date(); - let tradingDays = 0; - let checkDate = new Date(today); - - while (tradingDays < days) { - const dayOfWeek = checkDate.getDay(); - if (dayOfWeek !== 0 && dayOfWeek !== 6) { - const dateStr = checkDate.toISOString().split('T')[0]; - availableDates.push({ - date: dateStr, - formatted: `${checkDate.getMonth() + 1}月${checkDate.getDate()}日`, - }); - tradingDays++; - } - checkDate.setDate(checkDate.getDate() - 1); - } - - // 使用日期作为随机种子 - const targetDate = date || availableDates[0]?.date; - const dateSeed = targetDate ? parseInt(targetDate.replace(/-/g, '')) : Date.now(); - const seededRandom = (offset = 0) => { - const x = Math.sin(dateSeed + offset) * 10000; - return x - Math.floor(x); - }; - - // 题材数据模板 - const themeTemplates = [ - { label: '人工智能', baseX: 5, baseY: 15, status: 'rising' }, - { label: '机器人', baseX: 4, baseY: 12, status: 'rising' }, - { label: '半导体', baseX: 3, baseY: 10, status: 'clustering' }, - { label: '光模块', baseX: 6, baseY: 8, status: 'rising' }, - { label: '算力', baseX: 4, baseY: 9, status: 'clustering' }, - { label: '新能源汽车', baseX: 2, baseY: 7, status: 'lurking' }, - { label: '固态电池', baseX: 3, baseY: 6, status: 'lurking' }, - { label: '光伏', baseX: 2, baseY: 5, status: 'declining' }, - { label: '储能', baseX: 2, baseY: 4, status: 'declining' }, - { label: '医药', baseX: 1, baseY: 3, status: 'lurking' }, - ]; - - // 状态颜色映射 - const statusColors = { - rising: '#FF4D4F', - declining: '#52C41A', - lurking: '#1890FF', - clustering: '#722ED1', - }; - - // 生成题材数据 - const themes = themeTemplates.map((template, index) => { - const xVariation = seededRandom(index * 10) * 2 - 1; // -1 ~ 1 - const yVariation = seededRandom(index * 10 + 1) * 4 - 2; // -2 ~ 2 - - return { - label: template.label, - x: Math.max(1, Math.round(template.baseX + xVariation)), // 辨识度(最高板高度) - y: Math.max(1, Math.round(template.baseY + yVariation)), // 热度(涨停家数) - status: template.status, - color: statusColors[template.status], - countTrend: parseFloat((seededRandom(index * 10 + 2) * 40 - 20).toFixed(1)), // -20% ~ 20% - boardTrend: parseFloat((seededRandom(index * 10 + 3) * 2 - 1).toFixed(1)), // -1 ~ 1 - history: [], // 历史数据(简化版不填充) - }; - }); - - return HttpResponse.json({ - success: true, - data: { - themes, - availableDates, - currentDate: targetDate, - }, - message: '获取成功' - }); - } catch (error) { - console.error('[Mock] 获取涨停题材散点图失败:', error); - return HttpResponse.json( - { - success: false, - error: '获取涨停题材散点图失败', - data: null - }, - { status: 500 } - ); + // 历史数据:涨停 + 上证涨跌幅 + if (isPast || isToday) { + item.zt_count = Math.floor(seededRandom(dateSeed) * 80 + 30); // 30-110 + item.top_sector = currentConcept; // 使用连续概念 + item.index_change = parseFloat( + (seededRandom(dateSeed + 2) * 4 - 2).toFixed(2) + ); // -2% ~ +2% } - }), + + // 未来数据:事件数量 + if (isFuture) { + const hasEvents = seededRandom(dateSeed + 3) > 0.4; // 60% 概率有事件 + if (hasEvents) { + item.event_count = Math.floor(seededRandom(dateSeed + 4) * 10 + 1); // 1-10 + } + } + + // 今天:同时有涨停和事件 + if (isToday) { + item.event_count = Math.floor(seededRandom(dateSeed + 5) * 8 + 2); // 2-10 + } + + data.push(item); + } + + return HttpResponse.json({ + success: true, + data: data, + year: year, + month: month, + }); + } catch (error) { + console.error("[Mock] 获取日历综合数据失败:", error); + return HttpResponse.json( + { + success: false, + error: "获取日历综合数据失败", + data: [], + }, + { status: 500 } + ); + } + }), + + // ==================== 事件有效性统计 ==================== + + // 获取事件有效性统计数据 + http.get("/api/v1/events/effectiveness-stats", async ({ request }) => { + await delay(300); + + const url = new URL(request.url); + const days = parseInt(url.searchParams.get("days") || "30"); + + console.log("[Mock] 获取事件有效性统计:", { days }); + + try { + // 模拟市场涨跌分布数据 + const totalCount = 5200; + const risingCount = Math.floor(Math.random() * 1500) + 1800; // 1800-3300 + const fallingCount = Math.floor(Math.random() * 1500) + 1200; // 1200-2700 + const flatCount = totalCount - risingCount - fallingCount; + const marketStats = { + totalCount, + risingCount, + fallingCount, + flatCount: Math.max(0, flatCount), + risingRate: parseFloat(((risingCount / totalCount) * 100).toFixed(1)), + }; + + // 模拟统计数据 + const totalEvents = Math.floor(Math.random() * 50) + 30; + const totalStocks = Math.floor(Math.random() * 300) + 200; + const summary = { + totalEvents, + totalStocks, + avgChg: parseFloat((Math.random() * 6 - 2).toFixed(2)), // -2% ~ 4% + maxChg: parseFloat((Math.random() * 10 + 2).toFixed(2)), // 2% ~ 12% + positiveRate: parseFloat((Math.random() * 30 + 50).toFixed(1)), // 50% ~ 80% + }; + + // 模拟表现最佳的事件 TOP10 + const topPerformers = []; + const eventTitles = [ + "重大政策利好:半导体产业扶持政策出台", + "人工智能板块迎来突破性进展", + "新能源汽车销量创历史新高", + "消费复苏数据超预期", + "央行降准释放流动性", + "科技股龙头财报超预期", + "军工板块获重大订单", + "医药创新药获批上市", + "光伏产业链订单大增", + "锂电池技术突破", + "芯片国产化加速", + "机器人概念持续活跃", + ]; + + for (let i = 0; i < 12; i++) { + topPerformers.push({ + id: i + 1, + title: eventTitles[i % eventTitles.length], + importance: ["S", "A", "B"][Math.floor(Math.random() * 3)], + created_at: new Date( + Date.now() - Math.random() * days * 24 * 60 * 60 * 1000 + ).toISOString(), + avgChg: parseFloat((Math.random() * 8 + 1).toFixed(2)), + maxChg: parseFloat((Math.random() * 15 + 5).toFixed(2)), + stockCount: Math.floor(Math.random() * 20) + 5, + }); + } + + // 按 avgChg 降序排列 + topPerformers.sort((a, b) => b.avgChg - a.avgChg); + + // 模拟股票 TOP10 + const topStocks = []; + const stockNames = [ + { code: "300750.SZ", name: "宁德时代" }, + { code: "002594.SZ", name: "比亚迪" }, + { code: "601012.SH", name: "隆基绿能" }, + { code: "300274.SZ", name: "阳光电源" }, + { code: "002475.SZ", name: "立讯精密" }, + { code: "300059.SZ", name: "东方财富" }, + { code: "600519.SH", name: "贵州茅台" }, + { code: "000858.SZ", name: "五粮液" }, + { code: "601318.SH", name: "中国平安" }, + { code: "600036.SH", name: "招商银行" }, + { code: "002371.SZ", name: "北方华创" }, + { code: "688981.SH", name: "中芯国际" }, + ]; + + for (let i = 0; i < 12; i++) { + const stock = stockNames[i]; + topStocks.push({ + stockCode: stock.code, + stockName: stock.name, + maxChg: parseFloat((Math.random() * 12 + 3).toFixed(2)), + eventCount: Math.floor(Math.random() * 5) + 1, + }); + } + + // 按 maxChg 降序排列 + topStocks.sort((a, b) => b.maxChg - a.maxChg); + + return HttpResponse.json({ + code: 200, + data: { + summary, + marketStats, + topPerformers, + topStocks, + }, + message: "获取成功", + }); + } catch (error) { + console.error("[Mock] 获取事件有效性统计失败:", error); + return HttpResponse.json( + { + code: 500, + error: "获取事件有效性统计失败", + data: null, + }, + { status: 500 } + ); + } + }), + + // ==================== 涨停题材散点图数据 ==================== + + // 获取涨停题材散点图数据 + http.get("/api/v1/zt/theme-scatter", async ({ request }) => { + await delay(400); + + const url = new URL(request.url); + const days = parseInt(url.searchParams.get("days") || "5"); + const date = url.searchParams.get("date"); + + console.log("[Mock] 获取涨停题材散点图:", { days, date }); + + try { + // 生成可用日期列表(最近5个交易日) + const availableDates = []; + const today = new Date(); + let tradingDays = 0; + let checkDate = new Date(today); + + while (tradingDays < days) { + const dayOfWeek = checkDate.getDay(); + if (dayOfWeek !== 0 && dayOfWeek !== 6) { + const dateStr = checkDate.toISOString().split("T")[0]; + availableDates.push({ + date: dateStr, + formatted: `${checkDate.getMonth() + 1}月${checkDate.getDate()}日`, + }); + tradingDays++; + } + checkDate.setDate(checkDate.getDate() - 1); + } + + // 使用日期作为随机种子 + const targetDate = date || availableDates[0]?.date; + const dateSeed = targetDate + ? parseInt(targetDate.replace(/-/g, "")) + : Date.now(); + const seededRandom = (offset = 0) => { + const x = Math.sin(dateSeed + offset) * 10000; + return x - Math.floor(x); + }; + + // 题材数据模板 + const themeTemplates = [ + { label: "人工智能", baseX: 5, baseY: 15, status: "rising" }, + { label: "机器人", baseX: 4, baseY: 12, status: "rising" }, + { label: "半导体", baseX: 3, baseY: 10, status: "clustering" }, + { label: "光模块", baseX: 6, baseY: 8, status: "rising" }, + { label: "算力", baseX: 4, baseY: 9, status: "clustering" }, + { label: "新能源汽车", baseX: 2, baseY: 7, status: "lurking" }, + { label: "固态电池", baseX: 3, baseY: 6, status: "lurking" }, + { label: "光伏", baseX: 2, baseY: 5, status: "declining" }, + { label: "储能", baseX: 2, baseY: 4, status: "declining" }, + { label: "医药", baseX: 1, baseY: 3, status: "lurking" }, + ]; + + // 状态颜色映射 + const statusColors = { + rising: "#FF4D4F", + declining: "#52C41A", + lurking: "#1890FF", + clustering: "#722ED1", + }; + + // 生成题材数据 + const themes = themeTemplates.map((template, index) => { + const xVariation = seededRandom(index * 10) * 2 - 1; // -1 ~ 1 + const yVariation = seededRandom(index * 10 + 1) * 4 - 2; // -2 ~ 2 + + return { + label: template.label, + x: Math.max(1, Math.round(template.baseX + xVariation)), // 辨识度(最高板高度) + y: Math.max(1, Math.round(template.baseY + yVariation)), // 热度(涨停家数) + status: template.status, + color: statusColors[template.status], + countTrend: parseFloat( + (seededRandom(index * 10 + 2) * 40 - 20).toFixed(1) + ), // -20% ~ 20% + boardTrend: parseFloat( + (seededRandom(index * 10 + 3) * 2 - 1).toFixed(1) + ), // -1 ~ 1 + history: [], // 历史数据(简化版不填充) + }; + }); + + // 计算统计数据 + const totalLimitUp = themes.reduce((sum, t) => sum + t.y, 0); + const totalEvents = themes.length; + const indexChange = parseFloat((seededRandom(999) * 4 - 2).toFixed(2)); // 上证涨跌 -2% ~ 2% + + return HttpResponse.json({ + success: true, + data: { + themes, + availableDates, + currentDate: targetDate, + totalLimitUp, + totalEvents, + indexChange, + }, + message: "获取成功", + }); + } catch (error) { + console.error("[Mock] 获取涨停题材散点图失败:", error); + return HttpResponse.json( + { + success: false, + error: "获取涨停题材散点图失败", + data: null, + }, + { status: 500 } + ); + } + }), ]; From 098a88c5ba4166aac2c628e62a480eefb94b46e5 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Tue, 13 Jan 2026 14:55:28 +0800 Subject: [PATCH 02/22] =?UTF-8?q?feat(calendar):=20=E6=97=A5=E5=8E=86?= =?UTF-8?q?=E5=8D=95=E5=85=83=E6=A0=BC=E6=94=B9=E4=B8=BA=E4=B8=A4=E8=A1=8C?= =?UTF-8?q?=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 第一行:日期 + 上证涨跌 - 第二行:热度(涨停数)+ 事件数 - 周一至周日表头背景色修复为深色主题 - 事件条高度固定为12px,防止不同行对齐问题 --- src/components/Calendar/FullCalendarPro.tsx | 581 ++++++++++++-------- 1 file changed, 338 insertions(+), 243 deletions(-) diff --git a/src/components/Calendar/FullCalendarPro.tsx b/src/components/Calendar/FullCalendarPro.tsx index 81460e9c..80cc89df 100644 --- a/src/components/Calendar/FullCalendarPro.tsx +++ b/src/components/Calendar/FullCalendarPro.tsx @@ -4,14 +4,19 @@ * 使用 dayCellDidMount 钩子实现完整的单元格自定义内容 */ -import React, { useMemo, useRef, useCallback, useEffect } from 'react'; -import FullCalendar from '@fullcalendar/react'; -import dayGridPlugin from '@fullcalendar/daygrid'; -import interactionPlugin from '@fullcalendar/interaction'; -import type { EventInput, EventClickArg, DatesSetArg, DayCellMountArg } from '@fullcalendar/core'; -import { Box, Text, VStack, Tooltip } from '@chakra-ui/react'; -import { keyframes } from '@emotion/react'; -import dayjs from 'dayjs'; +import React, { useMemo, useRef, useCallback, useEffect } from "react"; +import FullCalendar from "@fullcalendar/react"; +import dayGridPlugin from "@fullcalendar/daygrid"; +import interactionPlugin from "@fullcalendar/interaction"; +import type { + EventInput, + EventClickArg, + DatesSetArg, + DayCellMountArg, +} from "@fullcalendar/core"; +import { Box, Text, VStack, Tooltip } from "@chakra-ui/react"; +import { keyframes } from "@emotion/react"; +import dayjs from "dayjs"; // 动画定义 const shimmer = keyframes` @@ -28,11 +33,11 @@ const glow = keyframes` * 事件数据接口 */ export interface CalendarEventData { - date: string; // YYYYMMDD 格式 - count: number; // 涨停数 - topSector: string; // 最热概念 - eventCount?: number; // 未来事件数 - indexChange?: number; // 上证指数涨跌幅 + date: string; // YYYYMMDD 格式 + count: number; // 涨停数 + topSector: string; // 最热概念 + eventCount?: number; // 未来事件数 + indexChange?: number; // 上证指数涨跌幅 } /** @@ -44,7 +49,12 @@ export interface FullCalendarProProps { /** 日期点击回调 */ onDateClick?: (date: Date, data?: CalendarEventData) => void; /** 事件点击回调(点击跨天条) */ - onEventClick?: (event: { title: string; start: Date; end: Date; dates: string[] }) => void; + onEventClick?: (event: { + title: string; + start: Date; + end: Date; + dates: string[]; + }) => void; /** 月份变化回调 */ onMonthChange?: (year: number, month: number) => void; /** 当前月份 */ @@ -56,18 +66,61 @@ export interface FullCalendarProProps { /** * 概念颜色映射 - 为不同概念生成不同的渐变色 */ -const CONCEPT_COLORS: Record = {}; +const CONCEPT_COLORS: Record< + string, + { bg: string; border: string; text: string } +> = {}; const COLOR_PALETTE = [ - { bg: 'linear-gradient(135deg, #FFD700 0%, #FFA500 100%)', border: '#FFD700', text: '#1a1a2e' }, // 金色 - 深色文字 - { bg: 'linear-gradient(135deg, #00CED1 0%, #20B2AA 100%)', border: '#00CED1', text: '#1a1a2e' }, // 青色 - 深色文字 - { bg: 'linear-gradient(135deg, #FF6B6B 0%, #EE5A5A 100%)', border: '#FF6B6B', text: '#fff' }, // 红色 - 白色文字 - { bg: 'linear-gradient(135deg, #A855F7 0%, #9333EA 100%)', border: '#A855F7', text: '#fff' }, // 紫色 - 白色文字 - { bg: 'linear-gradient(135deg, #3B82F6 0%, #2563EB 100%)', border: '#3B82F6', text: '#fff' }, // 蓝色 - 白色文字 - { bg: 'linear-gradient(135deg, #10B981 0%, #059669 100%)', border: '#10B981', text: '#1a1a2e' }, // 绿色 - 深色文字 - { bg: 'linear-gradient(135deg, #F59E0B 0%, #D97706 100%)', border: '#F59E0B', text: '#1a1a2e' }, // 橙色 - 深色文字 - { bg: 'linear-gradient(135deg, #EC4899 0%, #DB2777 100%)', border: '#EC4899', text: '#fff' }, // 粉色 - 白色文字 - { bg: 'linear-gradient(135deg, #6366F1 0%, #4F46E5 100%)', border: '#6366F1', text: '#fff' }, // 靛蓝 - 白色文字 - { bg: 'linear-gradient(135deg, #14B8A6 0%, #0D9488 100%)', border: '#14B8A6', text: '#1a1a2e' }, // 青绿 - 深色文字 + { + bg: "linear-gradient(135deg, #FFD700 0%, #FFA500 100%)", + border: "#FFD700", + text: "#1a1a2e", + }, // 金色 - 深色文字 + { + bg: "linear-gradient(135deg, #00CED1 0%, #20B2AA 100%)", + border: "#00CED1", + text: "#1a1a2e", + }, // 青色 - 深色文字 + { + bg: "linear-gradient(135deg, #FF6B6B 0%, #EE5A5A 100%)", + border: "#FF6B6B", + text: "#fff", + }, // 红色 - 白色文字 + { + bg: "linear-gradient(135deg, #A855F7 0%, #9333EA 100%)", + border: "#A855F7", + text: "#fff", + }, // 紫色 - 白色文字 + { + bg: "linear-gradient(135deg, #3B82F6 0%, #2563EB 100%)", + border: "#3B82F6", + text: "#fff", + }, // 蓝色 - 白色文字 + { + bg: "linear-gradient(135deg, #10B981 0%, #059669 100%)", + border: "#10B981", + text: "#1a1a2e", + }, // 绿色 - 深色文字 + { + bg: "linear-gradient(135deg, #F59E0B 0%, #D97706 100%)", + border: "#F59E0B", + text: "#1a1a2e", + }, // 橙色 - 深色文字 + { + bg: "linear-gradient(135deg, #EC4899 0%, #DB2777 100%)", + border: "#EC4899", + text: "#fff", + }, // 粉色 - 白色文字 + { + bg: "linear-gradient(135deg, #6366F1 0%, #4F46E5 100%)", + border: "#6366F1", + text: "#fff", + }, // 靛蓝 - 白色文字 + { + bg: "linear-gradient(135deg, #14B8A6 0%, #0D9488 100%)", + border: "#14B8A6", + text: "#1a1a2e", + }, // 青绿 - 深色文字 ]; let colorIndex = 0; @@ -87,11 +140,17 @@ const mergeConsecutiveConcepts = (data: CalendarEventData[]): EventInput[] => { // 按日期排序 const sorted = [...data] - .filter(d => d.topSector) + .filter((d) => d.topSector) .sort((a, b) => a.date.localeCompare(b.date)); const events: EventInput[] = []; - let currentEvent: { concept: string; startDate: string; endDate: string; dates: string[]; totalCount: number } | null = null; + let currentEvent: { + concept: string; + startDate: string; + endDate: string; + dates: string[]; + totalCount: number; + } | null = null; sorted.forEach((item, index) => { const dateStr = item.date; @@ -99,7 +158,8 @@ const mergeConsecutiveConcepts = (data: CalendarEventData[]): EventInput[] => { // 检查是否与前一天连续且概念相同 const prevItem = sorted[index - 1]; - const isConsecutive = prevItem && + const isConsecutive = + prevItem && concept === prevItem.topSector && isNextDay(prevItem.date, dateStr); @@ -136,11 +196,11 @@ const mergeConsecutiveConcepts = (data: CalendarEventData[]): EventInput[] => { * 检查两个日期是否连续(跳过周末) */ const isNextDay = (date1: string, date2: string): boolean => { - const d1 = dayjs(date1, 'YYYYMMDD'); - const d2 = dayjs(date2, 'YYYYMMDD'); + const d1 = dayjs(date1, "YYYYMMDD"); + const d2 = dayjs(date2, "YYYYMMDD"); // 简单判断:相差1-3天内(考虑周末) - const diff = d2.diff(d1, 'day'); + const diff = d2.diff(d1, "day"); if (diff === 1) return true; if (diff === 2 && d1.day() === 5) return true; // 周五到周日 if (diff === 3 && d1.day() === 5) return true; // 周五到周一 @@ -150,18 +210,24 @@ const isNextDay = (date1: string, date2: string): boolean => { /** * 创建 FullCalendar 事件对象 */ -const createEventInput = (event: { concept: string; startDate: string; endDate: string; dates: string[]; totalCount: number }): EventInput => { +const createEventInput = (event: { + concept: string; + startDate: string; + endDate: string; + dates: string[]; + totalCount: number; +}): EventInput => { const color = getConceptColor(event.concept); - const startDate = dayjs(event.startDate, 'YYYYMMDD'); - const endDate = dayjs(event.endDate, 'YYYYMMDD').add(1, 'day'); // FullCalendar 的 end 是 exclusive + const startDate = dayjs(event.startDate, "YYYYMMDD"); + const endDate = dayjs(event.endDate, "YYYYMMDD").add(1, "day"); // FullCalendar 的 end 是 exclusive return { id: `${event.concept}-${event.startDate}`, title: event.concept, - start: startDate.format('YYYY-MM-DD'), - end: endDate.format('YYYY-MM-DD'), - backgroundColor: 'transparent', - borderColor: 'transparent', + start: startDate.format("YYYY-MM-DD"), + end: endDate.format("YYYY-MM-DD"), + backgroundColor: "transparent", + borderColor: "transparent", textColor: color.text, extendedProps: { concept: event.concept, @@ -187,65 +253,57 @@ const createCellContentHTML = ( const isWeekend = date.getDay() === 0 || date.getDay() === 6; const hasZtData = dateData && dateData.count > 0; const hasEventCount = dateData?.eventCount && dateData.eventCount > 0; - const hasIndexChange = dateData?.indexChange !== undefined && dateData?.indexChange !== null; + const hasIndexChange = + dateData?.indexChange !== undefined && dateData?.indexChange !== null; // 日期颜色 - const dateColor = isToday ? '#FFD700' : isWeekend ? '#FB923C' : '#FFFFFF'; - const dateFontWeight = isToday ? 'bold' : '600'; + const dateColor = isToday ? "#FFD700" : isWeekend ? "#FB923C" : "#FFFFFF"; + const dateFontWeight = isToday ? "bold" : "600"; // 上证涨跌幅 - let indexChangeHTML = ''; + let indexChangeHTML = ""; if (hasIndexChange) { const indexChange = dateData.indexChange!; - const indexColor = indexChange >= 0 ? '#EF4444' : '#22C55E'; - const sign = indexChange >= 0 ? '+' : ''; - indexChangeHTML = `${sign}${indexChange.toFixed(2)}%`; + const indexColor = indexChange >= 0 ? "#EF4444" : "#22C55E"; + const sign = indexChange >= 0 ? "+" : ""; + indexChangeHTML = `${sign}${indexChange.toFixed(1)}%`; } - // 涨停数据 - let ztDataHTML = ''; + // 涨停数据(热度) + let ztDataHTML = ""; if (hasZtData) { - const ztColor = dateData.count >= 60 ? '#EF4444' : '#F59E0B'; + const ztColor = dateData.count >= 60 ? "#EF4444" : "#F59E0B"; ztDataHTML = ` -
- + + - ${dateData.count} -
+ ${dateData.count} + `; } // 未来事件计数 - let eventCountHTML = ''; + let eventCountHTML = ""; if (hasEventCount) { eventCountHTML = ` -
-
- ${dateData.eventCount} -
- 事件 -
+ + ${dateData.eventCount} + 事件 + `; } return ` -
-
- ${dayNum} +
+
+ ${dayNum} ${indexChangeHTML}
- ${ztDataHTML} - ${eventCountHTML} +
+ ${ztDataHTML} + ${eventCountHTML} +
`; }; @@ -259,7 +317,7 @@ export const FullCalendarPro: React.FC = ({ onEventClick, onMonthChange, currentMonth, - height = '600px', + height = "auto", }) => { const calendarRef = useRef(null); const dataMapRef = useRef>(new Map()); @@ -270,7 +328,7 @@ export const FullCalendarPro: React.FC = ({ // 创建日期数据映射 const dataMap = useMemo(() => { const map = new Map(); - data.forEach(d => map.set(d.date, d)); + data.forEach((d) => map.set(d.date, d)); return map; }, [data]); @@ -285,19 +343,19 @@ export const FullCalendarPro: React.FC = ({ // 获取所有日期单元格并更新内容 const calendarEl = calendarRef.current.getApi().el; - const dayCells = calendarEl?.querySelectorAll('.fc-daygrid-day'); + const dayCells = calendarEl?.querySelectorAll(".fc-daygrid-day"); dayCells?.forEach((cell: Element) => { - const dateAttr = cell.getAttribute('data-date'); + const dateAttr = cell.getAttribute("data-date"); if (!dateAttr) return; const date = new Date(dateAttr); - const dateStr = dayjs(date).format('YYYYMMDD'); + const dateStr = dayjs(date).format("YYYYMMDD"); const dateData = dataMapRef.current.get(dateStr); - const isToday = dayjs(date).isSame(dayjs(), 'day'); + const isToday = dayjs(date).isSame(dayjs(), "day"); // 找到 day-top 容器并更新内容 - const dayTop = cell.querySelector('.fc-daygrid-day-top'); + const dayTop = cell.querySelector(".fc-daygrid-day-top"); if (dayTop) { dayTop.innerHTML = createCellContentHTML(date, dateData, isToday); } @@ -305,121 +363,135 @@ export const FullCalendarPro: React.FC = ({ }, [dataMap]); // 处理日期点击 - const handleDateClick = useCallback((arg: { date: Date; dateStr: string }) => { - const dateStr = dayjs(arg.date).format('YYYYMMDD'); - const dateData = dataMapRef.current.get(dateStr); - onDateClick?.(arg.date, dateData); - }, [onDateClick]); + const handleDateClick = useCallback( + (arg: { date: Date; dateStr: string }) => { + const dateStr = dayjs(arg.date).format("YYYYMMDD"); + const dateData = dataMapRef.current.get(dateStr); + onDateClick?.(arg.date, dateData); + }, + [onDateClick] + ); // 处理事件点击 - const handleEventClick = useCallback((arg: EventClickArg) => { - const { extendedProps } = arg.event; - if (arg.event.start && arg.event.end) { - onEventClick?.({ - title: arg.event.title, - start: arg.event.start, - end: arg.event.end, - dates: extendedProps.dates as string[], - }); - } - }, [onEventClick]); + const handleEventClick = useCallback( + (arg: EventClickArg) => { + const { extendedProps } = arg.event; + if (arg.event.start && arg.event.end) { + onEventClick?.({ + title: arg.event.title, + start: arg.event.start, + end: arg.event.end, + dates: extendedProps.dates as string[], + }); + } + }, + [onEventClick] + ); // 处理月份变化 - const handleDatesSet = useCallback((arg: DatesSetArg) => { - const visibleDate = arg.view.currentStart; - onMonthChange?.(visibleDate.getFullYear(), visibleDate.getMonth() + 1); - }, [onMonthChange]); + const handleDatesSet = useCallback( + (arg: DatesSetArg) => { + const visibleDate = arg.view.currentStart; + onMonthChange?.(visibleDate.getFullYear(), visibleDate.getMonth() + 1); + }, + [onMonthChange] + ); // 单元格挂载时插入自定义内容 const handleDayCellDidMount = useCallback((arg: DayCellMountArg) => { const { date, el, isToday } = arg; - const dateStr = dayjs(date).format('YYYYMMDD'); + const dateStr = dayjs(date).format("YYYYMMDD"); const dateData = dataMapRef.current.get(dateStr); // 找到 day-top 容器并插入自定义内容 - const dayTop = el.querySelector('.fc-daygrid-day-top'); + const dayTop = el.querySelector(".fc-daygrid-day-top"); if (dayTop) { // 清空默认内容 - dayTop.innerHTML = ''; + dayTop.innerHTML = ""; // 插入自定义内容 dayTop.innerHTML = createCellContentHTML(date, dateData, isToday); } }, []); // 自定义事件内容(跨天条) - const eventContent = useCallback((arg: { event: { title: string; extendedProps: Record } }) => { - const { extendedProps } = arg.event; - const daysCount = extendedProps.daysCount as number; - const totalCount = extendedProps.totalCount as number; - const textColor = (extendedProps.textColor as string) || '#fff'; - const gradient = extendedProps.gradient as string; - const borderColor = extendedProps.borderColor as string; + const eventContent = useCallback( + (arg: { + event: { title: string; extendedProps: Record }; + }) => { + const { extendedProps } = arg.event; + const daysCount = extendedProps.daysCount as number; + const totalCount = extendedProps.totalCount as number; + const textColor = (extendedProps.textColor as string) || "#fff"; + const gradient = extendedProps.gradient as string; + const borderColor = extendedProps.borderColor as string; - return ( - - {arg.event.title} - 连续 {daysCount} 天 - 累计涨停 {totalCount} 家 - - } - placement="top" - hasArrow - bg="rgba(15, 15, 22, 0.95)" - border="1px solid rgba(212, 175, 55, 0.3)" - borderRadius="md" - > - + {arg.event.title} + 连续 {daysCount} 天 + 累计涨停 {totalCount} 家 + + } + placement="top" + hasArrow + bg="rgba(15, 15, 22, 0.95)" + border="1px solid rgba(212, 175, 55, 0.3)" + borderRadius="md" > - {/* 闪光效果 */} - - {arg.event.title} - {daysCount > 1 && ( - - ({daysCount}天) - - )} - - - - ); - }, []); + {/* 闪光效果 */} + + + {arg.event.title} + {daysCount > 1 && ( + + ({daysCount}天) + + )} + + + + ); + }, + [] + ); return ( = ({ position="relative" sx={{ // FullCalendar 深色主题样式 - '.fc': { - fontFamily: 'inherit', + ".fc": { + fontFamily: "inherit", }, - '.fc-theme-standard': { - bg: 'transparent', + ".fc-theme-standard": { + bg: "transparent", }, - '.fc-theme-standard td, .fc-theme-standard th': { - borderColor: 'rgba(212, 175, 55, 0.15)', + ".fc-theme-standard td, .fc-theme-standard th": { + borderColor: "rgba(212, 175, 55, 0.15)", }, - '.fc-theme-standard .fc-scrollgrid': { - borderColor: 'rgba(212, 175, 55, 0.2)', + ".fc-theme-standard .fc-scrollgrid": { + borderColor: "rgba(212, 175, 55, 0.2)", }, // 工具栏 - '.fc-toolbar-title': { - fontSize: '1.5rem !important', - fontWeight: 'bold !important', - background: 'linear-gradient(135deg, #FFD700 0%, #F5E6A3 100%)', - backgroundClip: 'text', - WebkitBackgroundClip: 'text', - color: 'transparent', + ".fc-toolbar": { + marginBottom: "0.5em !important", }, - '.fc-button': { - bg: 'rgba(212, 175, 55, 0.2) !important', - border: '1px solid rgba(212, 175, 55, 0.4) !important', - color: '#FFD700 !important', - borderRadius: '8px !important', - transition: 'all 0.2s !important', - '&:hover': { - bg: 'rgba(212, 175, 55, 0.3) !important', - transform: 'scale(1.05)', + ".fc-toolbar-title": { + fontSize: "0.95rem !important", + fontWeight: "bold !important", + background: "linear-gradient(135deg, #FFD700 0%, #F5E6A3 100%)", + backgroundClip: "text", + WebkitBackgroundClip: "text", + color: "transparent", + }, + ".fc-button": { + bg: "rgba(212, 175, 55, 0.2) !important", + border: "1px solid rgba(212, 175, 55, 0.4) !important", + color: "#FFD700 !important", + borderRadius: "6px !important", + padding: "4px 8px !important", + fontSize: "12px !important", + transition: "all 0.2s !important", + "&:hover": { + bg: "rgba(212, 175, 55, 0.3) !important", + transform: "scale(1.05)", }, - '&:disabled': { + "&:disabled": { opacity: 0.5, }, }, - '.fc-button-active': { - bg: 'rgba(212, 175, 55, 0.4) !important', + ".fc-button-active": { + bg: "rgba(212, 175, 55, 0.4) !important", }, // 星期头 - '.fc-col-header-cell': { - bg: 'rgba(212, 175, 55, 0.1)', - py: '12px !important', + ".fc-col-header": { + bg: "rgba(15, 15, 22, 0.95) !important", }, - '.fc-col-header-cell-cushion': { - color: '#FFD700 !important', - fontWeight: '600 !important', - fontSize: '14px', + ".fc-col-header-cell": { + bg: "transparent !important", + py: "6px !important", + borderColor: "rgba(255, 215, 0, 0.1) !important", + }, + ".fc-col-header-cell-cushion": { + color: "white !important", + fontWeight: "600 !important", + fontSize: "12px", + }, + ".fc-scrollgrid-section-header": { + bg: "rgba(15, 15, 22, 0.95) !important", + }, + ".fc-scrollgrid-section-header > td": { + bg: "rgba(15, 15, 22, 0.95) !important", + borderColor: "rgba(255, 215, 0, 0.1) !important", }, // 日期格子 - '.fc-daygrid-day': { - bg: 'rgba(15, 15, 22, 0.4)', - transition: 'all 0.2s', - cursor: 'pointer', - '&:hover': { - bg: 'rgba(212, 175, 55, 0.1)', + ".fc-daygrid-day": { + bg: "rgba(15, 15, 22, 0.4)", + transition: "all 0.2s", + cursor: "pointer", + "&:hover": { + bg: "rgba(212, 175, 55, 0.1)", }, }, - '.fc-daygrid-day.fc-day-today': { - bg: 'rgba(212, 175, 55, 0.15) !important', + ".fc-daygrid-day.fc-day-today": { + bg: "rgba(212, 175, 55, 0.15) !important", animation: `${glow} 2s ease-in-out infinite`, }, - '.fc-daygrid-day-frame': { - minHeight: '110px', + ".fc-daygrid-day-frame": { + minHeight: "50px", + display: "flex", + flexDirection: "column", + justifyContent: "space-between", }, - '.fc-daygrid-day-top': { - width: '100%', - padding: '0 !important', - flexDirection: 'column', + ".fc-daygrid-day-top": { + width: "100%", + padding: "0 !important", + flexDirection: "column", }, // 隐藏 FullCalendar 默认的日期数字链接 - '.fc-daygrid-day-top a.fc-daygrid-day-number': { - display: 'none !important', + ".fc-daygrid-day-top a.fc-daygrid-day-number": { + display: "none !important", }, // 非当月日期 - '.fc-day-other': { + ".fc-day-other": { opacity: 0.4, }, // 自定义单元格内容样式 - '.fc-custom-cell-content': { - width: '100%', + ".fc-custom-cell-content": { + width: "100%", }, // 日期内容区域 - '.fc-daygrid-day-events': { - marginTop: '0 !important', + ".fc-daygrid-day-events": { + marginTop: "auto !important", + marginBottom: "0 !important", + paddingBottom: "0 !important", }, // 事件 - '.fc-daygrid-event': { - borderRadius: '8px', - border: 'none !important', - margin: '3px 4px !important', + ".fc-daygrid-event": { + borderRadius: "6px", + border: "none !important", + margin: "0 3px !important", + marginBottom: "0 !important", }, - '.fc-event-main': { - padding: '0 !important', + ".fc-event-main": { + padding: "0 !important", }, - '.fc-daygrid-event-harness': { - marginTop: '3px', + ".fc-daygrid-event-harness": { + marginTop: "2px", }, // 更多事件链接 - '.fc-daygrid-more-link': { - color: '#FFD700 !important', - fontWeight: '600', - fontSize: '11px', + ".fc-daygrid-more-link": { + color: "#FFD700 !important", + fontWeight: "600", + fontSize: "11px", }, }} > @@ -539,12 +633,12 @@ export const FullCalendarPro: React.FC = ({ initialDate={currentMonth} locale="zh-cn" headerToolbar={{ - left: 'prev,next today', - center: 'title', - right: '', + left: "prev,next today", + center: "title", + right: "", }} buttonText={{ - today: '今天', + today: "今天", }} events={events} dateClick={handleDateClick} @@ -555,7 +649,8 @@ export const FullCalendarPro: React.FC = ({ dayMaxEvents={3} moreLinkText={(n) => `+${n} 更多`} fixedWeekCount={false} - height="100%" + height="auto" + contentHeight="auto" /> ); From 59da1718ae93856cbbdf93be50c29a1717956a66 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Tue, 13 Jan 2026 14:55:55 +0800 Subject: [PATCH 03/22] =?UTF-8?q?feat(community):=20=E4=BA=8B=E4=BB=B6TOP1?= =?UTF-8?q?0=E6=B7=BB=E5=8A=A0=E6=97=A0=E9=99=90=E6=BB=9A=E5=8A=A8?= =?UTF-8?q?=E5=8A=A8=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 默认展示8个事件,列表向上无限轮播 - 使用 framer-motion useAnimationControls 实现 - 鼠标悬停时暂停动画 --- .../Community/components/EventDailyStats.js | 740 +++--------------- 1 file changed, 120 insertions(+), 620 deletions(-) diff --git a/src/views/Community/components/EventDailyStats.js b/src/views/Community/components/EventDailyStats.js index 67382b27..7a346a84 100644 --- a/src/views/Community/components/EventDailyStats.js +++ b/src/views/Community/components/EventDailyStats.js @@ -1,8 +1,8 @@ /** - * EventDailyStats - 当日事件统计面板 - * 展示当前交易日的事件统计数据,证明系统推荐的胜率和市场热度 + * EventDailyStats - 事件 TOP 排行面板 + * 展示当日事件的表现排行 */ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Box, Text, @@ -12,24 +12,11 @@ import { Center, Tooltip, Badge, - Tabs, - TabList, - Tab, - TabPanels, - TabPanel, - Input, - Flex, -} from '@chakra-ui/react'; -import { - FireOutlined, - RiseOutlined, - ThunderboltOutlined, - TrophyOutlined, - StockOutlined, - CalendarOutlined, - ReloadOutlined, -} from '@ant-design/icons'; -import { getApiBase } from '@utils/apiConfig'; +} from "@chakra-ui/react"; +import { motion, useAnimationControls } from "framer-motion"; +import { getApiBase } from "@utils/apiConfig"; + +const MotionBox = motion.create(Box); /** * 生成事件详情页 URL @@ -43,275 +30,31 @@ const getEventDetailUrl = (eventId) => { * 格式化涨跌幅 */ const formatChg = (val) => { - if (val === null || val === undefined) return '-'; + if (val === null || val === undefined) return "-"; const num = parseFloat(val); - if (isNaN(num)) return '-'; - return (num >= 0 ? '+' : '') + num.toFixed(2) + '%'; + if (isNaN(num)) return "-"; + return (num >= 0 ? "+" : "") + num.toFixed(2) + "%"; }; /** * 获取涨跌幅颜色 */ const getChgColor = (val) => { - if (val === null || val === undefined) return 'gray.400'; + if (val === null || val === undefined) return "gray.400"; const num = parseFloat(val); - if (isNaN(num)) return 'gray.400'; - if (num > 0) return '#FF4D4F'; - if (num < 0) return '#52C41A'; - return 'gray.400'; + if (isNaN(num)) return "gray.400"; + if (num > 0) return "#FF4D4F"; + if (num < 0) return "#52C41A"; + return "gray.400"; }; -/** - * 获取胜率颜色(>50%红色,<50%绿色) - */ -const getRateColor = (rate) => { - if (rate >= 50) return '#F31260'; // HeroUI 红色 - return '#17C964'; // HeroUI 绿色 -}; - -/** - * HeroUI 风格圆环仪表盘 - */ -const CircularGauge = ({ rate, label, icon }) => { - const validRate = Math.min(100, Math.max(0, rate || 0)); - const gaugeColor = getRateColor(validRate); - const circumference = 2 * Math.PI * 42; // 半径42 - const strokeDashoffset = circumference - (validRate / 100) * circumference; - - return ( - - {/* 圆环仪表盘 */} -
- - - {/* 背景圆环 */} - - {/* 渐变定义 */} - - - - - - - {/* 进度圆环 */} - - - {/* 中心数值 */} - - - {validRate.toFixed(1)} - - % - - -
- - {/* 标签 */} - - {icon} - - {label} - - -
- ); -}; - -/** - * HeroUI 风格胜率对比面板 - */ -const WinRateGauge = ({ eventRate, marketRate, marketStats }) => { - const eventRateVal = eventRate || 0; - const marketRateVal = marketRate || 0; - - return ( - - {/* 双仪表盘对比 - HeroUI 毛玻璃卡片 */} - - } - /> - } - /> - - - {/* 市场统计 - 毛玻璃条 */} - {marketStats && marketStats.totalCount > 0 && ( - - - 沪深两市实时 - {marketStats.totalCount} 只 - - {/* 进度条 */} - - - - - {/* 数字统计 */} - - - - {marketStats.risingCount} - - - - - {marketStats.flatCount} - - - - - {marketStats.fallingCount} - - - - - )} - - ); -}; - -/** - * HeroUI 风格紧凑数据卡片 - */ -const CompactStatCard = ({ label, value, icon, color = '#7C3AED', subText }) => ( - - - - {icon} - - - {label} - - - - {value} - - {subText && ( - - {subText} - - )} - -); - /** * TOP事件列表项 */ const TopEventItem = ({ event, rank }) => { const handleClick = () => { if (event.id) { - window.open(getEventDetailUrl(event.id), '_blank'); + window.open(getEventDetailUrl(event.id), "_blank"); } }; @@ -322,12 +65,12 @@ const TopEventItem = ({ event, rank }) => { px={2} bg="rgba(0,0,0,0.2)" borderRadius="md" - _hover={{ bg: 'rgba(255,215,0,0.12)', cursor: 'pointer' }} + _hover={{ bg: "rgba(255,215,0,0.12)", cursor: "pointer" }} transition="all 0.15s" onClick={handleClick} > { color="gray.300" flex="1" noOfLines={1} - _hover={{ color: '#FFD700' }} + _hover={{ color: "#FFD700" }} > {event.title} @@ -354,52 +97,19 @@ const TopEventItem = ({ event, rank }) => { ); }; -/** - * TOP股票列表项 - */ -const TopStockItem = ({ stock, rank }) => { - return ( - - - {rank} - - - {stock.stockCode?.split('.')[0] || '-'} - - - {stock.stockName || '-'} - - - {formatChg(stock.maxChg)} - - - ); -}; +// 单个事件项高度(py=1 约 8px * 2 + 内容约 20px + spacing 4px) +const ITEM_HEIGHT = 32; +const VISIBLE_COUNT = 8; const EventDailyStats = () => { const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); + const [, setRefreshing] = useState(false); const [stats, setStats] = useState(null); const [error, setError] = useState(null); - const [activeTab, setActiveTab] = useState(0); - const [selectedDate, setSelectedDate] = useState(''); + const [isPaused, setIsPaused] = useState(false); + const controls = useAnimationControls(); - const fetchStats = useCallback(async (dateStr = '', isRefresh = false) => { + const fetchStats = useCallback(async (isRefresh = false) => { if (isRefresh) { setRefreshing(true); } else { @@ -408,17 +118,18 @@ const EventDailyStats = () => { setError(null); try { const apiBase = getApiBase(); - const dateParam = dateStr ? `&date=${dateStr}` : ''; - const response = await fetch(`${apiBase}/api/v1/events/effectiveness-stats?days=1${dateParam}`); - if (!response.ok) throw new Error('获取数据失败'); + const response = await fetch( + `${apiBase}/api/v1/events/effectiveness-stats?days=1` + ); + if (!response.ok) throw new Error("获取数据失败"); const data = await response.json(); if (data.success || data.code === 200) { setStats(data.data); } else { - throw new Error(data.message || '数据格式错误'); + throw new Error(data.message || "数据格式错误"); } } catch (err) { - console.error('获取事件统计失败:', err); + console.error("获取事件统计失败:", err); setError(err.message); } finally { setLoading(false); @@ -427,29 +138,50 @@ const EventDailyStats = () => { }, []); useEffect(() => { - fetchStats(selectedDate); - }, [fetchStats, selectedDate]); + fetchStats(); + }, [fetchStats]); - // 自动刷新(仅当选择今天时,每60秒刷新一次) + // 自动刷新(每60秒刷新一次) useEffect(() => { - if (!selectedDate) { - const interval = setInterval(() => fetchStats('', true), 60 * 1000); - return () => clearInterval(interval); + const interval = setInterval(() => fetchStats(true), 60 * 1000); + return () => clearInterval(interval); + }, [fetchStats]); + + // 获取显示列表(取前10个,复制一份用于无缝循环) + const displayList = useMemo(() => { + const topPerformers = stats?.topPerformers || []; + const list = topPerformers.slice(0, 10); + // 数据不足5个时不需要滚动 + if (list.length <= VISIBLE_COUNT) return list; + // 复制一份用于无缝循环 + return [...list, ...list]; + }, [stats]); + + const needScroll = displayList.length > VISIBLE_COUNT; + const originalCount = Math.min((stats?.topPerformers || []).length, 10); + const totalScrollHeight = originalCount * ITEM_HEIGHT; + + // 滚动动画 + useEffect(() => { + if (!needScroll || isPaused) { + controls.stop(); + return; } - }, [selectedDate, fetchStats]); - const handleDateChange = (e) => { - setSelectedDate(e.target.value); - }; + const startAnimation = async () => { + await controls.start({ + y: -totalScrollHeight, + transition: { + duration: originalCount * 2, // 每个item约2秒 + ease: "linear", + repeat: Infinity, + repeatType: "loop", + }, + }); + }; - // 手动刷新 - const handleRefresh = () => { - if (!refreshing) { - fetchStats(selectedDate, true); - } - }; - - const isToday = !selectedDate; + startAnimation(); + }, [needScroll, isPaused, controls, totalScrollHeight, originalCount]); if (loading) { return ( @@ -468,34 +200,14 @@ const EventDailyStats = () => { ); } - if (error || !stats) { - return ( - -
- - 暂无数据 - {error} - -
-
- ); - } - - const { summary, marketStats, topPerformers = [], topStocks = [] } = stats; + const hasData = stats && displayList.length > 0; return ( { /> {/* 标题行 */} - - - - - {isToday ? '今日统计' : '历史统计'} - - {isToday && ( - - - - 实时 - - - )} - - - {/* 刷新按钮 */} - - - - - - {/* 今天按钮 - 仅在查看历史时显示 */} - {!isToday && ( - setSelectedDate('')} - > - 返回今天 - - )} - - - - - - + + + + 事件 TOP 排行 + + - {/* 内容区域 - 固定高度滚动 */} + {/* 内容区域 - 固定高度显示8个,向上滚动轮播 */} setIsPaused(true)} + onMouseLeave={() => setIsPaused(false)} > - - {/* 胜率对比仪表盘 */} - - - {/* 核心指标 - 2x2 网格 */} - - } - color="#F59E0B" - /> - } - color="#06B6D4" - /> - } - color={summary?.avgChg >= 0 ? '#F31260' : '#17C964'} - /> - } - color="#F31260" - /> - - - {/* 分割线 */} - - - {/* TOP 表现 - Tab 切换 */} - - - - - - - 事件TOP10 - - - - - - 股票TOP10 - - - - - - {/* 事件 TOP10 */} - - - {topPerformers.slice(0, 10).map((event, idx) => ( - - ))} - {topPerformers.length === 0 && ( - - 暂无数据 - - )} - - - - {/* 股票 TOP10 */} - - - {topStocks.slice(0, 10).map((stock, idx) => ( - - ))} - {topStocks.length === 0 && ( - - 暂无数据 - - )} - - - - - - + {hasData ? ( + + + {displayList.map((event, idx) => ( + + ))} + + + ) : ( +
+ + + 暂无数据 + + {error && ( + + {error} + + )} + +
+ )}
); From de9dfeccca8336eb6dade1e5da049af523c3dd41 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Tue, 13 Jan 2026 14:56:37 +0800 Subject: [PATCH 04/22] =?UTF-8?q?feat(community):=20=E8=BF=9E=E6=9D=BF?= =?UTF-8?q?=E6=83=85=E7=BB=AA=E5=9B=BE=E8=A1=A8=E6=A0=87=E9=A2=98=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E4=B8=A4=E8=A1=8C=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 第一行:日期 + 图例 - 第二行:标题 + 热度数 + 事件数 - 热度图标改为 CaretUpFilled(涨停含义) --- .../Community/components/ThemeCometChart.js | 90 +++++++++++++------ 1 file changed, 61 insertions(+), 29 deletions(-) diff --git a/src/views/Community/components/ThemeCometChart.js b/src/views/Community/components/ThemeCometChart.js index 6e4f3acf..42f9ee23 100644 --- a/src/views/Community/components/ThemeCometChart.js +++ b/src/views/Community/components/ThemeCometChart.js @@ -34,7 +34,7 @@ import { Tooltip as ChakraTooltip, } from '@chakra-ui/react'; import ReactECharts from 'echarts-for-react'; -import { ThunderboltOutlined } from '@ant-design/icons'; +import { ThunderboltOutlined, CalendarOutlined, StockOutlined, CaretUpFilled } from '@ant-design/icons'; import { getApiBase } from '@utils/apiConfig'; // 板块状态配置 @@ -141,18 +141,7 @@ const generateChartOption = (themes) => { }, }, legend: { - show: true, - top: 5, - right: 10, - orient: 'horizontal', - textStyle: { color: 'rgba(255, 255, 255, 0.7)', fontSize: 13 }, - itemWidth: 14, - itemHeight: 14, - data: Object.values(STATUS_CONFIG).map((s) => ({ - name: s.name, - icon: 'circle', - itemStyle: { color: s.color }, - })), + show: false, }, grid: { left: '10%', @@ -223,6 +212,9 @@ const ThemeCometChart = ({ onThemeSelect }) => { dataCache[latestDate] = { themes: result.data.themes || [], currentDate: result.data.currentDate || '', + totalLimitUp: result.data.totalLimitUp || 0, + totalEvents: result.data.totalEvents || 0, + indexChange: result.data.indexChange || 0, }; } @@ -237,6 +229,9 @@ const ThemeCometChart = ({ onThemeSelect }) => { date: dateInfo.date, themes: data.data.themes || [], currentDate: data.data.currentDate || '', + totalLimitUp: data.data.totalLimitUp || 0, + totalEvents: data.data.totalEvents || 0, + indexChange: data.data.indexChange || 0, }; } } catch (e) { @@ -251,6 +246,9 @@ const ThemeCometChart = ({ onThemeSelect }) => { dataCache[item.date] = { themes: item.themes, currentDate: item.currentDate, + totalLimitUp: item.totalLimitUp, + totalEvents: item.totalEvents, + indexChange: item.indexChange, }; } }); @@ -280,7 +278,13 @@ const ThemeCometChart = ({ onThemeSelect }) => { // 获取当前显示的数据 const currentDateStr = availableDates[sliderIndex]?.date; - const currentData = allDatesData[currentDateStr] || { themes: [], currentDate: '' }; + const currentData = allDatesData[currentDateStr] || { + themes: [], + currentDate: '', + totalLimitUp: 0, + totalEvents: 0, + indexChange: 0 + }; const isCurrentDateLoaded = currentDateStr && allDatesData[currentDateStr]; const chartOption = useMemo(() => generateChartOption(currentData.themes), [currentData.themes]); @@ -323,23 +327,51 @@ const ThemeCometChart = ({ onThemeSelect }) => { h="100%" minH="350px" > - {/* 标题栏 */} - - - - - - - - 连板情绪监测 + {/* 标题栏 - 两行布局 */} + + {/* 第一行:日期 + 图例 */} + + + + + {currentSliderDate || currentData.currentDate} - {loading && } - - {currentSliderDate || currentData.currentDate} - - - + {/* 图例 */} + + {Object.values(STATUS_CONFIG).map((status) => ( + + + {status.name} + + ))} + + + {/* 第二行:标题 + 热度 + 事件 */} + + + + + + + + 连板情绪监测 + + {loading && } + + + + 热度 + {currentData.totalLimitUp} + + + + 事件 + {currentData.totalEvents} + + + + {/* 图表区域 */} From 9bfdd56af1c489324bd885804c81998e446082d2 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Tue, 13 Jan 2026 14:57:04 +0800 Subject: [PATCH 05/22] =?UTF-8?q?feat(community):=20=E6=96=B0=E5=A2=9E=20M?= =?UTF-8?q?arketOverviewBanner=20=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 股票 TOP10 弹窗功能 - 暗色主题表格样式 --- .../components/MarketOverviewBanner.js | 725 ++++++++++++++++++ 1 file changed, 725 insertions(+) create mode 100644 src/views/Community/components/MarketOverviewBanner.js diff --git a/src/views/Community/components/MarketOverviewBanner.js b/src/views/Community/components/MarketOverviewBanner.js new file mode 100644 index 00000000..98ad8651 --- /dev/null +++ b/src/views/Community/components/MarketOverviewBanner.js @@ -0,0 +1,725 @@ +/** + * MarketOverviewBanner - 市场与事件概览通栏组件 + * 顶部通栏展示市场涨跌分布和事件统计数据 + */ +import React, { useState, useEffect, useCallback, useRef } from "react"; +import { + Box, + Text, + HStack, + Spinner, + Tooltip, + Flex, + Grid, + Input, +} from "@chakra-ui/react"; +import { + FireOutlined, + RiseOutlined, + FallOutlined, + ThunderboltOutlined, + TrophyOutlined, + BarChartOutlined, + CalendarOutlined, + StockOutlined, +} from "@ant-design/icons"; +import { Modal, Table } from "antd"; +import { getApiBase } from "@utils/apiConfig"; + +// 涨跌颜色常量 +const UP_COLOR = "#FF4D4F"; // 涨 - 红色 +const DOWN_COLOR = "#52C41A"; // 跌 - 绿色 +const FLAT_COLOR = "#888888"; // 平 - 灰色 + +/** + * 判断是否在交易时间内 (9:30-15:00) + */ +const isInTradingTime = () => { + const now = new Date(); + const hours = now.getHours(); + const minutes = now.getMinutes(); + const time = hours * 60 + minutes; + return time >= 570 && time <= 900; // 9:30-15:00 +}; + +// 注入脉冲动画样式 +if (typeof document !== "undefined") { + const styleId = "market-banner-animations"; + if (!document.getElementById(styleId)) { + const styleSheet = document.createElement("style"); + styleSheet.id = styleId; + styleSheet.innerText = ` + @keyframes pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.6; transform: scale(1.1); } + } + `; + document.head.appendChild(styleSheet); + } +} + +/** + * 格式化涨跌幅 + */ +const formatChg = (val) => { + if (val === null || val === undefined) return "-"; + const num = parseFloat(val); + if (isNaN(num)) return "-"; + return (num >= 0 ? "+" : "") + num.toFixed(2) + "%"; +}; + +/** + * 沪深实时涨跌条形图组件 - 紧凑版 + */ +const MarketStatsBarCompact = ({ marketStats }) => { + if (!marketStats || marketStats.totalCount === 0) return null; + + const { + risingCount = 0, + flatCount = 0, + fallingCount = 0, + totalCount = 0, + } = marketStats; + const risePercent = totalCount > 0 ? (risingCount / totalCount) * 100 : 0; + const flatPercent = totalCount > 0 ? (flatCount / totalCount) * 100 : 0; + const fallPercent = totalCount > 0 ? (fallingCount / totalCount) * 100 : 0; + + return ( + + {/* 标题 */} + + + 沪深实时涨跌 + + + ({totalCount}只) + + + + {/* 进度条 */} + + + + + + + + + {/* 数值标签 */} + + + + + {risingCount} + + + 涨 + + + + + + + {flatCount} + + + 平 + + + + + + + {fallingCount} + + + 跌 + + + + + ); +}; + +/** + * 环形进度图组件 - 仿图片样式 + * @param {boolean} noBorder - 是否不显示边框(用于嵌套在其他容器中) + */ +const CircularProgressCard = ({ label, value, color = "#EC4899", size = 44, highlight = false, noBorder = false }) => { + const percentage = parseFloat(value) || 0; + const strokeWidth = 3; + const radius = (size - strokeWidth) / 2; + // 270度圆弧(底部有缺口) + const arcLength = (270 / 360) * 2 * Math.PI * radius; + const progressLength = (percentage / 100) * arcLength; + + return ( + + + {label} + + + + {/* 背景圆弧 */} + + {/* 进度圆弧 */} + + + {/* 中心数值 */} + + + {percentage.toFixed(1)}% + + + + + ); +}; + +/** + * 紧凑数据卡片 - 通栏版 + */ +const BannerStatCard = ({ label, value, icon, color = "#7C3AED", highlight = false }) => ( + + + + {icon} + + + {label} + + + + {value} + + +); + +const MarketOverviewBanner = () => { + const [loading, setLoading] = useState(true); + const [stats, setStats] = useState(null); + const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split("T")[0]); + const [stockModalVisible, setStockModalVisible] = useState(false); + const dateInputRef = useRef(null); + + const fetchStats = useCallback(async (dateStr = "", showLoading = false) => { + if (showLoading) { + setLoading(true); + } + try { + const apiBase = getApiBase(); + const dateParam = dateStr ? `&date=${dateStr}` : ""; + const response = await fetch( + `${apiBase}/api/v1/events/effectiveness-stats?days=1${dateParam}` + ); + if (!response.ok) throw new Error("获取数据失败"); + const data = await response.json(); + if (data.success || data.code === 200) { + setStats(data.data); + } + } catch (err) { + console.error("获取市场统计失败:", err); + } finally { + setLoading(false); + } + }, []); + + // 首次加载显示 loading + useEffect(() => { + fetchStats(selectedDate, true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // 日期变化时静默刷新(不显示 loading) + useEffect(() => { + if (selectedDate) { + fetchStats(selectedDate, false); + } + }, [fetchStats, selectedDate]); + + // 自动刷新(每60秒,仅当选择今天时) + useEffect(() => { + if (!selectedDate) { + const interval = setInterval(() => fetchStats(""), 60 * 1000); + return () => clearInterval(interval); + } + }, [selectedDate, fetchStats]); + + const handleDateChange = (e) => { + setSelectedDate(e.target.value); + }; + + const handleCalendarClick = () => { + dateInputRef.current?.showPicker?.(); + }; + + if (loading) { + return ( + + + + ); + } + + if (!stats) return null; + + const { summary, marketStats, topStocks = [] } = stats; + + return ( + + {/* 标题行 */} + + + + 事件中心 + + {/* 交易状态指示器 */} + {isInTradingTime() && ( + + + + 交易中 + + + )} + {/* 实时标签 */} + {!selectedDate && ( + + + + 实时 + + + )} + + + {/* 返回今天按钮 - 当选择的不是今天时显示 */} + {selectedDate !== new Date().toISOString().split("T")[0] && ( + setSelectedDate(new Date().toISOString().split("T")[0])} + > + 返回今天 + + )} + {/* 日期选择器 - 金色边框 */} + + + + + + + + {/* 内容:左右布局 */} + + {/* 左侧:涨跌条形图 */} + + + + + {/* 右侧:6个指标卡片 - 1行6列 */} + + + + = 0 ? : } + color={summary?.avgChg >= 0 ? UP_COLOR : DOWN_COLOR} + highlight + /> + = 0 ? : } + color={summary?.maxChg >= 0 ? UP_COLOR : DOWN_COLOR} + highlight + /> + } + color="#F59E0B" + highlight + /> + {/* 关联股票 + TOP10标签 - 与 BannerStatCard 样式统一 */} + + + + + + + 关联股票 + + + setStockModalVisible(true)} + > + TOP10 + + + + + {summary?.totalStocks || 0} + + + + + + {/* 股票TOP10弹窗 */} + + + 股票 TOP10 + + } + open={stockModalVisible} + onCancel={() => setStockModalVisible(false)} + footer={null} + width={600} + closeIcon={×} + styles={{ + content: { + background: "linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)", + border: "1px solid rgba(236, 72, 153, 0.3)", + }, + header: { + background: "transparent", + borderBottom: "1px solid rgba(255,255,255,0.1)", + }, + }} + > + + record.stockCode || record.stockName} + pagination={false} + size="small" + className="stock-top10-table" + columns={[ + { + title: "排名", + dataIndex: "rank", + key: "rank", + width: 60, + render: (_, __, index) => ( + + {index + 1} + + ), + }, + { + title: "股票代码", + dataIndex: "stockCode", + key: "stockCode", + width: 100, + render: (code) => ( + + {code?.split(".")[0] || "-"} + + ), + }, + { + title: "股票名称", + dataIndex: "stockName", + key: "stockName", + render: (name) => ( + + {name || "-"} + + ), + }, + { + title: "最大涨幅", + dataIndex: "maxChg", + key: "maxChg", + width: 100, + align: "right", + render: (val) => ( + = 0 ? UP_COLOR : DOWN_COLOR} + > + {formatChg(val)} + + ), + }, + ]} + /> + + + ); +}; + +export default MarketOverviewBanner; From 61df9af79825e932bdeaf336cb1fef5447f2803a Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Tue, 13 Jan 2026 14:57:45 +0800 Subject: [PATCH 06/22] =?UTF-8?q?style(community):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E4=B8=AD=E5=BF=83=E9=A1=B5=E9=9D=A2=E9=A1=B6?= =?UTF-8?q?=E9=83=A8=E5=A4=9A=E4=BD=99=20padding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/views/Community/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/Community/index.js b/src/views/Community/index.js index 737a0583..0375525e 100644 --- a/src/views/Community/index.js +++ b/src/views/Community/index.js @@ -151,7 +151,7 @@ const Community = () => { return ( {/* 主内容区域 - padding 由 MainLayout 统一设置 */} - + {/* ⚡ 顶部说明面板(懒加载):产品介绍 + 沪深指数 + 热门概念词云 */} From 7148dd97c20fd9b465f162db451416a16da911a5 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Tue, 13 Jan 2026 14:58:29 +0800 Subject: [PATCH 07/22] =?UTF-8?q?refactor(navbar):=20=E5=AF=BC=E8=88=AA?= =?UTF-8?q?=E6=A0=8F=E5=B8=83=E5=B1=80=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 MySpaceButton 组件 - 删除 PersonalCenterMenu,功能合并到 DesktopUserMenu - 桌面端布局:[我的空间] | [头像][用户名] --- .../Navbars/components/CalendarButton.js | 10 +- .../Navbars/components/NavbarActions/index.js | 60 +++-- .../components/Navigation/MySpaceButton.js | 38 +++ .../Navigation/PersonalCenterMenu.js | 116 -------- .../Navbars/components/Navigation/index.js | 3 +- .../components/UserMenu/DesktopUserMenu.js | 253 ++++++++++++++---- 6 files changed, 286 insertions(+), 194 deletions(-) create mode 100644 src/components/Navbars/components/Navigation/MySpaceButton.js delete mode 100644 src/components/Navbars/components/Navigation/PersonalCenterMenu.js diff --git a/src/components/Navbars/components/CalendarButton.js b/src/components/Navbars/components/CalendarButton.js index 3184de44..b2e2c056 100644 --- a/src/components/Navbars/components/CalendarButton.js +++ b/src/components/Navbars/components/CalendarButton.js @@ -32,10 +32,14 @@ const CalendarButton = memo(() => { <> + ); +}); + +MySpaceButton.displayName = 'MySpaceButton'; + +export default MySpaceButton; diff --git a/src/components/Navbars/components/Navigation/PersonalCenterMenu.js b/src/components/Navbars/components/Navigation/PersonalCenterMenu.js deleted file mode 100644 index 8817fe0a..00000000 --- a/src/components/Navbars/components/Navigation/PersonalCenterMenu.js +++ /dev/null @@ -1,116 +0,0 @@ -// src/components/Navbars/components/Navigation/PersonalCenterMenu.js -// 个人中心下拉菜单 - 仅桌面版显示 - -import React, { memo } from 'react'; -import { - Menu, - MenuButton, - MenuList, - MenuItem, - MenuDivider, - Button, - Box, - Text, - Badge, - useDisclosure -} from '@chakra-ui/react'; -import { ChevronDown, Home, User, Settings, LogOut, Crown } from 'lucide-react'; -import { useNavigate } from 'react-router-dom'; - -/** - * 个人中心下拉菜单组件 - * 仅在桌面版 (lg+) 显示 - * - * @param {Object} props - * @param {Object} props.user - 用户信息 - * @param {Function} props.handleLogout - 退出登录回调 - */ -const PersonalCenterMenu = memo(({ user, handleLogout }) => { - const navigate = useNavigate(); - - // 🎯 为个人中心菜单创建 useDisclosure Hook - const { isOpen, onOpen, onClose } = useDisclosure(); - - // 获取显示名称 - const getDisplayName = () => { - if (user.nickname) return user.nickname; - if (user.username) return user.username; - if (user.email) return user.email.split('@')[0]; - if (typeof user.phone === 'string' && user.phone) return user.phone; - return '用户'; - }; - - return ( - - } - onMouseEnter={onOpen} - onMouseLeave={onClose} - > - 个人中心 - - - {/* 用户信息区 */} - - {getDisplayName()} - {typeof user.phone === 'string' && user.phone && ( - {user.phone} - )} - {user.has_wechat && ( - 微信已绑定 - )} - - - {/* 前往个人中心 */} - } onClick={() => { - onClose(); // 先关闭菜单 - navigate('/home/center'); - }}> - 前往个人中心 - - - - - {/* 账户管理组 */} - } onClick={() => { - onClose(); // 先关闭菜单 - navigate('/home/profile'); - }}> - 个人资料 - - } onClick={() => { - onClose(); // 先关闭菜单 - navigate('/home/settings'); - }}> - 账户设置 - - - - - {/* 功能入口组 */} - } onClick={() => { - onClose(); // 先关闭菜单 - navigate('/home/pages/account/subscription'); - }}> - 订阅管理 - - - - - {/* 退出 */} - } onClick={handleLogout} color="red.500"> - 退出登录 - - - - ); -}); - -PersonalCenterMenu.displayName = 'PersonalCenterMenu'; - -export default PersonalCenterMenu; diff --git a/src/components/Navbars/components/Navigation/index.js b/src/components/Navbars/components/Navigation/index.js index d9e405d5..98ef4f7a 100644 --- a/src/components/Navbars/components/Navigation/index.js +++ b/src/components/Navbars/components/Navigation/index.js @@ -3,4 +3,5 @@ export { default as DesktopNav } from './DesktopNav'; export { default as MoreMenu } from './MoreMenu'; -export { default as PersonalCenterMenu } from './PersonalCenterMenu'; +export { default as MySpaceButton } from './MySpaceButton'; +// PersonalCenterMenu 已废弃,功能合并到 DesktopUserMenu diff --git a/src/components/Navbars/components/UserMenu/DesktopUserMenu.js b/src/components/Navbars/components/UserMenu/DesktopUserMenu.js index 76340e69..f742dc16 100644 --- a/src/components/Navbars/components/UserMenu/DesktopUserMenu.js +++ b/src/components/Navbars/components/UserMenu/DesktopUserMenu.js @@ -1,71 +1,234 @@ // src/components/Navbars/components/UserMenu/DesktopUserMenu.js -// 桌面版用户菜单 - 头像点击跳转到订阅页面 +// 桌面版用户菜单 - 头像+用户名组合,点击展开综合下拉面板 import React, { memo } from 'react'; import { - Popover, - PopoverTrigger, - PopoverContent, - PopoverArrow, - useColorModeValue + Menu, + MenuButton, + MenuList, + MenuItem, + MenuDivider, + Box, + HStack, + VStack, + Text, + Button, + useDisclosure } from '@chakra-ui/react'; +import { Settings, LogOut } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import UserAvatar from './UserAvatar'; -import { TooltipContent } from '../../../Subscription/CrownTooltip'; import { useSubscription } from '../../../../hooks/useSubscription'; /** - * 桌面版用户菜单组件 - * 大屏幕 (md+) 显示,头像点击跳转到订阅页面 - * - * @param {Object} props - * @param {Object} props.user - 用户信息 + * 会员权益条组件 + * 金色渐变背景,单行显示会员类型和到期时间 */ -const DesktopUserMenu = memo(({ user }) => { +const MembershipBar = memo(({ subscriptionInfo, onClose }) => { const navigate = useNavigate(); - const { subscriptionInfo } = useSubscription(); + const { type, days_left } = subscriptionInfo; - const popoverBg = useColorModeValue('white', 'gray.800'); - const popoverBorderColor = useColorModeValue('gray.200', 'gray.600'); + const getMemberText = () => { + if (type === 'free') return '基础版'; + if (type === 'pro') return 'Pro会员'; + return 'Max会员'; + }; - const handleAvatarClick = () => { + const getMemberIcon = () => { + if (type === 'free') return '✨'; + if (type === 'pro') return '💎'; + return '👑'; + }; + + const handleClick = () => { + onClose(); navigate('/home/pages/account/subscription'); }; + // 金色渐变背景 + const gradientBg = type === 'free' + ? 'linear(to-r, gray.100, gray.200)' + : 'linear(to-r, #F6E5A3, #D4AF37)'; + return ( - - - + + + {getMemberIcon()} + + {getMemberText()} + {type !== 'free' && ( + + {' '}· {days_left}天后到期 + + )} + + + + + + ); +}); + +MembershipBar.displayName = 'MembershipBar'; + +/** + * 桌面版用户菜单组件 + * 头像+用户名组合(去掉箭头),点击展开综合下拉面板 + * + * 布局: [头像][用户名] + * 交互: hover 时显示浅色圆角背景,点击展开面板 + * + * @param {Object} props + * @param {Object} props.user - 用户信息 + * @param {Function} props.handleLogout - 退出登录回调 + */ +const DesktopUserMenu = memo(({ user, handleLogout }) => { + const navigate = useNavigate(); + const { subscriptionInfo } = useSubscription(); + const { isOpen, onOpen, onClose } = useDisclosure(); + + // 获取显示名称(含手机号脱敏逻辑) + const getDisplayName = () => { + // 1. 优先显示昵称 + if (user.nickname) return user.nickname; + // 2. 其次显示用户名 + if (user.username) return user.username; + // 3. 手机号脱敏 + if (typeof user.phone === 'string' && user.phone) { + return user.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2'); + } + // 4. 默认显示 + return '股票新用户'; + }; + + // 跳转到我的空间 + const handleNavigateToMySpace = () => { + onClose(); + navigate('/home/center'); + }; + + // 跳转到账户设置 + const handleNavigateToSettings = () => { + onClose(); + navigate('/home/settings'); + }; + + // 退出登录 + const handleLogoutClick = () => { + onClose(); + handleLogout(); + }; + + return ( + + + {/* 使用 HStack 明确实现水平布局 */} + - - - - - - - + + {getDisplayName()} + + + + + + {/* 顶部:用户信息区 - 深色背景 + 头像 + 用户名 */} + + + + + + {getDisplayName()} + + + ID: {user.phone || user.id || '---'} + + + + + + {/* 会员权益条 - 金色渐变背景 */} + + + + + {/* 列表区:快捷功能 */} + } + onClick={handleNavigateToSettings} + py={3} + > + 账户设置 + + + + + {/* 底部:退出登录 */} + } + color="red.500" + onClick={handleLogoutClick} + py={3} + > + 退出登录 + + + ); }); From 6c25cd14c73e279867a9d72e559a000f57700f5d Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Tue, 13 Jan 2026 14:59:01 +0800 Subject: [PATCH 08/22] =?UTF-8?q?refactor(settings):=20=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E9=87=8D=E6=9E=84=20&=20=E6=94=AF=E4=BB=98?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SettingsPage: 功能增强 - PaymentStatus: 关闭按钮样式优化 - InvoicePage: 支持嵌入模式 --- src/views/Pages/Account/Invoice/index.tsx | 122 +- .../Payment/components/PaymentStatus.tsx | 50 +- src/views/Settings/SettingsPage.js | 2178 ++++++++++++----- 3 files changed, 1699 insertions(+), 651 deletions(-) diff --git a/src/views/Pages/Account/Invoice/index.tsx b/src/views/Pages/Account/Invoice/index.tsx index da78e25e..8e1b3b6e 100644 --- a/src/views/Pages/Account/Invoice/index.tsx +++ b/src/views/Pages/Account/Invoice/index.tsx @@ -1,7 +1,7 @@ /** * 发票管理页面 */ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback } from "react"; import { Box, Flex, @@ -31,38 +31,49 @@ import { StatLabel, StatNumber, StatHelpText, -} from '@chakra-ui/react'; -import { FileText, Plus, RefreshCw, Clock, CheckCircle, AlertCircle } from 'lucide-react'; -import Card from '@components/Card/Card'; -import CardHeader from '@components/Card/CardHeader'; -import { InvoiceCard, InvoiceApplyModal } from '@components/Invoice'; +} from "@chakra-ui/react"; +import { + FileText, + Plus, + RefreshCw, + Clock, + CheckCircle, + AlertCircle, +} from "lucide-react"; +import Card from "@components/Card/Card"; +import CardHeader from "@components/Card/CardHeader"; +import { InvoiceCard, InvoiceApplyModal } from "@components/Invoice"; import { getInvoiceList, getInvoiceStats, cancelInvoice, downloadInvoice, -} from '@/services/invoiceService'; -import type { InvoiceInfo, InvoiceStatus, InvoiceStats } from '@/types/invoice'; +} from "@/services/invoiceService"; +import type { InvoiceInfo, InvoiceStatus, InvoiceStats } from "@/types/invoice"; -type TabType = 'all' | 'pending' | 'processing' | 'completed'; +type TabType = "all" | "pending" | "processing" | "completed"; const tabConfig: { key: TabType; label: string; status?: InvoiceStatus }[] = [ - { key: 'all', label: '全部' }, - { key: 'pending', label: '待处理', status: 'pending' }, - { key: 'processing', label: '处理中', status: 'processing' }, - { key: 'completed', label: '已完成', status: 'completed' }, + { key: "all", label: "全部" }, + { key: "pending", label: "待处理", status: "pending" }, + { key: "processing", label: "处理中", status: "processing" }, + { key: "completed", label: "已完成", status: "completed" }, ]; -export default function InvoicePage() { +interface InvoicePageProps { + embedded?: boolean; +} + +export default function InvoicePage({ embedded = false }: InvoicePageProps) { const [invoices, setInvoices] = useState([]); const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); - const [activeTab, setActiveTab] = useState('all'); + const [activeTab, setActiveTab] = useState("all"); const [cancelingId, setCancelingId] = useState(null); const toast = useToast(); - const textColor = useColorModeValue('gray.700', 'white'); - const bgCard = useColorModeValue('white', 'gray.800'); + const textColor = useColorModeValue("gray.700", "white"); + const bgCard = useColorModeValue("white", "gray.800"); const cancelDialogRef = React.useRef(null); const { @@ -87,11 +98,11 @@ export default function InvoicePage() { setInvoices(res.data.list || []); } } catch (error) { - console.error('加载发票列表失败:', error); + console.error("加载发票列表失败:", error); toast({ - title: '加载失败', - description: '无法获取发票列表', - status: 'error', + title: "加载失败", + description: "无法获取发票列表", + status: "error", duration: 3000, }); } finally { @@ -107,7 +118,7 @@ export default function InvoicePage() { setStats(res.data); } } catch (error) { - console.error('加载发票统计失败:', error); + console.error("加载发票统计失败:", error); } }, []); @@ -124,25 +135,25 @@ export default function InvoicePage() { const res = await cancelInvoice(cancelingId); if (res.code === 200) { toast({ - title: '取消成功', - status: 'success', + title: "取消成功", + status: "success", duration: 2000, }); loadInvoices(); loadStats(); } else { toast({ - title: '取消失败', + title: "取消失败", description: res.message, - status: 'error', + status: "error", duration: 3000, }); } } catch (error) { toast({ - title: '取消失败', - description: '网络错误', - status: 'error', + title: "取消失败", + description: "网络错误", + status: "error", duration: 3000, }); } finally { @@ -156,7 +167,7 @@ export default function InvoicePage() { try { const blob = await downloadInvoice(invoice.id); const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); + const a = document.createElement("a"); a.href = url; a.download = `发票_${invoice.invoiceNo || invoice.id}.pdf`; document.body.appendChild(a); @@ -165,9 +176,9 @@ export default function InvoicePage() { window.URL.revokeObjectURL(url); } catch (error) { toast({ - title: '下载失败', - description: '无法下载发票文件', - status: 'error', + title: "下载失败", + description: "无法下载发票文件", + status: "error", duration: 3000, }); } @@ -186,11 +197,17 @@ export default function InvoicePage() { }; return ( - + {/* 统计卡片 */} {stats && ( - + 全部申请 {stats.total} @@ -200,7 +217,13 @@ export default function InvoicePage() { - + 待处理 {stats.pending} @@ -210,7 +233,13 @@ export default function InvoicePage() { - + 处理中 {stats.processing} @@ -220,7 +249,13 @@ export default function InvoicePage() { - + 已完成 {stats.completed} @@ -234,7 +269,12 @@ export default function InvoicePage() { )} {/* 主内容区 */} - + @@ -340,7 +380,9 @@ export default function InvoicePage() { 取消开票申请 - 确定要取消这个开票申请吗?取消后可重新申请。 + + 确定要取消这个开票申请吗?取消后可重新申请。 + - )} - {onBack && ( - - )} - + {(status === 'failed' || status === 'cancelled') && onRetry && ( + + )} {/* 支付中提示 */} {status === 'paying' && ( diff --git a/src/views/Settings/SettingsPage.js b/src/views/Settings/SettingsPage.js index 92dc7168..5e0ef2ad 100644 --- a/src/views/Settings/SettingsPage.js +++ b/src/views/Settings/SettingsPage.js @@ -1,611 +1,1611 @@ // src/views/Settings/SettingsPage.js -import React, { useState } from 'react'; +import React, { useState, useCallback } from "react"; import { - Box, - VStack, - HStack, - Text, - Heading, - Button, - Input, - FormControl, - FormLabel, - Card, - CardBody, - CardHeader, - useToast, - Modal, - ModalOverlay, - ModalContent, - ModalHeader, - ModalBody, - ModalFooter, - ModalCloseButton, - useDisclosure, - Badge, - Tabs, - TabList, - TabPanels, - Tab, - TabPanel, - PinInput, - PinInputField -} from '@chakra-ui/react'; -import { Link2, Trash2, Pencil, Smartphone, Mail, FileText, CreditCard } from 'lucide-react'; -import { WechatOutlined } from '@ant-design/icons'; -import { useAuth } from '../../contexts/AuthContext'; -import { getApiBase } from '../../utils/apiConfig'; -import { logger } from '../../utils/logger'; -import { useProfileEvents } from '../../hooks/useProfileEvents'; -import { useNavigate } from 'react-router-dom'; + Box, + VStack, + HStack, + Text, + Heading, + Button, + Input, + FormControl, + FormLabel, + Card, + CardBody, + CardHeader, + useToast, + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalBody, + ModalFooter, + ModalCloseButton, + useDisclosure, + Badge, + PinInput, + PinInputField, + Icon, + Avatar, + Textarea, + Select, + SimpleGrid, +} from "@chakra-ui/react"; +import { + Link2, + Trash2, + Pencil, + Smartphone, + Mail, + FileText, + CreditCard, + ChevronRight, + User, + Settings, + Shield, + ArrowLeft, + Calendar, + Clock, + Camera, + Save, + RotateCcw, + TrendingUp, +} from "lucide-react"; +import { WechatOutlined } from "@ant-design/icons"; +import { Form, Input as AntInput, Select as AntSelect, DatePicker, ConfigProvider, Modal as AntModal, Upload, message, Button as AntButton, Space } from "antd"; +import { PlusOutlined } from "@ant-design/icons"; +import zhCN from "antd/locale/zh_CN"; +import dayjs from "dayjs"; +import InvoicePage from "../Pages/Account/Invoice"; +import { useAuth } from "../../contexts/AuthContext"; +import { getApiBase } from "../../utils/apiConfig"; +import { logger } from "../../utils/logger"; +import { useProfileEvents } from "../../hooks/useProfileEvents"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { useSubscription } from "../../hooks/useSubscription"; + +/** + * 左侧菜单项组件 + */ +const SideMenuItem = ({ icon, label, isActive, onClick }) => ( + + + {label} + +); + +/** + * 胶囊按钮组件 - 用于标签选择 + */ +const TagButton = ({ label, isSelected, onClick }) => ( + + {label} + +); + +/** + * 市场磁贴组件 - 用于偏好市场多选 + */ +const MarketTile = ({ label, isSelected, onClick }) => ( + + {isSelected && "✓ "}{label} + +); + +/** + * 操作按钮组件 - 金色边框金色文字,紧随内容 + */ +const ActionButtons = ({ onSave, onReset, isLoading, hasChanges }) => ( + + + + +); + +/** + * 会员权益条组件 + */ +const MembershipBar = ({ onNavigate }) => { + const { subscriptionInfo } = useSubscription(); + const { type, days_left } = subscriptionInfo; + + const getMemberText = () => { + if (type === "free") return "基础版"; + if (type === "pro") return "Pro会员"; + return "Max会员"; + }; + + const getMemberIcon = () => { + if (type === "free") return "✨"; + if (type === "pro") return "💎"; + return "👑"; + }; + + const gradientBg = + type === "free" + ? "linear(to-r, gray.700, gray.600)" + : "linear(to-r, #F6E5A3, #D4AF37)"; + + return ( + + + + {getMemberIcon()} + + {getMemberText()} + {type !== "free" && ( + + {" "} + · {days_left}天后到期 + + )} + + + + + + ); +}; + +/** + * 手机号脱敏 - 135****0810 + */ +const maskPhone = (phone) => { + if (!phone || typeof phone !== "string") return "未绑定"; + if (phone.length < 7) return phone; + return phone.substring(0, 3) + "****" + phone.substring(phone.length - 4); +}; + +/** + * 邮箱脱敏 - abc***@xxx.com + */ +const maskEmail = (email) => { + if (!email || typeof email !== "string") return "未绑定"; + const atIndex = email.indexOf("@"); + if (atIndex <= 0) return email; + const localPart = email.substring(0, atIndex); + const domain = email.substring(atIndex); + const visibleChars = Math.min(3, localPart.length); + return localPart.substring(0, visibleChars) + "***" + domain; +}; + +/** + * 账号安全面板(手机/邮箱/微信绑定) + */ +const SecurityPanel = ({ + user, + updateUser, + toast, + isLoading, + setIsLoading, + onPhoneOpen, + onEmailOpen, + cardBg, + borderColor, + headingColor, + textColor, + subTextColor, + profileEvents, +}) => { + return ( + + {/* 手机号绑定 */} + + + + 手机号绑定 + + + + + + + + + {user?.phone ? maskPhone(user.phone) : "未绑定手机号"} + + {user?.phone_confirmed && ( + + 已验证 + + )} + + + 绑定手机号可用于登录和接收重要通知 + + + {user?.phone ? ( + + ) : ( + + )} + + + + + {/* 邮箱绑定 */} + + + + 邮箱设置 + + + + + + + + + {user?.email ? maskEmail(user.email) : "未绑定邮箱"} + + {user?.email_confirmed && ( + + 已验证 + + )} + + + 邮箱用于登录和接收重要通知 + + + + + + + + {/* 微信绑定 */} + + + + 微信绑定 + + + + + + + + + {user?.has_wechat ? "已绑定微信" : "未绑定微信"} + + {user?.has_wechat && ( + + 已绑定 + + )} + + + 绑定微信可使用微信一键登录 + + + {user?.has_wechat ? ( + + ) : ( + + )} + + + + + {/* 账户信息 - 页脚化处理 */} + + + 注册时间: + {user?.created_at + ? new Date(user.created_at).toLocaleDateString("zh-CN") + : "2024/2/1"} + + + 最后活跃: + {user?.last_active_at + ? new Date(user.last_active_at).toLocaleDateString("zh-CN") + : "2025/12/25"} + + + + ); +}; + +/** + * 订阅与发票面板 + */ +const BillingPanel = ({ navigate }) => { + return ( + + {/* 会员权益条 */} + navigate("/home/pages/account/subscription")} + /> + + {/* 直接展示发票组件 */} + + + + + ); +}; + +/** + * 个人资料面板 - 双栏布局优化版 + */ +const ProfilePanel = ({ + user, + profileForm, + setProfileForm, + onSave, + onReset, + isLoading, + hasChanges, + cardBg, + borderColor, + headingColor, + subTextColor, +}) => { + const { subscriptionInfo } = useSubscription(); + const [avatarModalOpen, setAvatarModalOpen] = useState(false); + const [uploadLoading, setUploadLoading] = useState(false); + + // 性别选项 + const genderOptions = [ + { value: "male", label: "男" }, + { value: "female", label: "女" }, + { value: "secret", label: "保密" }, + ]; + + // 省份选项 + const locationOptions = [ + "北京", "上海", "广东", "浙江", "江苏", "四川", "湖北", "湖南", + "山东", "河南", "福建", "陕西", "重庆", "天津", "其他", + ]; + + // 处理头像上传 + const handleAvatarUpload = async (file) => { + const isImage = file.type.startsWith("image/"); + if (!isImage) { + message.error("只能上传图片文件"); + return false; + } + const isLt2M = file.size / 1024 / 1024 < 2; + if (!isLt2M) { + message.error("图片大小不能超过 2MB"); + return false; + } + + setUploadLoading(true); + try { + const formData = new FormData(); + formData.append("avatar", file); + const res = await fetch(getApiBase() + "/api/account/avatar", { + method: "POST", + credentials: "include", + body: formData, + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "上传失败"); + + setProfileForm(prev => ({ ...prev, avatar: data.avatar_url })); + message.success("头像上传成功"); + setAvatarModalOpen(false); + } catch (error) { + message.error(error.message || "上传失败"); + } finally { + setUploadLoading(false); + } + return false; // 阻止默认上传行为 + }; + + // antd 深色主题 token - 更亮的文字颜色 + const darkTheme = { + token: { + colorBgContainer: "#374151", + colorBorder: "#4B5563", + colorText: "#F9FAFB", + colorTextPlaceholder: "#9CA3AF", + colorPrimary: "#D4AF37", + colorBgElevated: "#374151", + borderRadius: 6, + }, + }; + + // 标签文字颜色 - 更亮 + const labelColor = "#E5E7EB"; + + return ( + + + + {/* 头部用户信息卡片 - 紧凑版 */} + + setAvatarModalOpen(true)} + _hover={{ opacity: 0.8 }} + transition="opacity 0.2s" + > + + + + + + + + {user?.nickname || user?.username || "设置昵称"} + + setAvatarModalOpen(true)} + > + 点击头像更换 + + + + + {/* 头像更换弹窗 - 黑金主题 */} + + setAvatarModalOpen(false)} + footer={null} + centered + closeIcon={×} + styles={{ + header: { background: "#0D0D0D", borderBottom: "1px solid #D4AF37", color: "#D4AF37" }, + body: { background: "#0D0D0D", padding: "24px" }, + content: { background: "#0D0D0D", borderRadius: 12, border: "1px solid #D4AF37" }, + }} + > + + {/* 当前头像预览 */} + + + {/* 上传组件 */} + + + + + {uploadLoading ? "上传中..." : "选择图片"} + + + + + + 支持 JPG、PNG 格式,大小不超过 2MB + + + + + + {/* 基本信息 - 双栏布局 */} + + + + 基本信息 + + + +
+ {/* 双栏布局 */} + + {/* 左栏 */} + + {/* 昵称 */} + 昵称} + style={{ marginBottom: 0 }} + > + setProfileForm(prev => ({ ...prev, nickname: e.target.value }))} + placeholder="请输入昵称" + maxLength={20} + /> + + + {/* 性别 */} + + 性别 + + {genderOptions.map((option) => ( + setProfileForm(prev => ({ ...prev, gender: option.value }))} + /> + ))} + + + + + {/* 右栏 */} + + {/* 所在地 */} + 所在地} + style={{ marginBottom: 0 }} + > + setProfileForm(prev => ({ ...prev, location: value }))} + placeholder="请选择所在地" + options={locationOptions.map(loc => ({ value: loc, label: loc }))} + style={{ width: "100%" }} + /> + + + {/* 生日 */} + 生日} + style={{ marginBottom: 0 }} + > + setProfileForm(prev => ({ ...prev, birthday: date ? date.format("YYYY-MM-DD") : "" }))} + placeholder="请选择生日" + style={{ width: "100%" }} + /> + + + + + {/* 底部通栏 - 个人简介 */} + + 个人简介} + style={{ marginBottom: 0 }} + > + setProfileForm(prev => ({ ...prev, bio: e.target.value }))} + placeholder="介绍一下自己..." + rows={4} + maxLength={200} + showCount + /> + + + + {/* 操作按钮 - 在表单内部对齐 */} + + +
+
+
+
+
+ ); +}; + +/** + * 投资画像面板 - 标签选择交互 + */ +const InvestmentPortraitPanel = ({ + investmentForm, + setInvestmentForm, + onSave, + onReset, + isLoading, + hasChanges, + cardBg, + borderColor, + headingColor, + subTextColor, +}) => { + // 交易经验选项 + const experienceOptions = [ + { value: "beginner", label: "新手(<1年)" }, + { value: "intermediate", label: "有一定经验(1-3年)" }, + { value: "expert", label: "资深(3年以上)" }, + ]; + + // 投资风格选项 + const styleOptions = [ + { value: "short", label: "短线" }, + { value: "medium", label: "中长线" }, + { value: "value", label: "价值投资" }, + ]; + + // 风险偏好选项 + const riskOptions = [ + { value: "conservative", label: "保守型" }, + { value: "moderate", label: "稳健型" }, + { value: "aggressive", label: "激进型" }, + ]; + + // 投资金额选项 + const amountOptions = [ + { value: "small", label: "10万以下" }, + { value: "medium", label: "10-50万" }, + { value: "large", label: "50万以上" }, + ]; + + // 偏好市场选项 + const marketOptions = [ + { value: "a_stock", label: "A股" }, + { value: "hk_stock", label: "港股" }, + { value: "us_stock", label: "美股" }, + { value: "crypto", label: "加密货币" }, + { value: "fund", label: "基金" }, + { value: "bond", label: "债券" }, + ]; + + // 处理市场多选 + const toggleMarket = (market) => { + setInvestmentForm(prev => { + const markets = prev.preferredMarkets || []; + const isSelected = markets.includes(market); + return { + ...prev, + preferredMarkets: isSelected + ? markets.filter(m => m !== market) + : [...markets, market], + }; + }); + }; + + return ( + + + + + + + + 投资画像 + + + + 完善投资画像,获取更精准的投资建议 + + + + + {/* 交易经验 */} + + + 交易经验 + + + {experienceOptions.map((option) => ( + setInvestmentForm(prev => ({ ...prev, experience: option.value }))} + /> + ))} + + + + {/* 投资风格 */} + + + 投资风格 + + + {styleOptions.map((option) => ( + setInvestmentForm(prev => ({ ...prev, style: option.value }))} + /> + ))} + + + + {/* 风险偏好 */} + + + 风险偏好 + + + {riskOptions.map((option) => ( + setInvestmentForm(prev => ({ ...prev, riskPreference: option.value }))} + /> + ))} + + + + {/* 投资金额 */} + + + 投资金额 + + + {amountOptions.map((option) => ( + setInvestmentForm(prev => ({ ...prev, investmentAmount: option.value }))} + /> + ))} + + + + {/* 偏好市场 - 多选磁贴 */} + + + 偏好市场(可多选) + + + {marketOptions.map((option) => ( + toggleMarket(option.value)} + /> + ))} + + + + {/* 操作按钮 - 在卡片内部对齐 */} + + + + + + + ); +}; export default function SettingsPage() { - const { user, updateUser } = useAuth(); - const toast = useToast(); - const navigate = useNavigate(); + const { user, updateUser } = useAuth(); + const toast = useToast(); + const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); - // 深色模式固定颜色(Settings 页面始终使用深色主题) - const headingColor = 'white'; - const textColor = 'gray.100'; - const subTextColor = 'gray.400'; - const cardBg = 'gray.800'; - const borderColor = 'gray.600'; + // 当前选中的菜单项 + const currentTab = searchParams.get("tab") || "billing"; - // 🎯 初始化设置页面埋点Hook - const profileEvents = useProfileEvents({ pageType: 'settings' }); + // 深色模式固定颜色 + const headingColor = "white"; + const textColor = "gray.100"; + const subTextColor = "gray.400"; + const cardBg = "gray.800"; + const borderColor = "gray.600"; - // 模态框状态 - const { isOpen: isPhoneOpen, onOpen: onPhoneOpen, onClose: onPhoneClose } = useDisclosure(); - const { isOpen: isEmailOpen, onOpen: onEmailOpen, onClose: onEmailClose } = useDisclosure(); + const profileEvents = useProfileEvents({ pageType: "settings" }); - // 表单状态 - const [isLoading, setIsLoading] = useState(false); - const [phoneForm, setPhoneForm] = useState({ - phone: '', - verificationCode: '' - }); - const [emailForm, setEmailForm] = useState({ - email: '', - verificationCode: '' - }); + // 模态框状态 + const { + isOpen: isPhoneOpen, + onOpen: onPhoneOpen, + onClose: onPhoneClose, + } = useDisclosure(); + const { + isOpen: isEmailOpen, + onOpen: onEmailOpen, + onClose: onEmailClose, + } = useDisclosure(); - // 发送验证码 - const sendVerificationCode = async (type) => { - setIsLoading(true); - try { - if (type === 'phone') { - const url = '/api/account/phone/send-code'; - logger.api.request('POST', url, { phone: phoneForm.phone.substring(0, 3) + '****' }); + // 表单状态 + const [isLoading, setIsLoading] = useState(false); + const [phoneForm, setPhoneForm] = useState({ + phone: "", + verificationCode: "", + }); + const [emailForm, setEmailForm] = useState({ + email: "", + verificationCode: "", + }); - const res = await fetch(getApiBase() + url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ phone: phoneForm.phone }) - }); - const data = await res.json(); - logger.api.response('POST', url, res.status, data); + // 个人资料表单状态 + const [profileForm, setProfileForm] = useState({ + nickname: user?.nickname || "", + gender: user?.gender || "secret", + location: user?.location || "", + birthday: user?.birthday || "", + bio: user?.bio || "", + avatar: user?.avatar || "", + }); - if (!res.ok) throw new Error(data.error || '发送失败'); - } else { - const url = '/api/account/email/send-bind-code'; - logger.api.request('POST', url, { email: emailForm.email.substring(0, 3) + '***@***' }); + // 投资画像表单状态 + const [investmentForm, setInvestmentForm] = useState({ + experience: user?.investment_profile?.experience || "", + style: user?.investment_profile?.style || "", + riskPreference: user?.investment_profile?.risk_preference || "", + investmentAmount: user?.investment_profile?.investment_amount || "", + preferredMarkets: user?.investment_profile?.preferred_markets || [], + }); - // 使用绑定邮箱的验证码API - const res = await fetch(getApiBase() + url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ email: emailForm.email }) - }); - const data = await res.json(); - logger.api.response('POST', url, res.status, data); + // 初始表单状态(用于检测变化和重置) + const [initialProfileForm] = useState(profileForm); + const [initialInvestmentForm] = useState(investmentForm); - if (!res.ok) throw new Error(data.error || '发送失败'); - } + // 检测表单是否有变化 + const hasProfileChanges = JSON.stringify(profileForm) !== JSON.stringify(initialProfileForm); + const hasInvestmentChanges = JSON.stringify(investmentForm) !== JSON.stringify(initialInvestmentForm); - // ❌ 移除验证码发送成功toast - logger.info('SettingsPage', `${type === 'phone' ? '短信' : '邮件'}验证码已发送`); - } catch (error) { - logger.error('SettingsPage', 'sendVerificationCode', error, { type }); + // 保存个人资料 + const handleSaveProfile = async () => { + setIsLoading(true); + try { + const res = await fetch(getApiBase() + "/api/account/profile", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify(profileForm), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "保存失败"); + updateUser(profileForm); + toast({ + title: "个人资料已保存", + status: "success", + duration: 3000, + isClosable: true, + }); + } catch (error) { + toast({ + title: "保存失败", + description: error.message, + status: "error", + duration: 3000, + isClosable: true, + }); + } finally { + setIsLoading(false); + } + }; - toast({ - title: "发送失败", - description: error.message, - status: "error", - duration: 3000, - isClosable: true, - }); - } finally { - setIsLoading(false); - } + // 重置个人资料 + const handleResetProfile = () => { + setProfileForm(initialProfileForm); + }; + + // 保存投资画像 + const handleSaveInvestment = async () => { + setIsLoading(true); + try { + const res = await fetch(getApiBase() + "/api/account/investment-profile", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify(investmentForm), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "保存失败"); + toast({ + title: "投资画像已保存", + status: "success", + duration: 3000, + isClosable: true, + }); + } catch (error) { + toast({ + title: "保存失败", + description: error.message, + status: "error", + duration: 3000, + isClosable: true, + }); + } finally { + setIsLoading(false); + } + }; + + // 重置投资画像 + const handleResetInvestment = () => { + setInvestmentForm(initialInvestmentForm); + }; + + // 切换菜单 + const handleTabChange = (tab) => { + setSearchParams({ tab }); + }; + + // 发送验证码 + const sendVerificationCode = async (type) => { + setIsLoading(true); + try { + if (type === "phone") { + const url = "/api/account/phone/send-code"; + logger.api.request("POST", url, { + phone: phoneForm.phone.substring(0, 3) + "****", + }); + const res = await fetch(getApiBase() + url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ phone: phoneForm.phone }), + }); + const data = await res.json(); + logger.api.response("POST", url, res.status, data); + if (!res.ok) throw new Error(data.error || "发送失败"); + } else { + const url = "/api/account/email/send-bind-code"; + logger.api.request("POST", url, { + email: emailForm.email.substring(0, 3) + "***@***", + }); + const res = await fetch(getApiBase() + url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ email: emailForm.email }), + }); + const data = await res.json(); + logger.api.response("POST", url, res.status, data); + if (!res.ok) throw new Error(data.error || "发送失败"); + } + logger.info( + "SettingsPage", + `${type === "phone" ? "短信" : "邮件"}验证码已发送` + ); + } catch (error) { + logger.error("SettingsPage", "sendVerificationCode", error, { type }); + toast({ + title: "发送失败", + description: error.message, + status: "error", + duration: 3000, + isClosable: true, + }); + } finally { + setIsLoading(false); + } + }; + + // 绑定手机号 + const handlePhoneBind = async () => { + setIsLoading(true); + try { + const res = await fetch(getApiBase() + "/api/account/phone/bind", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + phone: phoneForm.phone, + code: phoneForm.verificationCode, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "绑定失败"); + updateUser({ phone: phoneForm.phone, phone_confirmed: true }); + toast({ + title: "手机号绑定成功", + status: "success", + duration: 3000, + isClosable: true, + }); + setPhoneForm({ phone: "", verificationCode: "" }); + onPhoneClose(); + } catch (error) { + toast({ + title: "绑定失败", + description: error.message, + status: "error", + duration: 3000, + isClosable: true, + }); + } finally { + setIsLoading(false); + } + }; + + // 更换邮箱 + const handleEmailBind = async () => { + setIsLoading(true); + try { + const res = await fetch(getApiBase() + "/api/account/email/bind", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + email: emailForm.email, + code: emailForm.verificationCode, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "绑定失败"); + updateUser({ + email: data.user.email, + email_confirmed: data.user.email_confirmed, + }); + profileEvents.trackAccountBound("email", true); + toast({ + title: "邮箱绑定成功", + status: "success", + duration: 3000, + isClosable: true, + }); + setEmailForm({ email: "", verificationCode: "" }); + onEmailClose(); + } catch (error) { + profileEvents.trackAccountBound("email", false); + toast({ + title: "绑定失败", + description: error.message, + status: "error", + duration: 3000, + isClosable: true, + }); + } finally { + setIsLoading(false); + } + }; + + // 菜单配置 + const menuItems = [ + { id: "profile", label: "个人资料", icon: User }, + { id: "preference", label: "投资画像", icon: TrendingUp }, + { id: "security", label: "账号安全", icon: Shield }, + { id: "billing", label: "订阅与发票", icon: CreditCard }, + ]; + + // 渲染右侧内容 + const renderContent = () => { + const commonProps = { + cardBg, + borderColor, + headingColor, + textColor, + subTextColor, }; - // 绑定手机号 - const handlePhoneBind = async () => { - setIsLoading(true); - try { - const res = await fetch(getApiBase() + '/api/account/phone/bind', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ phone: phoneForm.phone, code: phoneForm.verificationCode }) - }); - const data = await res.json(); - if (!res.ok) throw new Error(data.error || '绑定失败'); + switch (currentTab) { + case "profile": + return ( + + ); + case "preference": + return ( + + ); + case "security": + return ( + + ); + case "billing": + default: + return ; + } + }; - updateUser({ - phone: phoneForm.phone, - phone_confirmed: true - }); + return ( + + {/* 页面标题 */} + + 账户设置 + - toast({ - title: "手机号绑定成功", - status: "success", - duration: 3000, - isClosable: true, - }); + + {/* 左侧菜单 */} + + {menuItems.map((item) => ( + handleTabChange(item.id)} + /> + ))} + - setPhoneForm({ phone: '', verificationCode: '' }); - onPhoneClose(); - } catch (error) { - toast({ - title: "绑定失败", - description: error.message, - status: "error", - duration: 3000, - isClosable: true, - }); - } finally { - setIsLoading(false); - } - }; + {/* 右侧内容区 */} + {renderContent()} + - // 更换邮箱 - const handleEmailBind = async () => { - setIsLoading(true); - try { - // 调用真实的邮箱绑定API - const res = await fetch(getApiBase() + '/api/account/email/bind', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ - email: emailForm.email, - code: emailForm.verificationCode - }) - }); + {/* 绑定手机号模态框 - antd 黑金主题 */} + + ×} + footer={ + + + 取消 + + + 确认绑定 + + + } + styles={{ + header: { background: "#0D0D0D", borderBottom: "1px solid #D4AF37", paddingBottom: 16, color: "#D4AF37" }, + body: { background: "#0D0D0D", padding: "24px 24px 16px" }, + content: { background: "#0D0D0D", borderRadius: 12, border: "1px solid #D4AF37" }, + footer: { background: "#0D0D0D", borderTop: "1px solid #333", paddingTop: 16 }, + }} + > +
+ 手机号}> + setPhoneForm((prev) => ({ ...prev, phone: e.target.value }))} + placeholder="请输入11位手机号" + maxLength={11} + /> + + 验证码}> + + setPhoneForm((prev) => ({ ...prev, verificationCode: value }))} + /> + sendVerificationCode("phone")} + loading={isLoading} + > + 发送验证码 + + + + +
- const data = await res.json(); - if (!res.ok) { - throw new Error(data.error || '绑定失败'); - } - - // 更新用户信息 - updateUser({ - email: data.user.email, - email_confirmed: data.user.email_confirmed - }); - - // 🎯 追踪邮箱绑定成功 - profileEvents.trackAccountBound('email', true); - - toast({ - title: "邮箱绑定成功", - status: "success", - duration: 3000, - isClosable: true, - }); - - setEmailForm({ email: '', verificationCode: '' }); - onEmailClose(); - } catch (error) { - // 🎯 追踪邮箱绑定失败 - profileEvents.trackAccountBound('email', false); - - toast({ - title: "绑定失败", - description: error.message, - status: "error", - duration: 3000, - isClosable: true, - }); - } finally { - setIsLoading(false); - } - }; - - return ( - - - {/* 页面标题 */} - 账户设置 - - - - 账户绑定 - 账单与发票 - - - - {/* 账户绑定 */} - - - {/* 手机号绑定 */} - - - 手机号绑定 - - - - - - - - {user?.phone || '未绑定手机号'} - - {user?.phone_confirmed && ( - 已验证 - )} - - - 绑定手机号可用于登录和接收重要通知 - - - {user?.phone ? ( - - ) : ( - - )} - - - - - {/* 邮箱绑定 */} - - - 邮箱设置 - - - - - - - {user?.email} - {user?.email_confirmed && ( - 已验证 - )} - - - 邮箱用于登录和接收重要通知 - - - - - - - - {/* 微信绑定 */} - - - 微信绑定 - - - - - - - - {user?.has_wechat ? '已绑定微信' : '未绑定微信'} - - {user?.has_wechat && ( - 已绑定 - )} - - - 绑定微信可使用微信一键登录 - - - {user?.has_wechat ? ( - - ) : ( - - )} - - - - - - - - {/* 账单与发票 */} - - - {/* 订阅管理 */} - - - 订阅管理 - - - - - - - - {user?.subscription_type === 'max' ? 'Max 会员' : - user?.subscription_type === 'pro' ? 'Pro 会员' : '免费版'} - - {user?.subscription_type && user?.subscription_type !== 'free' && ( - 有效 - )} - - - 管理您的会员订阅和续费 - - - - - - - - {/* 发票管理 */} - - - 发票管理 - - - - - - - - 电子发票申请与下载 - - - - 已支付的订单可申请开具电子发票 - - - - - - - - - - - - - {/* 绑定手机号模态框 */} - - - - 绑定手机号 - - - - - 手机号 - setPhoneForm(prev => ({ - ...prev, - phone: e.target.value - }))} - placeholder="请输入11位手机号" - /> - - - - 验证码 - - - setPhoneForm(prev => ({ - ...prev, - verificationCode: value - }))} - > - - - - - - - - - - - - - - - - - - - - - {/* 更换邮箱模态框 */} - - - - 更换邮箱 - - - - - 新邮箱 - setEmailForm(prev => ({ - ...prev, - email: e.target.value - }))} - placeholder="请输入新邮箱地址" - /> - - - - 验证码 - - - setEmailForm(prev => ({ - ...prev, - verificationCode: value - }))} - > - - - - - - - - - - - - - - - - - - - - - - ); -} \ No newline at end of file + {/* 更换邮箱模态框 - antd 黑金主题 */} + ×} + footer={ + + + 取消 + + + 确认更换 + + + } + styles={{ + header: { background: "#0D0D0D", borderBottom: "1px solid #D4AF37", paddingBottom: 16, color: "#D4AF37" }, + body: { background: "#0D0D0D", padding: "24px 24px 16px" }, + content: { background: "#0D0D0D", borderRadius: 12, border: "1px solid #D4AF37" }, + footer: { background: "#0D0D0D", borderTop: "1px solid #333", paddingTop: 16 }, + }} + > +
+ 新邮箱}> + setEmailForm((prev) => ({ ...prev, email: e.target.value }))} + placeholder="请输入新邮箱地址" + /> + + 验证码}> + + setEmailForm((prev) => ({ ...prev, verificationCode: value }))} + /> + sendVerificationCode("email")} + loading={isLoading} + > + 发送验证码 + + + + +
+
+
+ ); +} From 1d1e25530f3b9e6c8a2d3be690483dc7099e0477 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Tue, 13 Jan 2026 15:50:50 +0800 Subject: [PATCH 09/22] =?UTF-8?q?chore(mocks):=20=E6=96=B0=E5=A2=9E=202026?= =?UTF-8?q?=E5=B9=B41=E6=9C=88=E4=BA=8B=E4=BB=B6=20mock=20=E6=95=B0?= =?UTF-8?q?=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/mocks/data/account.js | 115 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/src/mocks/data/account.js b/src/mocks/data/account.js index 0c53c2db..9b687d25 100644 --- a/src/mocks/data/account.js +++ b/src/mocks/data/account.js @@ -1105,6 +1105,121 @@ export const mockFutureEvents = [ ], concepts: ['新能源', '动力电池', '储能'], is_following: false + }, + // ==================== 2026年1月事件数据 ==================== + { + id: 601, + data_id: 601, + title: '特斯拉Q4财报发布', + calendar_time: '2026-01-15T21:00:00Z', + type: 'data', + star: 5, + former: { + data: [ + { + author: '特斯拉投资者关系', + sentences: '特斯拉将发布2025年第四季度及全年财务报告,市场关注其全球交付量、毛利率表现以及2026年产能扩张计划', + query_part: '特斯拉将发布2025年第四季度财报', + report_title: 'Q4 2025 Earnings Release', + declare_date: '2026-01-10T00:00:00', + match_score: '好' + } + ] + }, + forecast: '预计营收超过260亿美元,全年交付量超200万辆', + fact: null, + related_stocks: [ + ['002594', '比亚迪', { data: [{ author: '中信证券', sentences: '作为特斯拉主要竞争对手,比亚迪股价与特斯拉财报关联度高', query_part: '比亚迪与特斯拉竞争格局', report_title: '新能源汽车竞争分析', declare_date: '2026-01-12T00:00:00', match_score: '好' }] }, 0.85], + ['300750', '宁德时代', { data: [{ author: '招商证券', sentences: '作为特斯拉动力电池供应商,宁德时代业绩与特斯拉销量高度相关', query_part: '宁德时代与特斯拉供应链关系', report_title: '动力电池产业链研究', declare_date: '2026-01-11T00:00:00', match_score: '好' }] }, 0.80] + ], + concepts: ['新能源汽车', '特斯拉', '电动车'], + is_following: false + }, + { + id: 602, + data_id: 602, + title: '央行MLF操作', + calendar_time: '2026-01-15T09:30:00Z', + type: 'event', + star: 4, + former: '央行将开展中期借贷便利(MLF)操作,市场关注操作规模和利率变动。本次MLF到期规模约5000亿元,预计央行将等量或超量续作以维护流动性合理充裕。\n\n(AI合成)', + forecast: '预计MLF利率维持2.5%不变', + fact: null, + related_stocks: [ + ['601398', '工商银行', { data: [{ author: '国泰君安', sentences: 'MLF利率影响银行负债成本和净息差表现', query_part: 'MLF对银行净息差影响', report_title: '货币政策对银行业影响', declare_date: '2026-01-14T00:00:00', match_score: '好' }] }, 0.75] + ], + concepts: ['货币政策', 'MLF', '央行'], + is_following: true + }, + { + id: 603, + data_id: 603, + title: '英伟达CES 2026新品发布', + calendar_time: '2026-01-14T10:00:00Z', + type: 'event', + star: 5, + former: { + data: [ + { + author: '英伟达官方', + sentences: '英伟达CEO黄仁勋将在CES 2026发表主题演讲,预计发布新一代RTX 50系列显卡和AI芯片产品线更新', + query_part: '英伟达CES 2026发布会', + report_title: 'CES 2026 Keynote Preview', + declare_date: '2026-01-08T00:00:00', + match_score: '好' + } + ] + }, + forecast: '新一代AI芯片性能预计提升2-3倍', + fact: null, + related_stocks: [ + ['603986', '兆易创新', { data: [{ author: '华泰证券', sentences: '英伟达新品发布带动国产芯片概念关注度提升', query_part: '国产芯片替代机遇', report_title: '半导体产业链研究', declare_date: '2026-01-10T00:00:00', match_score: '好' }] }, 0.78], + ['002049', '紫光国微', { data: [{ author: '中金公司', sentences: '作为国产芯片龙头,紫光国微受益于AI芯片需求增长', query_part: '国产芯片龙头分析', report_title: 'AI芯片产业链报告', declare_date: '2026-01-09T00:00:00', match_score: '好' }] }, 0.75] + ], + concepts: ['人工智能', 'AI芯片', '半导体'], + is_following: false + }, + { + id: 604, + data_id: 604, + title: '12月CPI/PPI数据公布', + calendar_time: '2026-01-13T09:30:00Z', + type: 'data', + star: 4, + former: '国家统计局将公布2025年12月居民消费价格指数(CPI)和工业生产者出厂价格指数(PPI)数据,市场预期CPI同比上涨0.3%,PPI同比下降2.5%。\n\n【详细背景分析】\n\n一、CPI数据展望\n\n1. 食品价格方面:受季节性因素影响,12月食品价格预计环比上涨。猪肉价格在供给偏紧的情况下维持高位运行,鲜菜价格受寒潮天气影响有所上涨,鲜果价格保持稳定。预计食品价格环比上涨0.8%左右。\n\n2. 非食品价格方面:能源价格受国际油价波动影响,12月成品油价格有所调整。服务价格保持平稳,教育文化娱乐、医疗保健等服务价格环比持平。预计非食品价格环比微降0.1%。\n\n3. 核心CPI方面:剔除食品和能源价格的核心CPI预计同比上涨0.6%,反映出内需恢复仍在进行中,但力度有待加强。\n\n二、PPI数据展望\n\n1. 生产资料价格:受全球大宗商品价格波动影响,12月生产资料价格预计环比下降。其中,采掘工业、原材料工业价格降幅收窄,加工工业价格基本持平。\n\n2. 生活资料价格:食品类价格小幅上涨,衣着类价格保持稳定,一般日用品类价格持平,耐用消费品类价格略有下降。\n\n3. 主要行业分析:\n - 石油和天然气开采业:受国际油价影响,价格环比下降约2%\n - 黑色金属冶炼:钢材价格震荡运行,环比微降0.5%\n - 有色金属冶炼:铜、铝等价格受供需影响有所波动\n - 化学原料制造:基础化工品价格整体平稳\n\n三、对市场的影响分析\n\n1. 货币政策方面:当前通胀压力不大,为货币政策提供了较大的操作空间。预计央行将继续保持流动性合理充裕,适时运用降准、降息等工具支持实体经济发展。\n\n2. 股市方面:\n - 消费板块:CPI温和上涨利好食品饮料、农业等消费板块\n - 周期板块:PPI降幅收窄显示工业品需求有所改善,利好有色、钢铁等周期板块\n - 金融板块:货币政策宽松预期利好银行、保险等金融板块\n\n3. 债市方面:通胀压力可控,经济复苏温和,利率债配置价值凸显。\n\n四、历史数据回顾\n\n2025年全年CPI走势:\n- 1月:0.8%(春节因素)\n- 2月:0.1%\n- 3月:0.2%\n- 4月:0.3%\n- 5月:0.3%\n- 6月:0.2%\n- 7月:0.1%\n- 8月:0.2%\n- 9月:0.3%\n- 10月:0.2%\n- 11月:0.3%\n\n2025年全年PPI走势:\n- 1月:-2.8%\n- 2月:-2.6%\n- 3月:-2.5%\n- 4月:-2.7%\n- 5月:-2.9%\n- 6月:-3.0%\n- 7月:-2.8%\n- 8月:-2.7%\n- 9月:-2.6%\n- 10月:-2.5%\n- 11月:-2.4%\n\n五、投资策略建议\n\n1. 短期策略:关注数据公布后的市场反应,若数据好于预期,可适当增配周期板块;若数据不及预期,可关注防御性板块。\n\n2. 中期策略:在通胀温和、流动性充裕的环境下,建议均衡配置成长股和价值股,重点关注科技创新、消费升级、绿色转型等方向。\n\n3. 风险提示:需关注全球经济形势变化、地缘政治风险、国内政策调整等因素对市场的影响。\n\n(AI合成)', + forecast: 'CPI温和上涨,PPI降幅收窄', + fact: null, + related_stocks: [], + concepts: ['宏观经济', 'CPI', 'PPI'], + is_following: false + }, + { + id: 605, + data_id: 605, + title: '苹果Vision Pro 2发布会', + calendar_time: '2026-01-16T02:00:00Z', + type: 'event', + star: 5, + former: { + data: [ + { + author: '苹果公司', + sentences: '苹果将举办特别活动,预计发布第二代Vision Pro头显设备,新产品将在性能、重量和价格方面实现重大突破', + query_part: '苹果Vision Pro 2发布', + report_title: 'Apple Special Event', + declare_date: '2026-01-12T00:00:00', + match_score: '好' + } + ] + }, + forecast: '售价预计下调30%,重量减轻40%', + fact: null, + related_stocks: [ + ['002475', '立讯精密', { data: [{ author: '天风证券', sentences: '立讯精密是苹果Vision Pro核心代工厂,新品发布将带动订单增长', query_part: '立讯精密Vision Pro代工', report_title: '消费电子产业链研究', declare_date: '2026-01-13T00:00:00', match_score: '好' }] }, 0.88], + ['002241', '歌尔股份', { data: [{ author: '国盛证券', sentences: '歌尔股份在VR/AR光学模组领域具有领先地位', query_part: '歌尔股份VR光学布局', report_title: 'XR产业链深度报告', declare_date: '2026-01-12T00:00:00', match_score: '好' }] }, 0.82] + ], + concepts: ['苹果', 'VR/AR', '消费电子'], + is_following: true } ]; From 9c1ec403f0ddef4634c9ccff497109b9ebc4a1fb Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Tue, 13 Jan 2026 15:51:13 +0800 Subject: [PATCH 10/22] =?UTF-8?q?style(community):=20DynamicNewsCard=20?= =?UTF-8?q?=E6=A0=87=E9=A2=98=E6=A0=B7=E5=BC=8F=E5=BE=AE=E8=B0=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 Heading 组件,简化为 HStack + Text - 调整图标和文字大小 --- .../components/DynamicNews/DynamicNewsCard.js | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/views/Community/components/DynamicNews/DynamicNewsCard.js b/src/views/Community/components/DynamicNews/DynamicNewsCard.js index c5b9a8e2..d0ffb091 100644 --- a/src/views/Community/components/DynamicNews/DynamicNewsCard.js +++ b/src/views/Community/components/DynamicNews/DynamicNewsCard.js @@ -641,12 +641,10 @@ const [currentMode, setCurrentMode] = useState('vertical'); {/* 左侧:标题 + 模式切换按钮 */} - - - - 实时要闻·动态追踪 - - + + + 实时要闻·动态追踪 + {/* 模式切换按钮(移动端隐藏) */} {!isMobile && } From e070df5d62aba958ea81d619107d8508de4f9d9a Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Tue, 13 Jan 2026 19:00:42 +0800 Subject: [PATCH 11/22] =?UTF-8?q?refactor(HeroPanel):=20=E6=8F=90=E5=8F=96?= =?UTF-8?q?=E8=A1=A8=E6=A0=BC=E5=88=97=E5=AE=9A=E4=B9=89=E5=88=B0=20column?= =?UTF-8?q?s=20=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 renderers.js 通用渲染函数 - 新增 stockColumns.js 事件关联股票列 - 新增 sectorColumns.js 涨停板块列 - 新增 ztStockColumns.js 涨停个股列 - 新增 eventColumns.js 未来事件列 - 使用工厂函数模式,支持 useMemo 缓存 🤖 Generated with Claude Code Co-Authored-By: Claude --- .../HeroPanel/columns/eventColumns.js | 132 +++++++ .../components/HeroPanel/columns/index.js | 6 + .../components/HeroPanel/columns/renderers.js | 184 +++++++++ .../HeroPanel/columns/sectorColumns.js | 368 ++++++++++++++++++ .../HeroPanel/columns/stockColumns.js | 233 +++++++++++ .../HeroPanel/columns/ztStockColumns.js | 284 ++++++++++++++ 6 files changed, 1207 insertions(+) create mode 100644 src/views/Community/components/HeroPanel/columns/eventColumns.js create mode 100644 src/views/Community/components/HeroPanel/columns/index.js create mode 100644 src/views/Community/components/HeroPanel/columns/renderers.js create mode 100644 src/views/Community/components/HeroPanel/columns/sectorColumns.js create mode 100644 src/views/Community/components/HeroPanel/columns/stockColumns.js create mode 100644 src/views/Community/components/HeroPanel/columns/ztStockColumns.js diff --git a/src/views/Community/components/HeroPanel/columns/eventColumns.js b/src/views/Community/components/HeroPanel/columns/eventColumns.js new file mode 100644 index 00000000..d4cd78e9 --- /dev/null +++ b/src/views/Community/components/HeroPanel/columns/eventColumns.js @@ -0,0 +1,132 @@ +// 事件表格列定义 +// 用于未来事件 Tab + +import React from "react"; +import { Button, Space, Tooltip, Typography } from "antd"; +import { + ClockCircleOutlined, + LinkOutlined, + RobotOutlined, + StockOutlined, +} from "@ant-design/icons"; +import { StarFilled } from "@ant-design/icons"; +import dayjs from "dayjs"; + +const { Text: AntText } = Typography; + +// 渲染星级评分 +const renderStars = (star) => { + const level = parseInt(star, 10) || 0; + return ( + + {[1, 2, 3, 4, 5].map((i) => ( + + ))} + + ); +}; + +/** + * 创建事件表格列 + * @param {Object} options - 配置选项 + * @param {Function} options.showContentDetail - 显示内容详情 + * @param {Function} options.showRelatedStocks - 显示相关股票 + */ +export const createEventColumns = ({ showContentDetail, showRelatedStocks }) => [ + { + title: "时间", + dataIndex: "calendar_time", + key: "time", + width: 80, + render: (time) => ( + + + {dayjs(time).format("HH:mm")} + + ), + }, + { + title: "重要度", + dataIndex: "star", + key: "star", + width: 120, + render: renderStars, + }, + { + title: "标题", + dataIndex: "title", + key: "title", + ellipsis: true, + render: (text) => ( + + + {text} + + + ), + }, + { + title: "背景", + dataIndex: "former", + key: "former", + width: 80, + render: (text) => ( + + ), + }, + { + title: "未来推演", + dataIndex: "forecast", + key: "forecast", + width: 90, + render: (text) => ( + + ), + }, + { + title: "相关股票", + dataIndex: "related_stocks", + key: "stocks", + width: 120, + render: (stocks, record) => { + const hasStocks = stocks && stocks.length > 0; + if (!hasStocks) { + return ; + } + return ( + + ); + }, + }, +]; diff --git a/src/views/Community/components/HeroPanel/columns/index.js b/src/views/Community/components/HeroPanel/columns/index.js new file mode 100644 index 00000000..570ce704 --- /dev/null +++ b/src/views/Community/components/HeroPanel/columns/index.js @@ -0,0 +1,6 @@ +// HeroPanel 表格列相关导出 +export * from './renderers'; +export { createStockColumns } from './stockColumns'; +export { createSectorColumns } from './sectorColumns'; +export { createZtStockColumns } from './ztStockColumns'; +export { createEventColumns } from './eventColumns'; diff --git a/src/views/Community/components/HeroPanel/columns/renderers.js b/src/views/Community/components/HeroPanel/columns/renderers.js new file mode 100644 index 00000000..0292eddf --- /dev/null +++ b/src/views/Community/components/HeroPanel/columns/renderers.js @@ -0,0 +1,184 @@ +// HeroPanel 表格列渲染器 +import { Tag, Space, Button, Typography, Tooltip } from "antd"; +import { + ClockCircleOutlined, + LinkOutlined, + RobotOutlined, + StockOutlined, + StarFilled, + StarOutlined, + LineChartOutlined, +} from "@ant-design/icons"; +import dayjs from "dayjs"; + +const { Text: AntText } = Typography; + +/** + * 渲染星级评分 + */ +export const renderStars = (star) => { + const level = parseInt(star, 10) || 0; + return ( + + {[1, 2, 3, 4, 5].map((i) => ( + + ★ + + ))} + + ); +}; + +/** + * 渲染涨跌幅 + */ +export const renderChangePercent = (val) => { + if (val === null || val === undefined) return "-"; + const num = parseFloat(val); + const color = num > 0 ? "#ff4d4f" : num < 0 ? "#52c41a" : "#888"; + const prefix = num > 0 ? "+" : ""; + return ( + + {prefix}{num.toFixed(2)}% + + ); +}; + +/** + * 渲染时间 + */ +export const renderTime = (time) => ( + + + {dayjs(time).format("HH:mm")} + +); + +/** + * 渲染标题(带tooltip) + */ +export const renderTitle = (text) => ( + + + {text} + + +); + +/** + * 渲染排名样式 + */ +export const getRankStyle = (index) => { + if (index === 0) { + return { + background: "linear-gradient(135deg, #FFD700 0%, #FFA500 100%)", + color: "#000", + }; + } + if (index === 1) { + return { + background: "linear-gradient(135deg, #C0C0C0 0%, #A9A9A9 100%)", + color: "#000", + }; + } + if (index === 2) { + return { + background: "linear-gradient(135deg, #CD7F32 0%, #8B4513 100%)", + color: "#fff", + }; + } + return { + background: "rgba(255, 255, 255, 0.08)", + color: "#888", + }; +}; + +/** + * 渲染排名徽章 + */ +export const renderRankBadge = (index) => { + const style = getRankStyle(index); + return ( +
+ {index + 1} +
+ ); +}; + +/** + * 创建查看按钮渲染器 + */ +export const createViewButtonRenderer = (onClick, iconType = "link") => { + const icons = { + link: , + robot: , + stock: , + chart: , + }; + + return (text, record) => ( + + ); +}; + +/** + * 创建自选按钮渲染器 + */ +export const createWatchlistButtonRenderer = (isInWatchlist, onAdd) => { + return (_, record) => { + const inWatchlist = isInWatchlist(record.code); + return ( + + ); + }; +}; + +/** + * 创建K线按钮渲染器 + */ +export const createKlineButtonRenderer = (showKline) => { + return (_, record) => ( + + ); +}; diff --git a/src/views/Community/components/HeroPanel/columns/sectorColumns.js b/src/views/Community/components/HeroPanel/columns/sectorColumns.js new file mode 100644 index 00000000..b4995731 --- /dev/null +++ b/src/views/Community/components/HeroPanel/columns/sectorColumns.js @@ -0,0 +1,368 @@ +// 涨停板块表格列定义 +// 用于涨停分析板块视图 + +import React from "react"; +import { Tag, Button, Tooltip, Typography } from "antd"; +import { FireOutlined } from "@ant-design/icons"; +import { Box, HStack, VStack } from "@chakra-ui/react"; +import { FileText } from "lucide-react"; + +const { Text: AntText } = Typography; + +// 获取排名样式 +const getRankStyle = (index) => { + if (index === 0) { + return { + background: "linear-gradient(135deg, #FFD700 0%, #FFA500 100%)", + color: "#000", + fontWeight: "bold", + }; + } + if (index === 1) { + return { + background: "linear-gradient(135deg, #C0C0C0 0%, #A8A8A8 100%)", + color: "#000", + fontWeight: "bold", + }; + } + if (index === 2) { + return { + background: "linear-gradient(135deg, #CD7F32 0%, #A0522D 100%)", + color: "#fff", + fontWeight: "bold", + }; + } + return { background: "rgba(255,255,255,0.1)", color: "#888" }; +}; + +// 获取涨停数颜色 +const getCountColor = (count) => { + if (count >= 8) return { bg: "#ff4d4f", text: "#fff" }; + if (count >= 5) return { bg: "#fa541c", text: "#fff" }; + if (count >= 3) return { bg: "#fa8c16", text: "#fff" }; + return { bg: "rgba(255,215,0,0.2)", text: "#FFD700" }; +}; + +// 获取相关度颜色 +const getRelevanceColor = (score) => { + if (score >= 80) return "#10B981"; + if (score >= 60) return "#F59E0B"; + return "#6B7280"; +}; + +/** + * 创建涨停板块表格列 + * @param {Object} options - 配置选项 + * @param {Array} options.stockList - 股票列表数据 + * @param {Function} options.setSelectedSectorInfo - 设置选中板块信息 + * @param {Function} options.setSectorStocksModalVisible - 设置板块股票弹窗可见性 + * @param {Function} options.setSelectedRelatedEvents - 设置关联事件 + * @param {Function} options.setRelatedEventsModalVisible - 设置关联事件弹窗可见性 + */ +export const createSectorColumns = ({ + stockList, + setSelectedSectorInfo, + setSectorStocksModalVisible, + setSelectedRelatedEvents, + setRelatedEventsModalVisible, +}) => [ + { + title: "排名", + key: "rank", + width: 60, + align: "center", + render: (_, __, index) => { + const style = getRankStyle(index); + return ( +
+ {index + 1} +
+ ); + }, + }, + { + title: "板块名称", + dataIndex: "name", + key: "name", + width: 130, + render: (name, record, index) => ( + + + + {name} + + + ), + }, + { + title: "涨停数", + dataIndex: "count", + key: "count", + width: 90, + align: "center", + render: (count) => { + const colors = getCountColor(count); + return ( + + + + + {count} + + + + ); + }, + }, + { + title: "涨停股票", + dataIndex: "stocks", + key: "stocks", + render: (stocks, record) => { + // 根据股票代码查找股票详情,并按连板天数排序 + const getStockInfoList = () => { + return stocks + .map((code) => { + const stockInfo = stockList.find((s) => s.scode === code); + return stockInfo || { sname: code, scode: code, _continuousDays: 1 }; + }) + .sort((a, b) => (b._continuousDays || 1) - (a._continuousDays || 1)); + }; + + const stockInfoList = getStockInfoList(); + const displayStocks = stockInfoList.slice(0, 4); + + const handleShowAll = (e) => { + e.stopPropagation(); + setSelectedSectorInfo({ + name: record.name, + count: record.count, + stocks: stockInfoList, + }); + setSectorStocksModalVisible(true); + }; + + return ( + + {displayStocks.map((info) => ( + +
+ {info.sname} +
+
+ {info.scode} +
+ {info.continuous_days && ( +
+ {info.continuous_days} +
+ )} +
+ } + placement="top" + > + = 3 + ? "rgba(255, 77, 79, 0.2)" + : info._continuousDays >= 2 + ? "rgba(250, 140, 22, 0.2)" + : "rgba(59, 130, 246, 0.15)", + border: + info._continuousDays >= 3 + ? "1px solid rgba(255, 77, 79, 0.4)" + : info._continuousDays >= 2 + ? "1px solid rgba(250, 140, 22, 0.4)" + : "1px solid rgba(59, 130, 246, 0.3)", + borderRadius: "6px", + }} + > + = 3 + ? "#ff4d4f" + : info._continuousDays >= 2 + ? "#fa8c16" + : "#60A5FA", + fontSize: "13px", + }} + > + {info.sname} + {info._continuousDays > 1 && ( + + ({info._continuousDays}板) + + )} + + + + ))} + {stocks.length > 4 && ( + + )} + + ); + }, + }, + { + title: "涨停归因", + dataIndex: "related_events", + key: "related_events", + width: 280, + render: (events, record) => { + if (!events || events.length === 0) { + return ( + - + ); + } + + // 取相关度最高的事件 + const sortedEvents = [...events].sort( + (a, b) => (b.relevance_score || 0) - (a.relevance_score || 0) + ); + const topEvent = sortedEvents[0]; + + // 点击打开事件详情弹窗 + const handleClick = (e) => { + e.stopPropagation(); + setSelectedRelatedEvents({ + sectorName: record.name, + events: sortedEvents, + count: record.count, + }); + setRelatedEventsModalVisible(true); + }; + + return ( + + + + + + + {topEvent.title} + + + + 相关度 {topEvent.relevance_score || 0} + + {events.length > 1 && ( + + +{events.length - 1}条 + + )} + + + + + + ); + }, + }, +]; diff --git a/src/views/Community/components/HeroPanel/columns/stockColumns.js b/src/views/Community/components/HeroPanel/columns/stockColumns.js new file mode 100644 index 00000000..a3031782 --- /dev/null +++ b/src/views/Community/components/HeroPanel/columns/stockColumns.js @@ -0,0 +1,233 @@ +// 相关股票表格列定义 +// 用于事件关联股票弹窗 + +import React from "react"; +import { Tag, Button, Tooltip, Typography } from "antd"; +import { StarFilled, StarOutlined, LineChartOutlined } from "@ant-design/icons"; +import dayjs from "dayjs"; +import { getSixDigitCode } from "../utils"; + +const { Text: AntText } = Typography; + +/** + * 创建相关股票表格列 + * @param {Object} options - 配置选项 + * @param {Object} options.stockQuotes - 股票行情数据 + * @param {Object} options.expandedReasons - 展开状态 + * @param {Function} options.setExpandedReasons - 设置展开状态 + * @param {Function} options.showKline - 显示K线 + * @param {Function} options.isStockInWatchlist - 检查是否在自选 + * @param {Function} options.addSingleToWatchlist - 添加到自选 + */ +export const createStockColumns = ({ + stockQuotes, + expandedReasons, + setExpandedReasons, + showKline, + isStockInWatchlist, + addSingleToWatchlist, +}) => [ + { + title: "代码", + dataIndex: "code", + key: "code", + width: 90, + render: (code) => { + const sixDigitCode = getSixDigitCode(code); + return ( + + {sixDigitCode} + + ); + }, + }, + { + title: "名称", + dataIndex: "name", + key: "name", + width: 100, + render: (name, record) => { + const sixDigitCode = getSixDigitCode(record.code); + return ( + + {name} + + ); + }, + }, + { + title: "现价", + key: "price", + width: 80, + render: (_, record) => { + const quote = stockQuotes[record.code]; + if (quote && quote.price !== undefined) { + return ( + 0 ? "danger" : "success"}> + {quote.price?.toFixed(2)} + + ); + } + return -; + }, + }, + { + title: "涨跌幅", + key: "change", + width: 100, + render: (_, record) => { + const quote = stockQuotes[record.code]; + if (quote && quote.changePercent !== undefined) { + const changePercent = quote.changePercent || 0; + return ( + 0 + ? "red" + : changePercent < 0 + ? "green" + : "default" + } + > + {changePercent > 0 ? "+" : ""} + {changePercent.toFixed(2)}% + + ); + } + return -; + }, + }, + { + title: "关联理由", + dataIndex: "description", + key: "reason", + render: (description, record) => { + const stockCode = record.code; + const isExpanded = expandedReasons[stockCode] || false; + const reason = typeof description === "string" ? description : ""; + const shouldTruncate = reason && reason.length > 80; + + const toggleExpanded = () => { + setExpandedReasons((prev) => ({ + ...prev, + [stockCode]: !prev[stockCode], + })); + }; + + return ( +
+ + {isExpanded || !shouldTruncate + ? reason || "-" + : `${reason?.slice(0, 80)}...`} + + {shouldTruncate && ( + + )} + {reason && ( +
+ + (AI合成) + +
+ )} +
+ ); + }, + }, + { + title: "研报引用", + dataIndex: "report", + key: "report", + width: 180, + render: (report) => { + if (!report || !report.title) { + return -; + } + return ( +
+ +
+ + {report.title.length > 18 + ? `${report.title.slice(0, 18)}...` + : report.title} + + {report.author && ( + + {report.author} + + )} + {report.declare_date && ( + + {dayjs(report.declare_date).format("YYYY-MM-DD")} + + )} + {report.match_score && ( + + 匹配度: {report.match_score} + + )} +
+
+
+ ); + }, + }, + { + title: "K线图", + key: "kline", + width: 80, + render: (_, record) => ( + + ), + }, + { + title: "操作", + key: "action", + width: 90, + render: (_, record) => { + const inWatchlist = isStockInWatchlist(record.code); + return ( + + ); + }, + }, +]; diff --git a/src/views/Community/components/HeroPanel/columns/ztStockColumns.js b/src/views/Community/components/HeroPanel/columns/ztStockColumns.js new file mode 100644 index 00000000..1e2710ee --- /dev/null +++ b/src/views/Community/components/HeroPanel/columns/ztStockColumns.js @@ -0,0 +1,284 @@ +// 涨停股票详情表格列定义 +// 用于涨停分析个股视图 + +import React from "react"; +import { Tag, Button, Tooltip, Typography } from "antd"; +import { StarFilled, StarOutlined, LineChartOutlined } from "@ant-design/icons"; +import { Box, HStack, VStack } from "@chakra-ui/react"; +import { getTimeStyle, getDaysStyle } from "../utils"; + +const { Text: AntText } = Typography; + +/** + * 创建涨停股票详情表格列 + * @param {Object} options - 配置选项 + * @param {Function} options.showContentDetail - 显示内容详情 + * @param {Function} options.setSelectedKlineStock - 设置K线股票 + * @param {Function} options.setKlineModalVisible - 设置K线弹窗可见性 + * @param {Function} options.isStockInWatchlist - 检查是否在自选 + * @param {Function} options.addSingleToWatchlist - 添加到自选 + */ +export const createZtStockColumns = ({ + showContentDetail, + setSelectedKlineStock, + setKlineModalVisible, + isStockInWatchlist, + addSingleToWatchlist, +}) => [ + { + title: "股票信息", + key: "stock", + width: 140, + fixed: "left", + render: (_, record) => ( + + + {record.sname} + + + {record.scode} + + + ), + }, + { + title: "涨停时间", + dataIndex: "formatted_time", + key: "time", + width: 90, + align: "center", + render: (time) => { + const style = getTimeStyle(time || "15:00:00"); + return ( + + + {time?.substring(0, 5) || "-"} + + + {style.label} + + + ); + }, + }, + { + title: "连板", + dataIndex: "continuous_days", + key: "continuous", + width: 70, + align: "center", + render: (text) => { + if (!text || text === "首板") { + return ( + + 首板 + + ); + } + const match = text.match(/(\d+)/); + const days = match ? parseInt(match[1]) : 1; + const style = getDaysStyle(days); + return ( + + {text} + + ); + }, + }, + { + title: "核心板块", + dataIndex: "core_sectors", + key: "sectors", + width: 200, + render: (sectors) => ( + + {(sectors || []).slice(0, 3).map((sector, idx) => ( + + {sector} + + ))} + + ), + }, + { + title: "涨停简报", + dataIndex: "brief", + key: "brief", + width: 200, + render: (text, record) => { + if (!text) return -; + // 移除HTML标签 + const cleanText = text + .replace(//gi, " ") + .replace(/<[^>]+>/g, ""); + return ( + +
+ {record.sname} 涨停简报 +
+
+ {cleanText} +
+
+ } + placement="topLeft" + overlayStyle={{ maxWidth: 450 }} + > + + + ); + }, + }, + { + title: "K线图", + key: "kline", + width: 80, + align: "center", + render: (_, record) => ( + + ), + }, + { + title: "操作", + key: "action", + width: 90, + align: "center", + render: (_, record) => { + const code = record.scode; + const inWatchlist = isStockInWatchlist(code); + return ( + + ); + }, + }, +]; From 2948f149046ddfd0d4615604363d3ca192e8d1d0 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Tue, 13 Jan 2026 19:00:48 +0800 Subject: [PATCH 12/22] =?UTF-8?q?refactor(HeroPanel):=20=E6=8F=90=E5=8F=96?= =?UTF-8?q?=20DetailModal=20=E7=9B=B8=E5=85=B3=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 主弹窗 DetailModal 使用 Hook 管理状态 - ZTSectorView/ZTStockListView 使用 memo 优化 - EventsTabView 添加空状态处理 - RelatedEventsModal 涨停归因详情 - SectorStocksModal 板块股票详情 🤖 Generated with Claude Code Co-Authored-By: Claude --- .../components/DetailModal/DetailModal.js | 1130 +++++++++++++++++ .../components/DetailModal/EventsTabView.js | 51 + .../DetailModal/RelatedEventsModal.js | 220 ++++ .../DetailModal/SectorStocksModal.js | 373 ++++++ .../components/DetailModal/ZTSectorView.js | 39 + .../components/DetailModal/ZTStockListView.js | 134 ++ .../HeroPanel/components/DetailModal/index.js | 7 + 7 files changed, 1954 insertions(+) create mode 100644 src/views/Community/components/HeroPanel/components/DetailModal/DetailModal.js create mode 100644 src/views/Community/components/HeroPanel/components/DetailModal/EventsTabView.js create mode 100644 src/views/Community/components/HeroPanel/components/DetailModal/RelatedEventsModal.js create mode 100644 src/views/Community/components/HeroPanel/components/DetailModal/SectorStocksModal.js create mode 100644 src/views/Community/components/HeroPanel/components/DetailModal/ZTSectorView.js create mode 100644 src/views/Community/components/HeroPanel/components/DetailModal/ZTStockListView.js create mode 100644 src/views/Community/components/HeroPanel/components/DetailModal/index.js diff --git a/src/views/Community/components/HeroPanel/components/DetailModal/DetailModal.js b/src/views/Community/components/HeroPanel/components/DetailModal/DetailModal.js new file mode 100644 index 00000000..fb82f6e5 --- /dev/null +++ b/src/views/Community/components/HeroPanel/components/DetailModal/DetailModal.js @@ -0,0 +1,1130 @@ +// HeroPanel - 详情弹窗组件 +// 完整展示涨停分析和事件详情 +import React, { useCallback, useMemo } from "react"; +import { useSelector, useDispatch } from "react-redux"; +import { toggleWatchlist } from "@store/slices/stockSlice"; +import { useDetailModalState } from "../../hooks"; +import { Center } from "@chakra-ui/react"; +import { + Table, + Tabs, + Tag, + Button, + Spin, + Typography, + message, + Modal as AntModal, + ConfigProvider, + theme, +} from "antd"; +import { + CalendarOutlined, + StockOutlined, + TagsOutlined, + FireOutlined, +} from "@ant-design/icons"; +import { Calendar } from "lucide-react"; +import { GLASS_BLUR } from "@/constants/glassConfig"; +import { getApiBase } from "@utils/apiConfig"; +import ReactMarkdown from "react-markdown"; +import KLineChartModal from "@components/StockChart/KLineChartModal"; +import { getSixDigitCode, addExchangeSuffix } from "../../utils"; +import { + RelatedEventsModal, + SectorStocksModal, + ZTSectorView, + EventsTabView, + HotKeywordsCloud, + ZTStatsCards, +} from "../index"; +import { + createStockColumns, + createSectorColumns, + createZtStockColumns, + createEventColumns, +} from "../../columns"; + +const { TabPane } = Tabs; +const { Text: AntText } = Typography; + +/** + * 详情弹窗组件 - 完整展示涨停分析和事件详情 + */ +const DetailModal = ({ + isOpen, + onClose, + selectedDate, + ztDetail, + events, + loading, +}) => { + const dispatch = useDispatch(); + const reduxWatchlist = useSelector((state) => state.stock.watchlist); + + // 使用状态管理 Hook(整合 17 个状态) + const { + // UI 状态 + setters + ztViewMode, + setZtViewMode, + selectedSectorFilter, + setSelectedSectorFilter, + expandedReasons, + setExpandedReasons, + // 弹窗状态 + setters + detailDrawerVisible, + setDetailDrawerVisible, + selectedContent, + setSelectedContent, + sectorStocksModalVisible, + setSectorStocksModalVisible, + selectedSectorInfo, + setSelectedSectorInfo, + stocksDrawerVisible, + setStocksDrawerVisible, + selectedEventStocks, + setSelectedEventStocks, + selectedEventTime, + setSelectedEventTime, + selectedEventTitle, + setSelectedEventTitle, + klineModalVisible, + setKlineModalVisible, + selectedKlineStock, + setSelectedKlineStock, + relatedEventsModalVisible, + setRelatedEventsModalVisible, + selectedRelatedEvents, + setSelectedRelatedEvents, + // 数据状态 + setters + stockQuotes, + setStockQuotes, + stockQuotesLoading, + setStockQuotesLoading, + } = useDetailModalState(); + + // 板块数据处理 - 必须在条件返回之前调用所有hooks + const sectorList = useMemo(() => { + if (!ztDetail?.sector_data) return []; + return Object.entries(ztDetail.sector_data) + .filter(([name]) => name !== "其他") + .map(([name, data]) => ({ + name, + count: data.count, + stocks: data.stock_codes || [], + // 新增:关联事件数据(涨停归因) + related_events: data.related_events || [], + })) + .sort((a, b) => b.count - a.count); + }, [ztDetail]); + + // 股票详情数据处理 - 支持两种字段名:stocks 和 stock_infos + // 按连板天数降序排列(高连板在前) + const stockList = useMemo(() => { + const stocksData = ztDetail?.stocks || ztDetail?.stock_infos; + if (!stocksData) return []; + + // 解析连板天数的辅助函数 + const parseContinuousDays = (text) => { + if (!text || text === "首板") return 1; + const match = text.match(/(\d+)/); + return match ? parseInt(match[1]) : 1; + }; + + return stocksData + .map((stock) => ({ + ...stock, + key: stock.scode, + _continuousDays: parseContinuousDays(stock.continuous_days), // 用于排序 + })) + .sort((a, b) => b._continuousDays - a._continuousDays); // 降序排列 + }, [ztDetail]); + + // 筛选后的股票列表(按板块筛选) + const filteredStockList = useMemo(() => { + if (!selectedSectorFilter) return stockList; + // 根据选中板块筛选 + const sectorData = ztDetail?.sector_data?.[selectedSectorFilter]; + if (!sectorData?.stock_codes) return stockList; + const sectorStockCodes = new Set(sectorData.stock_codes); + return stockList.filter((stock) => sectorStockCodes.has(stock.scode)); + }, [stockList, selectedSectorFilter, ztDetail]); + + // 热门关键词 + const hotKeywords = useMemo(() => { + if (!ztDetail?.word_freq_data) return []; + return ztDetail.word_freq_data.slice(0, 12); + }, [ztDetail]); + + // 涨停统计数据 + const ztStats = useMemo(() => { + if (!stockList.length) return null; + + // 连板分布统计 + const continuousStats = { 首板: 0, "2连板": 0, "3连板": 0, "4连板+": 0 }; + // 涨停时间分布统计 + const timeStats = { 秒板: 0, 早盘: 0, 盘中: 0, 尾盘: 0 }; + // 公告驱动统计 + let announcementCount = 0; + + stockList.forEach((stock) => { + // 连板统计 + const days = stock.continuous_days || "首板"; + if (days === "首板" || days.includes("1")) { + continuousStats["首板"]++; + } else { + const match = days.match(/(\d+)/); + const num = match ? parseInt(match[1]) : 1; + if (num === 2) continuousStats["2连板"]++; + else if (num === 3) continuousStats["3连板"]++; + else if (num >= 4) continuousStats["4连板+"]++; + else continuousStats["首板"]++; + } + + // 时间统计 + const time = stock.formatted_time || "15:00:00"; + if (time <= "09:30:00") timeStats["秒板"]++; + else if (time <= "10:00:00") timeStats["早盘"]++; + else if (time <= "14:00:00") timeStats["盘中"]++; + else timeStats["尾盘"]++; + + // 公告驱动 + if (stock.is_announcement) announcementCount++; + }); + + return { + total: stockList.length, + continuousStats, + timeStats, + announcementCount, + announcementRatio: + stockList.length > 0 + ? Math.round((announcementCount / stockList.length) * 100) + : 0, + }; + }, [stockList]); + + // 检查股票是否已在自选中 - 必须在条件返回之前 + const isStockInWatchlist = useCallback( + (stockCode) => { + const sixDigitCode = getSixDigitCode(stockCode); + return reduxWatchlist?.some( + (item) => getSixDigitCode(item.stock_code) === sixDigitCode + ); + }, + [reduxWatchlist] + ); + + // 显示内容详情 + const showContentDetail = useCallback( + (content, title) => { + setSelectedContent({ content, title }); + setDetailDrawerVisible(true); + }, + [setSelectedContent, setDetailDrawerVisible] + ); + + // 显示K线图 + const showKline = useCallback( + (stock) => { + const code = stock.code; + const name = stock.name; + const stockCode = addExchangeSuffix(code); + setSelectedKlineStock({ + stock_code: stockCode, + stock_name: name, + }); + setKlineModalVisible(true); + }, + [setSelectedKlineStock, setKlineModalVisible] + ); + + // 添加单只股票到自选 + const addSingleToWatchlist = useCallback( + async (stock) => { + const code = stock.code; + const name = stock.name; + const stockCode = getSixDigitCode(code); + + if (isStockInWatchlist(code)) { + message.info(`${name} 已在自选中`); + return; + } + + try { + await dispatch( + toggleWatchlist({ + stockCode, + stockName: name, + isInWatchlist: false, + }) + ).unwrap(); + message.success(`已将 ${name}(${stockCode}) 添加到自选`); + } catch (error) { + console.error("添加自选失败:", error); + message.error("添加失败,请重试"); + } + }, + [dispatch, isStockInWatchlist] + ); + + // 加载股票行情 + const loadStockQuotes = useCallback( + async (stocks) => { + if (!stocks || stocks.length === 0) return; + setStockQuotesLoading(true); + const quotes = {}; + + for (const stock of stocks) { + const code = getSixDigitCode(stock.code); + try { + const response = await fetch( + `${getApiBase()}/api/market/trade/${code}?days=1` + ); + if (response.ok) { + const data = await response.json(); + if (data.success && data.data && data.data.length > 0) { + const latest = data.data[data.data.length - 1]; + quotes[stock.code] = { + price: latest.close, + change: latest.change_amount, + changePercent: latest.change_percent, + }; + } + } + } catch (err) { + console.error("加载股票行情失败:", code, err); + } + } + + setStockQuotes(quotes); + setStockQuotesLoading(false); + }, + [setStockQuotes, setStockQuotesLoading] + ); + + // 显示相关股票 + const showRelatedStocks = useCallback( + (stocks, eventTime, eventTitle) => { + if (!stocks || stocks.length === 0) return; + + // 归一化股票数据格式 + const normalizedStocks = stocks + .map((stock) => { + if (typeof stock === "object" && !Array.isArray(stock)) { + return { + code: stock.code || stock.stock_code || "", + name: stock.name || stock.stock_name || "", + description: stock.description || stock.relation_desc || "", + score: stock.score || 0, + report: stock.report || null, + }; + } + if (Array.isArray(stock)) { + return { + code: stock[0] || "", + name: stock[1] || "", + description: stock[2] || "", + score: stock[3] || 0, + report: null, + }; + } + return null; + }) + .filter(Boolean); + + // 按相关度排序 + const sortedStocks = normalizedStocks.sort( + (a, b) => (b.score || 0) - (a.score || 0) + ); + + setSelectedEventStocks(sortedStocks); + setSelectedEventTime(eventTime); + setSelectedEventTitle(eventTitle); + setStocksDrawerVisible(true); + setExpandedReasons({}); + loadStockQuotes(sortedStocks); + }, + [ + setSelectedEventStocks, + setSelectedEventTime, + setSelectedEventTitle, + setStocksDrawerVisible, + setExpandedReasons, + loadStockQuotes, + ] + ); + + // 相关股票表格列定义(和投资日历保持一致) + const stockColumns = useMemo( + () => + createStockColumns({ + stockQuotes, + expandedReasons, + setExpandedReasons, + showKline, + isStockInWatchlist, + addSingleToWatchlist, + }), + [ + stockQuotes, + expandedReasons, + setExpandedReasons, + showKline, + isStockInWatchlist, + addSingleToWatchlist, + ] + ); + + // 涨停板块表格列 - 精致风格设计 + const sectorColumns = useMemo( + () => + createSectorColumns({ + stockList, + setSelectedSectorInfo, + setSectorStocksModalVisible, + setSelectedRelatedEvents, + setRelatedEventsModalVisible, + }), + [ + stockList, + setSelectedSectorInfo, + setSectorStocksModalVisible, + setSelectedRelatedEvents, + setRelatedEventsModalVisible, + ] + ); + + // 涨停股票详情表格列 + const ztStockColumns = useMemo( + () => + createZtStockColumns({ + showContentDetail, + setSelectedKlineStock, + setKlineModalVisible, + isStockInWatchlist, + addSingleToWatchlist, + }), + [ + showContentDetail, + setSelectedKlineStock, + setKlineModalVisible, + isStockInWatchlist, + addSingleToWatchlist, + ] + ); + + // 事件表格列 + const eventColumns = useMemo( + () => createEventColumns({ showContentDetail, showRelatedStocks }), + [showContentDetail, showRelatedStocks] + ); + + // 条件返回必须在所有hooks之后 + if (!selectedDate) return null; + + const dateStr = `${selectedDate.getFullYear()}年${ + selectedDate.getMonth() + 1 + }月${selectedDate.getDate()}日`; + const isPastDate = selectedDate < new Date(new Date().setHours(0, 0, 0, 0)); + + return ( + <> + + +
+ +
+
+
+ {dateStr} +
+
+ + {isPastDate ? "历史数据" : "未来事件"} + + {ztDetail && ( + + 涨停 {ztDetail.total_stocks || 0} 家 + + )} + {events?.length > 0 && ( + + 事件 {events.length} 个 + + )} +
+
+ + } + styles={{ + header: { + background: "rgba(25,25,50,0.98)", + borderBottom: "1px solid rgba(255,215,0,0.2)", + padding: "16px 24px", + }, + body: { + background: + "linear-gradient(135deg, rgba(15,15,30,0.98) 0%, rgba(25,25,50,0.98) 100%)", + padding: "24px", + maxHeight: "80vh", + overflowY: "auto", + }, + content: { + background: + "linear-gradient(135deg, rgba(15,15,30,0.98) 0%, rgba(25,25,50,0.98) 100%)", + borderRadius: "16px", + border: "1px solid rgba(255,215,0,0.2)", + }, + mask: { + background: "rgba(0,0,0,0.7)", + backdropFilter: GLASS_BLUR.sm, + }, + }} + className="hero-panel-modal" + > + {loading ? ( +
+ +
+ ) : ( + + {/* 涨停分析 Tab */} + + + 涨停分析 ({ztDetail?.total_stocks || 0}) + + } + key="zt" + disabled={!ztDetail} + > + {sectorList.length > 0 || stockList.length > 0 ? ( +
+ {/* 热门关键词 */} + + + {/* 涨停统计卡片 */} + + + {/* 视图切换按钮 - 更精致的样式 */} +
+
+ + +
+
+
+ + + {ztDetail?.total_stocks || 0} + + + 只涨停 + +
+
+
+ + {/* 板块视图 */} + {ztViewMode === "sector" && ( + + )} + + {/* 个股视图 */} + {ztViewMode === "stock" && ( +
+ {/* 板块筛选器 */} +
+
+ + 板块筛选: + + +
+
+ {sectorList.slice(0, 10).map((sector) => ( + + ))} +
+
+ + {/* 筛选结果提示 */} + {selectedSectorFilter && ( +
+ + + 当前筛选:{selectedSectorFilter} + + + 共 {filteredStockList.length} 只 + + +
+ )} + +
+
+ + + )} + + ) : ( +
+
+
+ +
+
+ + 暂无涨停数据 + + + 该日期没有涨停股票记录 + +
+
+
+ )} + + + {/* 未来事件 Tab */} + + + 未来事件 ({events?.length || 0}) + + } + key="event" + disabled={!events?.length} + > + + + + )} + + + + {/* 内容详情弹窗 - 页面居中 */} + + setDetailDrawerVisible(false)} + footer={null} + width={700} + centered + zIndex={1500} + styles={{ + header: { + background: "rgba(25,25,50,0.98)", + borderBottom: "1px solid rgba(255,215,0,0.2)", + padding: "16px 24px", + marginBottom: 0, + }, + body: { + background: + "linear-gradient(135deg, rgba(15,15,30,0.98) 0%, rgba(25,25,50,0.98) 100%)", + padding: "24px", + maxHeight: "70vh", + overflowY: "auto", + }, + content: { + background: + "linear-gradient(135deg, rgba(15,15,30,0.98) 0%, rgba(25,25,50,0.98) 100%)", + borderRadius: "12px", + border: "1px solid rgba(255,215,0,0.2)", + }, + mask: { + background: "rgba(0,0,0,0.6)", + }, + }} + > +
+
+ + {typeof selectedContent?.content === "string" + ? selectedContent.content + : selectedContent?.content?.data + ? selectedContent.content.data + .map((item) => item.sentence || "") + .join("\n\n") + : "暂无内容"} + +

+ (AI合成内容) +

+
+
+
+
+ + {/* 相关股票弹窗 */} + + { + setStocksDrawerVisible(false); + setExpandedReasons({}); + }} + footer={null} + width={1100} + centered + title={ +
+ +
+ + 相关股票 + + {selectedEventTitle && ( + + {selectedEventTitle} + + )} +
+ + {selectedEventStocks?.length || 0}只 + + {stockQuotesLoading && } +
+ } + styles={{ + header: { + background: "rgba(25,25,50,0.98)", + borderBottom: "1px solid rgba(255,215,0,0.2)", + padding: "16px 24px", + }, + body: { + background: + "linear-gradient(135deg, rgba(15,15,30,0.98) 0%, rgba(25,25,50,0.98) 100%)", + padding: "16px", + maxHeight: "70vh", + overflowY: "auto", + }, + content: { + background: + "linear-gradient(135deg, rgba(15,15,30,0.98) 0%, rgba(25,25,50,0.98) 100%)", + border: "1px solid rgba(255,215,0,0.2)", + borderRadius: "16px", + }, + mask: { + background: "rgba(0,0,0,0.7)", + backdropFilter: GLASS_BLUR.sm, + }, + }} + > + {selectedEventStocks && selectedEventStocks.length > 0 ? ( +
+
record.code} + size="middle" + pagination={false} + scroll={{ y: 500 }} + /> + + ) : ( +
+ + 暂无相关股票 + +
+ )} + + + + {/* K线图弹窗 */} + {selectedKlineStock && ( + { + setKlineModalVisible(false); + setSelectedKlineStock(null); + }} + stock={selectedKlineStock} + eventTime={selectedEventTime} + size="5xl" + /> + )} + + {/* 板块股票弹窗 */} + { + setSectorStocksModalVisible(false); + setSelectedSectorInfo(null); + }} + sectorInfo={selectedSectorInfo} + onShowKline={(record) => { + const code = record.scode; + let stockCode = code; + if (!code.includes(".")) { + if (code.startsWith("6")) stockCode = `${code}.SH`; + else if (code.startsWith("0") || code.startsWith("3")) + stockCode = `${code}.SZ`; + } + setSelectedKlineStock({ + stock_code: stockCode, + stock_name: record.sname, + }); + setKlineModalVisible(true); + }} + onAddToWatchlist={addSingleToWatchlist} + isStockInWatchlist={isStockInWatchlist} + /> + + {/* 关联事件弹窗 - 涨停归因详情 */} + { + setRelatedEventsModalVisible(false); + setSelectedRelatedEvents({ sectorName: "", events: [] }); + }} + sectorName={selectedRelatedEvents.sectorName} + events={selectedRelatedEvents.events} + count={selectedRelatedEvents.count} + /> + + ); +}; + +export default DetailModal; diff --git a/src/views/Community/components/HeroPanel/components/DetailModal/EventsTabView.js b/src/views/Community/components/HeroPanel/components/DetailModal/EventsTabView.js new file mode 100644 index 00000000..cf0a5dc4 --- /dev/null +++ b/src/views/Community/components/HeroPanel/components/DetailModal/EventsTabView.js @@ -0,0 +1,51 @@ +// 未来事件视图组件 +// 展示选定日期的未来事件列表 + +import React, { memo } from "react"; +import { Table, Typography } from "antd"; +import { CalendarOutlined } from "@ant-design/icons"; + +const { Text: AntText } = Typography; + +/** + * 未来事件视图 + * @param {Array} events - 事件列表 + * @param {Array} columns - 表格列配置 + */ +const EventsTabView = memo(({ events, columns }) => { + // 无数据时的空状态 + if (!events?.length) { + return ( +
+
+ + + 暂无事件数据 + +
+
+ ); + } + + return ( +
+ ); +}); + +EventsTabView.displayName = "EventsTabView"; + +export default EventsTabView; diff --git a/src/views/Community/components/HeroPanel/components/DetailModal/RelatedEventsModal.js b/src/views/Community/components/HeroPanel/components/DetailModal/RelatedEventsModal.js new file mode 100644 index 00000000..553e8e32 --- /dev/null +++ b/src/views/Community/components/HeroPanel/components/DetailModal/RelatedEventsModal.js @@ -0,0 +1,220 @@ +// HeroPanel - 关联事件弹窗(涨停归因详情) +// 使用 Ant Design Modal 保持与现有代码风格一致 +import React from "react"; +import { Modal as AntModal, Tag, ConfigProvider, theme } from "antd"; +import { FileText } from "lucide-react"; +import { GLASS_BLUR } from "@/constants/glassConfig"; + +/** + * 获取相关度颜色 + */ +const getRelevanceColor = (score) => { + if (score >= 80) return "#10B981"; + if (score >= 60) return "#F59E0B"; + return "#6B7280"; +}; + +/** + * 关联事件弹窗 - 涨停归因详情 + */ +const RelatedEventsModal = ({ + visible, + onClose, + sectorName = "", + events = [], + count = 0, +}) => { + return ( + + +
+ +
+
+
+ {sectorName} - 涨停归因 +
+
+ + 涨停 {count} 只 + + + 关联事件 {events?.length || 0} 条 + +
+
+ + } + styles={{ + header: { + background: "rgba(25,25,50,0.98)", + borderBottom: "1px solid rgba(96,165,250,0.2)", + padding: "16px 24px", + }, + body: { + background: "linear-gradient(135deg, rgba(15,15,30,0.98) 0%, rgba(25,25,50,0.98) 100%)", + padding: "16px 24px", + maxHeight: "65vh", + overflowY: "auto", + }, + content: { + background: "linear-gradient(135deg, rgba(15,15,30,0.98) 0%, rgba(25,25,50,0.98) 100%)", + borderRadius: "16px", + border: "1px solid rgba(96,165,250,0.3)", + }, + mask: { background: "rgba(0,0,0,0.7)", backdropFilter: GLASS_BLUR.sm }, + }} + > + {events?.length > 0 ? ( +
+ {events.map((event, idx) => { + const relevanceColor = getRelevanceColor(event.relevance_score || 0); + + return ( +
{ + window.open(`/community?event_id=${event.event_id}`, "_blank"); + }} + onMouseEnter={(e) => { + e.currentTarget.style.background = "rgba(40,40,70,0.9)"; + e.currentTarget.style.borderColor = "rgba(96,165,250,0.3)"; + e.currentTarget.style.transform = "translateY(-2px)"; + }} + onMouseLeave={(e) => { + e.currentTarget.style.background = "rgba(30,30,50,0.8)"; + e.currentTarget.style.borderColor = "rgba(255,255,255,0.06)"; + e.currentTarget.style.transform = "translateY(0)"; + }} + > +
+ {/* 标题 */} +
+
+ + + {event.title} + +
+ + 相关度 {event.relevance_score || 0} + +
+ + {/* 相关原因 */} + {event.relevance_reason && ( + + {event.relevance_reason} + + )} + + {/* 匹配概念 */} + {event.matched_concepts?.length > 0 && ( +
+ + 匹配概念: + +
+ {event.matched_concepts.slice(0, 6).map((concept, i) => ( + + {concept} + + ))} + {event.matched_concepts.length > 6 && ( + + +{event.matched_concepts.length - 6} + + )} +
+
+ )} +
+
+ ); + })} +
+ ) : ( +
+ 暂无关联事件 +
+ )} +
+
+ ); +}; + +export default RelatedEventsModal; diff --git a/src/views/Community/components/HeroPanel/components/DetailModal/SectorStocksModal.js b/src/views/Community/components/HeroPanel/components/DetailModal/SectorStocksModal.js new file mode 100644 index 00000000..7a6123d7 --- /dev/null +++ b/src/views/Community/components/HeroPanel/components/DetailModal/SectorStocksModal.js @@ -0,0 +1,373 @@ +// HeroPanel - 板块股票弹窗 +// 使用 Ant Design Modal 保持与现有代码风格一致 +import React from "react"; +import { Modal as AntModal, Table, Tag, Button, Typography, ConfigProvider, theme } from "antd"; +import { TagsOutlined, LineChartOutlined, StarFilled, StarOutlined } from "@ant-design/icons"; +import { GLASS_BLUR } from "@/constants/glassConfig"; + +const { Text: AntText } = Typography; + +/** + * 获取连板天数样式 + */ +const getDaysStyle = (days) => { + if (days >= 5) + return { + bg: "linear-gradient(135deg, #ff4d4f 0%, #ff7875 100%)", + text: "#fff", + }; + if (days >= 3) + return { + bg: "linear-gradient(135deg, #fa541c 0%, #ff7a45 100%)", + text: "#fff", + }; + if (days >= 2) + return { + bg: "linear-gradient(135deg, #fa8c16 0%, #ffc53d 100%)", + text: "#fff", + }; + return { bg: "rgba(255,255,255,0.1)", text: "#888" }; +}; + +/** + * 获取涨停时间样式 + */ +const getTimeStyle = (time) => { + if (time <= "09:30:00") return { bg: "#ff4d4f", text: "#fff" }; + if (time <= "09:35:00") return { bg: "#fa541c", text: "#fff" }; + if (time <= "10:00:00") return { bg: "#fa8c16", text: "#fff" }; + return { bg: "rgba(255,255,255,0.1)", text: "#888" }; +}; + +/** + * 板块股票弹窗 + */ +const SectorStocksModal = ({ + visible, + onClose, + sectorInfo, + onShowKline, + onAddToWatchlist, + isStockInWatchlist, +}) => { + if (!sectorInfo) return null; + + const { name, count, stocks = [] } = sectorInfo; + + // 连板统计 + const stats = { 首板: 0, "2连板": 0, "3连板": 0, "4连板+": 0 }; + stocks.forEach((s) => { + const days = s._continuousDays || 1; + if (days === 1) stats["首板"]++; + else if (days === 2) stats["2连板"]++; + else if (days === 3) stats["3连板"]++; + else stats["4连板+"]++; + }); + + // 表格列定义 + const columns = [ + { + title: "股票", + key: "stock", + width: 130, + render: (_, record) => ( +
+ + {record.sname} + + + {record.scode} + +
+ ), + }, + { + title: "连板", + dataIndex: "continuous_days", + key: "continuous", + width: 90, + align: "center", + render: (text, record) => { + const days = record._continuousDays || 1; + const style = getDaysStyle(days); + return ( + + {text || "首板"} + + ); + }, + }, + { + title: "涨停时间", + dataIndex: "formatted_time", + key: "time", + width: 90, + align: "center", + render: (time) => { + const style = getTimeStyle(time || "15:00:00"); + return ( + + {time?.substring(0, 5) || "-"} + + ); + }, + }, + { + title: "核心板块", + dataIndex: "core_sectors", + key: "sectors", + render: (sectors) => ( +
+ {(sectors || []).slice(0, 2).map((sector, idx) => ( + + {sector} + + ))} +
+ ), + }, + { + title: "K线图", + key: "kline", + width: 80, + align: "center", + render: (_, record) => ( + + ), + }, + { + title: "操作", + key: "action", + width: 90, + align: "center", + render: (_, record) => { + const code = record.scode; + const inWatchlist = isStockInWatchlist(code); + return ( + + ); + }, + }, + ]; + + return ( + + +
+ +
+
+
+ + {name} + + + {count} 只涨停 + +
+
+ 按连板天数降序排列 +
+
+ + } + styles={{ + header: { + background: "rgba(25,25,50,0.98)", + borderBottom: "1px solid rgba(255,215,0,0.2)", + padding: "16px 24px", + }, + body: { + background: "linear-gradient(135deg, rgba(15,15,30,0.98) 0%, rgba(25,25,50,0.98) 100%)", + padding: "16px 24px", + maxHeight: "70vh", + overflowY: "auto", + }, + content: { + background: "linear-gradient(135deg, rgba(15,15,30,0.98) 0%, rgba(25,25,50,0.98) 100%)", + borderRadius: "16px", + border: "1px solid rgba(255,215,0,0.2)", + }, + mask: { background: "rgba(0,0,0,0.7)", backdropFilter: GLASS_BLUR.sm }, + }} + > + {stocks.length > 0 ? ( +
+ {/* 快速统计 */} +
+ {Object.entries(stats).map( + ([key, value]) => + value > 0 && ( + + {key}: {value} + + ) + )} +
+ + {/* 股票列表 */} +
+
+ + + ) : ( +
+ 暂无股票数据 +
+ )} + + + ); +}; + +export default SectorStocksModal; diff --git a/src/views/Community/components/HeroPanel/components/DetailModal/ZTSectorView.js b/src/views/Community/components/HeroPanel/components/DetailModal/ZTSectorView.js new file mode 100644 index 00000000..aeef4848 --- /dev/null +++ b/src/views/Community/components/HeroPanel/components/DetailModal/ZTSectorView.js @@ -0,0 +1,39 @@ +// 涨停板块视图组件 +// 展示按板块分组的涨停数据表格 + +import React, { memo } from "react"; +import { Table } from "antd"; + +/** + * 涨停板块视图 + * @param {Array} sectorList - 板块列表数据 + * @param {Array} columns - 表格列配置 + */ +const ZTSectorView = memo(({ sectorList, columns }) => { + if (!sectorList?.length) { + return null; + } + + return ( +
+
+ + ); +}); + +ZTSectorView.displayName = "ZTSectorView"; + +export default ZTSectorView; diff --git a/src/views/Community/components/HeroPanel/components/DetailModal/ZTStockListView.js b/src/views/Community/components/HeroPanel/components/DetailModal/ZTStockListView.js new file mode 100644 index 00000000..ad909105 --- /dev/null +++ b/src/views/Community/components/HeroPanel/components/DetailModal/ZTStockListView.js @@ -0,0 +1,134 @@ +// 涨停个股视图组件 +// 展示涨停个股列表,支持按板块筛选 + +import React, { memo } from "react"; +import { Table } from "antd"; + +/** + * 涨停个股视图 + * @param {Array} stockList - 完整股票列表 + * @param {Array} filteredStockList - 筛选后的股票列表 + * @param {Array} sectorList - 板块列表(用于筛选器) + * @param {Array} columns - 表格列配置 + * @param {string|null} selectedSectorFilter - 当前选中的板块筛选 + * @param {Function} onSectorFilterChange - 筛选变化回调 + */ +const ZTStockListView = memo(({ + stockList, + filteredStockList, + sectorList, + columns, + selectedSectorFilter, + onSectorFilterChange, +}) => { + if (!stockList?.length) { + return null; + } + + return ( +
+ {/* 板块筛选器 */} +
+
+ + 板块筛选: + + +
+
+ {sectorList.slice(0, 10).map((sector) => ( + + ))} +
+
+ + {/* 筛选结果提示 */} + {selectedSectorFilter && ( +
+ + {selectedSectorFilter} + + + 共 {filteredStockList.length} 只涨停 + + +
+ )} + + {/* 股票表格 */} +
+
+ + + ); +}); + +ZTStockListView.displayName = "ZTStockListView"; + +export default ZTStockListView; diff --git a/src/views/Community/components/HeroPanel/components/DetailModal/index.js b/src/views/Community/components/HeroPanel/components/DetailModal/index.js new file mode 100644 index 00000000..307a084f --- /dev/null +++ b/src/views/Community/components/HeroPanel/components/DetailModal/index.js @@ -0,0 +1,7 @@ +// HeroPanel - DetailModal 子组件导出 +export { default as DetailModal } from "./DetailModal"; +export { default as RelatedEventsModal } from "./RelatedEventsModal"; +export { default as SectorStocksModal } from "./SectorStocksModal"; +export { default as ZTSectorView } from "./ZTSectorView"; +export { default as ZTStockListView } from "./ZTStockListView"; +export { default as EventsTabView } from "./EventsTabView"; From 1aea8dcb6cfb2a34151f73578b53429804a71710 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Thu, 15 Jan 2026 11:42:24 +0800 Subject: [PATCH 13/22] =?UTF-8?q?refactor(HeroPanel):=20=E6=8F=90=E5=8F=96?= =?UTF-8?q?=20hooks/constants/utils/styles=20=E5=9F=BA=E7=A1=80=E6=A8=A1?= =?UTF-8?q?=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hooks/useDetailModalState: 整合弹窗 17 个状态为单一 Hook - constants: 主题色配置、热度级别常量、日期常量 - utils: 交易时间判断、热度颜色、日期/股票代码格式化 - styles/animations.css: 深色主题覆盖、动画、滚动条样式 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../components/HeroPanel/constants/index.js | 68 ++++++ .../components/HeroPanel/hooks/index.js | 2 + .../HeroPanel/hooks/useDetailModalState.js | 197 ++++++++++++++++++ .../HeroPanel/styles/animations.css | 159 ++++++++++++++ .../components/HeroPanel/utils/index.js | 84 ++++++++ 5 files changed, 510 insertions(+) create mode 100644 src/views/Community/components/HeroPanel/constants/index.js create mode 100644 src/views/Community/components/HeroPanel/hooks/index.js create mode 100644 src/views/Community/components/HeroPanel/hooks/useDetailModalState.js create mode 100644 src/views/Community/components/HeroPanel/styles/animations.css create mode 100644 src/views/Community/components/HeroPanel/utils/index.js diff --git a/src/views/Community/components/HeroPanel/constants/index.js b/src/views/Community/components/HeroPanel/constants/index.js new file mode 100644 index 00000000..c0175710 --- /dev/null +++ b/src/views/Community/components/HeroPanel/constants/index.js @@ -0,0 +1,68 @@ +// HeroPanel 常量配置 + +// 主题色配置 +export const goldColors = { + primary: "#D4AF37", + light: "#F4D03F", + dark: "#B8860B", + glow: "rgba(212, 175, 55, 0.4)", +}; + +export const textColors = { + primary: "#ffffff", + secondary: "rgba(255, 255, 255, 0.85)", + muted: "rgba(255, 255, 255, 0.5)", +}; + +// 热度级别配置 +export const HEAT_LEVELS = [ + { + key: "high", + threshold: 80, + colors: { + bg: "rgba(147, 51, 234, 0.55)", + text: "#d8b4fe", + border: "rgba(147, 51, 234, 0.65)", + }, + }, + { + key: "medium", + threshold: 60, + colors: { + bg: "rgba(239, 68, 68, 0.50)", + text: "#fca5a5", + border: "rgba(239, 68, 68, 0.60)", + }, + }, + { + key: "low", + threshold: 40, + colors: { + bg: "rgba(251, 146, 60, 0.45)", + text: "#fed7aa", + border: "rgba(251, 146, 60, 0.55)", + }, + }, + { + key: "cold", + threshold: 0, + colors: { + bg: "rgba(59, 130, 246, 0.35)", + text: "#93c5fd", + border: "rgba(59, 130, 246, 0.45)", + }, + }, +]; + +export const DEFAULT_HEAT_COLORS = { + bg: "rgba(60, 60, 70, 0.12)", + text: textColors.muted, + border: "transparent", +}; + +// 日期常量 +export const WEEK_DAYS = ["日", "一", "二", "三", "四", "五", "六"]; +export const MONTH_NAMES = [ + "1月", "2月", "3月", "4月", "5月", "6月", + "7月", "8月", "9月", "10月", "11月", "12月", +]; diff --git a/src/views/Community/components/HeroPanel/hooks/index.js b/src/views/Community/components/HeroPanel/hooks/index.js new file mode 100644 index 00000000..a5c0812a --- /dev/null +++ b/src/views/Community/components/HeroPanel/hooks/index.js @@ -0,0 +1,2 @@ +// HeroPanel Hooks 导出 +export { useDetailModalState } from "./useDetailModalState"; diff --git a/src/views/Community/components/HeroPanel/hooks/useDetailModalState.js b/src/views/Community/components/HeroPanel/hooks/useDetailModalState.js new file mode 100644 index 00000000..bac18613 --- /dev/null +++ b/src/views/Community/components/HeroPanel/hooks/useDetailModalState.js @@ -0,0 +1,197 @@ +// HeroPanel - DetailModal 状态管理 Hook +// 整合 DetailModal 组件的所有状态,使主组件更简洁 + +import { useState, useCallback } from "react"; + +/** + * DetailModal 状态管理 Hook + * 将 17 个 useState 整合为一个自定义 hook + */ +export const useDetailModalState = () => { + // ========== UI 状态 ========== + // 视图模式:板块视图 | 个股视图 + const [ztViewMode, setZtViewMode] = useState("sector"); // 'sector' | 'stock' + // 板块筛选(个股视图时使用) + const [selectedSectorFilter, setSelectedSectorFilter] = useState(null); + // 展开的涨停原因 + const [expandedReasons, setExpandedReasons] = useState({}); + + // ========== 弹窗/抽屉状态 ========== + // 内容详情抽屉 + const [detailDrawerVisible, setDetailDrawerVisible] = useState(false); + const [selectedContent, setSelectedContent] = useState(null); + // 板块股票弹窗 + const [sectorStocksModalVisible, setSectorStocksModalVisible] = useState(false); + const [selectedSectorInfo, setSelectedSectorInfo] = useState(null); + // 事件关联股票抽屉 + const [stocksDrawerVisible, setStocksDrawerVisible] = useState(false); + const [selectedEventStocks, setSelectedEventStocks] = useState([]); + const [selectedEventTime, setSelectedEventTime] = useState(null); + const [selectedEventTitle, setSelectedEventTitle] = useState(""); + // K线弹窗 + const [klineModalVisible, setKlineModalVisible] = useState(false); + const [selectedKlineStock, setSelectedKlineStock] = useState(null); + // 关联事件弹窗 + const [relatedEventsModalVisible, setRelatedEventsModalVisible] = useState(false); + const [selectedRelatedEvents, setSelectedRelatedEvents] = useState({ + sectorName: "", + events: [], + }); + + // ========== 数据加载状态 ========== + const [stockQuotes, setStockQuotes] = useState({}); + const [stockQuotesLoading, setStockQuotesLoading] = useState(false); + + // ========== 操作方法 ========== + + // 打开内容详情 + const openContentDetail = useCallback((content) => { + setSelectedContent(content); + setDetailDrawerVisible(true); + }, []); + + // 关闭内容详情 + const closeContentDetail = useCallback(() => { + setDetailDrawerVisible(false); + setSelectedContent(null); + }, []); + + // 打开板块股票弹窗 + const openSectorStocks = useCallback((sectorInfo) => { + setSelectedSectorInfo(sectorInfo); + setSectorStocksModalVisible(true); + }, []); + + // 关闭板块股票弹窗 + const closeSectorStocks = useCallback(() => { + setSectorStocksModalVisible(false); + setSelectedSectorInfo(null); + }, []); + + // 打开事件关联股票 + const openEventStocks = useCallback((stocks, time, title) => { + setSelectedEventStocks(stocks); + setSelectedEventTime(time); + setSelectedEventTitle(title); + setStocksDrawerVisible(true); + }, []); + + // 关闭事件关联股票 + const closeEventStocks = useCallback(() => { + setStocksDrawerVisible(false); + setSelectedEventStocks([]); + setSelectedEventTime(null); + setSelectedEventTitle(""); + }, []); + + // 打开 K 线弹窗 + const openKlineModal = useCallback((stock) => { + setSelectedKlineStock(stock); + setKlineModalVisible(true); + }, []); + + // 关闭 K 线弹窗 + const closeKlineModal = useCallback(() => { + setKlineModalVisible(false); + setSelectedKlineStock(null); + }, []); + + // 打开关联事件弹窗 + const openRelatedEvents = useCallback((sectorName, events) => { + setSelectedRelatedEvents({ sectorName, events }); + setRelatedEventsModalVisible(true); + }, []); + + // 关闭关联事件弹窗 + const closeRelatedEvents = useCallback(() => { + setRelatedEventsModalVisible(false); + setSelectedRelatedEvents({ sectorName: "", events: [] }); + }, []); + + // 切换展开原因 + const toggleExpandedReason = useCallback((stockCode) => { + setExpandedReasons((prev) => ({ + ...prev, + [stockCode]: !prev[stockCode], + })); + }, []); + + // 重置所有状态(用于关闭弹窗时) + const resetAllState = useCallback(() => { + setZtViewMode("sector"); + setSelectedSectorFilter(null); + setExpandedReasons({}); + setDetailDrawerVisible(false); + setSelectedContent(null); + setSectorStocksModalVisible(false); + setSelectedSectorInfo(null); + setStocksDrawerVisible(false); + setSelectedEventStocks([]); + setSelectedEventTime(null); + setSelectedEventTitle(""); + setKlineModalVisible(false); + setSelectedKlineStock(null); + setRelatedEventsModalVisible(false); + setSelectedRelatedEvents({ sectorName: "", events: [] }); + setStockQuotes({}); + setStockQuotesLoading(false); + }, []); + + return { + // UI 状态 + setters + ztViewMode, + setZtViewMode, + selectedSectorFilter, + setSelectedSectorFilter, + expandedReasons, + setExpandedReasons, + + // 弹窗状态 + setters + detailDrawerVisible, + setDetailDrawerVisible, + selectedContent, + setSelectedContent, + sectorStocksModalVisible, + setSectorStocksModalVisible, + selectedSectorInfo, + setSelectedSectorInfo, + stocksDrawerVisible, + setStocksDrawerVisible, + selectedEventStocks, + setSelectedEventStocks, + selectedEventTime, + setSelectedEventTime, + selectedEventTitle, + setSelectedEventTitle, + klineModalVisible, + setKlineModalVisible, + selectedKlineStock, + setSelectedKlineStock, + relatedEventsModalVisible, + setRelatedEventsModalVisible, + selectedRelatedEvents, + setSelectedRelatedEvents, + + // 数据状态 + setters + stockQuotes, + setStockQuotes, + stockQuotesLoading, + setStockQuotesLoading, + + // 操作方法(高级封装) + openContentDetail, + closeContentDetail, + openSectorStocks, + closeSectorStocks, + openEventStocks, + closeEventStocks, + openKlineModal, + closeKlineModal, + openRelatedEvents, + closeRelatedEvents, + toggleExpandedReason, + resetAllState, + }; +}; + +export default useDetailModalState; diff --git a/src/views/Community/components/HeroPanel/styles/animations.css b/src/views/Community/components/HeroPanel/styles/animations.css new file mode 100644 index 00000000..6e8c332a --- /dev/null +++ b/src/views/Community/components/HeroPanel/styles/animations.css @@ -0,0 +1,159 @@ +/* HeroPanel 动画和深色主题样式 */ + +@keyframes pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.6; transform: scale(1.1); } +} + +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +/* Ant Design 深色主题覆盖 - 弹窗专用 */ +.hero-panel-modal .ant-tabs { + color: rgba(255, 255, 255, 0.85); +} + +.hero-panel-modal .ant-tabs-nav::before { + border-color: rgba(255, 215, 0, 0.2) !important; +} + +.hero-panel-modal .ant-tabs-tab { + color: rgba(255, 255, 255, 0.65) !important; + font-size: 15px !important; +} + +.hero-panel-modal .ant-tabs-tab:hover { + color: #FFD700 !important; +} + +.hero-panel-modal .ant-tabs-tab-active .ant-tabs-tab-btn { + color: #FFD700 !important; +} + +.hero-panel-modal .ant-tabs-ink-bar { + background: linear-gradient(90deg, #FFD700, #FFA500) !important; +} + +/* 表格深色主题 */ +.hero-panel-modal .ant-table { + background: transparent !important; + color: rgba(255, 255, 255, 0.85) !important; +} + +.hero-panel-modal .ant-table-thead > tr > th { + background: rgba(255, 215, 0, 0.1) !important; + color: #FFD700 !important; + border-bottom: 1px solid rgba(255, 215, 0, 0.2) !important; + font-weight: 600 !important; + font-size: 14px !important; +} + +.hero-panel-modal .ant-table-tbody > tr > td { + background: transparent !important; + border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important; + color: rgba(255, 255, 255, 0.85) !important; + font-size: 14px !important; +} + +.hero-panel-modal .ant-table-tbody > tr:hover > td { + background: rgba(255, 215, 0, 0.08) !important; +} + +.hero-panel-modal .ant-table-tbody > tr.ant-table-row:hover > td { + background: rgba(255, 215, 0, 0.1) !important; +} + +.hero-panel-modal .ant-table-cell-row-hover { + background: rgba(255, 215, 0, 0.08) !important; +} + +.hero-panel-modal .ant-table-placeholder { + background: transparent !important; +} + +.hero-panel-modal .ant-empty-description { + color: rgba(255, 255, 255, 0.45) !important; +} + +/* 滚动条样式 */ +.hero-panel-modal .ant-table-body::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +.hero-panel-modal .ant-table-body::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.05); + border-radius: 3px; +} + +.hero-panel-modal .ant-table-body::-webkit-scrollbar-thumb { + background: rgba(255, 215, 0, 0.3); + border-radius: 3px; +} + +.hero-panel-modal .ant-table-body::-webkit-scrollbar-thumb:hover { + background: rgba(255, 215, 0, 0.5); +} + +/* 板块股票表格滚动 - 针对 Ant Design 5.x */ +.sector-stocks-table-wrapper { + max-height: 450px; + overflow: hidden; +} + +.sector-stocks-table-wrapper .ant-table-wrapper, +.sector-stocks-table-wrapper .ant-table, +.sector-stocks-table-wrapper .ant-table-container { + max-height: 100%; +} + +.sector-stocks-table-wrapper .ant-table-body { + max-height: 380px !important; + overflow-y: auto !important; + scrollbar-width: thin; + scrollbar-color: rgba(255, 215, 0, 0.4) rgba(255, 255, 255, 0.05); +} + +/* 相关股票表格滚动 */ +.related-stocks-table-wrapper .ant-table-body { + scrollbar-width: thin; + scrollbar-color: rgba(255, 215, 0, 0.4) rgba(255, 255, 255, 0.05); +} + +/* Tag 样式优化 */ +.hero-panel-modal .ant-tag { + border-radius: 4px !important; +} + +/* Button link 样式 */ +.hero-panel-modal .ant-btn-link { + color: #FFD700 !important; +} + +.hero-panel-modal .ant-btn-link:hover { + color: #FFA500 !important; +} + +.hero-panel-modal .ant-btn-link:disabled { + color: rgba(255, 255, 255, 0.25) !important; +} + +/* Typography 样式 */ +.hero-panel-modal .ant-typography { + color: rgba(255, 255, 255, 0.85) !important; +} + +.hero-panel-modal .ant-typography-secondary { + color: rgba(255, 255, 255, 0.45) !important; +} + +/* Spin 加载样式 */ +.hero-panel-modal .ant-spin-text { + color: #FFD700 !important; +} + +.hero-panel-modal .ant-spin-dot-item { + background-color: #FFD700 !important; +} diff --git a/src/views/Community/components/HeroPanel/utils/index.js b/src/views/Community/components/HeroPanel/utils/index.js new file mode 100644 index 00000000..4a21118f --- /dev/null +++ b/src/views/Community/components/HeroPanel/utils/index.js @@ -0,0 +1,84 @@ +// HeroPanel 工具函数 + +import { HEAT_LEVELS, DEFAULT_HEAT_COLORS } from '../constants'; + +/** + * 判断当前是否在交易时间内 (9:30-15:00) + */ +export const isInTradingTime = () => { + const now = new Date(); + const timeInMinutes = now.getHours() * 60 + now.getMinutes(); + return timeInMinutes >= 570 && timeInMinutes <= 900; +}; + +/** + * 根据涨停数获取热度颜色 + */ +export const getHeatColor = (count) => { + if (!count) return DEFAULT_HEAT_COLORS; + const level = HEAT_LEVELS.find((l) => count >= l.threshold); + return level?.colors || DEFAULT_HEAT_COLORS; +}; + +/** + * 日期格式化为 YYYYMMDD + */ +export const formatDateStr = (date) => { + if (!date) return ""; + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}${month}${day}`; +}; + +/** + * 获取六位股票代码(去掉后缀) + */ +export const getSixDigitCode = (code) => { + if (!code) return code; + return code.split(".")[0]; +}; + +/** + * 添加交易所后缀 + */ +export const addExchangeSuffix = (code) => { + const sixDigitCode = getSixDigitCode(code); + if (code.includes(".")) return code; + if (sixDigitCode.startsWith("6")) { + return `${sixDigitCode}.SH`; + } else if (sixDigitCode.startsWith("0") || sixDigitCode.startsWith("3")) { + return `${sixDigitCode}.SZ`; + } + return sixDigitCode; +}; + +/** + * 解析连板天数 + */ +export const parseContinuousDays = (text) => { + if (!text || text === "首板") return 1; + const match = text.match(/(\d+)/); + return match ? parseInt(match[1]) : 1; +}; + +/** + * 获取涨停时间样式 + */ +export const getTimeStyle = (time) => { + if (time <= "09:30:00") return { bg: "#ff4d4f", text: "#fff", label: "秒板" }; + if (time <= "09:35:00") return { bg: "#fa541c", text: "#fff", label: "早板" }; + if (time <= "10:00:00") return { bg: "#fa8c16", text: "#fff", label: "盘初" }; + if (time <= "11:00:00") return { bg: "#52c41a", text: "#fff", label: "盘中" }; + return { bg: "rgba(255,255,255,0.1)", text: "#888", label: "尾盘" }; +}; + +/** + * 获取连板天数样式 + */ +export const getDaysStyle = (days) => { + if (days >= 5) return { bg: "linear-gradient(135deg, #ff4d4f 0%, #ff7875 100%)", text: "#fff" }; + if (days >= 3) return { bg: "linear-gradient(135deg, #fa541c 0%, #ff7a45 100%)", text: "#fff" }; + if (days >= 2) return { bg: "linear-gradient(135deg, #fa8c16 0%, #ffc53d 100%)", text: "#fff" }; + return { bg: "rgba(255,255,255,0.1)", text: "#888" }; +}; From 635abfc1ab8cdc9546a775616c94391fa386d29e Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Thu, 15 Jan 2026 11:42:32 +0800 Subject: [PATCH 14/22] =?UTF-8?q?refactor(HeroPanel):=20=E6=8F=90=E5=8F=96?= =?UTF-8?q?=E6=97=A5=E5=8E=86=E5=92=8C=E7=BB=9F=E8=AE=A1=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CalendarCell: 日历单元格,memo 优化渲染 - CombinedCalendar: 综合日历组件,懒加载 DetailModal - HotKeywordsCloud: 热门关键词云,涨停分析 Tab 使用 - ZTStatsCards: 涨停统计卡片(连板分布、封板时间、公告驱动) - InfoModal: 使用说明弹窗 - index.js: 组件统一导出 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../HeroPanel/components/CalendarCell.js | 276 ++++++++++++++++++ .../HeroPanel/components/CombinedCalendar.js | 263 +++++++++++++++++ .../HeroPanel/components/HotKeywordsCloud.js | 122 ++++++++ .../HeroPanel/components/InfoModal.js | 152 ++++++++++ .../HeroPanel/components/ZTStatsCards.js | 171 +++++++++++ .../components/HeroPanel/components/index.js | 7 + 6 files changed, 991 insertions(+) create mode 100644 src/views/Community/components/HeroPanel/components/CalendarCell.js create mode 100644 src/views/Community/components/HeroPanel/components/CombinedCalendar.js create mode 100644 src/views/Community/components/HeroPanel/components/HotKeywordsCloud.js create mode 100644 src/views/Community/components/HeroPanel/components/InfoModal.js create mode 100644 src/views/Community/components/HeroPanel/components/ZTStatsCards.js create mode 100644 src/views/Community/components/HeroPanel/components/index.js diff --git a/src/views/Community/components/HeroPanel/components/CalendarCell.js b/src/views/Community/components/HeroPanel/components/CalendarCell.js new file mode 100644 index 00000000..3de10ac3 --- /dev/null +++ b/src/views/Community/components/HeroPanel/components/CalendarCell.js @@ -0,0 +1,276 @@ +// HeroPanel - 日历单元格组件 +import React, { memo } from "react"; +import { + Box, + VStack, + HStack, + Text, + Badge, + Tooltip, + Icon, +} from "@chakra-ui/react"; +import { Flame, FileText, TrendingUp, TrendingDown } from "lucide-react"; +import { goldColors, textColors } from "../constants"; +import { getHeatColor } from "../utils"; + +/** + * 趋势图标 + */ +const TrendIcon = memo(({ current, previous }) => { + if (!current || !previous) return null; + const diff = current - previous; + if (diff === 0) return null; + + const isUp = diff > 0; + return ( + + ); +}); + +TrendIcon.displayName = "TrendIcon"; + +/** + * 日历单元格 - 显示涨停数和事件数(加大尺寸) + * 新增:连续概念连接展示(connectLeft/connectRight 表示与左右格子是否同一概念) + */ +const CalendarCell = memo( + ({ + date, + ztData, + eventCount, + previousZtData, + isSelected, + isToday, + isWeekend, + onClick, + connectLeft, + connectRight, + }) => { + if (!date) { + return ; + } + + const hasZtData = !!ztData; + const hasEventData = eventCount > 0; + const ztCount = ztData?.count || 0; + const heatColors = getHeatColor(ztCount); + const topSector = ztData?.top_sector || ""; + + // 是否有连接线(连续概念) + const hasConnection = connectLeft || connectRight; + + // 周末无数据显示"休市" + if (isWeekend && !hasZtData && !hasEventData) { + return ( + + + {date.getDate()} + + + 休市 + + + ); + } + + // 正常日期 + return ( + + {`${ + date.getMonth() + 1 + }月${date.getDate()}日`} + {hasZtData && ( + + 涨停: {ztCount}家 {topSector && `| ${topSector}`} + + )} + {hasEventData && 未来事件: {eventCount}个} + {!hasZtData && !hasEventData && ( + 暂无数据 + )} + + } + placement="top" + hasArrow + bg="rgba(15, 15, 22, 0.95)" + border="1px solid rgba(212, 175, 55, 0.3)" + borderRadius="10px" + > + onClick && onClick(date)} + w="full" + minH="75px" + > + {/* 今天标记 */} + {isToday && ( + + 今天 + + )} + + + {/* 日期 */} + + {date.getDate()} + + + {/* 涨停数 + 趋势 */} + {hasZtData && ( + + + + {ztCount} + + + + )} + + {/* 事件数 */} + {hasEventData && ( + + + + {eventCount} + + + )} + + {/* 主要板块 - 连续概念用连接样式 */} + {hasZtData && topSector && ( + + {/* 左连接线 */} + {connectLeft && ( + + )} + + {topSector} + + {/* 右连接线 */} + {connectRight && ( + + )} + + )} + + + + ); + } +); + +CalendarCell.displayName = "CalendarCell"; + +export default CalendarCell; diff --git a/src/views/Community/components/HeroPanel/components/CombinedCalendar.js b/src/views/Community/components/HeroPanel/components/CombinedCalendar.js new file mode 100644 index 00000000..b925b58f --- /dev/null +++ b/src/views/Community/components/HeroPanel/components/CombinedCalendar.js @@ -0,0 +1,263 @@ +// HeroPanel - 综合日历组件 +import React, { useState, useEffect, useCallback, Suspense, lazy } from "react"; +import { + Box, + HStack, + VStack, + Text, + Icon, + Center, + Spinner, +} from "@chakra-ui/react"; +import { Flame } from "lucide-react"; +import dayjs from "dayjs"; +import { GLASS_BLUR } from "@/constants/glassConfig"; +import { eventService } from "@services/eventService"; +import { getApiBase } from "@utils/apiConfig"; +import { textColors } from "../constants"; +import { formatDateStr } from "../utils"; + +// 懒加载 FullCalendar +const FullCalendarPro = lazy(() => + import("@components/Calendar").then((module) => ({ + default: module.FullCalendarPro, + })) +); + +/** + * 综合日历组件 - 使用 FullCalendarPro 实现跨天事件条效果 + * @param {Object} props + * @param {React.ComponentType} props.DetailModal - 详情弹窗组件 + */ +const CombinedCalendar = ({ DetailModal }) => { + const [currentMonth, setCurrentMonth] = useState(new Date()); + const [selectedDate, setSelectedDate] = useState(null); + + // 日历综合数据(涨停 + 事件 + 上证涨跌幅)- 使用新的综合 API + const [calendarData, setCalendarData] = useState([]); + const [ztDailyDetails, setZtDailyDetails] = useState({}); + const [selectedZtDetail, setSelectedZtDetail] = useState(null); + const [selectedEvents, setSelectedEvents] = useState([]); + + const [detailLoading, setDetailLoading] = useState(false); + const [modalOpen, setModalOpen] = useState(false); + + // 加载日历综合数据(一次 API 调用获取所有数据) + useEffect(() => { + const loadCalendarCombinedData = async () => { + try { + const year = currentMonth.getFullYear(); + const month = currentMonth.getMonth() + 1; + const response = await fetch( + `${getApiBase()}/api/v1/calendar/combined-data?year=${year}&month=${month}` + ); + if (response.ok) { + const result = await response.json(); + if (result.success && result.data) { + // 转换为 FullCalendarPro 需要的格式 + const formattedData = result.data.map((item) => ({ + date: item.date, + count: item.zt_count || 0, + topSector: item.top_sector || "", + eventCount: item.event_count || 0, + indexChange: item.index_change, + })); + console.log( + "[HeroPanel] 加载日历综合数据成功,数据条数:", + formattedData.length + ); + setCalendarData(formattedData); + } + } + } catch (error) { + console.error("Failed to load calendar combined data:", error); + } + }; + loadCalendarCombinedData(); + }, [currentMonth]); + + // 处理日期点击 - 打开弹窗 + const handleDateClick = useCallback( + async (date) => { + setSelectedDate(date); + setModalOpen(true); + setDetailLoading(true); + + const ztDateStr = formatDateStr(date); + const eventDateStr = dayjs(date).format("YYYY-MM-DD"); + + // 加载涨停详情 + const detail = ztDailyDetails[ztDateStr]; + if (detail?.fullData) { + setSelectedZtDetail(detail.fullData); + } else { + try { + const response = await fetch(`/data/zt/daily/${ztDateStr}.json`); + if (response.ok) { + const data = await response.json(); + setSelectedZtDetail(data); + setZtDailyDetails((prev) => ({ + ...prev, + [ztDateStr]: { ...prev[ztDateStr], fullData: data }, + })); + } else { + setSelectedZtDetail(null); + } + } catch { + setSelectedZtDetail(null); + } + } + + // 加载事件详情 + try { + const response = await eventService.calendar.getEventsForDate( + eventDateStr + ); + if (response.success) { + setSelectedEvents(response.data || []); + } else { + setSelectedEvents([]); + } + } catch { + setSelectedEvents([]); + } + + setDetailLoading(false); + }, + [ztDailyDetails] + ); + + // 月份变化回调 + const handleMonthChange = useCallback((year, month) => { + setCurrentMonth(new Date(year, month - 1, 1)); + }, []); + + return ( + <> + + {/* 顶部装饰条 */} + + + {/* 图例说明 - 右上角 */} + + + + + 热门概念 + + + + + + ≥60 + + + + + + <60 + + + + + + N + + + + 事件 + + + + + + + + + / + + + - + + + 上证 + + + + + {/* FullCalendar Pro - 炫酷跨天事件条日历(懒加载) */} + + + + + 加载日历组件... + + + + } + > + + + + + {/* 详情弹窗 */} + {DetailModal && ( + setModalOpen(false)} + selectedDate={selectedDate} + ztDetail={selectedZtDetail} + events={selectedEvents} + loading={detailLoading} + /> + )} + + ); +}; + +export default CombinedCalendar; diff --git a/src/views/Community/components/HeroPanel/components/HotKeywordsCloud.js b/src/views/Community/components/HeroPanel/components/HotKeywordsCloud.js new file mode 100644 index 00000000..cd22d6e6 --- /dev/null +++ b/src/views/Community/components/HeroPanel/components/HotKeywordsCloud.js @@ -0,0 +1,122 @@ +// 热门关键词云组件 +// 用于涨停分析 Tab 显示今日热词 + +import React from "react"; +import { FireOutlined } from "@ant-design/icons"; + +/** + * 获取关键词样式(根据排名) + */ +const getKeywordStyle = (index) => { + if (index < 3) { + return { + fontSize: "15px", + fontWeight: "bold", + background: + "linear-gradient(135deg, rgba(255,215,0,0.3) 0%, rgba(255,165,0,0.2) 100%)", + border: "1px solid rgba(255,215,0,0.5)", + color: "#FFD700", + padding: "6px 12px", + }; + } + if (index < 6) { + return { + fontSize: "14px", + fontWeight: "600", + background: "rgba(255,215,0,0.15)", + border: "1px solid rgba(255,215,0,0.3)", + color: "#D4A84B", + padding: "4px 10px", + }; + } + return { + fontSize: "13px", + fontWeight: "normal", + background: "rgba(255,255,255,0.08)", + border: "1px solid rgba(255,255,255,0.15)", + color: "#888", + padding: "2px 8px", + }; +}; + +/** + * 热门关键词云组件 + * @param {Object} props + * @param {Array} props.keywords - 关键词数组 [{ name: string }] + */ +const HotKeywordsCloud = ({ keywords }) => { + if (!keywords || keywords.length === 0) { + return null; + } + + return ( +
+ {/* 装饰线 */} +
+
+
+ +
+ + 今日热词 + + + 词频越高排名越前 + +
+
+ {keywords.map((kw, idx) => { + const style = getKeywordStyle(idx); + return ( + + {kw.name} + + ); + })} +
+
+ ); +}; + +export default HotKeywordsCloud; diff --git a/src/views/Community/components/HeroPanel/components/InfoModal.js b/src/views/Community/components/HeroPanel/components/InfoModal.js new file mode 100644 index 00000000..b4df64e5 --- /dev/null +++ b/src/views/Community/components/HeroPanel/components/InfoModal.js @@ -0,0 +1,152 @@ +// HeroPanel - 使用说明弹窗组件 +import React, { useState } from "react"; +import { HStack, Icon, Text } from "@chakra-ui/react"; +import { Modal as AntModal, ConfigProvider, theme } from "antd"; +import { Info } from "lucide-react"; +import { GLASS_BLUR } from "@/constants/glassConfig"; + +/** + * 使用说明弹窗组件 + */ +const InfoModal = () => { + const [isOpen, setIsOpen] = useState(false); + const onOpen = () => setIsOpen(true); + const onClose = () => setIsOpen(false); + + return ( + <> + + + + 使用说明 + + + + + +
+ +
+ + 事件中心使用指南 + +
+ } + styles={{ + header: { + background: 'rgba(25,25,50,0.98)', + borderBottom: '1px solid rgba(255,215,0,0.2)', + paddingBottom: '16px', + }, + body: { + background: 'linear-gradient(135deg, rgba(15,15,30,0.98) 0%, rgba(25,25,50,0.98) 100%)', + padding: '24px', + }, + content: { + background: 'linear-gradient(135deg, rgba(15,15,30,0.98) 0%, rgba(25,25,50,0.98) 100%)', + border: '1px solid rgba(255,215,0,0.3)', + borderRadius: '16px', + boxShadow: '0 25px 80px rgba(0,0,0,0.8)', + }, + mask: { + background: 'rgba(0,0,0,0.7)', + backdropFilter: GLASS_BLUR.sm, + }, + }} + > +
+
+
+ 📅 综合日历 +
+
+ 日历同时展示 + 历史涨停数据 + 和 + 未来事件 + , 点击日期查看详细信息。 +
+
+ +
+
+ 🔥 涨停板块 +
+
+ 点击历史日期,查看当日涨停板块排行、涨停数量、涨停股票代码,帮助理解市场主线。 +
+
+ +
+
+ 📊 未来事件 +
+
+ 点击未来日期,查看事件详情,包括 + 背景分析 + 、 + 未来推演 + 、 + 相关股票 + 等。 +
+
+ +
+
+ 💡 颜色越深表示涨停数越多 · 绿色标记表示有未来事件 +
+
+
+ + + + ); +}; + +export default InfoModal; diff --git a/src/views/Community/components/HeroPanel/components/ZTStatsCards.js b/src/views/Community/components/HeroPanel/components/ZTStatsCards.js new file mode 100644 index 00000000..2a6950d0 --- /dev/null +++ b/src/views/Community/components/HeroPanel/components/ZTStatsCards.js @@ -0,0 +1,171 @@ +// 涨停统计卡片组件 +// 显示连板分布、封板时间、公告驱动统计 + +import React from "react"; + +/** + * 获取连板颜色 + */ +const getContinuousColor = (key) => { + if (key === "4连板+") return "#ff4d4f"; + if (key === "3连板") return "#fa541c"; + if (key === "2连板") return "#fa8c16"; + return "#52c41a"; +}; + +/** + * 获取时间颜色 + */ +const getTimeColor = (key) => { + if (key === "秒板") return "#ff4d4f"; + if (key === "早盘") return "#fa8c16"; + if (key === "盘中") return "#52c41a"; + return "#888"; +}; + +/** + * 统计卡片基础样式 + */ +const cardStyle = { + flex: 1, + minWidth: "200px", + padding: "12px", + background: "rgba(255,255,255,0.03)", + borderRadius: "12px", + border: "1px solid rgba(255,255,255,0.08)", +}; + +/** + * 涨停统计卡片组件 + * @param {Object} props + * @param {Object} props.stats - 统计数据 + * @param {Object} props.stats.continuousStats - 连板分布 + * @param {Object} props.stats.timeStats - 时间分布 + * @param {number} props.stats.announcementCount - 公告驱动数 + * @param {number} props.stats.announcementRatio - 公告驱动占比 + */ +const ZTStatsCards = ({ stats }) => { + if (!stats) { + return null; + } + + return ( +
+ {/* 连板分布 */} +
+ + 连板分布 + +
+ {Object.entries(stats.continuousStats).map(([key, value]) => ( +
+ + {value} + + + {key} + +
+ ))} +
+
+ + {/* 涨停时间分布 */} +
+ + 封板时间 + +
+ {Object.entries(stats.timeStats).map(([key, value]) => ( +
+ + {value} + + + {key} + +
+ ))} +
+
+ + {/* 公告驱动 */} +
+ + 公告驱动 + +
+ + {stats.announcementCount} + + + 只 ({stats.announcementRatio}%) + +
+
+
+ ); +}; + +export default ZTStatsCards; diff --git a/src/views/Community/components/HeroPanel/components/index.js b/src/views/Community/components/HeroPanel/components/index.js new file mode 100644 index 00000000..8de31be0 --- /dev/null +++ b/src/views/Community/components/HeroPanel/components/index.js @@ -0,0 +1,7 @@ +// HeroPanel 子组件导出 +export * from "./DetailModal"; +export { default as CalendarCell } from "./CalendarCell"; +export { default as InfoModal } from "./InfoModal"; +export { default as CombinedCalendar } from "./CombinedCalendar"; +export { default as HotKeywordsCloud } from "./HotKeywordsCloud"; +export { default as ZTStatsCards } from "./ZTStatsCards"; From a426fb22b63ad96dcb743b4f05310a325e2c2b34 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Thu, 15 Jan 2026 11:42:44 +0800 Subject: [PATCH 15/22] =?UTF-8?q?refactor(HeroPanel):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E5=85=A5=E5=8F=A3=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 统一导出主组件、常量、工具函数、表格渲染器 - 支持 import HeroPanel from './HeroPanel' 和模块化导入 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/views/Community/components/HeroPanel/index.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 src/views/Community/components/HeroPanel/index.js diff --git a/src/views/Community/components/HeroPanel/index.js b/src/views/Community/components/HeroPanel/index.js new file mode 100644 index 00000000..804490cc --- /dev/null +++ b/src/views/Community/components/HeroPanel/index.js @@ -0,0 +1,12 @@ +// HeroPanel 模块入口 +// 导出主组件 +export { default } from '../HeroPanel'; + +// 导出常量 +export * from './constants'; + +// 导出工具函数 +export * from './utils'; + +// 导出表格渲染器 +export * from './columns'; From 69f587ad08dc02932f76ad6765ec53a39e8d0391 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Thu, 15 Jan 2026 11:44:09 +0800 Subject: [PATCH 16/22] =?UTF-8?q?refactor(HeroPanel):=20=E4=B8=BB=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E9=87=8D=E6=9E=84=20&=20=E5=88=A0=E9=99=A4=E5=BA=9F?= =?UTF-8?q?=E5=BC=83=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HeroPanel 重构: - 从 3000+ 行精简到 219 行,代码提取到子模块 - 导入模块化组件:CombinedCalendar, DetailModal - 导入样式:animations.css StockChangeIndicators 修复: - 修复 React Hooks 规则违规:useColorModeValue 移至组件顶层 - 颜色选择函数不再调用 Hook,只做值选择 其他: - 删除废弃的 EventEffectivenessStats.js - 更新 Community/README.md 目录结构文档 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/components/StockChangeIndicators.js | 139 +- src/views/Community/README.md | 233 +- .../components/EventEffectivenessStats.js | 362 -- src/views/Community/components/HeroPanel.js | 3237 +---------------- 4 files changed, 423 insertions(+), 3548 deletions(-) delete mode 100644 src/views/Community/components/EventEffectivenessStats.js diff --git a/src/components/StockChangeIndicators.js b/src/components/StockChangeIndicators.js index 68f4970b..b5179fd7 100644 --- a/src/components/StockChangeIndicators.js +++ b/src/components/StockChangeIndicators.js @@ -5,7 +5,6 @@ import React from 'react'; import { Flex, Box, Text, useColorModeValue } from '@chakra-ui/react'; import { ChevronUp, ChevronDown } from 'lucide-react'; import { TbArrowBigUpFilled, TbArrowBigDownFilled } from 'react-icons/tb'; -import { getChangeColor } from '../utils/colorUtils'; /** * 股票涨跌幅指标组件(3个指标:平均超额、最大超额、超预期得分) @@ -27,50 +26,95 @@ const StockChangeIndicators = ({ const isComfortable = size === 'comfortable'; const isDefault = size === 'default'; - // 根据涨跌幅获取数字颜色(动态深浅) + // ============ 在组件顶层调用所有 useColorModeValue ============ + // 涨跌幅数字颜色 + const colors = { + // 灰色系(null 值、0 值) + grayText: useColorModeValue('gray.700', 'gray.400'), + grayTextAlt: useColorModeValue('gray.600', 'gray.400'), + grayBg: useColorModeValue('gray.50', 'gray.800'), + grayBorder: useColorModeValue('gray.200', 'gray.700'), + // 红色系(上涨) + redHigh: useColorModeValue('red.600', 'red.300'), + redMid: useColorModeValue('red.500', 'red.300'), + redLow: useColorModeValue('red.400', 'red.200'), + redBg: useColorModeValue('red.50', 'red.900'), + redBorder: useColorModeValue('red.200', 'red.700'), + redScore: useColorModeValue('red.600', 'red.400'), + // 绿色系(下跌) + greenHigh: useColorModeValue('green.600', 'green.300'), + greenMid: useColorModeValue('green.500', 'green.300'), + greenLow: useColorModeValue('green.400', 'green.200'), + greenBg: useColorModeValue('green.50', 'green.900'), + greenBorder: useColorModeValue('green.200', 'green.700'), + // 橙色系(中等分数) + orangeScore: useColorModeValue('orange.600', 'orange.400'), + orangeBg: useColorModeValue('orange.50', 'orange.900'), + orangeBorder: useColorModeValue('orange.200', 'orange.700'), + // 蓝色系(低分数) + blueScore: useColorModeValue('blue.600', 'blue.400'), + blueBg: useColorModeValue('blue.50', 'blue.900'), + blueBorder: useColorModeValue('blue.200', 'blue.700'), + }; + + // 标签颜色 + const labelColor = colors.grayTextAlt; + + // ============ 颜色选择函数(不调用 Hook,只做选择)============ + // 根据涨跌幅获取数字颜色 const getNumberColor = (value) => { - if (value == null) { - return useColorModeValue('gray.700', 'gray.400'); - } + if (value == null) return colors.grayText; + if (value === 0) return colors.grayTextAlt; - // 使用动态颜色函数 - return getChangeColor(value); + const absValue = Math.abs(value); + if (value > 0) { + if (absValue >= 3) return colors.redHigh; + if (absValue >= 1) return colors.redMid; + return colors.redLow; + } else { + if (absValue >= 3) return colors.greenHigh; + if (absValue >= 1) return colors.greenMid; + return colors.greenLow; + } }; - // 根据涨跌幅获取背景色(永远比文字色浅) + // 根据涨跌幅获取背景色 const getBgColor = (value) => { - if (value == null) { - return useColorModeValue('gray.50', 'gray.800'); - } - - // 0值使用中性灰色背景 - if (value === 0) { - return useColorModeValue('gray.50', 'gray.800'); - } - - // 统一背景色:上涨红色系,下跌绿色系 - return value > 0 - ? useColorModeValue('red.50', 'red.900') - : useColorModeValue('green.50', 'green.900'); + if (value == null || value === 0) return colors.grayBg; + return value > 0 ? colors.redBg : colors.greenBg; }; - // 根据涨跌幅获取边框色(比背景深,比文字浅) + // 根据涨跌幅获取边框色 const getBorderColor = (value) => { - if (value == null) { - return useColorModeValue('gray.200', 'gray.700'); - } - - // 0值使用中性灰色边框 - if (value === 0) { - return useColorModeValue('gray.200', 'gray.700'); - } - - // 统一边框色:上涨红色系,下跌绿色系 - return value > 0 - ? useColorModeValue('red.200', 'red.700') - : useColorModeValue('green.200', 'green.700'); + if (value == null || value === 0) return colors.grayBorder; + return value > 0 ? colors.redBorder : colors.greenBorder; }; + // 根据分数获取颜色 + const getScoreColor = (score) => { + if (score >= 60) return colors.redScore; + if (score >= 40) return colors.orangeScore; + if (score >= 20) return colors.blueScore; + return colors.grayTextAlt; + }; + + // 根据分数获取背景色 + const getScoreBgColor = (score) => { + if (score >= 60) return colors.redBg; + if (score >= 40) return colors.orangeBg; + if (score >= 20) return colors.blueBg; + return colors.grayBg; + }; + + // 根据分数获取边框色 + const getScoreBorderColor = (score) => { + if (score >= 60) return colors.redBorder; + if (score >= 40) return colors.orangeBorder; + if (score >= 20) return colors.blueBorder; + return colors.grayBorder; + }; + + // ============ 渲染函数 ============ // 渲染单个指标 const renderIndicator = (label, value) => { if (value == null) return null; @@ -81,7 +125,6 @@ const StockChangeIndicators = ({ const numberColor = getNumberColor(value); const bgColor = getBgColor(value); const borderColor = getBorderColor(value); - const labelColor = useColorModeValue('gray.600', 'gray.400'); return ( { if (score == null) return null; - const labelColor = useColorModeValue('gray.600', 'gray.400'); - - // 根据分数确定颜色:>=60红色,>=40橙色,>=20蓝色,其他灰色 - const getScoreColor = (s) => { - if (s >= 60) return useColorModeValue('red.600', 'red.400'); - if (s >= 40) return useColorModeValue('orange.600', 'orange.400'); - if (s >= 20) return useColorModeValue('blue.600', 'blue.400'); - return useColorModeValue('gray.600', 'gray.400'); - }; - - const getScoreBgColor = (s) => { - if (s >= 60) return useColorModeValue('red.50', 'red.900'); - if (s >= 40) return useColorModeValue('orange.50', 'orange.900'); - if (s >= 20) return useColorModeValue('blue.50', 'blue.900'); - return useColorModeValue('gray.50', 'gray.800'); - }; - - const getScoreBorderColor = (s) => { - if (s >= 60) return useColorModeValue('red.200', 'red.700'); - if (s >= 40) return useColorModeValue('orange.200', 'orange.700'); - if (s >= 20) return useColorModeValue('blue.200', 'blue.700'); - return useColorModeValue('gray.200', 'gray.700'); - }; - const scoreColor = getScoreColor(score); const bgColor = getScoreBgColor(score); const borderColor = getScoreBorderColor(score); diff --git a/src/views/Community/README.md b/src/views/Community/README.md index 0952464d..796cb470 100644 --- a/src/views/Community/README.md +++ b/src/views/Community/README.md @@ -6,34 +6,90 @@ ``` src/views/Community/ -├── index.js # 页面入口 -├── components/ # 组件目录 -│ ├── SearchFilters/ # 搜索筛选模块 +├── index.js # 页面入口 +├── components/ # 组件目录 +│ ├── SearchFilters/ # 搜索筛选模块 │ │ ├── CompactSearchBox.js -│ │ ├── CompactSearchBox.css │ │ ├── TradingTimeFilter.js │ │ └── index.js -│ ├── EventCard/ # 事件卡片模块 -│ │ ├── atoms/ # 原子组件 +│ ├── EventCard/ # 事件卡片模块 +│ │ ├── atoms/ # 原子组件 +│ │ │ ├── EventDescription.js +│ │ │ ├── EventEngagement.js +│ │ │ ├── EventFollowButton.js +│ │ │ ├── EventHeader.js +│ │ │ ├── EventImportanceBadge.js +│ │ │ ├── EventPriceDisplay.js +│ │ │ ├── EventStats.js +│ │ │ ├── EventTimeline.js +│ │ │ ├── ImportanceBadge.js +│ │ │ ├── ImportanceStamp.js +│ │ │ ├── KeywordsCarousel.js +│ │ │ └── index.js +│ │ ├── CompactEventCard.js +│ │ ├── DetailedEventCard.js +│ │ ├── DynamicNewsEventCard.js +│ │ ├── HorizontalDynamicNewsEventCard.js +│ │ ├── MiniEventCard.js │ │ └── index.js -│ ├── HotEvents/ # 热点事件模块 -│ │ ├── HotEvents.js -│ │ ├── HotEvents.css -│ │ ├── HotEventsSection.js -│ │ └── index.js -│ ├── DynamicNews/ # 动态新闻模块 -│ │ ├── layouts/ +│ ├── DynamicNews/ # 动态新闻模块 │ │ ├── hooks/ +│ │ │ ├── usePagination.js +│ │ │ └── index.js +│ │ ├── layouts/ +│ │ │ ├── MainlineTimelineView.js +│ │ │ ├── VerticalModeLayout.js +│ │ │ └── index.js +│ │ ├── constants.js +│ │ ├── DynamicNewsCard.js +│ │ ├── EventDetailScrollPanel.js +│ │ ├── EventScrollList.js +│ │ ├── ModeToggleButtons.js +│ │ ├── PaginationControl.js │ │ └── index.js -│ ├── EventDetailModal/ # 事件详情弹窗模块 +│ ├── EventDetailModal/ # 事件详情弹窗模块 │ │ ├── EventDetailModal.tsx -│ │ ├── EventDetailModal.less │ │ └── index.ts -│ └── HeroPanel.js # 英雄面板(独立组件) -└── hooks/ # 页面级 Hooks +│ ├── HeroPanel/ # 英雄面板模块(重构版) +│ │ ├── columns/ # 表格列定义 +│ │ │ ├── index.js # 统一导出 +│ │ │ ├── renderers.js # 通用渲染器 +│ │ │ ├── stockColumns.js # 事件关联股票列 +│ │ │ ├── sectorColumns.js # 涨停板块列 +│ │ │ ├── ztStockColumns.js # 涨停个股列 +│ │ │ └── eventColumns.js # 未来事件列 +│ │ ├── components/ # 子组件 +│ │ │ ├── DetailModal/ # 详情弹窗子模块 +│ │ │ │ ├── EventsTabView.js +│ │ │ │ ├── RelatedEventsModal.js +│ │ │ │ ├── SectorStocksModal.js +│ │ │ │ ├── ZTSectorView.js +│ │ │ │ ├── ZTStockListView.js +│ │ │ │ └── index.js +│ │ │ ├── CalendarCell.js # 日历单元格 +│ │ │ ├── CombinedCalendar.js # 组合日历视图 +│ │ │ ├── InfoModal.js # 信息弹窗 +│ │ │ ├── HotKeywordsCloud.js # 热门关键词云 +│ │ │ ├── ZTStatsCards.js # 涨停统计卡片 +│ │ │ └── index.js +│ │ ├── constants/ # 常量定义 +│ │ │ └── index.js +│ │ ├── hooks/ # 自定义 Hooks +│ │ │ ├── useDetailModalState.js +│ │ │ └── index.js +│ │ ├── styles/ # 样式文件 +│ │ │ └── animations.css +│ │ ├── utils/ # 工具函数 +│ │ │ └── index.js +│ │ └── index.js +│ ├── EventDailyStats.js # 事件每日统计 +│ ├── HeroPanel.js # 英雄面板(主入口) +│ ├── MarketOverviewBanner.js # 市场概览横幅 +│ └── ThemeCometChart.js # 主题彗星图 +└── hooks/ # 页面级 Hooks + ├── useCommunityEvents.js ├── useEventData.js - ├── useEventFilters.js - └── useCommunityEvents.js + └── useEventFilters.js ``` --- @@ -42,10 +98,10 @@ src/views/Community/ 路径:`components/SearchFilters/` -| 文件 | 行数 | 功能 | -|------|------|------| -| `CompactSearchBox.js` | 612 | 紧凑搜索框,集成关键词搜索、概念/行业筛选 | -| `TradingTimeFilter.js` | 491 | 交易时间筛选器,被 CompactSearchBox 引用 | +| 文件 | 功能 | +|------|------| +| `CompactSearchBox.js` | 紧凑搜索框,集成关键词搜索、概念/行业筛选 | +| `TradingTimeFilter.js` | 交易时间筛选器,被 CompactSearchBox 引用 | **使用方式**: ```javascript @@ -66,6 +122,7 @@ import { CompactSearchBox } from './components/SearchFilters'; | `DetailedEventCard.js` | 详细事件卡片(展开模式) | | `DynamicNewsEventCard.js` | 动态新闻事件卡片 | | `HorizontalDynamicNewsEventCard.js` | 水平布局新闻卡片 | +| `MiniEventCard.js` | 迷你事件卡片 | ### 原子组件(atoms/) @@ -77,6 +134,7 @@ import { CompactSearchBox } from './components/SearchFilters'; | `EventPriceDisplay.js` | 股价显示 | | `EventTimeline.js` | 事件时间线 | | `EventFollowButton.js` | 关注按钮 | +| `EventEngagement.js` | 事件互动数据 | | `EventImportanceBadge.js` | 重要性徽章 | | `ImportanceBadge.js` | 通用重要性徽章 | | `ImportanceStamp.js` | 重要性印章 | @@ -93,23 +151,7 @@ import { EventHeader, EventTimeline } from './components/EventCard/atoms'; --- -## 3. HotEvents 模块(热点事件) - -路径:`components/HotEvents/` - -| 文件 | 功能 | -|------|------| -| `HotEvents.js` | 热点事件列表渲染 | -| `HotEventsSection.js` | 热点事件区块容器 | - -**使用方式**: -```javascript -import { HotEventsSection } from './components/HotEvents'; -``` - ---- - -## 4. DynamicNews 模块(动态新闻) +## 3. DynamicNews 模块(动态新闻) 路径:`components/DynamicNews/` @@ -117,7 +159,7 @@ import { HotEventsSection } from './components/HotEvents'; | 文件 | 功能 | |------|------| -| `DynamicNewsCard.js` | 主列表容器(695行) | +| `DynamicNewsCard.js` | 主列表容器 | | `EventScrollList.js` | 事件滚动列表 | | `EventDetailScrollPanel.js` | 事件详情滚动面板 | | `ModeToggleButtons.js` | 模式切换按钮 | @@ -129,7 +171,7 @@ import { HotEventsSection } from './components/HotEvents'; | 文件 | 功能 | |------|------| | `VerticalModeLayout.js` | 垂直布局模式 | -| `VirtualizedFourRowGrid.js` | 虚拟滚动四行网格(性能优化) | +| `MainlineTimelineView.js` | 主线时间线视图 | ### Hooks(hooks/) @@ -145,14 +187,13 @@ import { usePagination } from './components/DynamicNews/hooks'; --- -## 5. EventDetailModal 模块(事件详情弹窗) +## 4. EventDetailModal 模块(事件详情弹窗) 路径:`components/EventDetailModal/` | 文件 | 功能 | |------|------| | `EventDetailModal.tsx` | 事件详情弹窗(TypeScript) | -| `EventDetailModal.less` | 弹窗样式 | **使用方式**: ```javascript @@ -161,21 +202,115 @@ import EventDetailModal from './components/EventDetailModal'; --- +## 5. HeroPanel 模块(英雄面板) + +路径:`components/HeroPanel/` + `components/HeroPanel.js` + +### 主入口 +- `HeroPanel.js` - 英雄面板主组件(首页指数K线 + 概念词云 + 日历) + +### 子模块结构 + +| 目录 | 功能 | +|------|------| +| `columns/` | 表格列定义(工厂函数模式) | +| `components/` | 子组件集合 | +| `constants/` | 常量定义(颜色、热度等级等) | +| `hooks/` | 自定义 Hooks | +| `styles/` | 样式文件(动画 CSS) | +| `utils/` | 工具函数(日期、股票代码处理等) | + +### columns/ 表格列定义 + +| 文件 | 功能 | +|------|------| +| `stockColumns.js` | 事件关联股票表格列(现价、涨跌幅、关联理由、研报引用) | +| `sectorColumns.js` | 涨停板块表格列(排名、板块名称、涨停数、涨停股票、涨停归因) | +| `ztStockColumns.js` | 涨停个股表格列(股票信息、涨停时间、连板、核心板块、涨停简报) | +| `eventColumns.js` | 未来事件表格列(时间、重要度、标题、背景、未来推演、相关股票) | +| `renderers.js` | 通用列渲染器 | + +### components/ 子组件 + +| 文件 | 功能 | +|------|------| +| `CalendarCell.js` | 日历单元格(显示涨停数/事件数热度) | +| `CombinedCalendar.js` | 组合日历视图(FullCalendar 封装) | +| `InfoModal.js` | 信息弹窗 | +| `HotKeywordsCloud.js` | 热门关键词云(今日热词展示) | +| `ZTStatsCards.js` | 涨停统计卡片(连板分布、封板时间、公告驱动) | +| `DetailModal/` | 详情弹窗子模块 | + +### DetailModal/ 详情弹窗 + +| 文件 | 功能 | +|------|------| +| `EventsTabView.js` | 事件标签页视图 | +| `RelatedEventsModal.js` | 相关事件弹窗(涨停归因详情) | +| `SectorStocksModal.js` | 板块股票弹窗 | +| `ZTSectorView.js` | 涨停板块视图 | +| `ZTStockListView.js` | 涨停股票列表视图 | + +### hooks/ 自定义 Hooks + +| 文件 | 功能 | +|------|------| +| `useDetailModalState.js` | 详情弹窗状态管理(整合 17 个状态) | + +**使用方式**: +```javascript +// 使用主组件 +import HeroPanel from './components/HeroPanel'; + +// 使用列定义工厂函数 +import { createStockColumns, createSectorColumns } from './components/HeroPanel/columns'; + +// 使用子组件 +import { HotKeywordsCloud, ZTStatsCards } from './components/HeroPanel/components'; +``` + +--- + ## 6. 独立组件 路径:`components/` -| 文件 | 行数 | 功能 | -|------|------|------| -| `HeroPanel.js` | 972 | 首页英雄面板(指数K线 + 概念词云) | +| 文件 | 功能 | +|------|------| +| `MarketOverviewBanner.js` | 市场概览横幅,展示指数行情 | +| `ThemeCometChart.js` | 主题彗星图,可视化概念热度 | +| `EventDailyStats.js` | 事件每日统计面板 | -**说明**: -- `HeroPanel.js` 使用懒加载,包含 ECharts (~600KB) +--- + +## 页面级 Hooks + +路径:`hooks/` + +| 文件 | 功能 | +|------|------| +| `useCommunityEvents.js` | 社区事件数据获取与管理 | +| `useEventData.js` | 事件数据处理 | +| `useEventFilters.js` | 事件筛选逻辑 | --- ## 更新日志 +- **2026-01-13**: HeroPanel 模块重构优化 + - 新增 `columns/` 表格列定义文件(stockColumns、sectorColumns、ztStockColumns、eventColumns) + - 新增 `HotKeywordsCloud.js` 热门关键词云组件 + - 新增 `ZTStatsCards.js` 涨停统计卡片组件 + - HeroPanel.js 从 2,299 行优化至 1,257 行(减少 45%) + - 采用工厂函数模式提取列定义,支持 useMemo 缓存 + +- **2026-01-13**: 目录结构同步更新 + - 移除已删除的 `HotEvents/` 模块 + - 新增 `HeroPanel/` 模块结构说明 + - 新增独立组件说明(MarketOverviewBanner、ThemeCometChart、EventDailyStats) + - 删除未引用组件 `EventEffectivenessStats.js` + - 完善 `EventCard/atoms/` 原子组件列表 + - **2024-12-09**: 目录结构重组 - 创建 `SearchFilters/` 模块(含 CSS) - 创建 `EventCard/atoms/` 原子组件目录 diff --git a/src/views/Community/components/EventEffectivenessStats.js b/src/views/Community/components/EventEffectivenessStats.js deleted file mode 100644 index 8ab0327f..00000000 --- a/src/views/Community/components/EventEffectivenessStats.js +++ /dev/null @@ -1,362 +0,0 @@ -/** - * EventEffectivenessStats - 事件有效性统计 - * 展示事件中心的事件有效性数据,证明系统推荐价值 - */ -import React, { useState, useEffect, useMemo, useCallback } from 'react'; -import { - Box, - Text, - VStack, - HStack, - Spinner, - Center, - useToast, - Stat, - StatLabel, - StatNumber, - StatHelpText, - StatArrow, - Progress, - Badge, - Divider, - Tooltip, - Icon, -} from '@chakra-ui/react'; -import { - TrophyOutlined, - RiseOutlined, - FireOutlined, - CheckCircleOutlined, - ThunderboltOutlined, - StarOutlined, -} from '@ant-design/icons'; -import { getApiBase } from '@utils/apiConfig'; - -/** - * 格式化涨跌幅 - */ -const formatChg = (val) => { - if (val === null || val === undefined) return '-'; - const num = parseFloat(val); - if (isNaN(num)) return '-'; - return (num >= 0 ? '+' : '') + num.toFixed(2) + '%'; -}; - -/** - * 获取涨跌幅颜色 - */ -const getChgColor = (val) => { - if (val === null || val === undefined) return 'gray.400'; - const num = parseFloat(val); - if (isNaN(num)) return 'gray.400'; - if (num > 0) return '#FF4D4F'; - if (num < 0) return '#52C41A'; - return 'gray.400'; -}; - -/** - * 数据卡片组件 - */ -const StatCard = ({ label, value, icon, color = '#FFD700', subText, trend, progress }) => ( - - - - {icon} - - - {label} - - - - {value} - - {subText && ( - - {subText} - - )} - {trend !== undefined && ( - - = 0 ? 'increase' : 'decrease'} /> - = 0 ? '#FF4D4F' : '#52C41A'}> - {Math.abs(trend).toFixed(1)}% - - - )} - {progress !== undefined && ( - = 60 ? 'green' : progress >= 40 ? 'yellow' : 'red'} - mt={2} - borderRadius="full" - bg="rgba(255,255,255,0.1)" - /> - )} - -); - -/** - * 热门事件列表项 - */ -const TopEventItem = ({ event, rank }) => ( - - - {rank} - - - - {event.title} - - - - {formatChg(event.max_chg)} - - -); - -const EventEffectivenessStats = () => { - const [loading, setLoading] = useState(true); - const [stats, setStats] = useState(null); - const [error, setError] = useState(null); - const toast = useToast(); - - const fetchStats = useCallback(async () => { - setLoading(true); - setError(null); - try { - const apiBase = getApiBase(); - const response = await fetch(`${apiBase}/api/v1/events/effectiveness-stats?days=30`); - if (!response.ok) throw new Error('获取数据失败'); - const data = await response.json(); - if (data.code === 200) { - setStats(data.data); - } else { - throw new Error(data.message || '数据格式错误'); - } - } catch (err) { - console.error('获取事件有效性统计失败:', err); - setError(err.message); - toast({ - title: '获取统计数据失败', - description: err.message, - status: 'error', - duration: 3000, - isClosable: true, - }); - } finally { - setLoading(false); - } - }, [toast]); - - useEffect(() => { - fetchStats(); - }, [fetchStats]); - - if (loading) { - return ( - -
- -
-
- ); - } - - if (error || !stats) { - return ( - -
- - 暂无数据 - {error} - -
-
- ); - } - - const { summary, topPerformers = [] } = stats; - - return ( - - {/* 背景装饰 */} - - - {/* 标题 */} - - - - 事件有效性统计 - - - 近30天 - - - - - {/* 核心指标 - 2列网格 */} - - } - color="#FFD700" - subText="活跃事件" - /> - } - color={summary?.positiveRate >= 50 ? '#52C41A' : '#FF4D4F'} - progress={summary?.positiveRate || 0} - /> - } - color={getChgColor(summary?.avgChg)} - subText="关联股票" - /> - } - color="#FF4D4F" - subText="单事件最佳" - /> - - - {/* 评分指标 */} - - } - color="#F59E0B" - progress={summary?.avgInvestScore || 0} - subText="平均评分" - /> - } - color="#8B5CF6" - progress={summary?.avgSurpriseScore || 0} - subText="惊喜程度" - /> - - - {/* 分割线 */} - - - {/* TOP表现事件 */} - - - - - TOP 表现事件 - - - - {topPerformers.slice(0, 5).map((event, idx) => ( - - ))} - {topPerformers.length === 0 && ( - - 暂无数据 - - )} - - - - - ); -}; - -export default EventEffectivenessStats; diff --git a/src/views/Community/components/HeroPanel.js b/src/views/Community/components/HeroPanel.js index fe446121..15e4fcd1 100644 --- a/src/views/Community/components/HeroPanel.js +++ b/src/views/Community/components/HeroPanel.js @@ -2,9 +2,7 @@ // 综合日历面板:融合涨停分析 + 投资日历 // 点击日期弹出详情弹窗(TAB切换历史涨停/未来事件) -import React, { useEffect, useState, useCallback, useMemo, memo, lazy, Suspense } from 'react'; -import { useSelector, useDispatch } from 'react-redux'; -import { loadWatchlist, toggleWatchlist } from '@store/slices/stockSlice'; +import React, { useState } from "react"; import { Box, Card, @@ -13,3043 +11,32 @@ import { VStack, HStack, Text, - Heading, - useColorModeValue, - useDisclosure, - Icon, - Spinner, - Center, - Modal, - ModalOverlay, - ModalContent, - ModalHeader, - ModalBody, - ModalCloseButton, - Tooltip, - Badge, - SimpleGrid, - IconButton, - Drawer, - DrawerOverlay, - DrawerContent, - DrawerHeader, - DrawerBody, - DrawerCloseButton, -} from '@chakra-ui/react'; -import { Table, Tabs, Tag, Space, Button, Spin, Typography, message } from 'antd'; -import { - CalendarOutlined, - StarFilled, - LinkOutlined, - StockOutlined, - TagsOutlined, - ClockCircleOutlined, - RobotOutlined, - FireOutlined, - LineChartOutlined, - StarOutlined, -} from '@ant-design/icons'; -import { AlertCircle, Clock, Info, Calendar, ChevronLeft, ChevronRight, Flame, TrendingUp, TrendingDown, FileText, Star } from 'lucide-react'; -import { GLASS_BLUR } from '@/constants/glassConfig'; -import { eventService } from '@services/eventService'; -import { getApiBase } from '@utils/apiConfig'; -import ReactMarkdown from 'react-markdown'; -import dayjs from 'dayjs'; -import KLineChartModal from '@components/StockChart/KLineChartModal'; -// 懒加载 FullCalendar(约 60KB gzip,延迟加载提升首屏性能) -const FullCalendarPro = lazy(() => - import('@components/Calendar').then(module => ({ default: module.FullCalendarPro })) -); -import ThemeCometChart from './ThemeCometChart'; -import EventDailyStats from './EventDailyStats'; - -const { TabPane } = Tabs; -const { Text: AntText } = Typography; - -// 定义动画和深色主题样式 -const animations = ` - @keyframes pulse { - 0%, 100% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.6; transform: scale(1.1); } - } - @keyframes shimmer { - 0% { background-position: -200% 0; } - 100% { background-position: 200% 0; } - } - - /* Ant Design 深色主题覆盖 - 弹窗专用 */ - .hero-panel-modal .ant-tabs { - color: rgba(255, 255, 255, 0.85); - } - .hero-panel-modal .ant-tabs-nav::before { - border-color: rgba(255, 215, 0, 0.2) !important; - } - .hero-panel-modal .ant-tabs-tab { - color: rgba(255, 255, 255, 0.65) !important; - font-size: 15px !important; - } - .hero-panel-modal .ant-tabs-tab:hover { - color: #FFD700 !important; - } - .hero-panel-modal .ant-tabs-tab-active .ant-tabs-tab-btn { - color: #FFD700 !important; - } - .hero-panel-modal .ant-tabs-ink-bar { - background: linear-gradient(90deg, #FFD700, #FFA500) !important; - } - - /* 表格深色主题 */ - .hero-panel-modal .ant-table { - background: transparent !important; - color: rgba(255, 255, 255, 0.85) !important; - } - .hero-panel-modal .ant-table-thead > tr > th { - background: rgba(255, 215, 0, 0.1) !important; - color: #FFD700 !important; - border-bottom: 1px solid rgba(255, 215, 0, 0.2) !important; - font-weight: 600 !important; - font-size: 14px !important; - } - .hero-panel-modal .ant-table-tbody > tr > td { - background: transparent !important; - border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important; - color: rgba(255, 255, 255, 0.85) !important; - font-size: 14px !important; - } - .hero-panel-modal .ant-table-tbody > tr:hover > td { - background: rgba(255, 215, 0, 0.08) !important; - } - .hero-panel-modal .ant-table-tbody > tr.ant-table-row:hover > td { - background: rgba(255, 215, 0, 0.1) !important; - } - .hero-panel-modal .ant-table-cell-row-hover { - background: rgba(255, 215, 0, 0.08) !important; - } - .hero-panel-modal .ant-table-placeholder { - background: transparent !important; - } - .hero-panel-modal .ant-empty-description { - color: rgba(255, 255, 255, 0.45) !important; - } - - /* 滚动条样式 */ - .hero-panel-modal .ant-table-body::-webkit-scrollbar { - width: 6px; - height: 6px; - } - .hero-panel-modal .ant-table-body::-webkit-scrollbar-track { - background: rgba(255, 255, 255, 0.05); - border-radius: 3px; - } - .hero-panel-modal .ant-table-body::-webkit-scrollbar-thumb { - background: rgba(255, 215, 0, 0.3); - border-radius: 3px; - } - .hero-panel-modal .ant-table-body::-webkit-scrollbar-thumb:hover { - background: rgba(255, 215, 0, 0.5); - } - - /* 板块股票表格滚动 - 针对 Ant Design 5.x */ - .sector-stocks-table-wrapper { - max-height: 450px; - overflow: hidden; - } - .sector-stocks-table-wrapper .ant-table-wrapper, - .sector-stocks-table-wrapper .ant-table, - .sector-stocks-table-wrapper .ant-table-container { - max-height: 100%; - } - .sector-stocks-table-wrapper .ant-table-body { - max-height: 380px !important; - overflow-y: auto !important; - scrollbar-width: thin; - scrollbar-color: rgba(255, 215, 0, 0.4) rgba(255, 255, 255, 0.05); - } - - /* 相关股票表格滚动 */ - .related-stocks-table-wrapper .ant-table-body { - scrollbar-width: thin; - scrollbar-color: rgba(255, 215, 0, 0.4) rgba(255, 255, 255, 0.05); - } - - /* Tag 样式优化 */ - .hero-panel-modal .ant-tag { - border-radius: 4px !important; - } - - /* Button link 样式 */ - .hero-panel-modal .ant-btn-link { - color: #FFD700 !important; - } - .hero-panel-modal .ant-btn-link:hover { - color: #FFA500 !important; - } - .hero-panel-modal .ant-btn-link:disabled { - color: rgba(255, 255, 255, 0.25) !important; - } - - /* Typography 样式 */ - .hero-panel-modal .ant-typography { - color: rgba(255, 255, 255, 0.85) !important; - } - .hero-panel-modal .ant-typography-secondary { - color: rgba(255, 255, 255, 0.45) !important; - } - - /* Spin 加载样式 */ - .hero-panel-modal .ant-spin-text { - color: #FFD700 !important; - } - .hero-panel-modal .ant-spin-dot-item { - background-color: #FFD700 !important; - } -`; - -// 注入样式 -if (typeof document !== 'undefined') { - const styleId = 'hero-panel-animations'; - if (!document.getElementById(styleId)) { - const styleSheet = document.createElement('style'); - styleSheet.id = styleId; - styleSheet.innerText = animations; - document.head.appendChild(styleSheet); - } -} - -/** - * 判断当前是否在交易时间内 - */ -const isInTradingTime = () => { - const now = new Date(); - const timeInMinutes = now.getHours() * 60 + now.getMinutes(); - return timeInMinutes >= 570 && timeInMinutes <= 900; -}; - -// 主题色配置 -const goldColors = { - primary: '#D4AF37', - light: '#F4D03F', - dark: '#B8860B', - glow: 'rgba(212, 175, 55, 0.4)', -}; - -const textColors = { - primary: '#ffffff', - secondary: 'rgba(255, 255, 255, 0.85)', - muted: 'rgba(255, 255, 255, 0.5)', -}; - -// 热度级别配置 -const HEAT_LEVELS = [ - { key: 'high', threshold: 80, colors: { bg: 'rgba(147, 51, 234, 0.55)', text: '#d8b4fe', border: 'rgba(147, 51, 234, 0.65)' } }, - { key: 'medium', threshold: 60, colors: { bg: 'rgba(239, 68, 68, 0.50)', text: '#fca5a5', border: 'rgba(239, 68, 68, 0.60)' } }, - { key: 'low', threshold: 40, colors: { bg: 'rgba(251, 146, 60, 0.45)', text: '#fed7aa', border: 'rgba(251, 146, 60, 0.55)' } }, - { key: 'cold', threshold: 0, colors: { bg: 'rgba(59, 130, 246, 0.35)', text: '#93c5fd', border: 'rgba(59, 130, 246, 0.45)' } }, -]; - -const DEFAULT_HEAT_COLORS = { - bg: 'rgba(60, 60, 70, 0.12)', - text: textColors.muted, - border: 'transparent', -}; - -const getHeatColor = (count) => { - if (!count) return DEFAULT_HEAT_COLORS; - const level = HEAT_LEVELS.find((l) => count >= l.threshold); - return level?.colors || DEFAULT_HEAT_COLORS; -}; - -// 日期格式化 -const formatDateStr = (date) => { - if (!date) return ''; - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - return `${year}${month}${day}`; -}; - -const WEEK_DAYS = ['日', '一', '二', '三', '四', '五', '六']; -const MONTH_NAMES = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']; - -/** - * 趋势图标 - */ -const TrendIcon = memo(({ current, previous }) => { - if (!current || !previous) return null; - const diff = current - previous; - if (diff === 0) return null; - - const isUp = diff > 0; - return ( - - ); -}); - -TrendIcon.displayName = 'TrendIcon'; - -/** - * 日历单元格 - 显示涨停数和事件数(加大尺寸) - * 新增:连续概念连接展示(connectLeft/connectRight 表示与左右格子是否同一概念) - */ -const CalendarCell = memo(({ date, ztData, eventCount, previousZtData, isSelected, isToday, isWeekend, onClick, connectLeft, connectRight }) => { - if (!date) { - return ; - } - - const hasZtData = !!ztData; - const hasEventData = eventCount > 0; - const ztCount = ztData?.count || 0; - const heatColors = getHeatColor(ztCount); - const topSector = ztData?.top_sector || ''; - - // 是否有连接线(连续概念) - const hasConnection = connectLeft || connectRight; - - // 周末无数据显示"休市" - if (isWeekend && !hasZtData && !hasEventData) { - return ( - - - {date.getDate()} - - - 休市 - - - ); - } - - // 正常日期 - return ( - - {`${date.getMonth() + 1}月${date.getDate()}日`} - {hasZtData && 涨停: {ztCount}家 {topSector && `| ${topSector}`}} - {hasEventData && 未来事件: {eventCount}个} - {!hasZtData && !hasEventData && 暂无数据} - - } - placement="top" - hasArrow - bg="rgba(15, 15, 22, 0.95)" - border="1px solid rgba(212, 175, 55, 0.3)" - borderRadius="10px" - > - onClick && onClick(date)} - w="full" - minH="75px" - > - {/* 今天标记 */} - {isToday && ( - - 今天 - - )} - - - {/* 日期 */} - - {date.getDate()} - - - {/* 涨停数 + 趋势 */} - {hasZtData && ( - - - - {ztCount} - - - - )} - - {/* 事件数 */} - {hasEventData && ( - - - - {eventCount} - - - )} - - {/* 主要板块 - 连续概念用连接样式 */} - {hasZtData && topSector && ( - - {/* 左连接线 */} - {connectLeft && ( - - )} - - {topSector} - - {/* 右连接线 */} - {connectRight && ( - - )} - - )} - - - - ); -}); - -CalendarCell.displayName = 'CalendarCell'; - -/** - * 详情弹窗组件 - 完整展示涨停分析和事件详情 - */ -const DetailModal = ({ isOpen, onClose, selectedDate, ztDetail, events, loading }) => { - const dispatch = useDispatch(); - const reduxWatchlist = useSelector(state => state.stock.watchlist); - - const [detailDrawerVisible, setDetailDrawerVisible] = useState(false); - const [selectedContent, setSelectedContent] = useState(null); - const [ztViewMode, setZtViewMode] = useState('sector'); // 'sector' | 'stock' - const [sectorStocksModalVisible, setSectorStocksModalVisible] = useState(false); // 板块股票弹窗 - const [selectedSectorInfo, setSelectedSectorInfo] = useState(null); // 选中的板块信息 - const [selectedSectorFilter, setSelectedSectorFilter] = useState(null); // 按个股视图的板块筛选 - const [stocksDrawerVisible, setStocksDrawerVisible] = useState(false); - const [selectedEventStocks, setSelectedEventStocks] = useState([]); - const [selectedEventTime, setSelectedEventTime] = useState(null); - const [selectedEventTitle, setSelectedEventTitle] = useState(''); - const [stockQuotes, setStockQuotes] = useState({}); - const [stockQuotesLoading, setStockQuotesLoading] = useState(false); - const [expandedReasons, setExpandedReasons] = useState({}); - const [klineModalVisible, setKlineModalVisible] = useState(false); - const [selectedKlineStock, setSelectedKlineStock] = useState(null); - // 关联事件弹窗状态 - const [relatedEventsModalVisible, setRelatedEventsModalVisible] = useState(false); - const [selectedRelatedEvents, setSelectedRelatedEvents] = useState({ sectorName: '', events: [] }); - - // 板块数据处理 - 必须在条件返回之前调用所有hooks - const sectorList = useMemo(() => { - if (!ztDetail?.sector_data) return []; - return Object.entries(ztDetail.sector_data) - .filter(([name]) => name !== '其他') - .map(([name, data]) => ({ - name, - count: data.count, - stocks: data.stock_codes || [], - // 新增:关联事件数据(涨停归因) - related_events: data.related_events || [], - })) - .sort((a, b) => b.count - a.count); - }, [ztDetail]); - - // 股票详情数据处理 - 支持两种字段名:stocks 和 stock_infos - // 按连板天数降序排列(高连板在前) - const stockList = useMemo(() => { - const stocksData = ztDetail?.stocks || ztDetail?.stock_infos; - if (!stocksData) return []; - - // 解析连板天数的辅助函数 - const parseContinuousDays = (text) => { - if (!text || text === '首板') return 1; - const match = text.match(/(\d+)/); - return match ? parseInt(match[1]) : 1; - }; - - return stocksData - .map(stock => ({ - ...stock, - key: stock.scode, - _continuousDays: parseContinuousDays(stock.continuous_days), // 用于排序 - })) - .sort((a, b) => b._continuousDays - a._continuousDays); // 降序排列 - }, [ztDetail]); - - // 筛选后的股票列表(按板块筛选) - const filteredStockList = useMemo(() => { - if (!selectedSectorFilter) return stockList; - // 根据选中板块筛选 - const sectorData = ztDetail?.sector_data?.[selectedSectorFilter]; - if (!sectorData?.stock_codes) return stockList; - const sectorStockCodes = new Set(sectorData.stock_codes); - return stockList.filter(stock => sectorStockCodes.has(stock.scode)); - }, [stockList, selectedSectorFilter, ztDetail]); - - // 热门关键词 - const hotKeywords = useMemo(() => { - if (!ztDetail?.word_freq_data) return []; - return ztDetail.word_freq_data.slice(0, 12); - }, [ztDetail]); - - // 涨停统计数据 - const ztStats = useMemo(() => { - if (!stockList.length) return null; - - // 连板分布统计 - const continuousStats = { '首板': 0, '2连板': 0, '3连板': 0, '4连板+': 0 }; - // 涨停时间分布统计 - const timeStats = { '秒板': 0, '早盘': 0, '盘中': 0, '尾盘': 0 }; - // 公告驱动统计 - let announcementCount = 0; - - stockList.forEach(stock => { - // 连板统计 - const days = stock.continuous_days || '首板'; - if (days === '首板' || days.includes('1')) { - continuousStats['首板']++; - } else { - const match = days.match(/(\d+)/); - const num = match ? parseInt(match[1]) : 1; - if (num === 2) continuousStats['2连板']++; - else if (num === 3) continuousStats['3连板']++; - else if (num >= 4) continuousStats['4连板+']++; - else continuousStats['首板']++; - } - - // 时间统计 - const time = stock.formatted_time || '15:00:00'; - if (time <= '09:30:00') timeStats['秒板']++; - else if (time <= '10:00:00') timeStats['早盘']++; - else if (time <= '14:00:00') timeStats['盘中']++; - else timeStats['尾盘']++; - - // 公告驱动 - if (stock.is_announcement) announcementCount++; - }); - - return { - total: stockList.length, - continuousStats, - timeStats, - announcementCount, - announcementRatio: stockList.length > 0 ? Math.round(announcementCount / stockList.length * 100) : 0 - }; - }, [stockList]); - - // 获取六位股票代码(去掉后缀)- 纯函数,不是hook - const getSixDigitCode = (code) => { - if (!code) return code; - return code.split('.')[0]; - }; - - // 检查股票是否已在自选中 - 必须在条件返回之前 - const isStockInWatchlist = useCallback((stockCode) => { - const sixDigitCode = getSixDigitCode(stockCode); - return reduxWatchlist?.some(item => - getSixDigitCode(item.stock_code) === sixDigitCode - ); - }, [reduxWatchlist]); - - // 条件返回必须在所有hooks之后 - if (!selectedDate) return null; - - const dateStr = `${selectedDate.getFullYear()}年${selectedDate.getMonth() + 1}月${selectedDate.getDate()}日`; - const isPastDate = selectedDate < new Date(new Date().setHours(0, 0, 0, 0)); - - // 渲染重要性星级 - const renderStars = (star) => { - const stars = []; - for (let i = 1; i <= 5; i++) { - stars.push( - - ); - } - return {stars}; - }; - - // 显示内容详情 - const showContentDetail = (content, title) => { - setSelectedContent({ content, title }); - setDetailDrawerVisible(true); - }; - - // 加载股票行情 - const loadStockQuotes = async (stocks) => { - if (!stocks || stocks.length === 0) return; - setStockQuotesLoading(true); - const quotes = {}; - - for (const stock of stocks) { - const code = getSixDigitCode(stock.code); - try { - const response = await fetch(`${getApiBase()}/api/market/trade/${code}?days=1`); - if (response.ok) { - const data = await response.json(); - if (data.success && data.data && data.data.length > 0) { - const latest = data.data[data.data.length - 1]; - quotes[stock.code] = { - price: latest.close, - change: latest.change_amount, - changePercent: latest.change_percent - }; - } - } - } catch (err) { - console.error('加载股票行情失败:', code, err); - } - } - - setStockQuotes(quotes); - setStockQuotesLoading(false); - }; - - // 显示相关股票 - const showRelatedStocks = (stocks, eventTime, eventTitle) => { - if (!stocks || stocks.length === 0) return; - - // 归一化股票数据格式 - const normalizedStocks = stocks.map(stock => { - if (typeof stock === 'object' && !Array.isArray(stock)) { - return { - code: stock.code || stock.stock_code || '', - name: stock.name || stock.stock_name || '', - description: stock.description || stock.relation_desc || '', - score: stock.score || 0, - report: stock.report || null, - }; - } - if (Array.isArray(stock)) { - return { - code: stock[0] || '', - name: stock[1] || '', - description: stock[2] || '', - score: stock[3] || 0, - report: null, - }; - } - return null; - }).filter(Boolean); - - // 按相关度排序 - const sortedStocks = normalizedStocks.sort((a, b) => (b.score || 0) - (a.score || 0)); - - setSelectedEventStocks(sortedStocks); - setSelectedEventTime(eventTime); - setSelectedEventTitle(eventTitle); - setStocksDrawerVisible(true); - setExpandedReasons({}); - loadStockQuotes(sortedStocks); - }; - - // 添加交易所后缀 - const addExchangeSuffix = (code) => { - const sixDigitCode = getSixDigitCode(code); - if (code.includes('.')) return code; - if (sixDigitCode.startsWith('6')) { - return `${sixDigitCode}.SH`; - } else if (sixDigitCode.startsWith('0') || sixDigitCode.startsWith('3')) { - return `${sixDigitCode}.SZ`; - } - return sixDigitCode; - }; - - // 显示K线图 - const showKline = (stock) => { - const code = stock.code; - const name = stock.name; - const stockCode = addExchangeSuffix(code); - - setSelectedKlineStock({ - stock_code: stockCode, - stock_name: name, - }); - setKlineModalVisible(true); - }; - - // 添加单只股票到自选 - const addSingleToWatchlist = async (stock) => { - const code = stock.code; - const name = stock.name; - const stockCode = getSixDigitCode(code); - - if (isStockInWatchlist(code)) { - message.info(`${name} 已在自选中`); - return; - } - - try { - await dispatch(toggleWatchlist({ - stockCode, - stockName: name, - isInWatchlist: false - })).unwrap(); - - message.success(`已将 ${name}(${stockCode}) 添加到自选`); - } catch (error) { - console.error('添加自选失败:', error); - message.error('添加失败,请重试'); - } - }; - - // 相关股票表格列定义(和投资日历保持一致) - const stockColumns = [ - { - title: '代码', - dataIndex: 'code', - key: 'code', - width: 90, - render: (code) => { - const sixDigitCode = getSixDigitCode(code); - return ( - - {sixDigitCode} - - ); - } - }, - { - title: '名称', - dataIndex: 'name', - key: 'name', - width: 100, - render: (name, record) => { - const sixDigitCode = getSixDigitCode(record.code); - return ( - - {name} - - ); - } - }, - { - title: '现价', - key: 'price', - width: 80, - render: (_, record) => { - const quote = stockQuotes[record.code]; - if (quote && quote.price !== undefined) { - return ( - 0 ? 'danger' : 'success'}> - {quote.price?.toFixed(2)} - - ); - } - return -; - } - }, - { - title: '涨跌幅', - key: 'change', - width: 100, - render: (_, record) => { - const quote = stockQuotes[record.code]; - if (quote && quote.changePercent !== undefined) { - const changePercent = quote.changePercent || 0; - return ( - 0 ? 'red' : changePercent < 0 ? 'green' : 'default'}> - {changePercent > 0 ? '+' : ''}{changePercent.toFixed(2)}% - - ); - } - return -; - } - }, - { - title: '关联理由', - dataIndex: 'description', - key: 'reason', - render: (description, record) => { - const stockCode = record.code; - const isExpanded = expandedReasons[stockCode] || false; - const reason = typeof description === 'string' ? description : ''; - const shouldTruncate = reason && reason.length > 80; - - const toggleExpanded = () => { - setExpandedReasons(prev => ({ - ...prev, - [stockCode]: !prev[stockCode] - })); - }; - - return ( -
- - {isExpanded || !shouldTruncate - ? reason || '-' - : `${reason?.slice(0, 80)}...` - } - - {shouldTruncate && ( - - )} - {reason && ( -
- (AI合成) -
- )} -
- ); - } - }, - { - title: '研报引用', - dataIndex: 'report', - key: 'report', - width: 180, - render: (report) => { - if (!report || !report.title) { - return -; - } - return ( -
- -
- - {report.title.length > 18 ? `${report.title.slice(0, 18)}...` : report.title} - - {report.author && ( - - {report.author} - - )} - {report.declare_date && ( - - {dayjs(report.declare_date).format('YYYY-MM-DD')} - - )} - {report.match_score && ( - - 匹配度: {report.match_score} - - )} -
-
-
- ); - } - }, - { - title: 'K线图', - key: 'kline', - width: 80, - render: (_, record) => ( - - ) - }, - { - title: '操作', - key: 'action', - width: 90, - render: (_, record) => { - const inWatchlist = isStockInWatchlist(record.code); - return ( - - ); - } - }, - ]; - - // 涨停板块表格列 - 精致风格设计 - const sectorColumns = [ - { - title: '排名', - key: 'rank', - width: 60, - align: 'center', - render: (_, __, index) => { - const getRankStyle = (idx) => { - if (idx === 0) return { background: 'linear-gradient(135deg, #FFD700 0%, #FFA500 100%)', color: '#000', fontWeight: 'bold' }; - if (idx === 1) return { background: 'linear-gradient(135deg, #C0C0C0 0%, #A8A8A8 100%)', color: '#000', fontWeight: 'bold' }; - if (idx === 2) return { background: 'linear-gradient(135deg, #CD7F32 0%, #A0522D 100%)', color: '#fff', fontWeight: 'bold' }; - return { background: 'rgba(255,255,255,0.1)', color: '#888' }; - }; - const style = getRankStyle(index); - return ( -
- {index + 1} -
- ); - }, - }, - { - title: '板块名称', - dataIndex: 'name', - key: 'name', - width: 130, - render: (name, record, index) => ( - - - - {name} - - - ), - }, - { - title: '涨停数', - dataIndex: 'count', - key: 'count', - width: 90, - align: 'center', - render: (count) => { - const getCountColor = (c) => { - if (c >= 8) return { bg: '#ff4d4f', text: '#fff' }; - if (c >= 5) return { bg: '#fa541c', text: '#fff' }; - if (c >= 3) return { bg: '#fa8c16', text: '#fff' }; - return { bg: 'rgba(255,215,0,0.2)', text: '#FFD700' }; - }; - const colors = getCountColor(count); - return ( - - - - {count} - - - ); - }, - }, - { - title: '涨停股票', - dataIndex: 'stocks', - key: 'stocks', - render: (stocks, record) => { - // 根据股票代码查找股票详情,并按连板天数排序 - const getStockInfoList = () => { - return stocks - .map(code => { - const stockInfo = stockList.find(s => s.scode === code); - return stockInfo || { sname: code, scode: code, _continuousDays: 1 }; - }) - .sort((a, b) => (b._continuousDays || 1) - (a._continuousDays || 1)); - }; - - const stockInfoList = getStockInfoList(); - const displayStocks = stockInfoList.slice(0, 4); - - const handleShowAll = (e) => { - e.stopPropagation(); - setSelectedSectorInfo({ - name: record.name, - count: record.count, - stocks: stockInfoList, - }); - setSectorStocksModalVisible(true); - }; - - return ( - - {displayStocks.map((info) => ( - -
{info.sname}
-
{info.scode}
- {info.continuous_days && ( -
- {info.continuous_days} -
- )} -
- } - placement="top" - > - = 3 - ? 'rgba(255, 77, 79, 0.2)' - : info._continuousDays >= 2 - ? 'rgba(250, 140, 22, 0.2)' - : 'rgba(59, 130, 246, 0.15)', - border: info._continuousDays >= 3 - ? '1px solid rgba(255, 77, 79, 0.4)' - : info._continuousDays >= 2 - ? '1px solid rgba(250, 140, 22, 0.4)' - : '1px solid rgba(59, 130, 246, 0.3)', - borderRadius: '6px', - }} - > - = 3 ? '#ff4d4f' : info._continuousDays >= 2 ? '#fa8c16' : '#60A5FA', - fontSize: '13px' - }} - > - {info.sname} - {info._continuousDays > 1 && ( - - ({info._continuousDays}板) - - )} - - - - ))} - {stocks.length > 4 && ( - - )} - - ); - }, - }, - { - title: '涨停归因', - dataIndex: 'related_events', - key: 'related_events', - width: 280, - render: (events, record) => { - if (!events || events.length === 0) { - return -; - } - - // 取相关度最高的事件 - const sortedEvents = [...events].sort((a, b) => (b.relevance_score || 0) - (a.relevance_score || 0)); - const topEvent = sortedEvents[0]; - - // 相关度颜色 - const getRelevanceColor = (score) => { - if (score >= 80) return '#10B981'; - if (score >= 60) return '#F59E0B'; - return '#6B7280'; - }; - - // 点击打开事件详情弹窗 - const handleClick = (e) => { - e.stopPropagation(); - setSelectedRelatedEvents({ - sectorName: record.name, - events: sortedEvents, - count: record.count, - }); - setRelatedEventsModalVisible(true); - }; - - return ( - - - - - - - {topEvent.title} - - - - 相关度 {topEvent.relevance_score || 0} - - {events.length > 1 && ( - - +{events.length - 1}条 - - )} - - - - - - ); - }, - }, - ]; - - // 涨停股票详情表格列 - 精致风格 + K线图 + 加自选 - const ztStockColumns = [ - { - title: '股票信息', - key: 'stock', - width: 140, - fixed: 'left', - render: (_, record) => ( - - - {record.sname} - - {record.scode} - - ), - }, - { - title: '涨停时间', - dataIndex: 'formatted_time', - key: 'time', - width: 90, - align: 'center', - render: (time) => { - const getTimeStyle = (t) => { - if (t <= '09:30:00') return { bg: '#ff4d4f', text: '#fff', label: '秒板' }; - if (t <= '09:35:00') return { bg: '#fa541c', text: '#fff', label: '早板' }; - if (t <= '10:00:00') return { bg: '#fa8c16', text: '#fff', label: '盘初' }; - if (t <= '11:00:00') return { bg: '#52c41a', text: '#fff', label: '盘中' }; - return { bg: 'rgba(255,255,255,0.1)', text: '#888', label: '尾盘' }; - }; - const style = getTimeStyle(time || '15:00:00'); - return ( - - - {time?.substring(0, 5) || '-'} - - {style.label} - - ); - }, - }, - { - title: '连板', - dataIndex: 'continuous_days', - key: 'continuous', - width: 70, - align: 'center', - render: (text) => { - if (!text || text === '首板') { - return ( - - 首板 - - ); - } - const match = text.match(/(\d+)/); - const days = match ? parseInt(match[1]) : 1; - const getDaysStyle = (d) => { - if (d >= 5) return { bg: 'linear-gradient(135deg, #ff4d4f 0%, #ff7875 100%)', text: '#fff' }; - if (d >= 3) return { bg: 'linear-gradient(135deg, #fa541c 0%, #ff7a45 100%)', text: '#fff' }; - if (d >= 2) return { bg: 'linear-gradient(135deg, #fa8c16 0%, #ffc53d 100%)', text: '#fff' }; - return { bg: 'rgba(255,255,255,0.1)', text: '#888' }; - }; - const style = getDaysStyle(days); - return ( - - {text} - - ); - }, - }, - { - title: '核心板块', - dataIndex: 'core_sectors', - key: 'sectors', - width: 200, - render: (sectors) => ( - - {(sectors || []).slice(0, 3).map((sector, idx) => ( - - {sector} - - ))} - - ), - }, - { - title: '涨停简报', - dataIndex: 'brief', - key: 'brief', - width: 200, - render: (text, record) => { - if (!text) return -; - // 移除HTML标签 - const cleanText = text.replace(//gi, ' ').replace(/<[^>]+>/g, ''); - return ( - -
- {record.sname} 涨停简报 -
-
- {cleanText} -
-
- } - placement="topLeft" - overlayStyle={{ maxWidth: 450 }} - > - - - ); - }, - }, - { - title: 'K线图', - key: 'kline', - width: 80, - align: 'center', - render: (_, record) => ( - - ), - }, - { - title: '操作', - key: 'action', - width: 90, - align: 'center', - render: (_, record) => { - const code = record.scode; - const inWatchlist = isStockInWatchlist(code); - return ( - - ); - }, - }, - ]; - - // 事件表格列(参考投资日历)- 去掉相关概念列 - const eventColumns = [ - { - title: '时间', - dataIndex: 'calendar_time', - key: 'time', - width: 80, - render: (time) => ( - - - {dayjs(time).format('HH:mm')} - - ), - }, - { - title: '重要度', - dataIndex: 'star', - key: 'star', - width: 120, - render: renderStars, - }, - { - title: '标题', - dataIndex: 'title', - key: 'title', - ellipsis: true, - render: (text) => ( - - {text} - - ), - }, - { - title: '背景', - dataIndex: 'former', - key: 'former', - width: 80, - render: (text) => ( - - ), - }, - { - title: '未来推演', - dataIndex: 'forecast', - key: 'forecast', - width: 90, - render: (text) => ( - - ), - }, - { - title: '相关股票', - dataIndex: 'related_stocks', - key: 'stocks', - width: 120, - render: (stocks, record) => { - const hasStocks = stocks && stocks.length > 0; - if (!hasStocks) { - return ; - } - return ( - - ); - }, - }, - ]; - - return ( - <> - - - - - - - - - - - {dateStr} - - - - {isPastDate ? '历史数据' : '未来事件'} - - {ztDetail && ( - - 涨停 {ztDetail.total_stocks || 0} 家 - - )} - {events?.length > 0 && ( - - 事件 {events.length} 个 - - )} - - - - - - - - {loading ? ( -
- -
- ) : ( - - {/* 涨停分析 Tab */} - - - 涨停分析 ({ztDetail?.total_stocks || 0}) - - } - key="zt" - disabled={!ztDetail} - > - {(sectorList.length > 0 || stockList.length > 0) ? ( - - {/* 热门关键词 - 更精致的词云展示 */} - {hotKeywords.length > 0 && ( - - {/* 装饰线 */} - - - - - - - 今日热词 - - - 词频越高排名越前 - - - - {hotKeywords.map((kw, idx) => { - // 根据排名计算样式 - const getKeywordStyle = (index) => { - if (index < 3) return { - fontSize: '15px', - fontWeight: 'bold', - background: 'linear-gradient(135deg, rgba(255,215,0,0.3) 0%, rgba(255,165,0,0.2) 100%)', - border: '1px solid rgba(255,215,0,0.5)', - color: '#FFD700', - px: 3, - py: 1.5, - }; - if (index < 6) return { - fontSize: '14px', - fontWeight: 'semibold', - background: 'rgba(255,215,0,0.15)', - border: '1px solid rgba(255,215,0,0.3)', - color: '#D4A84B', - px: 2.5, - py: 1, - }; - return { - fontSize: '13px', - fontWeight: 'normal', - background: 'rgba(255,255,255,0.08)', - border: '1px solid rgba(255,255,255,0.15)', - color: '#888', - px: 2, - py: 0.5, - }; - }; - const style = getKeywordStyle(idx); - return ( - - {kw.name} - - ); - })} - - - )} - - {/* 涨停统计卡片 */} - {ztStats && ( - - {/* 连板分布 */} - - 连板分布 - - {Object.entries(ztStats.continuousStats).map(([key, value]) => ( - - - {value} - - {key} - - ))} - - - - {/* 涨停时间分布 */} - - 封板时间 - - {Object.entries(ztStats.timeStats).map(([key, value]) => ( - - - {value} - - {key} - - ))} - - - - {/* 公告驱动 */} - - 公告驱动 - - - {ztStats.announcementCount} - - - 只 ({ztStats.announcementRatio}%) - - - - - )} - - {/* 视图切换按钮 - 更精致的样式 */} - - - setZtViewMode('sector')} - transition="all 0.2s" - _hover={{ bg: 'rgba(255,215,0,0.15)' }} - display="flex" - alignItems="center" - gap={2} - > - - 按板块 ({sectorList.length}) - - setZtViewMode('stock')} - transition="all 0.2s" - _hover={{ bg: 'rgba(59,130,246,0.15)' }} - display="flex" - alignItems="center" - gap={2} - > - - 按个股 ({stockList.length}) - - - - - - - - {ztDetail?.total_stocks || 0} - - 只涨停 - - - - - - {/* 板块视图 */} - {ztViewMode === 'sector' && ( - -
- - )} - - {/* 个股视图 */} - {ztViewMode === 'stock' && ( - - {/* 板块筛选器 */} - - - 板块筛选: - setSelectedSectorFilter(null)} - transition="all 0.2s" - _hover={{ bg: 'rgba(255,215,0,0.15)' }} - > - 全部 ({stockList.length}) - - - - {sectorList.slice(0, 10).map((sector) => ( - setSelectedSectorFilter( - selectedSectorFilter === sector.name ? null : sector.name - )} - transition="all 0.2s" - _hover={{ bg: 'rgba(59,130,246,0.1)' }} - > - {sector.name} ({sector.count}) - - ))} - - - - {/* 筛选结果提示 */} - {selectedSectorFilter && ( - - - - 当前筛选:{selectedSectorFilter} - - - 共 {filteredStockList.length} 只 - - - - )} - - -
- - - )} - - ) : ( -
- - - - - - 暂无涨停数据 - 该日期没有涨停股票记录 - - -
- )} - - - {/* 未来事件 Tab */} - - - 未来事件 ({events?.length || 0}) - - } - key="event" - disabled={!events?.length} - > - {events?.length > 0 ? ( -
- ) : ( -
- - - 暂无事件数据 - -
- )} - - - )} - - - - - {/* 内容详情抽屉 */} - setDetailDrawerVisible(false)} - > - - - - - {selectedContent?.title} - - - - - {typeof selectedContent?.content === 'string' - ? selectedContent.content - : selectedContent?.content?.data - ? selectedContent.content.data.map(item => item.sentence || '').join('\n\n') - : '暂无内容'} - - - (AI合成内容) - - - - - - - {/* 相关股票弹窗 */} - { - setStocksDrawerVisible(false); - setExpandedReasons({}); - }} - size="6xl" - scrollBehavior="inside" - > - - - - - - - 相关股票 - {selectedEventTitle && ( - - {selectedEventTitle} - - )} - - - {selectedEventStocks?.length || 0}只 - - {stockQuotesLoading && } - - - - - {selectedEventStocks && selectedEventStocks.length > 0 ? ( - -
record.code} - size="middle" - pagination={false} - scroll={{ y: 500 }} - /> - - ) : ( -
- 暂无相关股票 -
- )} - - - - - {/* K线图弹窗 */} - {selectedKlineStock && ( - { - setKlineModalVisible(false); - setSelectedKlineStock(null); - }} - stock={selectedKlineStock} - eventTime={selectedEventTime} - size="5xl" - /> - )} - - {/* 板块股票弹窗 */} - { - setSectorStocksModalVisible(false); - setSelectedSectorInfo(null); - }} - size="4xl" - scrollBehavior="inside" - > - - - - - - - - - - - {selectedSectorInfo?.name} - - - {selectedSectorInfo?.count} 只涨停 - - - - 按连板天数降序排列 - - - - - - - {selectedSectorInfo?.stocks?.length > 0 ? ( - - {/* 快速统计 */} - - {(() => { - const stats = { '首板': 0, '2连板': 0, '3连板': 0, '4连板+': 0 }; - selectedSectorInfo.stocks.forEach(s => { - const days = s._continuousDays || 1; - if (days === 1) stats['首板']++; - else if (days === 2) stats['2连板']++; - else if (days === 3) stats['3连板']++; - else stats['4连板+']++; - }); - return Object.entries(stats).map(([key, value]) => ( - value > 0 && ( - - - {key}: {value} - - - ) - )); - })()} - - - {/* 股票列表 - 使用 Ant Design Table 内置滚动 */} - -
( - - - {record.sname} - - {record.scode} - - ), - }, - { - title: '连板', - dataIndex: 'continuous_days', - key: 'continuous', - width: 90, - align: 'center', - render: (text, record) => { - const days = record._continuousDays || 1; - const getDaysStyle = (d) => { - if (d >= 5) return { bg: 'linear-gradient(135deg, #ff4d4f 0%, #ff7875 100%)', text: '#fff' }; - if (d >= 3) return { bg: 'linear-gradient(135deg, #fa541c 0%, #ff7a45 100%)', text: '#fff' }; - if (d >= 2) return { bg: 'linear-gradient(135deg, #fa8c16 0%, #ffc53d 100%)', text: '#fff' }; - return { bg: 'rgba(255,255,255,0.1)', text: '#888' }; - }; - const style = getDaysStyle(days); - return ( - - {text || '首板'} - - ); - }, - }, - { - title: '涨停时间', - dataIndex: 'formatted_time', - key: 'time', - width: 90, - align: 'center', - render: (time) => { - const getTimeStyle = (t) => { - if (t <= '09:30:00') return { bg: '#ff4d4f', text: '#fff' }; - if (t <= '09:35:00') return { bg: '#fa541c', text: '#fff' }; - if (t <= '10:00:00') return { bg: '#fa8c16', text: '#fff' }; - return { bg: 'rgba(255,255,255,0.1)', text: '#888' }; - }; - const style = getTimeStyle(time || '15:00:00'); - return ( - - {time?.substring(0, 5) || '-'} - - ); - }, - }, - { - title: '核心板块', - dataIndex: 'core_sectors', - key: 'sectors', - render: (sectors) => ( - - {(sectors || []).slice(0, 2).map((sector, idx) => ( - - {sector} - - ))} - - ), - }, - { - title: 'K线图', - key: 'kline', - width: 80, - align: 'center', - render: (_, record) => ( - - ), - }, - { - title: '操作', - key: 'action', - width: 90, - align: 'center', - render: (_, record) => { - const code = record.scode; - const inWatchlist = isStockInWatchlist(code); - return ( - - ); - }, - }, - ]} - rowKey="scode" - size="small" - pagination={false} - scroll={{ x: 650, y: 450 }} - /> - - - ) : ( -
- 暂无股票数据 -
- )} - - - - - {/* 关联事件弹窗 - 涨停归因详情 */} - { - setRelatedEventsModalVisible(false); - setSelectedRelatedEvents({ sectorName: '', events: [] }); - }} - size="xl" - scrollBehavior="inside" - > - - - - - - - - - - - {selectedRelatedEvents.sectorName} - 涨停归因 - - - - - 涨停 {selectedRelatedEvents.count || 0} 只 - - - 关联事件 {selectedRelatedEvents.events?.length || 0} 条 - - - - - - - - {selectedRelatedEvents.events?.length > 0 ? ( - - {selectedRelatedEvents.events.map((event, idx) => { - const getRelevanceColor = (score) => { - if (score >= 80) return '#10B981'; - if (score >= 60) return '#F59E0B'; - return '#6B7280'; - }; - const relevanceColor = getRelevanceColor(event.relevance_score || 0); - - return ( - { - // 跳转到事件详情页 - window.open(`/community?event_id=${event.event_id}`, '_blank'); - }} - _hover={{ - bg: 'rgba(40,40,70,0.9)', - borderColor: 'rgba(96,165,250,0.3)', - transform: 'translateY(-2px)', - }} - transition="all 0.2s" - > - - {/* 标题 */} - - - - - {event.title} - - - - 相关度 {event.relevance_score || 0} - - - - {/* 相关原因 */} - {event.relevance_reason && ( - - {event.relevance_reason} - - )} - - {/* 匹配概念 */} - {event.matched_concepts?.length > 0 && ( - - - 匹配概念: - - - {event.matched_concepts.slice(0, 6).map((concept, i) => ( - - {concept} - - ))} - {event.matched_concepts.length > 6 && ( - - +{event.matched_concepts.length - 6} - - )} - - - )} - - - ); - })} - - ) : ( -
- 暂无关联事件 -
- )} -
-
-
- - ); -}; - -/** - * 综合日历组件 - 使用 FullCalendarPro 实现跨天事件条效果 - */ -const CombinedCalendar = () => { - const [currentMonth, setCurrentMonth] = useState(new Date()); - const [selectedDate, setSelectedDate] = useState(null); - - // 日历综合数据(涨停 + 事件 + 上证涨跌幅)- 使用新的综合 API - const [calendarData, setCalendarData] = useState([]); - const [ztDailyDetails, setZtDailyDetails] = useState({}); - const [selectedZtDetail, setSelectedZtDetail] = useState(null); - const [selectedEvents, setSelectedEvents] = useState([]); - - const [detailLoading, setDetailLoading] = useState(false); - const [modalOpen, setModalOpen] = useState(false); - - // 加载日历综合数据(一次 API 调用获取所有数据) - useEffect(() => { - const loadCalendarCombinedData = async () => { - try { - const year = currentMonth.getFullYear(); - const month = currentMonth.getMonth() + 1; - const response = await fetch(`${getApiBase()}/api/v1/calendar/combined-data?year=${year}&month=${month}`); - if (response.ok) { - const result = await response.json(); - if (result.success && result.data) { - // 转换为 FullCalendarPro 需要的格式 - const formattedData = result.data.map(item => ({ - date: item.date, - count: item.zt_count || 0, - topSector: item.top_sector || '', - eventCount: item.event_count || 0, - indexChange: item.index_change, - })); - console.log('[HeroPanel] 加载日历综合数据成功,数据条数:', formattedData.length); - setCalendarData(formattedData); - } - } - } catch (error) { - console.error('Failed to load calendar combined data:', error); - } - }; - loadCalendarCombinedData(); - }, [currentMonth]); - - - // 处理日期点击 - 打开弹窗 - const handleDateClick = useCallback(async (date) => { - setSelectedDate(date); - setModalOpen(true); - setDetailLoading(true); - - const ztDateStr = formatDateStr(date); - const eventDateStr = dayjs(date).format('YYYY-MM-DD'); - - // 加载涨停详情 - const detail = ztDailyDetails[ztDateStr]; - if (detail?.fullData) { - setSelectedZtDetail(detail.fullData); - } else { - try { - const response = await fetch(`/data/zt/daily/${ztDateStr}.json`); - if (response.ok) { - const data = await response.json(); - setSelectedZtDetail(data); - setZtDailyDetails(prev => ({ - ...prev, - [ztDateStr]: { ...prev[ztDateStr], fullData: data } - })); - } else { - setSelectedZtDetail(null); - } - } catch { - setSelectedZtDetail(null); - } - } - - // 加载事件详情 - try { - const response = await eventService.calendar.getEventsForDate(eventDateStr); - if (response.success) { - setSelectedEvents(response.data || []); - } else { - setSelectedEvents([]); - } - } catch { - setSelectedEvents([]); - } - - setDetailLoading(false); - }, [ztDailyDetails]); - - // 月份变化回调 - const handleMonthChange = useCallback((year, month) => { - setCurrentMonth(new Date(year, month - 1, 1)); - }, []); - - return ( - <> - - {/* 顶部装饰条 */} - - - {/* FullCalendar Pro - 炫酷跨天事件条日历(懒加载) */} - - - - 加载日历组件... - - - }> - - - - {/* 图例说明 */} - - - - 连续热门概念 - - - - 涨停≥60 - - - - 涨停<60 - - - - N - - 未来事件数 - - - +0.5% - / - -0.5% - 上证涨跌 - - - - - {/* 详情弹窗 */} - setModalOpen(false)} - selectedDate={selectedDate} - ztDetail={selectedZtDetail} - events={selectedEvents} - loading={detailLoading} - /> - - ); -}; - -/** - * 右侧 Tab 面板 - HeroUI 风格毛玻璃 - */ -const RightPanelTabs = () => { - // 默认显示日历 - const [activeTab, setActiveTab] = useState('calendar'); - - return ( - - {/* 背景光效 */} - - - {/* Tab 切换头 */} - - setActiveTab('calendar')} - > - - - 涨停与未来日历 - - - setActiveTab('comet')} - > - - - 连板情绪监测 - - - - - {/* Tab 内容区域 */} - - {activeTab === 'comet' ? ( - - - - ) : ( - - - - )} - - - ); -}; - -/** - * 使用说明弹窗组件 - */ -const InfoModal = () => { - const { isOpen, onOpen, onClose } = useDisclosure(); - - return ( - <> - - - - 使用说明 - - - - - - - - - - - - - 事件中心使用指南 - - - - - - - - - 📅 综合日历 - - 日历同时展示历史涨停数据和 - 未来事件, - 点击日期查看详细信息。 - - - - - 🔥 涨停板块 - - 点击历史日期,查看当日涨停板块排行、涨停数量、涨停股票代码,帮助理解市场主线。 - - - - - 📊 未来事件 - - 点击未来日期,查看事件详情,包括背景分析、 - 未来推演、 - 相关股票等。 - - - - - - 💡 颜色越深表示涨停数越多 · 绿色标记表示有未来事件 - - - - - - - - ); -}; +} from "@chakra-ui/react"; +import { CalendarOutlined, FireOutlined } from "@ant-design/icons"; +import ThemeCometChart from "./ThemeCometChart"; +import EventDailyStats from "./EventDailyStats"; +import MarketOverviewBanner from "./MarketOverviewBanner"; +// HeroPanel 模块化导入 +import "./HeroPanel/styles/animations.css"; +import { DetailModal, CombinedCalendar } from "./HeroPanel/components"; /** * 顶部说明面板主组件 */ const HeroPanel = () => { - const gradientBg = 'linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 25%, #16213e 50%, #1a1a2e 75%, #0a0a0a 100%)'; - const borderColor = useColorModeValue('rgba(255, 215, 0, 0.3)', 'rgba(255, 215, 0, 0.25)'); + const [activeTab, setActiveTab] = useState("calendar"); // "calendar" | "comet" + const gradientBg = + "linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 25%, #16213e 50%, #1a1a2e 75%, #0a0a0a 100%)"; return ( {/* 装饰性光晕 */} { filter="blur(50px)" /> - - {/* 标题行 */} - - - - - 事件中心 - - - - {isInTradingTime() && ( - - - - 交易中 - - - )} - - + + {/* 顶部通栏:事件中心概览 */} + + + - {/* AI舆情时空决策驾驶舱 - 左侧今日统计(2/5),右侧Tab切换(3/5) */} - - {/* 左侧:今日事件统计 */} - - + {/* 左侧导航+TOP10(3/10),右侧内容(7/10) */} + + {/* 左侧:导航标签 + TOP10 */} + + + {/* 财经日历标签 */} + setActiveTab("calendar")} + > + + + + 财经日历 + + + 历史涨停 · 未来事件 + + + + + {/* 连板情绪监测标签 */} + setActiveTab("comet")} + > + + + + 连板情绪监测 + + + + + {/* TOP10 排行 */} + + + + - {/* 右侧:连板情绪 / 日历 Tab 切换 */} - - + {/* 右侧:内容区域 - 根据 Tab 切换 */} + + {activeTab === "calendar" ? ( + + ) : ( + + + + )} From d15d637c4ed1027d96f95e40039c3961aff6172d Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Thu, 15 Jan 2026 11:44:53 +0800 Subject: [PATCH 17/22] =?UTF-8?q?refactor(ThemeCometChart):=20=E6=8F=90?= =?UTF-8?q?=E5=8F=96=E5=B8=B8=E9=87=8F=E3=80=81=E5=9B=BE=E8=A1=A8=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E3=80=81=E5=BC=B9=E7=AA=97=E5=88=B0=E5=AD=90=E6=A8=A1?= =?UTF-8?q?=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - constants.js: STATUS_CONFIG 板块状态配置 - chartOptions.js: generateChartOption 图表配置生成 - ThemeDetailModal.js: 板块详情弹窗组件 - index.js: 模块统一导出 主文件从 ~400 行精简到 ~180 行 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Community/components/ThemeCometChart.js | 378 +++++------------- .../ThemeCometChart/ThemeDetailModal.js | 150 +++++++ .../ThemeCometChart/chartOptions.js | 143 +++++++ .../components/ThemeCometChart/constants.js | 9 + .../components/ThemeCometChart/index.js | 5 + 5 files changed, 396 insertions(+), 289 deletions(-) create mode 100644 src/views/Community/components/ThemeCometChart/ThemeDetailModal.js create mode 100644 src/views/Community/components/ThemeCometChart/chartOptions.js create mode 100644 src/views/Community/components/ThemeCometChart/constants.js create mode 100644 src/views/Community/components/ThemeCometChart/index.js diff --git a/src/views/Community/components/ThemeCometChart.js b/src/views/Community/components/ThemeCometChart.js index 42f9ee23..2082c702 100644 --- a/src/views/Community/components/ThemeCometChart.js +++ b/src/views/Community/components/ThemeCometChart.js @@ -4,7 +4,7 @@ * Y轴:板块热度(涨停家数) * 支持时间滑动条查看历史数据 */ -import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import React, { useState, useEffect, useMemo, useCallback } from "react"; import { Box, Text, @@ -13,178 +13,34 @@ import { Spinner, Center, useToast, - Modal, - ModalOverlay, - ModalContent, - ModalHeader, - ModalBody, - ModalCloseButton, useDisclosure, - Table, - Thead, - Tbody, - Tr, - Th, - Td, - Badge, Slider, SliderTrack, SliderFilledTrack, SliderThumb, Tooltip as ChakraTooltip, -} from '@chakra-ui/react'; -import ReactECharts from 'echarts-for-react'; -import { ThunderboltOutlined, CalendarOutlined, StockOutlined, CaretUpFilled } from '@ant-design/icons'; -import { getApiBase } from '@utils/apiConfig'; +} from "@chakra-ui/react"; +import ReactECharts from "echarts-for-react"; +import { + ThunderboltOutlined, + CalendarOutlined, + CaretUpFilled, +} from "@ant-design/icons"; +import { getApiBase } from "@utils/apiConfig"; -// 板块状态配置 -const STATUS_CONFIG = { - rising: { name: '主升', color: '#FF4D4F' }, - declining: { name: '退潮', color: '#52C41A' }, - lurking: { name: '潜伏', color: '#1890FF' }, - clustering: { name: '抱团', color: '#722ED1' }, -}; - -/** - * 生成 ECharts 配置 - */ -const generateChartOption = (themes) => { - if (!themes || themes.length === 0) return {}; - - const groupedData = { - 主升: [], - 退潮: [], - 潜伏: [], - 抱团: [], - }; - - themes.forEach((theme) => { - const statusName = STATUS_CONFIG[theme.status]?.name || '抱团'; - groupedData[statusName].push({ - name: theme.label, - value: [theme.x, theme.y], - countTrend: theme.countTrend, - boardTrend: theme.boardTrend, - themeColor: theme.color, - history: theme.history, - }); - }); - - const series = Object.entries(STATUS_CONFIG).map(([key, config]) => ({ - name: config.name, - type: 'scatter', - data: groupedData[config.name] || [], - symbolSize: (data) => Math.max(25, Math.min(70, data[1] * 3.5)), - itemStyle: { - color: (params) => params.data.themeColor || config.color, - shadowBlur: 12, - shadowColor: (params) => params.data.themeColor || config.color, - opacity: 0.85, - }, - emphasis: { - itemStyle: { opacity: 1, shadowBlur: 25 }, - }, - label: { - show: true, - formatter: (params) => params.data.name, - position: 'right', - color: '#fff', - fontSize: 14, - fontWeight: 'bold', - textShadowColor: 'rgba(0,0,0,0.8)', - textShadowBlur: 4, - }, - })); - - const maxX = Math.max(...themes.map((t) => t.x), 5) + 1; - const maxY = Math.max(...themes.map((t) => t.y), 10) + 3; - - return { - backgroundColor: 'transparent', - tooltip: { - trigger: 'item', - backgroundColor: 'rgba(15, 15, 30, 0.95)', - borderColor: 'rgba(255, 215, 0, 0.3)', - borderWidth: 1, - textStyle: { color: '#fff' }, - formatter: (params) => { - const { name, value, countTrend, boardTrend, history } = params.data; - const countTrendText = countTrend > 0 ? `+${countTrend}` : countTrend; - const boardTrendText = boardTrend > 0 ? `+${boardTrend}` : boardTrend; - const countIcon = countTrend > 0 ? '🔥' : countTrend < 0 ? '❄️' : '➡️'; - - const historyText = (history || []) - .slice(0, 5) - .map((h) => `${h.date?.slice(5) || ''}: ${h.count}家/${h.maxBoard}板`) - .join('
'); - - return ` -
- ${name} ${countIcon} -
-
- 涨停家数: - ${value[1]}家 - (${countTrendText}) -
-
- 最高连板: - ${value[0]}板 - (${boardTrendText}) -
-
-
近5日趋势:
-
${historyText}
-
-
点击查看详情
- `; - }, - }, - legend: { - show: false, - }, - grid: { - left: '10%', - right: '8%', - top: '12%', - bottom: '8%', - containLabel: true, - }, - xAxis: { - type: 'value', - name: '辨识度(最高板)', - nameLocation: 'middle', - nameGap: 28, - nameTextStyle: { color: 'rgba(255, 255, 255, 0.6)', fontSize: 13 }, - min: 0, - max: maxX, - interval: 1, - axisLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.2)' } }, - axisLabel: { color: 'rgba(255, 255, 255, 0.6)', fontSize: 12, formatter: '{value}板' }, - splitLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.05)' } }, - }, - yAxis: { - type: 'value', - name: '热度(家数)', - nameLocation: 'middle', - nameGap: 40, - nameTextStyle: { color: 'rgba(255, 255, 255, 0.6)', fontSize: 13 }, - min: 0, - max: maxY, - axisLine: { show: false }, - axisLabel: { color: 'rgba(255, 255, 255, 0.6)', fontSize: 12 }, - splitLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.05)' } }, - }, - series, - }; -}; +// 模块化导入 +import { + STATUS_CONFIG, + generateChartOption, + ThemeDetailModal, +} from "./ThemeCometChart/index"; /** * ThemeCometChart 主组件 */ const ThemeCometChart = ({ onThemeSelect }) => { const [loading, setLoading] = useState(true); - const [allDatesData, setAllDatesData] = useState({}); // 缓存所有日期的数据 + const [allDatesData, setAllDatesData] = useState({}); const [availableDates, setAvailableDates] = useState([]); const [selectedTheme, setSelectedTheme] = useState(null); const [sliderIndex, setSliderIndex] = useState(0); @@ -197,7 +53,6 @@ const ThemeCometChart = ({ onThemeSelect }) => { setLoading(true); try { const apiBase = getApiBase(); - // 先获取最新数据,拿到可用日期列表 const response = await fetch(`${apiBase}/api/v1/zt/theme-scatter?days=5`); const result = await response.json(); @@ -205,13 +60,12 @@ const ThemeCometChart = ({ onThemeSelect }) => { const dates = result.data.availableDates || []; setAvailableDates(dates); - // 缓存第一个日期(最新)的数据 const latestDate = dates[0]?.date; const dataCache = {}; if (latestDate) { dataCache[latestDate] = { themes: result.data.themes || [], - currentDate: result.data.currentDate || '', + currentDate: result.data.currentDate || "", totalLimitUp: result.data.totalLimitUp || 0, totalEvents: result.data.totalEvents || 0, indexChange: result.data.indexChange || 0, @@ -222,13 +76,15 @@ const ThemeCometChart = ({ onThemeSelect }) => { const otherDates = dates.slice(1); const promises = otherDates.map(async (dateInfo) => { try { - const res = await fetch(`${apiBase}/api/v1/zt/theme-scatter?date=${dateInfo.date}&days=5`); + const res = await fetch( + `${apiBase}/api/v1/zt/theme-scatter?date=${dateInfo.date}&days=5` + ); const data = await res.json(); if (data.success && data.data) { return { date: dateInfo.date, themes: data.data.themes || [], - currentDate: data.data.currentDate || '', + currentDate: data.data.currentDate || "", totalLimitUp: data.data.totalLimitUp || 0, totalEvents: data.data.totalEvents || 0, indexChange: data.data.indexChange || 0, @@ -254,24 +110,27 @@ const ThemeCometChart = ({ onThemeSelect }) => { }); setAllDatesData(dataCache); - setSliderIndex(0); // 默认显示最新日期 + setSliderIndex(0); } else { - throw new Error(result.error || '加载失败'); + throw new Error(result.error || "加载失败"); } } catch (error) { - console.error('加载题材数据失败:', error); - toast({ title: '加载数据失败', description: error.message, status: 'error', duration: 3000 }); + console.error("加载题材数据失败:", error); + toast({ + title: "加载数据失败", + description: error.message, + status: "error", + duration: 3000, + }); } finally { setLoading(false); } }, [toast]); - // 初始加载所有数据 useEffect(() => { loadAllData(); }, [loadAllData]); - // 滑动条变化时实时切换数据 const handleSliderChange = useCallback((value) => { setSliderIndex(value); }, []); @@ -280,19 +139,24 @@ const ThemeCometChart = ({ onThemeSelect }) => { const currentDateStr = availableDates[sliderIndex]?.date; const currentData = allDatesData[currentDateStr] || { themes: [], - currentDate: '', + currentDate: "", totalLimitUp: 0, totalEvents: 0, - indexChange: 0 + indexChange: 0, }; const isCurrentDateLoaded = currentDateStr && allDatesData[currentDateStr]; - const chartOption = useMemo(() => generateChartOption(currentData.themes), [currentData.themes]); + const chartOption = useMemo( + () => generateChartOption(currentData.themes), + [currentData.themes] + ); const handleChartClick = useCallback( (params) => { if (params.data) { - const theme = currentData.themes.find((t) => t.label === params.data.name); + const theme = currentData.themes.find( + (t) => t.label === params.data.name + ); if (theme) { setSelectedTheme(theme); onOpen(); @@ -302,10 +166,13 @@ const ThemeCometChart = ({ onThemeSelect }) => { [currentData.themes, onOpen] ); - const onChartEvents = useMemo(() => ({ click: handleChartClick }), [handleChartClick]); + const onChartEvents = useMemo( + () => ({ click: handleChartClick }), + [handleChartClick] + ); - // 当前滑动条对应的日期 - const currentSliderDate = availableDates[sliderIndex]?.formatted || currentData.currentDate; + const currentSliderDate = + availableDates[sliderIndex]?.formatted || currentData.currentDate; if (loading && Object.keys(allDatesData).length === 0) { return ( @@ -327,30 +194,36 @@ const ThemeCometChart = ({ onThemeSelect }) => { h="100%" minH="350px" > - {/* 标题栏 - 两行布局 */} + {/* 标题栏 */} - {/* 第一行:日期 + 图例 */} - + {currentSliderDate || currentData.currentDate} - {/* 图例 */} {Object.values(STATUS_CONFIG).map((status) => ( - {status.name} + + {status.name} + ))} - {/* 第二行:标题 + 热度 + 事件 */} - - + + @@ -360,14 +233,24 @@ const ThemeCometChart = ({ onThemeSelect }) => { {loading && } - - 热度 - {currentData.totalLimitUp} + + + 热度 + + + {currentData.totalLimitUp} + - - 事件 - {currentData.totalEvents} + + + 事件 + + + {currentData.totalEvents} + @@ -379,15 +262,17 @@ const ThemeCometChart = ({ onThemeSelect }) => {
- 加载中... + + 加载中... +
) : currentData.themes.length > 0 ? ( ) : (
@@ -401,7 +286,8 @@ const ThemeCometChart = ({ onThemeSelect }) => { - {availableDates[availableDates.length - 1]?.formatted?.slice(5) || ''} + {availableDates[availableDates.length - 1]?.formatted?.slice(5) || + ""} { bg="#FFD700" border="2px solid" borderColor="orange.400" - _focus={{ boxShadow: '0 0 0 3px rgba(255,215,0,0.3)' }} + _focus={{ boxShadow: "0 0 0 3px rgba(255,215,0,0.3)" }} /> - {availableDates[0]?.formatted?.slice(5) || ''} + {availableDates[0]?.formatted?.slice(5) || ""} )} {/* 详情弹窗 */} - - - - - {selectedTheme?.label} - 近5日趋势 - - - - {selectedTheme && ( - - - 历史数据 -
- - - - - - - - - - {selectedTheme.history?.map((h, idx) => { - const prev = selectedTheme.history[idx + 1]; - const countChange = prev ? h.count - prev.count : 0; - return ( - - - - - - - ); - })} - -
日期涨停家数最高连板变化
{h.date}{h.count}家{h.maxBoard}板 - {countChange !== 0 && ( - 0 ? 'green' : 'red'}> - {countChange > 0 ? '+' : ''}{countChange} - - )} -
-
- - - - 涨停股票({selectedTheme.stocks?.length || 0}只) - - - - - - - - - - - - {selectedTheme.stocks?.slice(0, 20).map((stock) => ( - - - - - - ))} - -
代码名称连板
{stock.scode}{stock.sname} - = 3 ? 'red' : 'gray'}> - {stock.continuous_days}板 - -
-
-
- - {selectedTheme.matchedSectors?.length > 0 && ( - - 匹配板块 - - {selectedTheme.matchedSectors.map((sector) => ( - - {sector} - - ))} - - - )} - - )} - - - +
); }; diff --git a/src/views/Community/components/ThemeCometChart/ThemeDetailModal.js b/src/views/Community/components/ThemeCometChart/ThemeDetailModal.js new file mode 100644 index 00000000..c24341c1 --- /dev/null +++ b/src/views/Community/components/ThemeCometChart/ThemeDetailModal.js @@ -0,0 +1,150 @@ +// 题材详情弹窗组件 + +import React from "react"; +import { + Box, + Text, + VStack, + HStack, + Modal, + ModalOverlay, + ModalContent, + ModalHeader, + ModalBody, + ModalCloseButton, + Table, + Thead, + Tbody, + Tr, + Th, + Td, + Badge, +} from "@chakra-ui/react"; + +/** + * 题材详情弹窗 + */ +const ThemeDetailModal = ({ isOpen, onClose, theme }) => { + if (!theme) return null; + + return ( + + + + {theme.label} - 近5日趋势 + + + + {/* 历史数据表格 */} + + + 历史数据 + + + + + + + + + + + + {theme.history?.map((h, idx) => { + const prev = theme.history[idx + 1]; + const countChange = prev ? h.count - prev.count : 0; + return ( + + + + + + + ); + })} + +
日期 + 涨停家数 + + 最高连板 + 变化
{h.date} + {h.count}家 + + {h.maxBoard}板 + + {countChange !== 0 && ( + 0 ? "green" : "red"} + > + {countChange > 0 ? "+" : ""} + {countChange} + + )} +
+
+ + {/* 涨停股票表格 */} + + + 涨停股票({theme.stocks?.length || 0}只) + + + + + + + + + + + + {theme.stocks?.slice(0, 20).map((stock) => ( + + + + + + ))} + +
代码名称连板
+ {stock.scode} + {stock.sname} + = 3 ? "red" : "gray" + } + > + {stock.continuous_days}板 + +
+
+
+ + {/* 匹配板块 */} + {theme.matchedSectors?.length > 0 && ( + + + 匹配板块 + + + {theme.matchedSectors.map((sector) => ( + + {sector} + + ))} + + + )} +
+
+
+
+ ); +}; + +export default ThemeDetailModal; diff --git a/src/views/Community/components/ThemeCometChart/chartOptions.js b/src/views/Community/components/ThemeCometChart/chartOptions.js new file mode 100644 index 00000000..3aa9dd45 --- /dev/null +++ b/src/views/Community/components/ThemeCometChart/chartOptions.js @@ -0,0 +1,143 @@ +// ThemeCometChart ECharts 配置生成 + +import { STATUS_CONFIG } from "./constants"; + +/** + * 生成 ECharts 配置 + * @param {Array} themes - 题材数据列表 + * @returns {Object} - ECharts 配置对象 + */ +export const generateChartOption = (themes) => { + if (!themes || themes.length === 0) return {}; + + const groupedData = { + 主升: [], + 退潮: [], + 潜伏: [], + 抱团: [], + }; + + themes.forEach((theme) => { + const statusName = STATUS_CONFIG[theme.status]?.name || "抱团"; + groupedData[statusName].push({ + name: theme.label, + value: [theme.x, theme.y], + countTrend: theme.countTrend, + boardTrend: theme.boardTrend, + themeColor: theme.color, + history: theme.history, + }); + }); + + const series = Object.entries(STATUS_CONFIG).map(([key, config]) => ({ + name: config.name, + type: "scatter", + data: groupedData[config.name] || [], + symbolSize: (data) => Math.max(25, Math.min(70, data[1] * 3.5)), + itemStyle: { + color: (params) => params.data.themeColor || config.color, + shadowBlur: 12, + shadowColor: (params) => params.data.themeColor || config.color, + opacity: 0.85, + }, + emphasis: { + itemStyle: { opacity: 1, shadowBlur: 25 }, + }, + label: { + show: true, + formatter: (params) => params.data.name, + position: "right", + color: "#fff", + fontSize: 14, + fontWeight: "bold", + textShadowColor: "rgba(0,0,0,0.8)", + textShadowBlur: 4, + }, + })); + + const maxX = Math.max(...themes.map((t) => t.x), 5) + 1; + const maxY = Math.max(...themes.map((t) => t.y), 10) + 3; + + return { + backgroundColor: "transparent", + tooltip: { + trigger: "item", + backgroundColor: "rgba(15, 15, 30, 0.95)", + borderColor: "rgba(255, 215, 0, 0.3)", + borderWidth: 1, + textStyle: { color: "#fff" }, + formatter: (params) => { + const { name, value, countTrend, boardTrend, history } = params.data; + const countTrendText = countTrend > 0 ? `+${countTrend}` : countTrend; + const boardTrendText = boardTrend > 0 ? `+${boardTrend}` : boardTrend; + const countIcon = countTrend > 0 ? "🔥" : countTrend < 0 ? "❄️" : "➡️"; + + const historyText = (history || []) + .slice(0, 5) + .map((h) => `${h.date?.slice(5) || ""}: ${h.count}家/${h.maxBoard}板`) + .join("
"); + + return ` +
+ ${name} ${countIcon} +
+
+ 涨停家数: + ${value[1]}家 + (${countTrendText}) +
+
+ 最高连板: + ${value[0]}板 + (${boardTrendText}) +
+
+
近5日趋势:
+
${historyText}
+
+
点击查看详情
+ `; + }, + }, + legend: { + show: false, + }, + grid: { + left: "10%", + right: "8%", + top: "12%", + bottom: "8%", + containLabel: true, + }, + xAxis: { + type: "value", + name: "辨识度(最高板)", + nameLocation: "middle", + nameGap: 28, + nameTextStyle: { color: "rgba(255, 255, 255, 0.6)", fontSize: 13 }, + min: 0, + max: maxX, + interval: 1, + axisLine: { lineStyle: { color: "rgba(255, 255, 255, 0.2)" } }, + axisLabel: { + color: "rgba(255, 255, 255, 0.6)", + fontSize: 12, + formatter: "{value}板", + }, + splitLine: { lineStyle: { color: "rgba(255, 255, 255, 0.05)" } }, + }, + yAxis: { + type: "value", + name: "热度(家数)", + nameLocation: "middle", + nameGap: 40, + nameTextStyle: { color: "rgba(255, 255, 255, 0.6)", fontSize: 13 }, + min: 0, + max: maxY, + axisLine: { show: false }, + axisLabel: { color: "rgba(255, 255, 255, 0.6)", fontSize: 12 }, + splitLine: { lineStyle: { color: "rgba(255, 255, 255, 0.05)" } }, + }, + series, + }; +}; diff --git a/src/views/Community/components/ThemeCometChart/constants.js b/src/views/Community/components/ThemeCometChart/constants.js new file mode 100644 index 00000000..6a67e317 --- /dev/null +++ b/src/views/Community/components/ThemeCometChart/constants.js @@ -0,0 +1,9 @@ +// ThemeCometChart 常量定义 + +// 板块状态配置 +export const STATUS_CONFIG = { + rising: { name: "主升", color: "#FF4D4F" }, + declining: { name: "退潮", color: "#52C41A" }, + lurking: { name: "潜伏", color: "#1890FF" }, + clustering: { name: "抱团", color: "#722ED1" }, +}; diff --git a/src/views/Community/components/ThemeCometChart/index.js b/src/views/Community/components/ThemeCometChart/index.js new file mode 100644 index 00000000..1e60c45d --- /dev/null +++ b/src/views/Community/components/ThemeCometChart/index.js @@ -0,0 +1,5 @@ +// ThemeCometChart 模块导出 + +export { STATUS_CONFIG } from "./constants"; +export { generateChartOption } from "./chartOptions"; +export { default as ThemeDetailModal } from "./ThemeDetailModal"; From b826b1e7dc63713664f85468a34eabf547d3fce3 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Thu, 15 Jan 2026 11:45:00 +0800 Subject: [PATCH 18/22] =?UTF-8?q?refactor(MarketOverviewBanner):=20?= =?UTF-8?q?=E6=8F=90=E5=8F=96=E5=B8=B8=E9=87=8F=E3=80=81=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E3=80=81=E5=BC=B9=E7=AA=97=E5=88=B0=E5=AD=90=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - constants.js: 涨跌颜色、交易时间判断、格式化函数 - components.js: MarketStatsBarCompact, CircularProgressCard, BannerStatCard - StockTop10Modal.js: TOP10 股票弹窗 - index.js: 模块统一导出 主文件从 ~440 行精简到 ~180 行 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../components/MarketOverviewBanner.js | 441 ++---------------- .../MarketOverviewBanner/StockTop10Modal.js | 138 ++++++ .../MarketOverviewBanner/components.js | 261 +++++++++++ .../MarketOverviewBanner/constants.js | 43 ++ .../components/MarketOverviewBanner/index.js | 5 + 5 files changed, 479 insertions(+), 409 deletions(-) create mode 100644 src/views/Community/components/MarketOverviewBanner/StockTop10Modal.js create mode 100644 src/views/Community/components/MarketOverviewBanner/components.js create mode 100644 src/views/Community/components/MarketOverviewBanner/constants.js create mode 100644 src/views/Community/components/MarketOverviewBanner/index.js diff --git a/src/views/Community/components/MarketOverviewBanner.js b/src/views/Community/components/MarketOverviewBanner.js index 98ad8651..1c03ac1c 100644 --- a/src/views/Community/components/MarketOverviewBanner.js +++ b/src/views/Community/components/MarketOverviewBanner.js @@ -17,295 +17,31 @@ import { FireOutlined, RiseOutlined, FallOutlined, - ThunderboltOutlined, - TrophyOutlined, - BarChartOutlined, CalendarOutlined, - StockOutlined, + BarChartOutlined, } from "@ant-design/icons"; -import { Modal, Table } from "antd"; import { getApiBase } from "@utils/apiConfig"; -// 涨跌颜色常量 -const UP_COLOR = "#FF4D4F"; // 涨 - 红色 -const DOWN_COLOR = "#52C41A"; // 跌 - 绿色 -const FLAT_COLOR = "#888888"; // 平 - 灰色 - -/** - * 判断是否在交易时间内 (9:30-15:00) - */ -const isInTradingTime = () => { - const now = new Date(); - const hours = now.getHours(); - const minutes = now.getMinutes(); - const time = hours * 60 + minutes; - return time >= 570 && time <= 900; // 9:30-15:00 -}; - -// 注入脉冲动画样式 -if (typeof document !== "undefined") { - const styleId = "market-banner-animations"; - if (!document.getElementById(styleId)) { - const styleSheet = document.createElement("style"); - styleSheet.id = styleId; - styleSheet.innerText = ` - @keyframes pulse { - 0%, 100% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.6; transform: scale(1.1); } - } - `; - document.head.appendChild(styleSheet); - } -} - -/** - * 格式化涨跌幅 - */ -const formatChg = (val) => { - if (val === null || val === undefined) return "-"; - const num = parseFloat(val); - if (isNaN(num)) return "-"; - return (num >= 0 ? "+" : "") + num.toFixed(2) + "%"; -}; - -/** - * 沪深实时涨跌条形图组件 - 紧凑版 - */ -const MarketStatsBarCompact = ({ marketStats }) => { - if (!marketStats || marketStats.totalCount === 0) return null; - - const { - risingCount = 0, - flatCount = 0, - fallingCount = 0, - totalCount = 0, - } = marketStats; - const risePercent = totalCount > 0 ? (risingCount / totalCount) * 100 : 0; - const flatPercent = totalCount > 0 ? (flatCount / totalCount) * 100 : 0; - const fallPercent = totalCount > 0 ? (fallingCount / totalCount) * 100 : 0; - - return ( - - {/* 标题 */} - - - 沪深实时涨跌 - - - ({totalCount}只) - - - - {/* 进度条 */} - - - - - - - - - {/* 数值标签 */} - - - - - {risingCount} - - - 涨 - - - - - - - {flatCount} - - - 平 - - - - - - - {fallingCount} - - - 跌 - - - - - ); -}; - -/** - * 环形进度图组件 - 仿图片样式 - * @param {boolean} noBorder - 是否不显示边框(用于嵌套在其他容器中) - */ -const CircularProgressCard = ({ label, value, color = "#EC4899", size = 44, highlight = false, noBorder = false }) => { - const percentage = parseFloat(value) || 0; - const strokeWidth = 3; - const radius = (size - strokeWidth) / 2; - // 270度圆弧(底部有缺口) - const arcLength = (270 / 360) * 2 * Math.PI * radius; - const progressLength = (percentage / 100) * arcLength; - - return ( - - - {label} - - - - {/* 背景圆弧 */} - - {/* 进度圆弧 */} - - - {/* 中心数值 */} - - - {percentage.toFixed(1)}% - - - - - ); -}; - -/** - * 紧凑数据卡片 - 通栏版 - */ -const BannerStatCard = ({ label, value, icon, color = "#7C3AED", highlight = false }) => ( - - - - {icon} - - - {label} - - - - {value} - - -); +// 模块化导入 +import { + UP_COLOR, + DOWN_COLOR, + isInTradingTime, + formatChg, +} from "./MarketOverviewBanner/constants"; +import { + MarketStatsBarCompact, + CircularProgressCard, + BannerStatCard, +} from "./MarketOverviewBanner/components"; +import StockTop10Modal from "./MarketOverviewBanner/StockTop10Modal"; const MarketOverviewBanner = () => { const [loading, setLoading] = useState(true); const [stats, setStats] = useState(null); - const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split("T")[0]); + const [selectedDate, setSelectedDate] = useState( + new Date().toISOString().split("T")[0] + ); const [stockModalVisible, setStockModalVisible] = useState(false); const dateInputRef = useRef(null); @@ -337,7 +73,7 @@ const MarketOverviewBanner = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // 日期变化时静默刷新(不显示 loading) + // 日期变化时静默刷新 useEffect(() => { if (selectedDate) { fetchStats(selectedDate, false); @@ -371,6 +107,7 @@ const MarketOverviewBanner = () => { if (!stats) return null; const { summary, marketStats, topStocks = [] } = stats; + const today = new Date().toISOString().split("T")[0]; return ( @@ -420,9 +157,10 @@ const MarketOverviewBanner = () => { )} + - {/* 返回今天按钮 - 当选择的不是今天时显示 */} - {selectedDate !== new Date().toISOString().split("T")[0] && ( + {/* 返回今天按钮 */} + {selectedDate !== today && ( { borderColor: "rgba(255, 215, 0, 0.6)", }} transition="all 0.2s" - onClick={() => setSelectedDate(new Date().toISOString().split("T")[0])} + onClick={() => setSelectedDate(today)} > 返回今天 )} - {/* 日期选择器 - 金色边框 */} + {/* 日期选择器 */} { value={selectedDate} onChange={handleDateChange} onClick={handleCalendarClick} - max={new Date().toISOString().split("T")[0]} + max={today} bg="transparent" border="none" color="#FFD700" @@ -503,7 +241,7 @@ const MarketOverviewBanner = () => { - {/* 右侧:6个指标卡片 - 1行6列 */} + {/* 右侧:6个指标卡片 */} { color="#F59E0B" highlight /> - {/* 关联股票 + TOP10标签 - 与 BannerStatCard 样式统一 */} + {/* 关联股票卡片 */} { {/* 股票TOP10弹窗 */} - - - 股票 TOP10 - - } - open={stockModalVisible} - onCancel={() => setStockModalVisible(false)} - footer={null} - width={600} - closeIcon={×} - styles={{ - content: { - background: "linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)", - border: "1px solid rgba(236, 72, 153, 0.3)", - }, - header: { - background: "transparent", - borderBottom: "1px solid rgba(255,255,255,0.1)", - }, - }} - > - - record.stockCode || record.stockName} - pagination={false} - size="small" - className="stock-top10-table" - columns={[ - { - title: "排名", - dataIndex: "rank", - key: "rank", - width: 60, - render: (_, __, index) => ( - - {index + 1} - - ), - }, - { - title: "股票代码", - dataIndex: "stockCode", - key: "stockCode", - width: 100, - render: (code) => ( - - {code?.split(".")[0] || "-"} - - ), - }, - { - title: "股票名称", - dataIndex: "stockName", - key: "stockName", - render: (name) => ( - - {name || "-"} - - ), - }, - { - title: "最大涨幅", - dataIndex: "maxChg", - key: "maxChg", - width: 100, - align: "right", - render: (val) => ( - = 0 ? UP_COLOR : DOWN_COLOR} - > - {formatChg(val)} - - ), - }, - ]} - /> - + setStockModalVisible(false)} + topStocks={topStocks} + /> ); }; diff --git a/src/views/Community/components/MarketOverviewBanner/StockTop10Modal.js b/src/views/Community/components/MarketOverviewBanner/StockTop10Modal.js new file mode 100644 index 00000000..db6704d3 --- /dev/null +++ b/src/views/Community/components/MarketOverviewBanner/StockTop10Modal.js @@ -0,0 +1,138 @@ +// 股票 TOP10 弹窗组件 + +import React from "react"; +import { Text, HStack } from "@chakra-ui/react"; +import { StockOutlined } from "@ant-design/icons"; +import { Modal, Table } from "antd"; +import { UP_COLOR, DOWN_COLOR, formatChg } from "./constants"; + +// 弹窗内表格样式 +const modalTableStyles = ` + .stock-top10-table .ant-table { + background: transparent !important; + } + .stock-top10-table .ant-table-thead > tr > th { + background: rgba(236, 72, 153, 0.1) !important; + color: rgba(255, 255, 255, 0.8) !important; + border-bottom: 1px solid rgba(236, 72, 153, 0.2) !important; + font-weight: 600; + } + .stock-top10-table .ant-table-tbody > tr > td { + border-bottom: 1px solid rgba(255, 255, 255, 0.05) !important; + padding: 10px 8px !important; + } + .stock-top10-table .ant-table-tbody > tr:nth-child(odd) { + background: rgba(255, 255, 255, 0.02) !important; + } + .stock-top10-table .ant-table-tbody > tr:nth-child(even) { + background: rgba(0, 0, 0, 0.1) !important; + } + .stock-top10-table .ant-table-tbody > tr:hover > td { + background: rgba(236, 72, 153, 0.15) !important; + } + .stock-top10-table .ant-table-cell { + background: transparent !important; + } +`; + +// 表格列定义 +const tableColumns = [ + { + title: "排名", + dataIndex: "rank", + key: "rank", + width: 60, + render: (_, __, index) => ( + + {index + 1} + + ), + }, + { + title: "股票代码", + dataIndex: "stockCode", + key: "stockCode", + width: 100, + render: (code) => ( + + {code?.split(".")[0] || "-"} + + ), + }, + { + title: "股票名称", + dataIndex: "stockName", + key: "stockName", + render: (name) => ( + + {name || "-"} + + ), + }, + { + title: "最大涨幅", + dataIndex: "maxChg", + key: "maxChg", + width: 100, + align: "right", + render: (val) => ( + = 0 ? UP_COLOR : DOWN_COLOR}> + {formatChg(val)} + + ), + }, +]; + +/** + * 股票 TOP10 弹窗组件 + */ +const StockTop10Modal = ({ visible, onClose, topStocks = [] }) => { + return ( + + + 股票 TOP10 + + } + open={visible} + onCancel={onClose} + footer={null} + width={600} + closeIcon={×} + styles={{ + content: { + background: "linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)", + border: "1px solid rgba(236, 72, 153, 0.3)", + }, + header: { + background: "transparent", + borderBottom: "1px solid rgba(255,255,255,0.1)", + }, + }} + > + +
record.stockCode || record.stockName} + pagination={false} + size="small" + className="stock-top10-table" + columns={tableColumns} + /> + + ); +}; + +export default StockTop10Modal; diff --git a/src/views/Community/components/MarketOverviewBanner/components.js b/src/views/Community/components/MarketOverviewBanner/components.js new file mode 100644 index 00000000..03df4b3e --- /dev/null +++ b/src/views/Community/components/MarketOverviewBanner/components.js @@ -0,0 +1,261 @@ +// MarketOverviewBanner 子组件 + +import React from "react"; +import { Box, Text, HStack, Flex } from "@chakra-ui/react"; +import { UP_COLOR, DOWN_COLOR, FLAT_COLOR } from "./constants"; + +/** + * 沪深实时涨跌条形图组件 - 紧凑版 + */ +export const MarketStatsBarCompact = ({ marketStats }) => { + if (!marketStats || marketStats.totalCount === 0) return null; + + const { + risingCount = 0, + flatCount = 0, + fallingCount = 0, + totalCount = 0, + } = marketStats; + const risePercent = totalCount > 0 ? (risingCount / totalCount) * 100 : 0; + const flatPercent = totalCount > 0 ? (flatCount / totalCount) * 100 : 0; + const fallPercent = totalCount > 0 ? (fallingCount / totalCount) * 100 : 0; + + return ( + + + + 沪深实时涨跌 + + + ({totalCount}只) + + + + + + + + + + + + + + + + {risingCount} + + + 涨 + + + + + + + {flatCount} + + + 平 + + + + + + + {fallingCount} + + + 跌 + + + + + ); +}; + +/** + * 环形进度图组件 + */ +export const CircularProgressCard = ({ + label, + value, + color = "#EC4899", + size = 44, + highlight = false, + noBorder = false, +}) => { + const percentage = parseFloat(value) || 0; + const strokeWidth = 3; + const radius = (size - strokeWidth) / 2; + const arcLength = (270 / 360) * 2 * Math.PI * radius; + const progressLength = (percentage / 100) * arcLength; + + return ( + + + {label} + + + + + + + + + {percentage.toFixed(1)}% + + + + + ); +}; + +/** + * 紧凑数据卡片 + */ +export const BannerStatCard = ({ + label, + value, + icon, + color = "#7C3AED", + highlight = false, +}) => ( + + + + {icon} + + + {label} + + + + {value} + + +); diff --git a/src/views/Community/components/MarketOverviewBanner/constants.js b/src/views/Community/components/MarketOverviewBanner/constants.js new file mode 100644 index 00000000..822a01ef --- /dev/null +++ b/src/views/Community/components/MarketOverviewBanner/constants.js @@ -0,0 +1,43 @@ +// MarketOverviewBanner 常量定义 + +// 涨跌颜色常量 +export const UP_COLOR = "#FF4D4F"; // 涨 - 红色 +export const DOWN_COLOR = "#52C41A"; // 跌 - 绿色 +export const FLAT_COLOR = "#888888"; // 平 - 灰色 + +/** + * 判断是否在交易时间内 (9:30-15:00) + */ +export const isInTradingTime = () => { + const now = new Date(); + const hours = now.getHours(); + const minutes = now.getMinutes(); + const time = hours * 60 + minutes; + return time >= 570 && time <= 900; // 9:30-15:00 +}; + +/** + * 格式化涨跌幅 + */ +export const formatChg = (val) => { + if (val === null || val === undefined) return "-"; + const num = parseFloat(val); + if (isNaN(num)) return "-"; + return (num >= 0 ? "+" : "") + num.toFixed(2) + "%"; +}; + +// 注入脉冲动画样式 +if (typeof document !== "undefined") { + const styleId = "market-banner-animations"; + if (!document.getElementById(styleId)) { + const styleSheet = document.createElement("style"); + styleSheet.id = styleId; + styleSheet.innerText = ` + @keyframes pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.6; transform: scale(1.1); } + } + `; + document.head.appendChild(styleSheet); + } +} diff --git a/src/views/Community/components/MarketOverviewBanner/index.js b/src/views/Community/components/MarketOverviewBanner/index.js new file mode 100644 index 00000000..3ccca49a --- /dev/null +++ b/src/views/Community/components/MarketOverviewBanner/index.js @@ -0,0 +1,5 @@ +// MarketOverviewBanner 模块导出 + +export { UP_COLOR, DOWN_COLOR, FLAT_COLOR, isInTradingTime, formatChg } from "./constants"; +export { MarketStatsBarCompact, CircularProgressCard, BannerStatCard } from "./components"; +export { default as StockTop10Modal } from "./StockTop10Modal"; From 8a7f624589a7deb271c38c3ed68726c46313996f Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Thu, 15 Jan 2026 11:45:06 +0800 Subject: [PATCH 19/22] =?UTF-8?q?refactor(CompactSearchBox):=20=E6=8F=90?= =?UTF-8?q?=E5=8F=96=E5=B8=B8=E9=87=8F=E5=92=8C=E5=B7=A5=E5=85=B7=E5=87=BD?= =?UTF-8?q?=E6=95=B0=E5=88=B0=E5=AD=90=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - constants.js: SORT_OPTIONS, IMPORTANCE_OPTIONS 选项配置 - utils.js: findIndustryPath, inferTimeRangeFromFilters, buildFilterParams - index.js: 模块统一导出 主文件逻辑更清晰,工具函数可复用 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../SearchFilters/CompactSearchBox.js | 1118 ++++++++--------- .../CompactSearchBox/constants.js | 18 + .../SearchFilters/CompactSearchBox/index.js | 4 + .../SearchFilters/CompactSearchBox/utils.js | 140 +++ 4 files changed, 672 insertions(+), 608 deletions(-) create mode 100644 src/views/Community/components/SearchFilters/CompactSearchBox/constants.js create mode 100644 src/views/Community/components/SearchFilters/CompactSearchBox/index.js create mode 100644 src/views/Community/components/SearchFilters/CompactSearchBox/utils.js diff --git a/src/views/Community/components/SearchFilters/CompactSearchBox.js b/src/views/Community/components/SearchFilters/CompactSearchBox.js index e2defe4c..65590524 100644 --- a/src/views/Community/components/SearchFilters/CompactSearchBox.js +++ b/src/views/Community/components/SearchFilters/CompactSearchBox.js @@ -1,641 +1,543 @@ // src/views/Community/components/SearchFilters/CompactSearchBox.js // 紧凑版搜索和筛选组件 - 优化布局 -import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react'; +import React, { useState, useMemo, useEffect, useCallback, useRef } from "react"; import { - Input, Cascader, Button, Space, Tag, AutoComplete, Select as AntSelect, - Tooltip, Divider, Flex -} from 'antd'; + Input, + Cascader, + Button, + Space, + AutoComplete, + Select as AntSelect, + Tooltip, + Divider, + Flex, +} from "antd"; import { - SearchOutlined, CloseCircleOutlined, StockOutlined, FilterOutlined, - CalendarOutlined, SortAscendingOutlined, ReloadOutlined, ThunderboltOutlined -} from '@ant-design/icons'; -import dayjs from 'dayjs'; -import debounce from 'lodash/debounce'; -import { useSelector, useDispatch } from 'react-redux'; -import { fetchIndustryData, selectIndustryData, selectIndustryLoading } from '@store/slices/industrySlice'; -import { loadAllStocks } from '@store/slices/stockSlice'; -import { stockService } from '@services/stockService'; -import { logger } from '@utils/logger'; -import TradingTimeFilter from './TradingTimeFilter'; -import { PROFESSIONAL_COLORS } from '@constants/professionalTheme'; -import './CompactSearchBox.css'; + SearchOutlined, + CloseCircleOutlined, + StockOutlined, + FilterOutlined, + CalendarOutlined, + SortAscendingOutlined, + ReloadOutlined, + ThunderboltOutlined, +} from "@ant-design/icons"; +import debounce from "lodash/debounce"; +import { useSelector, useDispatch } from "react-redux"; +import { + fetchIndustryData, + selectIndustryData, + selectIndustryLoading, +} from "@store/slices/industrySlice"; +import { loadAllStocks } from "@store/slices/stockSlice"; +import { stockService } from "@services/stockService"; +import { logger } from "@utils/logger"; +import TradingTimeFilter from "./TradingTimeFilter"; +import { PROFESSIONAL_COLORS } from "@constants/professionalTheme"; +import "./CompactSearchBox.css"; + +// 模块化导入 +import { + SORT_OPTIONS, + IMPORTANCE_OPTIONS, +} from "./CompactSearchBox/constants"; +import { + findIndustryPath, + inferTimeRangeFromFilters, + buildFilterParams, +} from "./CompactSearchBox/utils"; const { Option } = AntSelect; -// 排序选项常量 -const SORT_OPTIONS = [ - { value: 'new', label: '最新排序', mobileLabel: '最新' }, - { value: 'hot', label: '最热排序', mobileLabel: '热门' }, - { value: 'importance', label: '重要性排序', mobileLabel: '重要' }, - { value: 'returns_avg', label: '平均收益', mobileLabel: '均收' }, - { value: 'returns_week', label: '周收益', mobileLabel: '周收' }, -]; - -// 重要性等级常量 -const IMPORTANCE_OPTIONS = [ - { value: 'S', label: 'S级' }, - { value: 'A', label: 'A级' }, - { value: 'B', label: 'B级' }, - { value: 'C', label: 'C级' }, -]; - const CompactSearchBox = ({ - onSearch, - onSearchFocus, - filters = {}, - mode, - pageSize, - trackingFunctions = {}, - isMobile = false + onSearch, + onSearchFocus, + filters = {}, + mode, + pageSize, + trackingFunctions = {}, + isMobile = false, }) => { - // 状态 - const [stockOptions, setStockOptions] = useState([]); - const [allStocks, setAllStocks] = useState([]); - const [industryValue, setIndustryValue] = useState([]); - const [sort, setSort] = useState('new'); - const [importance, setImportance] = useState([]); - const [tradingTimeRange, setTradingTimeRange] = useState(null); - const [inputValue, setInputValue] = useState(''); + // 状态 + const [stockOptions, setStockOptions] = useState([]); + const [allStocks, setAllStocks] = useState([]); + const [industryValue, setIndustryValue] = useState([]); + const [sort, setSort] = useState("new"); + const [importance, setImportance] = useState([]); + const [tradingTimeRange, setTradingTimeRange] = useState(null); + const [inputValue, setInputValue] = useState(""); - // Redux - const dispatch = useDispatch(); - const industryData = useSelector(selectIndustryData); - const industryLoading = useSelector(selectIndustryLoading); - const reduxAllStocks = useSelector((state) => state.stock.allStocks); + // Redux + const dispatch = useDispatch(); + const industryData = useSelector(selectIndustryData); + const industryLoading = useSelector(selectIndustryLoading); + const reduxAllStocks = useSelector((state) => state.stock.allStocks); - // 防抖搜索 - const debouncedSearchRef = useRef(null); - // 存储股票选择时的显示值(代码+名称),用于 useEffect 同步时显示完整信息 - const stockDisplayValueRef = useRef(null); + // Refs + const debouncedSearchRef = useRef(null); + const stockDisplayValueRef = useRef(null); - const triggerSearch = useCallback((params) => { - logger.debug('CompactSearchBox', '触发搜索', { params }); - onSearch(params); - }, [onSearch]); + const triggerSearch = useCallback( + (params) => { + logger.debug("CompactSearchBox", "触发搜索", { params }); + onSearch(params); + }, + [onSearch] + ); - useEffect(() => { - debouncedSearchRef.current = debounce((params) => { - triggerSearch(params); - }, 300); + // 创建构建参数的封装函数 + const createFilterParams = useCallback( + (overrides = {}) => + buildFilterParams({ + overrides, + sort, + importance, + filtersQ: filters.q, + industryValue, + tradingTimeRange, + mode, + pageSize, + }), + [sort, importance, filters.q, industryValue, tradingTimeRange, mode, pageSize] + ); - return () => { - if (debouncedSearchRef.current) { - debouncedSearchRef.current.cancel(); - } - }; - }, [triggerSearch]); + // 防抖搜索初始化 + useEffect(() => { + debouncedSearchRef.current = debounce((params) => { + triggerSearch(params); + }, 300); - // 加载股票数据(从 Redux 获取) - useEffect(() => { - if (!reduxAllStocks || reduxAllStocks.length === 0) { - dispatch(loadAllStocks()); - } - }, [dispatch, reduxAllStocks]); - - // 同步 Redux 数据到本地状态 - useEffect(() => { - if (reduxAllStocks && reduxAllStocks.length > 0) { - setAllStocks(reduxAllStocks); - } - }, [reduxAllStocks]); - - // 预加载行业数据(解决第一次点击无数据问题) - useEffect(() => { - if (!industryData || industryData.length === 0) { - dispatch(fetchIndustryData()); - } - }, [dispatch, industryData]); - - // 初始化筛选条件 - const findIndustryPath = useCallback((targetCode, data, currentPath = []) => { - if (!data || data.length === 0) return null; - - for (const item of data) { - const newPath = [...currentPath, item.value]; - - if (item.value === targetCode) { - return newPath; - } - - if (item.children && item.children.length > 0) { - const found = findIndustryPath(targetCode, item.children, newPath); - if (found) return found; - } - } - return null; - }, []); - - useEffect(() => { - if (!filters) return; - - // 优先使用 _sortDisplay(原始排序值),否则回退到 sort - // 这样可以正确显示 returns_avg, returns_week 等复合排序选项 - if (filters._sortDisplay || filters.sort) { - setSort(filters._sortDisplay || filters.sort); - } - - if (filters.importance) { - const importanceArray = filters.importance === 'all' - ? [] - : filters.importance.split(',').map(v => v.trim()).filter(Boolean); - setImportance(importanceArray); - } else { - setImportance([]); - } - - if (filters.industry_code && industryData && industryData.length > 0 && (!industryValue || industryValue.length === 0)) { - const path = findIndustryPath(filters.industry_code, industryData); - if (path) { - setIndustryValue(path); - } - } else if (!filters.industry_code && industryValue && industryValue.length > 0) { - setIndustryValue([]); - } - - if (filters.q) { - // 如果是股票选择触发的搜索,使用存储的显示值(代码+名称) - if (stockDisplayValueRef.current && stockDisplayValueRef.current.code === filters.q) { - setInputValue(stockDisplayValueRef.current.displayValue); - } else { - setInputValue(filters.q); - // 清除已失效的显示值缓存 - stockDisplayValueRef.current = null; - } - } else if (!filters.q) { - setInputValue(''); - stockDisplayValueRef.current = null; - } - - const hasTimeInFilters = filters.start_date || filters.end_date || filters.recent_days || filters.time_filter_key; - - if (hasTimeInFilters && (!tradingTimeRange || !tradingTimeRange.key)) { - // 优先使用 time_filter_key(来自 useEventFilters 的默认值) - let inferredKey = filters.time_filter_key || 'custom'; - let inferredLabel = ''; - - if (filters.time_filter_key === 'current-trading-day') { - inferredKey = 'current-trading-day'; - inferredLabel = '当前交易日'; - } else if (filters.time_filter_key === 'all') { - inferredKey = 'all'; - inferredLabel = '全部'; - } else if (filters.recent_days) { - if (filters.recent_days === '7') { - inferredKey = 'week'; - inferredLabel = '近一周'; - } else if (filters.recent_days === '30') { - inferredKey = 'month'; - inferredLabel = '近一月'; - } else { - inferredLabel = `近${filters.recent_days}天`; - } - } else if (filters.start_date && filters.end_date) { - inferredLabel = `${dayjs(filters.start_date).format('MM-DD HH:mm')} - ${dayjs(filters.end_date).format('MM-DD HH:mm')}`; - } - - const timeRange = { - start_date: filters.start_date || '', - end_date: filters.end_date || '', - recent_days: filters.recent_days || '', - label: inferredLabel, - key: inferredKey - }; - setTradingTimeRange(timeRange); - } else if (!hasTimeInFilters && tradingTimeRange) { - setTradingTimeRange(null); - } - }, [filters, industryData, findIndustryPath, industryValue, tradingTimeRange]); - - // 搜索股票 - const handleSearch = (value) => { - if (!value || !allStocks || allStocks.length === 0) { - setStockOptions([]); - return; - } - - const results = stockService.fuzzySearch(value, allStocks, 10); - const options = results.map(stock => ({ - value: stock.code, - label: ( -
- - {stock.code} - {stock.name} -
- ), - stockInfo: stock - })); - - setStockOptions(options); + return () => { + if (debouncedSearchRef.current) { + debouncedSearchRef.current.cancel(); + } }; + }, [triggerSearch]); - const buildFilterParams = useCallback((overrides = {}) => { - const sortValue = overrides.sort ?? sort; - let actualSort = sortValue; - let returnType; + // 加载股票数据 + useEffect(() => { + if (!reduxAllStocks || reduxAllStocks.length === 0) { + dispatch(loadAllStocks()); + } + }, [dispatch, reduxAllStocks]); - if (sortValue === 'returns_avg') { - actualSort = 'returns'; - returnType = 'avg'; - } else if (sortValue === 'returns_week') { - actualSort = 'returns'; - returnType = 'week'; - } + useEffect(() => { + if (reduxAllStocks && reduxAllStocks.length > 0) { + setAllStocks(reduxAllStocks); + } + }, [reduxAllStocks]); - let importanceValue = overrides.importance ?? importance; - if (Array.isArray(importanceValue)) { - importanceValue = importanceValue.length === 0 - ? 'all' - : importanceValue.join(','); - } + // 预加载行业数据 + useEffect(() => { + if (!industryData || industryData.length === 0) { + dispatch(fetchIndustryData()); + } + }, [dispatch, industryData]); - // 先展开 overrides,再用处理后的值覆盖,避免 overrides.sort 覆盖 actualSort - const result = { - ...overrides, - sort: actualSort, - // 保留原始排序值用于 UI 显示(如 returns_avg, returns_week) - _sortDisplay: sortValue, - importance: importanceValue, - q: (overrides.q ?? filters.q) ?? '', - industry_code: overrides.industry_code ?? (industryValue?.join(',') || ''), - start_date: overrides.start_date ?? (tradingTimeRange?.start_date || ''), - end_date: overrides.end_date ?? (tradingTimeRange?.end_date || ''), - recent_days: overrides.recent_days ?? (tradingTimeRange?.recent_days || ''), - page: 1, - }; + // 同步外部 filters 到本地状态 + useEffect(() => { + if (!filters) return; - // 移除不需要的字段 - delete result.per_page; + // 排序 + if (filters._sortDisplay || filters.sort) { + setSort(filters._sortDisplay || filters.sort); + } - // 添加 return_type 参数(用于收益排序) - if (returnType) { - result.return_type = returnType; - } else { - // 确保非收益排序时不带 return_type - delete result.return_type; - } + // 重要性 + if (filters.importance) { + const importanceArray = + filters.importance === "all" + ? [] + : filters.importance + .split(",") + .map((v) => v.trim()) + .filter(Boolean); + setImportance(importanceArray); + } else { + setImportance([]); + } - if (mode !== undefined && mode !== null) { - result.mode = mode; - } - if (pageSize !== undefined && pageSize !== null) { - result.per_page = pageSize; - } + // 行业 + if ( + filters.industry_code && + industryData && + industryData.length > 0 && + (!industryValue || industryValue.length === 0) + ) { + const path = findIndustryPath(filters.industry_code, industryData); + if (path) { + setIndustryValue(path); + } + } else if ( + !filters.industry_code && + industryValue && + industryValue.length > 0 + ) { + setIndustryValue([]); + } - return result; - }, [sort, importance, filters.q, industryValue, tradingTimeRange, mode, pageSize]); + // 搜索关键词 + if (filters.q) { + if ( + stockDisplayValueRef.current && + stockDisplayValueRef.current.code === filters.q + ) { + setInputValue(stockDisplayValueRef.current.displayValue); + } else { + setInputValue(filters.q); + stockDisplayValueRef.current = null; + } + } else if (!filters.q) { + setInputValue(""); + stockDisplayValueRef.current = null; + } - const handleStockSelect = (_value, option) => { - const stockInfo = option.stockInfo; - if (stockInfo) { - if (trackingFunctions.trackRelatedStockClicked) { - trackingFunctions.trackRelatedStockClicked({ - stockCode: stockInfo.code, - stockName: stockInfo.name, - source: 'search_box_autocomplete', - timestamp: new Date().toISOString(), - }); - } + // 时间范围 + const hasTimeInFilters = + filters.start_date || + filters.end_date || + filters.recent_days || + filters.time_filter_key; - const displayValue = `${stockInfo.code} ${stockInfo.name}`; - setInputValue(displayValue); - // 存储显示值,供 useEffect 同步时使用 - stockDisplayValueRef.current = { code: stockInfo.code, displayValue }; + if (hasTimeInFilters && (!tradingTimeRange || !tradingTimeRange.key)) { + const timeRange = inferTimeRangeFromFilters(filters); + if (timeRange) { + setTradingTimeRange(timeRange); + } + } else if (!hasTimeInFilters && tradingTimeRange) { + setTradingTimeRange(null); + } + }, [filters, industryData, industryValue, tradingTimeRange]); - const params = buildFilterParams({ - q: stockInfo.code, // 接口只传代码 - industry_code: '' - }); - triggerSearch(params); - } - }; + // 股票搜索 + const handleSearch = (value) => { + if (!value || !allStocks || allStocks.length === 0) { + setStockOptions([]); + return; + } - const handleImportanceChange = (value) => { - setImportance(value); - - if (debouncedSearchRef.current) { - debouncedSearchRef.current.cancel(); - } - - const importanceStr = value.length === 0 ? 'all' : value.join(','); - - if (trackingFunctions.trackNewsFilterApplied) { - trackingFunctions.trackNewsFilterApplied({ - filterType: 'importance', - filterValue: importanceStr, - timestamp: new Date().toISOString(), - }); - } - - const params = buildFilterParams({ importance: importanceStr }); - triggerSearch(params); - }; - - const handleSortChange = (value) => { - setSort(value); - - if (debouncedSearchRef.current) { - debouncedSearchRef.current.cancel(); - } - - if (trackingFunctions.trackNewsSorted) { - trackingFunctions.trackNewsSorted({ - sortBy: value, - previousSortBy: sort, - timestamp: new Date().toISOString(), - }); - } - - const params = buildFilterParams({ sort: value }); - triggerSearch(params); - }; - - const handleIndustryChange = (value) => { - setIndustryValue(value); - - if (debouncedSearchRef.current) { - debouncedSearchRef.current.cancel(); - } - - if (trackingFunctions.trackNewsFilterApplied) { - trackingFunctions.trackNewsFilterApplied({ - filterType: 'industry', - filterValue: value?.[value.length - 1] || '', - timestamp: new Date().toISOString(), - }); - } - - const params = buildFilterParams({ - industry_code: value?.[value.length - 1] || '' - }); - triggerSearch(params); - }; - - const handleTradingTimeChange = (timeConfig) => { - if (!timeConfig) { - setTradingTimeRange(null); - - if (trackingFunctions.trackNewsFilterApplied) { - trackingFunctions.trackNewsFilterApplied({ - filterType: 'time_range', - filterValue: 'cleared', - timestamp: new Date().toISOString(), - }); - } - - const params = buildFilterParams({ - start_date: '', - end_date: '', - recent_days: '' - }); - triggerSearch(params); - return; - } - - const { range, type, label, key } = timeConfig; - let params = {}; - - if (type === 'all') { - // "全部"按钮:清除所有时间限制 - params.start_date = ''; - params.end_date = ''; - params.recent_days = ''; - } else if (type === 'recent_days') { - params.recent_days = range; - params.start_date = ''; - params.end_date = ''; - } else { - params.start_date = range[0].format('YYYY-MM-DD HH:mm:ss'); - params.end_date = range[1].format('YYYY-MM-DD HH:mm:ss'); - params.recent_days = ''; - } - - setTradingTimeRange({ ...params, label, key }); - - if (trackingFunctions.trackNewsFilterApplied) { - trackingFunctions.trackNewsFilterApplied({ - filterType: 'time_range', - filterValue: label, - timeRangeType: type, - timestamp: new Date().toISOString(), - }); - } - - const searchParams = buildFilterParams({ ...params, mode }); - triggerSearch(searchParams); - }; - - const handleMainSearch = () => { - if (debouncedSearchRef.current) { - debouncedSearchRef.current.cancel(); - } - - const params = buildFilterParams({ - q: inputValue, - industry_code: '' - }); - - if (trackingFunctions.trackNewsSearched && inputValue) { - trackingFunctions.trackNewsSearched({ - searchQuery: inputValue, - searchType: 'main_search', - filters: params, - timestamp: new Date().toISOString(), - }); - } - - triggerSearch(params); - }; - - const handleInputChange = (value) => { - setInputValue(value); - }; - - const handleReset = () => { - setInputValue(''); - setStockOptions([]); - setIndustryValue([]); - setSort('new'); - setImportance([]); - setTradingTimeRange(null); - - if (trackingFunctions.trackNewsFilterApplied) { - trackingFunctions.trackNewsFilterApplied({ - filterType: 'reset', - filterValue: 'all_filters_cleared', - timestamp: new Date().toISOString(), - }); - } - - const resetParams = { - q: '', - industry_code: '', - sort: 'new', - importance: 'all', - start_date: '', - end_date: '', - recent_days: '', - page: 1, - _forceRefresh: Date.now() - }; - - onSearch(resetParams); - }; - - const handleCascaderFocus = async () => { - if (!industryData || industryData.length === 0) { - dispatch(fetchIndustryData()); - } - }; - return ( -
- {/* 第一行:搜索框 + 日期筛选 */} - - {/* 搜索框 - flex: 1 占满剩余空间 */} - { - if (e.key === 'Enter') { - handleMainSearch(); - } - }} - style={{ flex: 1, minWidth: isMobile ? 100 : 200 }} - className="gold-placeholder" - allowClear={{ - clearIcon: - }} - > - } - placeholder="搜索股票/话题..." - style={{ - border: 'none', - background: 'transparent', - color: PROFESSIONAL_COLORS.text.primary, - boxShadow: 'none' - }} - /> - - - {/* 分隔线 - H5 时隐藏 */} - {!isMobile && } - - {/* 日期筛选按钮组 */} -
- - -
-
- - {/* 第二行:筛选条件 - 主线模式下隐藏(主线模式有自己的筛选器) */} - {mode !== 'mainline' && ( - - {/* 左侧筛选 */} - - {/* 行业筛选 */} - - - {isMobile ? '行业' : '行业筛选'} - - } - changeOnSelect - showSearch={{ - filter: (inputValue, path) => - path.some(option => - option.label.toLowerCase().includes(inputValue.toLowerCase()) - ) - }} - allowClear - expandTrigger="hover" - displayRender={(labels) => labels[labels.length - 1] || (isMobile ? '行业' : '行业筛选')} - disabled={industryLoading} - style={{ minWidth: isMobile ? 70 : 80 }} - suffixIcon={null} - className="transparent-cascader" - /> - - {/* 事件等级 */} - - - {isMobile ? '等级' : '事件等级'} - - } - maxTagCount={0} - maxTagPlaceholder={(omittedValues) => isMobile ? `${omittedValues.length}项` : `已选 ${omittedValues.length} 项`} - className="bracket-select" - > - {IMPORTANCE_OPTIONS.map(opt => ( - - ))} - - - - {/* 右侧排序和重置 */} - - {/* 排序 */} - - {SORT_OPTIONS.map(opt => ( - - ))} - - - {/* 重置按钮 */} - - - - )} + const results = stockService.fuzzySearch(value, allStocks, 10); + const options = results.map((stock) => ({ + value: stock.code, + label: ( +
+ + {stock.code} + {stock.name}
- ); + ), + stockInfo: stock, + })); + + setStockOptions(options); + }; + + // 股票选择 + const handleStockSelect = (_value, option) => { + const stockInfo = option.stockInfo; + if (stockInfo) { + trackingFunctions.trackRelatedStockClicked?.({ + stockCode: stockInfo.code, + stockName: stockInfo.name, + source: "search_box_autocomplete", + timestamp: new Date().toISOString(), + }); + + const displayValue = `${stockInfo.code} ${stockInfo.name}`; + setInputValue(displayValue); + stockDisplayValueRef.current = { code: stockInfo.code, displayValue }; + + const params = createFilterParams({ q: stockInfo.code, industry_code: "" }); + triggerSearch(params); + } + }; + + // 重要性变更 + const handleImportanceChange = (value) => { + setImportance(value); + debouncedSearchRef.current?.cancel(); + + const importanceStr = value.length === 0 ? "all" : value.join(","); + trackingFunctions.trackNewsFilterApplied?.({ + filterType: "importance", + filterValue: importanceStr, + timestamp: new Date().toISOString(), + }); + + triggerSearch(createFilterParams({ importance: importanceStr })); + }; + + // 排序变更 + const handleSortChange = (value) => { + setSort(value); + debouncedSearchRef.current?.cancel(); + + trackingFunctions.trackNewsSorted?.({ + sortBy: value, + previousSortBy: sort, + timestamp: new Date().toISOString(), + }); + + triggerSearch(createFilterParams({ sort: value })); + }; + + // 行业变更 + const handleIndustryChange = (value) => { + setIndustryValue(value); + debouncedSearchRef.current?.cancel(); + + trackingFunctions.trackNewsFilterApplied?.({ + filterType: "industry", + filterValue: value?.[value.length - 1] || "", + timestamp: new Date().toISOString(), + }); + + triggerSearch(createFilterParams({ industry_code: value?.[value.length - 1] || "" })); + }; + + // 时间筛选变更 + const handleTradingTimeChange = (timeConfig) => { + if (!timeConfig) { + setTradingTimeRange(null); + trackingFunctions.trackNewsFilterApplied?.({ + filterType: "time_range", + filterValue: "cleared", + timestamp: new Date().toISOString(), + }); + triggerSearch(createFilterParams({ start_date: "", end_date: "", recent_days: "" })); + return; + } + + const { range, type, label, key } = timeConfig; + let params = {}; + + if (type === "all") { + params = { start_date: "", end_date: "", recent_days: "" }; + } else if (type === "recent_days") { + params = { recent_days: range, start_date: "", end_date: "" }; + } else { + params = { + start_date: range[0].format("YYYY-MM-DD HH:mm:ss"), + end_date: range[1].format("YYYY-MM-DD HH:mm:ss"), + recent_days: "", + }; + } + + setTradingTimeRange({ ...params, label, key }); + trackingFunctions.trackNewsFilterApplied?.({ + filterType: "time_range", + filterValue: label, + timeRangeType: type, + timestamp: new Date().toISOString(), + }); + + triggerSearch(createFilterParams({ ...params, mode })); + }; + + // 主搜索 + const handleMainSearch = () => { + debouncedSearchRef.current?.cancel(); + const params = createFilterParams({ q: inputValue, industry_code: "" }); + + if (inputValue) { + trackingFunctions.trackNewsSearched?.({ + searchQuery: inputValue, + searchType: "main_search", + filters: params, + timestamp: new Date().toISOString(), + }); + } + + triggerSearch(params); + }; + + // 重置 + const handleReset = () => { + setInputValue(""); + setStockOptions([]); + setIndustryValue([]); + setSort("new"); + setImportance([]); + setTradingTimeRange(null); + + trackingFunctions.trackNewsFilterApplied?.({ + filterType: "reset", + filterValue: "all_filters_cleared", + timestamp: new Date().toISOString(), + }); + + onSearch({ + q: "", + industry_code: "", + sort: "new", + importance: "all", + start_date: "", + end_date: "", + recent_days: "", + page: 1, + _forceRefresh: Date.now(), + }); + }; + + const handleCascaderFocus = async () => { + if (!industryData || industryData.length === 0) { + dispatch(fetchIndustryData()); + } + }; + + return ( +
+ {/* 第一行:搜索框 + 日期筛选 */} + + e.key === "Enter" && handleMainSearch()} + style={{ flex: 1, minWidth: isMobile ? 100 : 200 }} + className="gold-placeholder" + allowClear={{ + clearIcon: ( + + ), + }} + > + } + placeholder="搜索股票/话题..." + style={{ + border: "none", + background: "transparent", + color: PROFESSIONAL_COLORS.text.primary, + boxShadow: "none", + }} + /> + + + {!isMobile && ( + + )} + +
+ + +
+
+ + {/* 第二行:筛选条件 */} + {mode !== "mainline" && ( + + + + + {isMobile ? "行业" : "行业筛选"} + + } + changeOnSelect + showSearch={{ + filter: (inputValue, path) => + path.some((option) => + option.label.toLowerCase().includes(inputValue.toLowerCase()) + ), + }} + allowClear + expandTrigger="hover" + displayRender={(labels) => + labels[labels.length - 1] || (isMobile ? "行业" : "行业筛选") + } + disabled={industryLoading} + style={{ minWidth: isMobile ? 70 : 80 }} + suffixIcon={null} + className="transparent-cascader" + /> + + + + {isMobile ? "等级" : "事件等级"} + + } + maxTagCount={0} + maxTagPlaceholder={(omittedValues) => + isMobile ? `${omittedValues.length}项` : `已选 ${omittedValues.length} 项` + } + className="bracket-select" + > + {IMPORTANCE_OPTIONS.map((opt) => ( + + ))} + + + + + + {SORT_OPTIONS.map((opt) => ( + + ))} + + + + + + )} +
+ ); }; export default CompactSearchBox; diff --git a/src/views/Community/components/SearchFilters/CompactSearchBox/constants.js b/src/views/Community/components/SearchFilters/CompactSearchBox/constants.js new file mode 100644 index 00000000..80247ddb --- /dev/null +++ b/src/views/Community/components/SearchFilters/CompactSearchBox/constants.js @@ -0,0 +1,18 @@ +// CompactSearchBox 常量定义 + +// 排序选项常量 +export const SORT_OPTIONS = [ + { value: "new", label: "最新排序", mobileLabel: "最新" }, + { value: "hot", label: "最热排序", mobileLabel: "热门" }, + { value: "importance", label: "重要性排序", mobileLabel: "重要" }, + { value: "returns_avg", label: "平均收益", mobileLabel: "均收" }, + { value: "returns_week", label: "周收益", mobileLabel: "周收" }, +]; + +// 重要性等级常量 +export const IMPORTANCE_OPTIONS = [ + { value: "S", label: "S级" }, + { value: "A", label: "A级" }, + { value: "B", label: "B级" }, + { value: "C", label: "C级" }, +]; diff --git a/src/views/Community/components/SearchFilters/CompactSearchBox/index.js b/src/views/Community/components/SearchFilters/CompactSearchBox/index.js new file mode 100644 index 00000000..4eed6d6b --- /dev/null +++ b/src/views/Community/components/SearchFilters/CompactSearchBox/index.js @@ -0,0 +1,4 @@ +// CompactSearchBox 模块导出 + +export { SORT_OPTIONS, IMPORTANCE_OPTIONS } from "./constants"; +export { findIndustryPath, inferTimeRangeFromFilters, buildFilterParams } from "./utils"; diff --git a/src/views/Community/components/SearchFilters/CompactSearchBox/utils.js b/src/views/Community/components/SearchFilters/CompactSearchBox/utils.js new file mode 100644 index 00000000..29c30dd4 --- /dev/null +++ b/src/views/Community/components/SearchFilters/CompactSearchBox/utils.js @@ -0,0 +1,140 @@ +// CompactSearchBox 工具函数 + +import dayjs from "dayjs"; + +/** + * 在行业树中查找指定代码的完整路径 + * @param {string} targetCode - 目标行业代码 + * @param {Array} data - 行业数据树 + * @param {Array} currentPath - 当前路径(递归用) + * @returns {Array|null} - 找到的完整路径,未找到返回 null + */ +export const findIndustryPath = (targetCode, data, currentPath = []) => { + if (!data || data.length === 0) return null; + + for (const item of data) { + const newPath = [...currentPath, item.value]; + + if (item.value === targetCode) { + return newPath; + } + + if (item.children && item.children.length > 0) { + const found = findIndustryPath(targetCode, item.children, newPath); + if (found) return found; + } + } + return null; +}; + +/** + * 从 filters 中推断时间范围配置 + * @param {Object} filters - 筛选条件 + * @returns {Object|null} - 时间范围配置 + */ +export const inferTimeRangeFromFilters = (filters) => { + if (!filters) return null; + + const hasTimeInFilters = + filters.start_date || + filters.end_date || + filters.recent_days || + filters.time_filter_key; + + if (!hasTimeInFilters) return null; + + let inferredKey = filters.time_filter_key || "custom"; + let inferredLabel = ""; + + if (filters.time_filter_key === "current-trading-day") { + inferredKey = "current-trading-day"; + inferredLabel = "当前交易日"; + } else if (filters.time_filter_key === "all") { + inferredKey = "all"; + inferredLabel = "全部"; + } else if (filters.recent_days) { + if (filters.recent_days === "7") { + inferredKey = "week"; + inferredLabel = "近一周"; + } else if (filters.recent_days === "30") { + inferredKey = "month"; + inferredLabel = "近一月"; + } else { + inferredLabel = `近${filters.recent_days}天`; + } + } else if (filters.start_date && filters.end_date) { + inferredLabel = `${dayjs(filters.start_date).format("MM-DD HH:mm")} - ${dayjs(filters.end_date).format("MM-DD HH:mm")}`; + } + + return { + start_date: filters.start_date || "", + end_date: filters.end_date || "", + recent_days: filters.recent_days || "", + label: inferredLabel, + key: inferredKey, + }; +}; + +/** + * 构建筛选参数 + * @param {Object} options - 配置选项 + * @returns {Object} - 构建的参数对象 + */ +export const buildFilterParams = ({ + overrides = {}, + sort, + importance, + filtersQ, + industryValue, + tradingTimeRange, + mode, + pageSize, +}) => { + const sortValue = overrides.sort ?? sort; + let actualSort = sortValue; + let returnType; + + if (sortValue === "returns_avg") { + actualSort = "returns"; + returnType = "avg"; + } else if (sortValue === "returns_week") { + actualSort = "returns"; + returnType = "week"; + } + + let importanceValue = overrides.importance ?? importance; + if (Array.isArray(importanceValue)) { + importanceValue = + importanceValue.length === 0 ? "all" : importanceValue.join(","); + } + + const result = { + ...overrides, + sort: actualSort, + _sortDisplay: sortValue, + importance: importanceValue, + q: overrides.q ?? filtersQ ?? "", + industry_code: overrides.industry_code ?? (industryValue?.join(",") || ""), + start_date: overrides.start_date ?? (tradingTimeRange?.start_date || ""), + end_date: overrides.end_date ?? (tradingTimeRange?.end_date || ""), + recent_days: overrides.recent_days ?? (tradingTimeRange?.recent_days || ""), + page: 1, + }; + + delete result.per_page; + + if (returnType) { + result.return_type = returnType; + } else { + delete result.return_type; + } + + if (mode !== undefined && mode !== null) { + result.mode = mode; + } + if (pageSize !== undefined && pageSize !== null) { + result.per_page = pageSize; + } + + return result; +}; From da13cf08c5fdb2bf687305afd7d4f14d7aa20375 Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Thu, 15 Jan 2026 11:45:12 +0800 Subject: [PATCH 20/22] =?UTF-8?q?refactor(TradingTimeFilter):=20=E6=8F=90?= =?UTF-8?q?=E5=8F=96=E5=B8=B8=E9=87=8F=E5=92=8C=E5=B7=A5=E5=85=B7=E5=87=BD?= =?UTF-8?q?=E6=95=B0=E5=88=B0=E5=AD=90=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - constants.js: TIME_BOUNDARIES, TRADING_SESSIONS 时间边界配置 - utils.js: getCurrentTradingSession, getPrevTradingDay, generateTimeRangeConfig, disabledDate/Time - index.js: 模块统一导出 主文件从 ~440 行精简到 ~260 行 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../SearchFilters/TradingTimeFilter.js | 443 ++++++------------ .../TradingTimeFilter/constants.js | 18 + .../SearchFilters/TradingTimeFilter/index.js | 10 + .../SearchFilters/TradingTimeFilter/utils.js | 224 +++++++++ 4 files changed, 395 insertions(+), 300 deletions(-) create mode 100644 src/views/Community/components/SearchFilters/TradingTimeFilter/constants.js create mode 100644 src/views/Community/components/SearchFilters/TradingTimeFilter/index.js create mode 100644 src/views/Community/components/SearchFilters/TradingTimeFilter/utils.js diff --git a/src/views/Community/components/SearchFilters/TradingTimeFilter.js b/src/views/Community/components/SearchFilters/TradingTimeFilter.js index 81728152..68dfed13 100644 --- a/src/views/Community/components/SearchFilters/TradingTimeFilter.js +++ b/src/views/Community/components/SearchFilters/TradingTimeFilter.js @@ -1,239 +1,84 @@ // src/views/Community/components/TradingTimeFilter.js // 交易时段智能筛选组件 -import React, { useState, useMemo, useEffect } from 'react'; -import { Space, Button, Tag, Tooltip, DatePicker, Popover, Select } from 'antd'; -import { ClockCircleOutlined, CalendarOutlined, FilterOutlined } from '@ant-design/icons'; -import dayjs from 'dayjs'; -import locale from 'antd/es/date-picker/locale/zh_CN'; -import { logger } from '@utils/logger'; -import { PROFESSIONAL_COLORS } from '@constants/professionalTheme'; -import tradingDayUtils from '@utils/tradingDayUtils'; + +import React, { useState, useMemo, useEffect } from "react"; +import { + Space, + Button, + Tag, + Tooltip, + DatePicker, + Popover, + Select, +} from "antd"; +import { + ClockCircleOutlined, + CalendarOutlined, + FilterOutlined, +} from "@ant-design/icons"; +import locale from "antd/es/date-picker/locale/zh_CN"; +import { logger } from "@utils/logger"; +import { PROFESSIONAL_COLORS } from "@constants/professionalTheme"; + +// 模块化导入 +import { + generateTimeRangeConfig, + disabledDate, + disabledTime, +} from "./TradingTimeFilter/utils"; const { RangePicker } = DatePicker; - const { Option } = Select; /** * 交易时段筛选组件 - * @param {string} value - 当前选中的 key(受控) - * @param {Function} onChange - 时间范围变化回调 (timeConfig) => void - * @param {boolean} compact - 是否使用紧凑模式(PC 端搜索栏内使用) - * @param {boolean} mobile - 是否使用移动端模式(下拉选择) */ -const TradingTimeFilter = ({ value, onChange, compact = false, mobile = false }) => { +const TradingTimeFilter = ({ + value, + onChange, + compact = false, + mobile = false, +}) => { const [selectedKey, setSelectedKey] = useState(null); const [customRangeVisible, setCustomRangeVisible] = useState(false); const [customRange, setCustomRange] = useState(null); - // 监听外部 value 变化,同步内部状态 + // 监听外部 value 变化 useEffect(() => { if (value === null || value === undefined) { - // 外部重置,清空内部状态 setSelectedKey(null); setCustomRange(null); - logger.debug('TradingTimeFilter', '外部重置,清空选中状态'); + logger.debug("TradingTimeFilter", "外部重置,清空选中状态"); } else { - // 外部选中值变化,同步内部状态 setSelectedKey(value); - logger.debug('TradingTimeFilter', '外部value变化,同步内部状态', { value }); + logger.debug("TradingTimeFilter", "外部value变化,同步内部状态", { value }); } }, [value]); - // 获取当前交易时段 - const getCurrentTradingSession = () => { - const now = dayjs(); - const hour = now.hour(); - const minute = now.minute(); - const currentMinutes = hour * 60 + minute; - - // 转换为分钟数便于比较 - const PRE_MARKET_END = 9 * 60 + 30; // 09:30 - const MORNING_END = 11 * 60 + 30; // 11:30 - const AFTERNOON_START = 13 * 60; // 13:00 - const MARKET_CLOSE = 15 * 60; // 15:00 - - if (currentMinutes < PRE_MARKET_END) { - return 'pre-market'; // 盘前 - } else if (currentMinutes < MORNING_END) { - return 'morning'; // 早盘 - } else if (currentMinutes < AFTERNOON_START) { - return 'lunch'; // 午休 - } else if (currentMinutes < MARKET_CLOSE) { - return 'afternoon'; // 午盘 - } else { - return 'after-hours'; // 盘后 - } - }; - // 获取时间范围配置 - const timeRangeConfig = useMemo(() => { - const session = getCurrentTradingSession(); - const now = dayjs(); - - // 今日关键时间点 - const today0930 = now.hour(9).minute(30).second(0); - const today1130 = now.hour(11).minute(30).second(0); - const today1300 = now.hour(13).minute(0).second(0); - const today1500 = now.hour(15).minute(0).second(0); - const todayStart = now.startOf('day'); - const todayEnd = now.endOf('day'); - - // 昨日关键时间点 - const yesterday1500 = now.subtract(1, 'day').hour(15).minute(0).second(0); - const yesterdayStart = now.subtract(1, 'day').startOf('day'); - const yesterdayEnd = now.subtract(1, 'day').endOf('day'); - - // 动态按钮配置(根据时段返回不同按钮数组) - // 注意:"当前交易日"已在固定按钮中,这里只放特定时段的快捷按钮 - const dynamicButtonsMap = { - 'pre-market': [], // 盘前:使用"当前交易日"即可 - 'morning': [ - { - key: 'intraday', - label: '盘中', - range: [today0930, today1500], - tooltip: '盘中交易时段', - timeHint: '今日 09:30 - 15:00', - color: 'blue', - type: 'precise' - } - ], - 'lunch': [], // 午休:使用"当前交易日"即可 - 'afternoon': [ - { - key: 'intraday', - label: '盘中', - range: [today0930, today1500], - tooltip: '盘中交易时段', - timeHint: '今日 09:30 - 15:00', - color: 'blue', - type: 'precise' - }, - { - key: 'afternoon', - label: '午盘', - range: [today1300, today1500], - tooltip: '午盘交易时段', - timeHint: '今日 13:00 - 15:00', - color: 'cyan', - type: 'precise' - } - ], - 'after-hours': [] // 盘后:使用"当前交易日"即可 - }; - - // 获取上一个交易日(使用 tdays.csv 数据) - const getPrevTradingDay = () => { - try { - const prevTradingDay = tradingDayUtils.getPreviousTradingDay(now.toDate()); - return dayjs(prevTradingDay); - } catch (e) { - // 降级:简单地减一天(不考虑周末节假日) - logger.warn('TradingTimeFilter', '获取上一交易日失败,降级处理', e); - return now.subtract(1, 'day'); - } - }; - - const prevTradingDay = getPrevTradingDay(); - const prevTradingDay1500 = prevTradingDay.hour(15).minute(0).second(0); - - // 固定按钮配置(始终显示) - const fixedButtons = [ - { - key: 'current-trading-day', - label: '当前交易日', - range: [prevTradingDay1500, now], - tooltip: '当前交易日事件', - timeHint: `${prevTradingDay.format('MM-DD')} 15:00 - 现在`, - color: 'green', - type: 'precise' - }, - { - key: 'morning-fixed', - label: '早盘', - range: [today0930, today1130], - tooltip: '早盘交易时段', - timeHint: '09:30 - 11:30', - color: 'geekblue', - type: 'precise' - }, - { - key: 'today', - label: '今日全天', - range: [todayStart, todayEnd], - tooltip: '今日全天', - timeHint: '今日 00:00 - 23:59', - color: 'purple', - type: 'precise' - }, - { - key: 'yesterday', - label: '昨日', - range: [yesterdayStart, yesterdayEnd], - tooltip: '昨日全天', - timeHint: '昨日 00:00 - 23:59', - color: 'orange', - type: 'precise' - }, - { - key: 'week', - label: '近一周', - range: 7, // 天数 - tooltip: '过去7个交易日', - timeHint: '过去7天', - color: 'magenta', - type: 'recent_days' - }, - { - key: 'month', - label: '近一月', - range: 30, // 天数 - tooltip: '过去30个交易日', - timeHint: '过去30天', - color: 'volcano', - type: 'recent_days' - }, - { - key: 'all', - label: '全部', - range: null, // 无时间限制 - tooltip: '显示全部事件', - timeHint: '不限时间', - color: 'default', - type: 'all' - } - ]; - - return { - dynamic: dynamicButtonsMap[session] || [], - fixed: fixedButtons - }; - }, []); // 空依赖,首次渲染时计算 + const timeRangeConfig = useMemo(() => generateTimeRangeConfig(), []); // 按钮点击处理 const handleButtonClick = (config) => { - logger.debug('TradingTimeFilter', '按钮点击', { + logger.debug("TradingTimeFilter", "按钮点击", { config, currentSelectedKey: selectedKey, - willToggle: selectedKey === config.key + willToggle: selectedKey === config.key, }); if (selectedKey === config.key) { - // 取消选中 setSelectedKey(null); onChange(null); - logger.debug('TradingTimeFilter', '取消选中', { key: config.key }); + logger.debug("TradingTimeFilter", "取消选中", { key: config.key }); } else { - // 选中 setSelectedKey(config.key); - const timeConfig = { + onChange({ range: config.range, type: config.type, label: config.label, - key: config.key - }; - onChange(timeConfig); - logger.debug('TradingTimeFilter', '选中新按钮', { timeConfig }); + key: config.key, + }); + logger.debug("TradingTimeFilter", "选中新按钮", { key: config.key }); } }; @@ -241,27 +86,29 @@ const TradingTimeFilter = ({ value, onChange, compact = false, mobile = false }) const handleCustomRangeOk = (dates) => { if (dates && dates.length === 2) { setCustomRange(dates); - setSelectedKey('custom'); + setSelectedKey("custom"); setCustomRangeVisible(false); onChange({ range: dates, - type: 'precise', - label: `${dates[0].format('MM-DD HH:mm')} - ${dates[1].format('MM-DD HH:mm')}`, - key: 'custom' + type: "precise", + label: `${dates[0].format("MM-DD HH:mm")} - ${dates[1].format("MM-DD HH:mm")}`, + key: "custom", }); - logger.debug('TradingTimeFilter', '自定义范围', { - start: dates[0].format('YYYY-MM-DD HH:mm:ss'), - end: dates[1].format('YYYY-MM-DD HH:mm:ss') + logger.debug("TradingTimeFilter", "自定义范围", { + start: dates[0].format("YYYY-MM-DD HH:mm:ss"), + end: dates[1].format("YYYY-MM-DD HH:mm:ss"), }); } }; - // 渲染紧凑模式按钮(PC 端搜索栏内使用,文字按钮 + | 分隔符) + // 渲染紧凑模式按钮 const renderCompactButton = (config, showDivider = true) => { const isSelected = selectedKey === config.key; - const fullTooltip = config.timeHint ? `${config.tooltip} · ${config.timeHint}` : config.tooltip; + const fullTooltip = config.timeHint + ? `${config.tooltip} · ${config.timeHint}` + : config.tooltip; return ( @@ -269,41 +116,51 @@ const TradingTimeFilter = ({ value, onChange, compact = false, mobile = false }) handleButtonClick(config)} style={{ - cursor: 'pointer', - padding: '4px 8px', - borderRadius: '4px', - fontSize: '13px', + cursor: "pointer", + padding: "4px 8px", + borderRadius: "4px", + fontSize: "13px", fontWeight: isSelected ? 600 : 400, - color: isSelected ? PROFESSIONAL_COLORS.gold[500] : PROFESSIONAL_COLORS.text.secondary, - background: isSelected ? 'rgba(255, 195, 0, 0.15)' : 'transparent', - transition: 'all 0.2s ease', - whiteSpace: 'nowrap', + color: isSelected + ? PROFESSIONAL_COLORS.gold[500] + : PROFESSIONAL_COLORS.text.secondary, + background: isSelected + ? "rgba(255, 195, 0, 0.15)" + : "transparent", + transition: "all 0.2s ease", + whiteSpace: "nowrap", }} > {config.label} {showDivider && ( - | + + | + )} ); }; - // 渲染按钮(默认模式) + // 渲染默认按钮 const renderButton = (config) => { const isSelected = selectedKey === config.key; - - // 构建完整的 tooltip 提示(文字 + 时间) - const fullTooltip = config.timeHint ? `${config.tooltip} · ${config.timeHint}` : config.tooltip; + const fullTooltip = config.timeHint + ? `${config.tooltip} · ${config.timeHint}` + : config.tooltip; if (isSelected) { - // 选中状态:只显示 Tag,不显示下方时间 return ( handleButtonClick(config)} > @@ -312,7 +169,6 @@ const TradingTimeFilter = ({ value, onChange, compact = false, mobile = false }) ); } else { - // 未选中状态:普通按钮 return ( diff --git a/src/views/Community/components/SearchFilters/TradingTimeFilter/constants.js b/src/views/Community/components/SearchFilters/TradingTimeFilter/constants.js new file mode 100644 index 00000000..01c9c01e --- /dev/null +++ b/src/views/Community/components/SearchFilters/TradingTimeFilter/constants.js @@ -0,0 +1,18 @@ +// TradingTimeFilter 常量定义 + +// 时段边界(分钟数) +export const TIME_BOUNDARIES = { + PRE_MARKET_END: 9 * 60 + 30, // 09:30 + MORNING_END: 11 * 60 + 30, // 11:30 + AFTERNOON_START: 13 * 60, // 13:00 + MARKET_CLOSE: 15 * 60, // 15:00 +}; + +// 时段类型 +export const TRADING_SESSIONS = { + PRE_MARKET: "pre-market", // 盘前 + MORNING: "morning", // 早盘 + LUNCH: "lunch", // 午休 + AFTERNOON: "afternoon", // 午盘 + AFTER_HOURS: "after-hours", // 盘后 +}; diff --git a/src/views/Community/components/SearchFilters/TradingTimeFilter/index.js b/src/views/Community/components/SearchFilters/TradingTimeFilter/index.js new file mode 100644 index 00000000..01a4f735 --- /dev/null +++ b/src/views/Community/components/SearchFilters/TradingTimeFilter/index.js @@ -0,0 +1,10 @@ +// TradingTimeFilter 模块导出 + +export { TIME_BOUNDARIES, TRADING_SESSIONS } from "./constants"; +export { + getCurrentTradingSession, + getPrevTradingDay, + generateTimeRangeConfig, + disabledDate, + disabledTime, +} from "./utils"; diff --git a/src/views/Community/components/SearchFilters/TradingTimeFilter/utils.js b/src/views/Community/components/SearchFilters/TradingTimeFilter/utils.js new file mode 100644 index 00000000..4fadd81a --- /dev/null +++ b/src/views/Community/components/SearchFilters/TradingTimeFilter/utils.js @@ -0,0 +1,224 @@ +// TradingTimeFilter 工具函数 + +import dayjs from "dayjs"; +import { TIME_BOUNDARIES, TRADING_SESSIONS } from "./constants"; +import { logger } from "@utils/logger"; +import tradingDayUtils from "@utils/tradingDayUtils"; + +/** + * 获取当前交易时段 + * @returns {string} 时段标识 + */ +export const getCurrentTradingSession = () => { + const now = dayjs(); + const hour = now.hour(); + const minute = now.minute(); + const currentMinutes = hour * 60 + minute; + + if (currentMinutes < TIME_BOUNDARIES.PRE_MARKET_END) { + return TRADING_SESSIONS.PRE_MARKET; + } else if (currentMinutes < TIME_BOUNDARIES.MORNING_END) { + return TRADING_SESSIONS.MORNING; + } else if (currentMinutes < TIME_BOUNDARIES.AFTERNOON_START) { + return TRADING_SESSIONS.LUNCH; + } else if (currentMinutes < TIME_BOUNDARIES.MARKET_CLOSE) { + return TRADING_SESSIONS.AFTERNOON; + } else { + return TRADING_SESSIONS.AFTER_HOURS; + } +}; + +/** + * 获取上一个交易日 + * @param {dayjs.Dayjs} now - 当前时间 + * @returns {dayjs.Dayjs} 上一交易日 + */ +export const getPrevTradingDay = (now) => { + try { + const prevTradingDay = tradingDayUtils.getPreviousTradingDay(now.toDate()); + return dayjs(prevTradingDay); + } catch (e) { + logger.warn("TradingTimeFilter", "获取上一交易日失败,降级处理", e); + return now.subtract(1, "day"); + } +}; + +/** + * 生成时间范围配置 + * @returns {Object} 包含 dynamic 和 fixed 按钮配置 + */ +export const generateTimeRangeConfig = () => { + const session = getCurrentTradingSession(); + const now = dayjs(); + + // 今日关键时间点 + const today0930 = now.hour(9).minute(30).second(0); + const today1130 = now.hour(11).minute(30).second(0); + const today1300 = now.hour(13).minute(0).second(0); + const today1500 = now.hour(15).minute(0).second(0); + const todayStart = now.startOf("day"); + const todayEnd = now.endOf("day"); + + // 昨日关键时间点 + const yesterdayStart = now.subtract(1, "day").startOf("day"); + const yesterdayEnd = now.subtract(1, "day").endOf("day"); + + // 动态按钮配置(根据时段) + const dynamicButtonsMap = { + [TRADING_SESSIONS.PRE_MARKET]: [], + [TRADING_SESSIONS.MORNING]: [ + { + key: "intraday", + label: "盘中", + range: [today0930, today1500], + tooltip: "盘中交易时段", + timeHint: "今日 09:30 - 15:00", + color: "blue", + type: "precise", + }, + ], + [TRADING_SESSIONS.LUNCH]: [], + [TRADING_SESSIONS.AFTERNOON]: [ + { + key: "intraday", + label: "盘中", + range: [today0930, today1500], + tooltip: "盘中交易时段", + timeHint: "今日 09:30 - 15:00", + color: "blue", + type: "precise", + }, + { + key: "afternoon", + label: "午盘", + range: [today1300, today1500], + tooltip: "午盘交易时段", + timeHint: "今日 13:00 - 15:00", + color: "cyan", + type: "precise", + }, + ], + [TRADING_SESSIONS.AFTER_HOURS]: [], + }; + + const prevTradingDay = getPrevTradingDay(now); + const prevTradingDay1500 = prevTradingDay.hour(15).minute(0).second(0); + + // 固定按钮配置 + const fixedButtons = [ + { + key: "current-trading-day", + label: "当前交易日", + range: [prevTradingDay1500, now], + tooltip: "当前交易日事件", + timeHint: `${prevTradingDay.format("MM-DD")} 15:00 - 现在`, + color: "green", + type: "precise", + }, + { + key: "morning-fixed", + label: "早盘", + range: [today0930, today1130], + tooltip: "早盘交易时段", + timeHint: "09:30 - 11:30", + color: "geekblue", + type: "precise", + }, + { + key: "today", + label: "今日全天", + range: [todayStart, todayEnd], + tooltip: "今日全天", + timeHint: "今日 00:00 - 23:59", + color: "purple", + type: "precise", + }, + { + key: "yesterday", + label: "昨日", + range: [yesterdayStart, yesterdayEnd], + tooltip: "昨日全天", + timeHint: "昨日 00:00 - 23:59", + color: "orange", + type: "precise", + }, + { + key: "week", + label: "近一周", + range: 7, + tooltip: "过去7个交易日", + timeHint: "过去7天", + color: "magenta", + type: "recent_days", + }, + { + key: "month", + label: "近一月", + range: 30, + tooltip: "过去30个交易日", + timeHint: "过去30天", + color: "volcano", + type: "recent_days", + }, + { + key: "all", + label: "全部", + range: null, + tooltip: "显示全部事件", + timeHint: "不限时间", + color: "default", + type: "all", + }, + ]; + + return { + dynamic: dynamicButtonsMap[session] || [], + fixed: fixedButtons, + }; +}; + +/** + * 禁用未来日期 + * @param {dayjs.Dayjs} current - 当前日期 + * @returns {boolean} 是否禁用 + */ +export const disabledDate = (current) => { + return current && current > dayjs().endOf("day"); +}; + +/** + * 禁用未来时间(精确到分钟) + * @param {dayjs.Dayjs} current - 当前时间 + * @returns {Object} 禁用配置 + */ +export const disabledTime = (current) => { + if (!current) return {}; + + const now = dayjs(); + const isToday = current.isSame(now, "day"); + + if (!isToday) return {}; + + const currentHour = now.hour(); + const currentMinute = now.minute(); + + return { + disabledHours: () => { + const hours = []; + for (let i = currentHour + 1; i < 24; i++) { + hours.push(i); + } + return hours; + }, + disabledMinutes: (selectedHour) => { + if (selectedHour === currentHour) { + const minutes = []; + for (let i = currentMinute + 1; i < 60; i++) { + minutes.push(i); + } + return minutes; + } + return []; + }, + }; +}; From a8dc68bddf90c65ae19a8ff12dbfdde326dfcceb Mon Sep 17 00:00:00 2001 From: zdl <3489966805@qq.com> Date: Thu, 15 Jan 2026 11:45:18 +0800 Subject: [PATCH 21/22] =?UTF-8?q?refactor(MainlineTimeline):=20=E6=8F=90?= =?UTF-8?q?=E5=8F=96=E5=B8=B8=E9=87=8F=E3=80=81=E5=B7=A5=E5=85=B7=E3=80=81?= =?UTF-8?q?=E5=AD=90=E7=BB=84=E4=BB=B6=E5=88=B0=E5=AD=90=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - constants.js: COLORS 颜色配置, EVENTS_PER_LOAD 分页常量 - utils.js: formatEventTime, getChangeBgColor, getColorScheme - TimelineEventItem.js: 时间线事件项组件 - MainlineCard.js: 主线卡片组件 - index.js: 模块统一导出 主文件从 ~670 行精简到 ~200 行 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../layouts/MainlineTimeline/MainlineCard.js | 294 ++++++++ .../MainlineTimeline/TimelineEventItem.js | 172 +++++ .../layouts/MainlineTimeline/constants.js | 18 + .../layouts/MainlineTimeline/index.js | 6 + .../layouts/MainlineTimeline/utils.js | 114 +++ .../layouts/MainlineTimelineView.js | 667 +----------------- 6 files changed, 640 insertions(+), 631 deletions(-) create mode 100644 src/views/Community/components/DynamicNews/layouts/MainlineTimeline/MainlineCard.js create mode 100644 src/views/Community/components/DynamicNews/layouts/MainlineTimeline/TimelineEventItem.js create mode 100644 src/views/Community/components/DynamicNews/layouts/MainlineTimeline/constants.js create mode 100644 src/views/Community/components/DynamicNews/layouts/MainlineTimeline/index.js create mode 100644 src/views/Community/components/DynamicNews/layouts/MainlineTimeline/utils.js diff --git a/src/views/Community/components/DynamicNews/layouts/MainlineTimeline/MainlineCard.js b/src/views/Community/components/DynamicNews/layouts/MainlineTimeline/MainlineCard.js new file mode 100644 index 00000000..5dba5ab2 --- /dev/null +++ b/src/views/Community/components/DynamicNews/layouts/MainlineTimeline/MainlineCard.js @@ -0,0 +1,294 @@ +// 单个主线卡片组件 - 支持懒加载 + +import React, { useState, useEffect, useCallback, useMemo } from "react"; +import { + Box, + VStack, + HStack, + Text, + Badge, + Flex, + Icon, + Button, +} from "@chakra-ui/react"; +import { ChevronDown, ChevronUp } from "lucide-react"; +import { FireOutlined } from "@ant-design/icons"; +import { COLORS, EVENTS_PER_LOAD } from "./constants"; +import TimelineEventItem from "./TimelineEventItem"; + +/** + * 单个主线卡片组件 + */ +const MainlineCard = React.memo( + ({ + mainline, + colorScheme, + isExpanded, + onToggle, + selectedEvent, + onEventSelect, + }) => { + // 懒加载状态 + const [displayCount, setDisplayCount] = useState(EVENTS_PER_LOAD); + const [isLoadingMore, setIsLoadingMore] = useState(false); + + // 重置显示数量当折叠时 + useEffect(() => { + if (!isExpanded) { + setDisplayCount(EVENTS_PER_LOAD); + } + }, [isExpanded]); + + // 找出最大超额涨幅最高的事件(HOT 事件) + const hotEvent = useMemo(() => { + if (!mainline.events || mainline.events.length === 0) return null; + let maxChange = -Infinity; + let hot = null; + mainline.events.forEach((event) => { + const change = event.related_max_chg ?? -Infinity; + if (change > maxChange) { + maxChange = change; + hot = event; + } + }); + return maxChange > 0 ? hot : null; + }, [mainline.events]); + + // 当前显示的事件 + const displayedEvents = useMemo(() => { + return mainline.events.slice(0, displayCount); + }, [mainline.events, displayCount]); + + // 是否还有更多 + const hasMore = displayCount < mainline.events.length; + + // 加载更多 + const loadMore = useCallback( + (e) => { + e.stopPropagation(); + setIsLoadingMore(true); + setTimeout(() => { + setDisplayCount((prev) => + Math.min(prev + EVENTS_PER_LOAD, mainline.events.length) + ); + setIsLoadingMore(false); + }, 50); + }, + [mainline.events.length] + ); + + return ( + + {/* 卡片头部 */} + + {/* 第一行:概念名称 + 涨跌幅 + 事件数 */} + + + + + {mainline.group_name || + mainline.lv2_name || + mainline.lv1_name || + "其他"} + + {mainline.avg_change_pct != null && ( + = 0 ? "#fc8181" : "#68d391"} + flexShrink={0} + > + {mainline.avg_change_pct >= 0 ? "+" : ""} + {mainline.avg_change_pct.toFixed(2)}% + + )} + + {mainline.event_count} + + + {mainline.parent_name && ( + + {mainline.grandparent_name + ? `${mainline.grandparent_name} > ` + : ""} + {mainline.parent_name} + + )} + + + + + {/* HOT 事件展示区域 */} + {hotEvent && ( + { + e.stopPropagation(); + onEventSelect?.(hotEvent); + }} + _hover={{ bg: "rgba(245, 101, 101, 0.18)" }} + transition="all 0.15s" + > + + + + HOT + + {hotEvent.related_max_chg != null && ( + + + 最大超额 +{hotEvent.related_max_chg.toFixed(2)}% + + + )} + + + {hotEvent.title} + + + )} + + + {/* 事件列表区域 */} + {isExpanded ? ( + + {displayedEvents.map((event) => ( + + ))} + + {hasMore && ( + + )} + + ) : ( + + {mainline.events.slice(0, 3).map((event) => ( + + ))} + {mainline.events.length > 3 && ( + + ... 还有 {mainline.events.length - 3} 条 + + )} + + )} + + ); + } +); + +MainlineCard.displayName = "MainlineCard"; + +export default MainlineCard; diff --git a/src/views/Community/components/DynamicNews/layouts/MainlineTimeline/TimelineEventItem.js b/src/views/Community/components/DynamicNews/layouts/MainlineTimeline/TimelineEventItem.js new file mode 100644 index 00000000..e33e3d27 --- /dev/null +++ b/src/views/Community/components/DynamicNews/layouts/MainlineTimeline/TimelineEventItem.js @@ -0,0 +1,172 @@ +// 单个事件项组件 - 卡片式布局 + +import React from "react"; +import { Box, HStack, Text } from "@chakra-ui/react"; +import { COLORS } from "./constants"; +import { formatEventTime, getChangeBgColor } from "./utils"; + +/** + * 单个事件项组件 + */ +const TimelineEventItem = React.memo(({ event, isSelected, onEventClick }) => { + // 使用 related_max_chg 作为主要涨幅显示 + const maxChange = event.related_max_chg; + const avgChange = event.related_avg_chg; + const hasMaxChange = maxChange != null && !isNaN(maxChange); + const hasAvgChange = avgChange != null && !isNaN(avgChange); + + // 用于背景色的涨幅(使用平均超额) + const bgValue = avgChange; + + return ( + onEventClick?.(event)} + bg={isSelected ? "rgba(66, 153, 225, 0.15)" : getChangeBgColor(bgValue)} + borderWidth="1px" + borderColor={isSelected ? "#4299e1" : COLORS.cardBorderColor} + borderRadius="lg" + p={3} + mb={2} + _hover={{ + bg: isSelected ? "rgba(66, 153, 225, 0.2)" : "rgba(255, 255, 255, 0.06)", + borderColor: isSelected ? "#63b3ed" : "#5a6070", + transform: "translateY(-1px)", + }} + transition="all 0.2s ease" + > + {/* 第一行:时间 */} + + {formatEventTime(event.created_at || event.event_time)} + + + {/* 第二行:标题 */} + + {event.title} + + + {/* 第三行:涨跌幅指标 */} + {(hasMaxChange || hasAvgChange) && ( + + {/* 最大超额 */} + {hasMaxChange && ( + 0 + ? "rgba(239, 68, 68, 0.15)" + : "rgba(16, 185, 129, 0.15)" + } + borderWidth="1px" + borderColor={ + maxChange > 0 + ? "rgba(239, 68, 68, 0.3)" + : "rgba(16, 185, 129, 0.3)" + } + borderRadius="md" + px={2} + py={1} + > + + 最大超额 + + 0 ? "#fc8181" : "#68d391"} + > + {maxChange > 0 ? "+" : ""} + {maxChange.toFixed(2)}% + + + )} + + {/* 平均超额 */} + {hasAvgChange && ( + 0 + ? "rgba(239, 68, 68, 0.15)" + : "rgba(16, 185, 129, 0.15)" + } + borderWidth="1px" + borderColor={ + avgChange > 0 + ? "rgba(239, 68, 68, 0.3)" + : "rgba(16, 185, 129, 0.3)" + } + borderRadius="md" + px={2} + py={1} + > + + 平均超额 + + 0 ? "#fc8181" : "#68d391"} + > + {avgChange > 0 ? "+" : ""} + {avgChange.toFixed(2)}% + + + )} + + {/* 超预期得分 */} + {event.expectation_surprise_score != null && ( + = 60 + ? "rgba(239, 68, 68, 0.15)" + : event.expectation_surprise_score >= 40 + ? "rgba(237, 137, 54, 0.15)" + : "rgba(66, 153, 225, 0.15)" + } + borderWidth="1px" + borderColor={ + event.expectation_surprise_score >= 60 + ? "rgba(239, 68, 68, 0.3)" + : event.expectation_surprise_score >= 40 + ? "rgba(237, 137, 54, 0.3)" + : "rgba(66, 153, 225, 0.3)" + } + borderRadius="md" + px={2} + py={1} + > + + 超预期 + + = 60 + ? "#fc8181" + : event.expectation_surprise_score >= 40 + ? "#ed8936" + : "#63b3ed" + } + > + {Math.round(event.expectation_surprise_score)}分 + + + )} + + )} + + ); +}); + +TimelineEventItem.displayName = "TimelineEventItem"; + +export default TimelineEventItem; diff --git a/src/views/Community/components/DynamicNews/layouts/MainlineTimeline/constants.js b/src/views/Community/components/DynamicNews/layouts/MainlineTimeline/constants.js new file mode 100644 index 00000000..9fa565a9 --- /dev/null +++ b/src/views/Community/components/DynamicNews/layouts/MainlineTimeline/constants.js @@ -0,0 +1,18 @@ +// MainlineTimelineView 常量定义 + +// 固定深色主题颜色 +export const COLORS = { + containerBg: "#1a1d24", + cardBg: "#252a34", + cardBorderColor: "#3a3f4b", + headerHoverBg: "#2d323e", + textColor: "#e2e8f0", + secondaryTextColor: "#a0aec0", + scrollbarTrackBg: "#2d3748", + scrollbarThumbBg: "#718096", + scrollbarThumbHoverBg: "#a0aec0", + statBarBg: "#252a34", +}; + +// 每次加载的事件数量 +export const EVENTS_PER_LOAD = 12; diff --git a/src/views/Community/components/DynamicNews/layouts/MainlineTimeline/index.js b/src/views/Community/components/DynamicNews/layouts/MainlineTimeline/index.js new file mode 100644 index 00000000..d91a6325 --- /dev/null +++ b/src/views/Community/components/DynamicNews/layouts/MainlineTimeline/index.js @@ -0,0 +1,6 @@ +// MainlineTimeline 模块导出 + +export { COLORS, EVENTS_PER_LOAD } from "./constants"; +export { formatEventTime, getChangeBgColor, getColorScheme } from "./utils"; +export { default as TimelineEventItem } from "./TimelineEventItem"; +export { default as MainlineCard } from "./MainlineCard"; diff --git a/src/views/Community/components/DynamicNews/layouts/MainlineTimeline/utils.js b/src/views/Community/components/DynamicNews/layouts/MainlineTimeline/utils.js new file mode 100644 index 00000000..4d3b92a8 --- /dev/null +++ b/src/views/Community/components/DynamicNews/layouts/MainlineTimeline/utils.js @@ -0,0 +1,114 @@ +// MainlineTimelineView 工具函数 + +import dayjs from "dayjs"; + +/** + * 格式化时间显示 - 始终显示日期,避免跨天混淆 + */ +export const formatEventTime = (dateStr) => { + if (!dateStr) return ""; + const date = dayjs(dateStr); + const now = dayjs(); + const isToday = date.isSame(now, "day"); + const isYesterday = date.isSame(now.subtract(1, "day"), "day"); + + if (isToday) { + return `今天 ${date.format("MM-DD HH:mm")}`; + } else if (isYesterday) { + return `昨天 ${date.format("MM-DD HH:mm")}`; + } else { + return date.format("MM-DD HH:mm"); + } +}; + +/** + * 根据涨跌幅获取背景色 + */ +export const getChangeBgColor = (value) => { + if (value == null || isNaN(value)) return "transparent"; + const absChange = Math.abs(value); + if (value > 0) { + if (absChange >= 5) return "rgba(239, 68, 68, 0.12)"; + if (absChange >= 3) return "rgba(239, 68, 68, 0.08)"; + return "rgba(239, 68, 68, 0.05)"; + } else if (value < 0) { + if (absChange >= 5) return "rgba(16, 185, 129, 0.12)"; + if (absChange >= 3) return "rgba(16, 185, 129, 0.08)"; + return "rgba(16, 185, 129, 0.05)"; + } + return "transparent"; +}; + +/** + * 根据主线类型获取配色方案 + */ +export const getColorScheme = (lv2Name) => { + if (!lv2Name) return "gray"; + const name = lv2Name.toLowerCase(); + + if ( + name.includes("ai") || + name.includes("人工智能") || + name.includes("算力") || + name.includes("大模型") + ) + return "purple"; + if ( + name.includes("半导体") || + name.includes("芯片") || + name.includes("光刻") + ) + return "blue"; + if (name.includes("机器人") || name.includes("人形")) return "pink"; + if ( + name.includes("消费电子") || + name.includes("手机") || + name.includes("xr") + ) + return "cyan"; + if ( + name.includes("汽车") || + name.includes("驾驶") || + name.includes("新能源车") + ) + return "teal"; + if ( + name.includes("新能源") || + name.includes("电力") || + name.includes("光伏") || + name.includes("储能") + ) + return "green"; + if ( + name.includes("低空") || + name.includes("航天") || + name.includes("卫星") + ) + return "orange"; + if (name.includes("军工") || name.includes("国防")) return "red"; + if ( + name.includes("医药") || + name.includes("医疗") || + name.includes("生物") + ) + return "messenger"; + if ( + name.includes("消费") || + name.includes("食品") || + name.includes("白酒") + ) + return "yellow"; + if ( + name.includes("煤炭") || + name.includes("石油") || + name.includes("钢铁") + ) + return "blackAlpha"; + if ( + name.includes("金融") || + name.includes("银行") || + name.includes("券商") + ) + return "linkedin"; + return "gray"; +}; diff --git a/src/views/Community/components/DynamicNews/layouts/MainlineTimelineView.js b/src/views/Community/components/DynamicNews/layouts/MainlineTimelineView.js index 389c17c9..92c66467 100644 --- a/src/views/Community/components/DynamicNews/layouts/MainlineTimelineView.js +++ b/src/views/Community/components/DynamicNews/layouts/MainlineTimelineView.js @@ -21,485 +21,15 @@ import { Center, IconButton, Tooltip, - Button, } from "@chakra-ui/react"; import { ChevronDown, ChevronUp, RefreshCw, TrendingUp, Zap } from "lucide-react"; -import { FireOutlined } from "@ant-design/icons"; -import dayjs from "dayjs"; import { Select } from "antd"; import { getApiBase } from "@utils/apiConfig"; -import { getChangeColor } from "@utils/colorUtils"; import "../../SearchFilters/CompactSearchBox.css"; -// 固定深色主题颜色 -const COLORS = { - containerBg: "#1a1d24", - cardBg: "#252a34", - cardBorderColor: "#3a3f4b", - headerHoverBg: "#2d323e", - textColor: "#e2e8f0", - secondaryTextColor: "#a0aec0", - scrollbarTrackBg: "#2d3748", - scrollbarThumbBg: "#718096", - scrollbarThumbHoverBg: "#a0aec0", - statBarBg: "#252a34", -}; - -// 每次加载的事件数量 -const EVENTS_PER_LOAD = 12; - -/** - * 格式化时间显示 - 始终显示日期,避免跨天混淆 - */ -const formatEventTime = (dateStr) => { - if (!dateStr) return ""; - const date = dayjs(dateStr); - const now = dayjs(); - const isToday = date.isSame(now, "day"); - const isYesterday = date.isSame(now.subtract(1, "day"), "day"); - - // 始终显示日期,用标签区分今天/昨天 - if (isToday) { - return `今天 ${date.format("MM-DD HH:mm")}`; - } else if (isYesterday) { - return `昨天 ${date.format("MM-DD HH:mm")}`; - } else { - return date.format("MM-DD HH:mm"); - } -}; - -/** - * 根据涨跌幅获取背景色 - */ -const getChangeBgColor = (value) => { - if (value == null || isNaN(value)) return "transparent"; - const absChange = Math.abs(value); - if (value > 0) { - if (absChange >= 5) return "rgba(239, 68, 68, 0.12)"; - if (absChange >= 3) return "rgba(239, 68, 68, 0.08)"; - return "rgba(239, 68, 68, 0.05)"; - } else if (value < 0) { - if (absChange >= 5) return "rgba(16, 185, 129, 0.12)"; - if (absChange >= 3) return "rgba(16, 185, 129, 0.08)"; - return "rgba(16, 185, 129, 0.05)"; - } - return "transparent"; -}; - -/** - * 单个事件项组件 - 卡片式布局 - */ -const TimelineEventItem = React.memo(({ event, isSelected, onEventClick }) => { - // 使用 related_max_chg 作为主要涨幅显示 - const maxChange = event.related_max_chg; - const avgChange = event.related_avg_chg; - const hasMaxChange = maxChange != null && !isNaN(maxChange); - const hasAvgChange = avgChange != null && !isNaN(avgChange); - - // 用于背景色的涨幅(使用平均超额) - const bgValue = avgChange; - - return ( - onEventClick?.(event)} - bg={isSelected ? "rgba(66, 153, 225, 0.15)" : getChangeBgColor(bgValue)} - borderWidth="1px" - borderColor={isSelected ? "#4299e1" : COLORS.cardBorderColor} - borderRadius="lg" - p={3} - mb={2} - _hover={{ - bg: isSelected ? "rgba(66, 153, 225, 0.2)" : "rgba(255, 255, 255, 0.06)", - borderColor: isSelected ? "#63b3ed" : "#5a6070", - transform: "translateY(-1px)", - }} - transition="all 0.2s ease" - > - {/* 第一行:时间 */} - - {formatEventTime(event.created_at || event.event_time)} - - - {/* 第二行:标题 */} - - {event.title} - - - {/* 第三行:涨跌幅指标 */} - {(hasMaxChange || hasAvgChange) && ( - - {/* 最大超额 */} - {hasMaxChange && ( - 0 ? "rgba(239, 68, 68, 0.15)" : "rgba(16, 185, 129, 0.15)"} - borderWidth="1px" - borderColor={maxChange > 0 ? "rgba(239, 68, 68, 0.3)" : "rgba(16, 185, 129, 0.3)"} - borderRadius="md" - px={2} - py={1} - > - - 最大超额 - - 0 ? "#fc8181" : "#68d391"} - > - {maxChange > 0 ? "+" : ""}{maxChange.toFixed(2)}% - - - )} - - {/* 平均超额 */} - {hasAvgChange && ( - 0 ? "rgba(239, 68, 68, 0.15)" : "rgba(16, 185, 129, 0.15)"} - borderWidth="1px" - borderColor={avgChange > 0 ? "rgba(239, 68, 68, 0.3)" : "rgba(16, 185, 129, 0.3)"} - borderRadius="md" - px={2} - py={1} - > - - 平均超额 - - 0 ? "#fc8181" : "#68d391"} - > - {avgChange > 0 ? "+" : ""}{avgChange.toFixed(2)}% - - - )} - - {/* 超预期得分 */} - {event.expectation_surprise_score != null && ( - = 60 ? "rgba(239, 68, 68, 0.15)" : - event.expectation_surprise_score >= 40 ? "rgba(237, 137, 54, 0.15)" : "rgba(66, 153, 225, 0.15)"} - borderWidth="1px" - borderColor={event.expectation_surprise_score >= 60 ? "rgba(239, 68, 68, 0.3)" : - event.expectation_surprise_score >= 40 ? "rgba(237, 137, 54, 0.3)" : "rgba(66, 153, 225, 0.3)"} - borderRadius="md" - px={2} - py={1} - > - - 超预期 - - = 60 ? "#fc8181" : - event.expectation_surprise_score >= 40 ? "#ed8936" : "#63b3ed"} - > - {Math.round(event.expectation_surprise_score)}分 - - - )} - - )} - - ); -}); - -TimelineEventItem.displayName = "TimelineEventItem"; - -/** - * 单个主线卡片组件 - 支持懒加载 - */ -const MainlineCard = React.memo( - ({ - mainline, - colorScheme, - isExpanded, - onToggle, - selectedEvent, - onEventSelect, - }) => { - // 懒加载状态 - const [displayCount, setDisplayCount] = useState(EVENTS_PER_LOAD); - const [isLoadingMore, setIsLoadingMore] = useState(false); - - // 重置显示数量当折叠时 - useEffect(() => { - if (!isExpanded) { - setDisplayCount(EVENTS_PER_LOAD); - } - }, [isExpanded]); - - // 找出最大超额涨幅最高的事件(HOT 事件) - const hotEvent = useMemo(() => { - if (!mainline.events || mainline.events.length === 0) return null; - let maxChange = -Infinity; - let hot = null; - mainline.events.forEach((event) => { - // 统一使用 related_max_chg(最大超额) - const change = event.related_max_chg ?? -Infinity; - if (change > maxChange) { - maxChange = change; - hot = event; - } - }); - // 只有当最大超额 > 0 时才显示 HOT - return maxChange > 0 ? hot : null; - }, [mainline.events]); - - // 当前显示的事件 - const displayedEvents = useMemo(() => { - return mainline.events.slice(0, displayCount); - }, [mainline.events, displayCount]); - - // 是否还有更多 - const hasMore = displayCount < mainline.events.length; - - // 加载更多 - const loadMore = useCallback( - (e) => { - e.stopPropagation(); - setIsLoadingMore(true); - setTimeout(() => { - setDisplayCount((prev) => - Math.min(prev + EVENTS_PER_LOAD, mainline.events.length) - ); - setIsLoadingMore(false); - }, 50); - }, - [mainline.events.length] - ); - - return ( - - {/* 卡片头部 */} - - {/* 第一行:概念名称 + 涨跌幅 + 事件数 */} - - - - - {mainline.group_name || mainline.lv2_name || mainline.lv1_name || "其他"} - - {/* 涨跌幅显示 - 在概念名称旁边 */} - {mainline.avg_change_pct != null && ( - = 0 ? "#fc8181" : "#68d391"} - flexShrink={0} - > - {mainline.avg_change_pct >= 0 ? "+" : ""} - {mainline.avg_change_pct.toFixed(2)}% - - )} - - {mainline.event_count} - - - {/* 显示上级概念名称作为副标题 */} - {mainline.parent_name && ( - - {mainline.grandparent_name ? `${mainline.grandparent_name} > ` : ""} - {mainline.parent_name} - - )} - - - - - {/* HOT 事件展示区域 */} - {hotEvent && ( - { - e.stopPropagation(); - onEventSelect?.(hotEvent); - }} - _hover={{ bg: "rgba(245, 101, 101, 0.18)" }} - transition="all 0.15s" - > - {/* 第一行:HOT 标签 + 最大超额 */} - - - - HOT - - {/* 最大超额涨幅 */} - {hotEvent.related_max_chg != null && ( - - - 最大超额 +{hotEvent.related_max_chg.toFixed(2)}% - - - )} - - {/* 第二行:标题 */} - - {hotEvent.title} - - - )} - - - {/* 事件列表区域 */} - {isExpanded ? ( - - {/* 事件列表 - 卡片式 */} - {displayedEvents.map((event) => ( - - ))} - - {/* 加载更多按钮 */} - {hasMore && ( - - )} - - ) : ( - /* 折叠时显示简要信息 - 卡片式 */ - - {mainline.events.slice(0, 3).map((event) => ( - - ))} - {mainline.events.length > 3 && ( - - ... 还有 {mainline.events.length - 3} 条 - - )} - - )} - - ); - } -); - -MainlineCard.displayName = "MainlineCard"; +// 模块化导入 +import { COLORS, getColorScheme } from "./MainlineTimeline"; +import MainlineCard from "./MainlineTimeline/MainlineCard"; /** * 主线时间轴布局组件 @@ -522,85 +52,10 @@ const MainlineTimelineViewComponent = forwardRef( const [error, setError] = useState(null); const [mainlineData, setMainlineData] = useState(null); const [expandedGroups, setExpandedGroups] = useState({}); - // 概念级别选择: 'lv1' | 'lv2' | 'lv3' | 具体概念ID(如 L1_TMT, L2_AI_INFRA, L3_AI_CHIP) const [groupBy, setGroupBy] = useState("lv3"); - // 层级选项(从 API 获取) const [hierarchyOptions, setHierarchyOptions] = useState({ lv1: [], lv2: [], lv3: [] }); - // 排序方式: 'event_count' | 'change_desc' | 'change_asc' const [sortBy, setSortBy] = useState("event_count"); - // 根据主线类型获取配色 - const getColorScheme = useCallback((lv2Name) => { - if (!lv2Name) return "gray"; - const name = lv2Name.toLowerCase(); - - if ( - name.includes("ai") || - name.includes("人工智能") || - name.includes("算力") || - name.includes("大模型") - ) - return "purple"; - if ( - name.includes("半导体") || - name.includes("芯片") || - name.includes("光刻") - ) - return "blue"; - if (name.includes("机器人") || name.includes("人形")) return "pink"; - if ( - name.includes("消费电子") || - name.includes("手机") || - name.includes("xr") - ) - return "cyan"; - if ( - name.includes("汽车") || - name.includes("驾驶") || - name.includes("新能源车") - ) - return "teal"; - if ( - name.includes("新能源") || - name.includes("电力") || - name.includes("光伏") || - name.includes("储能") - ) - return "green"; - if ( - name.includes("低空") || - name.includes("航天") || - name.includes("卫星") - ) - return "orange"; - if (name.includes("军工") || name.includes("国防")) return "red"; - if ( - name.includes("医药") || - name.includes("医疗") || - name.includes("生物") - ) - return "messenger"; - if ( - name.includes("消费") || - name.includes("食品") || - name.includes("白酒") - ) - return "yellow"; - if ( - name.includes("煤炭") || - name.includes("石油") || - name.includes("钢铁") - ) - return "blackAlpha"; - if ( - name.includes("金融") || - name.includes("银行") || - name.includes("券商") - ) - return "linkedin"; - return "gray"; - }, []); - // 加载主线数据 const fetchMainlineData = useCallback(async () => { if (display === "none") return; @@ -612,8 +67,6 @@ const MainlineTimelineViewComponent = forwardRef( const apiBase = getApiBase(); const params = new URLSearchParams(); - // 添加筛选参数(主线模式支持时间范围筛选) - // 优先使用精确时间范围(start_date/end_date),其次使用 recent_days if (filters.start_date) { params.append("start_date", filters.start_date); } @@ -621,34 +74,27 @@ const MainlineTimelineViewComponent = forwardRef( params.append("end_date", filters.end_date); } if (filters.recent_days && !filters.start_date && !filters.end_date) { - // 只有在没有精确时间范围时才使用 recent_days params.append("recent_days", filters.recent_days); } - // 添加分组方式参数 params.append("group_by", groupBy); const url = `${apiBase}/api/events/mainline?${params.toString()}`; - const response = await fetch(url); + if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const result = await response.json(); - - // 兼容两种响应格式:{ success, data: {...} } 或 { success, mainlines, ... } const responseData = result.data || result; if (result.success) { - // 保存原始数据,排序在渲染时根据 sortBy 状态进行 setMainlineData(responseData); - // 保存层级选项供下拉框使用 if (responseData.hierarchy_options) { setHierarchyOptions(responseData.hierarchy_options); } - // 初始化展开状态(默认全部展开) const initialExpanded = {}; (responseData.mainlines || []).forEach((mainline) => { const groupId = mainline.group_id || mainline.lv2_id || mainline.lv1_id; @@ -702,21 +148,18 @@ const MainlineTimelineViewComponent = forwardRef( [mainlineData] ); - // 根据排序方式排序主线列表(必须在条件渲染之前,遵循 Hooks 规则) + // 根据排序方式排序主线列表 const sortedMainlines = useMemo(() => { const rawMainlines = mainlineData?.mainlines; if (!rawMainlines) return []; const sorted = [...rawMainlines]; switch (sortBy) { case "change_desc": - // 按涨跌幅从高到低(涨幅大的在前) return sorted.sort((a, b) => (b.avg_change_pct ?? -999) - (a.avg_change_pct ?? -999)); case "change_asc": - // 按涨跌幅从低到高(跌幅大的在前) return sorted.sort((a, b) => (a.avg_change_pct ?? 999) - (b.avg_change_pct ?? 999)); case "event_count": default: - // 按事件数量从多到少 return sorted.sort((a, b) => b.event_count - a.event_count); } }, [mainlineData?.mainlines, sortBy]); @@ -771,22 +214,12 @@ const MainlineTimelineViewComponent = forwardRef( ); } - const { - total_events, - mainline_count, - ungrouped_count, - } = mainlineData; - - // 使用排序后的主线列表 + const { total_events, mainline_count, ungrouped_count } = mainlineData; const mainlines = sortedMainlines; return ( - - {/* 顶部统计栏 - 固定不滚动 */} + + {/* 顶部统计栏 */} 0 - ? [ - { - label: "一级概念(展开)", - options: hierarchyOptions.lv1.map((opt) => ({ - value: opt.id, - label: opt.name, - })), - }, - ] + ? [{ + label: "一级概念(展开)", + options: hierarchyOptions.lv1.map((opt) => ({ + value: opt.id, + label: opt.name, + })), + }] : []), ...(hierarchyOptions.lv2?.length > 0 - ? [ - { - label: "二级概念(展开)", - options: hierarchyOptions.lv2.map((opt) => ({ - value: opt.id, - label: `${opt.name}`, - })), - }, - ] + ? [{ + label: "二级概念(展开)", + options: hierarchyOptions.lv2.map((opt) => ({ + value: opt.id, + label: `${opt.name}`, + })), + }] : []), ...(hierarchyOptions.lv3?.length > 0 - ? [ - { - label: "三级概念(展开)", - options: hierarchyOptions.lv3.map((opt) => ({ - value: opt.id, - label: `${opt.name}`, - })), - }, - ] + ? [{ + label: "三级概念(展开)", + options: hierarchyOptions.lv3.map((opt) => ({ + value: opt.id, + label: `${opt.name}`, + })), + }] : []), ]} /> @@ -882,15 +302,9 @@ const MainlineTimelineViewComponent = forwardRef( value={sortBy} onChange={setSortBy} size="small" - style={{ - width: 140, - backgroundColor: "transparent", - }} + style={{ width: 140, backgroundColor: "transparent" }} popupClassName="dark-select-dropdown" - dropdownStyle={{ - backgroundColor: "#252a34", - borderColor: "#3a3f4b", - }} + dropdownStyle={{ backgroundColor: "#252a34", borderColor: "#3a3f4b" }} options={[ { value: "event_count", label: "按事件数量" }, { value: "change_desc", label: "按涨幅↓" }, @@ -933,9 +347,8 @@ const MainlineTimelineViewComponent = forwardRef( - {/* 横向滚动容器 - 滚动条在顶部 */} + {/* 横向滚动容器 */} - {/* 主线卡片横向排列容器 */} {mainlines.map((mainline) => { - const groupId = - mainline.group_id || - mainline.lv2_id || - mainline.lv1_id || - "ungrouped"; - const groupName = - mainline.group_name || - mainline.lv2_name || - mainline.lv1_name || - "其他"; + const groupId = mainline.group_id || mainline.lv2_id || mainline.lv1_id || "ungrouped"; + const groupName = mainline.group_name || mainline.lv2_name || mainline.lv1_name || "其他"; return ( Date: Thu, 15 Jan 2026 15:03:38 +0800 Subject: [PATCH 22/22] =?UTF-8?q?feat(HeroPanel):=20=E8=B4=A2=E7=BB=8F?= =?UTF-8?q?=E6=97=A5=E5=8E=86=E4=BA=A4=E4=BA=92=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CombinedCalendar: 点击概念条打开概念详情页 - DetailModal: 日期点击弹窗恢复为居中 Modal - DetailModal: 事件背景/推演详情改为右侧抽屉(500px) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../HeroPanel/components/CombinedCalendar.js | 12 +++++++++ .../components/DetailModal/DetailModal.js | 26 +++++++------------ 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/src/views/Community/components/HeroPanel/components/CombinedCalendar.js b/src/views/Community/components/HeroPanel/components/CombinedCalendar.js index b925b58f..f58ddcd8 100644 --- a/src/views/Community/components/HeroPanel/components/CombinedCalendar.js +++ b/src/views/Community/components/HeroPanel/components/CombinedCalendar.js @@ -14,6 +14,7 @@ import dayjs from "dayjs"; import { GLASS_BLUR } from "@/constants/glassConfig"; import { eventService } from "@services/eventService"; import { getApiBase } from "@utils/apiConfig"; +import { getConceptHtmlUrl } from "@utils/textUtils"; import { textColors } from "../constants"; import { formatDateStr } from "../utils"; @@ -132,6 +133,16 @@ const CombinedCalendar = ({ DetailModal }) => { setCurrentMonth(new Date(year, month - 1, 1)); }, []); + // 处理概念条点击 - 打开概念详情页 + const handleEventClick = useCallback((event) => { + // event.title 格式: "概念名 (N天)" 或 "概念名" + const conceptName = event.title.replace(/\s*\(\d+天\)$/, ""); + const url = getConceptHtmlUrl(conceptName); + if (url) { + window.open(url, "_blank"); + } + }, []); + return ( <> { currentMonth={currentMonth} onDateClick={handleDateClick} onMonthChange={handleMonthChange} + onEventClick={handleEventClick} /> diff --git a/src/views/Community/components/HeroPanel/components/DetailModal/DetailModal.js b/src/views/Community/components/HeroPanel/components/DetailModal/DetailModal.js index fb82f6e5..e415150e 100644 --- a/src/views/Community/components/HeroPanel/components/DetailModal/DetailModal.js +++ b/src/views/Community/components/HeroPanel/components/DetailModal/DetailModal.js @@ -14,6 +14,7 @@ import { Typography, message, Modal as AntModal, + Drawer, ConfigProvider, theme, } from "antd"; @@ -439,7 +440,7 @@ const DetailModal = ({ open={isOpen} onCancel={onClose} footer={null} - width={1300} + width={1000} centered title={
@@ -891,7 +892,7 @@ const DetailModal = ({ - {/* 内容详情弹窗 - 页面居中 */} + {/* 内容详情抽屉 - 右侧滑入 */} - setDetailDrawerVisible(false)} - footer={null} - width={700} - centered + onClose={() => setDetailDrawerVisible(false)} + placement="right" + width={500} zIndex={1500} styles={{ header: { background: "rgba(25,25,50,0.98)", borderBottom: "1px solid rgba(255,215,0,0.2)", padding: "16px 24px", - marginBottom: 0, }, body: { background: "linear-gradient(135deg, rgba(15,15,30,0.98) 0%, rgba(25,25,50,0.98) 100%)", padding: "24px", - maxHeight: "70vh", - overflowY: "auto", }, - content: { + wrapper: { background: "linear-gradient(135deg, rgba(15,15,30,0.98) 0%, rgba(25,25,50,0.98) 100%)", - borderRadius: "12px", - border: "1px solid rgba(255,215,0,0.2)", }, mask: { background: "rgba(0,0,0,0.6)", @@ -936,8 +931,7 @@ const DetailModal = ({ >
-
+
{/* 相关股票弹窗 */}