update ui
This commit is contained in:
610
src/views/Community/components/CompactSearchBox.js
Normal file
610
src/views/Community/components/CompactSearchBox.js
Normal file
@@ -0,0 +1,610 @@
|
||||
// src/views/Community/components/CompactSearchBox.js
|
||||
// 紧凑版搜索和筛选组件 - 优化布局
|
||||
|
||||
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Input, Cascader, Button, Space, Tag, AutoComplete, Select as AntSelect,
|
||||
Tooltip
|
||||
} from 'antd';
|
||||
import {
|
||||
SearchOutlined, CloseCircleOutlined, StockOutlined, FilterOutlined,
|
||||
CalendarOutlined, SortAscendingOutlined
|
||||
} 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 { stockService } from '../../../services/stockService';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import TradingTimeFilter from './TradingTimeFilter';
|
||||
|
||||
const { Option } = AntSelect;
|
||||
|
||||
const CompactSearchBox = ({
|
||||
onSearch,
|
||||
onSearchFocus,
|
||||
filters = {},
|
||||
mode,
|
||||
pageSize,
|
||||
trackingFunctions = {}
|
||||
}) => {
|
||||
// 状态
|
||||
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 debouncedSearchRef = useRef(null);
|
||||
|
||||
const triggerSearch = useCallback((params) => {
|
||||
logger.debug('CompactSearchBox', '触发搜索', { params });
|
||||
onSearch(params);
|
||||
}, [onSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
debouncedSearchRef.current = debounce((params) => {
|
||||
triggerSearch(params);
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
if (debouncedSearchRef.current) {
|
||||
debouncedSearchRef.current.cancel();
|
||||
}
|
||||
};
|
||||
}, [triggerSearch]);
|
||||
|
||||
// 加载股票数据
|
||||
useEffect(() => {
|
||||
const loadStocks = async () => {
|
||||
const response = await stockService.getAllStocks();
|
||||
if (response.success && response.data) {
|
||||
setAllStocks(response.data);
|
||||
}
|
||||
};
|
||||
loadStocks();
|
||||
}, []);
|
||||
|
||||
// 初始化筛选条件
|
||||
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;
|
||||
|
||||
if (filters.sort) setSort(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) {
|
||||
setInputValue(filters.q);
|
||||
} else if (!filters.q) {
|
||||
setInputValue('');
|
||||
}
|
||||
|
||||
const hasTimeInFilters = filters.start_date || filters.end_date || filters.recent_days;
|
||||
|
||||
if (hasTimeInFilters && (!tradingTimeRange || !tradingTimeRange.key)) {
|
||||
let inferredKey = 'custom';
|
||||
let inferredLabel = '';
|
||||
|
||||
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: (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<StockOutlined style={{ color: '#1890ff' }} />
|
||||
<span style={{ fontWeight: 500, color: '#333' }}>{stock.code}</span>
|
||||
<span style={{ color: '#666' }}>{stock.name}</span>
|
||||
</div>
|
||||
),
|
||||
stockInfo: stock
|
||||
}));
|
||||
|
||||
setStockOptions(options);
|
||||
};
|
||||
|
||||
const buildFilterParams = useCallback((overrides = {}) => {
|
||||
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 = {
|
||||
sort: actualSort,
|
||||
importance: importanceValue,
|
||||
q: (overrides.q ?? filters.q) ?? '',
|
||||
industry_code: overrides.industry_code ?? (industryValue?.[industryValue.length - 1] || ''),
|
||||
start_date: overrides.start_date ?? (tradingTimeRange?.start_date || ''),
|
||||
end_date: overrides.end_date ?? (tradingTimeRange?.end_date || ''),
|
||||
recent_days: overrides.recent_days ?? (tradingTimeRange?.recent_days || ''),
|
||||
...overrides,
|
||||
page: 1,
|
||||
};
|
||||
|
||||
delete result.per_page;
|
||||
|
||||
if (returnType) {
|
||||
result.return_type = returnType;
|
||||
}
|
||||
|
||||
if (mode !== undefined && mode !== null) {
|
||||
result.mode = mode;
|
||||
}
|
||||
if (pageSize !== undefined && pageSize !== null) {
|
||||
result.per_page = pageSize;
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [sort, importance, filters.q, industryValue, tradingTimeRange, mode, pageSize]);
|
||||
|
||||
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(),
|
||||
});
|
||||
}
|
||||
|
||||
setInputValue(`${stockInfo.code} ${stockInfo.name}`);
|
||||
|
||||
const params = buildFilterParams({
|
||||
q: stockInfo.code,
|
||||
industry_code: ''
|
||||
});
|
||||
triggerSearch(params);
|
||||
}
|
||||
};
|
||||
|
||||
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 === '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 (
|
||||
<div style={{ padding: '12px 16px', background: '#fafafa', borderRadius: '8px' }}>
|
||||
{/* 单行紧凑布局 */}
|
||||
<Space wrap style={{ width: '100%' }} size="small">
|
||||
{/* 搜索框 */}
|
||||
<AutoComplete
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onSearch={handleSearch}
|
||||
onSelect={handleStockSelect}
|
||||
onFocus={onSearchFocus}
|
||||
options={stockOptions}
|
||||
placeholder="搜索股票/话题..."
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleMainSearch();
|
||||
}
|
||||
}}
|
||||
style={{ width: 220 }}
|
||||
size="small"
|
||||
>
|
||||
<Input
|
||||
prefix={<SearchOutlined style={{ color: '#bfbfbf' }} />}
|
||||
size="small"
|
||||
/>
|
||||
</AutoComplete>
|
||||
|
||||
{/* 时间筛选 */}
|
||||
<Tooltip title="时间筛选">
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<CalendarOutlined style={{ color: '#8c8c8c', fontSize: 12 }} />
|
||||
<TradingTimeFilter
|
||||
value={tradingTimeRange?.key || null}
|
||||
onChange={handleTradingTimeChange}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
{/* 行业筛选 */}
|
||||
<Tooltip title="行业分类">
|
||||
<Cascader
|
||||
value={industryValue}
|
||||
onChange={handleIndustryChange}
|
||||
onFocus={handleCascaderFocus}
|
||||
options={industryData || []}
|
||||
placeholder="行业"
|
||||
changeOnSelect
|
||||
showSearch={{
|
||||
filter: (inputValue, path) =>
|
||||
path.some(option =>
|
||||
option.label.toLowerCase().includes(inputValue.toLowerCase())
|
||||
)
|
||||
}}
|
||||
allowClear
|
||||
expandTrigger="hover"
|
||||
displayRender={(labels) => labels[labels.length - 1] || '行业'}
|
||||
disabled={industryLoading}
|
||||
style={{ width: 100 }}
|
||||
size="small"
|
||||
suffixIcon={<FilterOutlined style={{ fontSize: 12 }} />}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{/* 重要性筛选 */}
|
||||
<Tooltip title="重要性筛选">
|
||||
<AntSelect
|
||||
mode="multiple"
|
||||
value={importance}
|
||||
onChange={handleImportanceChange}
|
||||
style={{ width: 100 }}
|
||||
size="small"
|
||||
placeholder="重要性"
|
||||
maxTagCount={0}
|
||||
maxTagPlaceholder={(omittedValues) => `已选 ${omittedValues.length} 项`}
|
||||
>
|
||||
<Option value="S">极高</Option>
|
||||
<Option value="A">高</Option>
|
||||
<Option value="B">中</Option>
|
||||
<Option value="C">低</Option>
|
||||
</AntSelect>
|
||||
</Tooltip>
|
||||
|
||||
{/* 排序 */}
|
||||
<Tooltip title="排序方式">
|
||||
<AntSelect
|
||||
value={sort}
|
||||
onChange={handleSortChange}
|
||||
style={{ width: 110 }}
|
||||
size="small"
|
||||
suffixIcon={<SortAscendingOutlined style={{ fontSize: 12 }} />}
|
||||
>
|
||||
<Option value="new">最新</Option>
|
||||
<Option value="hot">最热</Option>
|
||||
<Option value="importance">重要性</Option>
|
||||
<Option value="returns_avg">平均收益</Option>
|
||||
<Option value="returns_week">周收益</Option>
|
||||
</AntSelect>
|
||||
</Tooltip>
|
||||
|
||||
{/* 重置按钮 */}
|
||||
<Tooltip title="重置所有筛选">
|
||||
<Button
|
||||
icon={<CloseCircleOutlined />}
|
||||
onClick={handleReset}
|
||||
size="small"
|
||||
danger
|
||||
type="text"
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
|
||||
{/* 激活的筛选标签(如果有的话) */}
|
||||
{(inputValue || industryValue.length > 0 || importance.length > 0 || tradingTimeRange || sort !== 'new') && (
|
||||
<div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{inputValue && (
|
||||
<Tag closable onClose={() => {
|
||||
setInputValue('');
|
||||
const params = buildFilterParams({ q: '' });
|
||||
triggerSearch(params);
|
||||
}} color="blue">
|
||||
搜索: {inputValue}
|
||||
</Tag>
|
||||
)}
|
||||
{tradingTimeRange && (
|
||||
<Tag closable onClose={() => {
|
||||
setTradingTimeRange(null);
|
||||
const params = buildFilterParams({
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
recent_days: ''
|
||||
});
|
||||
triggerSearch(params);
|
||||
}} color="green">
|
||||
{tradingTimeRange.label}
|
||||
</Tag>
|
||||
)}
|
||||
{industryValue.length > 0 && industryData && (
|
||||
<Tag closable onClose={() => {
|
||||
setIndustryValue([]);
|
||||
const params = buildFilterParams({ industry_code: '' });
|
||||
triggerSearch(params);
|
||||
}} color="orange">
|
||||
行业: {(() => {
|
||||
const findLabel = (code, data) => {
|
||||
for (const item of data) {
|
||||
if (code.startsWith(item.value)) {
|
||||
if (item.value === code) {
|
||||
return item.label;
|
||||
} else {
|
||||
return findLabel(code, item.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const lastLevelCode = industryValue[industryValue.length - 1];
|
||||
return findLabel(lastLevelCode, industryData);
|
||||
})()}
|
||||
</Tag>
|
||||
)}
|
||||
{importance.length > 0 && (
|
||||
<Tag closable onClose={() => {
|
||||
setImportance([]);
|
||||
const params = buildFilterParams({ importance: 'all' });
|
||||
triggerSearch(params);
|
||||
}} color="purple">
|
||||
重要性: {importance.map(imp => ({ 'S': '极高', 'A': '高', 'B': '中', 'C': '低' }[imp] || imp)).join(', ')}
|
||||
</Tag>
|
||||
)}
|
||||
{sort && sort !== 'new' && (
|
||||
<Tag closable onClose={() => {
|
||||
setSort('new');
|
||||
const params = buildFilterParams({ sort: 'new' });
|
||||
triggerSearch(params);
|
||||
}} color="cyan">
|
||||
排序: {({ 'hot': '最热', 'importance': '重要性', 'returns_avg': '平均收益', 'returns_week': '周收益' }[sort] || sort)}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompactSearchBox;
|
||||
@@ -35,7 +35,7 @@ import EventScrollList from './DynamicNewsCard/EventScrollList';
|
||||
import ModeToggleButtons from './DynamicNewsCard/ModeToggleButtons';
|
||||
import PaginationControl from './DynamicNewsCard/PaginationControl';
|
||||
import DynamicNewsDetailPanel from './DynamicNewsDetail';
|
||||
import UnifiedSearchBox from './UnifiedSearchBox';
|
||||
import CompactSearchBox from './CompactSearchBox';
|
||||
import {
|
||||
fetchDynamicNews,
|
||||
toggleEventFollow,
|
||||
@@ -597,12 +597,11 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
||||
</VStack>
|
||||
</Flex>
|
||||
|
||||
{/* 搜索和筛选组件 */}
|
||||
{/* 搜索和筛选组件 - 紧凑版 */}
|
||||
<Box mt={4}>
|
||||
<UnifiedSearchBox
|
||||
<CompactSearchBox
|
||||
onSearch={onSearch}
|
||||
onSearchFocus={onSearchFocus}
|
||||
popularKeywords={popularKeywords}
|
||||
filters={filters}
|
||||
mode={mode}
|
||||
pageSize={pageSize}
|
||||
|
||||
463
src/views/Community/components/HeroPanel.js
Normal file
463
src/views/Community/components/HeroPanel.js
Normal file
@@ -0,0 +1,463 @@
|
||||
// src/views/Community/components/HeroPanel.js
|
||||
// 顶部说明面板组件:产品功能介绍 + 沪深指数折线图 + 热门概念词云图
|
||||
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardBody,
|
||||
Flex,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Heading,
|
||||
useColorModeValue,
|
||||
SimpleGrid,
|
||||
Icon,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
StatArrow,
|
||||
Spinner,
|
||||
Center,
|
||||
} from '@chakra-ui/react';
|
||||
import { TrendingUp, Activity, Globe, Zap } from 'lucide-react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
/**
|
||||
* 获取指数行情数据
|
||||
*/
|
||||
const fetchIndexKline = async (indexCode) => {
|
||||
try {
|
||||
const response = await fetch(`/api/index/${indexCode}/kline`);
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error('HeroPanel', 'fetchIndexKline', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取热门概念数据(用于词云图)
|
||||
*/
|
||||
const fetchPopularConcepts = async () => {
|
||||
try {
|
||||
const response = await fetch('/concept-api/search', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: '',
|
||||
size: 50, // 获取前50个概念用于词云
|
||||
page: 1,
|
||||
sort_by: 'change_pct'
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.results) {
|
||||
return data.results.map(item => ({
|
||||
name: item.concept,
|
||||
value: Math.abs(item.price_info?.avg_change_pct || 1), // 使用涨跌幅绝对值作为权重
|
||||
change_pct: item.price_info?.avg_change_pct || 0,
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
logger.error('HeroPanel', 'fetchPopularConcepts', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 迷你折线图组件
|
||||
*/
|
||||
const MiniIndexChart = ({ indexCode, indexName }) => {
|
||||
const [chartData, setChartData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [latestData, setLatestData] = useState(null);
|
||||
|
||||
const chartBg = useColorModeValue('transparent', 'transparent');
|
||||
const lineColor = useColorModeValue('#FFD700', '#FFD700'); // 金色
|
||||
const areaColor = useColorModeValue('rgba(255, 215, 0, 0.15)', 'rgba(255, 215, 0, 0.1)');
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
const data = await fetchIndexKline(indexCode);
|
||||
|
||||
if (data && data.data && data.data.length > 0) {
|
||||
// 取最近一个交易日的数据
|
||||
const latest = data.data[data.data.length - 1];
|
||||
setLatestData({
|
||||
close: latest[2],
|
||||
change: ((latest[2] - latest[1]) / latest[1] * 100).toFixed(2),
|
||||
isPositive: latest[2] >= latest[1]
|
||||
});
|
||||
|
||||
// 准备图表数据(最近60个交易日)
|
||||
const recentData = data.data.slice(-60);
|
||||
setChartData({
|
||||
dates: recentData.map(item => item[0]),
|
||||
values: recentData.map(item => item[2])
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [indexCode]);
|
||||
|
||||
const chartOption = useMemo(() => {
|
||||
if (!chartData) return {};
|
||||
|
||||
return {
|
||||
backgroundColor: chartBg,
|
||||
grid: {
|
||||
left: 5,
|
||||
right: 5,
|
||||
top: 5,
|
||||
bottom: 20,
|
||||
containLabel: false
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: chartData.dates,
|
||||
show: false
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
show: false,
|
||||
scale: true
|
||||
},
|
||||
series: [{
|
||||
type: 'line',
|
||||
data: chartData.values,
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
lineStyle: {
|
||||
color: lineColor,
|
||||
width: 2,
|
||||
shadowColor: lineColor,
|
||||
shadowBlur: 8,
|
||||
shadowOffsetY: 2
|
||||
},
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [{
|
||||
offset: 0,
|
||||
color: areaColor
|
||||
}, {
|
||||
offset: 1,
|
||||
color: 'rgba(255, 215, 0, 0)'
|
||||
}]
|
||||
}
|
||||
}
|
||||
}]
|
||||
};
|
||||
}, [chartData, chartBg, lineColor, areaColor]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Center h="120px">
|
||||
<Spinner size="sm" color="gold" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack spacing={2} align="stretch" h="120px">
|
||||
<HStack justify="space-between">
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text fontSize="xs" color="whiteAlpha.700">{indexName}</Text>
|
||||
<Text fontSize="lg" fontWeight="bold" color="white">
|
||||
{latestData?.close.toFixed(2)}
|
||||
</Text>
|
||||
</VStack>
|
||||
<HStack spacing={1}>
|
||||
<StatArrow type={latestData?.isPositive ? 'increase' : 'decrease'} />
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color={latestData?.isPositive ? 'green.300' : 'red.300'}
|
||||
>
|
||||
{latestData?.isPositive ? '+' : ''}{latestData?.change}%
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
<Box flex="1">
|
||||
<ReactECharts
|
||||
option={chartOption}
|
||||
style={{ height: '80px', width: '100%' }}
|
||||
opts={{ renderer: 'canvas' }}
|
||||
/>
|
||||
</Box>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 概念词云图组件
|
||||
*/
|
||||
const ConceptWordCloud = () => {
|
||||
const [concepts, setConcepts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const chartBg = useColorModeValue('transparent', 'transparent');
|
||||
|
||||
useEffect(() => {
|
||||
const loadConcepts = async () => {
|
||||
setLoading(true);
|
||||
const data = await fetchPopularConcepts();
|
||||
setConcepts(data);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
loadConcepts();
|
||||
}, []);
|
||||
|
||||
const chartOption = useMemo(() => {
|
||||
if (concepts.length === 0) return {};
|
||||
|
||||
return {
|
||||
backgroundColor: chartBg,
|
||||
tooltip: {
|
||||
show: true,
|
||||
formatter: (params) => {
|
||||
const changePct = params.data.change_pct;
|
||||
const sign = changePct > 0 ? '+' : '';
|
||||
return `${params.name}<br/>涨跌: ${sign}${changePct.toFixed(2)}%`;
|
||||
}
|
||||
},
|
||||
series: [{
|
||||
type: 'wordCloud',
|
||||
shape: 'circle',
|
||||
left: 'center',
|
||||
top: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
right: null,
|
||||
bottom: null,
|
||||
sizeRange: [12, 40],
|
||||
rotationRange: [-45, 45],
|
||||
rotationStep: 45,
|
||||
gridSize: 8,
|
||||
drawOutOfBound: false,
|
||||
layoutAnimation: true,
|
||||
textStyle: {
|
||||
fontFamily: 'sans-serif',
|
||||
fontWeight: 'bold',
|
||||
color: function (params) {
|
||||
// 根据涨跌幅设置颜色
|
||||
const changePct = params.data.change_pct;
|
||||
if (changePct > 5) return '#ff4d4f'; // 大涨:红色
|
||||
if (changePct > 3) return '#ff7875'; // 中涨:浅红
|
||||
if (changePct > 0) return '#ffa940'; // 小涨:橙色
|
||||
if (changePct === 0) return '#FFD700'; // 平盘:金色
|
||||
if (changePct > -3) return '#95de64'; // 小跌:浅绿
|
||||
if (changePct > -5) return '#52c41a'; // 中跌:绿色
|
||||
return '#13c2c2'; // 大跌:青色
|
||||
}
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'self',
|
||||
textStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowColor: '#FFD700',
|
||||
fontSize: 20
|
||||
}
|
||||
},
|
||||
data: concepts
|
||||
}]
|
||||
};
|
||||
}, [concepts, chartBg]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Center h="200px">
|
||||
<Spinner size="md" color="gold" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ReactECharts
|
||||
option={chartOption}
|
||||
style={{ height: '200px', width: '100%' }}
|
||||
opts={{ renderer: 'canvas' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 产品特性图标组件
|
||||
*/
|
||||
const FeatureIcon = ({ icon, title, description }) => {
|
||||
return (
|
||||
<HStack spacing={3} align="start">
|
||||
<Box
|
||||
p={2}
|
||||
borderRadius="lg"
|
||||
bg="whiteAlpha.200"
|
||||
color="gold"
|
||||
>
|
||||
<Icon as={icon} boxSize={5} />
|
||||
</Box>
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text fontSize="sm" fontWeight="bold" color="white">
|
||||
{title}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="whiteAlpha.700">
|
||||
{description}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 顶部说明面板主组件
|
||||
*/
|
||||
const HeroPanel = () => {
|
||||
const gradientBg = 'linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 50%, #1a1a1a 100%)';
|
||||
const cardBorder = '1px solid';
|
||||
const borderColor = useColorModeValue('rgba(255, 215, 0, 0.3)', 'rgba(255, 215, 0, 0.2)');
|
||||
|
||||
return (
|
||||
<Card
|
||||
bg={gradientBg}
|
||||
borderColor={borderColor}
|
||||
borderWidth={cardBorder}
|
||||
boxShadow="0 8px 32px rgba(0, 0, 0, 0.4)"
|
||||
mb={6}
|
||||
overflow="hidden"
|
||||
position="relative"
|
||||
>
|
||||
{/* 装饰性背景图案 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top="-20%"
|
||||
right="-10%"
|
||||
width="500px"
|
||||
height="500px"
|
||||
borderRadius="full"
|
||||
bg="radial-gradient(circle, rgba(255, 215, 0, 0.1) 0%, transparent 70%)"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
|
||||
<CardBody p={6}>
|
||||
<SimpleGrid columns={{ base: 1, lg: 3 }} spacing={6}>
|
||||
{/* 左侧:产品介绍 */}
|
||||
<Box>
|
||||
<VStack align="start" spacing={4}>
|
||||
<Heading size="lg" color="white" fontWeight="extrabold">
|
||||
<Text
|
||||
bgGradient="linear(to-r, #FFD700, #FFA500)"
|
||||
bgClip="text"
|
||||
>
|
||||
价值前沿
|
||||
</Text>
|
||||
</Heading>
|
||||
<Text fontSize="sm" color="whiteAlpha.800" lineHeight="1.8">
|
||||
实时捕捉市场动态,智能分析投资机会。
|
||||
<br />
|
||||
整合多维数据源,为您提供专业的投资决策支持。
|
||||
</Text>
|
||||
|
||||
<VStack spacing={3} align="stretch" w="100%">
|
||||
<FeatureIcon
|
||||
icon={Activity}
|
||||
title="实时监控"
|
||||
description="7×24小时追踪市场动态"
|
||||
/>
|
||||
<FeatureIcon
|
||||
icon={TrendingUp}
|
||||
title="智能分析"
|
||||
description="AI驱动的概念板块分析"
|
||||
/>
|
||||
<FeatureIcon
|
||||
icon={Globe}
|
||||
title="全面覆盖"
|
||||
description="A股全市场深度数据"
|
||||
/>
|
||||
<FeatureIcon
|
||||
icon={Zap}
|
||||
title="极速响应"
|
||||
description="毫秒级数据更新推送"
|
||||
/>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{/* 中间:沪深指数折线图 */}
|
||||
<Box>
|
||||
<VStack spacing={4} h="100%">
|
||||
<Box
|
||||
w="100%"
|
||||
p={4}
|
||||
bg="whiteAlpha.100"
|
||||
borderRadius="xl"
|
||||
borderWidth="1px"
|
||||
borderColor="whiteAlpha.200"
|
||||
backdropFilter="blur(10px)"
|
||||
>
|
||||
<MiniIndexChart indexCode="000001" indexName="上证指数" />
|
||||
</Box>
|
||||
<Box
|
||||
w="100%"
|
||||
p={4}
|
||||
bg="whiteAlpha.100"
|
||||
borderRadius="xl"
|
||||
borderWidth="1px"
|
||||
borderColor="whiteAlpha.200"
|
||||
backdropFilter="blur(10px)"
|
||||
>
|
||||
<MiniIndexChart indexCode="399001" indexName="深证成指" />
|
||||
</Box>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{/* 右侧:热门概念词云图 */}
|
||||
<Box>
|
||||
<VStack align="start" spacing={3} h="100%">
|
||||
<HStack>
|
||||
<Text fontSize="md" fontWeight="bold" color="white">
|
||||
🔥 热门概念
|
||||
</Text>
|
||||
<Text fontSize="xs" color="whiteAlpha.600">
|
||||
实时更新
|
||||
</Text>
|
||||
</HStack>
|
||||
<Box
|
||||
w="100%"
|
||||
flex="1"
|
||||
bg="whiteAlpha.100"
|
||||
borderRadius="xl"
|
||||
borderWidth="1px"
|
||||
borderColor="whiteAlpha.200"
|
||||
backdropFilter="blur(10px)"
|
||||
p={3}
|
||||
>
|
||||
<ConceptWordCloud />
|
||||
</Box>
|
||||
<Text fontSize="xs" color="whiteAlpha.600" textAlign="center" w="100%">
|
||||
字体大小表示热度 · 颜色表示涨跌幅
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroPanel;
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
// 导入组件
|
||||
import DynamicNewsCard from './components/DynamicNewsCard';
|
||||
import HotEventsSection from './components/HotEventsSection';
|
||||
import HeroPanel from './components/HeroPanel';
|
||||
|
||||
// 导入自定义 Hooks
|
||||
import { useEventData } from './hooks/useEventData';
|
||||
@@ -175,11 +176,9 @@ const Community = () => {
|
||||
</HStack>
|
||||
</Alert>
|
||||
)}
|
||||
{/* 热点事件区域 */}
|
||||
<HotEventsSection
|
||||
events={hotEvents}
|
||||
onEventClick={communityEvents.trackNewsArticleClicked}
|
||||
/>
|
||||
|
||||
{/* 顶部说明面板:产品介绍 + 沪深指数 + 热门概念词云 */}
|
||||
<HeroPanel />
|
||||
|
||||
{/* 实时要闻·动态追踪 - 横向滚动 */}
|
||||
<DynamicNewsCard
|
||||
@@ -198,6 +197,12 @@ const Community = () => {
|
||||
trackRelatedStockClicked: communityEvents.trackRelatedStockClicked,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 热点事件区域 - 移至底部 */}
|
||||
<HotEventsSection
|
||||
events={hotEvents}
|
||||
onEventClick={communityEvents.trackNewsArticleClicked}
|
||||
/>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user