This commit is contained in:
Sakurasan
2026-07-05 21:29:17 +08:00
commit 013ebdaffe
150 changed files with 15614 additions and 0 deletions
+607
View File
@@ -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>
);
}