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

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