@@ -173,9 +182,6 @@ export default function Home() {
悬赏大厅
-
- 全文搜索
-
diff --git a/frontend/src/features/products/components/ComparisonTagsSection.tsx b/frontend/src/features/products/components/ComparisonTagsSection.tsx
new file mode 100644
index 0000000..6652b72
--- /dev/null
+++ b/frontend/src/features/products/components/ComparisonTagsSection.tsx
@@ -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("");
+
+ 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 (
+
+
+
+
+
+
+ 比价标签
+ {historicalLowestCount > 0 && (
+
+
+ {historicalLowestCount}款历史低价
+
+ )}
+
+
+ {title}
+
+
{description}
+
+
+ {showDetailLink && activeSlug && (
+
+
+
+ )}
+ {ctaLabel && ctaHref && (
+
+
+
+ )}
+
+
+
+
+ {tagsLoading ? (
+
+ 加载中...
+
+ ) : (
+ tags.map((tag) => (
+
+ ))
+ )}
+
+
+ {detailLoading ? (
+
+
+
+ ) : visibleItems.length === 0 ? (
+
+
+ 暂无可对比商品
+
+
+ ) : (
+
+ {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 (
+
+ {/* 推荐角标 */}
+ {item.is_recommended && (
+
+
+ 推荐
+
+ )}
+
+
+
+
+ {/* 历史最低角标 */}
+ {item.is_at_historical_lowest && (
+
+ )}
+
+
+
+ {item.product.name}
+ {item.discount_from_highest_percent &&
+ Number(item.discount_from_highest_percent) >= 20 && (
+
+
+ 降{Number(item.discount_from_highest_percent).toFixed(0)}%
+
+ )}
+
+ {item.product.description && (
+
+ {item.product.description}
+
+ )}
+
+
+ {item.platform_count || prices.length} 个平台
+
+ {lowest && (
+
+ 低至 ¥{lowest}
+
+ )}
+ {highest && lowest && highest !== lowest && (
+
+ 最高 ¥{highest}
+
+ )}
+
+ {/* 历史最低价信息 */}
+ {item.historical_lowest && (
+
+
+ 历史最低 ¥{item.historical_lowest}
+ {item.historical_lowest_date && (
+
+ ·{" "}
+ {formatDistanceToNow(
+ new Date(item.historical_lowest_date),
+ { addSuffix: true, locale: zhCN }
+ )}
+
+ )}
+
+ )}
+
+
+
+
+ {topPrices.map((price, index) => (
+
+
+ {/* 平台Logo */}
+ {price.website_logo ? (
+

+ ) : (
+
+ {(price.website_name || "?")[0]}
+
+ )}
+
+
+ {price.website_name || "未知网站"}
+ {price.is_at_historical_lowest && (
+
+
+
+ )}
+
+
+
+ ¥{price.price}
+
+ {price.original_price &&
+ Number(price.original_price) >
+ Number(price.price) && (
+ <>
+
+ ¥{price.original_price}
+
+
+
+ {price.discount_percent}%
+
+ >
+ )}
+
+
+
+
+ 去购买
+
+
+
+ ))}
+
+ {prices.length > topPrices.length && (
+
+ 还有 {prices.length - topPrices.length} 个平台可比价
+
+ )}
+ {lowestRecord?.last_checked && (
+
+ 价格更新于{" "}
+ {formatDistanceToNow(
+ new Date(lowestRecord.last_checked),
+ { addSuffix: true, locale: zhCN }
+ )}
+
+ )}
+
+
+ );
+ })}
+
+ )}
+
+ {/* 底部统计信息 */}
+ {items.length > 0 && (
+
+ 本标签共 {items.length} 件商品
+ ·
+ 覆盖 {totalPlatforms} 个平台价格
+ {historicalLowestCount > 0 && (
+ <>
+ ·
+
+ {historicalLowestCount} 款处于历史低价
+
+ >
+ )}
+ {recommendedCount > 0 && (
+ <>
+ ·
+
+ {recommendedCount} 款推荐购买
+
+ >
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/features/products/pages/ComparisonTagDetail.tsx b/frontend/src/features/products/pages/ComparisonTagDetail.tsx
new file mode 100644
index 0000000..38032e5
--- /dev/null
+++ b/frontend/src/features/products/pages/ComparisonTagDetail.tsx
@@ -0,0 +1,1180 @@
+import { useMemo, useState } from "react";
+import { Link, useParams } from "wouter";
+import {
+ Line,
+ LineChart,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ ReferenceLine,
+ ReferenceArea,
+ ResponsiveContainer,
+} from "recharts";
+import { format, formatDistanceToNow } from "date-fns";
+import { zhCN } from "date-fns/locale";
+import { Navbar } from "@/components/Navbar";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Switch } from "@/components/ui/switch";
+import { Label } from "@/components/ui/label";
+import { LazyImage } from "@/components/LazyImage";
+import { Input } from "@/components/ui/input";
+import {
+ useComparisonTagDetail,
+ usePriceStats,
+ useMe,
+ useAddFavorite,
+ useCreatePriceMonitor,
+ useCheckFavorite,
+ type ComparisonTagFilters,
+} from "@/hooks/useApi";
+import { toast } from "sonner";
+import {
+ ExternalLink,
+ Loader2,
+ TrendingDown,
+ TrendingUp,
+ Award,
+ Star,
+ Flame,
+ Filter,
+ History,
+ BarChart3,
+ Zap,
+ ChevronDown,
+ ChevronUp,
+ Bell,
+ BellRing,
+} from "lucide-react";
+
+type ChartPoint = { date: string; price: number; isLowest?: boolean };
+
+const TIME_RANGE_OPTIONS = [
+ { value: "7", label: "近7天" },
+ { value: "30", label: "近30天" },
+ { value: "90", label: "近90天" },
+ { value: "365", label: "近1年" },
+ { value: "all", label: "全部" },
+];
+
+const DISCOUNT_OPTIONS = [
+ { value: "0", label: "不限" },
+ { value: "10", label: "≥10%" },
+ { value: "20", label: "≥20%" },
+ { value: "30", label: "≥30%" },
+ { value: "50", label: "≥50%" },
+];
+
+export default function ComparisonTagDetail() {
+ const { slug } = useParams<{ slug: string }>();
+
+ // 用户状态
+ const { data: user } = useMe();
+ const addFavoriteMutation = useAddFavorite();
+ const createMonitorMutation = useCreatePriceMonitor();
+
+ // 筛选状态
+ const [filters, setFilters] = useState({});
+ const [selectedWebsiteId, setSelectedWebsiteId] = useState("all");
+ const [showFilters, setShowFilters] = useState(false);
+
+ // 图表状态
+ const [activeProductId, setActiveProductId] = useState(null);
+ const [chartDays, setChartDays] = useState(30);
+
+ // 降价提醒状态
+ const [alertDialogOpen, setAlertDialogOpen] = useState(false);
+ const [alertProduct, setAlertProduct] = useState<{
+ id: number;
+ name: string;
+ prices: Array<{
+ id: number;
+ website_id: number;
+ website_name: string | null;
+ price: string;
+ }>;
+ historical_lowest: string | null;
+ } | null>(null);
+ const [alertWebsiteId, setAlertWebsiteId] = useState("");
+ const [alertTargetPrice, setAlertTargetPrice] = useState("");
+
+ const { data, isLoading } = useComparisonTagDetail(slug || "", filters);
+ const { data: priceStats } = usePriceStats(
+ activeProductId || 0,
+ chartDays,
+ { enabled: !!activeProductId }
+ );
+
+ // 图表数据处理
+ const chartData = useMemo(() => {
+ if (!priceStats?.history || priceStats.history.length === 0) return [];
+ const map = new Map();
+ const lowestPrice = priceStats.historical_lowest
+ ? Number(priceStats.historical_lowest)
+ : Infinity;
+
+ for (const item of priceStats.history) {
+ const key = format(new Date(item.recorded_at), "MM-dd");
+ const price = Number(item.price);
+ const current = map.get(key);
+ if (current === undefined || price < current.price) {
+ map.set(key, { price, isLowest: price <= lowestPrice * 1.02 });
+ }
+ }
+ return Array.from(map.entries()).map(([date, { price, isLowest }]) => ({
+ date,
+ price,
+ isLowest,
+ }));
+ }, [priceStats]);
+
+ // 计算好价区间(历史最低价 * 1.05)
+ const goodPriceThreshold = useMemo(() => {
+ if (!priceStats?.historical_lowest) return null;
+ return Number(priceStats.historical_lowest) * 1.05;
+ }, [priceStats]);
+
+ const tag = data?.tag;
+ const items = data?.items || [];
+
+ // 网站筛选选项
+ const websiteOptions = useMemo(() => {
+ const map = new Map();
+ for (const item of items) {
+ for (const price of item.prices) {
+ if (price.website_id && price.website_name) {
+ map.set(price.website_id, price.website_name);
+ }
+ }
+ }
+ return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
+ }, [items]);
+
+ // 筛选后的商品
+ const filteredItems = useMemo(() => {
+ if (selectedWebsiteId === "all") return items;
+ const targetId = Number(selectedWebsiteId);
+ return items
+ .map((item) => ({
+ ...item,
+ prices: item.prices.filter((p) => p.website_id === targetId),
+ platform_count: item.prices.filter((p) => p.website_id === targetId)
+ .length,
+ }))
+ .filter((item) => item.prices.length > 0);
+ }, [items, selectedWebsiteId]);
+
+ // 统计数据
+ const stats = useMemo(() => {
+ const productsCount = filteredItems.length;
+ const platformsCount = filteredItems.reduce(
+ (sum, item) => sum + (item.platform_count || 0),
+ 0
+ );
+ let lowest: number | null = null;
+ let highest: number | null = null;
+ let maxDrop: {
+ amount: number;
+ percent: number;
+ productName: string;
+ websiteName: string | null;
+ } | null = null;
+ let historicalLowestCount = 0;
+ let recommendedCount = 0;
+
+ for (const item of filteredItems) {
+ if (item.is_at_historical_lowest) historicalLowestCount++;
+ if (item.is_recommended) recommendedCount++;
+
+ for (const price of item.prices) {
+ const value = Number(price.price);
+ if (Number.isFinite(value)) {
+ if (lowest === null || value < lowest) lowest = value;
+ if (highest === null || value > highest) highest = value;
+ }
+ if (
+ price.original_price &&
+ Number(price.original_price) > Number(price.price)
+ ) {
+ const drop = Number(price.original_price) - Number(price.price);
+ const percent =
+ (drop / Number(price.original_price)) * 100;
+ if (!maxDrop || drop > maxDrop.amount) {
+ maxDrop = {
+ amount: drop,
+ percent,
+ productName: item.product.name,
+ websiteName: price.website_name || null,
+ };
+ }
+ }
+ }
+ }
+ return {
+ productsCount,
+ platformsCount,
+ lowest,
+ highest,
+ maxDrop,
+ historicalLowestCount,
+ recommendedCount,
+ };
+ }, [filteredItems]);
+
+ // 最近降价TOP5
+ const recentDrops = useMemo(() => {
+ const drops: Array<{
+ productId: number;
+ productName: string;
+ websiteName: string | null;
+ amount: number;
+ percent: number;
+ price: string;
+ isHistoricalLowest: boolean;
+ }> = [];
+ for (const item of filteredItems) {
+ for (const price of item.prices) {
+ if (
+ price.original_price &&
+ Number(price.original_price) > Number(price.price)
+ ) {
+ const amount = Number(price.original_price) - Number(price.price);
+ drops.push({
+ productId: item.product.id,
+ productName: item.product.name,
+ websiteName: price.website_name || null,
+ amount,
+ percent: Number(price.discount_percent) || 0,
+ price: price.price,
+ isHistoricalLowest: price.is_at_historical_lowest,
+ });
+ }
+ }
+ }
+ return drops.sort((a, b) => b.amount - a.amount).slice(0, 5);
+ }, [filteredItems]);
+
+ // 处理筛选变更
+ const handleFilterChange = (key: keyof ComparisonTagFilters, value: any) => {
+ setFilters((prev) => ({ ...prev, [key]: value }));
+ };
+
+ // 打开降价提醒对话框
+ const openAlertDialog = (item: typeof filteredItems[0]) => {
+ if (!user) {
+ toast.error("请先登录后再设置降价提醒");
+ return;
+ }
+ setAlertProduct({
+ id: item.product.id,
+ name: item.product.name,
+ prices: item.prices.map((p) => ({
+ id: p.id,
+ website_id: p.website_id,
+ website_name: p.website_name,
+ price: p.price,
+ })),
+ historical_lowest: item.historical_lowest,
+ });
+ setAlertWebsiteId(item.prices[0]?.website_id?.toString() || "");
+ setAlertTargetPrice(item.historical_lowest || item.lowest_price || "");
+ setAlertDialogOpen(true);
+ };
+
+ // 设置降价提醒
+ const handleSetAlert = async () => {
+ if (!alertProduct || !alertWebsiteId) {
+ toast.error("请选择平台");
+ return;
+ }
+ if (!alertTargetPrice || Number(alertTargetPrice) <= 0) {
+ toast.error("请输入有效的目标价格");
+ return;
+ }
+
+ try {
+ // 1. 先添加收藏
+ const favorite = await addFavoriteMutation.mutateAsync({
+ product_id: alertProduct.id,
+ website_id: Number(alertWebsiteId),
+ });
+
+ // 2. 创建价格监控
+ await createMonitorMutation.mutateAsync({
+ favoriteId: favorite.id,
+ data: {
+ target_price: alertTargetPrice,
+ is_active: true,
+ notify_enabled: true,
+ notify_on_target: true,
+ },
+ });
+
+ toast.success("降价提醒设置成功!当价格降到目标价格时,我们会通知您。");
+ setAlertDialogOpen(false);
+ setAlertProduct(null);
+ } catch (error: any) {
+ const msg = error?.response?.data?.message || error?.message || "设置失败";
+ if (msg.includes("已收藏") || msg.includes("already")) {
+ toast.info("该商品已在收藏中,请到收藏页面设置价格提醒");
+ } else {
+ toast.error(msg);
+ }
+ }
+ };
+
+ // 自定义图表Tooltip
+ const CustomTooltip = ({ active, payload, label }: any) => {
+ if (active && payload && payload.length) {
+ const data = payload[0].payload;
+ return (
+
+
{label}
+
¥{data.price.toFixed(2)}
+ {data.isLowest && (
+
+
+ 接近历史最低
+
+ )}
+
+ );
+ }
+ return null;
+ };
+
+ return (
+
+
+
+
+ {isLoading ? (
+
+
+
+ ) : tag ? (
+ <>
+ {/* 头部信息 */}
+
+
+
+ 比价专题
+
+ {tag.product_count} 个商品
+
+ {stats.historicalLowestCount > 0 && (
+
+
+ {stats.historicalLowestCount}款历史低价
+
+ )}
+
+
{tag.name}
+ {tag.description && (
+
+ {tag.description}
+
+ )}
+
+
+
+
+
+
+ {/* 筛选栏 */}
+
+
+
+
+
+ 筛选条件
+
+
+
+
+ {showFilters && (
+
+
+ {/* 平台筛选 */}
+
+
+
+
+
+ {/* 降价幅度筛选 */}
+
+
+
+
+
+ {/* 只看降价 */}
+
+
+ handleFilterChange("only_discounted", v)
+ }
+ />
+
+
+
+ {/* 只看历史低价 */}
+
+
+ handleFilterChange("only_historical_lowest", v)
+ }
+ />
+
+
+
+
+ )}
+
+
+ {/* 统计卡片 */}
+
+
+
+
+ 商品数
+
+
+
+ {stats.productsCount}
+
+
+
+
+
+ 平台覆盖
+
+
+
+ {stats.platformsCount}
+
+
+
+
+
+ 最低价
+
+
+
+ {stats.lowest !== null ? `¥${stats.lowest}` : "-"}
+
+
+
+
+
+ 历史低价
+
+
+
+ {stats.historicalLowestCount}款
+
+
+
+
+
+ 最大降价
+
+
+
+ {stats.maxDrop ? (
+
+
+ ¥{stats.maxDrop.amount.toFixed(2)}
+
+
+ {stats.maxDrop.productName}
+
+
+ ) : (
+ "-"
+ )}
+
+
+
+
+ {/* 最近降价TOP5 */}
+ {recentDrops.length > 0 && (
+
+
+
+
+ 最近降价TOP5
+
+
+ 基于当前原价与现价的降价幅度
+
+
+
+ {recentDrops.map((drop, index) => (
+
+
+
+ {index + 1}
+
+
+
+ {drop.productName}
+ {drop.isHistoricalLowest && (
+
+
+ 历史最低
+
+ )}
+
+
+ {drop.websiteName || "未知网站"}
+
+
+
+
+
+ 降 ¥{drop.amount.toFixed(2)}
+
+
+ {drop.percent.toFixed(0)}% OFF · 现价 ¥{drop.price}
+
+
+
+ ))}
+
+
+ )}
+
+ {/* 商品列表 */}
+ {filteredItems.length === 0 ? (
+
+
+ 暂无符合条件的商品
+
+
+ ) : (
+
+ {filteredItems.map((item) => {
+ const prices = [...item.prices].sort(
+ (a, b) => Number(a.price) - Number(b.price)
+ );
+ const lowest = prices[0];
+ const highest = prices[prices.length - 1];
+ const image =
+ item.product.images?.[0] || item.product.image || "";
+
+ return (
+
+
+ {/* 商品图片 */}
+
+
+ {/* 推荐标签 */}
+ {item.is_recommended && (
+
+
+ 推荐
+
+ )}
+
+
+ {/* 商品信息 */}
+
+
+ {item.product.name}
+ {item.is_at_historical_lowest && (
+
+
+ 历史最低
+
+ )}
+ {item.is_recommended &&
+ item.recommendation_reason &&
+ !item.is_at_historical_lowest && (
+
+
+ {item.recommendation_reason}
+
+ )}
+
+ {item.product.description && (
+
+ {item.product.description}
+
+ )}
+
+
+ {item.platform_count} 个平台
+
+ {lowest && (
+
+ 低至 ¥{lowest.price}
+
+ )}
+ {highest &&
+ lowest &&
+ highest.price !== lowest.price && (
+
+ 最高 ¥{highest.price}
+
+ )}
+ {item.historical_lowest && (
+
+ 历史最低 ¥{item.historical_lowest}
+
+ )}
+ {item.discount_from_highest_percent &&
+ Number(item.discount_from_highest_percent) >
+ 0 && (
+
+
+ 降
+ {Number(
+ item.discount_from_highest_percent
+ ).toFixed(0)}
+ %
+
+ )}
+
+
+
+ {/* 操作按钮 */}
+
+
+
+
+
+
+
+
+
+
+ {prices.map((price, index) => (
+
+
+ {/* 平台Logo */}
+ {price.website_logo ? (
+

+ ) : (
+
+ {(price.website_name || "?")[0]}
+
+ )}
+
+
+ {price.website_name || "未知网站"}
+ {index === 0 && (
+
+
+ 最低
+
+ )}
+ {price.is_at_historical_lowest && (
+
+
+ 历史最低
+
+ )}
+
+
+
+ ¥{price.price}
+
+ {price.original_price &&
+ Number(price.original_price) >
+ Number(price.price) && (
+ <>
+
+ ¥{price.original_price}
+
+
+
+ {price.discount_percent}% OFF
+
+ >
+ )}
+
+
+
+
+ 去购买
+
+
+
+ ))}
+
+
+
+ );
+ })}
+
+ )}
+ >
+ ) : (
+
+
+ 标签不存在或已下线
+
+
+ )}
+
+
+
+ {/* 价格趋势弹窗 */}
+
+
+ {/* 降价提醒弹窗 */}
+
+
+ );
+}
diff --git a/frontend/src/features/products/pages/ProductDetail.tsx b/frontend/src/features/products/pages/ProductDetail.tsx
index 2e99803..adb07b9 100644
--- a/frontend/src/features/products/pages/ProductDetail.tsx
+++ b/frontend/src/features/products/pages/ProductDetail.tsx
@@ -287,9 +287,6 @@ export default function ProductDetail() {
悬赏大厅
-
- 全文搜索
-
{isAuthenticated && (
个人中心
diff --git a/frontend/src/features/products/pages/Products.tsx b/frontend/src/features/products/pages/Products.tsx
index 50d407b..86de461 100644
--- a/frontend/src/features/products/pages/Products.tsx
+++ b/frontend/src/features/products/pages/Products.tsx
@@ -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("all");
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
@@ -59,67 +39,7 @@ export default function Products() {
const [favoriteWebsiteByProduct, setFavoriteWebsiteByProduct] = useState>({});
const [favoriteDialogOpen, setFavoriteDialogOpen] = useState(false);
const [favoriteDialogProduct, setFavoriteDialogProduct] = useState(null);
- const [isAddOpen, setIsAddOpen] = useState(false);
- const [isNewCategory, setIsNewCategory] = useState(false);
- const [isCreatingCategory, setIsCreatingCategory] = useState(false);
- const [imageFiles, setImageFiles] = useState([]);
- const [isUploadingImage, setIsUploadingImage] = useState(false);
- const [confirmOpen, setConfirmOpen] = useState(false);
- const confirmActionRef = useRef 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 (
- {isAuthenticated && (
- <>
-
-
- >
- )}
+
-
-
-
- 确认删除图片
- 该图片将从列表中移除,删除后无法恢复。
-
- {confirmItem && (
-
- {confirmItem.file ? (
-
- ) : (
-
- )}
-
{confirmItem.name || "图片"}
-
- )}
- {confirmMeta?.isFirst && (
-
- 当前为首图,删除后将自动使用下一张图片作为首图{confirmMeta.nextName ? `(${confirmMeta.nextName})` : "。"}
-
- )}
-
- 取消
- {
- confirmActionRef.current?.();
- confirmActionRef.current = null;
- setConfirmItem(null);
- }}
- >
- 删除
-
-
-
-
+
+
{/* Products Section */}
diff --git a/frontend/src/features/search/pages/Search.tsx b/frontend/src/features/search/pages/Search.tsx
deleted file mode 100644
index 1cd36c1..0000000
--- a/frontend/src/features/search/pages/Search.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
-
-
-
-
-
- setQuery(e.target.value)}
- />
-
-
-
-
-
-
- {!debouncedQuery.trim() && (
-
-
- 请输入关键词开始搜索
-
-
- )}
-
- {isLoading && (
-
-
-
- )}
-
- {!isLoading && debouncedQuery.trim() && (
- <>
-
-
-
-
- 商品
- {products.length}
-
-
- 去商品导航
-
-
-
- {products.length === 0 ? (
- 暂无匹配商品
- ) : (
- products.map((product) => (
-
-
-
-
{product.name}
-
- {product.description || "暂无描述"}
-
-
-
查看
-
-
- ))
- )}
-
-
-
-
-
-
-
- 网站
- {websites.length}
-
-
- 去商品导航
-
-
-
- {websites.length === 0 ? (
- 暂无匹配网站
- ) : (
- websites.map((website) => (
-
-
-
{website.name}
-
- {website.description || website.url}
-
-
- 访问
-
- ))
- )}
-
-
-
-
-
-
-
- 悬赏
- {bounties.length}
-
-
- 去悬赏大厅
-
-
-
- {bounties.length === 0 ? (
- 暂无匹配悬赏
- ) : (
- bounties.map((bounty) => (
-
-
-
-
{bounty.title}
-
- {bounty.description}
-
-
-
查看
-
-
- ))
- )}
-
-
- >
- )}
-
-
-
- );
-}
diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts
index da67fca..1f53f69 100644
--- a/frontend/src/hooks/useApi.ts
+++ b/frontend/src/hooks/useApi.ts
@@ -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 }) {
diff --git a/frontend/src/lib/api/admin.ts b/frontend/src/lib/api/admin.ts
index 30ca386..f65620a 100644
--- a/frontend/src/lib/api/admin.ts
+++ b/frontend/src/lib/api/admin.ts
@@ -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>("/admin/users/").then((r) => r.data),
@@ -26,4 +26,31 @@ export const adminApi = {
api.post(`/admin/products/${productId}/review/`, data).then((r) => r.data),
updateProductImages: (productId: number, data: { images: string[]; image?: string }) =>
api.put(`/admin/products/${productId}/images/`, data).then((r) => r.data),
+
+ // Comparison tags
+ listComparisonTags: () => api.get("/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("/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(`/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(`/admin/compare-tags/${tagId}/items/`).then((r) => r.data),
+ addComparisonTagItems: (tagId: number, data: { product_ids: number[]; sort_order?: number; is_pinned?: boolean }) =>
+ api.post(`/admin/compare-tags/${tagId}/items/`, data).then((r) => r.data),
+ updateComparisonTagItem: (tagId: number, itemId: number, data: { sort_order?: number; is_pinned?: boolean }) =>
+ api.patch(`/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),
};
diff --git a/frontend/src/lib/api/comparisonTags.ts b/frontend/src/lib/api/comparisonTags.ts
new file mode 100644
index 0000000..1514c42
--- /dev/null
+++ b/frontend/src/lib/api/comparisonTags.ts
@@ -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("/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(`/compare-tags/${slug}${query ? `?${query}` : ""}`).then((r) => r.data);
+ },
+ getPriceStats: (productId: number, days?: number) => {
+ const params = days ? `?days=${days}` : "";
+ return api.get(`/products/${productId}/price-stats/${params}`).then((r) => r.data);
+ },
+};
+
diff --git a/frontend/src/lib/api/index.ts b/frontend/src/lib/api/index.ts
index 87f1424..a515315 100644
--- a/frontend/src/lib/api/index.ts
+++ b/frontend/src/lib/api/index.ts
@@ -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,
diff --git a/frontend/src/lib/api/products.ts b/frontend/src/lib/api/products.ts
index 6bf2473..d1de518 100644
--- a/frontend/src/lib/api/products.ts
+++ b/frontend/src/lib/api/products.ts
@@ -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("/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("/products/prices/", data).then((r) => r.data),
+ priceHistory: (productId: number, params?: { website_id?: number; limit?: number }) =>
+ api.get(`/products/${productId}/price-history/`, { params }).then((r) => r.data),
uploadImage: (file: File) => {
const formData = new FormData();
formData.append("file", file);
diff --git a/frontend/src/lib/api/search.ts b/frontend/src/lib/api/search.ts
deleted file mode 100644
index 00b4183..0000000
--- a/frontend/src/lib/api/search.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { api, searchTimeout } from "./client";
-import type { SearchResults } from "../types";
-
-export const searchApi = {
- global: (q: string, limit?: number) =>
- api.get("/search/", { params: { q, limit }, timeout: searchTimeout }).then((r) => r.data),
-};
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index 838d52a..f09d187 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -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;