From 0ba7952082cfac4a0d91f4fe3b3de8a4f5cd13b6 Mon Sep 17 00:00:00 2001 From: Sakurasan <26715255+Sakurasan@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:05:19 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=88=A0=E9=99=A4=E9=9B=86?= =?UTF-8?q?=E5=90=88=E6=9D=A1=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/api-client.ts | 6 + src/routes/index.tsx | 356 ++++++++++++++++++++++++++++++++---------- 2 files changed, 279 insertions(+), 83 deletions(-) diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index b618731..00a61c7 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -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 diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 419e750..2271412 100755 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -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 (
@@ -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([]); const [stockQuotes, setStockQuotes] = useState>(new Map()); const [shareLink, setShareLink] = useState(null); const [loading, setLoading] = useState(false); + const [stockToDelete, setStockToDelete] = useState(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 ( -
+
{ + // 点击卡片空白区域关闭所有滑出的条目 + const t = e.target as HTMLElement; + if (!t.closest('button') && !t.closest('a')) { + setSwipeResetKey(k => k + 1); + } + }}>

{collection.name}

@@ -487,7 +500,7 @@ function CollectionCard({
+ + !open && setStockToDelete(null)}> + + + 确认移除 + + 确定要移除股票「{stockToDelete?.stock_name}」({stockToDelete?.stock_code}) 吗? + + + + 取消 + + 移除 + + + + + + + + + 确认删除集合 + + 确定要删除集合「{collection.name}」吗?集合内的所有股票将一并移除,此操作不可恢复。 + + + + 取消 + { setShowDeleteCollectionAlert(false); onDelete(collection.id); }} className="bg-destructive text-destructive-foreground hover:bg-destructive/90"> + 删除 + + + + ); } + +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 ( + <> +
+ {/* 删除按钮(在条目背后右侧) */} +
+ +
+ + {/* 可滑动内容 */} +
+ { + if (wasSwiped.current) { + e.preventDefault(); + wasSwiped.current = false; + } + }} + > +
+
+

{stock.stock_name}

+
+

{stock.stock_code}

+ {board.label && ( + + {board.label} + + )} +
+
+
+
+

自选

+

{addedPrice ? `¥${addedPrice.toFixed(2)}` : currentPrice ? `¥${currentPrice.toFixed(2)}` : "-"}

+
+
+

涨跌

+

+ {hasChangeData ? `${stockIsPositive ? "+" : ""}${changePercent.toFixed(2)}%` : "0.00%"} +

+
+
+

最新

+

{currentPrice ? `¥${currentPrice.toFixed(2)}` : "-"}

+
+
+
+ +
+
+ + ); +}