This commit is contained in:
27942
2026-02-04 15:25:04 +08:00
parent fc0679b199
commit 1b5adeaf22
33 changed files with 3597 additions and 894 deletions

View File

@@ -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

View File

@@ -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} />

View File

@@ -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 () => {

View File

@@ -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"}>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -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">

View File

@@ -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:15MB</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">

View File

@@ -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>
);
}

View File

@@ -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 }) {

View File

@@ -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),
};

View 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);
},
};

View File

@@ -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,

View File

@@ -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);

View 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),
};

View File

@@ -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;