Merge branch 'feature_bugfix/251201_vf_h5_ui' into feature_bugfix/251201_py_h5_ui
* feature_bugfix/251201_vf_h5_ui: refactor: 重构 StockDetailPanel 目录结构,清理未使用代码 chore: 清理 Community 目录下未使用的文件 fix: 修复 remeasure 依赖数组缺少 pageType refactor: 使用 performanceMonitor 替换 useFirstScreenMetrics 中的 performance.now() fix: 文案修改 fix: 修复个股搜索下拉弹窗被遮挡的问题 feat: 添加 React 性能追踪 Hooks (usePerformanceTracker) feat: 替换公众号文件
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
// src/views/Community/components/StockDetailPanel/components/MiniTimelineChart.js
|
// src/components/Charts/Stock/MiniTimelineChart.js
|
||||||
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||||
import ReactECharts from 'echarts-for-react';
|
import ReactECharts from 'echarts-for-react';
|
||||||
import * as echarts from 'echarts';
|
import * as echarts from 'echarts';
|
||||||
4
src/components/Charts/Stock/hooks/index.js
Normal file
4
src/components/Charts/Stock/hooks/index.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// src/components/Charts/Stock/hooks/index.js
|
||||||
|
// 股票图表 Hooks 统一导出
|
||||||
|
|
||||||
|
export { useEventStocks } from './useEventStocks';
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// src/views/Community/components/StockDetailPanel/hooks/useEventStocks.js
|
// src/components/Charts/Stock/hooks/useEventStocks.js
|
||||||
import { useSelector, useDispatch, shallowEqual } from 'react-redux';
|
import { useSelector, useDispatch, shallowEqual } from 'react-redux';
|
||||||
import { useEffect, useCallback, useMemo } from 'react';
|
import { useEffect, useCallback, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
@@ -8,8 +8,8 @@ import {
|
|||||||
fetchHistoricalEvents,
|
fetchHistoricalEvents,
|
||||||
fetchChainAnalysis,
|
fetchChainAnalysis,
|
||||||
fetchExpectationScore
|
fetchExpectationScore
|
||||||
} from '../../../../../store/slices/stockSlice';
|
} from '@store/slices/stockSlice';
|
||||||
import { logger } from '../../../../../utils/logger';
|
import { logger } from '@utils/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 事件股票数据 Hook
|
* 事件股票数据 Hook
|
||||||
5
src/components/Charts/Stock/index.js
Normal file
5
src/components/Charts/Stock/index.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// src/components/Charts/Stock/index.js
|
||||||
|
// 股票图表组件统一导出
|
||||||
|
|
||||||
|
export { default as MiniTimelineChart } from './MiniTimelineChart';
|
||||||
|
export { useEventStocks } from './hooks/useEventStocks';
|
||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { getImportanceConfig } from '@constants/importanceLevels';
|
import { getImportanceConfig } from '@constants/importanceLevels';
|
||||||
import { eventService } from '@services/eventService';
|
import { eventService } from '@services/eventService';
|
||||||
import { useEventStocks } from '@views/Community/components/StockDetailPanel/hooks/useEventStocks';
|
import { useEventStocks } from '@components/Charts/Stock';
|
||||||
import { toggleEventFollow, selectEventFollowStatus } from '@store/slices/communityDataSlice';
|
import { toggleEventFollow, selectEventFollowStatus } from '@store/slices/communityDataSlice';
|
||||||
import { useAuth } from '@contexts/AuthContext';
|
import { useAuth } from '@contexts/AuthContext';
|
||||||
import EventHeaderInfo from './EventHeaderInfo';
|
import EventHeaderInfo from './EventHeaderInfo';
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { StarIcon } from '@chakra-ui/icons';
|
|||||||
import { Tag } from 'antd';
|
import { Tag } from 'antd';
|
||||||
import { RobotOutlined } from '@ant-design/icons';
|
import { RobotOutlined } from '@ant-design/icons';
|
||||||
import { selectIsMobile } from '@store/slices/deviceSlice';
|
import { selectIsMobile } from '@store/slices/deviceSlice';
|
||||||
import MiniTimelineChart from '@views/Community/components/StockDetailPanel/components/MiniTimelineChart';
|
import { MiniTimelineChart } from '@components/Charts/Stock';
|
||||||
import MiniKLineChart from './MiniKLineChart';
|
import MiniKLineChart from './MiniKLineChart';
|
||||||
import TimelineChartModal from '@components/StockChart/TimelineChartModal';
|
import TimelineChartModal from '@components/StockChart/TimelineChartModal';
|
||||||
import KLineChartModal from '@components/StockChart/KLineChartModal';
|
import KLineChartModal from '@components/StockChart/KLineChartModal';
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { initWebVitalsTracking, getCachedMetrics } from '@utils/performance/webVitals';
|
import { initWebVitalsTracking, getCachedMetrics } from '@utils/performance/webVitals';
|
||||||
import { collectResourceStats, collectApiStats } from '@utils/performance/resourceMonitor';
|
import { collectResourceStats, collectApiStats } from '@utils/performance/resourceMonitor';
|
||||||
|
import { performanceMonitor } from '@utils/performanceMonitor';
|
||||||
|
import { usePerformanceMark } from '@hooks/usePerformanceTracker';
|
||||||
import posthog from 'posthog-js';
|
import posthog from 'posthog-js';
|
||||||
import type {
|
import type {
|
||||||
FirstScreenMetrics,
|
FirstScreenMetrics,
|
||||||
@@ -44,11 +46,17 @@ export const useFirstScreenMetrics = (
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [metrics, setMetrics] = useState<FirstScreenMetrics | null>(null);
|
const [metrics, setMetrics] = useState<FirstScreenMetrics | null>(null);
|
||||||
|
|
||||||
// 使用 ref 记录页面加载开始时间
|
// 使用 ref 避免重复标记
|
||||||
const pageLoadStartRef = useRef<number>(performance.now());
|
const hasMarkedRef = useRef(false);
|
||||||
const skeletonStartRef = useRef<number>(performance.now());
|
|
||||||
const hasInitializedRef = useRef(false);
|
const hasInitializedRef = useRef(false);
|
||||||
|
|
||||||
|
// 在组件首次渲染时标记开始时间点
|
||||||
|
if (!hasMarkedRef.current) {
|
||||||
|
hasMarkedRef.current = true;
|
||||||
|
performanceMonitor.mark(`${pageType}-page-load-start`);
|
||||||
|
performanceMonitor.mark(`${pageType}-skeleton-start`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 收集所有首屏指标
|
* 收集所有首屏指标
|
||||||
*/
|
*/
|
||||||
@@ -82,12 +90,20 @@ export const useFirstScreenMetrics = (
|
|||||||
customProperties,
|
customProperties,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 5. 计算首屏可交互时间(TTI)
|
// 5. 标记可交互时间点,并计算 TTI
|
||||||
const now = performance.now();
|
performanceMonitor.mark(`${pageType}-interactive`);
|
||||||
const timeToInteractive = now - pageLoadStartRef.current;
|
const timeToInteractive = performanceMonitor.measure(
|
||||||
|
`${pageType}-page-load-start`,
|
||||||
|
`${pageType}-interactive`,
|
||||||
|
`${pageType} TTI`
|
||||||
|
) || 0;
|
||||||
|
|
||||||
// 6. 计算骨架屏展示时长
|
// 6. 计算骨架屏展示时长
|
||||||
const skeletonDisplayDuration = now - skeletonStartRef.current;
|
const skeletonDisplayDuration = performanceMonitor.measure(
|
||||||
|
`${pageType}-skeleton-start`,
|
||||||
|
`${pageType}-interactive`,
|
||||||
|
`${pageType} 骨架屏时长`
|
||||||
|
) || 0;
|
||||||
|
|
||||||
const firstScreenMetrics: FirstScreenMetrics = {
|
const firstScreenMetrics: FirstScreenMetrics = {
|
||||||
webVitals,
|
webVitals,
|
||||||
@@ -143,9 +159,9 @@ export const useFirstScreenMetrics = (
|
|||||||
const remeasure = useCallback(() => {
|
const remeasure = useCallback(() => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
// 重置计时器
|
// 重置性能标记
|
||||||
pageLoadStartRef.current = performance.now();
|
performanceMonitor.mark(`${pageType}-page-load-start`);
|
||||||
skeletonStartRef.current = performance.now();
|
performanceMonitor.mark(`${pageType}-skeleton-start`);
|
||||||
|
|
||||||
// 延迟收集指标(等待 Web Vitals 完成)
|
// 延迟收集指标(等待 Web Vitals 完成)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -167,7 +183,7 @@ export const useFirstScreenMetrics = (
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, 1000); // 延迟 1 秒收集
|
}, 1000); // 延迟 1 秒收集
|
||||||
}, [collectAllMetrics, trackFirstScreenInteractive, enableConsoleLog]);
|
}, [pageType, collectAllMetrics, trackFirstScreenInteractive, enableConsoleLog]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 导出指标为 JSON
|
* 导出指标为 JSON
|
||||||
@@ -247,7 +263,7 @@ export const useFirstScreenMetrics = (
|
|||||||
*
|
*
|
||||||
* 使用示例:
|
* 使用示例:
|
||||||
* ```tsx
|
* ```tsx
|
||||||
* const { markSkeletonEnd } = useSkeletonTiming();
|
* const { markSkeletonEnd } = useSkeletonTiming('home-skeleton');
|
||||||
*
|
*
|
||||||
* useEffect(() => {
|
* useEffect(() => {
|
||||||
* if (!loading) {
|
* if (!loading) {
|
||||||
@@ -256,27 +272,32 @@ export const useFirstScreenMetrics = (
|
|||||||
* }, [loading, markSkeletonEnd]);
|
* }, [loading, markSkeletonEnd]);
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export const useSkeletonTiming = () => {
|
export const useSkeletonTiming = (prefix = 'skeleton') => {
|
||||||
const skeletonStartRef = useRef<number>(performance.now());
|
const { mark, getMeasure } = usePerformanceMark(prefix);
|
||||||
const skeletonEndRef = useRef<number | null>(null);
|
const hasMarkedEndRef = useRef(false);
|
||||||
|
const hasMarkedStartRef = useRef(false);
|
||||||
|
|
||||||
|
// 在组件首次渲染时标记开始
|
||||||
|
if (!hasMarkedStartRef.current) {
|
||||||
|
hasMarkedStartRef.current = true;
|
||||||
|
mark('start');
|
||||||
|
}
|
||||||
|
|
||||||
const markSkeletonEnd = useCallback(() => {
|
const markSkeletonEnd = useCallback(() => {
|
||||||
if (!skeletonEndRef.current) {
|
if (!hasMarkedEndRef.current) {
|
||||||
skeletonEndRef.current = performance.now();
|
hasMarkedEndRef.current = true;
|
||||||
const duration = skeletonEndRef.current - skeletonStartRef.current;
|
mark('end');
|
||||||
|
const duration = getMeasure('start', 'end');
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development' && duration) {
|
||||||
console.log(`⏱️ Skeleton Display Duration: ${(duration / 1000).toFixed(2)}s`);
|
console.log(`⏱️ Skeleton Display Duration: ${(duration / 1000).toFixed(2)}s`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, [mark, getMeasure]);
|
||||||
|
|
||||||
const getSkeletonDuration = useCallback((): number | null => {
|
const getSkeletonDuration = useCallback((): number | null => {
|
||||||
if (skeletonEndRef.current) {
|
return getMeasure('start', 'end');
|
||||||
return skeletonEndRef.current - skeletonStartRef.current;
|
}, [getMeasure]);
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
markSkeletonEnd,
|
markSkeletonEnd,
|
||||||
|
|||||||
129
src/hooks/usePerformanceTracker.ts
Normal file
129
src/hooks/usePerformanceTracker.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* React 性能追踪 Hooks
|
||||||
|
* 封装 performanceMonitor 工具,提供 React 友好的性能追踪 API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { performanceMonitor } from '@utils/performanceMonitor';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* usePerformanceMark 返回值类型
|
||||||
|
*/
|
||||||
|
export interface UsePerformanceMarkReturn {
|
||||||
|
/** 标记时间点 */
|
||||||
|
mark: (suffix: string) => void;
|
||||||
|
/** 测量并记录到 performanceMonitor */
|
||||||
|
measure: (startSuffix: string, endSuffix: string, name?: string) => number | null;
|
||||||
|
/** 获取测量值(不记录) */
|
||||||
|
getMeasure: (startSuffix: string, endSuffix: string) => number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* usePerformanceTracker - 自动追踪组件渲染性能
|
||||||
|
*
|
||||||
|
* @param componentName - 组件名称,用于标记
|
||||||
|
* @param options - 配置选项
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* function MyComponent() {
|
||||||
|
* usePerformanceTracker('MyComponent');
|
||||||
|
* return <div>...</div>;
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* 自动标记:
|
||||||
|
* - {componentName}-mount: 组件挂载时
|
||||||
|
* - {componentName}-rendered: 首次渲染完成
|
||||||
|
* - {componentName}-unmount: 组件卸载时
|
||||||
|
*/
|
||||||
|
export function usePerformanceTracker(
|
||||||
|
componentName: string,
|
||||||
|
options: { trackRender?: boolean } = {}
|
||||||
|
): void {
|
||||||
|
const { trackRender = true } = options;
|
||||||
|
const hasMounted = useRef(false);
|
||||||
|
|
||||||
|
// 首次渲染时立即标记(同步)
|
||||||
|
if (!hasMounted.current) {
|
||||||
|
performanceMonitor.mark(`${componentName}-mount`);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasMounted.current) {
|
||||||
|
hasMounted.current = true;
|
||||||
|
|
||||||
|
// 渲染完成标记(在 useEffect 中,表示 DOM 已更新)
|
||||||
|
if (trackRender) {
|
||||||
|
performanceMonitor.mark(`${componentName}-rendered`);
|
||||||
|
performanceMonitor.measure(
|
||||||
|
`${componentName}-mount`,
|
||||||
|
`${componentName}-rendered`,
|
||||||
|
`${componentName} 渲染`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件卸载时标记
|
||||||
|
return () => {
|
||||||
|
performanceMonitor.mark(`${componentName}-unmount`);
|
||||||
|
};
|
||||||
|
}, [componentName, trackRender]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* usePerformanceMark - 手动标记自定义操作的性能
|
||||||
|
*
|
||||||
|
* @param prefix - 标记前缀,用于区分不同操作
|
||||||
|
* @returns 包含 mark、measure、getMeasure 方法的对象
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* function MyComponent() {
|
||||||
|
* const { mark, getMeasure } = usePerformanceMark('api-call');
|
||||||
|
*
|
||||||
|
* const handleFetch = async () => {
|
||||||
|
* mark('start');
|
||||||
|
* await fetchData();
|
||||||
|
* mark('end');
|
||||||
|
* const duration = getMeasure('start', 'end');
|
||||||
|
* console.log('API耗时:', duration, 'ms');
|
||||||
|
* };
|
||||||
|
*
|
||||||
|
* return <button onClick={handleFetch}>加载数据</button>;
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function usePerformanceMark(prefix: string): UsePerformanceMarkReturn {
|
||||||
|
const mark = useCallback(
|
||||||
|
(suffix: string) => {
|
||||||
|
performanceMonitor.mark(`${prefix}-${suffix}`);
|
||||||
|
},
|
||||||
|
[prefix]
|
||||||
|
);
|
||||||
|
|
||||||
|
const measure = useCallback(
|
||||||
|
(startSuffix: string, endSuffix: string, name?: string) => {
|
||||||
|
return performanceMonitor.measure(
|
||||||
|
`${prefix}-${startSuffix}`,
|
||||||
|
`${prefix}-${endSuffix}`,
|
||||||
|
name || `${prefix}: ${startSuffix} → ${endSuffix}`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[prefix]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getMeasure = useCallback(
|
||||||
|
(startSuffix: string, endSuffix: string) => {
|
||||||
|
return performanceMonitor.measure(
|
||||||
|
`${prefix}-${startSuffix}`,
|
||||||
|
`${prefix}-${endSuffix}`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[prefix]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { mark, measure, getMeasure };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default usePerformanceTracker;
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
// src/hooks/useSearchEvents.js
|
|
||||||
// 全局搜索功能事件追踪 Hook
|
|
||||||
|
|
||||||
import { useCallback } from 'react';
|
|
||||||
import { usePostHogTrack } from './usePostHogRedux';
|
|
||||||
import { RETENTION_EVENTS } from '../lib/constants';
|
|
||||||
import { logger } from '../utils/logger';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 全局搜索事件追踪 Hook
|
|
||||||
* @param {Object} options - 配置选项
|
|
||||||
* @param {string} options.context - 搜索上下文 ('global' | 'stock' | 'news' | 'concept' | 'simulation')
|
|
||||||
* @returns {Object} 事件追踪处理函数集合
|
|
||||||
*/
|
|
||||||
export const useSearchEvents = ({ context = 'global' } = {}) => {
|
|
||||||
const { track } = usePostHogTrack();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 追踪搜索开始(聚焦搜索框)
|
|
||||||
* @param {string} placeholder - 搜索框提示文本
|
|
||||||
*/
|
|
||||||
const trackSearchInitiated = useCallback((placeholder = '') => {
|
|
||||||
track(RETENTION_EVENTS.SEARCH_INITIATED, {
|
|
||||||
context,
|
|
||||||
placeholder,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug('useSearchEvents', '🔍 Search Initiated', {
|
|
||||||
context,
|
|
||||||
placeholder,
|
|
||||||
});
|
|
||||||
}, [track, context]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 追踪搜索查询提交
|
|
||||||
* @param {string} query - 搜索查询词
|
|
||||||
* @param {number} resultCount - 搜索结果数量
|
|
||||||
* @param {Object} filters - 应用的筛选条件
|
|
||||||
*/
|
|
||||||
const trackSearchQuerySubmitted = useCallback((query, resultCount = 0, filters = {}) => {
|
|
||||||
if (!query) {
|
|
||||||
logger.warn('useSearchEvents', 'trackSearchQuerySubmitted: query is required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
track(RETENTION_EVENTS.SEARCH_QUERY_SUBMITTED, {
|
|
||||||
query,
|
|
||||||
query_length: query.length,
|
|
||||||
result_count: resultCount,
|
|
||||||
has_results: resultCount > 0,
|
|
||||||
context,
|
|
||||||
filters: filters,
|
|
||||||
filter_count: Object.keys(filters).length,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// 如果没有搜索结果,额外追踪
|
|
||||||
if (resultCount === 0) {
|
|
||||||
track(RETENTION_EVENTS.SEARCH_NO_RESULTS, {
|
|
||||||
query,
|
|
||||||
context,
|
|
||||||
filters,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug('useSearchEvents', '❌ Search No Results', {
|
|
||||||
query,
|
|
||||||
context,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
logger.debug('useSearchEvents', '✅ Search Query Submitted', {
|
|
||||||
query,
|
|
||||||
resultCount,
|
|
||||||
context,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [track, context]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 追踪搜索结果点击
|
|
||||||
* @param {Object} result - 被点击的搜索结果
|
|
||||||
* @param {string} result.type - 结果类型 ('stock' | 'news' | 'concept' | 'event')
|
|
||||||
* @param {string} result.id - 结果ID
|
|
||||||
* @param {string} result.title - 结果标题
|
|
||||||
* @param {number} position - 在搜索结果中的位置
|
|
||||||
* @param {string} query - 搜索查询词
|
|
||||||
*/
|
|
||||||
const trackSearchResultClicked = useCallback((result, position = 0, query = '') => {
|
|
||||||
if (!result || !result.type) {
|
|
||||||
logger.warn('useSearchEvents', 'trackSearchResultClicked: result object with type is required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
track(RETENTION_EVENTS.SEARCH_RESULT_CLICKED, {
|
|
||||||
result_type: result.type,
|
|
||||||
result_id: result.id || result.code || '',
|
|
||||||
result_title: result.title || result.name || '',
|
|
||||||
position,
|
|
||||||
query,
|
|
||||||
context,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug('useSearchEvents', '🎯 Search Result Clicked', {
|
|
||||||
type: result.type,
|
|
||||||
id: result.id || result.code,
|
|
||||||
position,
|
|
||||||
context,
|
|
||||||
});
|
|
||||||
}, [track, context]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 追踪搜索筛选应用
|
|
||||||
* @param {Object} filters - 应用的筛选条件
|
|
||||||
* @param {string} filterType - 筛选类型 ('sort' | 'category' | 'date_range' | 'price_range')
|
|
||||||
* @param {any} filterValue - 筛选值
|
|
||||||
*/
|
|
||||||
const trackSearchFilterApplied = useCallback((filterType, filterValue, filters = {}) => {
|
|
||||||
if (!filterType) {
|
|
||||||
logger.warn('useSearchEvents', 'trackSearchFilterApplied: filterType is required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
track(RETENTION_EVENTS.SEARCH_FILTER_APPLIED, {
|
|
||||||
filter_type: filterType,
|
|
||||||
filter_value: String(filterValue),
|
|
||||||
all_filters: filters,
|
|
||||||
context,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug('useSearchEvents', '🔍 Search Filter Applied', {
|
|
||||||
filterType,
|
|
||||||
filterValue,
|
|
||||||
context,
|
|
||||||
});
|
|
||||||
}, [track, context]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 追踪搜索建议点击(自动完成)
|
|
||||||
* @param {string} suggestion - 被点击的搜索建议
|
|
||||||
* @param {number} position - 在建议列表中的位置
|
|
||||||
* @param {string} source - 建议来源 ('history' | 'popular' | 'related')
|
|
||||||
*/
|
|
||||||
const trackSearchSuggestionClicked = useCallback((suggestion, position = 0, source = 'popular') => {
|
|
||||||
if (!suggestion) {
|
|
||||||
logger.warn('useSearchEvents', 'trackSearchSuggestionClicked: suggestion is required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
track('Search Suggestion Clicked', {
|
|
||||||
suggestion,
|
|
||||||
position,
|
|
||||||
source,
|
|
||||||
context,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug('useSearchEvents', '💡 Search Suggestion Clicked', {
|
|
||||||
suggestion,
|
|
||||||
position,
|
|
||||||
source,
|
|
||||||
context,
|
|
||||||
});
|
|
||||||
}, [track, context]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 追踪搜索历史查看
|
|
||||||
* @param {number} historyCount - 历史记录数量
|
|
||||||
*/
|
|
||||||
const trackSearchHistoryViewed = useCallback((historyCount = 0) => {
|
|
||||||
track('Search History Viewed', {
|
|
||||||
history_count: historyCount,
|
|
||||||
has_history: historyCount > 0,
|
|
||||||
context,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug('useSearchEvents', '📜 Search History Viewed', {
|
|
||||||
historyCount,
|
|
||||||
context,
|
|
||||||
});
|
|
||||||
}, [track, context]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 追踪搜索历史清除
|
|
||||||
*/
|
|
||||||
const trackSearchHistoryCleared = useCallback(() => {
|
|
||||||
track('Search History Cleared', {
|
|
||||||
context,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug('useSearchEvents', '🗑️ Search History Cleared', {
|
|
||||||
context,
|
|
||||||
});
|
|
||||||
}, [track, context]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 追踪热门搜索词点击
|
|
||||||
* @param {string} keyword - 被点击的热门关键词
|
|
||||||
* @param {number} position - 在列表中的位置
|
|
||||||
* @param {number} heatScore - 热度分数
|
|
||||||
*/
|
|
||||||
const trackPopularKeywordClicked = useCallback((keyword, position = 0, heatScore = 0) => {
|
|
||||||
if (!keyword) {
|
|
||||||
logger.warn('useSearchEvents', 'trackPopularKeywordClicked: keyword is required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
track('Popular Keyword Clicked', {
|
|
||||||
keyword,
|
|
||||||
position,
|
|
||||||
heat_score: heatScore,
|
|
||||||
context,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug('useSearchEvents', '🔥 Popular Keyword Clicked', {
|
|
||||||
keyword,
|
|
||||||
position,
|
|
||||||
context,
|
|
||||||
});
|
|
||||||
}, [track, context]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
// 搜索流程事件
|
|
||||||
trackSearchInitiated,
|
|
||||||
trackSearchQuerySubmitted,
|
|
||||||
trackSearchResultClicked,
|
|
||||||
|
|
||||||
// 筛选和建议
|
|
||||||
trackSearchFilterApplied,
|
|
||||||
trackSearchSuggestionClicked,
|
|
||||||
|
|
||||||
// 历史和热门
|
|
||||||
trackSearchHistoryViewed,
|
|
||||||
trackSearchHistoryCleared,
|
|
||||||
trackPopularKeywordClicked,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useSearchEvents;
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
// src/views/Community/components/DynamicNewsCard/PageNavigationButton.js
|
|
||||||
// 翻页导航按钮组件
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { IconButton, useColorModeValue } from '@chakra-ui/react';
|
|
||||||
import { ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 翻页导航按钮组件
|
|
||||||
* @param {Object} props
|
|
||||||
* @param {'prev'|'next'} props.direction - 按钮方向(prev=上一页,next=下一页)
|
|
||||||
* @param {number} props.currentPage - 当前页码
|
|
||||||
* @param {number} props.totalPages - 总页数
|
|
||||||
* @param {Function} props.onPageChange - 翻页回调
|
|
||||||
* @param {string} props.mode - 显示模式(只在carousel/grid模式下显示)
|
|
||||||
*/
|
|
||||||
const PageNavigationButton = ({
|
|
||||||
direction,
|
|
||||||
currentPage,
|
|
||||||
totalPages,
|
|
||||||
onPageChange,
|
|
||||||
mode
|
|
||||||
}) => {
|
|
||||||
// 主题适配
|
|
||||||
const arrowBtnBg = useColorModeValue('rgba(255, 255, 255, 0.9)', 'rgba(0, 0, 0, 0.6)');
|
|
||||||
const arrowBtnHoverBg = useColorModeValue('rgba(255, 255, 255, 1)', 'rgba(0, 0, 0, 0.8)');
|
|
||||||
|
|
||||||
// 根据方向计算配置
|
|
||||||
const isPrev = direction === 'prev';
|
|
||||||
const isNext = direction === 'next';
|
|
||||||
|
|
||||||
const Icon = isPrev ? ChevronLeftIcon : ChevronRightIcon;
|
|
||||||
const position = isPrev ? 'left' : 'right';
|
|
||||||
const label = isPrev ? '上一页' : '下一页';
|
|
||||||
const targetPage = isPrev ? currentPage - 1 : currentPage + 1;
|
|
||||||
const shouldShow = isPrev
|
|
||||||
? currentPage > 1
|
|
||||||
: currentPage < totalPages;
|
|
||||||
const isDisabled = isNext ? currentPage >= totalPages : false;
|
|
||||||
|
|
||||||
// 判断是否显示(只在单排/双排模式显示)
|
|
||||||
const shouldRender = shouldShow && (mode === 'carousel' || mode === 'grid');
|
|
||||||
|
|
||||||
if (!shouldRender) return null;
|
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
console.log(
|
|
||||||
`%c🔵 [翻页] 点击${label}: 当前页${currentPage} → 目标页${targetPage} (共${totalPages}页)`,
|
|
||||||
'color: #3B82F6; font-weight: bold;'
|
|
||||||
);
|
|
||||||
onPageChange(targetPage);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IconButton
|
|
||||||
icon={<Icon boxSize={6} color="blue.500" />}
|
|
||||||
position="absolute"
|
|
||||||
{...{ [position]: 0 }}
|
|
||||||
top="50%"
|
|
||||||
transform="translateY(-50%)"
|
|
||||||
zIndex={2}
|
|
||||||
onClick={handleClick}
|
|
||||||
variant="ghost"
|
|
||||||
size="md"
|
|
||||||
w="40px"
|
|
||||||
h="40px"
|
|
||||||
minW="40px"
|
|
||||||
borderRadius="full"
|
|
||||||
bg={arrowBtnBg}
|
|
||||||
boxShadow="0 2px 8px rgba(0, 0, 0, 0.15)"
|
|
||||||
_hover={{
|
|
||||||
bg: arrowBtnHoverBg,
|
|
||||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
|
|
||||||
transform: 'translateY(-50%) scale(1.05)'
|
|
||||||
}}
|
|
||||||
isDisabled={isDisabled}
|
|
||||||
aria-label={label}
|
|
||||||
title={label}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PageNavigationButton;
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
// src/views/Community/components/DynamicNewsCard/hooks/useInfiniteScroll.js
|
|
||||||
// 无限滚动 Hook
|
|
||||||
|
|
||||||
import { useEffect, useRef, useCallback } from 'react';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 无限滚动 Hook
|
|
||||||
* 监听容器滚动事件,当滚动到底部附近时触发加载更多数据
|
|
||||||
*
|
|
||||||
* @param {Object} options - 配置选项
|
|
||||||
* @param {Function} options.onLoadMore - 加载更多回调函数(返回 Promise)
|
|
||||||
* @param {boolean} options.hasMore - 是否还有更多数据
|
|
||||||
* @param {boolean} options.isLoading - 是否正在加载
|
|
||||||
* @param {number} options.threshold - 触发阈值(距离底部多少像素时触发,默认200px)
|
|
||||||
* @returns {Object} { containerRef } - 容器引用
|
|
||||||
*/
|
|
||||||
export const useInfiniteScroll = ({
|
|
||||||
onLoadMore,
|
|
||||||
hasMore = true,
|
|
||||||
isLoading = false,
|
|
||||||
threshold = 200
|
|
||||||
}) => {
|
|
||||||
const containerRef = useRef(null);
|
|
||||||
const isLoadingRef = useRef(false);
|
|
||||||
|
|
||||||
// 滚动处理函数
|
|
||||||
const handleScroll = useCallback(() => {
|
|
||||||
const container = containerRef.current;
|
|
||||||
|
|
||||||
// 检查条件:容器存在、未加载中、还有更多数据
|
|
||||||
if (!container || isLoadingRef.current || !hasMore) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
|
||||||
const distanceToBottom = scrollHeight - scrollTop - clientHeight;
|
|
||||||
|
|
||||||
// 距离底部小于阈值时触发加载
|
|
||||||
if (distanceToBottom < threshold) {
|
|
||||||
console.log(
|
|
||||||
'%c⬇️ [懒加载] 触发加载下一页',
|
|
||||||
'color: #8B5CF6; font-weight: bold;',
|
|
||||||
{
|
|
||||||
scrollTop,
|
|
||||||
scrollHeight,
|
|
||||||
clientHeight,
|
|
||||||
distanceToBottom,
|
|
||||||
threshold
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
isLoadingRef.current = true;
|
|
||||||
|
|
||||||
// 调用加载函数并更新状态
|
|
||||||
onLoadMore()
|
|
||||||
.then(() => {
|
|
||||||
console.log('%c✅ [懒加载] 加载完成', 'color: #10B981; font-weight: bold;');
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('%c❌ [懒加载] 加载失败', 'color: #DC2626; font-weight: bold;', error);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
isLoadingRef.current = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [onLoadMore, hasMore, threshold]);
|
|
||||||
|
|
||||||
// 绑定滚动事件
|
|
||||||
useEffect(() => {
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
// 添加滚动监听
|
|
||||||
container.addEventListener('scroll', handleScroll, { passive: true });
|
|
||||||
|
|
||||||
// 清理函数
|
|
||||||
return () => {
|
|
||||||
container.removeEventListener('scroll', handleScroll);
|
|
||||||
};
|
|
||||||
}, [handleScroll]);
|
|
||||||
|
|
||||||
// 更新 loading 状态的 ref
|
|
||||||
useEffect(() => {
|
|
||||||
isLoadingRef.current = isLoading;
|
|
||||||
}, [isLoading]);
|
|
||||||
|
|
||||||
return { containerRef };
|
|
||||||
};
|
|
||||||
@@ -1,614 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
Modal,
|
|
||||||
ModalOverlay,
|
|
||||||
ModalContent,
|
|
||||||
ModalHeader,
|
|
||||||
ModalBody,
|
|
||||||
ModalCloseButton,
|
|
||||||
Box,
|
|
||||||
Text,
|
|
||||||
VStack,
|
|
||||||
HStack,
|
|
||||||
Avatar,
|
|
||||||
Textarea,
|
|
||||||
Button,
|
|
||||||
Divider,
|
|
||||||
useToast,
|
|
||||||
Badge,
|
|
||||||
Flex,
|
|
||||||
IconButton,
|
|
||||||
Menu,
|
|
||||||
MenuButton,
|
|
||||||
MenuList,
|
|
||||||
MenuItem,
|
|
||||||
useColorModeValue,
|
|
||||||
Spinner,
|
|
||||||
Center,
|
|
||||||
Collapse,
|
|
||||||
Input,
|
|
||||||
} from '@chakra-ui/react';
|
|
||||||
import {
|
|
||||||
ChatIcon,
|
|
||||||
TimeIcon,
|
|
||||||
DeleteIcon,
|
|
||||||
EditIcon,
|
|
||||||
ChevronDownIcon,
|
|
||||||
TriangleDownIcon,
|
|
||||||
TriangleUpIcon,
|
|
||||||
} from '@chakra-ui/icons';
|
|
||||||
import { FaHeart, FaRegHeart, FaComment } from 'react-icons/fa';
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
import { zhCN } from 'date-fns/locale';
|
|
||||||
import { eventService } from '../../../services/eventService';
|
|
||||||
import { logger } from '../../../utils/logger';
|
|
||||||
|
|
||||||
const EventDiscussionModal = ({ isOpen, onClose, eventId, eventTitle, discussionType = '事件讨论' }) => {
|
|
||||||
const [posts, setPosts] = useState([]);
|
|
||||||
const [newPostContent, setNewPostContent] = useState('');
|
|
||||||
const [newPostTitle, setNewPostTitle] = useState('');
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [expandedPosts, setExpandedPosts] = useState({});
|
|
||||||
const [postComments, setPostComments] = useState({});
|
|
||||||
const [replyContents, setReplyContents] = useState({});
|
|
||||||
const [loadingComments, setLoadingComments] = useState({});
|
|
||||||
|
|
||||||
const toast = useToast();
|
|
||||||
const bgColor = useColorModeValue('white', 'gray.800');
|
|
||||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
|
||||||
const hoverBg = useColorModeValue('gray.50', 'gray.700');
|
|
||||||
|
|
||||||
// 加载帖子列表
|
|
||||||
const loadPosts = async () => {
|
|
||||||
if (!eventId) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/events/${eventId}/posts?sort=latest&page=1&per_page=20`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (response.ok && result.success) {
|
|
||||||
setPosts(result.data || []);
|
|
||||||
logger.debug('EventDiscussionModal', '帖子列表加载成功', {
|
|
||||||
eventId,
|
|
||||||
postsCount: result.data?.length || 0
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
logger.error('EventDiscussionModal', 'loadPosts', new Error('API返回错误'), {
|
|
||||||
eventId,
|
|
||||||
status: response.status,
|
|
||||||
message: result.message
|
|
||||||
});
|
|
||||||
toast({
|
|
||||||
title: '加载帖子失败',
|
|
||||||
status: 'error',
|
|
||||||
duration: 3000,
|
|
||||||
isClosable: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('EventDiscussionModal', 'loadPosts', error, { eventId });
|
|
||||||
toast({
|
|
||||||
title: '加载帖子失败',
|
|
||||||
status: 'error',
|
|
||||||
duration: 3000,
|
|
||||||
isClosable: true,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 加载帖子的评论
|
|
||||||
const loadPostComments = async (postId) => {
|
|
||||||
setLoadingComments(prev => ({ ...prev, [postId]: true }));
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/posts/${postId}/comments?sort=latest`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (response.ok && result.success) {
|
|
||||||
setPostComments(prev => ({ ...prev, [postId]: result.data || [] }));
|
|
||||||
logger.debug('EventDiscussionModal', '评论加载成功', {
|
|
||||||
postId,
|
|
||||||
commentsCount: result.data?.length || 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('EventDiscussionModal', 'loadPostComments', error, { postId });
|
|
||||||
} finally {
|
|
||||||
setLoadingComments(prev => ({ ...prev, [postId]: false }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 切换展开/收起评论
|
|
||||||
const togglePostComments = async (postId) => {
|
|
||||||
const isExpanded = expandedPosts[postId];
|
|
||||||
if (!isExpanded) {
|
|
||||||
// 展开时加载评论
|
|
||||||
await loadPostComments(postId);
|
|
||||||
}
|
|
||||||
setExpandedPosts(prev => ({ ...prev, [postId]: !isExpanded }));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 提交新帖子
|
|
||||||
const handleSubmitPost = async () => {
|
|
||||||
if (!newPostContent.trim()) return;
|
|
||||||
|
|
||||||
setSubmitting(true);
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/events/${eventId}/posts`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify({
|
|
||||||
title: newPostTitle.trim(),
|
|
||||||
content: newPostContent.trim(),
|
|
||||||
content_type: 'text',
|
|
||||||
})
|
|
||||||
});
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (response.ok && result.success) {
|
|
||||||
setNewPostContent('');
|
|
||||||
setNewPostTitle('');
|
|
||||||
loadPosts();
|
|
||||||
logger.info('EventDiscussionModal', '帖子发布成功', {
|
|
||||||
eventId,
|
|
||||||
postId: result.data?.id
|
|
||||||
});
|
|
||||||
toast({
|
|
||||||
title: '帖子发布成功',
|
|
||||||
status: 'success',
|
|
||||||
duration: 2000,
|
|
||||||
isClosable: true,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
logger.error('EventDiscussionModal', 'handleSubmitPost', new Error('API返回错误'), {
|
|
||||||
eventId,
|
|
||||||
message: result.message
|
|
||||||
});
|
|
||||||
toast({
|
|
||||||
title: result.message || '帖子发布失败',
|
|
||||||
status: 'error',
|
|
||||||
duration: 3000,
|
|
||||||
isClosable: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('EventDiscussionModal', 'handleSubmitPost', error, { eventId });
|
|
||||||
toast({
|
|
||||||
title: '帖子发布失败',
|
|
||||||
status: 'error',
|
|
||||||
duration: 3000,
|
|
||||||
isClosable: true,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 删除帖子
|
|
||||||
const handleDeletePost = async (postId) => {
|
|
||||||
if (!window.confirm('确定要删除这个帖子吗?')) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/posts/${postId}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (response.ok && result.success) {
|
|
||||||
loadPosts();
|
|
||||||
logger.info('EventDiscussionModal', '帖子删除成功', { postId });
|
|
||||||
toast({
|
|
||||||
title: '帖子已删除',
|
|
||||||
status: 'success',
|
|
||||||
duration: 2000,
|
|
||||||
isClosable: true,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
logger.error('EventDiscussionModal', 'handleDeletePost', new Error('API返回错误'), {
|
|
||||||
postId,
|
|
||||||
message: result.message
|
|
||||||
});
|
|
||||||
toast({
|
|
||||||
title: result.message || '删除失败',
|
|
||||||
status: 'error',
|
|
||||||
duration: 3000,
|
|
||||||
isClosable: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('EventDiscussionModal', 'handleDeletePost', error, { postId });
|
|
||||||
toast({
|
|
||||||
title: '删除失败',
|
|
||||||
status: 'error',
|
|
||||||
duration: 3000,
|
|
||||||
isClosable: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 点赞帖子
|
|
||||||
const handleLikePost = async (postId) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/posts/${postId}/like`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (response.ok && result.success) {
|
|
||||||
// 更新帖子列表中的点赞状态
|
|
||||||
setPosts(prev => prev.map(post =>
|
|
||||||
post.id === postId
|
|
||||||
? { ...post, likes_count: result.likes_count, liked: result.liked }
|
|
||||||
: post
|
|
||||||
));
|
|
||||||
logger.debug('EventDiscussionModal', '点赞操作成功', {
|
|
||||||
postId,
|
|
||||||
liked: result.liked,
|
|
||||||
likesCount: result.likes_count
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('EventDiscussionModal', 'handleLikePost', error, { postId });
|
|
||||||
toast({
|
|
||||||
title: '操作失败',
|
|
||||||
status: 'error',
|
|
||||||
duration: 2000,
|
|
||||||
isClosable: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 提交评论
|
|
||||||
const handleSubmitComment = async (postId) => {
|
|
||||||
const content = replyContents[postId];
|
|
||||||
if (!content?.trim()) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/posts/${postId}/comments`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify({
|
|
||||||
content: content.trim(),
|
|
||||||
})
|
|
||||||
});
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (response.ok && result.success) {
|
|
||||||
setReplyContents(prev => ({ ...prev, [postId]: '' }));
|
|
||||||
// 重新加载该帖子的评论
|
|
||||||
await loadPostComments(postId);
|
|
||||||
// 更新帖子的评论数
|
|
||||||
setPosts(prev => prev.map(post =>
|
|
||||||
post.id === postId
|
|
||||||
? { ...post, comments_count: (post.comments_count || 0) + 1 }
|
|
||||||
: post
|
|
||||||
));
|
|
||||||
logger.info('EventDiscussionModal', '评论发布成功', {
|
|
||||||
postId,
|
|
||||||
commentId: result.data?.id
|
|
||||||
});
|
|
||||||
toast({
|
|
||||||
title: '评论发布成功',
|
|
||||||
status: 'success',
|
|
||||||
duration: 2000,
|
|
||||||
isClosable: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('EventDiscussionModal', 'handleSubmitComment', error, { postId });
|
|
||||||
toast({
|
|
||||||
title: '评论发布失败',
|
|
||||||
status: 'error',
|
|
||||||
duration: 3000,
|
|
||||||
isClosable: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 删除评论
|
|
||||||
const handleDeleteComment = async (commentId, postId) => {
|
|
||||||
if (!window.confirm('确定要删除这条评论吗?')) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/comments/${commentId}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (response.ok && result.success) {
|
|
||||||
// 重新加载该帖子的评论
|
|
||||||
await loadPostComments(postId);
|
|
||||||
// 更新帖子的评论数
|
|
||||||
setPosts(prev => prev.map(post =>
|
|
||||||
post.id === postId
|
|
||||||
? { ...post, comments_count: Math.max(0, (post.comments_count || 0) - 1) }
|
|
||||||
: post
|
|
||||||
));
|
|
||||||
logger.info('EventDiscussionModal', '评论删除成功', { commentId, postId });
|
|
||||||
toast({
|
|
||||||
title: '评论已删除',
|
|
||||||
status: 'success',
|
|
||||||
duration: 2000,
|
|
||||||
isClosable: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('EventDiscussionModal', 'handleDeleteComment', error, { commentId, postId });
|
|
||||||
toast({
|
|
||||||
title: '删除失败',
|
|
||||||
status: 'error',
|
|
||||||
duration: 3000,
|
|
||||||
isClosable: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
loadPosts();
|
|
||||||
}
|
|
||||||
}, [isOpen, eventId]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal isOpen={isOpen} onClose={onClose} size="xl">
|
|
||||||
<ModalOverlay />
|
|
||||||
<ModalContent maxH="80vh">
|
|
||||||
<ModalHeader>
|
|
||||||
<VStack align="start" spacing={1}>
|
|
||||||
<HStack>
|
|
||||||
<ChatIcon />
|
|
||||||
<Text>{discussionType}</Text>
|
|
||||||
</HStack>
|
|
||||||
{eventTitle && (
|
|
||||||
<Text fontSize="sm" color="gray.500" fontWeight="normal">
|
|
||||||
{eventTitle}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
</ModalHeader>
|
|
||||||
<ModalCloseButton />
|
|
||||||
|
|
||||||
<ModalBody overflowY="auto">
|
|
||||||
{/* 发布新帖子 */}
|
|
||||||
<Box mb={4}>
|
|
||||||
<Input
|
|
||||||
value={newPostTitle}
|
|
||||||
onChange={(e) => setNewPostTitle(e.target.value)}
|
|
||||||
placeholder="帖子标题(可选)"
|
|
||||||
size="sm"
|
|
||||||
mb={2}
|
|
||||||
/>
|
|
||||||
<Textarea
|
|
||||||
value={newPostContent}
|
|
||||||
onChange={(e) => setNewPostContent(e.target.value)}
|
|
||||||
placeholder="分享您的观点..."
|
|
||||||
size="sm"
|
|
||||||
resize="vertical"
|
|
||||||
minH="80px"
|
|
||||||
/>
|
|
||||||
<Flex justify="flex-end" mt={2}>
|
|
||||||
<Button
|
|
||||||
colorScheme="blue"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleSubmitPost}
|
|
||||||
isLoading={submitting}
|
|
||||||
isDisabled={!newPostContent.trim()}
|
|
||||||
>
|
|
||||||
发布帖子
|
|
||||||
</Button>
|
|
||||||
</Flex>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider mb={4} />
|
|
||||||
|
|
||||||
{/* 帖子列表 */}
|
|
||||||
{loading ? (
|
|
||||||
<Center py={8}>
|
|
||||||
<Spinner size="lg" />
|
|
||||||
</Center>
|
|
||||||
) : posts.length > 0 ? (
|
|
||||||
<VStack spacing={4} align="stretch">
|
|
||||||
{posts.map((post) => (
|
|
||||||
<Box
|
|
||||||
key={post.id}
|
|
||||||
p={4}
|
|
||||||
borderWidth="1px"
|
|
||||||
borderColor={borderColor}
|
|
||||||
borderRadius="md"
|
|
||||||
transition="background 0.2s"
|
|
||||||
>
|
|
||||||
{/* 帖子头部 */}
|
|
||||||
<Flex justify="space-between" align="start" mb={3}>
|
|
||||||
<HStack align="start" spacing={3}>
|
|
||||||
<Avatar
|
|
||||||
size="sm"
|
|
||||||
name={post.user?.username || '匿名用户'}
|
|
||||||
src={post.user?.avatar_url}
|
|
||||||
/>
|
|
||||||
<VStack align="start" spacing={1} flex={1}>
|
|
||||||
<HStack>
|
|
||||||
<Text fontWeight="semibold" fontSize="sm">
|
|
||||||
{post.user?.username || '匿名用户'}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
<HStack fontSize="xs" color="gray.500">
|
|
||||||
<TimeIcon />
|
|
||||||
<Text>
|
|
||||||
{format(new Date(post.created_at), 'MM月dd日 HH:mm', {
|
|
||||||
locale: zhCN,
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
</VStack>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
{/* 操作菜单 */}
|
|
||||||
<Menu>
|
|
||||||
<MenuButton
|
|
||||||
as={IconButton}
|
|
||||||
icon={<ChevronDownIcon />}
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
<MenuList>
|
|
||||||
<MenuItem
|
|
||||||
icon={<DeleteIcon />}
|
|
||||||
color="red.500"
|
|
||||||
onClick={() => handleDeletePost(post.id)}
|
|
||||||
>
|
|
||||||
删除帖子
|
|
||||||
</MenuItem>
|
|
||||||
</MenuList>
|
|
||||||
</Menu>
|
|
||||||
</Flex>
|
|
||||||
|
|
||||||
{/* 帖子标题 */}
|
|
||||||
{post.title && (
|
|
||||||
<Text fontSize="md" fontWeight="bold" mb={2}>
|
|
||||||
{post.title}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 帖子内容 */}
|
|
||||||
<Text fontSize="sm" whiteSpace="pre-wrap" mb={3}>
|
|
||||||
{post.content}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* 帖子操作栏 */}
|
|
||||||
<HStack spacing={4} mb={3}>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
leftIcon={post.liked ? <FaHeart /> : <FaRegHeart />}
|
|
||||||
color={post.liked ? 'red.500' : 'gray.600'}
|
|
||||||
onClick={() => handleLikePost(post.id)}
|
|
||||||
>
|
|
||||||
{post.likes_count || 0}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
leftIcon={<FaComment />}
|
|
||||||
onClick={() => togglePostComments(post.id)}
|
|
||||||
rightIcon={expandedPosts[post.id] ? <TriangleUpIcon /> : <TriangleDownIcon />}
|
|
||||||
>
|
|
||||||
{post.comments_count || 0} 评论
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
{/* 评论区 */}
|
|
||||||
<Collapse in={expandedPosts[post.id]} animateOpacity>
|
|
||||||
<Box borderTopWidth="1px" borderColor={borderColor} pt={3}>
|
|
||||||
{/* 评论输入框 */}
|
|
||||||
<HStack mb={3}>
|
|
||||||
<Textarea
|
|
||||||
size="sm"
|
|
||||||
placeholder="写下你的评论..."
|
|
||||||
value={replyContents[post.id] || ''}
|
|
||||||
onChange={(e) => setReplyContents(prev => ({ ...prev, [post.id]: e.target.value }))}
|
|
||||||
rows={2}
|
|
||||||
flex={1}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
colorScheme="blue"
|
|
||||||
onClick={() => handleSubmitComment(post.id)}
|
|
||||||
isDisabled={!replyContents[post.id]?.trim()}
|
|
||||||
>
|
|
||||||
评论
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
{/* 评论列表 */}
|
|
||||||
{loadingComments[post.id] ? (
|
|
||||||
<Center py={4}>
|
|
||||||
<Spinner size="sm" />
|
|
||||||
</Center>
|
|
||||||
) : (
|
|
||||||
<VStack align="stretch" spacing={2}>
|
|
||||||
{postComments[post.id]?.map((comment) => (
|
|
||||||
<Box key={comment.id} pl={4} borderLeftWidth="2px" borderColor="gray.200">
|
|
||||||
<HStack justify="space-between" mb={1}>
|
|
||||||
<HStack spacing={2}>
|
|
||||||
<Avatar size="xs" name={comment.user?.username} src={comment.user?.avatar_url} />
|
|
||||||
<Text fontSize="sm" fontWeight="medium">
|
|
||||||
{comment.user?.username || '匿名用户'}
|
|
||||||
</Text>
|
|
||||||
<Text fontSize="xs" color="gray.500">
|
|
||||||
{format(new Date(comment.created_at), 'MM-dd HH:mm')}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
<IconButton
|
|
||||||
size="xs"
|
|
||||||
icon={<DeleteIcon />}
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => handleDeleteComment(comment.id, post.id)}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
<Text fontSize="sm" pl={7}>
|
|
||||||
{comment.content}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* 显示回复 */}
|
|
||||||
{comment.replies && comment.replies.length > 0 && (
|
|
||||||
<VStack align="stretch" mt={2} spacing={1} pl={4}>
|
|
||||||
{comment.replies.map((reply) => (
|
|
||||||
<Box key={reply.id}>
|
|
||||||
<HStack spacing={1}>
|
|
||||||
<Text fontSize="xs" fontWeight="medium">
|
|
||||||
{reply.user?.username}:
|
|
||||||
</Text>
|
|
||||||
<Text fontSize="xs">{reply.content}</Text>
|
|
||||||
</HStack>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</VStack>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
{(!postComments[post.id] || postComments[post.id].length === 0) && (
|
|
||||||
<Text fontSize="sm" color="gray.500" textAlign="center" py={2}>
|
|
||||||
暂无评论
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Collapse>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</VStack>
|
|
||||||
) : (
|
|
||||||
<Center py={8}>
|
|
||||||
<VStack>
|
|
||||||
<ChatIcon boxSize={8} color="gray.400" />
|
|
||||||
<Text color="gray.500">暂无帖子,快来发表您的观点吧!</Text>
|
|
||||||
</VStack>
|
|
||||||
</Center>
|
|
||||||
)}
|
|
||||||
</ModalBody>
|
|
||||||
</ModalContent>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EventDiscussionModal;
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
// src/views/Community/components/PopularKeywords.js
|
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { Tag, Space, Button } from 'antd';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { RightOutlined } from '@ant-design/icons';
|
|
||||||
import { logger } from '../../../utils/logger';
|
|
||||||
|
|
||||||
// 使用相对路径,让 MSW 在开发环境可以拦截请求
|
|
||||||
const API_BASE_URL = '/concept-api';
|
|
||||||
|
|
||||||
// 获取域名前缀
|
|
||||||
const DOMAIN_PREFIX = process.env.NODE_ENV === 'production'
|
|
||||||
? ''
|
|
||||||
: 'https://valuefrontier.cn';
|
|
||||||
|
|
||||||
const PopularKeywords = ({ onKeywordClick, keywords: propKeywords }) => {
|
|
||||||
const [keywords, setKeywords] = useState([]);
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
// 加载热门概念(涨幅前20)
|
|
||||||
const loadPopularConcepts = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE_URL}/search`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
query: '', // 空查询获取所有概念
|
|
||||||
size: 20, // 获取前20个
|
|
||||||
page: 1,
|
|
||||||
sort_by: 'change_pct' // 按涨跌幅排序
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.results) {
|
|
||||||
// 转换数据格式
|
|
||||||
const formattedData = data.results.map(item => ({
|
|
||||||
keyword: item.concept,
|
|
||||||
count: item.stock_count,
|
|
||||||
change_pct: item.price_info?.avg_change_pct || 0,
|
|
||||||
concept_id: item.concept_id
|
|
||||||
}));
|
|
||||||
setKeywords(formattedData);
|
|
||||||
logger.debug('PopularKeywords', '热门概念加载成功(自己请求)', {
|
|
||||||
count: formattedData.length
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('PopularKeywords', 'loadPopularConcepts', error);
|
|
||||||
setKeywords([]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理从父组件传入的数据
|
|
||||||
useEffect(() => {
|
|
||||||
if (propKeywords && propKeywords.length > 0) {
|
|
||||||
// 使用父组件传入的数据
|
|
||||||
setKeywords(propKeywords);
|
|
||||||
logger.debug('PopularKeywords', '使用父组件传入的数据', {
|
|
||||||
count: propKeywords.length
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// 没有 prop 数据,自己加载
|
|
||||||
loadPopularConcepts();
|
|
||||||
}
|
|
||||||
}, [propKeywords]);
|
|
||||||
|
|
||||||
// 根据涨跌幅获取标签颜色
|
|
||||||
const getTagColor = (changePct) => {
|
|
||||||
if (changePct > 5) return 'red';
|
|
||||||
if (changePct > 3) return 'volcano';
|
|
||||||
if (changePct > 1) return 'orange';
|
|
||||||
if (changePct > 0) return 'gold';
|
|
||||||
if (changePct === 0) return 'default';
|
|
||||||
if (changePct > -1) return 'lime';
|
|
||||||
if (changePct > -3) return 'green';
|
|
||||||
if (changePct > -5) return 'cyan';
|
|
||||||
return 'blue';
|
|
||||||
};
|
|
||||||
|
|
||||||
// 格式化涨跌幅显示
|
|
||||||
const formatChangePct = (pct) => {
|
|
||||||
if (pct === null || pct === undefined) return '';
|
|
||||||
const formatted = pct.toFixed(2);
|
|
||||||
if (pct > 0) return `+${formatted}%`;
|
|
||||||
return `${formatted}%`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ✅ 修复:处理概念标签点击
|
|
||||||
const handleConceptClick = (concept) => {
|
|
||||||
// 优先调用父组件传入的回调(用于搜索框显示和触发搜索)
|
|
||||||
if (onKeywordClick) {
|
|
||||||
onKeywordClick(concept.keyword);
|
|
||||||
logger.debug('PopularKeywords', '调用 onKeywordClick 回调', {
|
|
||||||
keyword: concept.keyword
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// 如果没有回调,则跳转到对应概念的页面(原有行为)
|
|
||||||
const url = `${DOMAIN_PREFIX}/htmls/${encodeURIComponent(concept.keyword)}.html`;
|
|
||||||
window.open(url, '_blank');
|
|
||||||
logger.debug('PopularKeywords', '跳转到概念页面', {
|
|
||||||
keyword: concept.keyword,
|
|
||||||
url
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理"更多概念"按钮点击 - 跳转到概念中心
|
|
||||||
const handleMoreClick = () => {
|
|
||||||
navigate('/concepts');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{keywords && keywords.length > 0 && (
|
|
||||||
<div style={{ position: 'relative' }}>
|
|
||||||
<Space
|
|
||||||
size={[6, 6]}
|
|
||||||
wrap
|
|
||||||
style={{
|
|
||||||
alignItems: 'center',
|
|
||||||
maxHeight: '29px', // 约两行的高度 (每行约28-30px)
|
|
||||||
overflow: 'hidden',
|
|
||||||
paddingRight: '90px' // 为右侧按钮留出空间
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 标题 */}
|
|
||||||
<span style={{
|
|
||||||
color: '#ff4d4f',
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 500,
|
|
||||||
marginRight: 4
|
|
||||||
}}>
|
|
||||||
热门概念:
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* 所有标签 */}
|
|
||||||
{keywords.map((item, index) => (
|
|
||||||
<Tag
|
|
||||||
key={item.concept_id || `keyword-${index}`}
|
|
||||||
color={getTagColor(item.change_pct)}
|
|
||||||
style={{
|
|
||||||
cursor: 'pointer',
|
|
||||||
padding: '1px 6px',
|
|
||||||
fontSize: 12,
|
|
||||||
transition: 'all 0.3s',
|
|
||||||
margin: 0
|
|
||||||
}}
|
|
||||||
onClick={() => handleConceptClick(item)}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.transform = 'scale(1.05)';
|
|
||||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)';
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.transform = 'scale(1)';
|
|
||||||
e.currentTarget.style.boxShadow = 'none';
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>{item.keyword}</span>
|
|
||||||
<span style={{
|
|
||||||
marginLeft: 4,
|
|
||||||
fontWeight: 'bold'
|
|
||||||
}}>
|
|
||||||
{formatChangePct(item.change_pct)}
|
|
||||||
</span>
|
|
||||||
{/* <span style={{
|
|
||||||
marginLeft: 3,
|
|
||||||
fontSize: 10,
|
|
||||||
opacity: 0.75
|
|
||||||
}}> */}
|
|
||||||
{/* ({item.count}股) */}
|
|
||||||
{/* </span> */}
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
</Space>
|
|
||||||
|
|
||||||
{/* 更多概念按钮 - 固定在第二行右侧 */}
|
|
||||||
<Button
|
|
||||||
type="link"
|
|
||||||
size="small"
|
|
||||||
onClick={handleMoreClick}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
right: 0,
|
|
||||||
fontSize: 12,
|
|
||||||
padding: '0 4px',
|
|
||||||
height: 'auto'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
更多概念 <RightOutlined style={{ fontSize: 10 }} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PopularKeywords;
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
// src/views/Community/components/SearchBox.js
|
|
||||||
import React from 'react';
|
|
||||||
import { Card, Input, Radio, Form, Button } from 'antd';
|
|
||||||
import { SearchOutlined } from '@ant-design/icons';
|
|
||||||
import { useSearchEvents } from '../../../hooks/useSearchEvents';
|
|
||||||
|
|
||||||
const SearchBox = ({ onSearch }) => {
|
|
||||||
const [form] = Form.useForm();
|
|
||||||
|
|
||||||
// 🎯 初始化搜索埋点Hook
|
|
||||||
const searchEvents = useSearchEvents({ context: 'community' });
|
|
||||||
|
|
||||||
const handleSubmit = (values) => {
|
|
||||||
// 🎯 追踪搜索查询提交(在调用onSearch之前)
|
|
||||||
if (values.q) {
|
|
||||||
searchEvents.trackSearchQuerySubmitted(values.q, 0, {
|
|
||||||
search_type: values.search_type || 'topic'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
onSearch(values);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card title="搜索事件" className="search-box" style={{ marginBottom: 16 }}>
|
|
||||||
<Form
|
|
||||||
form={form}
|
|
||||||
onFinish={handleSubmit}
|
|
||||||
initialValues={{ search_type: 'topic' }}
|
|
||||||
>
|
|
||||||
<Form.Item name="q" style={{ marginBottom: 12 }}>
|
|
||||||
<Input.Search
|
|
||||||
placeholder="搜索关键词..."
|
|
||||||
allowClear
|
|
||||||
enterButton={<SearchOutlined />}
|
|
||||||
onSearch={(value) => {
|
|
||||||
form.setFieldsValue({ q: value });
|
|
||||||
form.submit();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item name="search_type" style={{ marginBottom: 12 }}>
|
|
||||||
<Radio.Group>
|
|
||||||
<Radio value="topic">搜索话题</Radio>
|
|
||||||
<Radio value="stock">搜索股票</Radio>
|
|
||||||
</Radio.Group>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item style={{ marginBottom: 0 }}>
|
|
||||||
<Button type="primary" htmlType="submit" block>
|
|
||||||
搜索
|
|
||||||
</Button>
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SearchBox;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
// src/views/Community/components/StockDetailPanel/components/index.js
|
|
||||||
export { default as MiniTimelineChart } from './MiniTimelineChart';
|
|
||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Container,
|
Container,
|
||||||
useColorModeValue,
|
|
||||||
useBreakpointValue,
|
useBreakpointValue,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ const CompanyIndex = () => {
|
|||||||
<CardBody>
|
<CardBody>
|
||||||
<HStack justify="space-between" align="center">
|
<HStack justify="space-between" align="center">
|
||||||
<VStack align="start" spacing={1}>
|
<VStack align="start" spacing={1}>
|
||||||
<Heading size="lg">个股信息分析</Heading>
|
<Heading size="lg">个股详情</Heading>
|
||||||
<Text color="gray.600" fontSize="sm">
|
<Text color="gray.600" fontSize="sm">
|
||||||
查看股票实时行情、财务数据和盈利预测
|
查看股票实时行情、财务数据和盈利预测
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -584,7 +584,7 @@ const StockOverview = () => {
|
|||||||
position="relative"
|
position="relative"
|
||||||
bgGradient={heroBg}
|
bgGradient={heroBg}
|
||||||
color="white"
|
color="white"
|
||||||
overflow="hidden"
|
overflow="visible"
|
||||||
pt={{ base: 20, md: 24 }}
|
pt={{ base: 20, md: 24 }}
|
||||||
pb={{ base: 16, md: 20 }}
|
pb={{ base: 16, md: 20 }}
|
||||||
borderBottom={colorMode === 'dark' ? `2px solid ${goldColor}` : 'none'}
|
borderBottom={colorMode === 'dark' ? `2px solid ${goldColor}` : 'none'}
|
||||||
|
|||||||
Reference in New Issue
Block a user