支持删除集合条目
This commit is contained in:
@@ -95,6 +95,12 @@ export const collectionsApi = {
|
|||||||
body: JSON.stringify(stock),
|
body: JSON.stringify(stock),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
removeStock(collectionId: number, stockCode: string): Promise<{ success: boolean }> {
|
||||||
|
return apiFetch(`/api/collections/${collectionId}/stocks/${stockCode}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 分享链接 API
|
// 分享链接 API
|
||||||
|
|||||||
+272
-82
@@ -1,5 +1,5 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { collectionsApi, sharesApi } from "@/lib/api-client";
|
import { collectionsApi, sharesApi } from "@/lib/api-client";
|
||||||
import { fetchStockQuote, fetchStockSearch, getStockBoard, type StockQuote, type StockSearchResult } from "@/lib/stock-api";
|
import { fetchStockQuote, fetchStockSearch, getStockBoard, type StockQuote, type StockSearchResult } from "@/lib/stock-api";
|
||||||
import { getUserId, updateLastAccessed } from "@/lib/user-id";
|
import { getUserId, updateLastAccessed } from "@/lib/user-id";
|
||||||
@@ -7,11 +7,11 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
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 { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
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 { toast } from "sonner";
|
||||||
import { Link } from "@tanstack/react-router";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/")({
|
export const Route = createFileRoute("/")({
|
||||||
component: Index,
|
component: Index,
|
||||||
@@ -192,21 +192,16 @@ function Index() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteCollection = async (collectionId: number, collectionName: string) => {
|
const deleteCollection = useCallback(async (collectionId: number) => {
|
||||||
if (!confirm(`确定要删除集合"${collectionName}"吗?此操作不可恢复。`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await collectionsApi.delete(collectionId);
|
await collectionsApi.delete(collectionId);
|
||||||
|
|
||||||
toast.success("删除成功");
|
toast.success("删除成功");
|
||||||
loadCollections();
|
loadCollections();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("删除失败:", err);
|
console.error("删除失败:", err);
|
||||||
toast.error("删除集合失败");
|
toast.error("删除集合失败");
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-background via-muted/30 to-background">
|
<div className="min-h-screen bg-gradient-to-br from-background via-muted/30 to-background">
|
||||||
@@ -390,13 +385,16 @@ function CollectionCard({
|
|||||||
refreshKey
|
refreshKey
|
||||||
}: {
|
}: {
|
||||||
collection: StockCollection;
|
collection: StockCollection;
|
||||||
onDelete: (id: number, name: string) => void;
|
onDelete: (id: number) => void;
|
||||||
refreshKey: number;
|
refreshKey: number;
|
||||||
}) {
|
}) {
|
||||||
const [stocks, setStocks] = useState<CollectionStock[]>([]);
|
const [stocks, setStocks] = useState<CollectionStock[]>([]);
|
||||||
const [stockQuotes, setStockQuotes] = useState<Map<string, StockQuote>>(new Map());
|
const [stockQuotes, setStockQuotes] = useState<Map<string, StockQuote>>(new Map());
|
||||||
const [shareLink, setShareLink] = useState<string | null>(null);
|
const [shareLink, setShareLink] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [stockToDelete, setStockToDelete] = useState<CollectionStock | null>(null);
|
||||||
|
const [showDeleteCollectionAlert, setShowDeleteCollectionAlert] = useState(false);
|
||||||
|
const [swipeResetKey, setSwipeResetKey] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadStocks();
|
loadStocks();
|
||||||
@@ -408,14 +406,12 @@ function CollectionCard({
|
|||||||
const data = await collectionsApi.listStocks(collection.id);
|
const data = await collectionsApi.listStocks(collection.id);
|
||||||
setStocks(data || []);
|
setStocks(data || []);
|
||||||
|
|
||||||
// 批量获取实时行情
|
|
||||||
if (data && data.length > 0) {
|
if (data && data.length > 0) {
|
||||||
const codes = data.map((s: CollectionStock) => s.stock_code);
|
const codes = data.map((s: CollectionStock) => s.stock_code);
|
||||||
const quotes = await fetchStockQuotesFromCodes(codes);
|
const quotes = await fetchStockQuotesFromCodes(codes);
|
||||||
setStockQuotes(quotes);
|
setStockQuotes(quotes);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新最后访问时间
|
|
||||||
updateLastAccessed(collection.id);
|
updateLastAccessed(collection.id);
|
||||||
await collectionsApi.update(collection.id, {
|
await collectionsApi.update(collection.id, {
|
||||||
last_accessed_at: new Date().toISOString(),
|
last_accessed_at: new Date().toISOString(),
|
||||||
@@ -448,13 +444,11 @@ function CollectionCard({
|
|||||||
const link = `${window.location.origin}/share/${short_code}`;
|
const link = `${window.location.origin}/share/${short_code}`;
|
||||||
setShareLink(link);
|
setShareLink(link);
|
||||||
|
|
||||||
// 尝试复制链接,失败不影响生成链接的成功
|
|
||||||
try {
|
try {
|
||||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
await navigator.clipboard.writeText(link);
|
await navigator.clipboard.writeText(link);
|
||||||
toast.success("分享链接已复制到剪贴板");
|
toast.success("分享链接已复制到剪贴板");
|
||||||
} else {
|
} else {
|
||||||
// 降级方案
|
|
||||||
const textarea = document.createElement('textarea');
|
const textarea = document.createElement('textarea');
|
||||||
textarea.value = link;
|
textarea.value = link;
|
||||||
textarea.style.position = 'fixed';
|
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 (
|
return (
|
||||||
<Card className="hover:shadow-lg transition-shadow">
|
<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">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<h3 className="font-semibold text-base md:text-lg truncate mr-2">{collection.name}</h3>
|
<h3 className="font-semibold text-base md:text-lg truncate mr-2">{collection.name}</h3>
|
||||||
<div className="flex gap-1 shrink-0">
|
<div className="flex gap-1 shrink-0">
|
||||||
@@ -487,7 +500,7 @@ function CollectionCard({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => onDelete(collection.id, collection.name)}
|
onClick={() => setShowDeleteCollectionAlert(true)}
|
||||||
className="text-destructive hover:text-destructive hover:bg-destructive/10 p-2"
|
className="text-destructive hover:text-destructive hover:bg-destructive/10 p-2"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
@@ -503,70 +516,18 @@ function CollectionCard({
|
|||||||
) : stocks.length === 0 ? (
|
) : stocks.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground text-center py-4">暂无股票</p>
|
<p className="text-sm text-muted-foreground text-center py-4">暂无股票</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
{stocks.slice(0, 10).map((stock) => {
|
{stocks.slice(0, 10).map((stock) => (
|
||||||
const quote = stockQuotes.get(stock.stock_code);
|
<StockRowItem
|
||||||
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}
|
key={stock.id}
|
||||||
to="/stock/$code"
|
stock={stock}
|
||||||
params={{ code: stock.stock_code }}
|
quote={stockQuotes.get(stock.stock_code)}
|
||||||
className="block p-2 rounded hover:bg-accent/50 transition-colors cursor-pointer"
|
onRequestDelete={setStockToDelete}
|
||||||
>
|
swipeResetKey={swipeResetKey}
|
||||||
<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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{stocks.length > 10 && (
|
{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>
|
</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"
|
className="mt-4 p-2 bg-muted rounded text-xs break-all cursor-pointer hover:bg-accent/50 transition-colors"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
// 尝试使用现代 Clipboard API
|
|
||||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
await navigator.clipboard.writeText(shareLink);
|
await navigator.clipboard.writeText(shareLink);
|
||||||
toast.success("链接已复制到剪贴板");
|
toast.success("链接已复制到剪贴板");
|
||||||
} else {
|
} else {
|
||||||
// 降级方案:使用 textarea 方法
|
|
||||||
const textarea = document.createElement('textarea');
|
const textarea = document.createElement('textarea');
|
||||||
textarea.value = shareLink;
|
textarea.value = shareLink;
|
||||||
textarea.style.position = 'fixed';
|
textarea.style.position = 'fixed';
|
||||||
@@ -602,6 +561,237 @@ function CollectionCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</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