支持删除集合条目

This commit is contained in:
Sakurasan
2026-07-07 15:05:19 +08:00
parent 5153fa28de
commit 0ba7952082
2 changed files with 279 additions and 83 deletions
+6
View File
@@ -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
View File
@@ -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>
</>
);
}