262 lines
8.9 KiB
TypeScript
Executable File
262 lines
8.9 KiB
TypeScript
Executable File
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>
|
|
);
|
|
}
|