up
This commit is contained in:
Executable
+16
@@ -0,0 +1,16 @@
|
||||
import * as React from 'react'
|
||||
import { Outlet, createRootRoute } from '@tanstack/react-router'
|
||||
import { Toaster } from 'sonner'
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: RootComponent,
|
||||
})
|
||||
|
||||
function RootComponent() {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Outlet />
|
||||
<Toaster position="top-center" richColors />
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
Executable
+607
@@ -0,0 +1,607 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { collectionsApi, sharesApi } from "@/lib/api-client";
|
||||
import { fetchStockQuote, fetchStockSearch, getStockBoard, type StockQuote, type StockSearchResult } from "@/lib/stock-api";
|
||||
import { getUserId } from "@/lib/user-id";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Search, Plus, Share2, TrendingUp, TrendingDown, Trash2, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: Index,
|
||||
});
|
||||
|
||||
interface StockCollection {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
created_at: string;
|
||||
user_id: string;
|
||||
last_accessed_at: string;
|
||||
}
|
||||
|
||||
interface CollectionStock {
|
||||
id: number;
|
||||
collection_id: number;
|
||||
stock_code: string;
|
||||
stock_name: string;
|
||||
added_at: string;
|
||||
added_price: number | null;
|
||||
}
|
||||
|
||||
function Index() {
|
||||
const [collections, setCollections] = useState<StockCollection[]>([]);
|
||||
const [searchKeyword, setSearchKeyword] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<StockSearchResult[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [selectedStock, setSelectedStock] = useState<StockQuote | null>(null);
|
||||
const [isLoadingQuote, setIsLoadingQuote] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [newCollectionName, setNewCollectionName] = useState("");
|
||||
const [newCollectionDesc, setNewCollectionDesc] = useState("");
|
||||
const [refreshKey, setRefreshKey] = useState(0); // 用于触发集合卡片刷新
|
||||
const userId = getUserId();
|
||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const highlightIndexRef = useRef(-1);
|
||||
|
||||
useEffect(() => {
|
||||
loadCollections();
|
||||
}, []);
|
||||
|
||||
const loadCollections = async () => {
|
||||
try {
|
||||
const data = await collectionsApi.list(userId);
|
||||
setCollections(data || []);
|
||||
} catch (err) {
|
||||
console.error("加载集合失败:", err);
|
||||
toast.error("加载股票集合失败");
|
||||
}
|
||||
};
|
||||
|
||||
const searchStocks = (keyword: string) => {
|
||||
const trimmed = keyword.trim();
|
||||
setSearchKeyword(keyword);
|
||||
|
||||
// 清空已选股票
|
||||
setSelectedStock(null);
|
||||
|
||||
if (!trimmed) {
|
||||
setSearchResults([]);
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearching(true);
|
||||
highlightIndexRef.current = -1;
|
||||
|
||||
// 防抖:300ms
|
||||
if (searchTimerRef.current) {
|
||||
clearTimeout(searchTimerRef.current);
|
||||
}
|
||||
searchTimerRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const results = await fetchStockSearch(trimmed);
|
||||
setSearchResults(results);
|
||||
} catch (err) {
|
||||
console.error("搜索失败:", err);
|
||||
setSearchResults([]);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// 选中搜索结果后获取实时行情
|
||||
const selectStock = async (result: StockSearchResult) => {
|
||||
setSearchResults([]);
|
||||
setIsLoadingQuote(true);
|
||||
try {
|
||||
const quote = await fetchStockQuote(result.code);
|
||||
if (quote && quote.name) {
|
||||
setSelectedStock(quote);
|
||||
} else {
|
||||
toast.info("未找到该股票行情");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("获取行情失败:", err);
|
||||
const msg = err instanceof Error ? err.message : "获取行情失败";
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setIsLoadingQuote(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 键盘导航:上下选择,回车确认
|
||||
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (searchResults.length === 0) return;
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
highlightIndexRef.current = Math.min(
|
||||
highlightIndexRef.current + 1,
|
||||
searchResults.length - 1
|
||||
);
|
||||
// 强制刷新以更新高亮
|
||||
setSearchResults([...searchResults]);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
highlightIndexRef.current = Math.max(highlightIndexRef.current - 1, 0);
|
||||
setSearchResults([...searchResults]);
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const idx = highlightIndexRef.current;
|
||||
if (idx >= 0 && idx < searchResults.length) {
|
||||
selectStock(searchResults[idx]);
|
||||
} else if (searchResults.length > 0) {
|
||||
selectStock(searchResults[0]);
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
setSearchResults([]);
|
||||
highlightIndexRef.current = -1;
|
||||
}
|
||||
};
|
||||
|
||||
const createCollection = async () => {
|
||||
if (!newCollectionName.trim()) {
|
||||
toast.error("请输入集合名称");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await collectionsApi.create(newCollectionName.trim(), userId, newCollectionDesc.trim());
|
||||
|
||||
toast.success("创建成功");
|
||||
setNewCollectionName("");
|
||||
setNewCollectionDesc("");
|
||||
setIsCreating(false);
|
||||
loadCollections();
|
||||
} catch (err) {
|
||||
console.error("创建失败:", err);
|
||||
toast.error("创建集合失败");
|
||||
}
|
||||
};
|
||||
|
||||
const addStockToCollection = async (collectionId: number, stock: StockQuote) => {
|
||||
try {
|
||||
await collectionsApi.addStock(collectionId, {
|
||||
stock_code: stock.code,
|
||||
stock_name: stock.name,
|
||||
added_price: parseFloat(stock.currentPrice.toFixed(2)),
|
||||
});
|
||||
|
||||
// 更新集合的最后访问时间
|
||||
await collectionsApi.update(collectionId, {
|
||||
last_accessed_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
toast.success(`已添加 ${stock.name}(${stock.code}) @ ¥${stock.currentPrice.toFixed(2)}`);
|
||||
setSelectedStock(null);
|
||||
setSearchKeyword("");
|
||||
setSearchResults([]);
|
||||
setRefreshKey(prev => prev + 1); // 触发集合卡片刷新
|
||||
} catch (err) {
|
||||
console.error("添加失败:", err);
|
||||
const msg = err instanceof Error ? err.message : "添加股票失败";
|
||||
toast.error(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteCollection = async (collectionId: number, collectionName: string) => {
|
||||
if (!confirm(`确定要删除集合"${collectionName}"吗?此操作不可恢复。`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await collectionsApi.delete(collectionId);
|
||||
|
||||
toast.success("删除成功");
|
||||
loadCollections();
|
||||
} catch (err) {
|
||||
console.error("删除失败:", err);
|
||||
toast.error("删除集合失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-background via-muted/30 to-background">
|
||||
<div className="container mx-auto px-4 py-6 md:py-8 max-w-6xl">
|
||||
<div className="mb-6 md:mb-8 text-center">
|
||||
<h1 className="text-2xl md:text-4xl font-bold mb-2 bg-gradient-to-r from-primary to-primary/70 bg-clip-text text-transparent">
|
||||
A股走势追踪
|
||||
</h1>
|
||||
<p className="text-sm md:text-base text-muted-foreground">创建股票集合,分享历史走势</p>
|
||||
<Link to="/sectors">
|
||||
<Button variant="outline" size="sm" className="mt-3 gap-1.5 text-xs">
|
||||
<TrendingUp className="h-3.5 w-3.5" />
|
||||
板块资金流向
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card className="mb-6 md:mb-8 shadow-lg">
|
||||
<CardContent className="p-4 md:p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground h-4 w-4 z-10" />
|
||||
<Input
|
||||
placeholder="输入股票名称/代码/拼音(如 茅台 / 600519 / gzmt)"
|
||||
value={searchKeyword}
|
||||
onChange={(e) => searchStocks(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
className="pl-10"
|
||||
/>
|
||||
{isSearching && (
|
||||
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
|
||||
{/* 搜索结果下拉候选 */}
|
||||
{searchResults.length > 0 && !isSearching && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-popover border rounded-md shadow-lg max-h-[280px] overflow-y-auto z-20">
|
||||
{searchResults.map((result, idx) => {
|
||||
const board = getStockBoard(result.code);
|
||||
return (
|
||||
<button
|
||||
key={`${result.code}-${idx}`}
|
||||
type="button"
|
||||
onClick={() => selectStock(result)}
|
||||
onMouseEnter={() => {
|
||||
highlightIndexRef.current = idx;
|
||||
}}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 text-left hover:bg-accent transition-colors ${
|
||||
idx === highlightIndexRef.current ? "bg-accent" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1 flex items-center gap-2">
|
||||
{board.label && (
|
||||
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium border shrink-0 ${board.className}`}>
|
||||
{board.label}
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-sm truncate">{result.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{result.code}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground ml-2 shrink-0">
|
||||
{result.market}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 无结果提示 */}
|
||||
{searchKeyword.trim() && searchResults.length === 0 && !isSearching && !selectedStock && !isLoadingQuote && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-popover border rounded-md shadow-lg p-3 text-sm text-muted-foreground z-20">
|
||||
未找到匹配的股票
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Dialog open={isCreating} onOpenChange={setIsCreating}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="w-full sm:w-auto">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新建集合
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>创建股票集合</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 pt-4">
|
||||
<div className="space-y-2">
|
||||
<Label>集合名称</Label>
|
||||
<Input
|
||||
placeholder="例如:我的自选股"
|
||||
value={newCollectionName}
|
||||
onChange={(e) => setNewCollectionName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>描述(可选)</Label>
|
||||
<Textarea
|
||||
placeholder="简单描述这个集合"
|
||||
value={newCollectionDesc}
|
||||
onChange={(e) => setNewCollectionDesc(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={createCollection} className="w-full">
|
||||
创建
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* 加载行情中 */}
|
||||
{isLoadingQuote && (
|
||||
<div className="mt-4 flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
获取行情中...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 选中股票后展示行情 + 添加到集合 */}
|
||||
{selectedStock && !isLoadingQuote && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className="text-sm text-muted-foreground">已选股票:</p>
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between p-3 rounded-lg border bg-card hover:bg-accent/50 transition-colors gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium">{selectedStock.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedStock.code} ({selectedStock.market}) · ¥{selectedStock.currentPrice.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap justify-end w-full sm:w-auto">
|
||||
{collections.length === 0 ? (
|
||||
<span className="text-sm text-muted-foreground">请先创建集合</span>
|
||||
) : (
|
||||
collections.map((col) => (
|
||||
<Button
|
||||
key={col.id}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => addStockToCollection(col.id, selectedStock)}
|
||||
className="flex-1 sm:flex-none"
|
||||
>
|
||||
添加到 {col.name}
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6">
|
||||
{collections.length === 0 ? (
|
||||
<div className="col-span-full text-center py-12 text-muted-foreground">
|
||||
<TrendingUp className="mx-auto h-12 w-12 mb-4 opacity-50" />
|
||||
<p>暂无股票集合,点击上方按钮创建一个吧</p>
|
||||
</div>
|
||||
) : (
|
||||
collections.map((collection) => (
|
||||
<CollectionCard
|
||||
key={collection.id}
|
||||
collection={collection}
|
||||
onDelete={deleteCollection}
|
||||
refreshKey={refreshKey}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CollectionCard({
|
||||
collection,
|
||||
onDelete,
|
||||
refreshKey
|
||||
}: {
|
||||
collection: StockCollection;
|
||||
onDelete: (id: number, name: string) => void;
|
||||
refreshKey: number;
|
||||
}) {
|
||||
const [stocks, setStocks] = useState<CollectionStock[]>([]);
|
||||
const [stockQuotes, setStockQuotes] = useState<Map<string, StockQuote>>(new Map());
|
||||
const [shareLink, setShareLink] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadStocks();
|
||||
}, [collection.id, refreshKey]);
|
||||
|
||||
const loadStocks = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await collectionsApi.listStocks(collection.id);
|
||||
setStocks(data || []);
|
||||
|
||||
// 批量获取实时行情
|
||||
if (data && data.length > 0) {
|
||||
const codes = data.map((s: CollectionStock) => s.stock_code);
|
||||
const quotes = await fetchStockQuotesFromCodes(codes);
|
||||
setStockQuotes(quotes);
|
||||
}
|
||||
|
||||
// 更新最后访问时间
|
||||
updateLastAccessed(collection.id);
|
||||
await collectionsApi.update(collection.id, {
|
||||
last_accessed_at: new Date().toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("加载股票失败:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchStockQuotesFromCodes = async (codes: string[]): Promise<Map<string, StockQuote>> => {
|
||||
const map = new Map<string, StockQuote>();
|
||||
await Promise.all(
|
||||
codes.map(async (code) => {
|
||||
try {
|
||||
const quote = await fetchStockQuote(code);
|
||||
if (quote) map.set(code, quote);
|
||||
} catch {
|
||||
// 忽略单个股票获取失败
|
||||
}
|
||||
})
|
||||
);
|
||||
return map;
|
||||
};
|
||||
|
||||
const generateShareLink = async () => {
|
||||
try {
|
||||
const { short_code } = await sharesApi.create(collection.id);
|
||||
const link = `${window.location.origin}/share/${short_code}`;
|
||||
setShareLink(link);
|
||||
|
||||
// 尝试复制链接,失败不影响生成链接的成功
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(link);
|
||||
toast.success("分享链接已复制到剪贴板");
|
||||
} else {
|
||||
// 降级方案
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = link;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
toast.success("分享链接已复制到剪贴板");
|
||||
}
|
||||
} catch (copyErr) {
|
||||
console.error("复制失败:", copyErr);
|
||||
toast.success("分享链接已生成,请手动复制");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("生成链接失败:", err);
|
||||
toast.error("生成分享链接失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="hover:shadow-lg transition-shadow">
|
||||
<div className="p-4 md:p-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-semibold text-base md:text-lg truncate mr-2">{collection.name}</h3>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<Button size="sm" variant="ghost" onClick={generateShareLink} className="p-2">
|
||||
<Share2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onDelete(collection.id, collection.name)}
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10 p-2"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{collection.description && (
|
||||
<p className="text-sm text-muted-foreground mb-3 line-clamp-2">{collection.description}</p>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">加载中...</p>
|
||||
) : stocks.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">暂无股票</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{stocks.slice(0, 10).map((stock) => {
|
||||
const quote = stockQuotes.get(stock.stock_code);
|
||||
const currentPrice = quote?.currentPrice;
|
||||
const addedPrice = stock.added_price;
|
||||
|
||||
// 计算涨跌幅:如果有添加价则计算,否则显示 0%
|
||||
let changePercent = 0;
|
||||
let hasChangeData = false;
|
||||
|
||||
if (addedPrice && addedPrice > 0 && currentPrice) {
|
||||
changePercent = ((currentPrice - addedPrice) / addedPrice) * 100;
|
||||
hasChangeData = true;
|
||||
}
|
||||
|
||||
const stockIsPositive = changePercent >= 0;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={stock.id}
|
||||
to="/stock/$code"
|
||||
params={{ code: stock.stock_code }}
|
||||
className="block p-2 rounded hover:bg-accent/50 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{/* 左侧:名称和代码 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-sm truncate">{stock.stock_name}</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="text-xs text-muted-foreground">{stock.stock_code}</p>
|
||||
{(() => {
|
||||
const board = getStockBoard(stock.stock_code);
|
||||
if (!board.label) return null;
|
||||
return (
|
||||
<span className={`inline-flex items-center justify-center w-3.5 h-3.5 rounded-sm text-[8px] font-bold leading-none ${board.className}`}>
|
||||
{board.label}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
{/* 右侧:添加价、涨跌幅、最新价 */}
|
||||
<div className="flex items-center gap-2 shrink-0 text-xs">
|
||||
<div className="text-right min-w-[48px]">
|
||||
<p className="text-muted-foreground text-[10px]">自选</p>
|
||||
<p className="text-[11px]">{addedPrice ? `¥${addedPrice.toFixed(2)}` : currentPrice ? `¥${currentPrice.toFixed(2)}` : "-"}</p>
|
||||
</div>
|
||||
<div className="text-right min-w-[56px]">
|
||||
<p className="text-muted-foreground text-[10px]">涨跌</p>
|
||||
<p className={`font-semibold text-[11px] ${stockIsPositive ? "text-red-500" : "text-green-500"}`}>
|
||||
{hasChangeData ? `${stockIsPositive ? "+" : ""}${changePercent.toFixed(2)}%` : "0.00%"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right min-w-[48px]">
|
||||
<p className="text-muted-foreground text-[10px]">最新</p>
|
||||
<p className="font-medium text-[11px]">{currentPrice ? `¥${currentPrice.toFixed(2)}` : "-"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{stocks.length > 10 && (
|
||||
<p className="text-xs text-muted-foreground text-center">还有 {stocks.length - 10} 只股票...</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{shareLink && (
|
||||
<div
|
||||
className="mt-4 p-2 bg-muted rounded text-xs break-all cursor-pointer hover:bg-accent/50 transition-colors"
|
||||
onClick={async () => {
|
||||
try {
|
||||
// 尝试使用现代 Clipboard API
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(shareLink);
|
||||
toast.success("链接已复制到剪贴板");
|
||||
} else {
|
||||
// 降级方案:使用 textarea 方法
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = shareLink;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
toast.success("链接已复制到剪贴板");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("复制失败:", err);
|
||||
toast.error("复制失败,请手动复制链接");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<p className="text-muted-foreground mb-1">分享链接(点击复制):</p>
|
||||
<p>{shareLink}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useState, useEffect } from "react";
|
||||
import { fetchSectors, type SectorItem, type SectorType } from "@/lib/stock-api";
|
||||
import { formatMoney } from "@/lib/utils";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { ArrowLeft, TrendingUp, TrendingDown, RefreshCw } from "lucide-react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/sectors")({
|
||||
component: SectorsPage,
|
||||
});
|
||||
|
||||
const TABS: { key: SectorType; label: string }[] = [
|
||||
{ key: "industry", label: "行业板块" },
|
||||
{ key: "concept", label: "概念板块" },
|
||||
];
|
||||
|
||||
function SectorsPage() {
|
||||
const [tab, setTab] = useState<SectorType>("industry");
|
||||
const [data, setData] = useState<SectorItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetchSectors(tab).then((items) => {
|
||||
setData(items);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [tab]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* 顶栏 */}
|
||||
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b">
|
||||
<div className="max-w-5xl mx-auto px-4 h-12 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/" className="hover:opacity-70 transition-opacity">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<h1 className="text-base font-semibold">板块资金流向</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setLoading(true);
|
||||
fetchSectors(tab).then(setData).finally(() => setLoading(false));
|
||||
}}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Tab 切换 */}
|
||||
<div className="max-w-5xl mx-auto px-4 mt-4">
|
||||
<div className="flex gap-1 bg-muted rounded-lg p-1">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`flex-1 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||
tab === t.key
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 数据更新时间 */}
|
||||
<div className="max-w-5xl mx-auto px-4 mt-2">
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
实时数据 · 按主力净流入降序 · 共 {data.length} 个板块
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 方块图 */}
|
||||
<div className="max-w-5xl mx-auto px-4 mt-3 pb-8">
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
|
||||
{Array.from({ length: 20 }).map((_, i) => (
|
||||
<div key={i} className="animate-pulse rounded-xl bg-muted h-24" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
|
||||
{data.map((item) => (
|
||||
<SectorBlock key={item.code} item={item} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectorBlock({ item }: { item: SectorItem }) {
|
||||
const inflow = item.mainNetInflow;
|
||||
const isPositive = inflow >= 0;
|
||||
const change = item.changePercent;
|
||||
|
||||
return (
|
||||
<Card className="rounded-xl hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-3 space-y-2">
|
||||
{/* 板块名称 + 编号 */}
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<p className="text-sm font-medium truncate" title={item.name}>
|
||||
{item.name}
|
||||
</p>
|
||||
{item.code && (
|
||||
<span className="shrink-0 text-[9px] text-muted-foreground/60 font-mono">
|
||||
{item.code}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 涨跌幅 */}
|
||||
<div className="flex items-center gap-1">
|
||||
{change != null && (
|
||||
<span
|
||||
className={`inline-flex items-center gap-0.5 text-xs font-semibold ${
|
||||
change >= 0 ? "text-red-500" : "text-green-500"
|
||||
}`}
|
||||
>
|
||||
{change >= 0 ? (
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
) : (
|
||||
<TrendingDown className="h-3 w-3" />
|
||||
)}
|
||||
{change >= 0 ? "+" : ""}
|
||||
{change.toFixed(2)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 资金流向 */}
|
||||
<div className="pt-1 border-t border-border/50">
|
||||
<p className="text-[10px] text-muted-foreground">主力净流入</p>
|
||||
<p
|
||||
className={`text-xs font-bold ${
|
||||
isPositive ? "text-red-500" : "text-green-500"
|
||||
}`}
|
||||
>
|
||||
{isPositive ? "+" : ""}
|
||||
{formatMoney(inflow)}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Executable
+261
@@ -0,0 +1,261 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useState, useEffect } from "react";
|
||||
import { sharesApi } from "@/lib/api-client";
|
||||
import { fetchStockQuotes, fetchStockQuote, getStockBoard, type StockQuote } from "@/lib/stock-api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TrendingUp, TrendingDown, Calendar, Share2 } from "lucide-react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/share/$code")({
|
||||
component: SharePage,
|
||||
});
|
||||
|
||||
interface CollectionInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
interface StockRecord {
|
||||
id: number;
|
||||
stock_code: string;
|
||||
stock_name: string;
|
||||
added_at: string;
|
||||
added_price: number | null;
|
||||
}
|
||||
|
||||
function SharePage() {
|
||||
const { code } = Route.useParams();
|
||||
const [collection, setCollection] = useState<CollectionInfo | null>(null);
|
||||
const [stocks, setStocks] = useState<(StockRecord & { quote?: StockQuote })[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadSharedData();
|
||||
}, [code]);
|
||||
|
||||
const loadSharedData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// sharesApi.get 返回集合信息(含股票列表)
|
||||
const data = await sharesApi.get(code);
|
||||
|
||||
if (!data) {
|
||||
setError("分享链接无效");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setCollection({
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
});
|
||||
|
||||
const stocksData = data.stocks || [];
|
||||
|
||||
const stockRecords: StockRecord[] = stocksData || [];
|
||||
|
||||
// 批量获取实时行情
|
||||
if (stockRecords.length > 0) {
|
||||
const codes = stockRecords.map((s) => s.stock_code);
|
||||
const quoteMap = await fetchStockQuotesFromCodes(codes);
|
||||
|
||||
const merged = stockRecords.map((record) => ({
|
||||
...record,
|
||||
quote: quoteMap.get(record.stock_code),
|
||||
}));
|
||||
setStocks(merged);
|
||||
} else {
|
||||
setStocks([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("加载失败:", err);
|
||||
const msg = err instanceof Error ? err.message : "加载数据失败";
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchStockQuotesFromCodes = async (codes: string[]): Promise<Map<string, StockQuote>> => {
|
||||
const map = new Map<string, StockQuote>();
|
||||
await Promise.all(
|
||||
codes.map(async (code) => {
|
||||
try {
|
||||
const quote = await fetchStockQuote(code);
|
||||
if (quote) map.set(code, quote);
|
||||
} catch {
|
||||
// 忽略单个股票获取失败
|
||||
}
|
||||
})
|
||||
);
|
||||
return map;
|
||||
};
|
||||
|
||||
const copyShareLink = async () => {
|
||||
const link = window.location.href;
|
||||
try {
|
||||
await navigator.clipboard.writeText(link);
|
||||
toast.success("链接已复制到剪贴板");
|
||||
} catch (err) {
|
||||
toast.error("复制失败");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<p className="text-muted-foreground">加载中...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-xl font-semibold mb-2">加载失败</p>
|
||||
<p className="text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!collection) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-xl font-semibold mb-2">分享链接无效</p>
|
||||
<p className="text-muted-foreground">该链接可能已过期或不存在</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-background via-muted/30 to-background">
|
||||
<div className="container mx-auto px-4 py-8 max-w-6xl">
|
||||
{/* Header */}
|
||||
<Card className="mb-6 shadow-lg">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">{collection.name}</h1>
|
||||
{collection.description && (
|
||||
<p className="text-muted-foreground">{collection.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={copyShareLink} variant="outline">
|
||||
<Share2 className="mr-2 h-4 w-4" />
|
||||
分享
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Stocks List */}
|
||||
{stocks.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center text-muted-foreground">
|
||||
<p>该集合暂无股票</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{stocks.map((stock) => {
|
||||
const quote = stock.quote;
|
||||
const currentPrice = quote?.currentPrice;
|
||||
const addedPrice = stock.added_price;
|
||||
|
||||
let changePercent = 0;
|
||||
let hasChange = false;
|
||||
let isPositive = true;
|
||||
|
||||
if (currentPrice && addedPrice && addedPrice > 0) {
|
||||
changePercent = ((currentPrice - addedPrice) / addedPrice) * 100;
|
||||
hasChange = true;
|
||||
isPositive = changePercent >= 0;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={stock.id}
|
||||
to="/stock/$code"
|
||||
params={{ code: stock.stock_code }}
|
||||
search={{ from: `share/${code}` }}
|
||||
className="block"
|
||||
>
|
||||
<Card className="hover:shadow-lg transition-shadow cursor-pointer h-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="truncate">{stock.stock_name}</span>
|
||||
{(() => {
|
||||
const board = getStockBoard(stock.stock_code);
|
||||
if (!board.label) return null;
|
||||
return (
|
||||
<span className={`inline-flex items-center justify-center w-4 h-4 rounded-sm text-[9px] font-bold leading-none shrink-0 ${board.className}`}>
|
||||
{board.label}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<span className="text-sm font-normal text-muted-foreground shrink-0 ml-2">
|
||||
{stock.stock_code}
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{/* Current Price */}
|
||||
<div>
|
||||
<p className="text-2xl font-bold">
|
||||
{currentPrice ? `¥${currentPrice.toFixed(2)}` : "—"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Added Info and Change */}
|
||||
<div className="pt-2 border-t space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>
|
||||
添加于 {new Date(stock.added_at).toLocaleDateString("zh-CN")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{addedPrice && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
添加价: ¥{addedPrice.toFixed(2)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{hasChange && (
|
||||
<div className={`flex items-center gap-1 ${isPositive ? "text-red-500" : "text-green-500"}`}>
|
||||
{isPositive ? (
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
) : (
|
||||
<TrendingDown className="h-4 w-4" />
|
||||
)}
|
||||
<span className="font-semibold">
|
||||
{isPositive ? "+" : ""}{changePercent.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Executable
+940
@@ -0,0 +1,940 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { collectionsApi } from "@/lib/api-client";
|
||||
import { fetchStockQuote, fetchStockHistory, fetchStockFundFlow, getStockBoard, type StockQuote, type KLineData, type FundFlowData, type FundFlowSummary } from "@/lib/stock-api";
|
||||
import { getUserId } from "@/lib/user-id";
|
||||
import { formatMoney } from "@/lib/utils";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft, TrendingUp, TrendingDown, Calendar, ExternalLink, Newspaper } from "lucide-react";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import {
|
||||
ComposedChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Bar,
|
||||
Cell,
|
||||
Scatter,
|
||||
Brush,
|
||||
ReferenceLine,
|
||||
} from "recharts";
|
||||
|
||||
export const Route = createFileRoute("/stock/$code")({
|
||||
component: StockDetail,
|
||||
validateSearch: (search: Record<string, unknown>) => {
|
||||
return {
|
||||
from: typeof search.from === "string" ? search.from : undefined,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
interface StockData {
|
||||
date: string;
|
||||
dateObj: Date;
|
||||
open: number;
|
||||
close: number;
|
||||
high: number;
|
||||
low: number;
|
||||
volume: number;
|
||||
isAddedDate: boolean;
|
||||
}
|
||||
|
||||
interface StockInfo {
|
||||
code: string;
|
||||
name: string;
|
||||
currentPrice: number;
|
||||
changePercent: number;
|
||||
change: number;
|
||||
high: number;
|
||||
low: number;
|
||||
todayOpen: number;
|
||||
yesterdayClose: number;
|
||||
addedAt: string;
|
||||
addedPrice: number | null;
|
||||
outerDisk: number; // 外盘(手)
|
||||
innerDisk: number; // 内盘(手)
|
||||
amount: number; // 成交额(元)
|
||||
quoteDate: string; // 行情日期 YYYY-MM-DD
|
||||
}
|
||||
|
||||
function StockDetail() {
|
||||
const { code } = Route.useParams();
|
||||
const { from } = Route.useSearch();
|
||||
const userId = getUserId();
|
||||
const [stockInfo, setStockInfo] = useState<StockInfo | null>(null);
|
||||
const [chartData, setChartData] = useState<StockData[]>([]);
|
||||
const [chartLoading, setChartLoading] = useState(false);
|
||||
const [fundFlowData, setFundFlowData] = useState<FundFlowData[]>([]);
|
||||
const [fundFlowSummary, setFundFlowSummary] = useState<FundFlowSummary | null>(null);
|
||||
const [fundFlowLoading, setFundFlowLoading] = useState(false);
|
||||
const [fundFlowPeriod, setFundFlowPeriod] = useState(21);
|
||||
const [dailyTableDays, setDailyTableDays] = useState<number>(7);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [inCollection, setInCollection] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadStockData();
|
||||
}, [code]);
|
||||
|
||||
const loadStockData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setChartData([]);
|
||||
setChartLoading(false);
|
||||
setFundFlowData([]);
|
||||
setFundFlowSummary(null);
|
||||
setFundFlowLoading(false);
|
||||
try {
|
||||
// 阶段1:并行加载基础数据(数据库记录 + 实时行情)
|
||||
const [stockRecordResult, quoteResult] = await Promise.allSettled([
|
||||
collectionsApi.findStock(code),
|
||||
fetchStockQuote(code),
|
||||
]);
|
||||
|
||||
// 处理数据库查询结果
|
||||
const stockRecord = stockRecordResult.status === "fulfilled" ? stockRecordResult.value : null;
|
||||
setInCollection(stockRecord !== null);
|
||||
|
||||
// 处理实时行情结果
|
||||
if (quoteResult.status !== "fulfilled" || !quoteResult.value) {
|
||||
setError("未找到股票数据");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const quote = quoteResult.value;
|
||||
|
||||
const addedAt = stockRecord?.added_at || new Date().toISOString();
|
||||
const addedPrice = stockRecord?.added_price || quote.currentPrice;
|
||||
|
||||
setStockInfo({
|
||||
code: quote.code,
|
||||
name: quote.name,
|
||||
currentPrice: quote.currentPrice,
|
||||
changePercent: quote.changePercent,
|
||||
change: quote.change,
|
||||
high: quote.high,
|
||||
low: quote.low,
|
||||
todayOpen: quote.todayOpen,
|
||||
yesterdayClose: quote.yesterdayClose,
|
||||
addedAt,
|
||||
addedPrice,
|
||||
outerDisk: quote.outerDisk || 0,
|
||||
innerDisk: quote.innerDisk || 0,
|
||||
amount: (quote.amount || 0) * 10000,
|
||||
quoteDate: quote.date,
|
||||
});
|
||||
|
||||
// 基础信息已就绪,结束主loading,先渲染页面框架
|
||||
setLoading(false);
|
||||
|
||||
// 阶段2:并行加载历史K线 + 资金流向(后置加载,不阻塞首屏)
|
||||
const addedDate = new Date(addedAt);
|
||||
const now = new Date();
|
||||
const daysSinceAdded = Math.floor((now.getTime() - addedDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
const chartDays = Math.min(Math.max(90, daysSinceAdded), 120);
|
||||
|
||||
setChartLoading(true);
|
||||
setFundFlowLoading(true);
|
||||
|
||||
const [historyResult, fundFlowResult] = await Promise.allSettled([
|
||||
fetchStockHistory(code, chartDays),
|
||||
fetchStockFundFlow(code, quote.name, 21),
|
||||
]);
|
||||
|
||||
// 处理历史K线(必需)
|
||||
if (historyResult.status === "fulfilled" && historyResult.value && historyResult.value.length > 0) {
|
||||
const historyData = convertToStockData(historyResult.value, addedDate);
|
||||
// 如果添加日不是交易日(周末/节假日),标记最接近的K线日期
|
||||
if (stockRecord && !historyData.some(d => d.isAddedDate)) {
|
||||
const closestIdx = findClosestDateIndex(historyData, addedDate);
|
||||
if (historyData[closestIdx]) {
|
||||
historyData[closestIdx] = { ...historyData[closestIdx], isAddedDate: true };
|
||||
}
|
||||
}
|
||||
setChartData(historyData);
|
||||
if (historyResult.value.length < 2) {
|
||||
console.warn("历史K线数据过少(新股或上市首日),仅显示有限数据");
|
||||
}
|
||||
} else {
|
||||
console.error("无法获取历史K线数据");
|
||||
setError("获取历史K线数据失败,请稍后重试");
|
||||
}
|
||||
|
||||
// 处理资金流向(非必需,失败不影响主流程)
|
||||
if (fundFlowResult.status === "fulfilled" && fundFlowResult.value) {
|
||||
console.log("[stock-detail] 资金流向数据:", fundFlowResult.value);
|
||||
setFundFlowData(fundFlowResult.value.data);
|
||||
setFundFlowSummary(fundFlowResult.value.summary || null);
|
||||
} else {
|
||||
console.error("获取资金流向数据失败:", fundFlowResult.status === "rejected" ? fundFlowResult.reason : "未知错误");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("加载失败:", err);
|
||||
const msg = err instanceof Error ? err.message : "加载股票数据失败";
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setChartLoading(false);
|
||||
setFundFlowLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 将K线数据转换为图表格式
|
||||
const convertToStockData = (klines: KLineData[], addedDate: Date): StockData[] => {
|
||||
return klines.map(kline => {
|
||||
const dateObj = new Date(kline.date);
|
||||
const isAddedDate = dateObj.toDateString() === addedDate.toDateString();
|
||||
|
||||
return {
|
||||
date: dateObj.toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit" }),
|
||||
dateObj,
|
||||
open: kline.open,
|
||||
close: kline.close,
|
||||
high: kline.high,
|
||||
low: kline.low,
|
||||
volume: kline.volume,
|
||||
isAddedDate,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// 在K线数据中找到最接近目标日期的索引(当添加日非交易日时使用)
|
||||
const findClosestDateIndex = (data: StockData[], target: Date): number => {
|
||||
let closestIdx = 0;
|
||||
let closestDiff = Infinity;
|
||||
data.forEach((d, i) => {
|
||||
const diff = Math.abs(d.dateObj.getTime() - target.getTime());
|
||||
if (diff < closestDiff) {
|
||||
closestDiff = diff;
|
||||
closestIdx = i;
|
||||
}
|
||||
});
|
||||
return closestIdx;
|
||||
};
|
||||
|
||||
// 资金流向按周期切片
|
||||
const fundFlowSlice = useMemo(
|
||||
() => fundFlowData.slice(-fundFlowPeriod),
|
||||
[fundFlowData, fundFlowPeriod]
|
||||
);
|
||||
const fundFlowLocalSummary = useMemo(() => {
|
||||
if (fundFlowSlice.length === 0) return null;
|
||||
const totalMain = fundFlowSlice.reduce((s, d) => s + d.mainForceNet, 0);
|
||||
const totalTurnover = fundFlowSlice.reduce((s, d) => s + d.turnover, 0);
|
||||
const totalLargeInflow = fundFlowSlice.reduce((s, d) => s + d.largeInflow, 0);
|
||||
const positive = fundFlowSlice.filter(d => d.mainForceNet > 0).length;
|
||||
const negative = fundFlowSlice.filter(d => d.mainForceNet < 0).length;
|
||||
return {
|
||||
totalMainNet: totalMain,
|
||||
totalTurnover,
|
||||
totalLargeInflow,
|
||||
avgDailyMainNet: totalMain / fundFlowSlice.length,
|
||||
positiveDays: positive,
|
||||
negativeDays: negative,
|
||||
};
|
||||
}, [fundFlowSlice]);
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<p className="text-muted-foreground">加载中...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-xl font-semibold mb-2">加载失败</p>
|
||||
<p className="text-muted-foreground mb-4">{error}</p>
|
||||
<Link to="/">
|
||||
<Button variant="outline">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
返回首页
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stockInfo) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<p className="text-muted-foreground">未找到股票信息</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isPositive = stockInfo.changePercent >= 0;
|
||||
const trendColor = isPositive ? "text-red-500" : "text-green-500";
|
||||
|
||||
// 计算自添加以来的累计涨跌幅
|
||||
const addedPrice = stockInfo.addedPrice;
|
||||
const cumulativeChange = addedPrice && addedPrice > 0
|
||||
? ((stockInfo.currentPrice - addedPrice) / addedPrice) * 100
|
||||
: null;
|
||||
const cumulativeIsPositive = cumulativeChange !== null && cumulativeChange >= 0;
|
||||
|
||||
// 找到添加日期在图表中的位置
|
||||
const addedDateObj = stockInfo ? new Date(stockInfo.addedAt) : null;
|
||||
const addedDateStr = addedDateObj ? addedDateObj.toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit" }) : "";
|
||||
const addedDataPoint = inCollection ? chartData.find(d => d.isAddedDate) : undefined;
|
||||
|
||||
// 计算X轴刻度:保证添加日期始终有标签
|
||||
const tickDates = (() => {
|
||||
if (chartData.length === 0) return [];
|
||||
const first = chartData[0].date;
|
||||
const last = chartData[chartData.length - 1].date;
|
||||
const ticks = [first];
|
||||
const added = addedDataPoint?.date;
|
||||
const targetCount = Math.min(7, chartData.length);
|
||||
const step = Math.max(1, Math.floor((chartData.length - 1) / (targetCount - 2)));
|
||||
for (let i = step; i < chartData.length - 1; i += step) {
|
||||
ticks.push(chartData[i].date);
|
||||
}
|
||||
if (added && !ticks.includes(added)) {
|
||||
ticks.push(added);
|
||||
}
|
||||
ticks.push(last);
|
||||
return [...new Set(ticks)].sort((a, b) => {
|
||||
// 按日期排序(MM/DD格式需按MM和DD比较)
|
||||
const [am, ad] = a.split('/').map(Number);
|
||||
const [bm, bd] = b.split('/').map(Number);
|
||||
return am - bm || ad - bd;
|
||||
});
|
||||
})();
|
||||
|
||||
// 东方财富市场标识:0=深圳(000/002/300), 1=上海(60), 6=科创板(688)
|
||||
const getEastMoneyMarket = (code: string): string => {
|
||||
if (code.startsWith('688')) return '6';
|
||||
if (code.startsWith('60')) return '1';
|
||||
return '0';
|
||||
};
|
||||
|
||||
const eastMoneyUrl = `https://wap.eastmoney.com/quote/stock/${getEastMoneyMarket(stockInfo.code)}.${stockInfo.code}.html?appfenxiang=1`;
|
||||
const jin10Url = `https://search.jin10.com/?keyword=${encodeURIComponent(stockInfo.name)}`;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-background via-muted/30 to-background overscroll-none">
|
||||
<div className="container mx-auto px-4 py-4 md:py-8 max-w-6xl">
|
||||
{/* Top Action Bar: 返回 + 东方财富 */}
|
||||
<div className="flex items-center justify-between mb-4 md:mb-6 gap-2">
|
||||
{from ? (
|
||||
<Link to={`/${from}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
返回
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Link to="/">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
返回首页
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
<a href={eastMoneyUrl} target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="outline" size="sm">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
东方财富
|
||||
</Button>
|
||||
</a>
|
||||
<a href={jin10Url} target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="outline" size="sm">
|
||||
<Newspaper className="mr-2 h-4 w-4" />
|
||||
金十数据
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Stock Info Header */}
|
||||
<Card className="mb-4 md:mb-6 shadow-lg">
|
||||
<CardContent className="p-4 md:p-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<h1 className="text-xl md:text-3xl font-bold">{stockInfo.name}</h1>
|
||||
{(() => {
|
||||
const board = getStockBoard(stockInfo.code);
|
||||
if (!board.label) return null;
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border ${board.className}`}>
|
||||
{board.label}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<p className="text-sm md:text-base text-muted-foreground">代码: {stockInfo.code}</p>
|
||||
<div className="flex items-center gap-2 mt-2 text-xs md:text-sm text-muted-foreground">
|
||||
<Calendar className="h-3 w-3 md:h-4 md:w-4" />
|
||||
<span>自选日期: {new Date(stockInfo.addedAt).toLocaleDateString("zh-CN")}</span>
|
||||
</div>
|
||||
{addedPrice && (
|
||||
<p className="text-xs md:text-sm mt-1">
|
||||
<span className="text-muted-foreground">自选价格:</span>
|
||||
<span className="font-medium">¥{addedPrice.toFixed(2)}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-right space-y-2">
|
||||
<div>
|
||||
<p className="text-2xl md:text-4xl font-bold">¥{stockInfo.currentPrice.toFixed(2)}</p>
|
||||
<div className={`flex items-center justify-end gap-1 md:gap-2 mt-1 ${trendColor}`}>
|
||||
{isPositive ? (
|
||||
<TrendingUp className="h-4 w-4 md:h-5 md:w-5" />
|
||||
) : (
|
||||
<TrendingDown className="h-4 w-4 md:h-5 md:w-5" />
|
||||
)}
|
||||
<span className="text-base md:text-xl font-semibold">
|
||||
{isPositive ? "+" : ""}{stockInfo.change.toFixed(2)} ({isPositive ? "+" : ""}{stockInfo.changePercent.toFixed(2)}%)
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs md:text-sm text-muted-foreground mt-1">今日涨幅</p>
|
||||
</div>
|
||||
|
||||
{/* 累计涨跌幅 */}
|
||||
{cumulativeChange !== null && (
|
||||
<div className={`pt-2 border-t`}>
|
||||
<p className="text-sm text-muted-foreground">自添加以来累计</p>
|
||||
<div className={`flex items-center justify-end gap-2 mt-1 ${cumulativeIsPositive ? "text-red-500" : "text-green-500"}`}>
|
||||
{cumulativeIsPositive ? (
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
) : (
|
||||
<TrendingDown className="h-4 w-4" />
|
||||
)}
|
||||
<span className="text-lg font-semibold">
|
||||
{cumulativeIsPositive ? "+" : ""}{cumulativeChange.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Chart */}
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader className="pb-2 md:pb-4 px-3 md:px-6 pt-4 md:pt-6">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<CardTitle className="text-base md:text-lg">
|
||||
K线图
|
||||
<span className="text-xs md:text-sm font-normal text-muted-foreground ml-2">
|
||||
{chartLoading ? "(加载中...)" : `(${chartData.length}个交易日)`}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<span className="text-xs text-muted-foreground hidden sm:inline">
|
||||
双指缩放 · 拖动选区查看
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 md:px-6 pb-2 md:pb-6">
|
||||
{chartLoading ? (
|
||||
<div className="h-[320px] sm:h-[380px] md:h-[440px] w-full flex items-center justify-center">
|
||||
<div className="flex items-center text-muted-foreground text-sm">
|
||||
<div className="animate-pulse mr-2 h-2 w-2 rounded-full bg-primary"></div>
|
||||
K线数据加载中...
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="h-[320px] sm:h-[380px] md:h-[440px] w-full touch-none select-none"
|
||||
style={{ touchAction: "none", overscrollBehavior: "none", WebkitOverflowScrolling: "auto" }}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart
|
||||
data={chartData}
|
||||
margin={{ top: 10, right: 8, left: 0, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" opacity={0.5} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={10}
|
||||
ticks={tickDates}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={56}
|
||||
tick={(props: any) => {
|
||||
const { x, y, payload } = props;
|
||||
const dataPoint = chartData.find(d => d.date === payload.value);
|
||||
const isAddedDate = inCollection && dataPoint?.isAddedDate;
|
||||
|
||||
return (
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
dy={16}
|
||||
textAnchor="end"
|
||||
fill={isAddedDate ? '#f59e0b' : 'var(--muted-foreground)'}
|
||||
fontWeight={isAddedDate ? 'bold' : 'normal'}
|
||||
fontSize={isAddedDate ? 11 : 10}
|
||||
>
|
||||
{payload.value}
|
||||
</text>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={10}
|
||||
domain={["auto", "auto"]}
|
||||
tickCount={5}
|
||||
width={42}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={10}
|
||||
tickCount={5}
|
||||
width={42}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "8px",
|
||||
fontSize: "12px",
|
||||
padding: "8px 12px",
|
||||
}}
|
||||
labelFormatter={(label) => `日期: ${label}`}
|
||||
formatter={(value: any, name: string) => {
|
||||
if (name === 'close') return [`¥${Number(value).toFixed(2)}`, '收盘价'];
|
||||
if (name === 'volume') return [Number(value).toLocaleString(), '成交量'];
|
||||
return [value, name];
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
yAxisId="right"
|
||||
dataKey="volume"
|
||||
fill="var(--primary)"
|
||||
opacity={0.2}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="close"
|
||||
stroke="var(--primary)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
{/* 高亮添加日期的K线点 */}
|
||||
<Scatter
|
||||
yAxisId="left"
|
||||
dataKey="close"
|
||||
fill="#fbbf24"
|
||||
shape={(props: any) => {
|
||||
const { cx, cy, payload } = props;
|
||||
if (!payload.isAddedDate) {
|
||||
return <circle cx={cx} cy={cy} r={0} fill="transparent" />;
|
||||
}
|
||||
return (
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={7}
|
||||
fill="#fbbf24"
|
||||
stroke="#f59e0b"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{/* 添加日期参考线 */}
|
||||
{addedDataPoint && (
|
||||
<ReferenceLine
|
||||
yAxisId="left"
|
||||
x={addedDataPoint.date}
|
||||
stroke="#f59e0b"
|
||||
strokeDasharray="4 4"
|
||||
opacity={0.7}
|
||||
/>
|
||||
)}
|
||||
{/* 缩放/滑动控件:移动端可双指缩放,拖动选区查看局部 */}
|
||||
<Brush
|
||||
dataKey="date"
|
||||
height={24}
|
||||
stroke="var(--primary)"
|
||||
fill="var(--muted)"
|
||||
travellerWidth={8}
|
||||
gap={8}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap justify-center gap-3 md:gap-6 mt-2 md:mt-4 text-xs md:text-sm">
|
||||
<div className="flex items-center gap-1.5 md:gap-2">
|
||||
<div className="w-2.5 h-2.5 md:w-3 md:h-3 rounded-full bg-primary"></div>
|
||||
<span>收盘价</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 md:gap-2">
|
||||
<div className="w-2.5 h-2.5 md:w-3 md:h-3 rounded-full bg-primary opacity-20"></div>
|
||||
<span>成交量</span>
|
||||
</div>
|
||||
{inCollection && (
|
||||
<div className="flex items-center gap-1.5 md:gap-2">
|
||||
<div className="w-2.5 h-2.5 md:w-3 md:h-3 rounded-full" style={{ backgroundColor: '#fbbf24' }}></div>
|
||||
<span>自选日期</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Price Details */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4 mt-4 md:mt-6">
|
||||
<Card>
|
||||
<CardContent className="p-3 md:p-4 text-center">
|
||||
<p className="text-xs md:text-sm text-muted-foreground mb-1">今开</p>
|
||||
<p className="text-lg md:text-xl font-semibold">¥{stockInfo.todayOpen.toFixed(2)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-3 md:p-4 text-center">
|
||||
<p className="text-xs md:text-sm text-muted-foreground mb-1">最高</p>
|
||||
<p className="text-lg md:text-xl font-semibold">¥{stockInfo.high.toFixed(2)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-3 md:p-4 text-center">
|
||||
<p className="text-xs md:text-sm text-muted-foreground mb-1">最低</p>
|
||||
<p className="text-lg md:text-xl font-semibold">¥{stockInfo.low.toFixed(2)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-3 md:p-4 text-center">
|
||||
<p className="text-xs md:text-sm text-muted-foreground mb-1">昨收</p>
|
||||
<p className="text-lg md:text-xl font-semibold">¥{stockInfo.yesterdayClose.toFixed(2)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Daily Data Table - Independent from fund flow */}
|
||||
{chartData.length > 0 && (
|
||||
<Card className="mt-4 md:mt-6 shadow-lg">
|
||||
<CardHeader className="pb-2 md:pb-4 px-3 md:px-6 pt-4 md:pt-6">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<CardTitle className="text-base md:text-lg">每日行情明细</CardTitle>
|
||||
<Tabs value={String(dailyTableDays)} onValueChange={(v) => setDailyTableDays(Number(v))}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="7">近7日</TabsTrigger>
|
||||
<TabsTrigger value="21">近21日</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
{fundFlowData.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground mt-2">资金流向数据未加载(可能因外部服务限制)</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 md:px-6 pb-3 md:pb-6">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs md:text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left py-2 px-2 font-medium text-muted-foreground">日期</th>
|
||||
<th className="text-right py-2 px-2 font-medium text-muted-foreground">涨幅</th>
|
||||
<th className="text-right py-2 px-2 font-medium text-muted-foreground">主力净流入</th>
|
||||
<th className="text-right py-2 px-2 font-medium text-muted-foreground">成交额</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(() => {
|
||||
// 根据选择的天数截取最近N个交易日的数据并倒序(最新日期在前)
|
||||
const displayData = chartData.slice(-dailyTableDays).reverse();
|
||||
|
||||
return displayData.map((item, idx) => {
|
||||
// 计算涨幅:与后一天收盘价比较(因为已倒序,后一天是更早的日期)
|
||||
let changePercent = 0;
|
||||
if (idx < displayData.length - 1) {
|
||||
const nextClose = displayData[idx + 1].close;
|
||||
if (nextClose > 0) {
|
||||
changePercent = ((item.close - nextClose) / nextClose) * 100;
|
||||
}
|
||||
}
|
||||
const isPositive = changePercent >= 0;
|
||||
|
||||
// 查找对应的资金流向数据
|
||||
// 使用本地时区格式化日期,避免 toISOString() 的 UTC 时区偏移导致日期错位
|
||||
const dateStr = `${item.dateObj.getFullYear()}-${String(item.dateObj.getMonth() + 1).padStart(2, '0')}-${String(item.dateObj.getDate()).padStart(2, '0')}`;
|
||||
const fundFlowItem = fundFlowData.find(f => f.date === dateStr);
|
||||
// 主力净流入:从 fundFlowData 获取,匹配不到则显示 "-"
|
||||
let mainNetInflow: number | null = null;
|
||||
if (fundFlowItem) {
|
||||
mainNetInflow = fundFlowItem.mainForceNet || 0;
|
||||
}
|
||||
// 成交额:优先从 fundFlowData,其次最末行用行情成交额,最后 volume × close 近似
|
||||
const turnover = fundFlowItem?.turnover
|
||||
|| (idx === 0 && stockInfo?.amount ? stockInfo.amount : 0)
|
||||
|| item.volume * item.close;
|
||||
|
||||
return (
|
||||
<tr key={idx} className="border-b border-border/50 hover:bg-muted/20">
|
||||
<td className="py-2 px-2">{item.date}</td>
|
||||
<td className={`text-right py-2 px-2 font-medium ${isPositive ? 'text-red-500' : 'text-green-500'}`}>
|
||||
{isPositive ? '+' : ''}{changePercent.toFixed(2)}%
|
||||
</td>
|
||||
<td className={`text-right py-2 px-2 font-medium ${mainNetInflow !== null ? (mainNetInflow >= 0 ? 'text-red-500' : 'text-green-500') : ''}`}>
|
||||
{mainNetInflow !== null ? formatMoney(mainNetInflow) : '-'}
|
||||
</td>
|
||||
<td className="text-right py-2 px-2">
|
||||
{formatMoney(turnover)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Fund Flow Loading Skeleton */}
|
||||
{fundFlowLoading && fundFlowData.length === 0 && (
|
||||
<Card className="mt-4 md:mt-6 shadow-lg">
|
||||
<CardHeader className="pb-2 md:pb-4 px-3 md:px-6 pt-4 md:pt-6">
|
||||
<CardTitle className="text-base md:text-lg">资金流向分析</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 md:px-6 pb-3 md:pb-6">
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground text-sm">
|
||||
<div className="animate-pulse mr-2 h-2 w-2 rounded-full bg-primary"></div>
|
||||
资金流向数据加载中...
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Fund Flow Section */}
|
||||
{fundFlowData.length > 0 && fundFlowSummary && (
|
||||
<>
|
||||
<div className="mt-4 md:mt-6 space-y-4 md:space-y-6">
|
||||
{/* Period Summary Cards */}
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader className="pb-2 md:pb-4 px-3 md:px-6 pt-4 md:pt-6">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<CardTitle className="text-base md:text-lg">资金流向概览</CardTitle>
|
||||
<Tabs value={String(fundFlowPeriod)} onValueChange={(v) => setFundFlowPeriod(Number(v))}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="5">近5日</TabsTrigger>
|
||||
<TabsTrigger value="21">近21日</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 md:px-6 pb-3 md:pb-6">
|
||||
{fundFlowLocalSummary && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4">
|
||||
<div className="text-center p-3 rounded-lg bg-muted/30">
|
||||
<p className="text-xs text-muted-foreground mb-1">主力总净流入</p>
|
||||
<p className={`text-lg font-bold ${fundFlowLocalSummary.totalMainNet >= 0 ? 'text-red-500' : 'text-green-500'}`}>
|
||||
{formatMoney(fundFlowLocalSummary.totalMainNet)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center p-3 rounded-lg bg-orange-500/5 border border-orange-500/20 relative">
|
||||
<span className="absolute top-1 right-1 text-[10px] text-orange-500 bg-orange-500/10 px-1.5 py-0.5 rounded">≈主买</span>
|
||||
<p className="text-xs text-muted-foreground mb-1">大单流入资金</p>
|
||||
<p className="text-lg font-bold text-red-500">
|
||||
{formatMoney(fundFlowLocalSummary.totalLargeInflow ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center p-3 rounded-lg bg-muted/30">
|
||||
<p className="text-xs text-muted-foreground mb-1">日均主力流入</p>
|
||||
<p className={`text-lg font-bold ${fundFlowLocalSummary.avgDailyMainNet >= 0 ? 'text-red-500' : 'text-green-500'}`}>
|
||||
{formatMoney(fundFlowLocalSummary.avgDailyMainNet)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center p-3 rounded-lg bg-muted/30">
|
||||
<p className="text-xs text-muted-foreground mb-1">主力流入天数</p>
|
||||
<p className="text-lg font-bold text-red-500">{fundFlowLocalSummary.positiveDays}天</p>
|
||||
<p className="text-xs text-muted-foreground">流出{fundFlowLocalSummary.negativeDays}天</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Main Force Net Inflow Chart */}
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader className="pb-2 md:pb-4 px-3 md:px-6 pt-4 md:pt-6">
|
||||
<CardTitle className="text-base md:text-lg">主力资金流向</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 md:px-6 pb-3 md:pb-6">
|
||||
<div className="h-[280px] sm:h-[320px] md:h-[360px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart
|
||||
data={fundFlowSlice.map(d => ({
|
||||
...d,
|
||||
dateShort: d.date.slice(5),
|
||||
mainForceNet: d.mainForceNet,
|
||||
}))}
|
||||
margin={{ top: 10, right: 8, left: 0, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" opacity={0.5} />
|
||||
<XAxis
|
||||
dataKey="dateShort"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={10}
|
||||
tickCount={6}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={56}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={10}
|
||||
tickFormatter={(value) => {
|
||||
return (value / 100000000).toFixed(2) + '亿';
|
||||
}}
|
||||
width={60}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "8px",
|
||||
fontSize: "12px",
|
||||
padding: "8px 12px",
|
||||
}}
|
||||
labelFormatter={(label) => `日期: ${label}`}
|
||||
formatter={(value: any, name: string) => {
|
||||
const numValue = Number(value);
|
||||
if (name === 'mainForceNet' || name === '主力') return [formatMoney(numValue), '主力净额'];
|
||||
return [value, name];
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="mainForceNet" opacity={0.6} name="主力">
|
||||
{fundFlowSlice.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.mainForceNet >= 0 ? '#ef4444' : '#22c55e'} />
|
||||
))}
|
||||
</Bar>
|
||||
<ReferenceLine y={0} stroke="var(--border)" strokeWidth={1} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="flex justify-center gap-4 mt-3 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-sm bg-red-500"></div>
|
||||
<span>净流入</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded-sm bg-green-500"></div>
|
||||
<span>净流出</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Cumulative Main Force Trend */}
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader className="pb-2 md:pb-4 px-3 md:px-6 pt-4 md:pt-6">
|
||||
<CardTitle className="text-base md:text-lg">累计主力资金趋势</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 md:px-6 pb-3 md:pb-6">
|
||||
<div className="h-[280px] sm:h-[320px] md:h-[360px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart
|
||||
data={fundFlowSlice.map(d => ({
|
||||
...d,
|
||||
dateShort: d.date.slice(5),
|
||||
cumulativeMainNet: d.cumulativeMainNet,
|
||||
}))}
|
||||
margin={{ top: 10, right: 8, left: 0, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" opacity={0.5} />
|
||||
<XAxis
|
||||
dataKey="dateShort"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={10}
|
||||
tickCount={6}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={56}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={10}
|
||||
tickFormatter={(value) => {
|
||||
return (value / 100000000).toFixed(2) + '亿';
|
||||
}}
|
||||
width={60}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "8px",
|
||||
fontSize: "12px",
|
||||
padding: "8px 12px",
|
||||
}}
|
||||
labelFormatter={(label) => `日期: ${label}`}
|
||||
formatter={(value: any, name: string, props: any) => {
|
||||
const numValue = Number(value);
|
||||
if (props?.dataKey === 'cumulativeMainNet') {
|
||||
return [(numValue / 100000000).toFixed(2) + '亿', '累计主力'];
|
||||
}
|
||||
return [value, name];
|
||||
}}
|
||||
/>
|
||||
{(() => {
|
||||
const vals = fundFlowSlice.map(d => d.cumulativeMainNet);
|
||||
const mx = Math.max(...vals, 0);
|
||||
const mn = Math.min(...vals, 0);
|
||||
const rng = mx - mn;
|
||||
const zeroPct = rng > 0 ? (mx / rng * 100) : 50;
|
||||
return (
|
||||
<defs>
|
||||
<linearGradient id="cumulativeLineGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#ef4444" />
|
||||
<stop offset={`${zeroPct}%`} stopColor="#ef4444" />
|
||||
<stop offset={`${zeroPct}%`} stopColor="#22c55e" />
|
||||
<stop offset="100%" stopColor="#22c55e" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
);
|
||||
})()}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="cumulativeMainNet"
|
||||
stroke="url(#cumulativeLineGradient)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
name="累计主力"
|
||||
/>
|
||||
<ReferenceLine y={0} stroke="var(--border)" strokeWidth={1} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="flex justify-center gap-4 mt-3 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-0.5 bg-red-500"></div>
|
||||
<span>累计主力</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user