haha
This commit is contained in:
10
frontend/pnpm-lock.yaml
generated
10
frontend/pnpm-lock.yaml
generated
@@ -139,6 +139,9 @@ importers:
|
||||
next-themes:
|
||||
specifier: ^0.4.6
|
||||
version: 0.4.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
||||
pnpm:
|
||||
specifier: ^10.28.2
|
||||
version: 10.28.2
|
||||
qrcode.react:
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0(react@19.2.1)
|
||||
@@ -1899,6 +1902,11 @@ packages:
|
||||
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
pnpm@10.28.2:
|
||||
resolution: {integrity: sha512-QYcvA3rSL3NI47Heu69+hnz9RI8nJtnPdMCPGVB8MdLI56EVJbmD/rwt9kC1Q43uYCPrsfhO1DzC1lTSvDJiZA==}
|
||||
engines: {node: '>=18.12'}
|
||||
hasBin: true
|
||||
|
||||
postcss-selector-parser@6.0.10:
|
||||
resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -3747,6 +3755,8 @@ snapshots:
|
||||
|
||||
picomatch@4.0.3: {}
|
||||
|
||||
pnpm@10.28.2: {}
|
||||
|
||||
postcss-selector-parser@6.0.10:
|
||||
dependencies:
|
||||
cssesc: 3.0.0
|
||||
|
||||
@@ -11,13 +11,13 @@ import { Loader2 } from "lucide-react";
|
||||
const Login = lazy(() => import("@/features/auth/pages/Login"));
|
||||
const Products = lazy(() => import("@/features/products/pages/Products"));
|
||||
const ProductDetail = lazy(() => import("@/features/products/pages/ProductDetail"));
|
||||
const ComparisonTagDetail = lazy(() => import("@/features/products/pages/ComparisonTagDetail"));
|
||||
const Bounties = lazy(() => import("@/features/bounties/pages/Bounties"));
|
||||
const BountyDetail = lazy(() => import("@/features/bounties/pages/BountyDetail"));
|
||||
const Dashboard = lazy(() => import("@/features/dashboard/pages/Dashboard"));
|
||||
const Favorites = lazy(() => import("@/features/favorites/pages/Favorites"));
|
||||
const ProductComparison = lazy(() => import("@/features/products/pages/ProductComparison"));
|
||||
const Admin = lazy(() => import("@/features/admin/pages/Admin"));
|
||||
const Search = lazy(() => import("@/features/search/pages/Search"));
|
||||
const Settings = lazy(() => import("@/features/settings/pages/Settings"));
|
||||
const NotFound = lazy(() => import("@/features/common/pages/NotFound"));
|
||||
|
||||
@@ -37,12 +37,12 @@ function Router() {
|
||||
<Route path="/login" component={Login} />
|
||||
<Route path="/products" component={Products} />
|
||||
<Route path="/products/:id" component={ProductDetail} />
|
||||
<Route path="/compare-tags/:slug" component={ComparisonTagDetail} />
|
||||
<Route path="/bounties" component={Bounties} />
|
||||
<Route path="/bounties/:id" component={BountyDetail} />
|
||||
<Route path="/dashboard" component={Dashboard} />
|
||||
<Route path="/favorites" component={Favorites} />
|
||||
<Route path="/comparison" component={ProductComparison} />
|
||||
<Route path="/search" component={Search} />
|
||||
<Route path="/admin" component={Admin} />
|
||||
<Route path="/settings" component={Settings} />
|
||||
<Route path="/404" component={NotFound} />
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Link, useLocation } from "wouter";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Sparkles, Menu, X, ShoppingBag, Trophy, Search, User, Heart, LogOut } from "lucide-react";
|
||||
import { Sparkles, Menu, X, ShoppingBag, Trophy, User, Heart, LogOut } from "lucide-react";
|
||||
import { useUnreadNotificationCount } from "@/hooks/useApi";
|
||||
|
||||
export function MobileNav() {
|
||||
@@ -16,7 +16,6 @@ export function MobileNav() {
|
||||
const navItems = [
|
||||
{ href: "/products", label: "商品导航", icon: ShoppingBag },
|
||||
{ href: "/bounties", label: "悬赏大厅", icon: Trophy },
|
||||
{ href: "/search", label: "全文搜索", icon: Search },
|
||||
];
|
||||
|
||||
const handleLogout = async () => {
|
||||
|
||||
@@ -34,9 +34,6 @@ export function Navbar({ children, showLinks = true }: NavbarProps) {
|
||||
<Link href="/bounties" className={location === "/bounties" ? "text-foreground font-medium" : "text-muted-foreground hover:text-foreground transition-colors"}>
|
||||
悬赏大厅
|
||||
</Link>
|
||||
<Link href="/search" className={location === "/search" ? "text-foreground font-medium" : "text-muted-foreground hover:text-foreground transition-colors"}>
|
||||
全文搜索
|
||||
</Link>
|
||||
{isAuthenticated && (
|
||||
<Link href="/dashboard" className={location === "/dashboard" ? "text-foreground font-medium" : "text-muted-foreground hover:text-foreground transition-colors"}>
|
||||
个人中心
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useAdminUsers, useUpdateAdminUser, useAdminBounties, useAdminPayments, useAdminDisputes, useResolveDispute, useAdminPendingProducts, useReviewProduct, useUpdateAdminProductImages } from "@/hooks/useApi";
|
||||
import { useAdminUsers, useUpdateAdminUser, useAdminBounties, useAdminPayments, useAdminDisputes, useResolveDispute, useAdminPendingProducts, useReviewProduct, useUpdateAdminProductImages, useAdminComparisonTags, useCreateAdminComparisonTag, useUpdateAdminComparisonTag, useDeleteAdminComparisonTag, useAdminComparisonTagItems, useAddAdminComparisonTagItems, useUpdateAdminComparisonTagItem, useDeleteAdminComparisonTagItem, useAdminSimpleProducts, useBackfillPriceHistory, useBackfillTagPriceHistory, useRecordPriceHistory, useRecordTagPriceHistory } from "@/hooks/useApi";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
import { Loader2, Package, Users, Trophy, CreditCard, AlertTriangle, Star, Trash2, Image as ImageIcon } from "lucide-react";
|
||||
import { Loader2, Package, Users, Trophy, CreditCard, AlertTriangle, Star, Trash2, Image as ImageIcon, Tags } from "lucide-react";
|
||||
import { useLocation } from "wouter";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -14,9 +14,10 @@ import { getErrorCopy } from "@/lib/i18n/errorMessages";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { zhCN } from "date-fns/locale";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { productApi, type AdminProduct } from "@/lib/api";
|
||||
import { productApi, type AdminProduct, type AdminComparisonTag } from "@/lib/api";
|
||||
import SortableGrid, { HeroDropzone } from "@/components/SortableGrid";
|
||||
import FileThumbnail from "@/components/FileThumbnail";
|
||||
import { createImageFileItems, processImageFile, type ImageFileItem } from "@/lib/image";
|
||||
@@ -45,6 +46,23 @@ const createId = () => {
|
||||
|
||||
const createImageUrlItems = (urls: string[]) => urls.map((url) => ({ id: createId(), url }));
|
||||
|
||||
const slugify = (value: string) => {
|
||||
const base = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^a-z0-9-]/g, "");
|
||||
return base || `tag-${Date.now()}`;
|
||||
};
|
||||
|
||||
const parseProductIdsFromText = (text: string) => {
|
||||
return text
|
||||
.replace(/\r\n/g, "\n")
|
||||
.split(/[\n,;]+/)
|
||||
.map((v) => Number(v.trim()))
|
||||
.filter((v) => !Number.isNaN(v) && v > 0);
|
||||
};
|
||||
|
||||
export default function Admin() {
|
||||
const { user, isAuthenticated, loading } = useAuth();
|
||||
const [, navigate] = useLocation();
|
||||
@@ -63,6 +81,26 @@ export default function Admin() {
|
||||
const MAX_IMAGES = 6;
|
||||
const urlHeroId = "admin-image-url-hero";
|
||||
const uploadHeroId = "admin-image-upload-hero";
|
||||
const [tagDialogOpen, setTagDialogOpen] = useState(false);
|
||||
const [editingTag, setEditingTag] = useState<AdminComparisonTag | null>(null);
|
||||
const [tagForm, setTagForm] = useState({
|
||||
name: "",
|
||||
slug: "",
|
||||
description: "",
|
||||
cover_image: "",
|
||||
icon: "",
|
||||
sort_order: 0,
|
||||
is_active: true,
|
||||
});
|
||||
const [itemsDialogOpen, setItemsDialogOpen] = useState(false);
|
||||
const [activeTag, setActiveTag] = useState<AdminComparisonTag | null>(null);
|
||||
const [selectedProductId, setSelectedProductId] = useState("");
|
||||
const [tagItemEdits, setTagItemEdits] = useState<Record<number, { sort_order: number; is_pinned: boolean }>>({});
|
||||
const [batchProductIds, setBatchProductIds] = useState("");
|
||||
const [sortableItems, setSortableItems] = useState<typeof tagItems>([]);
|
||||
const [isSortMode, setIsSortMode] = useState(false);
|
||||
const [isSavingSort, setIsSavingSort] = useState(false);
|
||||
const [forceRecordHistory, setForceRecordHistory] = useState(false);
|
||||
const moveUrlToFront = (activeId: string) => {
|
||||
setEditingImages((prev) => {
|
||||
const index = prev.findIndex((item) => item.id === activeId);
|
||||
@@ -106,10 +144,25 @@ export default function Admin() {
|
||||
const { data: paymentsData, isLoading: paymentsLoading } = useAdminPayments();
|
||||
const { data: disputesData, isLoading: disputesLoading } = useAdminDisputes();
|
||||
const { data: pendingProductsData, isLoading: pendingProductsLoading } = useAdminPendingProducts();
|
||||
const { data: compareTagsData, isLoading: compareTagsLoading } = useAdminComparisonTags();
|
||||
const { data: adminProductsData } = useAdminSimpleProducts();
|
||||
const { data: tagItemsData, isLoading: tagItemsLoading } = useAdminComparisonTagItems(activeTag?.id || 0, {
|
||||
enabled: itemsDialogOpen && !!activeTag,
|
||||
});
|
||||
const updateUserMutation = useUpdateAdminUser();
|
||||
const resolveDisputeMutation = useResolveDispute();
|
||||
const reviewProductMutation = useReviewProduct();
|
||||
const updateProductImagesMutation = useUpdateAdminProductImages();
|
||||
const createTagMutation = useCreateAdminComparisonTag();
|
||||
const updateTagMutation = useUpdateAdminComparisonTag();
|
||||
const deleteTagMutation = useDeleteAdminComparisonTag();
|
||||
const addTagItemsMutation = useAddAdminComparisonTagItems();
|
||||
const updateTagItemMutation = useUpdateAdminComparisonTagItem();
|
||||
const deleteTagItemMutation = useDeleteAdminComparisonTagItem();
|
||||
const backfillHistoryMutation = useBackfillPriceHistory();
|
||||
const backfillTagHistoryMutation = useBackfillTagPriceHistory();
|
||||
const recordHistoryMutation = useRecordPriceHistory();
|
||||
const recordTagHistoryMutation = useRecordTagPriceHistory();
|
||||
|
||||
// Extract items from paginated responses
|
||||
const users = usersData?.items || [];
|
||||
@@ -117,6 +170,9 @@ export default function Admin() {
|
||||
const payments = paymentsData?.items || [];
|
||||
const disputes = disputesData?.items || [];
|
||||
const pendingProducts = pendingProductsData?.items || [];
|
||||
const compareTags = compareTagsData || [];
|
||||
const adminProducts = adminProductsData?.items || [];
|
||||
const tagItems = tagItemsData || [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && (!isAuthenticated || user?.role !== "admin")) {
|
||||
@@ -124,6 +180,19 @@ export default function Admin() {
|
||||
}
|
||||
}, [loading, isAuthenticated, user, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!itemsDialogOpen) return;
|
||||
const next: Record<number, { sort_order: number; is_pinned: boolean }> = {};
|
||||
for (const item of tagItems) {
|
||||
next[item.id] = {
|
||||
sort_order: item.sort_order,
|
||||
is_pinned: item.is_pinned,
|
||||
};
|
||||
}
|
||||
setTagItemEdits(next);
|
||||
setSortableItems(tagItems);
|
||||
}, [itemsDialogOpen, tagItems]);
|
||||
|
||||
const openImageEditor = (product: AdminProduct) => {
|
||||
const initialImages = product.images && product.images.length > 0
|
||||
? [...product.images]
|
||||
@@ -211,6 +280,11 @@ export default function Admin() {
|
||||
};
|
||||
|
||||
const pendingProductsCount = pendingProducts?.length || 0;
|
||||
const productStatusLabel: Record<string, string> = {
|
||||
pending: "待审核",
|
||||
approved: "已通过",
|
||||
rejected: "已拒绝",
|
||||
};
|
||||
|
||||
const handleApproveProduct = (productId: number) => {
|
||||
reviewProductMutation.mutate(
|
||||
@@ -246,6 +320,250 @@ export default function Admin() {
|
||||
);
|
||||
};
|
||||
|
||||
const openCreateTag = () => {
|
||||
setEditingTag(null);
|
||||
setTagForm({
|
||||
name: "",
|
||||
slug: "",
|
||||
description: "",
|
||||
cover_image: "",
|
||||
icon: "",
|
||||
sort_order: 0,
|
||||
is_active: true,
|
||||
});
|
||||
setTagDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEditTag = (tag: AdminComparisonTag) => {
|
||||
setEditingTag(tag);
|
||||
setTagForm({
|
||||
name: tag.name,
|
||||
slug: tag.slug,
|
||||
description: tag.description || "",
|
||||
cover_image: tag.cover_image || "",
|
||||
icon: tag.icon || "",
|
||||
sort_order: tag.sort_order,
|
||||
is_active: tag.is_active,
|
||||
});
|
||||
setTagDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveTag = async () => {
|
||||
const name = tagForm.name.trim();
|
||||
if (!name) {
|
||||
toast.error("请输入标签名称");
|
||||
return;
|
||||
}
|
||||
const slug = (tagForm.slug || "").trim() || slugify(name);
|
||||
const payload = {
|
||||
name,
|
||||
slug,
|
||||
description: tagForm.description.trim() || undefined,
|
||||
cover_image: tagForm.cover_image.trim() || undefined,
|
||||
icon: tagForm.icon.trim() || undefined,
|
||||
sort_order: Number(tagForm.sort_order) || 0,
|
||||
is_active: tagForm.is_active,
|
||||
};
|
||||
try {
|
||||
if (editingTag) {
|
||||
await updateTagMutation.mutateAsync({ tagId: editingTag.id, data: payload });
|
||||
toast.success("标签已更新");
|
||||
} else {
|
||||
await createTagMutation.mutateAsync(payload);
|
||||
toast.success("标签已创建");
|
||||
}
|
||||
setTagDialogOpen(false);
|
||||
} catch (error: unknown) {
|
||||
const { title, description } = getErrorCopy(error, { context: "admin.compare_tag" });
|
||||
toast.error(title, { description });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTag = (tagId: number) => {
|
||||
if (!window.confirm("确认删除该标签?")) return;
|
||||
deleteTagMutation.mutate(tagId, {
|
||||
onSuccess: () => toast.success("标签已删除"),
|
||||
onError: (error: unknown) => {
|
||||
const { title, description } = getErrorCopy(error, { context: "admin.compare_tag" });
|
||||
toast.error(title, { description });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const openTagItems = (tag: AdminComparisonTag) => {
|
||||
setActiveTag(tag);
|
||||
setSelectedProductId("");
|
||||
setItemsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleAddTagItem = () => {
|
||||
if (!activeTag) return;
|
||||
if (!selectedProductId) {
|
||||
toast.error("请选择商品");
|
||||
return;
|
||||
}
|
||||
addTagItemsMutation.mutate(
|
||||
{ tagId: activeTag.id, data: { product_ids: [Number(selectedProductId)] } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("商品已添加");
|
||||
setSelectedProductId("");
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const { title, description } = getErrorCopy(error, { context: "admin.compare_tag_item" });
|
||||
toast.error(title, { description });
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleBatchAddTagItems = () => {
|
||||
if (!activeTag) return;
|
||||
const ids = batchProductIds
|
||||
.split(/[,\n]/)
|
||||
.map((v) => Number(v.trim()))
|
||||
.filter((v) => !Number.isNaN(v) && v > 0);
|
||||
if (ids.length === 0) {
|
||||
toast.error("请输入有效的商品ID");
|
||||
return;
|
||||
}
|
||||
addTagItemsMutation.mutate(
|
||||
{ tagId: activeTag.id, data: { product_ids: ids } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("批量添加成功");
|
||||
setBatchProductIds("");
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const { title, description } = getErrorCopy(error, { context: "admin.compare_tag_item" });
|
||||
toast.error(title, { description });
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleCsvImport = (file: File | null) => {
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const text = String(reader.result || "");
|
||||
const ids = parseProductIdsFromText(text);
|
||||
if (ids.length === 0) {
|
||||
toast.error("未识别到有效商品ID");
|
||||
return;
|
||||
}
|
||||
setBatchProductIds(ids.join(", "));
|
||||
toast.success(`已解析 ${ids.length} 个商品ID`);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const handleSaveTagItem = (itemId: number) => {
|
||||
if (!activeTag) return;
|
||||
const edit = tagItemEdits[itemId];
|
||||
if (!edit) return;
|
||||
updateTagItemMutation.mutate(
|
||||
{ tagId: activeTag.id, itemId, data: { sort_order: edit.sort_order, is_pinned: edit.is_pinned } },
|
||||
{
|
||||
onSuccess: () => toast.success("已保存"),
|
||||
onError: (error: unknown) => {
|
||||
const { title, description } = getErrorCopy(error, { context: "admin.compare_tag_item" });
|
||||
toast.error(title, { description });
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteTagItem = (itemId: number) => {
|
||||
if (!activeTag) return;
|
||||
deleteTagItemMutation.mutate(
|
||||
{ tagId: activeTag.id, itemId },
|
||||
{
|
||||
onSuccess: () => toast.success("已移除"),
|
||||
onError: (error: unknown) => {
|
||||
const { title, description } = getErrorCopy(error, { context: "admin.compare_tag_item" });
|
||||
toast.error(title, { description });
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleBackfillHistory = () => {
|
||||
backfillHistoryMutation.mutate(undefined, {
|
||||
onSuccess: (res) => {
|
||||
toast.success(`历史已补齐(新增 ${res.created} 条,跳过 ${res.skipped} 条)`);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const { title, description } = getErrorCopy(error, { context: "admin.price_history" });
|
||||
toast.error(title, { description });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleBackfillTagHistory = () => {
|
||||
if (!activeTag) return;
|
||||
backfillTagHistoryMutation.mutate(activeTag.id, {
|
||||
onSuccess: (res) => {
|
||||
toast.success(`标签历史已补齐(新增 ${res.created} 条,跳过 ${res.skipped} 条)`);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const { title, description } = getErrorCopy(error, { context: "admin.price_history" });
|
||||
toast.error(title, { description });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleRecordHistory = () => {
|
||||
recordHistoryMutation.mutate({ force: forceRecordHistory }, {
|
||||
onSuccess: (res) => {
|
||||
toast.success(`已记录当前价格(新增 ${res.created} 条,跳过 ${res.skipped} 条)`);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const { title, description } = getErrorCopy(error, { context: "admin.price_history" });
|
||||
toast.error(title, { description });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleRecordTagHistory = () => {
|
||||
if (!activeTag) return;
|
||||
recordTagHistoryMutation.mutate({ tagId: activeTag.id, data: { force: forceRecordHistory } }, {
|
||||
onSuccess: (res) => {
|
||||
toast.success(`标签价格已记录(新增 ${res.created} 条,跳过 ${res.skipped} 条)`);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const { title, description } = getErrorCopy(error, { context: "admin.price_history" });
|
||||
toast.error(title, { description });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveSortOrder = async () => {
|
||||
if (!activeTag) return;
|
||||
setIsSavingSort(true);
|
||||
try {
|
||||
const updates = sortableItems.map((item, index) => ({
|
||||
itemId: item.id,
|
||||
sort_order: index + 1,
|
||||
}));
|
||||
await Promise.all(
|
||||
updates.map((u) =>
|
||||
updateTagItemMutation.mutateAsync({
|
||||
tagId: activeTag.id,
|
||||
itemId: u.itemId,
|
||||
data: { sort_order: u.sort_order },
|
||||
})
|
||||
)
|
||||
);
|
||||
toast.success("排序已保存");
|
||||
} catch (error: unknown) {
|
||||
const { title, description } = getErrorCopy(error, { context: "admin.compare_tag_item" });
|
||||
toast.error(title, { description });
|
||||
} finally {
|
||||
setIsSavingSort(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Navbar />
|
||||
@@ -504,6 +822,260 @@ export default function Admin() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={tagDialogOpen} onOpenChange={setTagDialogOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingTag ? "编辑比价标签" : "创建比价标签"}</DialogTitle>
|
||||
<DialogDescription>管理员维护标签,用于首页和商品页比价展示</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>标签名称 *</Label>
|
||||
<Input
|
||||
value={tagForm.name}
|
||||
onChange={(e) => setTagForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="如:办公效率"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>标签标识 *</Label>
|
||||
<Input
|
||||
value={tagForm.slug}
|
||||
onChange={(e) => setTagForm((prev) => ({ ...prev, slug: e.target.value }))}
|
||||
placeholder="如:office-tools"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>描述</Label>
|
||||
<Input
|
||||
value={tagForm.description}
|
||||
onChange={(e) => setTagForm((prev) => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="简单描述标签用途"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>封面图</Label>
|
||||
<Input
|
||||
value={tagForm.cover_image}
|
||||
onChange={(e) => setTagForm((prev) => ({ ...prev, cover_image: e.target.value }))}
|
||||
placeholder="封面图URL"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>图标</Label>
|
||||
<Input
|
||||
value={tagForm.icon}
|
||||
onChange={(e) => setTagForm((prev) => ({ ...prev, icon: e.target.value }))}
|
||||
placeholder="图标标识(可选)"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>排序</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={tagForm.sort_order}
|
||||
onChange={(e) => setTagForm((prev) => ({ ...prev, sort_order: Number(e.target.value) }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border px-3">
|
||||
<span className="text-sm">启用</span>
|
||||
<Switch
|
||||
checked={tagForm.is_active}
|
||||
onCheckedChange={(checked) => setTagForm((prev) => ({ ...prev, is_active: checked }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setTagDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSaveTag} disabled={createTagMutation.isPending || updateTagMutation.isPending}>
|
||||
{createTagMutation.isPending || updateTagMutation.isPending ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={itemsDialogOpen} onOpenChange={setItemsDialogOpen}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>管理标签商品</DialogTitle>
|
||||
<DialogDescription>{activeTag?.name || "请选择标签"} 的商品列表</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={selectedProductId}
|
||||
onChange={(e) => setSelectedProductId(e.target.value)}
|
||||
className="px-3 py-2 border rounded-lg bg-background text-foreground text-sm min-w-[240px]"
|
||||
>
|
||||
<option value="">选择商品</option>
|
||||
{adminProducts.map((product) => (
|
||||
<option key={product.id} value={product.id}>
|
||||
{product.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button onClick={handleAddTagItem} disabled={addTagItemsMutation.isPending}>
|
||||
添加商品
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleRecordTagHistory} disabled={recordTagHistoryMutation.isPending}>
|
||||
{recordTagHistoryMutation.isPending ? "记录中..." : "记录标签价格"}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleBackfillTagHistory} disabled={backfillTagHistoryMutation.isPending}>
|
||||
{backfillTagHistoryMutation.isPending ? "补齐中..." : "补齐标签历史"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>批量添加(商品ID,逗号或换行分隔)</Label>
|
||||
<textarea
|
||||
value={batchProductIds}
|
||||
onChange={(e) => setBatchProductIds(e.target.value)}
|
||||
placeholder="例如:12, 15, 21"
|
||||
className="min-h-[90px] w-full rounded-lg border bg-background px-3 py-2 text-sm text-foreground"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
onChange={(e) => handleCsvImport(e.target.files?.[0] || null)}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">支持CSV或纯文本</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleBatchAddTagItems} disabled={addTagItemsMutation.isPending}>
|
||||
批量添加
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
可在商品审核列表中查看商品ID
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">拖拽排序模式</div>
|
||||
<div className="text-xs text-muted-foreground">拖拽调整显示顺序,保存后生效</div>
|
||||
</div>
|
||||
<Switch checked={isSortMode} onCheckedChange={setIsSortMode} />
|
||||
</div>
|
||||
|
||||
{isSortMode && sortableItems.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<SortableGrid
|
||||
items={sortableItems}
|
||||
onReorder={setSortableItems}
|
||||
className="grid grid-cols-1 gap-2"
|
||||
itemClassName="p-2"
|
||||
renderItem={(item) => (
|
||||
<div className="flex items-center gap-3">
|
||||
{item.product_image ? (
|
||||
<img src={item.product_image} alt={item.product_name} className="h-10 w-10 rounded object-cover" />
|
||||
) : (
|
||||
<div className="h-10 w-10 rounded bg-muted" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{item.product_name}</div>
|
||||
<div className="text-xs text-muted-foreground">当前排序: {item.sort_order}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={handleSaveSortOrder} disabled={isSavingSort}>
|
||||
{isSavingSort ? "保存中..." : "保存排序"}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">保存后会覆盖当前排序值</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{tagItemsLoading ? (
|
||||
<div className="flex justify-center py-6">
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
</div>
|
||||
) : tagItems.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>商品</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>排序</TableHead>
|
||||
<TableHead>置顶</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tagItems.map((item) => {
|
||||
const edit = tagItemEdits[item.id] || {
|
||||
sort_order: item.sort_order,
|
||||
is_pinned: item.is_pinned,
|
||||
};
|
||||
return (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="min-w-[200px]">
|
||||
<div className="flex items-center gap-3">
|
||||
{item.product_image && (
|
||||
<img src={item.product_image} alt={item.product_name} className="w-10 h-10 rounded object-cover" />
|
||||
)}
|
||||
<div className="font-medium">{item.product_name}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={item.product_status === "approved" ? "secondary" : item.product_status === "rejected" ? "destructive" : "outline"}>
|
||||
{productStatusLabel[item.product_status] || item.product_status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
className="w-24"
|
||||
value={edit.sort_order}
|
||||
onChange={(e) =>
|
||||
setTagItemEdits((prev) => ({
|
||||
...prev,
|
||||
[item.id]: { ...edit, sort_order: Number(e.target.value) },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch
|
||||
checked={edit.is_pinned}
|
||||
onCheckedChange={(checked) =>
|
||||
setTagItemEdits((prev) => ({
|
||||
...prev,
|
||||
[item.id]: { ...edit, is_pinned: checked },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="space-x-2">
|
||||
<Button size="sm" variant="outline" onClick={() => handleSaveTagItem(item.id)}>
|
||||
保存
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => handleDeleteTagItem(item.id)}>
|
||||
移除
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">暂无商品</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setItemsDialogOpen(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className="container pt-24 pb-12 space-y-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
@@ -552,7 +1124,7 @@ export default function Admin() {
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="products" className="space-y-6">
|
||||
<TabsList className="grid w-full grid-cols-5 lg:w-auto lg:inline-grid">
|
||||
<TabsList className="grid w-full grid-cols-6 lg:w-auto lg:inline-grid">
|
||||
<TabsTrigger value="products" className="gap-2">
|
||||
<Package className="w-4 h-4" />
|
||||
商品审核
|
||||
@@ -576,6 +1148,10 @@ export default function Admin() {
|
||||
<CreditCard className="w-4 h-4" />
|
||||
支付事件
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="compare-tags" className="gap-2">
|
||||
<Tags className="w-4 h-4" />
|
||||
比价标签
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Products Review Tab */}
|
||||
@@ -594,6 +1170,7 @@ export default function Admin() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>商品名称</TableHead>
|
||||
<TableHead>分类</TableHead>
|
||||
<TableHead>提交者</TableHead>
|
||||
@@ -604,6 +1181,7 @@ export default function Admin() {
|
||||
<TableBody>
|
||||
{pendingProducts.map((product) => (
|
||||
<TableRow key={product.id}>
|
||||
<TableCell className="text-muted-foreground">#{product.id}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
{(product.images?.[0] || product.image) && (
|
||||
@@ -920,6 +1498,80 @@ export default function Admin() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Comparison Tags Tab */}
|
||||
<TabsContent value="compare-tags">
|
||||
<Card className="card-elegant">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>比价标签管理</CardTitle>
|
||||
<CardDescription>配置首页与商品页的比价专题标签</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 rounded-lg border px-3 py-1.5">
|
||||
<span className="text-xs text-muted-foreground">强制记录</span>
|
||||
<Switch checked={forceRecordHistory} onCheckedChange={setForceRecordHistory} />
|
||||
</div>
|
||||
<Button variant="outline" onClick={handleRecordHistory} disabled={recordHistoryMutation.isPending}>
|
||||
{recordHistoryMutation.isPending ? "记录中..." : "记录当前价格"}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleBackfillHistory} disabled={backfillHistoryMutation.isPending}>
|
||||
{backfillHistoryMutation.isPending ? "补齐中..." : "补齐历史价格"}
|
||||
</Button>
|
||||
<Button onClick={openCreateTag}>新增标签</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{compareTagsLoading ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : compareTags.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>标签</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>商品数</TableHead>
|
||||
<TableHead>排序</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{compareTags.map((tag) => (
|
||||
<TableRow key={tag.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{tag.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{tag.slug}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={tag.is_active ? "secondary" : "outline"}>
|
||||
{tag.is_active ? "启用" : "停用"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{tag.product_count}</TableCell>
|
||||
<TableCell>{tag.sort_order}</TableCell>
|
||||
<TableCell className="space-x-2">
|
||||
<Button size="sm" variant="outline" onClick={() => openTagItems(tag)}>
|
||||
管理商品
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => openEditTag(tag)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => handleDeleteTag(tag.id)}>
|
||||
删除
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
暂无标签
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
useFavorites,
|
||||
useMyProducts,
|
||||
useCategories,
|
||||
useWebsites,
|
||||
} from "@/hooks/useApi";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { toast } from "sonner";
|
||||
@@ -131,6 +132,7 @@ export default function Dashboard() {
|
||||
const { data: favoritesData, isLoading: favoritesLoading } = useFavorites(undefined, { enabled: activeTab === "favorites" });
|
||||
const { data: myProductsData, isLoading: myProductsLoading } = useMyProducts(undefined, { enabled: activeTab === "products" });
|
||||
const { data: categoriesData, isLoading: categoriesLoading } = useCategories({ enabled: activeTab === "products" });
|
||||
const { data: websitesData } = useWebsites(undefined, { enabled: activeTab === "products" });
|
||||
const { data: notificationsData, isLoading: notificationsLoading, refetch: refetchNotifications } = useNotifications(undefined, { enabled: activeTab === "notifications" });
|
||||
const { data: unreadCountData } = useUnreadNotificationCount();
|
||||
const { data: notificationPreferences } = useNotificationPreferences({ enabled: activeTab === "notifications" });
|
||||
@@ -169,6 +171,7 @@ export default function Dashboard() {
|
||||
const favorites = favoritesData?.items || [];
|
||||
const myProducts = myProductsData?.items || [];
|
||||
const categories = categoriesData || [];
|
||||
const websites = websitesData?.items || [];
|
||||
const notifications = notificationsData?.items || [];
|
||||
const unreadCount = unreadCountData?.count || 0;
|
||||
|
||||
@@ -948,9 +951,13 @@ export default function Dashboard() {
|
||||
value={newProduct.websiteId}
|
||||
onChange={(e) => setNewProduct((prev) => ({ ...prev, websiteId: e.target.value }))}
|
||||
className="px-3 py-2 border rounded-lg bg-background text-foreground text-sm"
|
||||
disabled={!newProduct.categoryId}
|
||||
>
|
||||
<option value="">请先选择分类后选择网站</option>
|
||||
<option value="">请选择网站</option>
|
||||
{websites.map((website) => (
|
||||
<option key={website.id} value={website.id}>
|
||||
{website.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Link } from "wouter";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
import ComparisonTagsSection from "@/features/products/components/ComparisonTagsSection";
|
||||
import {
|
||||
ShoppingBag,
|
||||
Trophy,
|
||||
@@ -126,6 +127,14 @@ export default function Home() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ComparisonTagsSection
|
||||
title="热门比价标签"
|
||||
description="精选标签聚合多平台价格,一键查看最优选择"
|
||||
maxItems={4}
|
||||
ctaLabel="查看更多商品"
|
||||
ctaHref="/products"
|
||||
/>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="py-20">
|
||||
<div className="container">
|
||||
@@ -173,9 +182,6 @@ export default function Home() {
|
||||
<Link href="/bounties" className="hover:text-foreground transition-colors">
|
||||
悬赏大厅
|
||||
</Link>
|
||||
<Link href="/search" className="hover:text-foreground transition-colors">
|
||||
全文搜索
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "wouter";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { LazyImage } from "@/components/LazyImage";
|
||||
import { useComparisonTagDetail, useComparisonTags } from "@/hooks/useApi";
|
||||
import {
|
||||
ArrowRight,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Tags,
|
||||
TrendingDown,
|
||||
Award,
|
||||
Star,
|
||||
Flame,
|
||||
History,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { zhCN } from "date-fns/locale";
|
||||
|
||||
type ComparisonTagsSectionProps = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
maxItems?: number;
|
||||
ctaLabel?: string;
|
||||
ctaHref?: string;
|
||||
useContainer?: boolean;
|
||||
showDetailLink?: boolean;
|
||||
};
|
||||
|
||||
export default function ComparisonTagsSection({
|
||||
title = "精选比价专题",
|
||||
description = "管理员精选标签,一键查看多平台价格对比",
|
||||
maxItems,
|
||||
ctaLabel,
|
||||
ctaHref,
|
||||
useContainer = true,
|
||||
showDetailLink = true,
|
||||
}: ComparisonTagsSectionProps) {
|
||||
const { data: tagsData, isLoading: tagsLoading } = useComparisonTags();
|
||||
const tags = tagsData || [];
|
||||
const [activeSlug, setActiveSlug] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSlug && tags.length > 0) {
|
||||
setActiveSlug(tags[0].slug);
|
||||
}
|
||||
}, [activeSlug, tags]);
|
||||
|
||||
const { data: tagDetail, isLoading: detailLoading } =
|
||||
useComparisonTagDetail(activeSlug);
|
||||
const items = tagDetail?.items || [];
|
||||
const visibleItems = maxItems ? items.slice(0, maxItems) : items;
|
||||
|
||||
// 统计历史最低价商品数量
|
||||
const historicalLowestCount = useMemo(() => {
|
||||
return items.filter((item) => item.is_at_historical_lowest).length;
|
||||
}, [items]);
|
||||
|
||||
// 统计推荐商品数量
|
||||
const recommendedCount = useMemo(() => {
|
||||
return items.filter((item) => item.is_recommended).length;
|
||||
}, [items]);
|
||||
|
||||
const totalPlatforms = useMemo(() => {
|
||||
return items.reduce((sum, item) => sum + (item.platform_count || 0), 0);
|
||||
}, [items]);
|
||||
|
||||
if (!tagsLoading && tags.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="py-12">
|
||||
<div className={useContainer ? "container" : ""}>
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between mb-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-2">
|
||||
<Tags className="w-4 h-4" />
|
||||
比价标签
|
||||
{historicalLowestCount > 0 && (
|
||||
<Badge variant="default" className="gap-1 bg-green-600 ml-2">
|
||||
<Flame className="w-3 h-3" />
|
||||
{historicalLowestCount}款历史低价
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<h2
|
||||
className="text-2xl font-bold"
|
||||
style={{ fontFamily: "'Playfair Display', serif" }}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm mt-1">{description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{showDetailLink && activeSlug && (
|
||||
<Link href={`/compare-tags/${activeSlug}`}>
|
||||
<Button variant="outline" className="gap-2">
|
||||
查看专题
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{ctaLabel && ctaHref && (
|
||||
<Link href={ctaHref}>
|
||||
<Button variant="outline" className="gap-2">
|
||||
{ctaLabel}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
{tagsLoading ? (
|
||||
<Badge variant="secondary" className="px-3 py-1">
|
||||
加载中...
|
||||
</Badge>
|
||||
) : (
|
||||
tags.map((tag) => (
|
||||
<Button
|
||||
key={tag.id}
|
||||
size="sm"
|
||||
variant={activeSlug === tag.slug ? "default" : "outline"}
|
||||
className="gap-2"
|
||||
onClick={() => setActiveSlug(tag.slug)}
|
||||
>
|
||||
{tag.name}
|
||||
<Badge variant="secondary" className="ml-1">
|
||||
{tag.product_count}
|
||||
</Badge>
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{detailLoading ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-primary" />
|
||||
</div>
|
||||
) : visibleItems.length === 0 ? (
|
||||
<Card className="card-elegant">
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
暂无可对比商品
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{visibleItems.map((item) => {
|
||||
const prices = [...item.prices].sort(
|
||||
(a, b) => Number(a.price) - Number(b.price)
|
||||
);
|
||||
const topPrices = prices.slice(0, 3);
|
||||
const lowest =
|
||||
item.lowest_price ?? (prices[0]?.price || null);
|
||||
const highest =
|
||||
item.highest_price ??
|
||||
(prices[prices.length - 1]?.price || null);
|
||||
const image =
|
||||
item.product.images?.[0] || item.product.image || "";
|
||||
const lowestRecord = prices[0];
|
||||
return (
|
||||
<Card key={item.product.id} className="card-elegant relative">
|
||||
{/* 推荐角标 */}
|
||||
{item.is_recommended && (
|
||||
<div className="absolute top-0 right-0 bg-gradient-to-l from-yellow-500 to-orange-500 text-white text-xs px-3 py-1 rounded-bl-lg font-medium z-10">
|
||||
<Star className="w-3 h-3 inline mr-1" />
|
||||
推荐
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardHeader className="flex flex-row gap-4 items-start">
|
||||
<div className="w-20 h-20 rounded-lg overflow-hidden bg-muted/40 flex-shrink-0 relative">
|
||||
<LazyImage
|
||||
src={image}
|
||||
alt={item.product.name}
|
||||
className="w-full h-full"
|
||||
aspectRatio="1/1"
|
||||
/>
|
||||
{/* 历史最低角标 */}
|
||||
{item.is_at_historical_lowest && (
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-green-600 text-white text-xs py-0.5 text-center font-medium">
|
||||
<Award className="w-3 h-3 inline mr-1" />
|
||||
历史最低
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-base line-clamp-2 flex items-center gap-2 flex-wrap">
|
||||
{item.product.name}
|
||||
{item.discount_from_highest_percent &&
|
||||
Number(item.discount_from_highest_percent) >= 20 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="gap-1 bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300"
|
||||
>
|
||||
<Zap className="w-3 h-3" />
|
||||
降{Number(item.discount_from_highest_percent).toFixed(0)}%
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
{item.product.description && (
|
||||
<CardDescription className="line-clamp-2 mt-1">
|
||||
{item.product.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm">
|
||||
<Badge variant="secondary">
|
||||
{item.platform_count || prices.length} 个平台
|
||||
</Badge>
|
||||
{lowest && (
|
||||
<span className="text-primary font-semibold">
|
||||
低至 ¥{lowest}
|
||||
</span>
|
||||
)}
|
||||
{highest && lowest && highest !== lowest && (
|
||||
<span className="text-muted-foreground">
|
||||
最高 ¥{highest}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 历史最低价信息 */}
|
||||
{item.historical_lowest && (
|
||||
<div className="mt-1 text-xs text-muted-foreground flex items-center gap-1">
|
||||
<History className="w-3 h-3" />
|
||||
历史最低 ¥{item.historical_lowest}
|
||||
{item.historical_lowest_date && (
|
||||
<span>
|
||||
·{" "}
|
||||
{formatDistanceToNow(
|
||||
new Date(item.historical_lowest_date),
|
||||
{ addSuffix: true, locale: zhCN }
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-2">
|
||||
{topPrices.map((price, index) => (
|
||||
<div
|
||||
key={price.id}
|
||||
className={`flex items-center justify-between text-sm p-2 rounded-lg transition-colors ${
|
||||
index === 0
|
||||
? "bg-primary/5 border border-primary/20"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 flex items-center gap-2">
|
||||
{/* 平台Logo */}
|
||||
{price.website_logo ? (
|
||||
<img
|
||||
src={price.website_logo}
|
||||
alt={price.website_name || ""}
|
||||
className="w-5 h-5 rounded object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-5 h-5 rounded bg-muted flex items-center justify-center text-xs font-medium">
|
||||
{(price.website_name || "?")[0]}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="font-medium truncate flex items-center gap-1">
|
||||
{price.website_name || "未知网站"}
|
||||
{price.is_at_historical_lowest && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-xs gap-0.5 bg-green-100 text-green-700 px-1"
|
||||
>
|
||||
<Award className="w-2.5 h-2.5" />
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground flex items-center gap-2">
|
||||
<span className="font-semibold text-foreground">
|
||||
¥{price.price}
|
||||
</span>
|
||||
{price.original_price &&
|
||||
Number(price.original_price) >
|
||||
Number(price.price) && (
|
||||
<>
|
||||
<span className="line-through text-xs">
|
||||
¥{price.original_price}
|
||||
</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="gap-0.5 text-xs"
|
||||
>
|
||||
<TrendingDown className="w-2.5 h-2.5" />
|
||||
{price.discount_percent}%
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={price.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-primary hover:underline flex items-center gap-1"
|
||||
>
|
||||
去购买
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{prices.length > topPrices.length && (
|
||||
<div className="mt-3 text-xs text-muted-foreground">
|
||||
还有 {prices.length - topPrices.length} 个平台可比价
|
||||
</div>
|
||||
)}
|
||||
{lowestRecord?.last_checked && (
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
价格更新于{" "}
|
||||
{formatDistanceToNow(
|
||||
new Date(lowestRecord.last_checked),
|
||||
{ addSuffix: true, locale: zhCN }
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 底部统计信息 */}
|
||||
{items.length > 0 && (
|
||||
<div className="mt-6 flex flex-wrap items-center justify-center gap-4 text-sm text-muted-foreground">
|
||||
<span>本标签共 {items.length} 件商品</span>
|
||||
<span>·</span>
|
||||
<span>覆盖 {totalPlatforms} 个平台价格</span>
|
||||
{historicalLowestCount > 0 && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="text-green-600 font-medium">
|
||||
{historicalLowestCount} 款处于历史低价
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{recommendedCount > 0 && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="text-orange-600 font-medium">
|
||||
{recommendedCount} 款推荐购买
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
1180
frontend/src/features/products/pages/ComparisonTagDetail.tsx
Normal file
1180
frontend/src/features/products/pages/ComparisonTagDetail.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -287,9 +287,6 @@ export default function ProductDetail() {
|
||||
<Link href="/bounties" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
悬赏大厅
|
||||
</Link>
|
||||
<Link href="/search" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
全文搜索
|
||||
</Link>
|
||||
{isAuthenticated && (
|
||||
<Link href="/dashboard" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
个人中心
|
||||
|
||||
@@ -1,54 +1,34 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
import { useCategories, useWebsites, useProducts, useFavorites, useAddFavorite, useRemoveFavorite, useRecommendedProducts, useProductSearch } from "@/hooks/useApi";
|
||||
import { useDebounce } from "@/hooks/useDebounce";
|
||||
import { categoryApi, productApi, websiteApi, type Product, type ProductWithPrices } from "@/lib/api";
|
||||
import { Link } from "wouter";
|
||||
import { useState, useMemo, useEffect, useRef } from "react";
|
||||
import { type Product, type ProductWithPrices } from "@/lib/api";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import {
|
||||
ShoppingBag,
|
||||
ArrowUpDown,
|
||||
Loader2,
|
||||
Heart,
|
||||
Sparkles,
|
||||
Image as ImageIcon,
|
||||
Trash2,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { getErrorCopy } from "@/lib/i18n/errorMessages";
|
||||
import { MobileNav } from "@/components/MobileNav";
|
||||
import { ProductListSkeleton } from "@/components/ProductCardSkeleton";
|
||||
import { LazyImage } from "@/components/LazyImage";
|
||||
import SortableGrid, { HeroDropzone } from "@/components/SortableGrid";
|
||||
import FileThumbnail from "@/components/FileThumbnail";
|
||||
import { createImageFileItems, processImageFile, type ImageFileItem } from "@/lib/image";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import ProductsHeader from "@/features/products/components/ProductsHeader";
|
||||
import WebsitesSection from "@/features/products/components/WebsitesSection";
|
||||
import RecommendedProducts from "@/features/products/components/RecommendedProducts";
|
||||
import ComparisonTagsSection from "@/features/products/components/ComparisonTagsSection";
|
||||
|
||||
export default function Products() {
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const [, navigate] = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
const MAX_IMAGES = 6;
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>("all");
|
||||
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
||||
@@ -59,67 +39,7 @@ export default function Products() {
|
||||
const [favoriteWebsiteByProduct, setFavoriteWebsiteByProduct] = useState<Record<number, number>>({});
|
||||
const [favoriteDialogOpen, setFavoriteDialogOpen] = useState(false);
|
||||
const [favoriteDialogProduct, setFavoriteDialogProduct] = useState<ProductWithPrices | null>(null);
|
||||
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||
const [isNewCategory, setIsNewCategory] = useState(false);
|
||||
const [isCreatingCategory, setIsCreatingCategory] = useState(false);
|
||||
const [imageFiles, setImageFiles] = useState<ImageFileItem[]>([]);
|
||||
const [isUploadingImage, setIsUploadingImage] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const confirmActionRef = useRef<null | (() => void)>(null);
|
||||
const [confirmItem, setConfirmItem] = useState<{ name?: string; file?: File } | null>(null);
|
||||
const [confirmMeta, setConfirmMeta] = useState<{ isFirst?: boolean; nextName?: string | null } | null>(null);
|
||||
const [newProduct, setNewProduct] = useState({
|
||||
name: "",
|
||||
description: "",
|
||||
image: "",
|
||||
categoryId: "",
|
||||
websiteId: "",
|
||||
price: "",
|
||||
originalPrice: "",
|
||||
currency: "CNY",
|
||||
url: "",
|
||||
inStock: true,
|
||||
});
|
||||
const [isNewWebsite, setIsNewWebsite] = useState(false);
|
||||
const [newWebsite, setNewWebsite] = useState({
|
||||
name: "",
|
||||
url: "",
|
||||
});
|
||||
const [newCategory, setNewCategory] = useState({
|
||||
name: "",
|
||||
slug: "",
|
||||
description: "",
|
||||
});
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300);
|
||||
const imageHeroId = "product-image-hero";
|
||||
const moveImageToFront = (activeId: string) => {
|
||||
setImageFiles((prev) => {
|
||||
const index = prev.findIndex((item) => item.id === activeId);
|
||||
if (index <= 0) return prev;
|
||||
const next = [...prev];
|
||||
const [picked] = next.splice(index, 1);
|
||||
next.unshift(picked);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const appendImageFiles = (files: File[]) => {
|
||||
if (files.length === 0) return;
|
||||
setImageFiles((prev) => {
|
||||
const merged = [...prev, ...createImageFileItems(files)];
|
||||
if (merged.length > MAX_IMAGES) {
|
||||
toast.error(`最多上传${MAX_IMAGES}张图片`);
|
||||
return merged.slice(0, MAX_IMAGES);
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
setNewProduct(prev => ({ ...prev, image: "" }));
|
||||
};
|
||||
const openConfirm = (action: () => void, item?: { name?: string; file?: File }, meta?: { isFirst?: boolean; nextName?: string | null }) => {
|
||||
confirmActionRef.current = action;
|
||||
setConfirmItem(item || null);
|
||||
setConfirmMeta(meta || null);
|
||||
setConfirmOpen(true);
|
||||
};
|
||||
|
||||
const { data: categoriesData, isLoading: categoriesLoading } = useCategories();
|
||||
const websiteParams = selectedCategory !== "all"
|
||||
@@ -194,523 +114,28 @@ export default function Products() {
|
||||
|
||||
const isLoading = categoriesLoading || websitesLoading || (debouncedSearchQuery.trim() ? searchLoading : productsLoading);
|
||||
|
||||
|
||||
const handleAddProduct = async () => {
|
||||
if (!newProduct.name.trim()) {
|
||||
toast.error("请输入商品名称");
|
||||
const handleAddProductClick = () => {
|
||||
if (!isAuthenticated) {
|
||||
toast.error("请先登录");
|
||||
navigate("/login");
|
||||
return;
|
||||
}
|
||||
if (!newProduct.categoryId) {
|
||||
toast.error("请选择分类");
|
||||
return;
|
||||
}
|
||||
if (isNewWebsite) {
|
||||
if (!newWebsite.name.trim()) {
|
||||
toast.error("请输入网站名称");
|
||||
return;
|
||||
}
|
||||
if (!newWebsite.url.trim()) {
|
||||
toast.error("请输入网站URL");
|
||||
return;
|
||||
}
|
||||
} else if (!newProduct.websiteId) {
|
||||
toast.error("请选择网站");
|
||||
return;
|
||||
}
|
||||
if (!newProduct.price || Number(newProduct.price) <= 0) {
|
||||
toast.error("请输入有效价格");
|
||||
return;
|
||||
}
|
||||
if (!newProduct.url.trim()) {
|
||||
toast.error("请输入商品链接");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let websiteId = Number(newProduct.websiteId);
|
||||
|
||||
// Create new website if needed
|
||||
if (isNewWebsite) {
|
||||
const website = await websiteApi.create({
|
||||
name: newWebsite.name.trim(),
|
||||
url: newWebsite.url.trim(),
|
||||
category_id: Number(newProduct.categoryId),
|
||||
});
|
||||
websiteId = website.id;
|
||||
queryClient.invalidateQueries({ queryKey: ["websites"] });
|
||||
}
|
||||
|
||||
let imageUrl = newProduct.image.trim() || undefined;
|
||||
let images: string[] | undefined;
|
||||
if (imageFiles.length > 0) {
|
||||
setIsUploadingImage(true);
|
||||
const uploadedUrls: string[] = [];
|
||||
for (const item of imageFiles) {
|
||||
let processed: File;
|
||||
try {
|
||||
processed = await processImageFile(item.file);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "图片处理失败";
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
const uploadResult = await productApi.uploadImage(processed);
|
||||
uploadedUrls.push(uploadResult.url);
|
||||
}
|
||||
images = uploadedUrls;
|
||||
imageUrl = uploadedUrls[0];
|
||||
} else if (imageUrl) {
|
||||
images = [imageUrl];
|
||||
}
|
||||
|
||||
const product = await productApi.create({
|
||||
name: newProduct.name.trim(),
|
||||
description: newProduct.description.trim() || undefined,
|
||||
image: imageUrl,
|
||||
images,
|
||||
category_id: Number(newProduct.categoryId),
|
||||
});
|
||||
await productApi.addPrice({
|
||||
product_id: product.id,
|
||||
website_id: websiteId,
|
||||
price: newProduct.price,
|
||||
original_price: newProduct.originalPrice || undefined,
|
||||
currency: newProduct.currency || "CNY",
|
||||
url: newProduct.url.trim(),
|
||||
in_stock: newProduct.inStock,
|
||||
});
|
||||
toast.success("商品已添加");
|
||||
queryClient.invalidateQueries({ queryKey: ["products"] });
|
||||
setIsAddOpen(false);
|
||||
setNewProduct({
|
||||
name: "",
|
||||
description: "",
|
||||
image: "",
|
||||
categoryId: "",
|
||||
websiteId: "",
|
||||
price: "",
|
||||
originalPrice: "",
|
||||
currency: "CNY",
|
||||
url: "",
|
||||
inStock: true,
|
||||
});
|
||||
setIsNewWebsite(false);
|
||||
setImageFiles([]);
|
||||
setIsUploadingImage(false);
|
||||
setNewWebsite({ name: "", url: "" });
|
||||
} catch (error: unknown) {
|
||||
const { title, description } = getErrorCopy(error, { context: "product.create" });
|
||||
toast.error(title, { description });
|
||||
} finally {
|
||||
setIsUploadingImage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateCategory = async () => {
|
||||
const name = newCategory.name.trim();
|
||||
if (!name) {
|
||||
toast.error("请输入分类名称");
|
||||
return;
|
||||
}
|
||||
|
||||
const rawSlug = newCategory.slug.trim();
|
||||
const fallbackSlug = name
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^a-z0-9-]/g, "");
|
||||
const slug = rawSlug || fallbackSlug || `category-${Date.now()}`;
|
||||
|
||||
setIsCreatingCategory(true);
|
||||
try {
|
||||
const category = await categoryApi.create({
|
||||
name,
|
||||
slug,
|
||||
description: newCategory.description.trim() || undefined,
|
||||
});
|
||||
queryClient.setQueryData(["categories"], (prev) => {
|
||||
if (Array.isArray(prev)) {
|
||||
const exists = prev.some((item) => item.id === category.id);
|
||||
return exists ? prev : [...prev, category];
|
||||
}
|
||||
return [category];
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
||||
setNewProduct((prev) => ({ ...prev, categoryId: category.id.toString() }));
|
||||
setIsNewCategory(false);
|
||||
setNewCategory({ name: "", slug: "", description: "" });
|
||||
toast.success("分类已创建");
|
||||
} catch (error: unknown) {
|
||||
const { title, description } = getErrorCopy(error, { context: "category.create" });
|
||||
toast.error(title, { description });
|
||||
} finally {
|
||||
setIsCreatingCategory(false);
|
||||
}
|
||||
navigate("/dashboard?tab=products");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Navbar>
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
<Dialog open={isAddOpen} onOpenChange={setIsAddOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="hidden md:inline-flex">
|
||||
添加商品
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加商品</DialogTitle>
|
||||
<DialogDescription>填写商品信息以添加到列表</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="product-name">商品名称</Label>
|
||||
<Input
|
||||
id="product-name"
|
||||
value={newProduct.name}
|
||||
onChange={(e) => setNewProduct(prev => ({ ...prev, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="product-desc">描述</Label>
|
||||
<Input
|
||||
id="product-desc"
|
||||
value={newProduct.description}
|
||||
onChange={(e) => setNewProduct(prev => ({ ...prev, description: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="product-image">图片URL</Label>
|
||||
<Input
|
||||
id="product-image"
|
||||
value={newProduct.image}
|
||||
onChange={(e) => {
|
||||
setNewProduct(prev => ({ ...prev, image: e.target.value }));
|
||||
if (e.target.value.trim()) {
|
||||
setImageFiles([]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="product-image-file">上传本地图片</Label>
|
||||
<div
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
appendImageFiles(Array.from(e.dataTransfer.files || []));
|
||||
}}
|
||||
onPaste={(e) => {
|
||||
appendImageFiles(Array.from(e.clipboardData?.files || []));
|
||||
}}
|
||||
className="rounded-md border border-dashed bg-muted/20 p-3 transition hover:bg-muted/40"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
拖拽/粘贴图片到此处,或点击选择文件
|
||||
</div>
|
||||
<div>最多{MAX_IMAGES}张</div>
|
||||
</div>
|
||||
<Input
|
||||
id="product-image-file"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (files.length > MAX_IMAGES) {
|
||||
toast.error(`最多上传${MAX_IMAGES}张图片`);
|
||||
setImageFiles(createImageFileItems(files.slice(0, MAX_IMAGES)));
|
||||
} else {
|
||||
setImageFiles(createImageFileItems(files));
|
||||
}
|
||||
if (files.length > 0) {
|
||||
setNewProduct(prev => ({ ...prev, image: "" }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">拖拽排序,自动裁剪为1:1,单张≤5MB,自动压缩</p>
|
||||
{imageFiles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<HeroDropzone id={imageHeroId} className="rounded-md border bg-muted/40 p-3">
|
||||
<div className="text-xs text-muted-foreground mb-2">首图预览(拖拽或点击缩略图设为首图)</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<FileThumbnail file={imageFiles[0].file} className="h-16 w-16 rounded object-cover" />
|
||||
<div className="text-xs text-muted-foreground truncate">{imageFiles[0].file.name}</div>
|
||||
</div>
|
||||
</HeroDropzone>
|
||||
<SortableGrid
|
||||
items={imageFiles}
|
||||
onReorder={setImageFiles}
|
||||
heroDropId={imageHeroId}
|
||||
onHeroDrop={moveImageToFront}
|
||||
className="grid grid-cols-2 gap-3"
|
||||
itemClassName="p-3"
|
||||
renderItem={(item) => {
|
||||
const isFirst = imageFiles[0]?.id === item.id;
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" className="relative" onClick={() => moveImageToFront(item.id)}>
|
||||
<FileThumbnail file={item.file} className="h-20 w-20 rounded object-cover" />
|
||||
{isFirst && (
|
||||
<div className="absolute right-1 top-1 rounded bg-primary/90 px-2 py-0.5 text-[11px] text-primary-foreground">
|
||||
首图
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-muted-foreground truncate">{item.file.name}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
const isFirstItem = isFirst;
|
||||
const nextName = imageFiles.length > 1
|
||||
? imageFiles[1]?.file.name || null
|
||||
: null;
|
||||
openConfirm(
|
||||
() => {
|
||||
setImageFiles((prev) => prev.filter((f) => f.id !== item.id));
|
||||
},
|
||||
{ name: item.file.name, file: item.file },
|
||||
{ isFirst: isFirstItem, nextName }
|
||||
);
|
||||
}}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>分类</Label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsNewCategory(!isNewCategory);
|
||||
if (!isNewCategory) {
|
||||
setNewProduct((prev) => ({ ...prev, categoryId: "" }));
|
||||
} else {
|
||||
setNewCategory({ name: "", slug: "", description: "" });
|
||||
}
|
||||
}}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
{isNewCategory ? "选择已有分类" : "+ 添加新分类"}
|
||||
</button>
|
||||
</div>
|
||||
{isNewCategory ? (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder="分类名称"
|
||||
value={newCategory.name}
|
||||
onChange={(e) => setNewCategory((prev) => ({ ...prev, name: e.target.value }))}
|
||||
disabled={isCreatingCategory}
|
||||
/>
|
||||
<Input
|
||||
placeholder="分类标识(可选,如: digital)"
|
||||
value={newCategory.slug}
|
||||
onChange={(e) => setNewCategory((prev) => ({ ...prev, slug: e.target.value }))}
|
||||
disabled={isCreatingCategory}
|
||||
/>
|
||||
<Input
|
||||
placeholder="分类描述(可选)"
|
||||
value={newCategory.description}
|
||||
onChange={(e) => setNewCategory((prev) => ({ ...prev, description: e.target.value }))}
|
||||
disabled={isCreatingCategory}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleCreateCategory}
|
||||
disabled={isCreatingCategory}
|
||||
>
|
||||
{isCreatingCategory ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
创建中...
|
||||
</>
|
||||
) : (
|
||||
"创建分类"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<select
|
||||
value={newProduct.categoryId}
|
||||
onChange={(e) => setNewProduct(prev => ({ ...prev, categoryId: e.target.value }))}
|
||||
className="px-3 py-2 border rounded-lg bg-background text-foreground text-sm"
|
||||
>
|
||||
<option value="">请选择分类</option>
|
||||
{categories.map((category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{categories.length === 0 && !categoriesLoading && (
|
||||
<p className="text-xs text-muted-foreground">暂无分类,请先创建</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>网站</Label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsNewWebsite(!isNewWebsite);
|
||||
if (!isNewWebsite) {
|
||||
setNewProduct(prev => ({ ...prev, websiteId: "" }));
|
||||
} else {
|
||||
setNewWebsite({ name: "", url: "" });
|
||||
}
|
||||
}}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
{isNewWebsite ? "选择已有网站" : "+ 添加新网站"}
|
||||
</button>
|
||||
</div>
|
||||
{isNewWebsite ? (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder="网站名称 (如: 京东)"
|
||||
value={newWebsite.name}
|
||||
onChange={(e) => setNewWebsite(prev => ({ ...prev, name: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
placeholder="网站URL (如: https://www.jd.com)"
|
||||
value={newWebsite.url}
|
||||
onChange={(e) => setNewWebsite(prev => ({ ...prev, url: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={newProduct.websiteId}
|
||||
onChange={(e) => setNewProduct(prev => ({ ...prev, websiteId: e.target.value }))}
|
||||
className="px-3 py-2 border rounded-lg bg-background text-foreground text-sm"
|
||||
>
|
||||
<option value="">请选择网站</option>
|
||||
{websites.map((website) => (
|
||||
<option key={website.id} value={website.id}>
|
||||
{website.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="product-price">价格</Label>
|
||||
<Input
|
||||
id="product-price"
|
||||
type="number"
|
||||
value={newProduct.price}
|
||||
onChange={(e) => setNewProduct(prev => ({ ...prev, price: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="product-original-price">原价</Label>
|
||||
<Input
|
||||
id="product-original-price"
|
||||
type="number"
|
||||
value={newProduct.originalPrice}
|
||||
onChange={(e) => setNewProduct(prev => ({ ...prev, originalPrice: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="product-currency">币种</Label>
|
||||
<Input
|
||||
id="product-currency"
|
||||
value={newProduct.currency}
|
||||
onChange={(e) => setNewProduct(prev => ({ ...prev, currency: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="product-url">商品链接</Label>
|
||||
<Input
|
||||
id="product-url"
|
||||
value={newProduct.url}
|
||||
onChange={(e) => setNewProduct(prev => ({ ...prev, url: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={newProduct.inStock}
|
||||
onCheckedChange={(checked) =>
|
||||
setNewProduct(prev => ({ ...prev, inStock: Boolean(checked) }))
|
||||
}
|
||||
/>
|
||||
<span className="text-sm">有货</span>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsAddOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleAddProduct} disabled={isUploadingImage}>
|
||||
{isUploadingImage ? "上传中..." : "保存"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="hidden md:inline-flex gap-2"
|
||||
onClick={handleAddProductClick}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
发布商品
|
||||
</Button>
|
||||
</Navbar>
|
||||
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除图片</AlertDialogTitle>
|
||||
<AlertDialogDescription>该图片将从列表中移除,删除后无法恢复。</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{confirmItem && (
|
||||
<div className="flex items-center gap-3 rounded-md border bg-muted/40 p-3">
|
||||
{confirmItem.file ? (
|
||||
<FileThumbnail file={confirmItem.file} className="h-20 w-20 rounded object-cover" />
|
||||
) : (
|
||||
<div className="h-20 w-20 rounded bg-muted" />
|
||||
)}
|
||||
<div className="text-sm text-muted-foreground truncate">{confirmItem.name || "图片"}</div>
|
||||
</div>
|
||||
)}
|
||||
{confirmMeta?.isFirst && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
当前为首图,删除后将自动使用下一张图片作为首图{confirmMeta.nextName ? `(${confirmMeta.nextName})` : "。"}
|
||||
</div>
|
||||
)}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => {
|
||||
confirmActionRef.current?.();
|
||||
confirmActionRef.current = null;
|
||||
setConfirmItem(null);
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<ProductsHeader
|
||||
searchQuery={searchQuery}
|
||||
@@ -740,6 +165,12 @@ export default function Products() {
|
||||
|
||||
<RecommendedProducts products={recommendedProducts} />
|
||||
|
||||
<ComparisonTagsSection
|
||||
title="比价标签"
|
||||
description="管理员精选标签,一键查看多平台价格对比"
|
||||
useContainer={false}
|
||||
/>
|
||||
|
||||
{/* Products Section */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-6 flex items-center gap-2">
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { Link } from "wouter";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useDebounce } from "@/hooks/useDebounce";
|
||||
import { useGlobalSearch } from "@/hooks/useApi";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ExternalLink, Loader2, Search as SearchIcon, ShoppingBag, Trophy } from "lucide-react";
|
||||
|
||||
export default function Search() {
|
||||
const [query, setQuery] = useState("");
|
||||
const debouncedQuery = useDebounce(query, 300);
|
||||
const { data, isLoading } = useGlobalSearch(debouncedQuery, 8);
|
||||
|
||||
const products = data?.products || [];
|
||||
const websites = data?.websites || [];
|
||||
const bounties = data?.bounties || [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Navbar />
|
||||
|
||||
<section className="pt-24 pb-8">
|
||||
<div className="container max-w-4xl">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center">
|
||||
<SearchIcon className="w-5 h-5 text-primary-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">全文搜索</h1>
|
||||
<p className="text-muted-foreground">跨商品、网站与悬赏快速定位</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="输入关键词..."
|
||||
className="pl-10"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="pb-20">
|
||||
<div className="container max-w-4xl space-y-6">
|
||||
{!debouncedQuery.trim() && (
|
||||
<Card className="card-elegant">
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
请输入关键词开始搜索
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && debouncedQuery.trim() && (
|
||||
<>
|
||||
<Card className="card-elegant">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ShoppingBag className="w-4 h-4 text-primary" />
|
||||
商品
|
||||
<Badge variant="secondary">{products.length}</Badge>
|
||||
</CardTitle>
|
||||
<Link href="/products" className="text-sm text-muted-foreground hover:text-foreground">
|
||||
去商品导航
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{products.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">暂无匹配商品</div>
|
||||
) : (
|
||||
products.map((product) => (
|
||||
<Link key={product.id} href={`/products/${product.id}`}>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/40 hover:bg-muted transition-colors cursor-pointer">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate">{product.name}</div>
|
||||
<div className="text-sm text-muted-foreground line-clamp-1">
|
||||
{product.description || "暂无描述"}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm text-primary">查看</span>
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="card-elegant">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ExternalLink className="w-4 h-4 text-primary" />
|
||||
网站
|
||||
<Badge variant="secondary">{websites.length}</Badge>
|
||||
</CardTitle>
|
||||
<Link href="/products" className="text-sm text-muted-foreground hover:text-foreground">
|
||||
去商品导航
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{websites.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">暂无匹配网站</div>
|
||||
) : (
|
||||
websites.map((website) => (
|
||||
<a
|
||||
key={website.id}
|
||||
href={website.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between p-3 rounded-lg bg-muted/40 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate">{website.name}</div>
|
||||
<div className="text-sm text-muted-foreground line-clamp-1">
|
||||
{website.description || website.url}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm text-primary">访问</span>
|
||||
</a>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="card-elegant">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Trophy className="w-4 h-4 text-primary" />
|
||||
悬赏
|
||||
<Badge variant="secondary">{bounties.length}</Badge>
|
||||
</CardTitle>
|
||||
<Link href="/bounties" className="text-sm text-muted-foreground hover:text-foreground">
|
||||
去悬赏大厅
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{bounties.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">暂无匹配悬赏</div>
|
||||
) : (
|
||||
bounties.map((bounty) => (
|
||||
<Link key={bounty.id} href={`/bounties/${bounty.id}`}>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/40 hover:bg-muted transition-colors cursor-pointer">
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate">{bounty.title}</div>
|
||||
<div className="text-sm text-muted-foreground line-clamp-1">
|
||||
{bounty.description}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm text-primary">查看</span>
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
categoryApi,
|
||||
websiteApi,
|
||||
productApi,
|
||||
comparisonTagApi,
|
||||
bountyApi,
|
||||
favoriteApi,
|
||||
notificationApi,
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
setAccessToken,
|
||||
setRefreshToken,
|
||||
clearRefreshToken,
|
||||
searchApi,
|
||||
type User,
|
||||
type Bounty,
|
||||
type BountyApplication,
|
||||
@@ -280,6 +280,15 @@ export function useProductWithPrices(id: number) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useProductPriceHistory(productId: number, params?: { website_id?: number; limit?: number }, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: ['products', productId, 'price-history', params],
|
||||
queryFn: () => productApi.priceHistory(productId, params),
|
||||
enabled: (options?.enabled !== false) && !!productId,
|
||||
staleTime: shortStaleTime,
|
||||
});
|
||||
}
|
||||
|
||||
export function useProductSearch(q: string, params?: { page?: number; category_id?: number; user_id?: string; min_price?: number; max_price?: number; sort_by?: string }) {
|
||||
const debouncedQuery = useDebouncedValue(q, 300);
|
||||
return useQuery({
|
||||
@@ -291,6 +300,40 @@ export function useProductSearch(q: string, params?: { page?: number; category_i
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Comparison Tag Hooks ====================
|
||||
|
||||
export function useComparisonTags() {
|
||||
return useQuery({
|
||||
queryKey: ['compare-tags'],
|
||||
queryFn: comparisonTagApi.list,
|
||||
staleTime: staticStaleTime,
|
||||
});
|
||||
}
|
||||
|
||||
export interface ComparisonTagFilters {
|
||||
only_discounted?: boolean;
|
||||
min_discount_percent?: number;
|
||||
only_historical_lowest?: boolean;
|
||||
}
|
||||
|
||||
export function useComparisonTagDetail(slug: string, filters?: ComparisonTagFilters) {
|
||||
return useQuery({
|
||||
queryKey: ['compare-tags', slug, filters],
|
||||
queryFn: () => comparisonTagApi.get(slug, filters),
|
||||
enabled: !!slug,
|
||||
staleTime: shortStaleTime,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePriceStats(productId: number, days?: number, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: ['products', productId, 'price-stats', days],
|
||||
queryFn: () => comparisonTagApi.getPriceStats(productId, days),
|
||||
enabled: (options?.enabled !== false) && !!productId,
|
||||
staleTime: shortStaleTime,
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Bounty Hooks ====================
|
||||
|
||||
export function useBounties(params?: { status?: string; publisher_id?: number; acceptor_id?: number; page?: number }) {
|
||||
@@ -736,16 +779,6 @@ export function useNotifications(params?: { is_read?: boolean; type?: string; st
|
||||
});
|
||||
}
|
||||
|
||||
export function useGlobalSearch(q: string, limit = 10) {
|
||||
const debouncedQuery = useDebouncedValue(q, 300);
|
||||
return useQuery({
|
||||
queryKey: ['search', debouncedQuery, limit],
|
||||
queryFn: () => searchApi.global(debouncedQuery, limit),
|
||||
enabled: !!debouncedQuery.trim(),
|
||||
staleTime: shortStaleTime,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUnreadNotificationCount(options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: ['notifications', 'unread-count'],
|
||||
@@ -896,6 +929,14 @@ export function useAdminAllProducts(status?: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminSimpleProducts() {
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'products', 'simple'],
|
||||
queryFn: adminApi.listProducts,
|
||||
staleTime: shortStaleTime,
|
||||
});
|
||||
}
|
||||
|
||||
export function useReviewProduct() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -918,6 +959,115 @@ export function useUpdateAdminProductImages() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminComparisonTags() {
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'compare-tags'],
|
||||
queryFn: adminApi.listComparisonTags,
|
||||
staleTime: shortStaleTime,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateAdminComparisonTag() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { name: string; slug: string; description?: string; cover_image?: string; icon?: string; sort_order?: number; is_active?: boolean }) =>
|
||||
adminApi.createComparisonTag(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'compare-tags'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAdminComparisonTag() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ tagId, data }: { tagId: number; data: { name?: string; slug?: string; description?: string; cover_image?: string; icon?: string; sort_order?: number; is_active?: boolean } }) =>
|
||||
adminApi.updateComparisonTag(tagId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'compare-tags'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAdminComparisonTag() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (tagId: number) => adminApi.deleteComparisonTag(tagId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'compare-tags'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminComparisonTagItems(tagId: number, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'compare-tags', tagId, 'items'],
|
||||
queryFn: () => adminApi.listComparisonTagItems(tagId),
|
||||
enabled: (options?.enabled !== false) && !!tagId,
|
||||
staleTime: shortStaleTime,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddAdminComparisonTagItems() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ tagId, data }: { tagId: number; data: { product_ids: number[]; sort_order?: number; is_pinned?: boolean } }) =>
|
||||
adminApi.addComparisonTagItems(tagId, data),
|
||||
onSuccess: (_, { tagId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'compare-tags', tagId, 'items'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'compare-tags'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAdminComparisonTagItem() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ tagId, itemId, data }: { tagId: number; itemId: number; data: { sort_order?: number; is_pinned?: boolean } }) =>
|
||||
adminApi.updateComparisonTagItem(tagId, itemId, data),
|
||||
onSuccess: (_, { tagId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'compare-tags', tagId, 'items'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAdminComparisonTagItem() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ tagId, itemId }: { tagId: number; itemId: number }) =>
|
||||
adminApi.deleteComparisonTagItem(tagId, itemId),
|
||||
onSuccess: (_, { tagId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'compare-tags', tagId, 'items'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'compare-tags'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useBackfillPriceHistory() {
|
||||
return useMutation({
|
||||
mutationFn: (productIds?: number[]) => adminApi.backfillPriceHistory(productIds),
|
||||
});
|
||||
}
|
||||
|
||||
export function useBackfillTagPriceHistory() {
|
||||
return useMutation({
|
||||
mutationFn: (tagId: number) => adminApi.backfillTagPriceHistory(tagId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRecordPriceHistory() {
|
||||
return useMutation({
|
||||
mutationFn: (data?: { product_ids?: number[]; force?: boolean }) => adminApi.recordPriceHistory(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRecordTagPriceHistory() {
|
||||
return useMutation({
|
||||
mutationFn: ({ tagId, data }: { tagId: number; data?: { product_ids?: number[]; force?: boolean } }) =>
|
||||
adminApi.recordTagPriceHistory(tagId, data),
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== My Products Hooks ====================
|
||||
|
||||
export function useMyProducts(status?: string, options?: { enabled?: boolean }) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { api } from "./client";
|
||||
import type { AdminBounty, AdminPaymentEvent, AdminUser, AdminProduct, PaginatedResponse } from "../types";
|
||||
import type { AdminBounty, AdminPaymentEvent, AdminUser, AdminProduct, AdminComparisonTag, AdminComparisonTagItem, PaginatedResponse } from "../types";
|
||||
|
||||
export const adminApi = {
|
||||
listUsers: () => api.get<PaginatedResponse<AdminUser>>("/admin/users/").then((r) => r.data),
|
||||
@@ -26,4 +26,31 @@ export const adminApi = {
|
||||
api.post<AdminProduct>(`/admin/products/${productId}/review/`, data).then((r) => r.data),
|
||||
updateProductImages: (productId: number, data: { images: string[]; image?: string }) =>
|
||||
api.put<AdminProduct>(`/admin/products/${productId}/images/`, data).then((r) => r.data),
|
||||
|
||||
// Comparison tags
|
||||
listComparisonTags: () => api.get<AdminComparisonTag[]>("/admin/compare-tags/").then((r) => r.data),
|
||||
createComparisonTag: (data: { name: string; slug: string; description?: string; cover_image?: string; icon?: string; sort_order?: number; is_active?: boolean }) =>
|
||||
api.post<AdminComparisonTag>("/admin/compare-tags/", data).then((r) => r.data),
|
||||
updateComparisonTag: (tagId: number, data: { name?: string; slug?: string; description?: string; cover_image?: string; icon?: string; sort_order?: number; is_active?: boolean }) =>
|
||||
api.patch<AdminComparisonTag>(`/admin/compare-tags/${tagId}`, data).then((r) => r.data),
|
||||
deleteComparisonTag: (tagId: number) =>
|
||||
api.delete<{ message: string }>(`/admin/compare-tags/${tagId}`).then((r) => r.data),
|
||||
listComparisonTagItems: (tagId: number) =>
|
||||
api.get<AdminComparisonTagItem[]>(`/admin/compare-tags/${tagId}/items/`).then((r) => r.data),
|
||||
addComparisonTagItems: (tagId: number, data: { product_ids: number[]; sort_order?: number; is_pinned?: boolean }) =>
|
||||
api.post<AdminComparisonTagItem[]>(`/admin/compare-tags/${tagId}/items/`, data).then((r) => r.data),
|
||||
updateComparisonTagItem: (tagId: number, itemId: number, data: { sort_order?: number; is_pinned?: boolean }) =>
|
||||
api.patch<AdminComparisonTagItem>(`/admin/compare-tags/${tagId}/items/${itemId}`, data).then((r) => r.data),
|
||||
deleteComparisonTagItem: (tagId: number, itemId: number) =>
|
||||
api.delete<{ message: string }>(`/admin/compare-tags/${tagId}/items/${itemId}`).then((r) => r.data),
|
||||
|
||||
// Price history backfill
|
||||
backfillPriceHistory: (productIds?: number[]) =>
|
||||
api.post<{ created: number; skipped: number }>("/admin/price-history/backfill/", { product_ids: productIds }).then((r) => r.data),
|
||||
backfillTagPriceHistory: (tagId: number) =>
|
||||
api.post<{ created: number; skipped: number }>(`/admin/compare-tags/${tagId}/backfill-history/`).then((r) => r.data),
|
||||
recordPriceHistory: (data?: { product_ids?: number[]; force?: boolean }) =>
|
||||
api.post<{ created: number; skipped: number }>("/admin/price-history/record/", data || {}).then((r) => r.data),
|
||||
recordTagPriceHistory: (tagId: number, data?: { product_ids?: number[]; force?: boolean }) =>
|
||||
api.post<{ created: number; skipped: number }>(`/admin/compare-tags/${tagId}/record-history/`, data || {}).then((r) => r.data),
|
||||
};
|
||||
|
||||
25
frontend/src/lib/api/comparisonTags.ts
Normal file
25
frontend/src/lib/api/comparisonTags.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { api } from "./client";
|
||||
import type { ComparisonTag, ComparisonTagDetail, PriceHistoryStats } from "../types";
|
||||
|
||||
export interface ComparisonTagFilters {
|
||||
only_discounted?: boolean;
|
||||
min_discount_percent?: number;
|
||||
only_historical_lowest?: boolean;
|
||||
}
|
||||
|
||||
export const comparisonTagApi = {
|
||||
list: () => api.get<ComparisonTag[]>("/compare-tags/").then((r) => r.data),
|
||||
get: (slug: string, filters?: ComparisonTagFilters) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.only_discounted) params.append("only_discounted", "true");
|
||||
if (filters?.min_discount_percent) params.append("min_discount_percent", String(filters.min_discount_percent));
|
||||
if (filters?.only_historical_lowest) params.append("only_historical_lowest", "true");
|
||||
const query = params.toString();
|
||||
return api.get<ComparisonTagDetail>(`/compare-tags/${slug}${query ? `?${query}` : ""}`).then((r) => r.data);
|
||||
},
|
||||
getPriceStats: (productId: number, days?: number) => {
|
||||
const params = days ? `?days=${days}` : "";
|
||||
return api.get<PriceHistoryStats>(`/products/${productId}/price-stats/${params}`).then((r) => r.data);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,14 +4,16 @@ export { friendApi } from "./friends";
|
||||
export { categoryApi } from "./categories";
|
||||
export { websiteApi } from "./websites";
|
||||
export { productApi } from "./products";
|
||||
export { comparisonTagApi } from "./comparisonTags";
|
||||
export { bountyApi } from "./bounties";
|
||||
export { favoriteApi } from "./favorites";
|
||||
export { notificationApi } from "./notifications";
|
||||
export { searchApi } from "./search";
|
||||
export { paymentApi } from "./payments";
|
||||
export { adminApi } from "./admin";
|
||||
export { isApiError, normalizeApiError, type ApiError } from "./errors";
|
||||
|
||||
export type { ComparisonTagFilters } from "./comparisonTags";
|
||||
|
||||
export type {
|
||||
User,
|
||||
UserBrief,
|
||||
@@ -19,7 +21,13 @@ export type {
|
||||
Website,
|
||||
Product,
|
||||
ProductPrice,
|
||||
ProductPriceHistory,
|
||||
PriceHistoryStats,
|
||||
EnhancedProductPrice,
|
||||
ProductWithPrices,
|
||||
ComparisonTag,
|
||||
ComparisonTagDetail,
|
||||
ComparisonTagItem,
|
||||
Bounty,
|
||||
BountyApplication,
|
||||
BountyComment,
|
||||
@@ -33,10 +41,11 @@ export type {
|
||||
BountyDispute,
|
||||
BountyReview,
|
||||
BountyExtensionRequest,
|
||||
SearchResults,
|
||||
AdminUser,
|
||||
AdminBounty,
|
||||
AdminProduct,
|
||||
AdminComparisonTag,
|
||||
AdminComparisonTagItem,
|
||||
AdminPaymentEvent,
|
||||
PaginatedResponse,
|
||||
MessageResponse,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { api, searchTimeout, uploadTimeout } from "./client";
|
||||
import type { PaginatedResponse, Product, ProductPrice, ProductWithPrices, MyProduct } from "../types";
|
||||
import type { PaginatedResponse, Product, ProductPrice, ProductWithPrices, MyProduct, ProductPriceHistory } from "../types";
|
||||
|
||||
type UploadImageResponse = { url: string };
|
||||
|
||||
@@ -29,6 +29,8 @@ export const productApi = {
|
||||
api.post<Product>("/products/", data).then((r) => r.data),
|
||||
addPrice: (data: { product_id: number; website_id: number; price: string; original_price?: string; currency?: string; url: string; in_stock?: boolean }) =>
|
||||
api.post<ProductPrice>("/products/prices/", data).then((r) => r.data),
|
||||
priceHistory: (productId: number, params?: { website_id?: number; limit?: number }) =>
|
||||
api.get<ProductPriceHistory[]>(`/products/${productId}/price-history/`, { params }).then((r) => r.data),
|
||||
uploadImage: (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { api, searchTimeout } from "./client";
|
||||
import type { SearchResults } from "../types";
|
||||
|
||||
export const searchApi = {
|
||||
global: (q: string, limit?: number) =>
|
||||
api.get<SearchResults>("/search/", { params: { q, limit }, timeout: searchTimeout }).then((r) => r.data),
|
||||
};
|
||||
@@ -73,12 +73,76 @@ export interface ProductPrice {
|
||||
last_checked: string;
|
||||
}
|
||||
|
||||
export interface ProductPriceHistory {
|
||||
id: number;
|
||||
product_id: number;
|
||||
website_id: number;
|
||||
price: string;
|
||||
recorded_at: string;
|
||||
}
|
||||
|
||||
export interface PriceHistoryStats {
|
||||
product_id: number;
|
||||
historical_lowest: string | null;
|
||||
historical_lowest_date: string | null;
|
||||
historical_highest: string | null;
|
||||
historical_highest_date: string | null;
|
||||
average_price: string | null;
|
||||
current_lowest: string | null;
|
||||
is_historical_lowest: boolean;
|
||||
discount_from_highest: string | null;
|
||||
distance_to_lowest: string | null;
|
||||
price_trend: "up" | "down" | "stable";
|
||||
history: ProductPriceHistory[];
|
||||
}
|
||||
|
||||
export interface EnhancedProductPrice extends ProductPrice {
|
||||
historical_lowest: string | null;
|
||||
is_at_historical_lowest: boolean;
|
||||
discount_percent: string | null;
|
||||
discount_amount: string | null;
|
||||
}
|
||||
|
||||
export interface ProductWithPrices extends Product {
|
||||
prices: ProductPrice[];
|
||||
lowest_price: string | null;
|
||||
highest_price: string | null;
|
||||
}
|
||||
|
||||
export interface ComparisonTag {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
cover_image: string | null;
|
||||
icon: string | null;
|
||||
sort_order: number;
|
||||
is_active: boolean;
|
||||
product_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ComparisonTagItem {
|
||||
product: Product;
|
||||
prices: EnhancedProductPrice[];
|
||||
lowest_price: string | null;
|
||||
highest_price: string | null;
|
||||
platform_count: number;
|
||||
// 增强字段
|
||||
historical_lowest: string | null;
|
||||
historical_lowest_date: string | null;
|
||||
is_at_historical_lowest: boolean;
|
||||
discount_from_highest_percent: string | null;
|
||||
is_recommended: boolean;
|
||||
recommendation_reason: string | null;
|
||||
}
|
||||
|
||||
export interface ComparisonTagDetail {
|
||||
tag: ComparisonTag;
|
||||
items: ComparisonTagItem[];
|
||||
}
|
||||
|
||||
export interface Bounty {
|
||||
id: number;
|
||||
title: string;
|
||||
@@ -248,12 +312,6 @@ export interface BountyExtensionRequest {
|
||||
reviewed_at: string | null;
|
||||
}
|
||||
|
||||
export interface SearchResults {
|
||||
products: Product[];
|
||||
websites: Website[];
|
||||
bounties: Bounty[];
|
||||
}
|
||||
|
||||
export interface AdminUser {
|
||||
id: number;
|
||||
open_id: string;
|
||||
@@ -302,6 +360,32 @@ export interface AdminProduct {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AdminComparisonTag {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
cover_image: string | null;
|
||||
icon: string | null;
|
||||
sort_order: number;
|
||||
is_active: boolean;
|
||||
product_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AdminComparisonTagItem {
|
||||
id: number;
|
||||
tag_id: number;
|
||||
product_id: number;
|
||||
product_name: string;
|
||||
product_image: string | null;
|
||||
product_status: string;
|
||||
sort_order: number;
|
||||
is_pinned: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface MyProduct {
|
||||
id: number;
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user