diff --git a/backend/apps/admin/api.py b/backend/apps/admin/api.py index 65c0317..dc80a76 100644 --- a/backend/apps/admin/api.py +++ b/backend/apps/admin/api.py @@ -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 diff --git a/backend/apps/products/api.py b/backend/apps/products/api.py index 0564abc..ef4579b 100644 --- a/backend/apps/products/api.py +++ b/backend/apps/products/api.py @@ -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): diff --git a/backend/apps/products/migrations/0002_product_images.py b/backend/apps/products/migrations/0002_product_images.py new file mode 100644 index 0000000..109b1aa --- /dev/null +++ b/backend/apps/products/migrations/0002_product_images.py @@ -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="图片列表"), + ), + ] diff --git a/backend/apps/products/models.py b/backend/apps/products/models.py index eca2563..bb323cf 100644 --- a/backend/apps/products/models.py +++ b/backend/apps/products/models.py @@ -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, diff --git a/backend/apps/products/schemas.py b/backend/apps/products/schemas.py index dceb480..9a82147 100644 --- a/backend/apps/products/schemas.py +++ b/backend/apps/products/schemas.py @@ -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 diff --git a/backend/config/api.py b/backend/config/api.py index a4e6cda..9fa5177 100644 --- a/backend/config/api.py +++ b/backend/config/api.py @@ -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, ) diff --git a/backend/config/settings.py b/backend/config/settings.py index 8bf28b3..8817b2c 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -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 diff --git a/backend/config/urls.py b/backend/config/urls.py index 019181b..f6de379 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -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) diff --git a/frontend/package.json b/frontend/package.json index 1c09322..069a46f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 48d7288..0f7ada3 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -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 diff --git a/frontend/src/components/FileThumbnail.tsx b/frontend/src/components/FileThumbnail.tsx new file mode 100644 index 0000000..e639c38 --- /dev/null +++ b/frontend/src/components/FileThumbnail.tsx @@ -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(""); + + useEffect(() => { + const url = URL.createObjectURL(file); + setSrc(url); + return () => { + URL.revokeObjectURL(url); + }; + }, [file]); + + return ( + {alt + ); +} diff --git a/frontend/src/components/SortableGrid.tsx b/frontend/src/components/SortableGrid.tsx new file mode 100644 index 0000000..f0189d2 --- /dev/null +++ b/frontend/src/components/SortableGrid.tsx @@ -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 = { + 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({ + 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 ( +
+ + {renderItem(item)} +
+ ); +} + +export function HeroDropzone({ id, className, children }: HeroDropzoneProps) { + const { setNodeRef, isOver } = useDroppable({ id }); + return ( +
+ {children} +
+ ); +} + +export default function SortableGrid({ + items, + onReorder, + renderItem, + className, + itemClassName, + heroDropId, + onHeroDrop, +}: SortableGridProps) { + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })); + const [activeId, setActiveId] = useState(null); + const activeItem = activeId ? items.find((item) => item.id === activeId) : null; + + return ( + 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)); + }} + > + item.id)} strategy={rectSortingStrategy}> +
+ {items.map((item) => ( + + ))} +
+
+ + {activeItem ? ( +
+ {renderItem(activeItem)} +
+ ) : null} +
+
+ ); +} diff --git a/frontend/src/components/SortableList.tsx b/frontend/src/components/SortableList.tsx new file mode 100644 index 0000000..2a8c6e5 --- /dev/null +++ b/frontend/src/components/SortableList.tsx @@ -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 = { + items: T[]; + onReorder: (items: T[]) => void; + renderContent: (item: T) => ReactNode; + className?: string; +}; + +function SortableRow({ 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 ( +
+ +
{renderContent(item)}
+
+ ); +} + +export default function SortableList({ + items, + onReorder, + renderContent, + className, +}: SortableListProps) { + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })); + + return ( + { + 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)); + }} + > + item.id)} strategy={rectSortingStrategy}> +
+ {items.map((item) => ( + + ))} +
+
+
+ ); +} diff --git a/frontend/src/features/admin/pages/Admin.tsx b/frontend/src/features/admin/pages/Admin.tsx index cdbd1a8..482b5ee 100644 --- a/frontend/src/features/admin/pages/Admin.tsx +++ b/frontend/src/features/admin/pages/Admin.tsx @@ -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(null); + const [imageDialogOpen, setImageDialogOpen] = useState(false); + const [editingProduct, setEditingProduct] = useState(null); + const [editingImages, setEditingImages] = useState([]); + const [newImageUrl, setNewImageUrl] = useState(""); + const [imageFiles, setImageFiles] = useState([]); + const [isUploadingImages, setIsUploadingImages] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); + const confirmActionRef = useRef 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 (
@@ -100,6 +249,261 @@ export default function Admin() { return (
+ + + + 确认删除图片 + 该图片将从列表中移除,删除后无法恢复。 + + {confirmItem && ( +
+ {confirmItem.file ? ( + + ) : confirmItem.url ? ( + preview + ) : ( +
+ )} +
{confirmItem.name || "图片"}
+
+ )} + {confirmMeta?.isFirst && ( +
+ 当前为首图,删除后将自动使用下一张图片作为首图{confirmMeta.nextName ? `(${confirmMeta.nextName})` : "。"} +
+ )} + + 取消 + { + confirmActionRef.current?.(); + confirmActionRef.current = null; + setConfirmItem(null); + }} + > + 删除 + + + + + + + + 编辑商品图片 + 最多{MAX_IMAGES}张,按顺序展示 + + {editingProduct && ( +
当前商品:{editingProduct.name}
+ )} +
+ {editingImages.length > 0 ? ( +
+ +
首图预览(拖拽或点击缩略图设为首图)
+
+ {editingImages[0].url ? ( + preview + ) : ( +
+ )} +
{editingImages[0].url}
+
+ + { + const isFirst = editingImages[0]?.id === item.id; + return ( +
+ +
+ { + const value = e.target.value; + setEditingImages((prev) => + prev.map((img) => (img.id === item.id ? { ...img, url: value } : img)) + ); + }} + /> +
+
+ + +
+
+ ); + }} + /> +
+ ) : ( +
暂无图片
+ )} +
+ setNewImageUrl(e.target.value)} + /> + +
+
+ +
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" + > +
+
+ + 拖拽/粘贴图片到此处,或点击选择文件 +
+
最多{MAX_IMAGES}张
+
+ { + 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)); + } + }} + /> +
+

拖拽排序,自动裁剪为1:1,单张≤5MB,自动压缩

+ {imageFiles.length > 0 && ( +
+ +
首图预览(拖拽或点击缩略图设为首图)
+
+ +
{imageFiles[0].file.name}
+
+
+ { + const isFirst = imageFiles[0]?.id === item.id; + return ( +
+ +
+
{item.file.name}
+
+
+ + +
+
+ ); + }} + /> +
+ )} +
+
+ + + + + +
@@ -202,8 +606,8 @@ export default function Admin() {
- {product.image && ( - {product.name} + {(product.images?.[0] || product.image) && ( + {product.name} )}
{product.name}
@@ -249,6 +653,14 @@ export default function Admin() {
) : (
+ +
+
{item.file.name}
+
+
+ + +
+
+ ); + }} + /> +
+ )} +
@@ -738,11 +969,11 @@ export default function Dashboard() { - @@ -332,10 +366,10 @@ export default function ProductDetail() { {/* Product Image */} -
- {product.image ? ( +
+ {displayImage ? ( {product.name} )}
+ {productImages.length > 1 && ( +
+ {productImages.map((img) => ( + + ))} +
+ )} diff --git a/frontend/src/features/products/pages/Products.tsx b/frontend/src/features/products/pages/Products.tsx index efadd5f..50d407b 100644 --- a/frontend/src/features/products/pages/Products.tsx +++ b/frontend/src/features/products/pages/Products.tsx @@ -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("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([]); + const [isUploadingImage, setIsUploadingImage] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); + const confirmActionRef = useRef void)>(null); + const [confirmItem, setConfirmItem] = useState<{ name?: string; file?: File } | null>(null); + const [confirmMeta, setConfirmMeta] = useState<{ isFirst?: boolean; nextName?: string | null } | null>(null); const [newProduct, setNewProduct] = useState({ name: "", description: "", @@ -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() { setNewProduct(prev => ({ ...prev, image: e.target.value }))} + onChange={(e) => { + setNewProduct(prev => ({ ...prev, image: e.target.value })); + if (e.target.value.trim()) { + setImageFiles([]); + } + }} />
+
+ +
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" + > +
+
+ + 拖拽/粘贴图片到此处,或点击选择文件 +
+
最多{MAX_IMAGES}张
+
+ { + 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: "" })); + } + }} + /> +
+

拖拽排序,自动裁剪为1:1,单张≤5MB,自动压缩

+ {imageFiles.length > 0 && ( +
+ +
首图预览(拖拽或点击缩略图设为首图)
+
+ +
{imageFiles[0].file.name}
+
+
+ { + const isFirst = imageFiles[0]?.id === item.id; + return ( +
+ +
+
{item.file.name}
+
+
+ +
+
+ ); + }} + /> +
+ )} +
@@ -480,7 +665,9 @@ export default function Products() { - + @@ -488,6 +675,42 @@ export default function Products() { )} + + + + 确认删除图片 + 该图片将从列表中移除,删除后无法恢复。 + + {confirmItem && ( +
+ {confirmItem.file ? ( + + ) : ( +
+ )} +
{confirmItem.name || "图片"}
+
+ )} + {confirmMeta?.isFirst && ( +
+ 当前为首图,删除后将自动使用下一张图片作为首图{confirmMeta.nextName ? `(${confirmMeta.nextName})` : "。"} +
+ )} + + 取消 + { + confirmActionRef.current?.(); + confirmActionRef.current = null; + setConfirmItem(null); + }} + > + 删除 + + + +
+ adminApi.updateProductImages(productId, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'products'] }); + }, + }); +} + // ==================== My Products Hooks ==================== export function useMyProducts(status?: string) { diff --git a/frontend/src/lib/api/admin.ts b/frontend/src/lib/api/admin.ts index 1a98e84..30ca386 100644 --- a/frontend/src/lib/api/admin.ts +++ b/frontend/src/lib/api/admin.ts @@ -24,4 +24,6 @@ export const adminApi = { api.get>("/admin/products/all/", { params: { status } }).then((r) => r.data), reviewProduct: (productId: number, data: { approved: boolean, reject_reason?: string }) => api.post(`/admin/products/${productId}/review/`, data).then((r) => r.data), + updateProductImages: (productId: number, data: { images: string[]; image?: string }) => + api.put(`/admin/products/${productId}/images/`, data).then((r) => r.data), }; diff --git a/frontend/src/lib/api/index.ts b/frontend/src/lib/api/index.ts index 5804cee..87f1424 100644 --- a/frontend/src/lib/api/index.ts +++ b/frontend/src/lib/api/index.ts @@ -36,6 +36,7 @@ export type { SearchResults, AdminUser, AdminBounty, + AdminProduct, AdminPaymentEvent, PaginatedResponse, MessageResponse, diff --git a/frontend/src/lib/api/products.ts b/frontend/src/lib/api/products.ts index 7a69dee..3048096 100644 --- a/frontend/src/lib/api/products.ts +++ b/frontend/src/lib/api/products.ts @@ -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>("/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("/products/", data).then((r) => r.data), addPrice: (data: { product_id: number; website_id: number; price: string; original_price?: string; currency?: string; url: string; in_stock?: boolean }) => api.post("/products/prices/", data).then((r) => r.data), + uploadImage: (file: File) => { + const formData = new FormData(); + formData.append("file", file); + return api + .post("/products/upload-image/", formData, { + headers: { "Content-Type": "multipart/form-data" }, + timeout: uploadTimeout, + }) + .then((r) => r.data); + }, // 我的商品 myProducts: (status?: string) => api.get>("/products/my/", { params: { status } }).then((r) => r.data), diff --git a/frontend/src/lib/image.ts b/frontend/src/lib/image.ts new file mode 100644 index 0000000..477c6f0 --- /dev/null +++ b/frontend/src/lib/image.ts @@ -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 { + 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 { + 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((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() }); +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index e673d76..62fcc05 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 9db7138..78d78cf 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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',