diff --git a/backend/apps/admin/api.py b/backend/apps/admin/api.py index dc80a76..82a5ef2 100644 --- a/backend/apps/admin/api.py +++ b/backend/apps/admin/api.py @@ -10,7 +10,7 @@ from ninja_jwt.authentication import JWTAuth from ninja.pagination import paginate, PageNumberPagination from apps.users.models import User -from apps.products.models import Product, Website, Category +from apps.products.models import Product, Website, Category, ComparisonTag, ComparisonTagItem, ProductPrice, ProductPriceHistory from apps.bounties.models import Bounty, BountyDispute, PaymentEvent router = Router() @@ -40,6 +40,10 @@ class SimpleOut(Schema): name: str +class MessageOut(Schema): + message: str + + class BountyAdminOut(Schema): id: int title: str @@ -193,6 +197,77 @@ class ProductImagesIn(Schema): image: Optional[str] = None +class ComparisonTagAdminOut(Schema): + id: int + name: str + slug: str + description: Optional[str] = None + cover_image: Optional[str] = None + icon: Optional[str] = None + sort_order: int + is_active: bool + product_count: int = 0 + created_at: datetime + updated_at: datetime + + +class ComparisonTagIn(Schema): + name: str + slug: str + description: Optional[str] = None + cover_image: Optional[str] = None + icon: Optional[str] = None + sort_order: int = 0 + is_active: bool = True + + +class ComparisonTagUpdate(Schema): + name: Optional[str] = None + slug: Optional[str] = None + description: Optional[str] = None + cover_image: Optional[str] = None + icon: Optional[str] = None + sort_order: Optional[int] = None + is_active: Optional[bool] = None + + +class ComparisonTagItemOut(Schema): + id: int + tag_id: int + product_id: int + product_name: str + product_image: Optional[str] = None + product_status: str + sort_order: int + is_pinned: bool + created_at: datetime + + +class ComparisonTagItemIn(Schema): + product_ids: List[int] + sort_order: int = 0 + is_pinned: bool = False + + +class ComparisonTagItemUpdate(Schema): + sort_order: Optional[int] = None + is_pinned: Optional[bool] = None + + +class BackfillHistoryIn(Schema): + product_ids: Optional[List[int]] = None + + +class BackfillHistoryOut(Schema): + created: int + skipped: int + + +class RecordHistoryIn(Schema): + product_ids: Optional[List[int]] = None + force: bool = False + + @router.get("/products/pending/", response=List[ProductAdminOut], auth=JWTAuth()) @paginate(PageNumberPagination, page_size=20) def list_pending_products(request): @@ -262,3 +337,235 @@ def update_product_images(request, product_id: int, data: ProductImagesIn): product.images = images product.save(update_fields=["image", "images", "updated_at"]) return product + + +# ==================== Comparison Tag Management ==================== + +@router.get("/compare-tags/", response=List[ComparisonTagAdminOut], auth=JWTAuth()) +def list_compare_tags(request): + require_admin(request.auth) + tags = ComparisonTag.objects.all().order_by("sort_order", "id") + return [ + ComparisonTagAdminOut( + id=tag.id, + 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, + product_count=tag.items.count(), + created_at=tag.created_at, + updated_at=tag.updated_at, + ) + for tag in tags + ] + + +@router.post("/compare-tags/", response=ComparisonTagAdminOut, auth=JWTAuth()) +def create_compare_tag(request, data: ComparisonTagIn): + require_admin(request.auth) + if ComparisonTag.objects.filter(slug=data.slug).exists(): + raise HttpError(400, "标签标识已存在") + tag = ComparisonTag.objects.create(**data.dict()) + return ComparisonTagAdminOut( + id=tag.id, + 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, + product_count=0, + created_at=tag.created_at, + updated_at=tag.updated_at, + ) + + +@router.patch("/compare-tags/{tag_id}", response=ComparisonTagAdminOut, auth=JWTAuth()) +def update_compare_tag(request, tag_id: int, data: ComparisonTagUpdate): + require_admin(request.auth) + try: + tag = ComparisonTag.objects.get(id=tag_id) + except ComparisonTag.DoesNotExist: + raise HttpError(404, "标签不存在") + update_data = data.dict(exclude_unset=True) + if "slug" in update_data: + if ComparisonTag.objects.filter(slug=update_data["slug"]).exclude(id=tag_id).exists(): + raise HttpError(400, "标签标识已存在") + for key, value in update_data.items(): + setattr(tag, key, value) + tag.save() + return ComparisonTagAdminOut( + id=tag.id, + 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, + product_count=tag.items.count(), + created_at=tag.created_at, + updated_at=tag.updated_at, + ) + + +@router.delete("/compare-tags/{tag_id}", response=MessageOut, auth=JWTAuth()) +def delete_compare_tag(request, tag_id: int): + require_admin(request.auth) + deleted, _ = ComparisonTag.objects.filter(id=tag_id).delete() + if not deleted: + raise HttpError(404, "标签不存在") + return MessageOut(message="标签已删除") + + +@router.get("/compare-tags/{tag_id}/items/", response=List[ComparisonTagItemOut], auth=JWTAuth()) +def list_compare_tag_items(request, tag_id: int): + require_admin(request.auth) + items = ( + ComparisonTagItem.objects.select_related("product") + .filter(tag_id=tag_id) + .order_by("-is_pinned", "sort_order", "id") + ) + return [ + ComparisonTagItemOut( + id=item.id, + tag_id=item.tag_id, + product_id=item.product_id, + product_name=item.product.name, + product_image=item.product.image, + product_status=item.product.status, + sort_order=item.sort_order, + is_pinned=item.is_pinned, + created_at=item.created_at, + ) + for item in items + ] + + +@router.post("/compare-tags/{tag_id}/items/", response=List[ComparisonTagItemOut], auth=JWTAuth()) +def add_compare_tag_items(request, tag_id: int, data: ComparisonTagItemIn): + require_admin(request.auth) + try: + tag = ComparisonTag.objects.get(id=tag_id) + except ComparisonTag.DoesNotExist: + raise HttpError(404, "标签不存在") + product_ids = list(dict.fromkeys(data.product_ids or [])) + if not product_ids: + raise HttpError(400, "请选择商品") + existing = set( + ComparisonTagItem.objects.filter(tag=tag, product_id__in=product_ids).values_list("product_id", flat=True) + ) + items = [] + for product_id in product_ids: + if product_id in existing: + continue + try: + product = Product.objects.get(id=product_id) + except Product.DoesNotExist: + continue + items.append( + ComparisonTagItem( + tag=tag, + product=product, + sort_order=data.sort_order, + is_pinned=data.is_pinned, + ) + ) + if items: + ComparisonTagItem.objects.bulk_create(items) + + return list_compare_tag_items(request, tag_id) + + +@router.patch("/compare-tags/{tag_id}/items/{item_id}", response=ComparisonTagItemOut, auth=JWTAuth()) +def update_compare_tag_item(request, tag_id: int, item_id: int, data: ComparisonTagItemUpdate): + require_admin(request.auth) + try: + item = ComparisonTagItem.objects.select_related("product").get(id=item_id, tag_id=tag_id) + except ComparisonTagItem.DoesNotExist: + raise HttpError(404, "标签商品不存在") + update_data = data.dict(exclude_unset=True) + for key, value in update_data.items(): + setattr(item, key, value) + item.save() + return ComparisonTagItemOut( + id=item.id, + tag_id=item.tag_id, + product_id=item.product_id, + product_name=item.product.name, + product_image=item.product.image, + product_status=item.product.status, + sort_order=item.sort_order, + is_pinned=item.is_pinned, + created_at=item.created_at, + ) + + +@router.delete("/compare-tags/{tag_id}/items/{item_id}", response=MessageOut, auth=JWTAuth()) +def delete_compare_tag_item(request, tag_id: int, item_id: int): + require_admin(request.auth) + deleted, _ = ComparisonTagItem.objects.filter(id=item_id, tag_id=tag_id).delete() + if not deleted: + raise HttpError(404, "标签商品不存在") + return MessageOut(message="标签商品已移除") + + +def _record_price_history(product_ids: Optional[List[int]] = None, force: bool = False): + queryset = ProductPrice.objects.select_related("product", "website").all() + if product_ids: + queryset = queryset.filter(product_id__in=product_ids) + created = 0 + skipped = 0 + for price in queryset: + latest = ProductPriceHistory.objects.filter( + product_id=price.product_id, + website_id=price.website_id, + ).order_by("-recorded_at").first() + if force or (not latest or latest.price != price.price): + ProductPriceHistory.objects.create( + product_id=price.product_id, + website_id=price.website_id, + price=price.price, + ) + created += 1 + else: + skipped += 1 + return created, skipped + + +@router.post("/price-history/backfill/", response=BackfillHistoryOut, auth=JWTAuth()) +def backfill_price_history(request, data: BackfillHistoryIn): + require_admin(request.auth) + created, skipped = _record_price_history(data.product_ids, force=False) + return BackfillHistoryOut(created=created, skipped=skipped) + + +@router.post("/compare-tags/{tag_id}/backfill-history/", response=BackfillHistoryOut, auth=JWTAuth()) +def backfill_tag_price_history(request, tag_id: int): + require_admin(request.auth) + product_ids = list( + ComparisonTagItem.objects.filter(tag_id=tag_id).values_list("product_id", flat=True) + ) + created, skipped = _record_price_history(product_ids, force=False) + return BackfillHistoryOut(created=created, skipped=skipped) + + +@router.post("/price-history/record/", response=BackfillHistoryOut, auth=JWTAuth()) +def record_price_history(request, data: RecordHistoryIn): + require_admin(request.auth) + created, skipped = _record_price_history(data.product_ids, force=data.force) + return BackfillHistoryOut(created=created, skipped=skipped) + + +@router.post("/compare-tags/{tag_id}/record-history/", response=BackfillHistoryOut, auth=JWTAuth()) +def record_tag_price_history(request, tag_id: int, data: RecordHistoryIn): + require_admin(request.auth) + product_ids = list( + ComparisonTagItem.objects.filter(tag_id=tag_id).values_list("product_id", flat=True) + ) + created, skipped = _record_price_history(product_ids, force=data.force) + return BackfillHistoryOut(created=created, skipped=skipped) diff --git a/backend/apps/products/admin.py b/backend/apps/products/admin.py index 3e76453..0085107 100644 --- a/backend/apps/products/admin.py +++ b/backend/apps/products/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from .models import Category, Website, Product, ProductPrice +from .models import Category, Website, Product, ProductPrice, ProductPriceHistory, ComparisonTag, ComparisonTagItem @admin.register(Category) @@ -33,3 +33,36 @@ class ProductPriceAdmin(admin.ModelAdmin): list_filter = ['website', 'in_stock', 'currency'] search_fields = ['product__name', 'website__name'] ordering = ['-updated_at'] + + +@admin.register(ProductPriceHistory) +class ProductPriceHistoryAdmin(admin.ModelAdmin): + list_display = ['id', 'product', 'website', 'price', 'recorded_at'] + list_filter = ['website'] + search_fields = ['product__name', 'website__name'] + ordering = ['-recorded_at'] + + +class ComparisonTagItemInline(admin.TabularInline): + model = ComparisonTagItem + extra = 1 + autocomplete_fields = ["product"] + ordering = ["-is_pinned", "sort_order", "id"] + + +@admin.register(ComparisonTag) +class ComparisonTagAdmin(admin.ModelAdmin): + list_display = ['id', 'name', 'slug', 'is_active', 'sort_order', 'created_at'] + list_filter = ['is_active'] + search_fields = ['name', 'slug'] + prepopulated_fields = {'slug': ('name',)} + ordering = ['sort_order', 'id'] + inlines = [ComparisonTagItemInline] + + +@admin.register(ComparisonTagItem) +class ComparisonTagItemAdmin(admin.ModelAdmin): + list_display = ['id', 'tag', 'product', 'is_pinned', 'sort_order', 'created_at'] + list_filter = ['tag', 'is_pinned'] + search_fields = ['product__name', 'tag__name'] + ordering = ['-created_at'] diff --git a/backend/apps/products/api.py b/backend/apps/products/api.py index 02592a1..74ace3a 100644 --- a/backend/apps/products/api.py +++ b/backend/apps/products/api.py @@ -22,15 +22,19 @@ from django.db import transaction, IntegrityError from django.shortcuts import get_object_or_404 from django.core.exceptions import ObjectDoesNotExist from django.core.files.storage import default_storage -from django.views.decorators.cache import cache_page - -from .models import Category, Website, Product, ProductPrice +from .models import Category, Website, Product, ProductPrice, ProductPriceHistory, ComparisonTag, ComparisonTagItem from .schemas import ( CategoryOut, CategoryIn, WebsiteOut, WebsiteIn, WebsiteFilter, ProductOut, ProductIn, ProductWithPricesOut, ProductPriceOut, ProductPriceIn, ProductFilter, ProductSearchFilter, + ProductPriceHistoryOut, + PriceHistoryStatsOut, + EnhancedProductPriceOut, + ComparisonTagOut, + ComparisonTagDetailOut, + ComparisonTagItemOut, ImportResultOut, MyProductOut, UploadImageOut, @@ -40,21 +44,29 @@ from apps.favorites.models import Favorite router = Router() category_router = Router() website_router = Router() +compare_tag_router = Router() MAX_PRODUCT_IMAGES = 6 +def record_product_price_history(price_record: ProductPrice): + """Record price history for a product price record.""" + ProductPriceHistory.objects.create( + product_id=price_record.product_id, + website_id=price_record.website_id, + price=price_record.price, + ) + + # ==================== Category Routes ==================== @category_router.get("/", response=List[CategoryOut]) -@cache_page(settings.CACHE_TTL_SECONDS) def list_categories(request): """Get all categories.""" return Category.objects.all() @category_router.get("/{slug}", response=CategoryOut) -@cache_page(settings.CACHE_TTL_SECONDS) def get_category_by_slug(request, slug: str): """Get category by slug.""" return get_object_or_404(Category, slug=slug) @@ -115,7 +127,6 @@ def create_category(request, data: CategoryIn): @website_router.get("/", response=List[WebsiteOut]) @paginate(PageNumberPagination, page_size=20) -@cache_page(settings.CACHE_TTL_SECONDS) def list_websites(request, filters: WebsiteFilter = Query(...)): """Get all websites with optional filters.""" queryset = Website.objects.all() @@ -129,7 +140,6 @@ def list_websites(request, filters: WebsiteFilter = Query(...)): @website_router.get("/{website_id}", response=WebsiteOut) -@cache_page(settings.CACHE_TTL_SECONDS) def get_website(request, website_id: int): """Get website by ID.""" return get_object_or_404(Website, id=website_id) @@ -142,6 +152,199 @@ def create_website(request, data: WebsiteIn): return website +# ==================== Comparison Tag Routes ==================== + +@compare_tag_router.get("/", response=List[ComparisonTagOut]) +def list_compare_tags(request): + """List active comparison tags.""" + tags = ( + ComparisonTag.objects.filter(is_active=True) + .annotate(product_count=Count("items", filter=Q(items__product__status="approved"), distinct=True)) + .order_by("sort_order", "id") + ) + return [ + ComparisonTagOut( + id=tag.id, + 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, + product_count=tag.product_count or 0, + created_at=tag.created_at, + updated_at=tag.updated_at, + ) + for tag in tags + ] + + +@compare_tag_router.get("/{slug}", response=ComparisonTagDetailOut) +def get_compare_tag_detail(request, slug: str, only_discounted: bool = False, min_discount_percent: Optional[int] = None, only_historical_lowest: bool = False): + """Get comparison tag detail with product prices and enhanced stats.""" + tag = get_object_or_404(ComparisonTag, slug=slug, is_active=True) + prices_prefetch = Prefetch( + "product__prices", + queryset=ProductPrice.objects.select_related("website"), + ) + items = ( + ComparisonTagItem.objects.filter(tag=tag, product__status="approved") + .select_related("product", "product__category") + .prefetch_related(prices_prefetch) + .order_by("-is_pinned", "sort_order", "id") + ) + + # 获取所有商品ID,批量查询历史最低价 + product_ids = [item.product_id for item in items] + historical_lowest_map = {} + if product_ids: + from django.db.models.functions import Coalesce + from django.db.models import Subquery, OuterRef + # 查询每个商品的历史最低价 + for pid in product_ids: + history = ProductPriceHistory.objects.filter(product_id=pid).order_by("price").first() + if history: + historical_lowest_map[pid] = { + "price": history.price, + "date": history.recorded_at, + } + + item_out: List[ComparisonTagItemOut] = [] + for item in items: + product = item.product + prices = list(product.prices.all()) + + # 获取历史最低价信息 + hist_info = historical_lowest_map.get(product.id, {}) + historical_lowest = hist_info.get("price") + historical_lowest_date = hist_info.get("date") + + # 当前最低价 + current_lowest = min((pp.price for pp in prices), default=None) + current_highest = max((pp.price for pp in prices), default=None) + + # 判断是否处于历史最低 + is_at_historical_lowest = False + if historical_lowest and current_lowest: + is_at_historical_lowest = current_lowest <= historical_lowest + + # 计算距最高价降幅百分比 + discount_from_highest_percent = None + if current_lowest and current_highest and current_highest > 0: + discount_from_highest_percent = round((1 - current_lowest / current_highest) * 100, 1) + + # 推荐逻辑 + is_recommended = False + recommendation_reason = None + if is_at_historical_lowest: + is_recommended = True + recommendation_reason = "历史最低价" + elif discount_from_highest_percent and discount_from_highest_percent >= 30: + is_recommended = True + recommendation_reason = f"降幅{discount_from_highest_percent}%" + + # 筛选:只看降价商品 + if only_discounted: + has_discount = any(pp.original_price and pp.original_price > pp.price for pp in prices) + if not has_discount: + continue + + # 筛选:最小降价幅度 + if min_discount_percent: + max_discount = 0 + for pp in prices: + if pp.original_price and pp.original_price > 0: + discount = (1 - pp.price / pp.original_price) * 100 + max_discount = max(max_discount, discount) + if max_discount < min_discount_percent: + continue + + # 筛选:只看历史最低 + if only_historical_lowest and not is_at_historical_lowest: + continue + + price_outs: List[EnhancedProductPriceOut] = [] + for pp in prices: + website_name = None + website_logo = None + if pp.website_id: + website_name = pp.website.name + website_logo = pp.website.logo + + # 计算单个价格的降价信息 + discount_percent = None + discount_amount = None + if pp.original_price and pp.original_price > pp.price: + discount_amount = pp.original_price - pp.price + discount_percent = round((discount_amount / pp.original_price) * 100, 1) + + # 判断是否处于该商品的历史最低 + price_is_at_lowest = False + if historical_lowest: + price_is_at_lowest = pp.price <= historical_lowest + + price_outs.append(EnhancedProductPriceOut( + id=pp.id, + product_id=pp.product_id, + website_id=pp.website_id, + website_name=website_name, + website_logo=website_logo, + price=pp.price, + original_price=pp.original_price, + currency=pp.currency, + url=pp.url, + in_stock=pp.in_stock, + last_checked=pp.last_checked, + historical_lowest=historical_lowest, + is_at_historical_lowest=price_is_at_lowest, + discount_percent=discount_percent, + discount_amount=discount_amount, + )) + + item_out.append(ComparisonTagItemOut( + product=ProductOut( + id=product.id, + name=product.name, + description=product.description, + image=product.image, + images=list(product.images or []), + category_id=product.category_id, + status=product.status, + submitted_by_id=product.submitted_by_id, + reject_reason=product.reject_reason, + reviewed_at=product.reviewed_at, + created_at=product.created_at, + updated_at=product.updated_at, + ), + prices=price_outs, + lowest_price=current_lowest, + highest_price=current_highest, + platform_count=len(prices), + historical_lowest=historical_lowest, + historical_lowest_date=historical_lowest_date, + is_at_historical_lowest=is_at_historical_lowest, + discount_from_highest_percent=discount_from_highest_percent, + is_recommended=is_recommended, + recommendation_reason=recommendation_reason, + )) + + tag_out = ComparisonTagOut( + id=tag.id, + 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, + product_count=len(item_out), + created_at=tag.created_at, + updated_at=tag.updated_at, + ) + return ComparisonTagDetailOut(tag=tag_out, items=item_out) + + # ==================== Product Routes ==================== @router.post("/import/", response=ImportResultOut, auth=JWTAuth()) @@ -265,6 +468,10 @@ def import_products_csv(request, file: UploadedFile = File(...)): currency = (row.get("currency") or "CNY").strip() or "CNY" in_stock = parse_bool(row.get("in_stock"), True) + existing_price = ProductPrice.objects.filter( + product=product, + website=website, + ).first() price_record, created = ProductPrice.objects.update_or_create( product=product, website=website, @@ -280,6 +487,8 @@ def import_products_csv(request, file: UploadedFile = File(...)): result.created_prices += 1 else: result.updated_prices += 1 + if created or (existing_price and existing_price.price != price_record.price): + record_product_price_history(price_record) except Exception: result.errors.append(f"第{idx}行处理失败") continue @@ -322,7 +531,6 @@ def recommend_products(request, limit: int = 12): @router.get("/", response=List[ProductOut]) @paginate(PageNumberPagination, page_size=20) -@cache_page(settings.CACHE_TTL_SECONDS) def list_products(request, filters: ProductFilter = Query(...)): """Get all approved products with optional filters.""" # 只显示已审核通过的商品 @@ -362,7 +570,6 @@ def list_products(request, filters: ProductFilter = Query(...)): @router.get("/{product_id}", response=ProductOut) -@cache_page(settings.CACHE_TTL_SECONDS) def get_product(request, product_id: int): """Get product by ID.""" # 只返回已审核通过的商品 @@ -430,7 +637,6 @@ def get_product_with_prices(request, product_id: int): @router.get("/search/", response=List[ProductWithPricesOut]) @paginate(PageNumberPagination, page_size=20) -@cache_page(settings.CACHE_TTL_SECONDS) def search_products(request, q: str, filters: ProductSearchFilter = Query(...)): """Search approved products by name, description, or by user_id (submitter).""" from apps.users.models import User @@ -599,6 +805,7 @@ def add_product_price(request, data: ProductPriceIn): from ninja.errors import HttpError raise HttpError(403, "只能为自己提交的商品添加价格") price = ProductPrice.objects.create(**data.dict()) + record_product_price_history(price) website = price.website return ProductPriceOut( @@ -614,3 +821,120 @@ def add_product_price(request, data: ProductPriceIn): in_stock=price.in_stock, last_checked=price.last_checked, ) + + +@router.get("/{product_id}/price-history/", response=List[ProductPriceHistoryOut]) +def get_product_price_history(request, product_id: int, website_id: Optional[int] = None, limit: int = 60, days: Optional[int] = None): + """Get product price history.""" + from django.utils import timezone + from datetime import timedelta + + queryset = ProductPriceHistory.objects.filter(product_id=product_id) + if website_id: + queryset = queryset.filter(website_id=website_id) + if days: + cutoff = timezone.now() - timedelta(days=days) + queryset = queryset.filter(recorded_at__gte=cutoff) + + limit = max(1, min(limit, 500)) + histories = list(queryset.order_by("-recorded_at")[:limit]) + histories.reverse() + return [ + ProductPriceHistoryOut( + id=h.id, + product_id=h.product_id, + website_id=h.website_id, + price=h.price, + recorded_at=h.recorded_at, + ) + for h in histories + ] + + +@router.get("/{product_id}/price-stats/", response=PriceHistoryStatsOut) +def get_product_price_stats(request, product_id: int, days: Optional[int] = None): + """Get product price statistics including historical lowest/highest.""" + from django.utils import timezone + from django.db.models import Avg + from datetime import timedelta + from decimal import Decimal + + queryset = ProductPriceHistory.objects.filter(product_id=product_id) + if days: + cutoff = timezone.now() - timedelta(days=days) + queryset = queryset.filter(recorded_at__gte=cutoff) + + # 历史最低 + lowest_record = queryset.order_by("price").first() + historical_lowest = lowest_record.price if lowest_record else None + historical_lowest_date = lowest_record.recorded_at if lowest_record else None + + # 历史最高 + highest_record = queryset.order_by("-price").first() + historical_highest = highest_record.price if highest_record else None + historical_highest_date = highest_record.recorded_at if highest_record else None + + # 平均价 + avg_result = queryset.aggregate(avg_price=Avg("price")) + average_price = avg_result["avg_price"] + if average_price: + average_price = round(Decimal(str(average_price)), 2) + + # 当前最低价 + current_prices = ProductPrice.objects.filter(product_id=product_id) + current_lowest_record = current_prices.order_by("price").first() + current_lowest = current_lowest_record.price if current_lowest_record else None + + # 是否处于历史最低 + is_historical_lowest = False + if current_lowest and historical_lowest: + is_historical_lowest = current_lowest <= historical_lowest + + # 距最高价降幅 + discount_from_highest = None + if current_lowest and historical_highest and historical_highest > 0: + discount_from_highest = round((1 - current_lowest / historical_highest) * 100, 1) + + # 距历史最低差额 + distance_to_lowest = None + if current_lowest and historical_lowest: + distance_to_lowest = current_lowest - historical_lowest + + # 价格趋势(最近7条记录) + recent = list(queryset.order_by("-recorded_at")[:7]) + price_trend = "stable" + if len(recent) >= 2: + first_price = recent[-1].price + last_price = recent[0].price + if last_price < first_price * Decimal("0.95"): + price_trend = "down" + elif last_price > first_price * Decimal("1.05"): + price_trend = "up" + + # 历史数据(最近60条) + histories = list(queryset.order_by("-recorded_at")[:60]) + histories.reverse() + + return PriceHistoryStatsOut( + product_id=product_id, + historical_lowest=historical_lowest, + historical_lowest_date=historical_lowest_date, + historical_highest=historical_highest, + historical_highest_date=historical_highest_date, + average_price=average_price, + current_lowest=current_lowest, + is_historical_lowest=is_historical_lowest, + discount_from_highest=discount_from_highest, + distance_to_lowest=distance_to_lowest, + price_trend=price_trend, + history=[ + ProductPriceHistoryOut( + id=h.id, + product_id=h.product_id, + website_id=h.website_id, + price=h.price, + recorded_at=h.recorded_at, + ) + for h in histories + ], + ) diff --git a/backend/apps/products/management/commands/record_price_history.py b/backend/apps/products/management/commands/record_price_history.py new file mode 100644 index 0000000..f5f1db9 --- /dev/null +++ b/backend/apps/products/management/commands/record_price_history.py @@ -0,0 +1,41 @@ +""" +Record current product prices into history. +""" +from django.core.management.base import BaseCommand +from apps.products.models import ProductPrice, ProductPriceHistory + + +class Command(BaseCommand): + help = "Record current product prices into history." + + def add_arguments(self, parser): + parser.add_argument( + "--force", + action="store_true", + help="Always create a history record even if price unchanged", + ) + + def handle(self, *args, **options): + force = options.get("force", False) + created = 0 + skipped = 0 + queryset = ProductPrice.objects.select_related("product", "website").all() + for price in queryset: + latest = ProductPriceHistory.objects.filter( + product_id=price.product_id, + website_id=price.website_id, + ).order_by("-recorded_at").first() + if force or (not latest or latest.price != price.price): + ProductPriceHistory.objects.create( + product_id=price.product_id, + website_id=price.website_id, + price=price.price, + ) + created += 1 + else: + skipped += 1 + + self.stdout.write(self.style.SUCCESS( + f"History recorded. created={created}, skipped={skipped}" + )) + diff --git a/backend/apps/products/migrations/0006_comparison_tags.py b/backend/apps/products/migrations/0006_comparison_tags.py new file mode 100644 index 0000000..803f805 --- /dev/null +++ b/backend/apps/products/migrations/0006_comparison_tags.py @@ -0,0 +1,57 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("products", "0005_merge_20260130_1626"), + ] + + operations = [ + migrations.CreateModel( + name="ComparisonTag", + fields=[ + ("id", models.AutoField(primary_key=True, serialize=False)), + ("name", models.CharField(max_length=120, verbose_name="标签名称")), + ("slug", models.CharField(max_length=120, unique=True, verbose_name="Slug")), + ("description", models.TextField(blank=True, null=True, verbose_name="描述")), + ("cover_image", models.TextField(blank=True, null=True, verbose_name="封面图")), + ("icon", models.CharField(blank=True, max_length=120, null=True, verbose_name="图标")), + ("sort_order", models.IntegerField(default=0, verbose_name="排序")), + ("is_active", models.BooleanField(default=True, verbose_name="是否启用")), + ("created_at", models.DateTimeField(auto_now_add=True, verbose_name="创建时间")), + ("updated_at", models.DateTimeField(auto_now=True, verbose_name="更新时间")), + ], + options={ + "verbose_name": "比价标签", + "verbose_name_plural": "比价标签", + "db_table": "comparison_tags", + "ordering": ["sort_order", "id"], + "indexes": [ + models.Index(fields=["is_active", "sort_order"], name="comparison_is_4b7062_idx"), + ], + }, + ), + migrations.CreateModel( + name="ComparisonTagItem", + fields=[ + ("id", models.AutoField(primary_key=True, serialize=False)), + ("sort_order", models.IntegerField(default=0, verbose_name="排序")), + ("is_pinned", models.BooleanField(default=False, verbose_name="置顶")), + ("created_at", models.DateTimeField(auto_now_add=True, verbose_name="创建时间")), + ("product", models.ForeignKey(on_delete=models.deletion.CASCADE, related_name="comparison_items", to="products.product", verbose_name="商品")), + ("tag", models.ForeignKey(on_delete=models.deletion.CASCADE, related_name="items", to="products.comparisontag", verbose_name="比价标签")), + ], + options={ + "verbose_name": "比价标签商品", + "verbose_name_plural": "比价标签商品", + "db_table": "comparison_tag_items", + "ordering": ["-is_pinned", "sort_order", "id"], + "unique_together": {("tag", "product")}, + "indexes": [ + models.Index(fields=["tag", "sort_order"], name="comparison_tag_183b4d_idx"), + ], + }, + ), + ] + diff --git a/backend/apps/products/migrations/0007_product_price_history.py b/backend/apps/products/migrations/0007_product_price_history.py new file mode 100644 index 0000000..4aa4f5f --- /dev/null +++ b/backend/apps/products/migrations/0007_product_price_history.py @@ -0,0 +1,31 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("products", "0006_comparison_tags"), + ] + + operations = [ + migrations.CreateModel( + name="ProductPriceHistory", + fields=[ + ("id", models.AutoField(primary_key=True, serialize=False)), + ("price", models.DecimalField(decimal_places=2, max_digits=10, verbose_name="价格")), + ("recorded_at", models.DateTimeField(auto_now_add=True, verbose_name="记录时间")), + ("product", models.ForeignKey(on_delete=models.deletion.CASCADE, related_name="price_history", to="products.product", verbose_name="商品")), + ("website", models.ForeignKey(on_delete=models.deletion.CASCADE, related_name="price_history", to="products.website", verbose_name="网站")), + ], + options={ + "verbose_name": "商品价格历史", + "verbose_name_plural": "商品价格历史", + "db_table": "product_price_history", + "ordering": ["-recorded_at"], + "indexes": [ + models.Index(fields=["product", "website", "recorded_at"], name="product_pri_6b8559_idx"), + ], + }, + ), + ] + diff --git a/backend/apps/products/migrations/0008_rename_comparison_is_4b7062_idx_comparison__is_acti_f2c47e_idx_and_more.py b/backend/apps/products/migrations/0008_rename_comparison_is_4b7062_idx_comparison__is_acti_f2c47e_idx_and_more.py new file mode 100644 index 0000000..a8a65c8 --- /dev/null +++ b/backend/apps/products/migrations/0008_rename_comparison_is_4b7062_idx_comparison__is_acti_f2c47e_idx_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.27 on 2026-02-03 09:33 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0007_product_price_history'), + ] + + operations = [ + migrations.RenameIndex( + model_name='comparisontag', + new_name='comparison__is_acti_f2c47e_idx', + old_name='comparison_is_4b7062_idx', + ), + migrations.RenameIndex( + model_name='comparisontagitem', + new_name='comparison__tag_id_ea17fc_idx', + old_name='comparison_tag_183b4d_idx', + ), + migrations.RenameIndex( + model_name='productpricehistory', + new_name='product_pri_product_8d9f8a_idx', + old_name='product_pri_6b8559_idx', + ), + ] diff --git a/backend/apps/products/models.py b/backend/apps/products/models.py index bb323cf..f7e19ed 100644 --- a/backend/apps/products/models.py +++ b/backend/apps/products/models.py @@ -122,6 +122,67 @@ class Product(models.Model): return self.name +class ComparisonTag(models.Model): + """Admin-curated comparison tags for products.""" + + id = models.AutoField(primary_key=True) + name = models.CharField('标签名称', max_length=120) + slug = models.CharField('Slug', max_length=120, unique=True) + description = models.TextField('描述', blank=True, null=True) + cover_image = models.TextField('封面图', blank=True, null=True) + icon = models.CharField('图标', max_length=120, blank=True, null=True) + sort_order = models.IntegerField('排序', default=0) + is_active = models.BooleanField('是否启用', default=True) + created_at = models.DateTimeField('创建时间', auto_now_add=True) + updated_at = models.DateTimeField('更新时间', auto_now=True) + + class Meta: + db_table = 'comparison_tags' + verbose_name = '比价标签' + verbose_name_plural = '比价标签' + ordering = ['sort_order', 'id'] + indexes = [ + models.Index(fields=["is_active", "sort_order"]), + ] + + def __str__(self): + return self.name + + +class ComparisonTagItem(models.Model): + """Products included in a comparison tag.""" + + id = models.AutoField(primary_key=True) + tag = models.ForeignKey( + ComparisonTag, + on_delete=models.CASCADE, + related_name='items', + verbose_name='比价标签', + ) + product = models.ForeignKey( + Product, + on_delete=models.CASCADE, + related_name='comparison_items', + verbose_name='商品', + ) + sort_order = models.IntegerField('排序', default=0) + is_pinned = models.BooleanField('置顶', default=False) + created_at = models.DateTimeField('创建时间', auto_now_add=True) + + class Meta: + db_table = 'comparison_tag_items' + verbose_name = '比价标签商品' + verbose_name_plural = '比价标签商品' + ordering = ['-is_pinned', 'sort_order', 'id'] + unique_together = ['tag', 'product'] + indexes = [ + models.Index(fields=["tag", "sort_order"]), + ] + + def __str__(self): + return f"{self.tag.name} - {self.product.name}" + + class ProductPrice(models.Model): """Product prices from different websites.""" @@ -164,3 +225,35 @@ class ProductPrice(models.Model): def __str__(self): return f"{self.product.name} - {self.website.name}: {self.price}" + + +class ProductPriceHistory(models.Model): + """Historical price records for products.""" + + id = models.AutoField(primary_key=True) + product = models.ForeignKey( + Product, + on_delete=models.CASCADE, + related_name='price_history', + verbose_name='商品', + ) + website = models.ForeignKey( + Website, + on_delete=models.CASCADE, + related_name='price_history', + verbose_name='网站', + ) + price = models.DecimalField('价格', max_digits=10, decimal_places=2) + recorded_at = models.DateTimeField('记录时间', auto_now_add=True) + + class Meta: + db_table = 'product_price_history' + verbose_name = '商品价格历史' + verbose_name_plural = '商品价格历史' + ordering = ['-recorded_at'] + indexes = [ + models.Index(fields=["product", "website", "recorded_at"]), + ] + + def __str__(self): + return f"{self.product.name} - {self.website.name}: {self.price}" diff --git a/backend/apps/products/schemas.py b/backend/apps/products/schemas.py index d77d512..c0727f5 100644 --- a/backend/apps/products/schemas.py +++ b/backend/apps/products/schemas.py @@ -72,6 +72,39 @@ class ProductPriceOut(Schema): last_checked: datetime +class ProductPriceHistoryOut(Schema): + """Product price history output schema.""" + id: int + product_id: int + website_id: int + price: Decimal + recorded_at: datetime + + +class PriceHistoryStatsOut(Schema): + """Price history statistics output schema.""" + product_id: int + historical_lowest: Optional[Decimal] = None + historical_lowest_date: Optional[datetime] = None + historical_highest: Optional[Decimal] = None + historical_highest_date: Optional[datetime] = None + average_price: Optional[Decimal] = None + current_lowest: Optional[Decimal] = None + is_historical_lowest: bool = False + discount_from_highest: Optional[Decimal] = None # 距最高价降幅百分比 + distance_to_lowest: Optional[Decimal] = None # 距历史最低差额 + price_trend: str = "stable" # up, down, stable + history: List["ProductPriceHistoryOut"] = [] + + +class EnhancedProductPriceOut(ProductPriceOut): + """Enhanced product price with history stats.""" + historical_lowest: Optional[Decimal] = None + is_at_historical_lowest: bool = False + discount_percent: Optional[Decimal] = None # 降价百分比 + discount_amount: Optional[Decimal] = None # 降价金额 + + class ProductOut(Schema): """Product output schema.""" id: int @@ -95,6 +128,43 @@ class ProductWithPricesOut(ProductOut): highest_price: Optional[Decimal] = None +class ComparisonTagOut(Schema): + """Comparison tag output schema.""" + id: int + name: str + slug: str + description: Optional[str] = None + cover_image: Optional[str] = None + icon: Optional[str] = None + sort_order: int + is_active: bool + product_count: int = 0 + created_at: datetime + updated_at: datetime + + +class ComparisonTagItemOut(Schema): + """Comparison tag item output schema.""" + product: ProductOut + prices: List["EnhancedProductPriceOut"] = [] + lowest_price: Optional[Decimal] = None + highest_price: Optional[Decimal] = None + platform_count: int = 0 + # 增强字段 + historical_lowest: Optional[Decimal] = None + historical_lowest_date: Optional[datetime] = None + is_at_historical_lowest: bool = False + discount_from_highest_percent: Optional[Decimal] = None + is_recommended: bool = False # 推荐标签 + recommendation_reason: Optional[str] = None # 推荐理由 + + +class ComparisonTagDetailOut(Schema): + """Comparison tag detail output schema.""" + tag: ComparisonTagOut + items: List[ComparisonTagItemOut] = [] + + class UploadImageOut(Schema): """Image upload output.""" url: str diff --git a/backend/apps/users/migrations/0006_user_users_user_id_83dd09_idx.py b/backend/apps/users/migrations/0006_user_users_user_id_83dd09_idx.py new file mode 100644 index 0000000..549c7f9 --- /dev/null +++ b/backend/apps/users/migrations/0006_user_users_user_id_83dd09_idx.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.27 on 2026-02-03 09:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0005_add_user_id'), + ] + + operations = [ + migrations.AddIndex( + model_name='user', + index=models.Index(fields=['user_id'], name='users_user_id_83dd09_idx'), + ), + ] diff --git a/backend/config/api.py b/backend/config/api.py index 9fa5177..7d3c92a 100644 --- a/backend/config/api.py +++ b/backend/config/api.py @@ -10,13 +10,12 @@ from ninja_jwt.authentication import JWTAuth # Import routers from apps from apps.users.api import router as auth_router from apps.users.friends import router as friends_router -from apps.products.api import router as products_router, category_router, website_router +from apps.products.api import router as products_router, category_router, website_router, compare_tag_router from apps.bounties.api import router as bounties_router from apps.bounties.payments import router as payments_router from apps.favorites.api import router as favorites_router from apps.notifications.api import router as notifications_router from apps.admin.api import router as admin_router -from config.search import router as search_router from apps.common.errors import build_error_payload # Create main API instance @@ -69,9 +68,9 @@ api.add_router("/friends/", friends_router, tags=["好友"]) api.add_router("/categories/", category_router, tags=["分类"]) api.add_router("/websites/", website_router, tags=["网站"]) api.add_router("/products/", products_router, tags=["商品"]) +api.add_router("/compare-tags/", compare_tag_router, tags=["比价标签"]) api.add_router("/bounties/", bounties_router, tags=["悬赏"]) api.add_router("/payments/", payments_router, tags=["支付"]) api.add_router("/favorites/", favorites_router, tags=["收藏"]) api.add_router("/notifications/", notifications_router, tags=["通知"]) -api.add_router("/search/", search_router, tags=["搜索"]) api.add_router("/admin/", admin_router, tags=["后台"]) diff --git a/backend/config/search.py b/backend/config/search.py deleted file mode 100644 index 4f7b6e3..0000000 --- a/backend/config/search.py +++ /dev/null @@ -1,63 +0,0 @@ -""" -Global search API routes. -""" -from typing import List -from ninja import Router, Schema -from django.db.models import Count, Q -from django.conf import settings -from django.views.decorators.cache import cache_page - -from apps.products.models import Product, Website -from apps.products.schemas import ProductOut, WebsiteOut -from apps.bounties.models import Bounty -from apps.bounties.schemas import BountyWithDetailsOut -from apps.common.serializers import serialize_bounty - -router = Router() - - -class SearchResultsOut(Schema): - products: List[ProductOut] - websites: List[WebsiteOut] - bounties: List[BountyWithDetailsOut] - - -def _serialize_bounty_with_counts(bounty): - return serialize_bounty(bounty, include_counts=True) - - -@router.get("/", response=SearchResultsOut) -@cache_page(settings.CACHE_TTL_SECONDS) -def global_search(request, q: str, limit: int = 10): - """Search products, websites and bounties by keyword.""" - keyword = (q or "").strip() - if not keyword: - return SearchResultsOut(products=[], websites=[], bounties=[]) - - products = list( - Product.objects.select_related("category").filter( - Q(name__icontains=keyword) | Q(description__icontains=keyword) - ).order_by("-created_at")[:limit] - ) - - websites = list( - Website.objects.select_related("category").filter( - Q(name__icontains=keyword) | Q(description__icontains=keyword) - ).order_by("-created_at")[:limit] - ) - - bounties = list( - Bounty.objects.select_related("publisher", "acceptor") - .annotate( - applications_count=Count("applications", distinct=True), - comments_count=Count("comments", distinct=True), - ) - .filter(Q(title__icontains=keyword) | Q(description__icontains=keyword)) - .order_by("-created_at")[:limit] - ) - - return SearchResultsOut( - products=products, - websites=websites, - bounties=[_serialize_bounty_with_counts(b) for b in bounties], - ) diff --git a/backend/media/products/50b85ed16f4d40b698c57925cf35738c.jpg b/backend/media/products/50b85ed16f4d40b698c57925cf35738c.jpg new file mode 100644 index 0000000..d92d035 Binary files /dev/null and b/backend/media/products/50b85ed16f4d40b698c57925cf35738c.jpg differ diff --git a/backend/media/products/a793c69e92bc4bceb76a758662ef51fc.jpg b/backend/media/products/a793c69e92bc4bceb76a758662ef51fc.jpg new file mode 100644 index 0000000..d92d035 Binary files /dev/null and b/backend/media/products/a793c69e92bc4bceb76a758662ef51fc.jpg differ diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 0f7ada3..777ce1f 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4496f90..2e674b3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { + - diff --git a/frontend/src/components/MobileNav.tsx b/frontend/src/components/MobileNav.tsx index 55b76a8..4e5c65a 100644 --- a/frontend/src/components/MobileNav.tsx +++ b/frontend/src/components/MobileNav.tsx @@ -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 () => { diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index 88582b0..0aef7a9 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -34,9 +34,6 @@ export function Navbar({ children, showLinks = true }: NavbarProps) { 悬赏大厅 - - 全文搜索 - {isAuthenticated && ( 个人中心 diff --git a/frontend/src/features/admin/pages/Admin.tsx b/frontend/src/features/admin/pages/Admin.tsx index 482b5ee..19be351 100644 --- a/frontend/src/features/admin/pages/Admin.tsx +++ b/frontend/src/features/admin/pages/Admin.tsx @@ -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(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(null); + const [selectedProductId, setSelectedProductId] = useState(""); + const [tagItemEdits, setTagItemEdits] = useState>({}); + const [batchProductIds, setBatchProductIds] = useState(""); + const [sortableItems, setSortableItems] = useState([]); + 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 = {}; + 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 = { + 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 (
@@ -504,6 +822,260 @@ export default function Admin() { + + + + + {editingTag ? "编辑比价标签" : "创建比价标签"} + 管理员维护标签,用于首页和商品页比价展示 + +
+
+ + setTagForm((prev) => ({ ...prev, name: e.target.value }))} + placeholder="如:办公效率" + /> +
+
+ + setTagForm((prev) => ({ ...prev, slug: e.target.value }))} + placeholder="如:office-tools" + /> +
+
+ + setTagForm((prev) => ({ ...prev, description: e.target.value }))} + placeholder="简单描述标签用途" + /> +
+
+ + setTagForm((prev) => ({ ...prev, cover_image: e.target.value }))} + placeholder="封面图URL" + /> +
+
+ + setTagForm((prev) => ({ ...prev, icon: e.target.value }))} + placeholder="图标标识(可选)" + /> +
+
+
+ + setTagForm((prev) => ({ ...prev, sort_order: Number(e.target.value) }))} + /> +
+
+ 启用 + setTagForm((prev) => ({ ...prev, is_active: checked }))} + /> +
+
+
+ + + + +
+
+ + + + + 管理标签商品 + {activeTag?.name || "请选择标签"} 的商品列表 + +
+
+ + + + +
+
+ +