This commit is contained in:
27942
2026-01-29 13:18:59 +08:00
parent 2471ed8a05
commit 407471c1ff
25 changed files with 1531 additions and 77 deletions

View File

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

View File

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

View 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="图片列表"),
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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"}
/>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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:15MB</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)}

View File

@@ -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:15MB</p>
{imageFiles.length > 0 && (
<div className="space-y-2">
<HeroDropzone id={imageHeroId} className="rounded-md border bg-muted/40 p-3">
<div className="text-xs text-muted-foreground mb-2"></div>
<div className="flex items-center gap-3">
<FileThumbnail file={imageFiles[0].file} className="h-16 w-16 rounded object-cover" />
<div className="text-xs text-muted-foreground truncate">{imageFiles[0].file.name}</div>
</div>
</HeroDropzone>
<SortableGrid
items={imageFiles}
onReorder={setImageFiles}
heroDropId={imageHeroId}
onHeroDrop={moveImageToFront}
className="grid grid-cols-2 gap-3"
itemClassName="p-3"
renderItem={(item) => {
const isFirst = imageFiles[0]?.id === item.id;
return (
<div className="flex items-center gap-3">
<button type="button" className="relative" onClick={() => moveImageToFront(item.id)}>
<FileThumbnail file={item.file} className="h-20 w-20 rounded object-cover" />
{isFirst && (
<div className="absolute right-1 top-1 rounded bg-primary/90 px-2 py-0.5 text-[11px] text-primary-foreground">
</div>
)}
</button>
<div className="flex-1 min-w-0">
<div className="text-sm text-muted-foreground truncate">{item.file.name}</div>
</div>
<div className="flex items-center gap-1">
<Button
type="button"
variant={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"
/>

View File

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

View File

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

View File

@@ -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:15MB</p>
{imageFiles.length > 0 && (
<div className="space-y-2">
<HeroDropzone id={imageHeroId} className="rounded-md border bg-muted/40 p-3">
<div className="text-xs text-muted-foreground mb-2"></div>
<div className="flex items-center gap-3">
<FileThumbnail file={imageFiles[0].file} className="h-16 w-16 rounded object-cover" />
<div className="text-xs text-muted-foreground truncate">{imageFiles[0].file.name}</div>
</div>
</HeroDropzone>
<SortableGrid
items={imageFiles}
onReorder={setImageFiles}
heroDropId={imageHeroId}
onHeroDrop={moveImageToFront}
className="grid grid-cols-2 gap-3"
itemClassName="p-3"
renderItem={(item) => {
const isFirst = imageFiles[0]?.id === item.id;
return (
<div className="flex items-center gap-3">
<button type="button" className="relative" onClick={() => moveImageToFront(item.id)}>
<FileThumbnail file={item.file} className="h-20 w-20 rounded object-cover" />
{isFirst && (
<div className="absolute right-1 top-1 rounded bg-primary/90 px-2 py-0.5 text-[11px] text-primary-foreground">
</div>
)}
</button>
<div className="flex-1 min-w-0">
<div className="text-sm text-muted-foreground truncate">{item.file.name}</div>
</div>
<div className="flex items-center gap-1">
<Button
type="button"
variant="outline"
size="icon"
onClick={() => {
const isFirstItem = isFirst;
const nextName = imageFiles.length > 1
? imageFiles[1]?.file.name || null
: null;
openConfirm(
() => {
setImageFiles((prev) => prev.filter((f) => f.id !== item.id));
},
{ name: item.file.name, file: item.file },
{ isFirst: isFirstItem, nextName }
);
}}
title="删除"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
);
}}
/>
</div>
)}
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label></Label>
@@ -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}

View File

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

View File

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

View File

@@ -36,6 +36,7 @@ export type {
SearchResults,
AdminUser,
AdminBounty,
AdminProduct,
AdminPaymentEvent,
PaginatedResponse,
MessageResponse,

View File

@@ -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
View 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() });
}

View File

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

View File

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