Files
auv/src/routes/index.tsx
T
SakurasanandClaude 90569918a3 refactor: 删除板块资金流向,题材板块功能更全
- 删除前端 /sectors 页面、首页入口按钮、stock-api 板块类型与 fetchSectors
- 删除后端 /api/sectors 路由,main.py 移除注册
- eastmoney.py 移除板块数据段(_fetch_push2/_fetch_akshare/UT令牌管理),
  清理重复 import 与无用 datetime 子导入
- mootdx.py 移除板块降级方案(fetch_sector_list)
- routeTree.gen.ts 由 build 自动重新生成,移除 sectors 路由

题材热点/热点穿透/核心股已覆盖板块能力,功能更全,板块资金流向不再需要

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 18:50:54 +08:00

806 lines
29 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createFileRoute, Link } from "@tanstack/react-router";
import { useState, useEffect, useRef, useCallback } from "react";
import { collectionsApi, sharesApi } from "@/lib/api-client";
import { fetchStockQuote, fetchStockSearch, getStockBoard, type StockQuote, type StockSearchResult } from "@/lib/stock-api";
import { getUserId, updateLastAccessed } 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 { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Search, Plus, Share2, Trash2, TrendingUp, Loader2, Flame, Network } from "lucide-react";
import { toast } from "sonner";
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 = useCallback(async (collectionId: number) => {
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>
<div className="mt-3 flex items-center justify-center gap-2">
<Link to="/themes">
<Button variant="outline" size="sm" className="gap-1.5 text-xs">
<Flame className="h-3.5 w-3.5" />
题材热点
</Button>
</Link>
<Link to="/hot-map">
<Button variant="outline" size="sm" className="gap-1.5 text-xs">
<Network className="h-3.5 w-3.5" />
热点穿透
</Button>
</Link>
</div>
</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) => 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);
const [stockToDelete, setStockToDelete] = useState<CollectionStock | null>(null);
const [showDeleteCollectionAlert, setShowDeleteCollectionAlert] = useState(false);
const [swipeResetKey, setSwipeResetKey] = useState(0);
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("生成分享链接失败");
}
};
const handleRemoveStock = async () => {
if (!stockToDelete) return;
try {
await collectionsApi.removeStock(collection.id, stockToDelete.stock_code);
toast.success(`已移除 ${stockToDelete.stock_name}`);
setStockToDelete(null);
loadStocks();
} catch (err) {
console.error("移除股票失败:", err);
toast.error("移除股票失败");
}
};
return (
<Card className="hover:shadow-lg transition-shadow">
<div className="p-4 md:p-6" onClick={(e) => {
// 点击卡片空白区域关闭所有滑出的条目
const t = e.target as HTMLElement;
if (!t.closest('button') && !t.closest('a')) {
setSwipeResetKey(k => k + 1);
}
}}>
<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={() => setShowDeleteCollectionAlert(true)}
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-1">
{stocks.slice(0, 20).map((stock) => (
<StockRowItem
key={stock.id}
stock={stock}
quote={stockQuotes.get(stock.stock_code)}
onRequestDelete={setStockToDelete}
swipeResetKey={swipeResetKey}
/>
))}
{stocks.length > 20 && (
<p className="text-xs text-muted-foreground text-center pt-1">还有 {stocks.length - 20} 只股票...</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 {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(shareLink);
toast.success("链接已复制到剪贴板");
} else {
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>
<AlertDialog open={!!stockToDelete} onOpenChange={(open) => !open && setStockToDelete(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认移除</AlertDialogTitle>
<AlertDialogDescription>
确定要移除股票「{stockToDelete?.stock_name}({stockToDelete?.stock_code}) 吗?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={handleRemoveStock} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
移除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={showDeleteCollectionAlert} onOpenChange={setShowDeleteCollectionAlert}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除集合</AlertDialogTitle>
<AlertDialogDescription>
确定要删除集合「{collection.name}」吗?集合内的所有股票将一并移除,此操作不可恢复。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={() => { setShowDeleteCollectionAlert(false); onDelete(collection.id); }} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Card>
);
}
function StockRowItem({
stock,
quote,
onRequestDelete,
swipeResetKey,
}: {
stock: CollectionStock;
quote: StockQuote | undefined;
onRequestDelete: (stock: CollectionStock) => void;
swipeResetKey: number;
}) {
const [swipedX, setSwipedX] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const touchOriginX = useRef(0);
const touchOriginY = useRef(0);
const isHorizontal = useRef(false);
const swipeXRef = useRef(0);
const startOffsetRef = useRef(0);
const wasSwiped = useRef(false);
const DELETE_WIDTH = 80;
const SWIPE_THRESH = 40;
const resetSwipe = () => {
setSwipedX(0);
swipeXRef.current = 0;
};
const snapOpen = () => {
setSwipedX(DELETE_WIDTH);
swipeXRef.current = DELETE_WIDTH;
};
useEffect(() => {
resetSwipe();
}, [swipeResetKey]);
const currentPrice = quote?.currentPrice;
const addedPrice = stock.added_price;
let changePercent = 0;
let hasChangeData = false;
if (addedPrice && addedPrice > 0 && currentPrice) {
changePercent = ((currentPrice - addedPrice) / addedPrice) * 100;
hasChangeData = true;
}
const stockIsPositive = changePercent >= 0;
const onTouchStart = (e: React.TouchEvent) => {
wasSwiped.current = false;
touchOriginX.current = e.touches[0].clientX;
touchOriginY.current = e.touches[0].clientY;
isHorizontal.current = false;
startOffsetRef.current = swipeXRef.current;
};
const onTouchMove = (e: React.TouchEvent) => {
const dx = touchOriginX.current - e.touches[0].clientX;
if (!isHorizontal.current) {
const dy = Math.abs(touchOriginY.current - e.touches[0].clientY);
if (Math.abs(dx) < 10 || dy > Math.abs(dx)) return;
isHorizontal.current = true;
setIsDragging(true);
}
e.preventDefault();
const next = Math.max(0, Math.min(DELETE_WIDTH, startOffsetRef.current + dx));
swipeXRef.current = next;
setSwipedX(next);
};
const onTouchEnd = () => {
setIsDragging(false);
if (swipeXRef.current >= SWIPE_THRESH) {
wasSwiped.current = true;
snapOpen();
} else {
resetSwipe();
}
};
const onMouseDown = (e: React.MouseEvent) => {
wasSwiped.current = false;
touchOriginX.current = e.clientX;
touchOriginY.current = e.clientY;
isHorizontal.current = false;
startOffsetRef.current = swipeXRef.current;
};
const onMouseMove = (e: React.MouseEvent) => {
if (e.buttons !== 1) return;
const dx = touchOriginX.current - e.clientX;
if (!isHorizontal.current) {
const dy = Math.abs(touchOriginY.current - e.clientY);
if (Math.abs(dx) < 10 || dy > Math.abs(dx)) return;
isHorizontal.current = true;
setIsDragging(true);
}
const next = Math.max(0, Math.min(DELETE_WIDTH, startOffsetRef.current + dx));
swipeXRef.current = next;
setSwipedX(next);
};
const onMouseUp = () => {
if (!isHorizontal.current) return;
setIsDragging(false);
if (swipeXRef.current >= SWIPE_THRESH) {
wasSwiped.current = true;
snapOpen();
} else {
resetSwipe();
}
};
const board = getStockBoard(stock.stock_code);
return (
<>
<div className="relative overflow-hidden rounded">
{/* 删除按钮(在条目背后右侧) */}
<div className="absolute right-0 top-0 bottom-0 flex items-center pr-2">
<Button
variant="destructive"
size="sm"
className="h-9 gap-1 px-3 rounded-md text-xs font-medium"
onClick={() => onRequestDelete(stock)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
{/* 可滑动内容 */}
<div
className="relative bg-card"
style={{
transform: `translateX(-${swipedX}px)`,
transition: isDragging ? 'none' : 'transform 0.2s ease-out',
}}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
onMouseLeave={onMouseUp}
>
<Link
to="/stock/$code"
params={{ code: stock.stock_code }}
className="block p-2 rounded hover:bg-accent/50 transition-colors cursor-pointer"
onClick={(e) => {
if (wasSwiped.current) {
e.preventDefault();
wasSwiped.current = false;
}
}}
>
<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>
{board.label && (
<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>
</div>
</div>
</>
);
}