- 删除前端 /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>
806 lines
29 KiB
TypeScript
Executable File
806 lines
29 KiB
TypeScript
Executable File
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>
|
||
</>
|
||
);
|
||
}
|