支持删除集合条目
This commit is contained in:
@@ -95,6 +95,12 @@ export const collectionsApi = {
|
||||
body: JSON.stringify(stock),
|
||||
});
|
||||
},
|
||||
|
||||
removeStock(collectionId: number, stockCode: string): Promise<{ success: boolean }> {
|
||||
return apiFetch(`/api/collections/${collectionId}/stocks/${stockCode}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// 分享链接 API
|
||||
|
||||
+273
-83
@@ -1,5 +1,5 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
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";
|
||||
@@ -7,11 +7,11 @@ 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, TrendingUp, TrendingDown, Trash2, Loader2 } from "lucide-react";
|
||||
import { Search, Plus, Share2, Trash2, TrendingUp, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: Index,
|
||||
@@ -192,21 +192,16 @@ function Index() {
|
||||
}
|
||||
};
|
||||
|
||||
const deleteCollection = async (collectionId: number, collectionName: string) => {
|
||||
if (!confirm(`确定要删除集合"${collectionName}"吗?此操作不可恢复。`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
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">
|
||||
@@ -390,13 +385,16 @@ function CollectionCard({
|
||||
refreshKey
|
||||
}: {
|
||||
collection: StockCollection;
|
||||
onDelete: (id: number, name: string) => void;
|
||||
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();
|
||||
@@ -408,14 +406,12 @@ function CollectionCard({
|
||||
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(),
|
||||
@@ -448,13 +444,11 @@ function CollectionCard({
|
||||
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';
|
||||
@@ -475,9 +469,28 @@ function CollectionCard({
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
<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">
|
||||
@@ -487,7 +500,7 @@ function CollectionCard({
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onDelete(collection.id, collection.name)}
|
||||
onClick={() => setShowDeleteCollectionAlert(true)}
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10 p-2"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
@@ -503,70 +516,18 @@ function CollectionCard({
|
||||
) : 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>
|
||||
);
|
||||
})}
|
||||
<div className="space-y-1">
|
||||
{stocks.slice(0, 10).map((stock) => (
|
||||
<StockRowItem
|
||||
key={stock.id}
|
||||
stock={stock}
|
||||
quote={stockQuotes.get(stock.stock_code)}
|
||||
onRequestDelete={setStockToDelete}
|
||||
swipeResetKey={swipeResetKey}
|
||||
/>
|
||||
))}
|
||||
{stocks.length > 10 && (
|
||||
<p className="text-xs text-muted-foreground text-center">还有 {stocks.length - 10} 只股票...</p>
|
||||
<p className="text-xs text-muted-foreground text-center pt-1">还有 {stocks.length - 10} 只股票...</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -575,12 +536,10 @@ function CollectionCard({
|
||||
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';
|
||||
@@ -602,6 +561,237 @@ function CollectionCard({
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user