更新Company页面的UI为FUI风格

This commit is contained in:
2025-12-18 07:26:10 +08:00
parent 028869aa0c
commit 26548c7036
6 changed files with 552 additions and 6 deletions

View File

@@ -87,3 +87,55 @@ select::-webkit-scrollbar-thumb {
select::-webkit-scrollbar-thumb:hover {
background: #FFC107;
}
/**
* Ant Design AutoComplete 下拉框样式 (FUI 主题)
*/
.fui-autocomplete-dropdown {
background-color: #1a1a2e !important;
border: 1px solid rgba(212, 175, 55, 0.3) !important;
border-radius: 10px !important;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5) !important;
}
.fui-autocomplete-dropdown .ant-select-item {
color: #ffffff !important;
padding: 10px 12px !important;
border-bottom: 1px solid rgba(212, 175, 55, 0.1);
}
.fui-autocomplete-dropdown .ant-select-item:last-child {
border-bottom: none;
}
.fui-autocomplete-dropdown .ant-select-item-option-active,
.fui-autocomplete-dropdown .ant-select-item:hover {
background-color: rgba(212, 175, 55, 0.15) !important;
}
.fui-autocomplete-dropdown .ant-select-item-option-selected {
background-color: rgba(212, 175, 55, 0.25) !important;
}
.fui-autocomplete-dropdown .ant-select-item-empty {
color: rgba(255, 255, 255, 0.5) !important;
}
/* AutoComplete 下拉框滚动条 */
.fui-autocomplete-dropdown::-webkit-scrollbar {
width: 6px;
}
.fui-autocomplete-dropdown::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
.fui-autocomplete-dropdown::-webkit-scrollbar-thumb {
background: rgba(212, 175, 55, 0.4);
border-radius: 3px;
}
.fui-autocomplete-dropdown::-webkit-scrollbar-thumb:hover {
background: rgba(212, 175, 55, 0.6);
}

View File

@@ -236,6 +236,7 @@ const SearchActions = memo<{
}}
>
<AutoComplete
popupClassName="fui-autocomplete-dropdown"
value={inputCode}
options={stockOptions}
onSearch={doSearch}
@@ -243,6 +244,12 @@ const SearchActions = memo<{
onChange={onInputChange}
placeholder="输入代码、名称或拼音"
style={{ width: 240 }}
dropdownStyle={{
backgroundColor: FUI_COLORS.bg.elevated,
borderRadius: '10px',
border: `1px solid ${FUI_COLORS.line.emphasis}`,
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.5)',
}}
notFoundContent={isSearching ? <Spin size="small" /> : null}
onKeyDown={handleKeyDown}
/>

View File

@@ -1,7 +1,7 @@
// src/views/Company/components/MarketDataView/components/panels/TradeDataPanel/KLineModule.tsx
// K线模块 - 日K线/分时图切换展示(黑金主题 + 专业技术指标)
// K线模块 - 日K线/分时图切换展示(黑金主题 + 专业技术指标 + 商品数据叠加
import React, { useState } from 'react';
import React, { useState, useMemo, useCallback } from 'react';
import {
Box,
Text,
@@ -33,7 +33,8 @@ import {
type MainIndicatorType,
type DrawingType,
} from '../../../utils/chartOptions';
import type { KLineModuleProps } from '../../../types';
import type { KLineModuleProps, OverlayMetricData } from '../../../types';
import MetricOverlaySearch from './MetricOverlaySearch';
// 空状态组件(内联)
const EmptyState: React.FC<{ title: string; description: string }> = ({ title, description }) => (
@@ -95,8 +96,28 @@ const KLineModule: React.FC<KLineModuleProps> = ({
const [mainIndicator, setMainIndicator] = useState<MainIndicatorType>('MA');
const [showAnalysis, setShowAnalysis] = useState<boolean>(true);
const [drawingType, setDrawingType] = useState<DrawingType>('NONE');
const [overlayMetrics, setOverlayMetrics] = useState<OverlayMetricData[]>([]);
const hasMinuteData = minuteData && minuteData.data && minuteData.data.length > 0;
// 计算股票数据的日期范围(用于查询商品数据)
const stockDateRange = useMemo(() => {
if (tradeData.length === 0) return undefined;
return {
startDate: tradeData[0].date.substring(0, 10),
endDate: tradeData[tradeData.length - 1].date.substring(0, 10),
};
}, [tradeData]);
// 添加叠加指标
const handleAddOverlayMetric = useCallback((metric: OverlayMetricData) => {
setOverlayMetrics(prev => [...prev, metric]);
}, []);
// 移除叠加指标
const handleRemoveOverlayMetric = useCallback((metricId: string) => {
setOverlayMetrics(prev => prev.filter(m => m.metric_id !== metricId));
}, []);
// 切换到分时模式时自动加载数据
const handleModeChange = (newMode: ChartMode) => {
setMode(newMode);
@@ -337,6 +358,14 @@ const KLineModule: React.FC<KLineModuleProps> = ({
))}
</MenuList>
</Menu>
{/* 商品数据叠加搜索 */}
<MetricOverlaySearch
overlayMetrics={overlayMetrics}
onAddMetric={handleAddOverlayMetric}
onRemoveMetric={handleRemoveOverlayMetric}
stockDateRange={stockDateRange}
/>
</>
)}
@@ -383,7 +412,7 @@ const KLineModule: React.FC<KLineModuleProps> = ({
tradeData.length > 0 ? (
<Box h="650px">
<ReactECharts
option={getKLineDarkGoldOption(tradeData, analysisMap, subIndicator, mainIndicator, showAnalysis, drawingType)}
option={getKLineDarkGoldOption(tradeData, analysisMap, subIndicator, mainIndicator, showAnalysis, drawingType, overlayMetrics)}
style={{ height: '100%', width: '100%' }}
theme="dark"
notMerge={true}

View File

@@ -0,0 +1,350 @@
// src/views/Company/components/MarketDataView/components/panels/TradeDataPanel/MetricOverlaySearch.tsx
// Metric overlay search component - search metrics and overlay on K-line chart
import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import {
Box,
HStack,
VStack,
Text,
Input,
InputGroup,
InputLeftElement,
InputRightElement,
IconButton,
Tag,
TagLabel,
TagCloseButton,
Spinner,
Popover,
PopoverTrigger,
PopoverContent,
PopoverBody,
List,
ListItem,
Button,
Tooltip,
Badge,
Flex,
useDisclosure,
} from '@chakra-ui/react';
import { SearchIcon, CloseIcon, AddIcon } from '@chakra-ui/icons';
import { Database, TrendingUp } from 'lucide-react';
import debounce from 'lodash/debounce';
import { searchMetrics, fetchMetricData, type MetricSearchResult } from '@services/categoryService';
import { darkGoldTheme } from '../../../constants';
import type { OverlayMetricData } from '../../../types';
// Available overlay colors
const OVERLAY_COLORS = [
'#00D4FF', // Cyan
'#A855F7', // Purple
'#FF6B6B', // Pink-red
'#4ADE80', // Green
'#F97316', // Orange
'#EC4899', // Pink
];
interface MetricOverlaySearchProps {
overlayMetrics: OverlayMetricData[];
onAddMetric: (metric: OverlayMetricData) => void;
onRemoveMetric: (metricId: string) => void;
stockDateRange?: { startDate: string; endDate: string };
}
/**
* Metric overlay search component
*/
const MetricOverlaySearch: React.FC<MetricOverlaySearchProps> = ({
overlayMetrics,
onAddMetric,
onRemoveMetric,
stockDateRange,
}) => {
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<MetricSearchResult[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [isLoadingData, setIsLoadingData] = useState(false);
const { isOpen, onOpen, onClose } = useDisclosure();
const inputRef = useRef<HTMLInputElement>(null);
// Get next available color
const getNextColor = useCallback(() => {
const usedColors = overlayMetrics.map(m => m.color);
const availableColor = OVERLAY_COLORS.find(c => !usedColors.includes(c));
return availableColor || OVERLAY_COLORS[overlayMetrics.length % OVERLAY_COLORS.length];
}, [overlayMetrics]);
// Search metrics
const doSearch = useCallback(async (query: string) => {
if (!query.trim()) {
setSearchResults([]);
return;
}
setIsSearching(true);
try {
const response = await searchMetrics(query, undefined, undefined, 20);
setSearchResults(response.results || []);
} catch (error) {
console.error('Search metrics failed:', error);
setSearchResults([]);
} finally {
setIsSearching(false);
}
}, []);
// Debounced search
const debouncedSearch = useMemo(
() => debounce(doSearch, 300),
[doSearch]
);
// Handle input change
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setSearchQuery(value);
debouncedSearch(value);
if (value && !isOpen) {
onOpen();
}
};
// Select metric
const handleSelectMetric = async (metric: MetricSearchResult) => {
// Check if already added
if (overlayMetrics.some(m => m.metric_id === metric.metric_id)) {
return;
}
// Max 3 overlays
if (overlayMetrics.length >= 3) {
alert('Max 3 overlay metrics allowed');
return;
}
setIsLoadingData(true);
try {
// Fetch metric data
const data = await fetchMetricData(
metric.metric_id,
stockDateRange?.startDate,
stockDateRange?.endDate,
500
);
if (data && data.data && data.data.length > 0) {
const overlayData: OverlayMetricData = {
metric_id: metric.metric_id,
metric_name: metric.metric_name,
source: metric.source,
unit: metric.unit,
data: data.data,
color: getNextColor(),
};
onAddMetric(overlayData);
setSearchQuery('');
setSearchResults([]);
onClose();
} else {
alert('No data available for this metric');
}
} catch (error) {
console.error('Fetch metric data failed:', error);
alert('Failed to fetch metric data');
} finally {
setIsLoadingData(false);
}
};
// Cleanup debounce
useEffect(() => {
return () => debouncedSearch.cancel?.();
}, [debouncedSearch]);
return (
<HStack spacing={2} align="center">
{/* Added overlay metrics tags */}
{overlayMetrics.map((metric) => (
<Tag
key={metric.metric_id}
size="sm"
borderRadius="full"
variant="outline"
borderColor={metric.color}
color={metric.color}
bg="transparent"
>
<Box
w="8px"
h="8px"
borderRadius="full"
bg={metric.color}
mr={1}
/>
<TagLabel fontSize="xs" maxW="100px" isTruncated>
{metric.metric_name}
</TagLabel>
<TagCloseButton onClick={() => onRemoveMetric(metric.metric_id)} />
</Tag>
))}
{/* Search button and dropdown */}
<Popover
isOpen={isOpen}
onClose={onClose}
placement="bottom-start"
closeOnBlur={true}
initialFocusRef={inputRef}
>
<PopoverTrigger>
<Box>
<Tooltip label="Overlay commodity data" placement="top" hasArrow>
<Button
size="sm"
variant="outline"
leftIcon={<Database size={14} />}
onClick={onOpen}
color={darkGoldTheme.textMuted}
borderColor={darkGoldTheme.border}
_hover={{
bg: 'rgba(212, 175, 55, 0.1)',
borderColor: darkGoldTheme.gold,
color: darkGoldTheme.gold,
}}
minW="100px"
>
Data Search
</Button>
</Tooltip>
</Box>
</PopoverTrigger>
<PopoverContent
bg="#1a1a2e"
borderColor={darkGoldTheme.border}
boxShadow="0 4px 20px rgba(0, 0, 0, 0.5)"
w="350px"
_focus={{ outline: 'none' }}
>
<PopoverBody p={3}>
{/* Search input */}
<InputGroup size="sm" mb={3}>
<InputLeftElement pointerEvents="none">
<SearchIcon color={darkGoldTheme.textMuted} />
</InputLeftElement>
<Input
ref={inputRef}
placeholder="Search commodity metrics..."
value={searchQuery}
onChange={handleInputChange}
bg="rgba(255,255,255,0.05)"
borderColor={darkGoldTheme.border}
color={darkGoldTheme.textPrimary}
_placeholder={{ color: darkGoldTheme.textMuted }}
_hover={{ borderColor: darkGoldTheme.gold }}
_focus={{ borderColor: darkGoldTheme.gold, boxShadow: 'none' }}
/>
{(searchQuery || isSearching) && (
<InputRightElement>
{isSearching || isLoadingData ? (
<Spinner size="xs" color={darkGoldTheme.gold} />
) : (
<IconButton
size="xs"
variant="ghost"
icon={<CloseIcon w="8px" h="8px" />}
onClick={() => {
setSearchQuery('');
setSearchResults([]);
}}
aria-label="Clear"
_hover={{ bg: 'transparent' }}
/>
)}
</InputRightElement>
)}
</InputGroup>
{/* Search results */}
{searchResults.length > 0 ? (
<List spacing={0} maxH="300px" overflowY="auto">
{searchResults.map((metric) => {
const isAdded = overlayMetrics.some(m => m.metric_id === metric.metric_id);
return (
<ListItem
key={metric.metric_id}
px={3}
py={2}
cursor={isAdded ? 'default' : 'pointer'}
opacity={isAdded ? 0.5 : 1}
_hover={isAdded ? {} : { bg: 'rgba(212, 175, 55, 0.1)' }}
onClick={() => !isAdded && handleSelectMetric(metric)}
borderRadius="md"
>
<Flex justify="space-between" align="center">
<VStack align="start" spacing={0} flex={1}>
<Text
fontSize="sm"
color={darkGoldTheme.gold}
fontWeight="medium"
noOfLines={1}
>
{metric.metric_name}
</Text>
<HStack spacing={2}>
<Text fontSize="xs" color={darkGoldTheme.textMuted}>
{metric.source}
</Text>
{metric.frequency && (
<Badge
size="sm"
bg="rgba(212, 175, 55, 0.1)"
color={darkGoldTheme.gold}
fontSize="10px"
>
{metric.frequency}
</Badge>
)}
{metric.unit && (
<Text fontSize="xs" color={darkGoldTheme.textMuted}>
{metric.unit}
</Text>
)}
</HStack>
</VStack>
{isAdded ? (
<Badge colorScheme="green" fontSize="10px">Added</Badge>
) : (
<AddIcon boxSize={3} color={darkGoldTheme.textMuted} />
)}
</Flex>
</ListItem>
);
})}
</List>
) : searchQuery && !isSearching ? (
<Text color={darkGoldTheme.textMuted} fontSize="sm" textAlign="center" py={4}>
No metrics found
</Text>
) : !searchQuery ? (
<VStack spacing={2} py={4}>
<TrendingUp size={24} color={darkGoldTheme.textMuted} />
<Text color={darkGoldTheme.textMuted} fontSize="sm" textAlign="center">
Enter keywords to search
</Text>
<Text color={darkGoldTheme.textMuted} fontSize="xs" textAlign="center">
Supports SMM and Mysteel data sources
</Text>
</VStack>
) : null}
</PopoverBody>
</PopoverContent>
</Popover>
</HStack>
);
};
export default MetricOverlaySearch;

View File

@@ -351,6 +351,18 @@ export interface AnalysisModalContentProps {
theme: Theme;
}
/**
* 叠加指标数据
*/
export interface OverlayMetricData {
metric_id: string;
metric_name: string;
source: string;
unit: string;
data: { date: string; value: number | null }[];
color?: string;
}
/**
* useMarketData Hook 返回值
*/

View File

@@ -9,6 +9,7 @@ import type {
FundingDayData,
PledgeData,
RiseAnalysis,
OverlayMetricData,
} from '../types';
import { formatNumber } from './formatUtils';
@@ -856,6 +857,7 @@ export const calculateTrendLine = (
* @param mainIndicator 主图指标 (默认MA)
* @param showAnalysis 是否显示涨幅分析标记 (默认true)
* @param drawingType 绘图工具类型 (默认NONE)
* @param overlayMetrics 叠加的商品指标数据 (可选)
*/
export const getKLineDarkGoldOption = (
tradeData: TradeDayData[],
@@ -863,7 +865,8 @@ export const getKLineDarkGoldOption = (
subIndicator: IndicatorType = 'MACD',
mainIndicator: MainIndicatorType = 'MA',
showAnalysis: boolean = true,
drawingType: DrawingType = 'NONE'
drawingType: DrawingType = 'NONE',
overlayMetrics: OverlayMetricData[] = []
): EChartsOption => {
if (!tradeData || tradeData.length === 0) return {};
@@ -1034,6 +1037,65 @@ export const getKLineDarkGoldOption = (
});
}
// 处理叠加指标数据 - 使用右侧Y轴
const hasOverlayMetrics = overlayMetrics && overlayMetrics.length > 0;
const overlaySeriesData: { name: string; data: (number | null)[]; color: string; unit: string }[] = [];
if (hasOverlayMetrics) {
// 创建日期到索引的映射(股票数据日期)
const fullDates = tradeData.map((item) => item.date.substring(0, 10));
const dateToIndex: Record<string, number> = {};
fullDates.forEach((date, idx) => {
dateToIndex[date] = idx;
});
// 为每个叠加指标处理数据
overlayMetrics.forEach((metric) => {
const seriesData: (number | null)[] = new Array(tradeData.length).fill(null);
metric.data.forEach((point) => {
if (point.value !== null && point.value !== undefined) {
const pointDate = point.date.substring(0, 10);
const idx = dateToIndex[pointDate];
if (idx !== undefined) {
seriesData[idx] = point.value;
}
}
});
overlaySeriesData.push({
name: metric.metric_name,
data: seriesData,
color: metric.color || '#00D4FF',
unit: metric.unit,
});
});
// 为叠加指标添加右侧Y轴只添加一个所有叠加指标共用
yAxes[0] = {
...yAxes[0],
position: 'left',
};
// 右侧Y轴用于显示叠加指标
const overlayYAxisIndex = hasSubIndicator ? 3 : 2;
yAxes.push({
scale: true,
position: 'right',
axisLine: { lineStyle: { color: overlaySeriesData[0]?.color || '#00D4FF' } },
axisLabel: {
color: overlaySeriesData[0]?.color || '#00D4FF',
fontSize: 10,
formatter: (value: number) => {
if (Math.abs(value) >= 10000) return (value / 10000).toFixed(1) + '万';
if (Math.abs(value) >= 1000) return (value / 1000).toFixed(1) + 'k';
return value.toFixed(1);
}
},
splitLine: { show: false },
});
}
// 图例数据
const legendData = ['K线'];
if (mainIndicator === 'MA') {
@@ -1458,6 +1520,33 @@ export const getKLineDarkGoldOption = (
legendData.push('BIAS6', 'BIAS12', 'BIAS24');
}
// 添加叠加指标的 series使用右侧Y轴
if (hasOverlayMetrics && overlaySeriesData.length > 0) {
const overlayYAxisIndex = hasSubIndicator ? 3 : 2;
overlaySeriesData.forEach((overlayItem) => {
series.push({
name: overlayItem.name,
type: 'line',
yAxisIndex: overlayYAxisIndex,
data: overlayItem.data,
smooth: true,
symbol: 'none',
lineStyle: {
color: overlayItem.color,
width: 2,
type: 'solid',
},
emphasis: {
lineStyle: {
width: 3,
},
},
z: 90, // 确保在K线上方显示
});
legendData.push(overlayItem.name);
});
}
return {
backgroundColor: 'transparent',
animation: true,
@@ -1500,7 +1589,14 @@ export const getKLineDarkGoldOption = (
// 跳过涨幅分析标记
} else if (typeof value === 'number' || (value !== null && !isNaN(Number(value)))) {
// MA、BOLL、MACD、KDJ、RSI 等指标
result += `${marker} ${seriesName}${formatPrice(Number(value))}<br/>`;
// 检查是否是叠加指标
const overlayItem = overlaySeriesData.find(o => o.name === seriesName);
if (overlayItem) {
// 叠加指标,显示单位
result += `${marker} <span style="color: ${overlayItem.color}">${seriesName}${formatPrice(Number(value))}${overlayItem.unit ? ' ' + overlayItem.unit : ''}</span><br/>`;
} else {
result += `${marker} ${seriesName}${formatPrice(Number(value))}<br/>`;
}
}
});