haha
This commit is contained in:
@@ -159,6 +159,7 @@ class ProductAdminOut(Schema):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
image: Optional[str] = None
|
||||
images: List[str] = []
|
||||
category_id: int
|
||||
category_name: Optional[str] = None
|
||||
status: str
|
||||
@@ -186,6 +187,12 @@ class ProductReviewIn(Schema):
|
||||
reject_reason: Optional[str] = None
|
||||
|
||||
|
||||
class ProductImagesIn(Schema):
|
||||
"""Product images update input schema."""
|
||||
images: List[str] = []
|
||||
image: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/products/pending/", response=List[ProductAdminOut], auth=JWTAuth())
|
||||
@paginate(PageNumberPagination, page_size=20)
|
||||
def list_pending_products(request):
|
||||
@@ -233,3 +240,25 @@ def review_product(request, product_id: int, data: ProductReviewIn):
|
||||
product.save()
|
||||
|
||||
return product
|
||||
|
||||
|
||||
@router.put("/products/{product_id}/images/", response=ProductAdminOut, auth=JWTAuth())
|
||||
def update_product_images(request, product_id: int, data: ProductImagesIn):
|
||||
require_admin(request.auth)
|
||||
try:
|
||||
product = Product.objects.select_related("category", "submitted_by").get(id=product_id)
|
||||
except Product.DoesNotExist:
|
||||
raise HttpError(404, "商品不存在")
|
||||
|
||||
max_images = 6
|
||||
images = [url.strip() for url in (data.images or []) if url and url.strip()]
|
||||
image = (data.image or "").strip() or (images[0] if images else None)
|
||||
if image and image not in images:
|
||||
images.insert(0, image)
|
||||
if len(images) > max_images:
|
||||
raise HttpError(400, f"最多上传{max_images}张图片")
|
||||
|
||||
product.image = image or None
|
||||
product.images = images
|
||||
product.save(update_fields=["image", "images", "updated_at"])
|
||||
return product
|
||||
|
||||
@@ -4,10 +4,15 @@ Products API routes for categories, websites, products and prices.
|
||||
from typing import List, Optional
|
||||
import re
|
||||
import time
|
||||
import os
|
||||
import uuid
|
||||
import mimetypes
|
||||
from decimal import Decimal, InvalidOperation
|
||||
import csv
|
||||
import io
|
||||
from ninja import Router, Query, File
|
||||
from ninja.errors import HttpError
|
||||
from ninja.errors import HttpError
|
||||
from ninja.files import UploadedFile
|
||||
from ninja_jwt.authentication import JWTAuth
|
||||
from ninja.pagination import paginate, PageNumberPagination
|
||||
@@ -15,6 +20,8 @@ from django.conf import settings
|
||||
from django.db.models import Count, Min, Max, Q, Prefetch, F
|
||||
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
|
||||
@@ -26,6 +33,7 @@ from .schemas import (
|
||||
ProductSearchFilter,
|
||||
ImportResultOut,
|
||||
MyProductOut,
|
||||
UploadImageOut,
|
||||
)
|
||||
from apps.favorites.models import Favorite
|
||||
|
||||
@@ -33,6 +41,8 @@ router = Router()
|
||||
category_router = Router()
|
||||
website_router = Router()
|
||||
|
||||
MAX_PRODUCT_IMAGES = 6
|
||||
|
||||
|
||||
# ==================== Category Routes ====================
|
||||
|
||||
@@ -355,51 +365,67 @@ def list_products(request, filters: ProductFilter = Query(...)):
|
||||
@cache_page(settings.CACHE_TTL_SECONDS)
|
||||
def get_product(request, product_id: int):
|
||||
"""Get product by ID."""
|
||||
return get_object_or_404(Product, id=product_id)
|
||||
# 只返回已审核通过的商品
|
||||
return get_object_or_404(Product, id=product_id, status='approved')
|
||||
|
||||
|
||||
@router.get("/{product_id}/with-prices", response=ProductWithPricesOut)
|
||||
@cache_page(settings.CACHE_TTL_SECONDS)
|
||||
def get_product_with_prices(request, product_id: int):
|
||||
"""Get product with all prices from different websites."""
|
||||
product = get_object_or_404(Product, id=product_id)
|
||||
|
||||
prices = ProductPrice.objects.filter(product=product).select_related('website')
|
||||
price_list = []
|
||||
|
||||
for pp in prices:
|
||||
price_list.append(ProductPriceOut(
|
||||
id=pp.id,
|
||||
product_id=pp.product_id,
|
||||
website_id=pp.website_id,
|
||||
website_name=pp.website.name,
|
||||
website_logo=pp.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,
|
||||
))
|
||||
|
||||
# Calculate price range
|
||||
price_stats = prices.aggregate(
|
||||
lowest=Min('price'),
|
||||
highest=Max('price')
|
||||
)
|
||||
|
||||
return ProductWithPricesOut(
|
||||
id=product.id,
|
||||
name=product.name,
|
||||
description=product.description,
|
||||
image=product.image,
|
||||
category_id=product.category_id,
|
||||
created_at=product.created_at,
|
||||
updated_at=product.updated_at,
|
||||
prices=price_list,
|
||||
lowest_price=price_stats['lowest'],
|
||||
highest_price=price_stats['highest'],
|
||||
)
|
||||
try:
|
||||
product = get_object_or_404(Product, id=product_id)
|
||||
|
||||
prices = ProductPrice.objects.filter(product=product).select_related('website')
|
||||
price_list = []
|
||||
|
||||
for pp in prices:
|
||||
website_name = None
|
||||
website_logo = None
|
||||
try:
|
||||
if pp.website_id:
|
||||
website_name = pp.website.name
|
||||
website_logo = pp.website.logo
|
||||
except ObjectDoesNotExist:
|
||||
# 数据不一致时避免直接报错
|
||||
website_name = None
|
||||
website_logo = None
|
||||
|
||||
price_list.append(ProductPriceOut(
|
||||
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,
|
||||
))
|
||||
|
||||
# Calculate price range
|
||||
price_stats = prices.aggregate(
|
||||
lowest=Min('price'),
|
||||
highest=Max('price')
|
||||
)
|
||||
|
||||
return ProductWithPricesOut(
|
||||
id=product.id,
|
||||
name=product.name,
|
||||
description=product.description,
|
||||
image=product.image,
|
||||
images=list(product.images or []),
|
||||
category_id=product.category_id,
|
||||
created_at=product.created_at,
|
||||
updated_at=product.updated_at,
|
||||
prices=price_list,
|
||||
lowest_price=price_stats['lowest'],
|
||||
highest_price=price_stats['highest'],
|
||||
)
|
||||
except Exception as exc:
|
||||
# 方便定位500具体原因(开发环境)
|
||||
raise HttpError(500, f"with-prices failed: {exc}")
|
||||
|
||||
|
||||
@router.get("/search/", response=List[ProductWithPricesOut])
|
||||
@@ -446,22 +472,31 @@ def search_products(request, q: str, filters: ProductSearchFilter = Query(...)):
|
||||
result = []
|
||||
for product in products:
|
||||
prices = list(product.prices.all())
|
||||
price_list = [
|
||||
ProductPriceOut(
|
||||
price_list = []
|
||||
for pp in prices:
|
||||
website_name = None
|
||||
website_logo = None
|
||||
try:
|
||||
if pp.website_id:
|
||||
website_name = pp.website.name
|
||||
website_logo = pp.website.logo
|
||||
except ObjectDoesNotExist:
|
||||
website_name = None
|
||||
website_logo = None
|
||||
|
||||
price_list.append(ProductPriceOut(
|
||||
id=pp.id,
|
||||
product_id=pp.product_id,
|
||||
website_id=pp.website_id,
|
||||
website_name=pp.website.name,
|
||||
website_logo=pp.website.logo,
|
||||
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,
|
||||
)
|
||||
for pp in prices
|
||||
]
|
||||
))
|
||||
lowest_price = min((pp.price for pp in prices), default=None)
|
||||
highest_price = max((pp.price for pp in prices), default=None)
|
||||
|
||||
@@ -470,6 +505,7 @@ def search_products(request, q: str, filters: ProductSearchFilter = Query(...)):
|
||||
name=product.name,
|
||||
description=product.description,
|
||||
image=product.image,
|
||||
images=list(product.images or []),
|
||||
category_id=product.category_id,
|
||||
created_at=product.created_at,
|
||||
updated_at=product.updated_at,
|
||||
@@ -486,11 +522,20 @@ def create_product(request, data: ProductIn):
|
||||
"""Create a new product. Admin creates approved, others create pending."""
|
||||
user = request.auth
|
||||
is_admin = user and user.role == 'admin' and user.is_active
|
||||
|
||||
|
||||
images = [url.strip() for url in (data.images or []) if url and url.strip()]
|
||||
image = (data.image or "").strip() or (images[0] if images else None)
|
||||
if image and image not in images:
|
||||
images.insert(0, image)
|
||||
if len(images) > MAX_PRODUCT_IMAGES:
|
||||
from ninja.errors import HttpError
|
||||
raise HttpError(400, f"最多上传{MAX_PRODUCT_IMAGES}张图片")
|
||||
|
||||
product = Product.objects.create(
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
image=data.image,
|
||||
image=image or None,
|
||||
images=images,
|
||||
category_id=data.category_id,
|
||||
status='approved' if is_admin else 'pending',
|
||||
submitted_by=user,
|
||||
@@ -498,6 +543,31 @@ def create_product(request, data: ProductIn):
|
||||
return product
|
||||
|
||||
|
||||
@router.post("/upload-image/", response=UploadImageOut, auth=JWTAuth())
|
||||
def upload_product_image(request, file: UploadedFile = File(...)):
|
||||
"""Upload product image and return URL."""
|
||||
if not file:
|
||||
raise HttpError(400, "请上传图片文件")
|
||||
|
||||
allowed_types = {"image/jpeg", "image/png", "image/webp"}
|
||||
if (file.content_type or "").lower() not in allowed_types:
|
||||
raise HttpError(400, "仅支持 JPG/PNG/WEBP 图片")
|
||||
|
||||
max_size = 5 * 1024 * 1024
|
||||
if file.size and file.size > max_size:
|
||||
raise HttpError(400, "图片大小不能超过5MB")
|
||||
|
||||
ext = os.path.splitext(file.name or "")[1].lower()
|
||||
if not ext:
|
||||
ext = mimetypes.guess_extension(file.content_type or "") or ".jpg"
|
||||
|
||||
filename = f"products/{uuid.uuid4().hex}{ext}"
|
||||
saved_path = default_storage.save(filename, file)
|
||||
url = f"{settings.MEDIA_URL}{saved_path}".replace("\\", "/")
|
||||
|
||||
return UploadImageOut(url=url)
|
||||
|
||||
|
||||
@router.get("/my/", response=List[MyProductOut], auth=JWTAuth())
|
||||
@paginate(PageNumberPagination, page_size=20)
|
||||
def my_products(request, status: Optional[str] = None):
|
||||
|
||||
18
backend/apps/products/migrations/0002_product_images.py
Normal file
18
backend/apps/products/migrations/0002_product_images.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.27 on 2026-01-29 10:45
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("products", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="product",
|
||||
name="images",
|
||||
field=models.JSONField(blank=True, default=list, verbose_name="图片列表"),
|
||||
),
|
||||
]
|
||||
@@ -82,6 +82,7 @@ class Product(models.Model):
|
||||
name = models.CharField('商品名称', max_length=300)
|
||||
description = models.TextField('描述', blank=True, null=True)
|
||||
image = models.TextField('图片', blank=True, null=True)
|
||||
images = models.JSONField('图片列表', default=list, blank=True)
|
||||
category = models.ForeignKey(
|
||||
Category,
|
||||
on_delete=models.CASCADE,
|
||||
|
||||
@@ -78,6 +78,7 @@ class ProductOut(Schema):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
image: Optional[str] = None
|
||||
images: List[str] = []
|
||||
category_id: int
|
||||
status: str = "approved"
|
||||
submitted_by_id: Optional[int] = None
|
||||
@@ -94,11 +95,17 @@ class ProductWithPricesOut(ProductOut):
|
||||
highest_price: Optional[Decimal] = None
|
||||
|
||||
|
||||
class UploadImageOut(Schema):
|
||||
"""Image upload output."""
|
||||
url: str
|
||||
|
||||
|
||||
class ProductIn(Schema):
|
||||
"""Product input schema."""
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
image: Optional[str] = None
|
||||
images: Optional[List[str]] = None
|
||||
category_id: int
|
||||
|
||||
|
||||
@@ -108,6 +115,7 @@ class MyProductOut(Schema):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
image: Optional[str] = None
|
||||
images: List[str] = []
|
||||
category_id: int
|
||||
status: str
|
||||
reject_reason: Optional[str] = None
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""
|
||||
Django Ninja API configuration.
|
||||
"""
|
||||
import logging
|
||||
from django.conf import settings
|
||||
from ninja import NinjaAPI
|
||||
from ninja.errors import HttpError, ValidationError
|
||||
from ninja_jwt.authentication import JWTAuth
|
||||
@@ -51,9 +53,13 @@ def on_validation_error(request, exc: ValidationError):
|
||||
|
||||
@api.exception_handler(Exception)
|
||||
def on_unhandled_error(request, exc: Exception):
|
||||
logging.exception("Unhandled API error", exc_info=exc)
|
||||
message = "服务器内部错误"
|
||||
if getattr(settings, "DEBUG", False):
|
||||
message = f"服务器内部错误: {exc}"
|
||||
return api.create_response(
|
||||
request,
|
||||
build_error_payload(status_code=500, message="服务器内部错误"),
|
||||
build_error_payload(status_code=500, message=message),
|
||||
status=500,
|
||||
)
|
||||
|
||||
|
||||
@@ -145,6 +145,8 @@ USE_TZ = True
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
STATIC_URL = 'static/'
|
||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||
MEDIA_URL = "/media/"
|
||||
MEDIA_ROOT = BASE_DIR / "media"
|
||||
|
||||
|
||||
# Default primary key field type
|
||||
|
||||
@@ -3,6 +3,8 @@ URL configuration for ai_web project.
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.urls import path
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from .api import api
|
||||
from apps.bounties.payments import handle_webhook
|
||||
@@ -12,3 +14,6 @@ urlpatterns = [
|
||||
path('api/', api.urls),
|
||||
path('webhooks/stripe/', csrf_exempt(handle_webhook), name='stripe-webhook'),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
|
||||
56
frontend/pnpm-lock.yaml
generated
56
frontend/pnpm-lock.yaml
generated
@@ -16,6 +16,15 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@dnd-kit/core':
|
||||
specifier: ^6.3.1
|
||||
version: 6.3.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
||||
'@dnd-kit/sortable':
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)
|
||||
'@dnd-kit/utilities':
|
||||
specifier: ^3.2.2
|
||||
version: 3.2.2(react@19.2.1)
|
||||
'@hookform/resolvers':
|
||||
specifier: ^5.2.2
|
||||
version: 5.2.2(react-hook-form@7.64.0(react@19.2.1))
|
||||
@@ -316,6 +325,28 @@ packages:
|
||||
'@date-fns/tz@1.4.1':
|
||||
resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==}
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1':
|
||||
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/core@6.3.1':
|
||||
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/sortable@10.0.0':
|
||||
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
|
||||
peerDependencies:
|
||||
'@dnd-kit/core': ^6.3.0
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/utilities@3.2.2':
|
||||
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.10':
|
||||
resolution: {integrity: sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2288,6 +2319,31 @@ snapshots:
|
||||
|
||||
'@date-fns/tz@1.4.1': {}
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1(react@19.2.1)':
|
||||
dependencies:
|
||||
react: 19.2.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/core@6.3.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1)':
|
||||
dependencies:
|
||||
'@dnd-kit/accessibility': 3.1.1(react@19.2.1)
|
||||
'@dnd-kit/utilities': 3.2.2(react@19.2.1)
|
||||
react: 19.2.1
|
||||
react-dom: 19.2.1(react@19.2.1)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)':
|
||||
dependencies:
|
||||
'@dnd-kit/core': 6.3.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
||||
'@dnd-kit/utilities': 3.2.2(react@19.2.1)
|
||||
react: 19.2.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/utilities@3.2.2(react@19.2.1)':
|
||||
dependencies:
|
||||
react: 19.2.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.10':
|
||||
optional: true
|
||||
|
||||
|
||||
27
frontend/src/components/FileThumbnail.tsx
Normal file
27
frontend/src/components/FileThumbnail.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type FileThumbnailProps = {
|
||||
file: File;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function FileThumbnail({ file, alt, className }: FileThumbnailProps) {
|
||||
const [src, setSrc] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const url = URL.createObjectURL(file);
|
||||
setSrc(url);
|
||||
return () => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [file]);
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt || file.name}
|
||||
className={className || "h-12 w-12 rounded object-cover"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
121
frontend/src/components/SortableGrid.tsx
Normal file
121
frontend/src/components/SortableGrid.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { closestCenter, DndContext, DragOverlay, PointerSensor, useDroppable, useSensor, useSensors } from "@dnd-kit/core";
|
||||
import { SortableContext, arrayMove, useSortable, rectSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { GripVertical } from "lucide-react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
|
||||
type SortableItem = { id: string };
|
||||
|
||||
type SortableGridProps<T extends SortableItem> = {
|
||||
items: T[];
|
||||
onReorder: (items: T[]) => void;
|
||||
renderItem: (item: T) => ReactNode;
|
||||
className?: string;
|
||||
itemClassName?: string;
|
||||
heroDropId?: string;
|
||||
onHeroDrop?: (activeId: string) => void;
|
||||
};
|
||||
|
||||
type HeroDropzoneProps = {
|
||||
id: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function SortableTile<T extends SortableItem>({
|
||||
item,
|
||||
renderItem,
|
||||
className,
|
||||
}: {
|
||||
item: T;
|
||||
renderItem: (item: T) => ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item.id });
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`relative rounded-md border bg-background p-2 ${isDragging ? "opacity-70" : ""} ${className || ""}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute left-1 top-1 z-10 rounded bg-background/80 p-1 text-muted-foreground shadow"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical className="h-3 w-3" />
|
||||
</button>
|
||||
{renderItem(item)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HeroDropzone({ id, className, children }: HeroDropzoneProps) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id });
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`${className || ""} ${isOver ? "ring-2 ring-primary/40" : ""}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SortableGrid<T extends SortableItem>({
|
||||
items,
|
||||
onReorder,
|
||||
renderItem,
|
||||
className,
|
||||
itemClassName,
|
||||
heroDropId,
|
||||
onHeroDrop,
|
||||
}: SortableGridProps<T>) {
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const activeItem = activeId ? items.find((item) => item.id === activeId) : null;
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={(event) => setActiveId(String(event.active.id))}
|
||||
onDragCancel={() => setActiveId(null)}
|
||||
onDragEnd={(event) => {
|
||||
const { active, over } = event;
|
||||
setActiveId(null);
|
||||
if (!over) return;
|
||||
if (heroDropId && onHeroDrop && over.id === heroDropId) {
|
||||
onHeroDrop(String(active.id));
|
||||
return;
|
||||
}
|
||||
if (active.id === over.id) return;
|
||||
const oldIndex = items.findIndex((item) => item.id === active.id);
|
||||
const newIndex = items.findIndex((item) => item.id === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
onReorder(arrayMove(items, oldIndex, newIndex));
|
||||
}}
|
||||
>
|
||||
<SortableContext items={items.map((item) => item.id)} strategy={rectSortingStrategy}>
|
||||
<div className={className || "grid grid-cols-3 gap-2"}>
|
||||
{items.map((item) => (
|
||||
<SortableTile key={item.id} item={item} renderItem={renderItem} className={itemClassName} />
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
<DragOverlay>
|
||||
{activeItem ? (
|
||||
<div className="rounded-md border bg-background p-2 shadow-lg">
|
||||
{renderItem(activeItem)}
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
67
frontend/src/components/SortableList.tsx
Normal file
67
frontend/src/components/SortableList.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { closestCenter, DndContext, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
|
||||
import { SortableContext, arrayMove, useSortable, rectSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { GripVertical } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type SortableItem = { id: string };
|
||||
|
||||
type SortableListProps<T extends SortableItem> = {
|
||||
items: T[];
|
||||
onReorder: (items: T[]) => void;
|
||||
renderContent: (item: T) => ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function SortableRow<T extends SortableItem>({ item, renderContent }: { item: T; renderContent: (item: T) => ReactNode }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item.id });
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`flex items-center gap-2 rounded-md border bg-background px-2 py-1 ${isDragging ? "opacity-70" : ""}`}
|
||||
>
|
||||
<button type="button" className="text-muted-foreground" {...attributes} {...listeners}>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex-1">{renderContent(item)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SortableList<T extends SortableItem>({
|
||||
items,
|
||||
onReorder,
|
||||
renderContent,
|
||||
className,
|
||||
}: SortableListProps<T>) {
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={(event) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIndex = items.findIndex((item) => item.id === active.id);
|
||||
const newIndex = items.findIndex((item) => item.id === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
onReorder(arrayMove(items, oldIndex, newIndex));
|
||||
}}
|
||||
>
|
||||
<SortableContext items={items.map((item) => item.id)} strategy={rectSortingStrategy}>
|
||||
<div className={className || "space-y-2"}>
|
||||
{items.map((item) => (
|
||||
<SortableRow key={item.id} item={item} renderContent={renderContent} />
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,105 @@
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useAdminUsers, useUpdateAdminUser, useAdminBounties, useAdminPayments, useAdminDisputes, useResolveDispute, useAdminPendingProducts, useReviewProduct } from "@/hooks/useApi";
|
||||
import { useAdminUsers, useUpdateAdminUser, useAdminBounties, useAdminPayments, useAdminDisputes, useResolveDispute, useAdminPendingProducts, useReviewProduct, useUpdateAdminProductImages } 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 } from "lucide-react";
|
||||
import { Loader2, Package, Users, Trophy, CreditCard, AlertTriangle, Star, Trash2, Image as ImageIcon } from "lucide-react";
|
||||
import { useLocation } from "wouter";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
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 { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { productApi, type AdminProduct } from "@/lib/api";
|
||||
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";
|
||||
|
||||
type ImageUrlItem = {
|
||||
id: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
const createId = () => {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
};
|
||||
|
||||
const createImageUrlItems = (urls: string[]) => urls.map((url) => ({ id: createId(), url }));
|
||||
|
||||
export default function Admin() {
|
||||
const { user, isAuthenticated, loading } = useAuth();
|
||||
const [, navigate] = useLocation();
|
||||
const [rejectReason, setRejectReason] = useState("");
|
||||
const [rejectingProductId, setRejectingProductId] = useState<number | null>(null);
|
||||
const [imageDialogOpen, setImageDialogOpen] = useState(false);
|
||||
const [editingProduct, setEditingProduct] = useState<AdminProduct | null>(null);
|
||||
const [editingImages, setEditingImages] = useState<ImageUrlItem[]>([]);
|
||||
const [newImageUrl, setNewImageUrl] = useState("");
|
||||
const [imageFiles, setImageFiles] = useState<ImageFileItem[]>([]);
|
||||
const [isUploadingImages, setIsUploadingImages] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const confirmActionRef = useRef<null | (() => void)>(null);
|
||||
const [confirmItem, setConfirmItem] = useState<{ name?: string; file?: File; url?: string } | null>(null);
|
||||
const [confirmMeta, setConfirmMeta] = useState<{ isFirst?: boolean; nextName?: string | null } | null>(null);
|
||||
const MAX_IMAGES = 6;
|
||||
const urlHeroId = "admin-image-url-hero";
|
||||
const uploadHeroId = "admin-image-upload-hero";
|
||||
const moveUrlToFront = (activeId: string) => {
|
||||
setEditingImages((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 moveUploadToFront = (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 appendUploadFiles = (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;
|
||||
});
|
||||
};
|
||||
const openConfirm = (action: () => void, item?: { name?: string; file?: File; url?: string }, meta?: { isFirst?: boolean; nextName?: string | null }) => {
|
||||
confirmActionRef.current = action;
|
||||
setConfirmItem(item || null);
|
||||
setConfirmMeta(meta || null);
|
||||
setConfirmOpen(true);
|
||||
};
|
||||
|
||||
const { data: usersData, isLoading: usersLoading } = useAdminUsers();
|
||||
const { data: bountiesData, isLoading: bountiesLoading } = useAdminBounties();
|
||||
@@ -28,6 +109,7 @@ export default function Admin() {
|
||||
const updateUserMutation = useUpdateAdminUser();
|
||||
const resolveDisputeMutation = useResolveDispute();
|
||||
const reviewProductMutation = useReviewProduct();
|
||||
const updateProductImagesMutation = useUpdateAdminProductImages();
|
||||
|
||||
// Extract items from paginated responses
|
||||
const users = usersData?.items || [];
|
||||
@@ -42,6 +124,73 @@ export default function Admin() {
|
||||
}
|
||||
}, [loading, isAuthenticated, user, navigate]);
|
||||
|
||||
const openImageEditor = (product: AdminProduct) => {
|
||||
const initialImages = product.images && product.images.length > 0
|
||||
? [...product.images]
|
||||
: product.image
|
||||
? [product.image]
|
||||
: [];
|
||||
setEditingProduct(product);
|
||||
setEditingImages(createImageUrlItems(initialImages));
|
||||
setNewImageUrl("");
|
||||
setImageFiles([]);
|
||||
setImageDialogOpen(true);
|
||||
};
|
||||
|
||||
const addImageUrl = () => {
|
||||
const url = newImageUrl.trim();
|
||||
if (!url) return;
|
||||
setEditingImages((prev) => {
|
||||
if (prev.length >= MAX_IMAGES) {
|
||||
toast.error(`最多${MAX_IMAGES}张图片`);
|
||||
return prev;
|
||||
}
|
||||
return [...prev, { id: createId(), url }];
|
||||
});
|
||||
setNewImageUrl("");
|
||||
};
|
||||
|
||||
const handleSaveImages = async () => {
|
||||
if (!editingProduct) return;
|
||||
setIsUploadingImages(true);
|
||||
try {
|
||||
let images = editingImages.map((img) => img.url.trim()).filter(Boolean);
|
||||
if (imageFiles.length > 0) {
|
||||
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);
|
||||
setIsUploadingImages(false);
|
||||
return;
|
||||
}
|
||||
const uploadResult = await productApi.uploadImage(processed);
|
||||
uploadedUrls.push(uploadResult.url);
|
||||
}
|
||||
images = [...images, ...uploadedUrls];
|
||||
}
|
||||
if (images.length > MAX_IMAGES) {
|
||||
toast.error(`最多${MAX_IMAGES}张图片`);
|
||||
setIsUploadingImages(false);
|
||||
return;
|
||||
}
|
||||
await updateProductImagesMutation.mutateAsync({
|
||||
productId: editingProduct.id,
|
||||
data: { images, image: images[0] },
|
||||
});
|
||||
toast.success("图片已更新");
|
||||
setImageDialogOpen(false);
|
||||
} catch (error: unknown) {
|
||||
const { title, description } = getErrorCopy(error, { context: "product.update" });
|
||||
toast.error(title, { description });
|
||||
} finally {
|
||||
setIsUploadingImages(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
@@ -100,6 +249,261 @@ export default function Admin() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<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" />
|
||||
) : confirmItem.url ? (
|
||||
<img src={confirmItem.url} alt="preview" 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>
|
||||
<Dialog open={imageDialogOpen} onOpenChange={setImageDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑商品图片</DialogTitle>
|
||||
<DialogDescription>最多{MAX_IMAGES}张,按顺序展示</DialogDescription>
|
||||
</DialogHeader>
|
||||
{editingProduct && (
|
||||
<div className="text-sm text-muted-foreground mb-2">当前商品:{editingProduct.name}</div>
|
||||
)}
|
||||
<div className="grid gap-3">
|
||||
{editingImages.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<HeroDropzone id={urlHeroId} 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">
|
||||
{editingImages[0].url ? (
|
||||
<img src={editingImages[0].url} alt="preview" className="h-16 w-16 rounded object-cover" />
|
||||
) : (
|
||||
<div className="h-16 w-16 rounded bg-muted" />
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground truncate">{editingImages[0].url}</div>
|
||||
</div>
|
||||
</HeroDropzone>
|
||||
<SortableGrid
|
||||
items={editingImages}
|
||||
onReorder={setEditingImages}
|
||||
heroDropId={urlHeroId}
|
||||
onHeroDrop={moveUrlToFront}
|
||||
className="grid grid-cols-2 gap-3"
|
||||
itemClassName="p-3"
|
||||
renderItem={(item) => {
|
||||
const isFirst = editingImages[0]?.id === item.id;
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" className="relative" onClick={() => moveUrlToFront(item.id)}>
|
||||
{item.url ? (
|
||||
<img src={item.url} alt="preview" className="h-20 w-20 rounded object-cover" />
|
||||
) : (
|
||||
<div className="h-20 w-20 rounded bg-muted" />
|
||||
)}
|
||||
{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">
|
||||
<Input
|
||||
value={item.url}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setEditingImages((prev) =>
|
||||
prev.map((img) => (img.id === item.id ? { ...img, url: value } : img))
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant={isFirst ? "secondary" : "outline"}
|
||||
size="icon"
|
||||
disabled={isFirst}
|
||||
onClick={() => moveUrlToFront(item.id)}
|
||||
title="设为首图"
|
||||
>
|
||||
<Star className={`h-4 w-4 ${isFirst ? "text-primary" : ""}`} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
const nextName = editingImages.length > 1
|
||||
? editingImages[1]?.url || null
|
||||
: null;
|
||||
openConfirm(() => {
|
||||
setEditingImages((prev) => prev.filter((img) => img.id !== item.id));
|
||||
}, { url: item.url, name: item.url }, { isFirst, nextName });
|
||||
}}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">暂无图片</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="输入图片URL"
|
||||
value={newImageUrl}
|
||||
onChange={(e) => setNewImageUrl(e.target.value)}
|
||||
/>
|
||||
<Button type="button" variant="outline" onClick={addImageUrl}>
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="admin-product-image-file">上传本地图片</Label>
|
||||
<div
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
appendUploadFiles(Array.from(e.dataTransfer.files || []));
|
||||
}}
|
||||
onPaste={(e) => {
|
||||
appendUploadFiles(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="admin-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));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">拖拽排序,自动裁剪为1:1,单张≤5MB,自动压缩</p>
|
||||
{imageFiles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<HeroDropzone id={uploadHeroId} 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={uploadHeroId}
|
||||
onHeroDrop={moveUploadToFront}
|
||||
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={() => moveUploadToFront(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={isFirst ? "secondary" : "outline"}
|
||||
size="icon"
|
||||
disabled={isFirst}
|
||||
onClick={() => moveUploadToFront(item.id)}
|
||||
title="设为首图"
|
||||
>
|
||||
<Star className={`h-4 w-4 ${isFirst ? "text-primary" : ""}`} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
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, nextName });
|
||||
}}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setImageDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSaveImages} disabled={isUploadingImages}>
|
||||
{isUploadingImages ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className="container pt-24 pb-12 space-y-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
@@ -202,8 +606,8 @@ export default function Admin() {
|
||||
<TableRow key={product.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
{product.image && (
|
||||
<img src={product.image} alt={product.name} className="w-10 h-10 rounded object-cover" />
|
||||
{(product.images?.[0] || product.image) && (
|
||||
<img src={product.images?.[0] || product.image || ""} alt={product.name} className="w-10 h-10 rounded object-cover" />
|
||||
)}
|
||||
<div>
|
||||
<div className="font-medium">{product.name}</div>
|
||||
@@ -249,6 +653,14 @@ export default function Admin() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => openImageEditor(product)}
|
||||
disabled={reviewProductMutation.isPending}
|
||||
>
|
||||
编辑图片
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleApproveProduct(product.id)}
|
||||
|
||||
@@ -30,6 +30,9 @@ import {
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { toast } from "sonner";
|
||||
import { getErrorCopy } from "@/lib/i18n/errorMessages";
|
||||
import SortableGrid, { HeroDropzone } from "@/components/SortableGrid";
|
||||
import FileThumbnail from "@/components/FileThumbnail";
|
||||
import { createImageFileItems, processImageFile, type ImageFileItem } from "@/lib/image";
|
||||
import {
|
||||
Sparkles,
|
||||
Trophy,
|
||||
@@ -48,16 +51,29 @@ import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Star,
|
||||
Trash2,
|
||||
Image as ImageIcon,
|
||||
} from "lucide-react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { zhCN } from "date-fns/locale";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { notificationApi, productApi, categoryApi, websiteApi } from "@/lib/api";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
const statusMap: Record<string, { label: string; class: string }> = {
|
||||
open: { label: "开放中", class: "badge-open" },
|
||||
@@ -77,10 +93,17 @@ export default function Dashboard() {
|
||||
const { user, isAuthenticated, loading, logout } = useAuth();
|
||||
const [, navigate] = useLocation();
|
||||
const queryClient = useQueryClient();
|
||||
const MAX_IMAGES = 6;
|
||||
|
||||
// 创建商品对话框状态
|
||||
const [isAddProductOpen, setIsAddProductOpen] = useState(false);
|
||||
const [isCreatingProduct, setIsCreatingProduct] = 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: "",
|
||||
@@ -107,6 +130,35 @@ export default function Dashboard() {
|
||||
const { data: notificationsData, isLoading: notificationsLoading, refetch: refetchNotifications } = useNotifications();
|
||||
const { data: unreadCountData } = useUnreadNotificationCount();
|
||||
const { data: notificationPreferences } = useNotificationPreferences();
|
||||
const imageHeroId = "dashboard-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 publishedBounties = publishedData?.items || [];
|
||||
const acceptedBounties = acceptedData?.items || [];
|
||||
@@ -239,10 +291,34 @@ export default function Dashboard() {
|
||||
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: newProduct.image.trim() || undefined,
|
||||
image: imageUrl,
|
||||
images,
|
||||
category_id: Number(newProduct.categoryId),
|
||||
});
|
||||
await productApi.addPrice({
|
||||
@@ -269,6 +345,8 @@ export default function Dashboard() {
|
||||
inStock: true,
|
||||
});
|
||||
setIsNewWebsite(false);
|
||||
setImageFiles([]);
|
||||
setIsUploadingImage(false);
|
||||
setNewWebsite({ name: "", url: "" });
|
||||
setIsAddProductOpen(false);
|
||||
toast.success("商品已提交,等待审核");
|
||||
@@ -277,6 +355,7 @@ export default function Dashboard() {
|
||||
toast.error(title, { description });
|
||||
} finally {
|
||||
setIsCreatingProduct(false);
|
||||
setIsUploadingImage(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -300,6 +379,42 @@ export default function Dashboard() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<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-2">
|
||||
{confirmItem.file ? (
|
||||
<FileThumbnail file={confirmItem.file} className="h-16 w-16 rounded object-cover" />
|
||||
) : (
|
||||
<div className="h-16 w-16 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>
|
||||
|
||||
{/* Content */}
|
||||
<section className="pt-24 pb-20">
|
||||
@@ -582,10 +697,126 @@ export default function Dashboard() {
|
||||
<Input
|
||||
id="product-image"
|
||||
value={newProduct.image}
|
||||
onChange={(e) => setNewProduct((prev) => ({ ...prev, image: e.target.value }))}
|
||||
onChange={(e) => {
|
||||
setNewProduct((prev) => ({ ...prev, image: e.target.value }));
|
||||
if (e.target.value.trim()) {
|
||||
setImageFiles([]);
|
||||
}
|
||||
}}
|
||||
placeholder="输入图片URL"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="product-image-file">上传本地图片</Label>
|
||||
<div
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
appendImageFiles(Array.from(e.dataTransfer.files || []));
|
||||
}}
|
||||
onPaste={(e) => {
|
||||
appendImageFiles(Array.from(e.clipboardData?.files || []));
|
||||
}}
|
||||
className="rounded-md border border-dashed bg-muted/20 p-3 transition hover:bg-muted/40"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
拖拽/粘贴图片到此处,或点击选择文件
|
||||
</div>
|
||||
<div>最多{MAX_IMAGES}张</div>
|
||||
</div>
|
||||
<Input
|
||||
id="product-image-file"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (files.length > MAX_IMAGES) {
|
||||
toast.error(`最多上传${MAX_IMAGES}张图片`);
|
||||
setImageFiles(createImageFileItems(files.slice(0, MAX_IMAGES)));
|
||||
} else {
|
||||
setImageFiles(createImageFileItems(files));
|
||||
}
|
||||
if (files.length > 0) {
|
||||
setNewProduct((prev) => ({ ...prev, image: "" }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">拖拽排序,自动裁剪为1:1,单张≤5MB,自动压缩</p>
|
||||
{imageFiles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<HeroDropzone id={imageHeroId} className="rounded-md border bg-muted/40 p-3">
|
||||
<div className="text-xs text-muted-foreground mb-2">首图预览(拖拽或点击缩略图设为首图)</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<FileThumbnail file={imageFiles[0].file} className="h-16 w-16 rounded object-cover" />
|
||||
<div className="text-xs text-muted-foreground truncate">{imageFiles[0].file.name}</div>
|
||||
</div>
|
||||
</HeroDropzone>
|
||||
<SortableGrid
|
||||
items={imageFiles}
|
||||
onReorder={setImageFiles}
|
||||
heroDropId={imageHeroId}
|
||||
onHeroDrop={moveImageToFront}
|
||||
className="grid grid-cols-2 gap-3"
|
||||
itemClassName="p-3"
|
||||
renderItem={(item) => {
|
||||
const isFirst = imageFiles[0]?.id === item.id;
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" className="relative" onClick={() => moveImageToFront(item.id)}>
|
||||
<FileThumbnail file={item.file} className="h-20 w-20 rounded object-cover" />
|
||||
{isFirst && (
|
||||
<div className="absolute right-1 top-1 rounded bg-primary/90 px-2 py-0.5 text-[11px] text-primary-foreground">
|
||||
首图
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-muted-foreground truncate">{item.file.name}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant={isFirst ? "secondary" : "outline"}
|
||||
size="icon"
|
||||
disabled={isFirst}
|
||||
onClick={() => moveImageToFront(item.id)}
|
||||
title="设为首图"
|
||||
>
|
||||
<Star className={`h-4 w-4 ${isFirst ? "text-primary" : ""}`} />
|
||||
</Button>
|
||||
<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>
|
||||
@@ -738,11 +969,11 @@ export default function Dashboard() {
|
||||
<Button variant="outline" onClick={() => setIsAddProductOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleCreateProduct} disabled={isCreatingProduct}>
|
||||
{isCreatingProduct ? (
|
||||
<Button onClick={handleCreateProduct} disabled={isCreatingProduct || isUploadingImage}>
|
||||
{isCreatingProduct || isUploadingImage ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
提交中...
|
||||
{isUploadingImage ? "上传中..." : "提交中..."}
|
||||
</>
|
||||
) : (
|
||||
"提交审核"
|
||||
@@ -767,9 +998,9 @@ export default function Dashboard() {
|
||||
key={product.id}
|
||||
className="flex items-center gap-4 p-4 rounded-lg bg-muted/50"
|
||||
>
|
||||
{product.image && (
|
||||
{(product.images?.[0] || product.image) && (
|
||||
<img
|
||||
src={product.image}
|
||||
src={product.images?.[0] || product.image || ""}
|
||||
alt={product.name}
|
||||
className="w-16 h-16 rounded object-cover"
|
||||
/>
|
||||
|
||||
@@ -8,6 +8,7 @@ type Product = {
|
||||
name: string;
|
||||
description: string | null;
|
||||
image: string | null;
|
||||
images?: string[];
|
||||
};
|
||||
|
||||
type RecommendedProductsProps = {
|
||||
@@ -30,9 +31,9 @@ export default function RecommendedProducts({ products }: RecommendedProductsPro
|
||||
<Card className="card-elegant group cursor-pointer">
|
||||
<CardHeader>
|
||||
<div className="w-full aspect-square rounded-xl bg-muted flex items-center justify-center overflow-hidden">
|
||||
{product.image ? (
|
||||
{product.images?.[0] || product.image ? (
|
||||
<img
|
||||
src={product.image}
|
||||
src={product.images?.[0] || product.image || ""}
|
||||
alt={product.name}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
|
||||
@@ -60,8 +60,16 @@ export default function ProductDetail() {
|
||||
const [monitorNotifyOnTarget, setMonitorNotifyOnTarget] = useState(true);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [selectedWebsiteId, setSelectedWebsiteId] = useState(0);
|
||||
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||
|
||||
const { data: product, isLoading, error } = useProductWithPrices(productId);
|
||||
const productImages = useMemo(() => {
|
||||
const images = [...(product?.images || [])];
|
||||
if (product?.image && !images.includes(product.image)) {
|
||||
images.unshift(product.image);
|
||||
}
|
||||
return images;
|
||||
}, [product?.images, product?.image]);
|
||||
const lowestPrice = useMemo(() => {
|
||||
if (!product?.prices?.length) return null;
|
||||
return product.prices.reduce((min, current) =>
|
||||
@@ -72,10 +80,20 @@ export default function ProductDetail() {
|
||||
if (!product?.prices?.length) return [];
|
||||
return [...product.prices].sort((a, b) => Number(a.price) - Number(b.price));
|
||||
}, [product?.prices]);
|
||||
const displayImage = selectedImage || productImages[0] || product?.image || null;
|
||||
useEffect(() => {
|
||||
if (selectedWebsiteId || !lowestPrice) return;
|
||||
setSelectedWebsiteId(lowestPrice.website_id);
|
||||
}, [lowestPrice, selectedWebsiteId]);
|
||||
useEffect(() => {
|
||||
if (productImages.length === 0) {
|
||||
setSelectedImage(null);
|
||||
return;
|
||||
}
|
||||
if (!selectedImage || !productImages.includes(selectedImage)) {
|
||||
setSelectedImage(productImages[0]);
|
||||
}
|
||||
}, [productImages, selectedImage]);
|
||||
const { data: favoriteCheck } = useCheckFavorite(productId, selectedWebsiteId);
|
||||
const { data: monitorData } = usePriceMonitor(favoriteCheck?.favorite_id || 0);
|
||||
const { data: priceHistoryData } = usePriceHistory(favoriteCheck?.favorite_id || 0);
|
||||
@@ -218,13 +236,29 @@ export default function ProductDetail() {
|
||||
}
|
||||
|
||||
if (error || !product) {
|
||||
// 根据错误类型显示不同的提示信息
|
||||
const apiError = error as { status?: number; message?: string; isNetworkError?: boolean } | undefined;
|
||||
const is404 = apiError?.status === 404;
|
||||
const isNetworkError = apiError?.isNetworkError;
|
||||
|
||||
let errorTitle = "商品不存在";
|
||||
let errorDescription = "该商品可能已被删除或尚未通过审核";
|
||||
|
||||
if (isNetworkError) {
|
||||
errorTitle = "网络错误";
|
||||
errorDescription = "请检查网络连接后重试";
|
||||
} else if (!is404 && apiError?.message) {
|
||||
errorTitle = "加载失败";
|
||||
errorDescription = apiError.message;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<Card className="card-elegant max-w-md">
|
||||
<CardContent className="py-12 text-center">
|
||||
<AlertCircle className="w-16 h-16 mx-auto text-muted-foreground mb-4" />
|
||||
<h3 className="text-xl font-semibold mb-2">商品不存在</h3>
|
||||
<p className="text-muted-foreground mb-6">该商品可能已被删除或不存在</p>
|
||||
<h3 className="text-xl font-semibold mb-2">{errorTitle}</h3>
|
||||
<p className="text-muted-foreground mb-6">{errorDescription}</p>
|
||||
<Link href="/products">
|
||||
<Button>返回商品导航</Button>
|
||||
</Link>
|
||||
@@ -332,10 +366,10 @@ export default function ProductDetail() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Product Image */}
|
||||
<div className="w-full aspect-square rounded-xl bg-muted flex items-center justify-center overflow-hidden mb-6">
|
||||
{product.image ? (
|
||||
<div className="w-full aspect-square rounded-xl bg-muted flex items-center justify-center overflow-hidden mb-3">
|
||||
{displayImage ? (
|
||||
<img
|
||||
src={product.image}
|
||||
src={displayImage}
|
||||
alt={product.name}
|
||||
className="w-full h-full object-cover"
|
||||
loading="eager"
|
||||
@@ -344,6 +378,22 @@ export default function ProductDetail() {
|
||||
<ShoppingBag className="w-24 h-24 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
{productImages.length > 1 && (
|
||||
<div className="grid grid-cols-5 gap-2 mb-6">
|
||||
{productImages.map((img) => (
|
||||
<button
|
||||
key={img}
|
||||
type="button"
|
||||
onClick={() => setSelectedImage(img)}
|
||||
className={`aspect-square rounded-lg overflow-hidden border ${
|
||||
img === displayImage ? "border-primary ring-2 ring-primary/30" : "border-border"
|
||||
}`}
|
||||
>
|
||||
<img src={img} alt={product.name} className="w-full h-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator className="my-6" />
|
||||
|
||||
|
||||
@@ -11,13 +11,15 @@ import { useCategories, useWebsites, useProducts, useFavorites, useAddFavorite,
|
||||
import { useDebounce } from "@/hooks/useDebounce";
|
||||
import { categoryApi, productApi, websiteApi, type Product, type ProductWithPrices } from "@/lib/api";
|
||||
import { Link } from "wouter";
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import { useState, useMemo, useEffect, useRef } from "react";
|
||||
import {
|
||||
ShoppingBag,
|
||||
ArrowUpDown,
|
||||
Loader2,
|
||||
Heart,
|
||||
Sparkles
|
||||
Sparkles,
|
||||
Image as ImageIcon,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
@@ -26,6 +28,19 @@ 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";
|
||||
@@ -33,6 +48,7 @@ import RecommendedProducts from "@/features/products/components/RecommendedProdu
|
||||
export default function Products() {
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const MAX_IMAGES = 6;
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>("all");
|
||||
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
||||
@@ -46,6 +62,12 @@ export default function Products() {
|
||||
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: "",
|
||||
@@ -69,6 +91,35 @@ export default function Products() {
|
||||
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"
|
||||
@@ -189,10 +240,34 @@ export default function Products() {
|
||||
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: newProduct.image.trim() || undefined,
|
||||
image: imageUrl,
|
||||
images,
|
||||
category_id: Number(newProduct.categoryId),
|
||||
});
|
||||
await productApi.addPrice({
|
||||
@@ -220,10 +295,14 @@ export default function Products() {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -306,9 +385,115 @@ export default function Products() {
|
||||
<Input
|
||||
id="product-image"
|
||||
value={newProduct.image}
|
||||
onChange={(e) => setNewProduct(prev => ({ ...prev, image: e.target.value }))}
|
||||
onChange={(e) => {
|
||||
setNewProduct(prev => ({ ...prev, image: e.target.value }));
|
||||
if (e.target.value.trim()) {
|
||||
setImageFiles([]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="product-image-file">上传本地图片</Label>
|
||||
<div
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
appendImageFiles(Array.from(e.dataTransfer.files || []));
|
||||
}}
|
||||
onPaste={(e) => {
|
||||
appendImageFiles(Array.from(e.clipboardData?.files || []));
|
||||
}}
|
||||
className="rounded-md border border-dashed bg-muted/20 p-3 transition hover:bg-muted/40"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
拖拽/粘贴图片到此处,或点击选择文件
|
||||
</div>
|
||||
<div>最多{MAX_IMAGES}张</div>
|
||||
</div>
|
||||
<Input
|
||||
id="product-image-file"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (files.length > MAX_IMAGES) {
|
||||
toast.error(`最多上传${MAX_IMAGES}张图片`);
|
||||
setImageFiles(createImageFileItems(files.slice(0, MAX_IMAGES)));
|
||||
} else {
|
||||
setImageFiles(createImageFileItems(files));
|
||||
}
|
||||
if (files.length > 0) {
|
||||
setNewProduct(prev => ({ ...prev, image: "" }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">拖拽排序,自动裁剪为1:1,单张≤5MB,自动压缩</p>
|
||||
{imageFiles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<HeroDropzone id={imageHeroId} className="rounded-md border bg-muted/40 p-3">
|
||||
<div className="text-xs text-muted-foreground mb-2">首图预览(拖拽或点击缩略图设为首图)</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<FileThumbnail file={imageFiles[0].file} className="h-16 w-16 rounded object-cover" />
|
||||
<div className="text-xs text-muted-foreground truncate">{imageFiles[0].file.name}</div>
|
||||
</div>
|
||||
</HeroDropzone>
|
||||
<SortableGrid
|
||||
items={imageFiles}
|
||||
onReorder={setImageFiles}
|
||||
heroDropId={imageHeroId}
|
||||
onHeroDrop={moveImageToFront}
|
||||
className="grid grid-cols-2 gap-3"
|
||||
itemClassName="p-3"
|
||||
renderItem={(item) => {
|
||||
const isFirst = imageFiles[0]?.id === item.id;
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" className="relative" onClick={() => moveImageToFront(item.id)}>
|
||||
<FileThumbnail file={item.file} className="h-20 w-20 rounded object-cover" />
|
||||
{isFirst && (
|
||||
<div className="absolute right-1 top-1 rounded bg-primary/90 px-2 py-0.5 text-[11px] text-primary-foreground">
|
||||
首图
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-muted-foreground truncate">{item.file.name}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
const isFirstItem = isFirst;
|
||||
const nextName = imageFiles.length > 1
|
||||
? imageFiles[1]?.file.name || null
|
||||
: null;
|
||||
openConfirm(
|
||||
() => {
|
||||
setImageFiles((prev) => prev.filter((f) => f.id !== item.id));
|
||||
},
|
||||
{ name: item.file.name, file: item.file },
|
||||
{ isFirst: isFirstItem, nextName }
|
||||
);
|
||||
}}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>分类</Label>
|
||||
@@ -480,7 +665,9 @@ export default function Products() {
|
||||
<Button variant="outline" onClick={() => setIsAddOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleAddProduct}>保存</Button>
|
||||
<Button onClick={handleAddProduct} disabled={isUploadingImage}>
|
||||
{isUploadingImage ? "上传中..." : "保存"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -488,6 +675,42 @@ export default function Products() {
|
||||
</>
|
||||
)}
|
||||
</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}
|
||||
@@ -557,7 +780,7 @@ export default function Products() {
|
||||
<CardHeader className={viewMode === "list" ? "flex-row items-center gap-4 space-y-0" : ""}>
|
||||
<div className={`${viewMode === "list" ? "w-16 h-16 flex-shrink-0" : "w-full aspect-square"} rounded-xl overflow-hidden`}>
|
||||
<LazyImage
|
||||
src={product.image}
|
||||
src={product.images?.[0] || product.image || ""}
|
||||
alt={product.name}
|
||||
className="w-full h-full"
|
||||
aspectRatio={viewMode === "list" ? "1/1" : undefined}
|
||||
|
||||
@@ -889,6 +889,17 @@ export function useReviewProduct() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAdminProductImages() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ productId, data }: { productId: number; data: { images: string[]; image?: string } }) =>
|
||||
adminApi.updateProductImages(productId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'products'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== My Products Hooks ====================
|
||||
|
||||
export function useMyProducts(status?: string) {
|
||||
|
||||
@@ -24,4 +24,6 @@ export const adminApi = {
|
||||
api.get<PaginatedResponse<AdminProduct>>("/admin/products/all/", { params: { status } }).then((r) => r.data),
|
||||
reviewProduct: (productId: number, data: { approved: boolean, reject_reason?: string }) =>
|
||||
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),
|
||||
};
|
||||
|
||||
@@ -36,6 +36,7 @@ export type {
|
||||
SearchResults,
|
||||
AdminUser,
|
||||
AdminBounty,
|
||||
AdminProduct,
|
||||
AdminPaymentEvent,
|
||||
PaginatedResponse,
|
||||
MessageResponse,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { api, searchTimeout, uploadTimeout } from "./client";
|
||||
import type { PaginatedResponse, Product, ProductPrice, ProductWithPrices, MyProduct } from "../types";
|
||||
|
||||
type UploadImageResponse = { url: string };
|
||||
|
||||
export const productApi = {
|
||||
list: (params?: { category_id?: number; search?: string; page?: number; min_price?: number; max_price?: number; sort_by?: string }) =>
|
||||
api.get<PaginatedResponse<Product>>("/products/", { params }).then((r) => r.data),
|
||||
@@ -23,10 +25,20 @@ export const productApi = {
|
||||
"/products/search/",
|
||||
{ params, timeout: searchTimeout }
|
||||
).then((r) => r.data),
|
||||
create: (data: { name: string; description?: string; image?: string; category_id: number }) =>
|
||||
create: (data: { name: string; description?: string; image?: string; images?: string[]; category_id: number }) =>
|
||||
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),
|
||||
uploadImage: (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
return api
|
||||
.post<UploadImageResponse>("/products/upload-image/", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
timeout: uploadTimeout,
|
||||
})
|
||||
.then((r) => r.data);
|
||||
},
|
||||
// 我的商品
|
||||
myProducts: (status?: string) =>
|
||||
api.get<PaginatedResponse<MyProduct>>("/products/my/", { params: { status } }).then((r) => r.data),
|
||||
|
||||
90
frontend/src/lib/image.ts
Normal file
90
frontend/src/lib/image.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
type ImageProcessOptions = {
|
||||
maxSizeBytes?: number;
|
||||
maxDimension?: number;
|
||||
aspectRatio?: number;
|
||||
ratioTolerance?: number;
|
||||
quality?: number;
|
||||
allowedTypes?: string[];
|
||||
};
|
||||
|
||||
const DEFAULT_ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"];
|
||||
|
||||
export type ImageFileItem = {
|
||||
id: string;
|
||||
file: File;
|
||||
};
|
||||
|
||||
const createId = () => {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
};
|
||||
|
||||
export function createImageFileItems(files: File[]): ImageFileItem[] {
|
||||
return files.map((file) => ({ id: createId(), file }));
|
||||
}
|
||||
|
||||
function loadImageFromFile(file: File): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(file);
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(img);
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error("图片读取失败"));
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
export async function processImageFile(file: File, options: ImageProcessOptions = {}): Promise<File> {
|
||||
const {
|
||||
maxSizeBytes = 5 * 1024 * 1024,
|
||||
maxDimension = 1600,
|
||||
quality = 0.8,
|
||||
allowedTypes = DEFAULT_ALLOWED_TYPES,
|
||||
} = options;
|
||||
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
throw new Error("仅支持 JPG/PNG/WEBP 图片");
|
||||
}
|
||||
if (file.size > maxSizeBytes) {
|
||||
throw new Error("图片大小不能超过5MB");
|
||||
}
|
||||
|
||||
const img = await loadImageFromFile(file);
|
||||
const cropSize = Math.min(img.width, img.height);
|
||||
const cropX = Math.round((img.width - cropSize) / 2);
|
||||
const cropY = Math.round((img.height - cropSize) / 2);
|
||||
|
||||
const maxSide = cropSize;
|
||||
const needsResize = maxSide > maxDimension;
|
||||
const needsCompress = file.size > maxSizeBytes * 0.6;
|
||||
|
||||
const scale = Math.min(1, maxDimension / maxSide);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.round(cropSize * scale);
|
||||
canvas.height = Math.round(cropSize * scale);
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
throw new Error("图片处理失败");
|
||||
}
|
||||
ctx.drawImage(img, cropX, cropY, cropSize, cropSize, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
const outputType = file.type === "image/png" ? "image/png" : "image/jpeg";
|
||||
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(result) => (result ? resolve(result) : reject(new Error("图片压缩失败"))),
|
||||
outputType,
|
||||
outputType === "image/jpeg" ? quality : undefined
|
||||
);
|
||||
});
|
||||
|
||||
const ext = outputType === "image/png" ? ".png" : ".jpg";
|
||||
const filename = file.name.replace(/\.[^.]+$/, "") + ext;
|
||||
return new File([blob], filename, { type: outputType, lastModified: Date.now() });
|
||||
}
|
||||
@@ -49,6 +49,7 @@ export interface Product {
|
||||
name: string;
|
||||
description: string | null;
|
||||
image: string | null;
|
||||
images?: string[];
|
||||
category_id: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -285,6 +286,7 @@ export interface AdminProduct {
|
||||
name: string;
|
||||
description: string | null;
|
||||
image: string | null;
|
||||
images?: string[];
|
||||
category_id: number;
|
||||
category_name: string | null;
|
||||
status: "pending" | "approved" | "rejected";
|
||||
@@ -301,6 +303,7 @@ export interface MyProduct {
|
||||
name: string;
|
||||
description: string | null;
|
||||
image: string | null;
|
||||
images?: string[];
|
||||
category_id: number;
|
||||
status: "pending" | "approved" | "rejected";
|
||||
reject_reason: string | null;
|
||||
|
||||
@@ -185,6 +185,11 @@ export default defineConfig({
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
// Proxy media files
|
||||
'/media': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
// Proxy Stripe webhooks
|
||||
'/webhooks': {
|
||||
target: 'http://localhost:8000',
|
||||
|
||||
Reference in New Issue
Block a user