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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -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',
),
]

View File

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

View File

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

View File

@@ -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'),
),
]

View File

@@ -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=["后台"])

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

View File

@@ -139,6 +139,9 @@ importers:
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
pnpm:
specifier: ^10.28.2
version: 10.28.2
qrcode.react:
specifier: ^4.2.0
version: 4.2.0(react@19.2.1)
@@ -1899,6 +1902,11 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
pnpm@10.28.2:
resolution: {integrity: sha512-QYcvA3rSL3NI47Heu69+hnz9RI8nJtnPdMCPGVB8MdLI56EVJbmD/rwt9kC1Q43uYCPrsfhO1DzC1lTSvDJiZA==}
engines: {node: '>=18.12'}
hasBin: true
postcss-selector-parser@6.0.10:
resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==}
engines: {node: '>=4'}
@@ -3747,6 +3755,8 @@ snapshots:
picomatch@4.0.3: {}
pnpm@10.28.2: {}
postcss-selector-parser@6.0.10:
dependencies:
cssesc: 3.0.0

View File

@@ -11,13 +11,13 @@ import { Loader2 } from "lucide-react";
const Login = lazy(() => import("@/features/auth/pages/Login"));
const Products = lazy(() => import("@/features/products/pages/Products"));
const ProductDetail = lazy(() => import("@/features/products/pages/ProductDetail"));
const ComparisonTagDetail = lazy(() => import("@/features/products/pages/ComparisonTagDetail"));
const Bounties = lazy(() => import("@/features/bounties/pages/Bounties"));
const BountyDetail = lazy(() => import("@/features/bounties/pages/BountyDetail"));
const Dashboard = lazy(() => import("@/features/dashboard/pages/Dashboard"));
const Favorites = lazy(() => import("@/features/favorites/pages/Favorites"));
const ProductComparison = lazy(() => import("@/features/products/pages/ProductComparison"));
const Admin = lazy(() => import("@/features/admin/pages/Admin"));
const Search = lazy(() => import("@/features/search/pages/Search"));
const Settings = lazy(() => import("@/features/settings/pages/Settings"));
const NotFound = lazy(() => import("@/features/common/pages/NotFound"));
@@ -37,12 +37,12 @@ function Router() {
<Route path="/login" component={Login} />
<Route path="/products" component={Products} />
<Route path="/products/:id" component={ProductDetail} />
<Route path="/compare-tags/:slug" component={ComparisonTagDetail} />
<Route path="/bounties" component={Bounties} />
<Route path="/bounties/:id" component={BountyDetail} />
<Route path="/dashboard" component={Dashboard} />
<Route path="/favorites" component={Favorites} />
<Route path="/comparison" component={ProductComparison} />
<Route path="/search" component={Search} />
<Route path="/admin" component={Admin} />
<Route path="/settings" component={Settings} />
<Route path="/404" component={NotFound} />

View File

@@ -3,7 +3,7 @@ import { Link, useLocation } from "wouter";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import { useAuth } from "@/hooks/useAuth";
import { Sparkles, Menu, X, ShoppingBag, Trophy, Search, User, Heart, LogOut } from "lucide-react";
import { Sparkles, Menu, X, ShoppingBag, Trophy, User, Heart, LogOut } from "lucide-react";
import { useUnreadNotificationCount } from "@/hooks/useApi";
export function MobileNav() {
@@ -16,7 +16,6 @@ export function MobileNav() {
const navItems = [
{ href: "/products", label: "商品导航", icon: ShoppingBag },
{ href: "/bounties", label: "悬赏大厅", icon: Trophy },
{ href: "/search", label: "全文搜索", icon: Search },
];
const handleLogout = async () => {

View File

@@ -34,9 +34,6 @@ export function Navbar({ children, showLinks = true }: NavbarProps) {
<Link href="/bounties" className={location === "/bounties" ? "text-foreground font-medium" : "text-muted-foreground hover:text-foreground transition-colors"}>
</Link>
<Link href="/search" className={location === "/search" ? "text-foreground font-medium" : "text-muted-foreground hover:text-foreground transition-colors"}>
</Link>
{isAuthenticated && (
<Link href="/dashboard" className={location === "/dashboard" ? "text-foreground font-medium" : "text-muted-foreground hover:text-foreground transition-colors"}>

View File

@@ -1,12 +1,12 @@
import { useAuth } from "@/hooks/useAuth";
import { useAdminUsers, useUpdateAdminUser, useAdminBounties, useAdminPayments, useAdminDisputes, useResolveDispute, useAdminPendingProducts, useReviewProduct, useUpdateAdminProductImages } from "@/hooks/useApi";
import { useAdminUsers, useUpdateAdminUser, useAdminBounties, useAdminPayments, useAdminDisputes, useResolveDispute, useAdminPendingProducts, useReviewProduct, useUpdateAdminProductImages, useAdminComparisonTags, useCreateAdminComparisonTag, useUpdateAdminComparisonTag, useDeleteAdminComparisonTag, useAdminComparisonTagItems, useAddAdminComparisonTagItems, useUpdateAdminComparisonTagItem, useDeleteAdminComparisonTagItem, useAdminSimpleProducts, useBackfillPriceHistory, useBackfillTagPriceHistory, useRecordPriceHistory, useRecordTagPriceHistory } from "@/hooks/useApi";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Navbar } from "@/components/Navbar";
import { Loader2, Package, Users, Trophy, CreditCard, AlertTriangle, Star, Trash2, Image as ImageIcon } from "lucide-react";
import { Loader2, Package, Users, Trophy, CreditCard, AlertTriangle, Star, Trash2, Image as ImageIcon, Tags } from "lucide-react";
import { useLocation } from "wouter";
import { useEffect, useState, useRef } from "react";
import { toast } from "sonner";
@@ -14,9 +14,10 @@ import { getErrorCopy } from "@/lib/i18n/errorMessages";
import { formatDistanceToNow } from "date-fns";
import { zhCN } from "date-fns/locale";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { productApi, type AdminProduct } from "@/lib/api";
import { productApi, type AdminProduct, type AdminComparisonTag } from "@/lib/api";
import SortableGrid, { HeroDropzone } from "@/components/SortableGrid";
import FileThumbnail from "@/components/FileThumbnail";
import { createImageFileItems, processImageFile, type ImageFileItem } from "@/lib/image";
@@ -45,6 +46,23 @@ const createId = () => {
const createImageUrlItems = (urls: string[]) => urls.map((url) => ({ id: createId(), url }));
const slugify = (value: string) => {
const base = value
.trim()
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "");
return base || `tag-${Date.now()}`;
};
const parseProductIdsFromText = (text: string) => {
return text
.replace(/\r\n/g, "\n")
.split(/[\n,;]+/)
.map((v) => Number(v.trim()))
.filter((v) => !Number.isNaN(v) && v > 0);
};
export default function Admin() {
const { user, isAuthenticated, loading } = useAuth();
const [, navigate] = useLocation();
@@ -63,6 +81,26 @@ export default function Admin() {
const MAX_IMAGES = 6;
const urlHeroId = "admin-image-url-hero";
const uploadHeroId = "admin-image-upload-hero";
const [tagDialogOpen, setTagDialogOpen] = useState(false);
const [editingTag, setEditingTag] = useState<AdminComparisonTag | null>(null);
const [tagForm, setTagForm] = useState({
name: "",
slug: "",
description: "",
cover_image: "",
icon: "",
sort_order: 0,
is_active: true,
});
const [itemsDialogOpen, setItemsDialogOpen] = useState(false);
const [activeTag, setActiveTag] = useState<AdminComparisonTag | null>(null);
const [selectedProductId, setSelectedProductId] = useState("");
const [tagItemEdits, setTagItemEdits] = useState<Record<number, { sort_order: number; is_pinned: boolean }>>({});
const [batchProductIds, setBatchProductIds] = useState("");
const [sortableItems, setSortableItems] = useState<typeof tagItems>([]);
const [isSortMode, setIsSortMode] = useState(false);
const [isSavingSort, setIsSavingSort] = useState(false);
const [forceRecordHistory, setForceRecordHistory] = useState(false);
const moveUrlToFront = (activeId: string) => {
setEditingImages((prev) => {
const index = prev.findIndex((item) => item.id === activeId);
@@ -106,10 +144,25 @@ export default function Admin() {
const { data: paymentsData, isLoading: paymentsLoading } = useAdminPayments();
const { data: disputesData, isLoading: disputesLoading } = useAdminDisputes();
const { data: pendingProductsData, isLoading: pendingProductsLoading } = useAdminPendingProducts();
const { data: compareTagsData, isLoading: compareTagsLoading } = useAdminComparisonTags();
const { data: adminProductsData } = useAdminSimpleProducts();
const { data: tagItemsData, isLoading: tagItemsLoading } = useAdminComparisonTagItems(activeTag?.id || 0, {
enabled: itemsDialogOpen && !!activeTag,
});
const updateUserMutation = useUpdateAdminUser();
const resolveDisputeMutation = useResolveDispute();
const reviewProductMutation = useReviewProduct();
const updateProductImagesMutation = useUpdateAdminProductImages();
const createTagMutation = useCreateAdminComparisonTag();
const updateTagMutation = useUpdateAdminComparisonTag();
const deleteTagMutation = useDeleteAdminComparisonTag();
const addTagItemsMutation = useAddAdminComparisonTagItems();
const updateTagItemMutation = useUpdateAdminComparisonTagItem();
const deleteTagItemMutation = useDeleteAdminComparisonTagItem();
const backfillHistoryMutation = useBackfillPriceHistory();
const backfillTagHistoryMutation = useBackfillTagPriceHistory();
const recordHistoryMutation = useRecordPriceHistory();
const recordTagHistoryMutation = useRecordTagPriceHistory();
// Extract items from paginated responses
const users = usersData?.items || [];
@@ -117,6 +170,9 @@ export default function Admin() {
const payments = paymentsData?.items || [];
const disputes = disputesData?.items || [];
const pendingProducts = pendingProductsData?.items || [];
const compareTags = compareTagsData || [];
const adminProducts = adminProductsData?.items || [];
const tagItems = tagItemsData || [];
useEffect(() => {
if (!loading && (!isAuthenticated || user?.role !== "admin")) {
@@ -124,6 +180,19 @@ export default function Admin() {
}
}, [loading, isAuthenticated, user, navigate]);
useEffect(() => {
if (!itemsDialogOpen) return;
const next: Record<number, { sort_order: number; is_pinned: boolean }> = {};
for (const item of tagItems) {
next[item.id] = {
sort_order: item.sort_order,
is_pinned: item.is_pinned,
};
}
setTagItemEdits(next);
setSortableItems(tagItems);
}, [itemsDialogOpen, tagItems]);
const openImageEditor = (product: AdminProduct) => {
const initialImages = product.images && product.images.length > 0
? [...product.images]
@@ -211,6 +280,11 @@ export default function Admin() {
};
const pendingProductsCount = pendingProducts?.length || 0;
const productStatusLabel: Record<string, string> = {
pending: "待审核",
approved: "已通过",
rejected: "已拒绝",
};
const handleApproveProduct = (productId: number) => {
reviewProductMutation.mutate(
@@ -246,6 +320,250 @@ export default function Admin() {
);
};
const openCreateTag = () => {
setEditingTag(null);
setTagForm({
name: "",
slug: "",
description: "",
cover_image: "",
icon: "",
sort_order: 0,
is_active: true,
});
setTagDialogOpen(true);
};
const openEditTag = (tag: AdminComparisonTag) => {
setEditingTag(tag);
setTagForm({
name: tag.name,
slug: tag.slug,
description: tag.description || "",
cover_image: tag.cover_image || "",
icon: tag.icon || "",
sort_order: tag.sort_order,
is_active: tag.is_active,
});
setTagDialogOpen(true);
};
const handleSaveTag = async () => {
const name = tagForm.name.trim();
if (!name) {
toast.error("请输入标签名称");
return;
}
const slug = (tagForm.slug || "").trim() || slugify(name);
const payload = {
name,
slug,
description: tagForm.description.trim() || undefined,
cover_image: tagForm.cover_image.trim() || undefined,
icon: tagForm.icon.trim() || undefined,
sort_order: Number(tagForm.sort_order) || 0,
is_active: tagForm.is_active,
};
try {
if (editingTag) {
await updateTagMutation.mutateAsync({ tagId: editingTag.id, data: payload });
toast.success("标签已更新");
} else {
await createTagMutation.mutateAsync(payload);
toast.success("标签已创建");
}
setTagDialogOpen(false);
} catch (error: unknown) {
const { title, description } = getErrorCopy(error, { context: "admin.compare_tag" });
toast.error(title, { description });
}
};
const handleDeleteTag = (tagId: number) => {
if (!window.confirm("确认删除该标签?")) return;
deleteTagMutation.mutate(tagId, {
onSuccess: () => toast.success("标签已删除"),
onError: (error: unknown) => {
const { title, description } = getErrorCopy(error, { context: "admin.compare_tag" });
toast.error(title, { description });
},
});
};
const openTagItems = (tag: AdminComparisonTag) => {
setActiveTag(tag);
setSelectedProductId("");
setItemsDialogOpen(true);
};
const handleAddTagItem = () => {
if (!activeTag) return;
if (!selectedProductId) {
toast.error("请选择商品");
return;
}
addTagItemsMutation.mutate(
{ tagId: activeTag.id, data: { product_ids: [Number(selectedProductId)] } },
{
onSuccess: () => {
toast.success("商品已添加");
setSelectedProductId("");
},
onError: (error: unknown) => {
const { title, description } = getErrorCopy(error, { context: "admin.compare_tag_item" });
toast.error(title, { description });
},
}
);
};
const handleBatchAddTagItems = () => {
if (!activeTag) return;
const ids = batchProductIds
.split(/[,\n]/)
.map((v) => Number(v.trim()))
.filter((v) => !Number.isNaN(v) && v > 0);
if (ids.length === 0) {
toast.error("请输入有效的商品ID");
return;
}
addTagItemsMutation.mutate(
{ tagId: activeTag.id, data: { product_ids: ids } },
{
onSuccess: () => {
toast.success("批量添加成功");
setBatchProductIds("");
},
onError: (error: unknown) => {
const { title, description } = getErrorCopy(error, { context: "admin.compare_tag_item" });
toast.error(title, { description });
},
}
);
};
const handleCsvImport = (file: File | null) => {
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const text = String(reader.result || "");
const ids = parseProductIdsFromText(text);
if (ids.length === 0) {
toast.error("未识别到有效商品ID");
return;
}
setBatchProductIds(ids.join(", "));
toast.success(`已解析 ${ids.length} 个商品ID`);
};
reader.readAsText(file);
};
const handleSaveTagItem = (itemId: number) => {
if (!activeTag) return;
const edit = tagItemEdits[itemId];
if (!edit) return;
updateTagItemMutation.mutate(
{ tagId: activeTag.id, itemId, data: { sort_order: edit.sort_order, is_pinned: edit.is_pinned } },
{
onSuccess: () => toast.success("已保存"),
onError: (error: unknown) => {
const { title, description } = getErrorCopy(error, { context: "admin.compare_tag_item" });
toast.error(title, { description });
},
}
);
};
const handleDeleteTagItem = (itemId: number) => {
if (!activeTag) return;
deleteTagItemMutation.mutate(
{ tagId: activeTag.id, itemId },
{
onSuccess: () => toast.success("已移除"),
onError: (error: unknown) => {
const { title, description } = getErrorCopy(error, { context: "admin.compare_tag_item" });
toast.error(title, { description });
},
}
);
};
const handleBackfillHistory = () => {
backfillHistoryMutation.mutate(undefined, {
onSuccess: (res) => {
toast.success(`历史已补齐(新增 ${res.created} 条,跳过 ${res.skipped} 条)`);
},
onError: (error: unknown) => {
const { title, description } = getErrorCopy(error, { context: "admin.price_history" });
toast.error(title, { description });
},
});
};
const handleBackfillTagHistory = () => {
if (!activeTag) return;
backfillTagHistoryMutation.mutate(activeTag.id, {
onSuccess: (res) => {
toast.success(`标签历史已补齐(新增 ${res.created} 条,跳过 ${res.skipped} 条)`);
},
onError: (error: unknown) => {
const { title, description } = getErrorCopy(error, { context: "admin.price_history" });
toast.error(title, { description });
},
});
};
const handleRecordHistory = () => {
recordHistoryMutation.mutate({ force: forceRecordHistory }, {
onSuccess: (res) => {
toast.success(`已记录当前价格(新增 ${res.created} 条,跳过 ${res.skipped} 条)`);
},
onError: (error: unknown) => {
const { title, description } = getErrorCopy(error, { context: "admin.price_history" });
toast.error(title, { description });
},
});
};
const handleRecordTagHistory = () => {
if (!activeTag) return;
recordTagHistoryMutation.mutate({ tagId: activeTag.id, data: { force: forceRecordHistory } }, {
onSuccess: (res) => {
toast.success(`标签价格已记录(新增 ${res.created} 条,跳过 ${res.skipped} 条)`);
},
onError: (error: unknown) => {
const { title, description } = getErrorCopy(error, { context: "admin.price_history" });
toast.error(title, { description });
},
});
};
const handleSaveSortOrder = async () => {
if (!activeTag) return;
setIsSavingSort(true);
try {
const updates = sortableItems.map((item, index) => ({
itemId: item.id,
sort_order: index + 1,
}));
await Promise.all(
updates.map((u) =>
updateTagItemMutation.mutateAsync({
tagId: activeTag.id,
itemId: u.itemId,
data: { sort_order: u.sort_order },
})
)
);
toast.success("排序已保存");
} catch (error: unknown) {
const { title, description } = getErrorCopy(error, { context: "admin.compare_tag_item" });
toast.error(title, { description });
} finally {
setIsSavingSort(false);
}
};
return (
<div className="min-h-screen bg-background">
<Navbar />
@@ -504,6 +822,260 @@ export default function Admin() {
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={tagDialogOpen} onOpenChange={setTagDialogOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{editingTag ? "编辑比价标签" : "创建比价标签"}</DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<div className="grid gap-2">
<Label> *</Label>
<Input
value={tagForm.name}
onChange={(e) => setTagForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder="如:办公效率"
/>
</div>
<div className="grid gap-2">
<Label> *</Label>
<Input
value={tagForm.slug}
onChange={(e) => setTagForm((prev) => ({ ...prev, slug: e.target.value }))}
placeholder="如office-tools"
/>
</div>
<div className="grid gap-2">
<Label></Label>
<Input
value={tagForm.description}
onChange={(e) => setTagForm((prev) => ({ ...prev, description: e.target.value }))}
placeholder="简单描述标签用途"
/>
</div>
<div className="grid gap-2">
<Label></Label>
<Input
value={tagForm.cover_image}
onChange={(e) => setTagForm((prev) => ({ ...prev, cover_image: e.target.value }))}
placeholder="封面图URL"
/>
</div>
<div className="grid gap-2">
<Label></Label>
<Input
value={tagForm.icon}
onChange={(e) => setTagForm((prev) => ({ ...prev, icon: e.target.value }))}
placeholder="图标标识(可选)"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label></Label>
<Input
type="number"
value={tagForm.sort_order}
onChange={(e) => setTagForm((prev) => ({ ...prev, sort_order: Number(e.target.value) }))}
/>
</div>
<div className="flex items-center justify-between rounded-lg border px-3">
<span className="text-sm"></span>
<Switch
checked={tagForm.is_active}
onCheckedChange={(checked) => setTagForm((prev) => ({ ...prev, is_active: checked }))}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setTagDialogOpen(false)}>
</Button>
<Button onClick={handleSaveTag} disabled={createTagMutation.isPending || updateTagMutation.isPending}>
{createTagMutation.isPending || updateTagMutation.isPending ? "保存中..." : "保存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={itemsDialogOpen} onOpenChange={setItemsDialogOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>{activeTag?.name || "请选择标签"} </DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<div className="flex flex-wrap items-center gap-2">
<select
value={selectedProductId}
onChange={(e) => setSelectedProductId(e.target.value)}
className="px-3 py-2 border rounded-lg bg-background text-foreground text-sm min-w-[240px]"
>
<option value=""></option>
{adminProducts.map((product) => (
<option key={product.id} value={product.id}>
{product.name}
</option>
))}
</select>
<Button onClick={handleAddTagItem} disabled={addTagItemsMutation.isPending}>
</Button>
<Button variant="outline" onClick={handleRecordTagHistory} disabled={recordTagHistoryMutation.isPending}>
{recordTagHistoryMutation.isPending ? "记录中..." : "记录标签价格"}
</Button>
<Button variant="outline" onClick={handleBackfillTagHistory} disabled={backfillTagHistoryMutation.isPending}>
{backfillTagHistoryMutation.isPending ? "补齐中..." : "补齐标签历史"}
</Button>
</div>
<div className="grid gap-2">
<Label>ID</Label>
<textarea
value={batchProductIds}
onChange={(e) => setBatchProductIds(e.target.value)}
placeholder="例如12, 15, 21"
className="min-h-[90px] w-full rounded-lg border bg-background px-3 py-2 text-sm text-foreground"
/>
<div className="flex items-center gap-2">
<Input
type="file"
accept=".csv,text/csv"
onChange={(e) => handleCsvImport(e.target.files?.[0] || null)}
/>
<span className="text-xs text-muted-foreground">CSV或纯文本</span>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleBatchAddTagItems} disabled={addTagItemsMutation.isPending}>
</Button>
<span className="text-xs text-muted-foreground">
ID
</span>
</div>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<div className="text-sm font-medium"></div>
<div className="text-xs text-muted-foreground"></div>
</div>
<Switch checked={isSortMode} onCheckedChange={setIsSortMode} />
</div>
{isSortMode && sortableItems.length > 0 && (
<div className="space-y-3">
<SortableGrid
items={sortableItems}
onReorder={setSortableItems}
className="grid grid-cols-1 gap-2"
itemClassName="p-2"
renderItem={(item) => (
<div className="flex items-center gap-3">
{item.product_image ? (
<img src={item.product_image} alt={item.product_name} className="h-10 w-10 rounded object-cover" />
) : (
<div className="h-10 w-10 rounded bg-muted" />
)}
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{item.product_name}</div>
<div className="text-xs text-muted-foreground">: {item.sort_order}</div>
</div>
</div>
)}
/>
<div className="flex items-center gap-2">
<Button onClick={handleSaveSortOrder} disabled={isSavingSort}>
{isSavingSort ? "保存中..." : "保存排序"}
</Button>
<span className="text-xs text-muted-foreground"></span>
</div>
</div>
)}
{tagItemsLoading ? (
<div className="flex justify-center py-6">
<Loader2 className="w-5 h-5 animate-spin" />
</div>
) : tagItems.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tagItems.map((item) => {
const edit = tagItemEdits[item.id] || {
sort_order: item.sort_order,
is_pinned: item.is_pinned,
};
return (
<TableRow key={item.id}>
<TableCell className="min-w-[200px]">
<div className="flex items-center gap-3">
{item.product_image && (
<img src={item.product_image} alt={item.product_name} className="w-10 h-10 rounded object-cover" />
)}
<div className="font-medium">{item.product_name}</div>
</div>
</TableCell>
<TableCell>
<Badge variant={item.product_status === "approved" ? "secondary" : item.product_status === "rejected" ? "destructive" : "outline"}>
{productStatusLabel[item.product_status] || item.product_status}
</Badge>
</TableCell>
<TableCell>
<Input
type="number"
className="w-24"
value={edit.sort_order}
onChange={(e) =>
setTagItemEdits((prev) => ({
...prev,
[item.id]: { ...edit, sort_order: Number(e.target.value) },
}))
}
/>
</TableCell>
<TableCell>
<Switch
checked={edit.is_pinned}
onCheckedChange={(checked) =>
setTagItemEdits((prev) => ({
...prev,
[item.id]: { ...edit, is_pinned: checked },
}))
}
/>
</TableCell>
<TableCell className="space-x-2">
<Button size="sm" variant="outline" onClick={() => handleSaveTagItem(item.id)}>
</Button>
<Button size="sm" variant="outline" onClick={() => handleDeleteTagItem(item.id)}>
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
) : (
<div className="text-center py-8 text-muted-foreground"></div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setItemsDialogOpen(false)}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div className="container pt-24 pb-12 space-y-6">
<div className="flex items-center gap-3 mb-6">
@@ -552,7 +1124,7 @@ export default function Admin() {
{/* Tabs */}
<Tabs defaultValue="products" className="space-y-6">
<TabsList className="grid w-full grid-cols-5 lg:w-auto lg:inline-grid">
<TabsList className="grid w-full grid-cols-6 lg:w-auto lg:inline-grid">
<TabsTrigger value="products" className="gap-2">
<Package className="w-4 h-4" />
@@ -576,6 +1148,10 @@ export default function Admin() {
<CreditCard className="w-4 h-4" />
</TabsTrigger>
<TabsTrigger value="compare-tags" className="gap-2">
<Tags className="w-4 h-4" />
</TabsTrigger>
</TabsList>
{/* Products Review Tab */}
@@ -594,6 +1170,7 @@ export default function Admin() {
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
@@ -604,6 +1181,7 @@ export default function Admin() {
<TableBody>
{pendingProducts.map((product) => (
<TableRow key={product.id}>
<TableCell className="text-muted-foreground">#{product.id}</TableCell>
<TableCell>
<div className="flex items-center gap-3">
{(product.images?.[0] || product.image) && (
@@ -920,6 +1498,80 @@ export default function Admin() {
</CardContent>
</Card>
</TabsContent>
{/* Comparison Tags Tab */}
<TabsContent value="compare-tags">
<Card className="card-elegant">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 rounded-lg border px-3 py-1.5">
<span className="text-xs text-muted-foreground"></span>
<Switch checked={forceRecordHistory} onCheckedChange={setForceRecordHistory} />
</div>
<Button variant="outline" onClick={handleRecordHistory} disabled={recordHistoryMutation.isPending}>
{recordHistoryMutation.isPending ? "记录中..." : "记录当前价格"}
</Button>
<Button variant="outline" onClick={handleBackfillHistory} disabled={backfillHistoryMutation.isPending}>
{backfillHistoryMutation.isPending ? "补齐中..." : "补齐历史价格"}
</Button>
<Button onClick={openCreateTag}></Button>
</div>
</CardHeader>
<CardContent>
{compareTagsLoading ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : compareTags.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{compareTags.map((tag) => (
<TableRow key={tag.id}>
<TableCell>
<div className="font-medium">{tag.name}</div>
<div className="text-xs text-muted-foreground">{tag.slug}</div>
</TableCell>
<TableCell>
<Badge variant={tag.is_active ? "secondary" : "outline"}>
{tag.is_active ? "启用" : "停用"}
</Badge>
</TableCell>
<TableCell>{tag.product_count}</TableCell>
<TableCell>{tag.sort_order}</TableCell>
<TableCell className="space-x-2">
<Button size="sm" variant="outline" onClick={() => openTagItems(tag)}>
</Button>
<Button size="sm" variant="outline" onClick={() => openEditTag(tag)}>
</Button>
<Button size="sm" variant="outline" onClick={() => handleDeleteTag(tag.id)}>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="text-center py-12 text-muted-foreground">
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
</div>

View File

@@ -26,6 +26,7 @@ import {
useFavorites,
useMyProducts,
useCategories,
useWebsites,
} from "@/hooks/useApi";
import { Link, useLocation } from "wouter";
import { toast } from "sonner";
@@ -131,6 +132,7 @@ export default function Dashboard() {
const { data: favoritesData, isLoading: favoritesLoading } = useFavorites(undefined, { enabled: activeTab === "favorites" });
const { data: myProductsData, isLoading: myProductsLoading } = useMyProducts(undefined, { enabled: activeTab === "products" });
const { data: categoriesData, isLoading: categoriesLoading } = useCategories({ enabled: activeTab === "products" });
const { data: websitesData } = useWebsites(undefined, { enabled: activeTab === "products" });
const { data: notificationsData, isLoading: notificationsLoading, refetch: refetchNotifications } = useNotifications(undefined, { enabled: activeTab === "notifications" });
const { data: unreadCountData } = useUnreadNotificationCount();
const { data: notificationPreferences } = useNotificationPreferences({ enabled: activeTab === "notifications" });
@@ -169,6 +171,7 @@ export default function Dashboard() {
const favorites = favoritesData?.items || [];
const myProducts = myProductsData?.items || [];
const categories = categoriesData || [];
const websites = websitesData?.items || [];
const notifications = notificationsData?.items || [];
const unreadCount = unreadCountData?.count || 0;
@@ -948,9 +951,13 @@ export default function Dashboard() {
value={newProduct.websiteId}
onChange={(e) => setNewProduct((prev) => ({ ...prev, websiteId: e.target.value }))}
className="px-3 py-2 border rounded-lg bg-background text-foreground text-sm"
disabled={!newProduct.categoryId}
>
<option value=""></option>
<option value=""></option>
{websites.map((website) => (
<option key={website.id} value={website.id}>
{website.name}
</option>
))}
</select>
)}
</div>

View File

@@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Link } from "wouter";
import { Navbar } from "@/components/Navbar";
import ComparisonTagsSection from "@/features/products/components/ComparisonTagsSection";
import {
ShoppingBag,
Trophy,
@@ -126,6 +127,14 @@ export default function Home() {
</div>
</section>
<ComparisonTagsSection
title="热门比价标签"
description="精选标签聚合多平台价格,一键查看最优选择"
maxItems={4}
ctaLabel="查看更多商品"
ctaHref="/products"
/>
{/* CTA Section */}
<section className="py-20">
<div className="container">
@@ -173,9 +182,6 @@ export default function Home() {
<Link href="/bounties" className="hover:text-foreground transition-colors">
</Link>
<Link href="/search" className="hover:text-foreground transition-colors">
</Link>
</div>
<p className="text-sm text-muted-foreground">

View File

@@ -0,0 +1,369 @@
import { useEffect, useMemo, useState } from "react";
import { Link } from "wouter";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { LazyImage } from "@/components/LazyImage";
import { useComparisonTagDetail, useComparisonTags } from "@/hooks/useApi";
import {
ArrowRight,
ExternalLink,
Loader2,
Tags,
TrendingDown,
Award,
Star,
Flame,
History,
Zap,
} from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { zhCN } from "date-fns/locale";
type ComparisonTagsSectionProps = {
title?: string;
description?: string;
maxItems?: number;
ctaLabel?: string;
ctaHref?: string;
useContainer?: boolean;
showDetailLink?: boolean;
};
export default function ComparisonTagsSection({
title = "精选比价专题",
description = "管理员精选标签,一键查看多平台价格对比",
maxItems,
ctaLabel,
ctaHref,
useContainer = true,
showDetailLink = true,
}: ComparisonTagsSectionProps) {
const { data: tagsData, isLoading: tagsLoading } = useComparisonTags();
const tags = tagsData || [];
const [activeSlug, setActiveSlug] = useState<string>("");
useEffect(() => {
if (!activeSlug && tags.length > 0) {
setActiveSlug(tags[0].slug);
}
}, [activeSlug, tags]);
const { data: tagDetail, isLoading: detailLoading } =
useComparisonTagDetail(activeSlug);
const items = tagDetail?.items || [];
const visibleItems = maxItems ? items.slice(0, maxItems) : items;
// 统计历史最低价商品数量
const historicalLowestCount = useMemo(() => {
return items.filter((item) => item.is_at_historical_lowest).length;
}, [items]);
// 统计推荐商品数量
const recommendedCount = useMemo(() => {
return items.filter((item) => item.is_recommended).length;
}, [items]);
const totalPlatforms = useMemo(() => {
return items.reduce((sum, item) => sum + (item.platform_count || 0), 0);
}, [items]);
if (!tagsLoading && tags.length === 0) {
return null;
}
return (
<section className="py-12">
<div className={useContainer ? "container" : ""}>
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between mb-6">
<div>
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-2">
<Tags className="w-4 h-4" />
{historicalLowestCount > 0 && (
<Badge variant="default" className="gap-1 bg-green-600 ml-2">
<Flame className="w-3 h-3" />
{historicalLowestCount}
</Badge>
)}
</div>
<h2
className="text-2xl font-bold"
style={{ fontFamily: "'Playfair Display', serif" }}
>
{title}
</h2>
<p className="text-muted-foreground text-sm mt-1">{description}</p>
</div>
<div className="flex items-center gap-2">
{showDetailLink && activeSlug && (
<Link href={`/compare-tags/${activeSlug}`}>
<Button variant="outline" className="gap-2">
<ArrowRight className="w-4 h-4" />
</Button>
</Link>
)}
{ctaLabel && ctaHref && (
<Link href={ctaHref}>
<Button variant="outline" className="gap-2">
{ctaLabel}
<ArrowRight className="w-4 h-4" />
</Button>
</Link>
)}
</div>
</div>
<div className="flex flex-wrap gap-2 mb-6">
{tagsLoading ? (
<Badge variant="secondary" className="px-3 py-1">
...
</Badge>
) : (
tags.map((tag) => (
<Button
key={tag.id}
size="sm"
variant={activeSlug === tag.slug ? "default" : "outline"}
className="gap-2"
onClick={() => setActiveSlug(tag.slug)}
>
{tag.name}
<Badge variant="secondary" className="ml-1">
{tag.product_count}
</Badge>
</Button>
))
)}
</div>
{detailLoading ? (
<div className="flex items-center justify-center py-10">
<Loader2 className="w-6 h-6 animate-spin text-primary" />
</div>
) : visibleItems.length === 0 ? (
<Card className="card-elegant">
<CardContent className="py-12 text-center text-muted-foreground">
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2">
{visibleItems.map((item) => {
const prices = [...item.prices].sort(
(a, b) => Number(a.price) - Number(b.price)
);
const topPrices = prices.slice(0, 3);
const lowest =
item.lowest_price ?? (prices[0]?.price || null);
const highest =
item.highest_price ??
(prices[prices.length - 1]?.price || null);
const image =
item.product.images?.[0] || item.product.image || "";
const lowestRecord = prices[0];
return (
<Card key={item.product.id} className="card-elegant relative">
{/* 推荐角标 */}
{item.is_recommended && (
<div className="absolute top-0 right-0 bg-gradient-to-l from-yellow-500 to-orange-500 text-white text-xs px-3 py-1 rounded-bl-lg font-medium z-10">
<Star className="w-3 h-3 inline mr-1" />
</div>
)}
<CardHeader className="flex flex-row gap-4 items-start">
<div className="w-20 h-20 rounded-lg overflow-hidden bg-muted/40 flex-shrink-0 relative">
<LazyImage
src={image}
alt={item.product.name}
className="w-full h-full"
aspectRatio="1/1"
/>
{/* 历史最低角标 */}
{item.is_at_historical_lowest && (
<div className="absolute bottom-0 left-0 right-0 bg-green-600 text-white text-xs py-0.5 text-center font-medium">
<Award className="w-3 h-3 inline mr-1" />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<CardTitle className="text-base line-clamp-2 flex items-center gap-2 flex-wrap">
{item.product.name}
{item.discount_from_highest_percent &&
Number(item.discount_from_highest_percent) >= 20 && (
<Badge
variant="secondary"
className="gap-1 bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300"
>
<Zap className="w-3 h-3" />
{Number(item.discount_from_highest_percent).toFixed(0)}%
</Badge>
)}
</CardTitle>
{item.product.description && (
<CardDescription className="line-clamp-2 mt-1">
{item.product.description}
</CardDescription>
)}
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm">
<Badge variant="secondary">
{item.platform_count || prices.length}
</Badge>
{lowest && (
<span className="text-primary font-semibold">
¥{lowest}
</span>
)}
{highest && lowest && highest !== lowest && (
<span className="text-muted-foreground">
¥{highest}
</span>
)}
</div>
{/* 历史最低价信息 */}
{item.historical_lowest && (
<div className="mt-1 text-xs text-muted-foreground flex items-center gap-1">
<History className="w-3 h-3" />
¥{item.historical_lowest}
{item.historical_lowest_date && (
<span>
·{" "}
{formatDistanceToNow(
new Date(item.historical_lowest_date),
{ addSuffix: true, locale: zhCN }
)}
</span>
)}
</div>
)}
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-2">
{topPrices.map((price, index) => (
<div
key={price.id}
className={`flex items-center justify-between text-sm p-2 rounded-lg transition-colors ${
index === 0
? "bg-primary/5 border border-primary/20"
: "hover:bg-muted/50"
}`}
>
<div className="min-w-0 flex items-center gap-2">
{/* 平台Logo */}
{price.website_logo ? (
<img
src={price.website_logo}
alt={price.website_name || ""}
className="w-5 h-5 rounded object-contain"
/>
) : (
<div className="w-5 h-5 rounded bg-muted flex items-center justify-center text-xs font-medium">
{(price.website_name || "?")[0]}
</div>
)}
<div>
<div className="font-medium truncate flex items-center gap-1">
{price.website_name || "未知网站"}
{price.is_at_historical_lowest && (
<Badge
variant="secondary"
className="text-xs gap-0.5 bg-green-100 text-green-700 px-1"
>
<Award className="w-2.5 h-2.5" />
</Badge>
)}
</div>
<div className="text-muted-foreground flex items-center gap-2">
<span className="font-semibold text-foreground">
¥{price.price}
</span>
{price.original_price &&
Number(price.original_price) >
Number(price.price) && (
<>
<span className="line-through text-xs">
¥{price.original_price}
</span>
<Badge
variant="secondary"
className="gap-0.5 text-xs"
>
<TrendingDown className="w-2.5 h-2.5" />
{price.discount_percent}%
</Badge>
</>
)}
</div>
</div>
</div>
<a
href={price.url}
target="_blank"
rel="noreferrer"
className="text-primary hover:underline flex items-center gap-1"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
</div>
))}
</div>
{prices.length > topPrices.length && (
<div className="mt-3 text-xs text-muted-foreground">
{prices.length - topPrices.length}
</div>
)}
{lowestRecord?.last_checked && (
<div className="mt-2 text-xs text-muted-foreground">
{" "}
{formatDistanceToNow(
new Date(lowestRecord.last_checked),
{ addSuffix: true, locale: zhCN }
)}
</div>
)}
</CardContent>
</Card>
);
})}
</div>
)}
{/* 底部统计信息 */}
{items.length > 0 && (
<div className="mt-6 flex flex-wrap items-center justify-center gap-4 text-sm text-muted-foreground">
<span> {items.length} </span>
<span>·</span>
<span> {totalPlatforms} </span>
{historicalLowestCount > 0 && (
<>
<span>·</span>
<span className="text-green-600 font-medium">
{historicalLowestCount}
</span>
</>
)}
{recommendedCount > 0 && (
<>
<span>·</span>
<span className="text-orange-600 font-medium">
{recommendedCount}
</span>
</>
)}
</div>
)}
</div>
</section>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -287,9 +287,6 @@ export default function ProductDetail() {
<Link href="/bounties" className="text-muted-foreground hover:text-foreground transition-colors">
</Link>
<Link href="/search" className="text-muted-foreground hover:text-foreground transition-colors">
</Link>
{isAuthenticated && (
<Link href="/dashboard" className="text-muted-foreground hover:text-foreground transition-colors">

View File

@@ -1,54 +1,34 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Navbar } from "@/components/Navbar";
import { useCategories, useWebsites, useProducts, useFavorites, useAddFavorite, useRemoveFavorite, useRecommendedProducts, useProductSearch } from "@/hooks/useApi";
import { useDebounce } from "@/hooks/useDebounce";
import { categoryApi, productApi, websiteApi, type Product, type ProductWithPrices } from "@/lib/api";
import { Link } from "wouter";
import { useState, useMemo, useEffect, useRef } from "react";
import { type Product, type ProductWithPrices } from "@/lib/api";
import { Link, useLocation } from "wouter";
import { useState, useMemo, useEffect } from "react";
import {
ShoppingBag,
ArrowUpDown,
Loader2,
Heart,
Sparkles,
Image as ImageIcon,
Trash2,
Plus,
} from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { getErrorCopy } from "@/lib/i18n/errorMessages";
import { MobileNav } from "@/components/MobileNav";
import { ProductListSkeleton } from "@/components/ProductCardSkeleton";
import { LazyImage } from "@/components/LazyImage";
import SortableGrid, { HeroDropzone } from "@/components/SortableGrid";
import FileThumbnail from "@/components/FileThumbnail";
import { createImageFileItems, processImageFile, type ImageFileItem } from "@/lib/image";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import ProductsHeader from "@/features/products/components/ProductsHeader";
import WebsitesSection from "@/features/products/components/WebsitesSection";
import RecommendedProducts from "@/features/products/components/RecommendedProducts";
import ComparisonTagsSection from "@/features/products/components/ComparisonTagsSection";
export default function Products() {
const { user, isAuthenticated } = useAuth();
const { isAuthenticated } = useAuth();
const [, navigate] = useLocation();
const queryClient = useQueryClient();
const MAX_IMAGES = 6;
const [searchQuery, setSearchQuery] = useState("");
const [selectedCategory, setSelectedCategory] = useState<string>("all");
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
@@ -59,67 +39,7 @@ export default function Products() {
const [favoriteWebsiteByProduct, setFavoriteWebsiteByProduct] = useState<Record<number, number>>({});
const [favoriteDialogOpen, setFavoriteDialogOpen] = useState(false);
const [favoriteDialogProduct, setFavoriteDialogProduct] = useState<ProductWithPrices | null>(null);
const [isAddOpen, setIsAddOpen] = useState(false);
const [isNewCategory, setIsNewCategory] = useState(false);
const [isCreatingCategory, setIsCreatingCategory] = useState(false);
const [imageFiles, setImageFiles] = useState<ImageFileItem[]>([]);
const [isUploadingImage, setIsUploadingImage] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const confirmActionRef = useRef<null | (() => void)>(null);
const [confirmItem, setConfirmItem] = useState<{ name?: string; file?: File } | null>(null);
const [confirmMeta, setConfirmMeta] = useState<{ isFirst?: boolean; nextName?: string | null } | null>(null);
const [newProduct, setNewProduct] = useState({
name: "",
description: "",
image: "",
categoryId: "",
websiteId: "",
price: "",
originalPrice: "",
currency: "CNY",
url: "",
inStock: true,
});
const [isNewWebsite, setIsNewWebsite] = useState(false);
const [newWebsite, setNewWebsite] = useState({
name: "",
url: "",
});
const [newCategory, setNewCategory] = useState({
name: "",
slug: "",
description: "",
});
const debouncedSearchQuery = useDebounce(searchQuery, 300);
const imageHeroId = "product-image-hero";
const moveImageToFront = (activeId: string) => {
setImageFiles((prev) => {
const index = prev.findIndex((item) => item.id === activeId);
if (index <= 0) return prev;
const next = [...prev];
const [picked] = next.splice(index, 1);
next.unshift(picked);
return next;
});
};
const appendImageFiles = (files: File[]) => {
if (files.length === 0) return;
setImageFiles((prev) => {
const merged = [...prev, ...createImageFileItems(files)];
if (merged.length > MAX_IMAGES) {
toast.error(`最多上传${MAX_IMAGES}张图片`);
return merged.slice(0, MAX_IMAGES);
}
return merged;
});
setNewProduct(prev => ({ ...prev, image: "" }));
};
const openConfirm = (action: () => void, item?: { name?: string; file?: File }, meta?: { isFirst?: boolean; nextName?: string | null }) => {
confirmActionRef.current = action;
setConfirmItem(item || null);
setConfirmMeta(meta || null);
setConfirmOpen(true);
};
const { data: categoriesData, isLoading: categoriesLoading } = useCategories();
const websiteParams = selectedCategory !== "all"
@@ -194,523 +114,28 @@ export default function Products() {
const isLoading = categoriesLoading || websitesLoading || (debouncedSearchQuery.trim() ? searchLoading : productsLoading);
const handleAddProduct = async () => {
if (!newProduct.name.trim()) {
toast.error("请输入商品名称");
const handleAddProductClick = () => {
if (!isAuthenticated) {
toast.error("请先登录");
navigate("/login");
return;
}
if (!newProduct.categoryId) {
toast.error("请选择分类");
return;
}
if (isNewWebsite) {
if (!newWebsite.name.trim()) {
toast.error("请输入网站名称");
return;
}
if (!newWebsite.url.trim()) {
toast.error("请输入网站URL");
return;
}
} else if (!newProduct.websiteId) {
toast.error("请选择网站");
return;
}
if (!newProduct.price || Number(newProduct.price) <= 0) {
toast.error("请输入有效价格");
return;
}
if (!newProduct.url.trim()) {
toast.error("请输入商品链接");
return;
}
try {
let websiteId = Number(newProduct.websiteId);
// Create new website if needed
if (isNewWebsite) {
const website = await websiteApi.create({
name: newWebsite.name.trim(),
url: newWebsite.url.trim(),
category_id: Number(newProduct.categoryId),
});
websiteId = website.id;
queryClient.invalidateQueries({ queryKey: ["websites"] });
}
let imageUrl = newProduct.image.trim() || undefined;
let images: string[] | undefined;
if (imageFiles.length > 0) {
setIsUploadingImage(true);
const uploadedUrls: string[] = [];
for (const item of imageFiles) {
let processed: File;
try {
processed = await processImageFile(item.file);
} catch (err) {
const message = err instanceof Error ? err.message : "图片处理失败";
toast.error(message);
return;
}
const uploadResult = await productApi.uploadImage(processed);
uploadedUrls.push(uploadResult.url);
}
images = uploadedUrls;
imageUrl = uploadedUrls[0];
} else if (imageUrl) {
images = [imageUrl];
}
const product = await productApi.create({
name: newProduct.name.trim(),
description: newProduct.description.trim() || undefined,
image: imageUrl,
images,
category_id: Number(newProduct.categoryId),
});
await productApi.addPrice({
product_id: product.id,
website_id: websiteId,
price: newProduct.price,
original_price: newProduct.originalPrice || undefined,
currency: newProduct.currency || "CNY",
url: newProduct.url.trim(),
in_stock: newProduct.inStock,
});
toast.success("商品已添加");
queryClient.invalidateQueries({ queryKey: ["products"] });
setIsAddOpen(false);
setNewProduct({
name: "",
description: "",
image: "",
categoryId: "",
websiteId: "",
price: "",
originalPrice: "",
currency: "CNY",
url: "",
inStock: true,
});
setIsNewWebsite(false);
setImageFiles([]);
setIsUploadingImage(false);
setNewWebsite({ name: "", url: "" });
} catch (error: unknown) {
const { title, description } = getErrorCopy(error, { context: "product.create" });
toast.error(title, { description });
} finally {
setIsUploadingImage(false);
}
};
const handleCreateCategory = async () => {
const name = newCategory.name.trim();
if (!name) {
toast.error("请输入分类名称");
return;
}
const rawSlug = newCategory.slug.trim();
const fallbackSlug = name
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "");
const slug = rawSlug || fallbackSlug || `category-${Date.now()}`;
setIsCreatingCategory(true);
try {
const category = await categoryApi.create({
name,
slug,
description: newCategory.description.trim() || undefined,
});
queryClient.setQueryData(["categories"], (prev) => {
if (Array.isArray(prev)) {
const exists = prev.some((item) => item.id === category.id);
return exists ? prev : [...prev, category];
}
return [category];
});
queryClient.invalidateQueries({ queryKey: ["categories"] });
setNewProduct((prev) => ({ ...prev, categoryId: category.id.toString() }));
setIsNewCategory(false);
setNewCategory({ name: "", slug: "", description: "" });
toast.success("分类已创建");
} catch (error: unknown) {
const { title, description } = getErrorCopy(error, { context: "category.create" });
toast.error(title, { description });
} finally {
setIsCreatingCategory(false);
}
navigate("/dashboard?tab=products");
};
return (
<div className="min-h-screen bg-background">
<Navbar>
{isAuthenticated && (
<>
<Dialog open={isAddOpen} onOpenChange={setIsAddOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="hidden md:inline-flex">
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label htmlFor="product-name"></Label>
<Input
id="product-name"
value={newProduct.name}
onChange={(e) => setNewProduct(prev => ({ ...prev, name: e.target.value }))}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="product-desc"></Label>
<Input
id="product-desc"
value={newProduct.description}
onChange={(e) => setNewProduct(prev => ({ ...prev, description: e.target.value }))}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="product-image">URL</Label>
<Input
id="product-image"
value={newProduct.image}
onChange={(e) => {
setNewProduct(prev => ({ ...prev, image: e.target.value }));
if (e.target.value.trim()) {
setImageFiles([]);
}
}}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="product-image-file"></Label>
<div
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
appendImageFiles(Array.from(e.dataTransfer.files || []));
}}
onPaste={(e) => {
appendImageFiles(Array.from(e.clipboardData?.files || []));
}}
className="rounded-md border border-dashed bg-muted/20 p-3 transition hover:bg-muted/40"
>
<div className="mb-2 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<ImageIcon className="h-4 w-4" />
/
</div>
<div>{MAX_IMAGES}</div>
</div>
<Input
id="product-image-file"
type="file"
accept="image/*"
multiple
onChange={(e) => {
const files = Array.from(e.target.files || []);
if (files.length > MAX_IMAGES) {
toast.error(`最多上传${MAX_IMAGES}张图片`);
setImageFiles(createImageFileItems(files.slice(0, MAX_IMAGES)));
} else {
setImageFiles(createImageFileItems(files));
}
if (files.length > 0) {
setNewProduct(prev => ({ ...prev, image: "" }));
}
}}
/>
</div>
<p className="text-xs text-muted-foreground">自动裁剪为1:15MB</p>
{imageFiles.length > 0 && (
<div className="space-y-2">
<HeroDropzone id={imageHeroId} className="rounded-md border bg-muted/40 p-3">
<div className="text-xs text-muted-foreground mb-2"></div>
<div className="flex items-center gap-3">
<FileThumbnail file={imageFiles[0].file} className="h-16 w-16 rounded object-cover" />
<div className="text-xs text-muted-foreground truncate">{imageFiles[0].file.name}</div>
</div>
</HeroDropzone>
<SortableGrid
items={imageFiles}
onReorder={setImageFiles}
heroDropId={imageHeroId}
onHeroDrop={moveImageToFront}
className="grid grid-cols-2 gap-3"
itemClassName="p-3"
renderItem={(item) => {
const isFirst = imageFiles[0]?.id === item.id;
return (
<div className="flex items-center gap-3">
<button type="button" className="relative" onClick={() => moveImageToFront(item.id)}>
<FileThumbnail file={item.file} className="h-20 w-20 rounded object-cover" />
{isFirst && (
<div className="absolute right-1 top-1 rounded bg-primary/90 px-2 py-0.5 text-[11px] text-primary-foreground">
</div>
)}
</button>
<div className="flex-1 min-w-0">
<div className="text-sm text-muted-foreground truncate">{item.file.name}</div>
</div>
<div className="flex items-center gap-1">
<Button
type="button"
variant="outline"
size="icon"
onClick={() => {
const isFirstItem = isFirst;
const nextName = imageFiles.length > 1
? imageFiles[1]?.file.name || null
: null;
openConfirm(
() => {
setImageFiles((prev) => prev.filter((f) => f.id !== item.id));
},
{ name: item.file.name, file: item.file },
{ isFirst: isFirstItem, nextName }
);
}}
title="删除"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
);
}}
/>
</div>
)}
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label></Label>
<button
type="button"
onClick={() => {
setIsNewCategory(!isNewCategory);
if (!isNewCategory) {
setNewProduct((prev) => ({ ...prev, categoryId: "" }));
} else {
setNewCategory({ name: "", slug: "", description: "" });
}
}}
className="text-xs text-primary hover:underline"
>
{isNewCategory ? "选择已有分类" : "+ 添加新分类"}
</button>
</div>
{isNewCategory ? (
<div className="space-y-2">
<Input
placeholder="分类名称"
value={newCategory.name}
onChange={(e) => setNewCategory((prev) => ({ ...prev, name: e.target.value }))}
disabled={isCreatingCategory}
/>
<Input
placeholder="分类标识(可选,如: digital"
value={newCategory.slug}
onChange={(e) => setNewCategory((prev) => ({ ...prev, slug: e.target.value }))}
disabled={isCreatingCategory}
/>
<Input
placeholder="分类描述(可选)"
value={newCategory.description}
onChange={(e) => setNewCategory((prev) => ({ ...prev, description: e.target.value }))}
disabled={isCreatingCategory}
/>
<Button
type="button"
onClick={handleCreateCategory}
disabled={isCreatingCategory}
>
{isCreatingCategory ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
"创建分类"
)}
</Button>
</div>
) : (
<>
<select
value={newProduct.categoryId}
onChange={(e) => setNewProduct(prev => ({ ...prev, categoryId: e.target.value }))}
className="px-3 py-2 border rounded-lg bg-background text-foreground text-sm"
>
<option value=""></option>
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
</select>
{categories.length === 0 && !categoriesLoading && (
<p className="text-xs text-muted-foreground"></p>
)}
</>
)}
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label></Label>
<button
type="button"
onClick={() => {
setIsNewWebsite(!isNewWebsite);
if (!isNewWebsite) {
setNewProduct(prev => ({ ...prev, websiteId: "" }));
} else {
setNewWebsite({ name: "", url: "" });
}
}}
className="text-xs text-primary hover:underline"
>
{isNewWebsite ? "选择已有网站" : "+ 添加新网站"}
</button>
</div>
{isNewWebsite ? (
<div className="space-y-2">
<Input
placeholder="网站名称 (如: 京东)"
value={newWebsite.name}
onChange={(e) => setNewWebsite(prev => ({ ...prev, name: e.target.value }))}
/>
<Input
placeholder="网站URL (如: https://www.jd.com)"
value={newWebsite.url}
onChange={(e) => setNewWebsite(prev => ({ ...prev, url: e.target.value }))}
/>
</div>
) : (
<select
value={newProduct.websiteId}
onChange={(e) => setNewProduct(prev => ({ ...prev, websiteId: e.target.value }))}
className="px-3 py-2 border rounded-lg bg-background text-foreground text-sm"
>
<option value=""></option>
{websites.map((website) => (
<option key={website.id} value={website.id}>
{website.name}
</option>
))}
</select>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-2">
<Label htmlFor="product-price"></Label>
<Input
id="product-price"
type="number"
value={newProduct.price}
onChange={(e) => setNewProduct(prev => ({ ...prev, price: e.target.value }))}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="product-original-price"></Label>
<Input
id="product-original-price"
type="number"
value={newProduct.originalPrice}
onChange={(e) => setNewProduct(prev => ({ ...prev, originalPrice: e.target.value }))}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-2">
<Label htmlFor="product-currency"></Label>
<Input
id="product-currency"
value={newProduct.currency}
onChange={(e) => setNewProduct(prev => ({ ...prev, currency: e.target.value }))}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="product-url"></Label>
<Input
id="product-url"
value={newProduct.url}
onChange={(e) => setNewProduct(prev => ({ ...prev, url: e.target.value }))}
/>
</div>
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={newProduct.inStock}
onCheckedChange={(checked) =>
setNewProduct(prev => ({ ...prev, inStock: Boolean(checked) }))
}
/>
<span className="text-sm"></span>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddOpen(false)}>
</Button>
<Button onClick={handleAddProduct} disabled={isUploadingImage}>
{isUploadingImage ? "上传中..." : "保存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)}
<Button
variant="outline"
size="sm"
className="hidden md:inline-flex gap-2"
onClick={handleAddProductClick}
>
<Plus className="w-4 h-4" />
</Button>
</Navbar>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription></AlertDialogDescription>
</AlertDialogHeader>
{confirmItem && (
<div className="flex items-center gap-3 rounded-md border bg-muted/40 p-3">
{confirmItem.file ? (
<FileThumbnail file={confirmItem.file} className="h-20 w-20 rounded object-cover" />
) : (
<div className="h-20 w-20 rounded bg-muted" />
)}
<div className="text-sm text-muted-foreground truncate">{confirmItem.name || "图片"}</div>
</div>
)}
{confirmMeta?.isFirst && (
<div className="text-xs text-muted-foreground">
使{confirmMeta.nextName ? `${confirmMeta.nextName}` : "。"}
</div>
)}
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => {
confirmActionRef.current?.();
confirmActionRef.current = null;
setConfirmItem(null);
}}
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ProductsHeader
searchQuery={searchQuery}
@@ -740,6 +165,12 @@ export default function Products() {
<RecommendedProducts products={recommendedProducts} />
<ComparisonTagsSection
title="比价标签"
description="管理员精选标签,一键查看多平台价格对比"
useContainer={false}
/>
{/* Products Section */}
<div>
<h2 className="text-xl font-semibold mb-6 flex items-center gap-2">

View File

@@ -1,172 +0,0 @@
import { useState } from "react";
import { Link } from "wouter";
import { Navbar } from "@/components/Navbar";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useDebounce } from "@/hooks/useDebounce";
import { useGlobalSearch } from "@/hooks/useApi";
import { Button } from "@/components/ui/button";
import { ExternalLink, Loader2, Search as SearchIcon, ShoppingBag, Trophy } from "lucide-react";
export default function Search() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 300);
const { data, isLoading } = useGlobalSearch(debouncedQuery, 8);
const products = data?.products || [];
const websites = data?.websites || [];
const bounties = data?.bounties || [];
return (
<div className="min-h-screen bg-background">
<Navbar />
<section className="pt-24 pb-8">
<div className="container max-w-4xl">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center">
<SearchIcon className="w-5 h-5 text-primary-foreground" />
</div>
<div>
<h1 className="text-3xl font-bold"></h1>
<p className="text-muted-foreground"></p>
</div>
</div>
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="输入关键词..."
className="pl-10"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
</div>
</section>
<section className="pb-20">
<div className="container max-w-4xl space-y-6">
{!debouncedQuery.trim() && (
<Card className="card-elegant">
<CardContent className="py-12 text-center text-muted-foreground">
</CardContent>
</Card>
)}
{isLoading && (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-primary" />
</div>
)}
{!isLoading && debouncedQuery.trim() && (
<>
<Card className="card-elegant">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<ShoppingBag className="w-4 h-4 text-primary" />
<Badge variant="secondary">{products.length}</Badge>
</CardTitle>
<Link href="/products" className="text-sm text-muted-foreground hover:text-foreground">
</Link>
</CardHeader>
<CardContent className="space-y-3">
{products.length === 0 ? (
<div className="text-sm text-muted-foreground"></div>
) : (
products.map((product) => (
<Link key={product.id} href={`/products/${product.id}`}>
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/40 hover:bg-muted transition-colors cursor-pointer">
<div className="min-w-0">
<div className="font-medium truncate">{product.name}</div>
<div className="text-sm text-muted-foreground line-clamp-1">
{product.description || "暂无描述"}
</div>
</div>
<span className="text-sm text-primary"></span>
</div>
</Link>
))
)}
</CardContent>
</Card>
<Card className="card-elegant">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<ExternalLink className="w-4 h-4 text-primary" />
<Badge variant="secondary">{websites.length}</Badge>
</CardTitle>
<Link href="/products" className="text-sm text-muted-foreground hover:text-foreground">
</Link>
</CardHeader>
<CardContent className="space-y-3">
{websites.length === 0 ? (
<div className="text-sm text-muted-foreground"></div>
) : (
websites.map((website) => (
<a
key={website.id}
href={website.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between p-3 rounded-lg bg-muted/40 hover:bg-muted transition-colors"
>
<div className="min-w-0">
<div className="font-medium truncate">{website.name}</div>
<div className="text-sm text-muted-foreground line-clamp-1">
{website.description || website.url}
</div>
</div>
<span className="text-sm text-primary">访</span>
</a>
))
)}
</CardContent>
</Card>
<Card className="card-elegant">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Trophy className="w-4 h-4 text-primary" />
<Badge variant="secondary">{bounties.length}</Badge>
</CardTitle>
<Link href="/bounties" className="text-sm text-muted-foreground hover:text-foreground">
</Link>
</CardHeader>
<CardContent className="space-y-3">
{bounties.length === 0 ? (
<div className="text-sm text-muted-foreground"></div>
) : (
bounties.map((bounty) => (
<Link key={bounty.id} href={`/bounties/${bounty.id}`}>
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/40 hover:bg-muted transition-colors cursor-pointer">
<div className="min-w-0">
<div className="font-medium truncate">{bounty.title}</div>
<div className="text-sm text-muted-foreground line-clamp-1">
{bounty.description}
</div>
</div>
<span className="text-sm text-primary"></span>
</div>
</Link>
))
)}
</CardContent>
</Card>
</>
)}
</div>
</section>
</div>
);
}

View File

@@ -8,6 +8,7 @@ import {
categoryApi,
websiteApi,
productApi,
comparisonTagApi,
bountyApi,
favoriteApi,
notificationApi,
@@ -17,7 +18,6 @@ import {
setAccessToken,
setRefreshToken,
clearRefreshToken,
searchApi,
type User,
type Bounty,
type BountyApplication,
@@ -280,6 +280,15 @@ export function useProductWithPrices(id: number) {
});
}
export function useProductPriceHistory(productId: number, params?: { website_id?: number; limit?: number }, options?: { enabled?: boolean }) {
return useQuery({
queryKey: ['products', productId, 'price-history', params],
queryFn: () => productApi.priceHistory(productId, params),
enabled: (options?.enabled !== false) && !!productId,
staleTime: shortStaleTime,
});
}
export function useProductSearch(q: string, params?: { page?: number; category_id?: number; user_id?: string; min_price?: number; max_price?: number; sort_by?: string }) {
const debouncedQuery = useDebouncedValue(q, 300);
return useQuery({
@@ -291,6 +300,40 @@ export function useProductSearch(q: string, params?: { page?: number; category_i
});
}
// ==================== Comparison Tag Hooks ====================
export function useComparisonTags() {
return useQuery({
queryKey: ['compare-tags'],
queryFn: comparisonTagApi.list,
staleTime: staticStaleTime,
});
}
export interface ComparisonTagFilters {
only_discounted?: boolean;
min_discount_percent?: number;
only_historical_lowest?: boolean;
}
export function useComparisonTagDetail(slug: string, filters?: ComparisonTagFilters) {
return useQuery({
queryKey: ['compare-tags', slug, filters],
queryFn: () => comparisonTagApi.get(slug, filters),
enabled: !!slug,
staleTime: shortStaleTime,
});
}
export function usePriceStats(productId: number, days?: number, options?: { enabled?: boolean }) {
return useQuery({
queryKey: ['products', productId, 'price-stats', days],
queryFn: () => comparisonTagApi.getPriceStats(productId, days),
enabled: (options?.enabled !== false) && !!productId,
staleTime: shortStaleTime,
});
}
// ==================== Bounty Hooks ====================
export function useBounties(params?: { status?: string; publisher_id?: number; acceptor_id?: number; page?: number }) {
@@ -736,16 +779,6 @@ export function useNotifications(params?: { is_read?: boolean; type?: string; st
});
}
export function useGlobalSearch(q: string, limit = 10) {
const debouncedQuery = useDebouncedValue(q, 300);
return useQuery({
queryKey: ['search', debouncedQuery, limit],
queryFn: () => searchApi.global(debouncedQuery, limit),
enabled: !!debouncedQuery.trim(),
staleTime: shortStaleTime,
});
}
export function useUnreadNotificationCount(options?: { enabled?: boolean }) {
return useQuery({
queryKey: ['notifications', 'unread-count'],
@@ -896,6 +929,14 @@ export function useAdminAllProducts(status?: string) {
});
}
export function useAdminSimpleProducts() {
return useQuery({
queryKey: ['admin', 'products', 'simple'],
queryFn: adminApi.listProducts,
staleTime: shortStaleTime,
});
}
export function useReviewProduct() {
const queryClient = useQueryClient();
return useMutation({
@@ -918,6 +959,115 @@ export function useUpdateAdminProductImages() {
});
}
export function useAdminComparisonTags() {
return useQuery({
queryKey: ['admin', 'compare-tags'],
queryFn: adminApi.listComparisonTags,
staleTime: shortStaleTime,
});
}
export function useCreateAdminComparisonTag() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { name: string; slug: string; description?: string; cover_image?: string; icon?: string; sort_order?: number; is_active?: boolean }) =>
adminApi.createComparisonTag(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'compare-tags'] });
},
});
}
export function useUpdateAdminComparisonTag() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ tagId, data }: { tagId: number; data: { name?: string; slug?: string; description?: string; cover_image?: string; icon?: string; sort_order?: number; is_active?: boolean } }) =>
adminApi.updateComparisonTag(tagId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'compare-tags'] });
},
});
}
export function useDeleteAdminComparisonTag() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (tagId: number) => adminApi.deleteComparisonTag(tagId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'compare-tags'] });
},
});
}
export function useAdminComparisonTagItems(tagId: number, options?: { enabled?: boolean }) {
return useQuery({
queryKey: ['admin', 'compare-tags', tagId, 'items'],
queryFn: () => adminApi.listComparisonTagItems(tagId),
enabled: (options?.enabled !== false) && !!tagId,
staleTime: shortStaleTime,
});
}
export function useAddAdminComparisonTagItems() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ tagId, data }: { tagId: number; data: { product_ids: number[]; sort_order?: number; is_pinned?: boolean } }) =>
adminApi.addComparisonTagItems(tagId, data),
onSuccess: (_, { tagId }) => {
queryClient.invalidateQueries({ queryKey: ['admin', 'compare-tags', tagId, 'items'] });
queryClient.invalidateQueries({ queryKey: ['admin', 'compare-tags'] });
},
});
}
export function useUpdateAdminComparisonTagItem() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ tagId, itemId, data }: { tagId: number; itemId: number; data: { sort_order?: number; is_pinned?: boolean } }) =>
adminApi.updateComparisonTagItem(tagId, itemId, data),
onSuccess: (_, { tagId }) => {
queryClient.invalidateQueries({ queryKey: ['admin', 'compare-tags', tagId, 'items'] });
},
});
}
export function useDeleteAdminComparisonTagItem() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ tagId, itemId }: { tagId: number; itemId: number }) =>
adminApi.deleteComparisonTagItem(tagId, itemId),
onSuccess: (_, { tagId }) => {
queryClient.invalidateQueries({ queryKey: ['admin', 'compare-tags', tagId, 'items'] });
queryClient.invalidateQueries({ queryKey: ['admin', 'compare-tags'] });
},
});
}
export function useBackfillPriceHistory() {
return useMutation({
mutationFn: (productIds?: number[]) => adminApi.backfillPriceHistory(productIds),
});
}
export function useBackfillTagPriceHistory() {
return useMutation({
mutationFn: (tagId: number) => adminApi.backfillTagPriceHistory(tagId),
});
}
export function useRecordPriceHistory() {
return useMutation({
mutationFn: (data?: { product_ids?: number[]; force?: boolean }) => adminApi.recordPriceHistory(data),
});
}
export function useRecordTagPriceHistory() {
return useMutation({
mutationFn: ({ tagId, data }: { tagId: number; data?: { product_ids?: number[]; force?: boolean } }) =>
adminApi.recordTagPriceHistory(tagId, data),
});
}
// ==================== My Products Hooks ====================
export function useMyProducts(status?: string, options?: { enabled?: boolean }) {

View File

@@ -1,5 +1,5 @@
import { api } from "./client";
import type { AdminBounty, AdminPaymentEvent, AdminUser, AdminProduct, PaginatedResponse } from "../types";
import type { AdminBounty, AdminPaymentEvent, AdminUser, AdminProduct, AdminComparisonTag, AdminComparisonTagItem, PaginatedResponse } from "../types";
export const adminApi = {
listUsers: () => api.get<PaginatedResponse<AdminUser>>("/admin/users/").then((r) => r.data),
@@ -26,4 +26,31 @@ export const adminApi = {
api.post<AdminProduct>(`/admin/products/${productId}/review/`, data).then((r) => r.data),
updateProductImages: (productId: number, data: { images: string[]; image?: string }) =>
api.put<AdminProduct>(`/admin/products/${productId}/images/`, data).then((r) => r.data),
// Comparison tags
listComparisonTags: () => api.get<AdminComparisonTag[]>("/admin/compare-tags/").then((r) => r.data),
createComparisonTag: (data: { name: string; slug: string; description?: string; cover_image?: string; icon?: string; sort_order?: number; is_active?: boolean }) =>
api.post<AdminComparisonTag>("/admin/compare-tags/", data).then((r) => r.data),
updateComparisonTag: (tagId: number, data: { name?: string; slug?: string; description?: string; cover_image?: string; icon?: string; sort_order?: number; is_active?: boolean }) =>
api.patch<AdminComparisonTag>(`/admin/compare-tags/${tagId}`, data).then((r) => r.data),
deleteComparisonTag: (tagId: number) =>
api.delete<{ message: string }>(`/admin/compare-tags/${tagId}`).then((r) => r.data),
listComparisonTagItems: (tagId: number) =>
api.get<AdminComparisonTagItem[]>(`/admin/compare-tags/${tagId}/items/`).then((r) => r.data),
addComparisonTagItems: (tagId: number, data: { product_ids: number[]; sort_order?: number; is_pinned?: boolean }) =>
api.post<AdminComparisonTagItem[]>(`/admin/compare-tags/${tagId}/items/`, data).then((r) => r.data),
updateComparisonTagItem: (tagId: number, itemId: number, data: { sort_order?: number; is_pinned?: boolean }) =>
api.patch<AdminComparisonTagItem>(`/admin/compare-tags/${tagId}/items/${itemId}`, data).then((r) => r.data),
deleteComparisonTagItem: (tagId: number, itemId: number) =>
api.delete<{ message: string }>(`/admin/compare-tags/${tagId}/items/${itemId}`).then((r) => r.data),
// Price history backfill
backfillPriceHistory: (productIds?: number[]) =>
api.post<{ created: number; skipped: number }>("/admin/price-history/backfill/", { product_ids: productIds }).then((r) => r.data),
backfillTagPriceHistory: (tagId: number) =>
api.post<{ created: number; skipped: number }>(`/admin/compare-tags/${tagId}/backfill-history/`).then((r) => r.data),
recordPriceHistory: (data?: { product_ids?: number[]; force?: boolean }) =>
api.post<{ created: number; skipped: number }>("/admin/price-history/record/", data || {}).then((r) => r.data),
recordTagPriceHistory: (tagId: number, data?: { product_ids?: number[]; force?: boolean }) =>
api.post<{ created: number; skipped: number }>(`/admin/compare-tags/${tagId}/record-history/`, data || {}).then((r) => r.data),
};

View File

@@ -0,0 +1,25 @@
import { api } from "./client";
import type { ComparisonTag, ComparisonTagDetail, PriceHistoryStats } from "../types";
export interface ComparisonTagFilters {
only_discounted?: boolean;
min_discount_percent?: number;
only_historical_lowest?: boolean;
}
export const comparisonTagApi = {
list: () => api.get<ComparisonTag[]>("/compare-tags/").then((r) => r.data),
get: (slug: string, filters?: ComparisonTagFilters) => {
const params = new URLSearchParams();
if (filters?.only_discounted) params.append("only_discounted", "true");
if (filters?.min_discount_percent) params.append("min_discount_percent", String(filters.min_discount_percent));
if (filters?.only_historical_lowest) params.append("only_historical_lowest", "true");
const query = params.toString();
return api.get<ComparisonTagDetail>(`/compare-tags/${slug}${query ? `?${query}` : ""}`).then((r) => r.data);
},
getPriceStats: (productId: number, days?: number) => {
const params = days ? `?days=${days}` : "";
return api.get<PriceHistoryStats>(`/products/${productId}/price-stats/${params}`).then((r) => r.data);
},
};

View File

@@ -4,14 +4,16 @@ export { friendApi } from "./friends";
export { categoryApi } from "./categories";
export { websiteApi } from "./websites";
export { productApi } from "./products";
export { comparisonTagApi } from "./comparisonTags";
export { bountyApi } from "./bounties";
export { favoriteApi } from "./favorites";
export { notificationApi } from "./notifications";
export { searchApi } from "./search";
export { paymentApi } from "./payments";
export { adminApi } from "./admin";
export { isApiError, normalizeApiError, type ApiError } from "./errors";
export type { ComparisonTagFilters } from "./comparisonTags";
export type {
User,
UserBrief,
@@ -19,7 +21,13 @@ export type {
Website,
Product,
ProductPrice,
ProductPriceHistory,
PriceHistoryStats,
EnhancedProductPrice,
ProductWithPrices,
ComparisonTag,
ComparisonTagDetail,
ComparisonTagItem,
Bounty,
BountyApplication,
BountyComment,
@@ -33,10 +41,11 @@ export type {
BountyDispute,
BountyReview,
BountyExtensionRequest,
SearchResults,
AdminUser,
AdminBounty,
AdminProduct,
AdminComparisonTag,
AdminComparisonTagItem,
AdminPaymentEvent,
PaginatedResponse,
MessageResponse,

View File

@@ -1,5 +1,5 @@
import { api, searchTimeout, uploadTimeout } from "./client";
import type { PaginatedResponse, Product, ProductPrice, ProductWithPrices, MyProduct } from "../types";
import type { PaginatedResponse, Product, ProductPrice, ProductWithPrices, MyProduct, ProductPriceHistory } from "../types";
type UploadImageResponse = { url: string };
@@ -29,6 +29,8 @@ export const productApi = {
api.post<Product>("/products/", data).then((r) => r.data),
addPrice: (data: { product_id: number; website_id: number; price: string; original_price?: string; currency?: string; url: string; in_stock?: boolean }) =>
api.post<ProductPrice>("/products/prices/", data).then((r) => r.data),
priceHistory: (productId: number, params?: { website_id?: number; limit?: number }) =>
api.get<ProductPriceHistory[]>(`/products/${productId}/price-history/`, { params }).then((r) => r.data),
uploadImage: (file: File) => {
const formData = new FormData();
formData.append("file", file);

View File

@@ -1,7 +0,0 @@
import { api, searchTimeout } from "./client";
import type { SearchResults } from "../types";
export const searchApi = {
global: (q: string, limit?: number) =>
api.get<SearchResults>("/search/", { params: { q, limit }, timeout: searchTimeout }).then((r) => r.data),
};

View File

@@ -73,12 +73,76 @@ export interface ProductPrice {
last_checked: string;
}
export interface ProductPriceHistory {
id: number;
product_id: number;
website_id: number;
price: string;
recorded_at: string;
}
export interface PriceHistoryStats {
product_id: number;
historical_lowest: string | null;
historical_lowest_date: string | null;
historical_highest: string | null;
historical_highest_date: string | null;
average_price: string | null;
current_lowest: string | null;
is_historical_lowest: boolean;
discount_from_highest: string | null;
distance_to_lowest: string | null;
price_trend: "up" | "down" | "stable";
history: ProductPriceHistory[];
}
export interface EnhancedProductPrice extends ProductPrice {
historical_lowest: string | null;
is_at_historical_lowest: boolean;
discount_percent: string | null;
discount_amount: string | null;
}
export interface ProductWithPrices extends Product {
prices: ProductPrice[];
lowest_price: string | null;
highest_price: string | null;
}
export interface ComparisonTag {
id: number;
name: string;
slug: string;
description: string | null;
cover_image: string | null;
icon: string | null;
sort_order: number;
is_active: boolean;
product_count: number;
created_at: string;
updated_at: string;
}
export interface ComparisonTagItem {
product: Product;
prices: EnhancedProductPrice[];
lowest_price: string | null;
highest_price: string | null;
platform_count: number;
// 增强字段
historical_lowest: string | null;
historical_lowest_date: string | null;
is_at_historical_lowest: boolean;
discount_from_highest_percent: string | null;
is_recommended: boolean;
recommendation_reason: string | null;
}
export interface ComparisonTagDetail {
tag: ComparisonTag;
items: ComparisonTagItem[];
}
export interface Bounty {
id: number;
title: string;
@@ -248,12 +312,6 @@ export interface BountyExtensionRequest {
reviewed_at: string | null;
}
export interface SearchResults {
products: Product[];
websites: Website[];
bounties: Bounty[];
}
export interface AdminUser {
id: number;
open_id: string;
@@ -302,6 +360,32 @@ export interface AdminProduct {
updated_at: string;
}
export interface AdminComparisonTag {
id: number;
name: string;
slug: string;
description: string | null;
cover_image: string | null;
icon: string | null;
sort_order: number;
is_active: boolean;
product_count: number;
created_at: string;
updated_at: string;
}
export interface AdminComparisonTagItem {
id: number;
tag_id: number;
product_id: number;
product_name: string;
product_image: string | null;
product_status: string;
sort_order: number;
is_pinned: boolean;
created_at: string;
}
export interface MyProduct {
id: number;
name: string;