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

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;