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