You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
302 lines
12 KiB
302 lines
12 KiB
import type React from 'react';
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import { Card, Badge } from '../components/common';
|
|
import { marketApi } from '../api/market';
|
|
import type {
|
|
SectorMomentum,
|
|
StockMomentum,
|
|
NewHighLowStock,
|
|
UpDownStats,
|
|
KLineChartResponse,
|
|
} from '../types/market';
|
|
import KLineChartModal from '../components/charts/KLineChartModal';
|
|
|
|
function formatPercent(value: number): string {
|
|
const sign = value >= 0 ? '+' : '';
|
|
return `${sign}${value.toFixed(2)}%`;
|
|
}
|
|
|
|
function formatValue(value: number): string {
|
|
return value.toFixed(4);
|
|
}
|
|
|
|
function getChangeColor(value: number): string {
|
|
if (value > 0) return 'text-emerald-400';
|
|
if (value < 0) return 'text-red-400';
|
|
return 'text-secondary';
|
|
}
|
|
|
|
function getRankChangeColor(change: number): string {
|
|
if (change > 0) return 'text-emerald-400'; // 排名提升
|
|
if (change < 0) return 'text-red-400'; // 排名下降
|
|
return 'text-secondary';
|
|
}
|
|
|
|
function getRankChangeIcon(change: number): React.ReactNode {
|
|
if (change > 0) return '↑';
|
|
if (change < 0) return '↓';
|
|
return '→';
|
|
}
|
|
|
|
function recommendationBadge(rec?: string) {
|
|
if (!rec) return null;
|
|
switch (rec) {
|
|
case 'buy':
|
|
return <Badge variant="success" glow>BUY</Badge>;
|
|
case 'watch':
|
|
return <Badge variant="warning">WATCH</Badge>;
|
|
case 'hold':
|
|
return <Badge variant="default">HOLD</Badge>;
|
|
default:
|
|
return <Badge variant="default">{rec}</Badge>;
|
|
}
|
|
}
|
|
|
|
const TrendPage: React.FC = () => {
|
|
const [sectors, setSectors] = useState<SectorMomentum[]>([]);
|
|
const [stocks, setStocks] = useState<StockMomentum[]>([]);
|
|
const [newHigh, setNewHigh] = useState<NewHighLowStock[]>([]);
|
|
const [newLow, setNewLow] = useState<NewHighLowStock[]>([]);
|
|
const [updownStats, setUpdownStats] = useState<UpDownStats | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
const [chartModal, setChartModal] = useState<{
|
|
open: boolean;
|
|
type: 'stock' | 'sector';
|
|
code: string;
|
|
name: string;
|
|
} | null>(null);
|
|
|
|
const [chartData, setChartData] = useState<KLineChartResponse | null>(null);
|
|
const [isLoadingChart, setIsLoadingChart] = useState(false);
|
|
|
|
const fetchData = useCallback(async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const [sectorsRes, stocksRes, highRes, lowRes, statsRes] = await Promise.all([
|
|
marketApi.getSectorMomentum('momentumValue', 'desc', 5),
|
|
marketApi.getStockMomentumRecommendation(15),
|
|
marketApi.getNewHighStocks(20, 20),
|
|
marketApi.getNewLowStocks(20, 20),
|
|
marketApi.getUpDownStats(),
|
|
]);
|
|
|
|
setSectors(Array.isArray(sectorsRes?.items) ? sectorsRes.items : []);
|
|
setStocks(Array.isArray(stocksRes?.items) ? stocksRes.items : []);
|
|
setNewHigh(Array.isArray(highRes) ? highRes : []);
|
|
setNewLow(Array.isArray(lowRes) ? lowRes : []);
|
|
setUpdownStats(statsRes || null);
|
|
} catch (err) {
|
|
console.error('Failed to fetch trend data:', err);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, [fetchData]);
|
|
|
|
const handleItemClick = async (type: 'stock' | 'sector', code: string, name: string) => {
|
|
setChartModal({ open: true, type, code, name });
|
|
setIsLoadingChart(true);
|
|
setChartData(null);
|
|
|
|
try {
|
|
if (type === 'stock') {
|
|
const data = await marketApi.getStockKline(code, 60);
|
|
setChartData(data);
|
|
} else {
|
|
const data = await marketApi.getSectorKline(code, 60);
|
|
setChartData(data);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to fetch kline data:', err);
|
|
} finally {
|
|
setIsLoadingChart(false);
|
|
}
|
|
};
|
|
|
|
const closeChartModal = () => {
|
|
setChartModal(null);
|
|
setChartData(null);
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen flex flex-col">
|
|
<header className="flex-shrink-0 px-4 py-3 border-b border-white/5">
|
|
<h1 className="text-lg font-semibold text-white">趋势分析</h1>
|
|
<p className="text-xs text-muted mt-1">动量板块、个股推荐、新高新低分布</p>
|
|
</header>
|
|
|
|
<main className="flex-1 overflow-y-auto p-4">
|
|
{isLoading ? (
|
|
<div className="flex flex-col items-center justify-center h-64">
|
|
<div className="w-10 h-10 border-3 border-cyan/20 border-t-cyan rounded-full animate-spin" />
|
|
<p className="mt-3 text-secondary text-sm">加载中...</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
<Card variant="gradient" padding="md" className="animate-fade-in">
|
|
<div className="mb-3">
|
|
<span className="label-uppercase">动量板块 TOP5</span>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{sectors.map((sector, idx) => (
|
|
<div
|
|
key={sector.code}
|
|
className="flex items-center justify-between py-2 px-3 rounded-lg bg-elevated/50 hover:bg-hover cursor-pointer transition-colors"
|
|
onClick={() => handleItemClick('sector', sector.code, sector.name)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-muted font-mono">{sector.rank || idx + 1}</span>
|
|
<span className="text-sm text-white">{sector.name}</span>
|
|
{sector.rank_change !== undefined && (
|
|
<span className={`text-xs font-mono ${getRankChangeColor(sector.rank_change)} ml-1`}>
|
|
{getRankChangeIcon(sector.rank_change)} {Math.abs(sector.rank_change)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-xs font-mono text-cyan">{formatValue(sector.momentum_value || 0)}</span>
|
|
{sector.momentum_value_change !== undefined && (
|
|
<span className={`text-xs font-mono ${getChangeColor(sector.momentum_value_change)}`}>
|
|
{formatValue(sector.momentum_value_change)}
|
|
</span>
|
|
)}
|
|
<span className={`text-xs font-mono ${getChangeColor(sector.change_percent)}`}>
|
|
{formatPercent(sector.change_percent)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
|
|
<Card variant="gradient" padding="md" className="animate-fade-in">
|
|
<div className="mb-3">
|
|
<span className="label-uppercase">动量个股推荐</span>
|
|
</div>
|
|
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
|
{stocks.map((stock, idx) => (
|
|
<div
|
|
key={stock.code}
|
|
className="flex items-center justify-between py-2 px-3 rounded-lg bg-elevated/50 hover:bg-hover cursor-pointer transition-colors"
|
|
onClick={() => handleItemClick('stock', stock.code, stock.name)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-muted font-mono">{idx + 1}</span>
|
|
<div>
|
|
<span className="text-sm text-white">{stock.name}</span>
|
|
<span className="text-xs text-muted ml-1">{stock.code}</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{recommendationBadge(stock.recommendation)}
|
|
<span className={`text-xs font-mono ${getChangeColor(stock.change_percent)}`}>
|
|
{formatPercent(stock.change_percent)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
|
|
<Card variant="gradient" padding="md" className="animate-fade-in">
|
|
<div className="mb-3">
|
|
<span className="label-uppercase">新高个股</span>
|
|
<span className="text-xs text-muted ml-2">(20日内)</span>
|
|
</div>
|
|
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
|
{newHigh.slice(0, 10).map((stock) => (
|
|
<div
|
|
key={stock.code}
|
|
className="flex items-center justify-between py-2 px-3 rounded-lg bg-elevated/50 hover:bg-hover cursor-pointer transition-colors"
|
|
onClick={() => handleItemClick('stock', stock.code, stock.name)}
|
|
>
|
|
<div>
|
|
<span className="text-sm text-white">{stock.name}</span>
|
|
<span className="text-xs text-muted ml-1">{stock.code}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs font-mono text-emerald-400">{stock.price.toFixed(2)}</span>
|
|
<span className="text-xs font-mono text-emerald-400">{formatPercent(stock.change_percent)}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
|
|
<Card variant="gradient" padding="md" className="animate-fade-in">
|
|
<div className="mb-3">
|
|
<span className="label-uppercase">新低个股</span>
|
|
<span className="text-xs text-muted ml-2">(20日内)</span>
|
|
</div>
|
|
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
|
{newLow.slice(0, 10).map((stock) => (
|
|
<div
|
|
key={stock.code}
|
|
className="flex items-center justify-between py-2 px-3 rounded-lg bg-elevated/50 hover:bg-hover cursor-pointer transition-colors"
|
|
onClick={() => handleItemClick('stock', stock.code, stock.name)}
|
|
>
|
|
<div>
|
|
<span className="text-sm text-white">{stock.name}</span>
|
|
<span className="text-xs text-muted ml-1">{stock.code}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs font-mono text-red-400">{stock.price.toFixed(2)}</span>
|
|
<span className="text-xs font-mono text-red-400">{formatPercent(stock.change_percent)}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
|
|
{updownStats && (
|
|
<Card variant="gradient" padding="md" className="animate-fade-in lg:col-span-2">
|
|
<div className="mb-3">
|
|
<span className="label-uppercase">涨跌分布</span>
|
|
</div>
|
|
<div className="grid grid-cols-5 gap-4">
|
|
<div className="text-center">
|
|
<div className="text-2xl font-mono text-emerald-400">{updownStats.up_count}</div>
|
|
<div className="text-xs text-muted mt-1">上涨</div>
|
|
</div>
|
|
<div className="text-center">
|
|
<div className="text-2xl font-mono text-red-400">{updownStats.down_count}</div>
|
|
<div className="text-xs text-muted mt-1">下跌</div>
|
|
</div>
|
|
<div className="text-center">
|
|
<div className="text-2xl font-mono text-secondary">{updownStats.flat_count}</div>
|
|
<div className="text-xs text-muted mt-1">平盘</div>
|
|
</div>
|
|
<div className="text-center">
|
|
<div className="text-2xl font-mono text-emerald-400 glow">{updownStats.limit_up_count}</div>
|
|
<div className="text-xs text-muted mt-1">涨停</div>
|
|
</div>
|
|
<div className="text-center">
|
|
<div className="text-2xl font-mono text-red-400">{updownStats.limit_down_count}</div>
|
|
<div className="text-xs text-muted mt-1">跌停</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
)}
|
|
</main>
|
|
|
|
{chartModal?.open && (
|
|
<KLineChartModal
|
|
title={chartModal.name}
|
|
code={chartModal.code}
|
|
type={chartModal.type}
|
|
data={chartData}
|
|
isLoading={isLoadingChart}
|
|
onClose={closeChartModal}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default TrendPage; |