up
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user