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