2025-05-22 20:42:10 +08:00

555 lines
21 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import { AdminNav } from "@/components/admin-nav"
import { ThemeToggle } from "@/components/theme-toggle"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Combobox } from "@/components/ui/combobox"
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Skeleton } from "@/components/ui/skeleton"
import { Textarea } from "@/components/ui/textarea"
import { useToast } from "@/components/ui/use-toast"
import { useAuth } from "@/context/auth-context"
import { fetchWithAuth } from "@/lib/api"
import { zodResolver } from "@hookform/resolvers/zod"
import { format } from "date-fns"
import { ArrowLeft, BookOpen, CalendarIcon, ImageIcon, Loader2, Save, X } from "lucide-react"
import { useRouter } from "next/navigation"
import { useEffect, useState, use } from "react"
import { useFieldArray, useForm } from "react-hook-form"
import * as z from "zod"
import { Plus } from "lucide-react"
interface Publisher {
publisherId: number
name: string
address: string
}
interface BookDetail {
bookId: number
title: string
isbn: string
price: number
stock: number
publishDate: string
publisherId: number
publisherName: string
description: string
coverImage: string
author: string[]
}
const formSchema = z.object({
title: z.string().min(1, "标题不能为空"),
isbn: z.string().min(1, "ISBN不能为空"),
price: z.coerce.number().min(0, "价格不能为负数"),
stock: z.coerce.number().min(0, "库存不能为负数").int("库存必须是整数"),
publishDate: z.date(),
publisherId: z.coerce.number(),
description: z.string().optional(),
coverImage: z.string().optional(),
authors: z
.array(
z.object({
name: z.string().min(1, "作者名不能为空"),
}),
)
.min(1, "至少需要一位作者"),
})
export default function EditBookPage({ params }: { params: Promise<{ id: string }> | { id: string } }) {
// 使用 React.use() 解包 params
const resolvedParams = "then" in params ? use(params) : params
const bookId = resolvedParams.id
const { user } = useAuth()
const router = useRouter()
const { toast } = useToast()
const [publishers, setPublishers] = useState<Publisher[]>([])
const [book, setBook] = useState<BookDetail | null>(null)
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [coverPreview, setCoverPreview] = useState<string | null>(null)
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
title: "",
isbn: "",
price: 0,
stock: 0,
publishDate: new Date(),
publisherId: 0,
description: "",
coverImage: "",
authors: [{ name: "" }],
},
})
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "authors",
})
// 监听封面图片URL变化更新预览
const coverImageValue = form.watch("coverImage")
useEffect(() => {
if (coverImageValue) {
setCoverPreview(coverImageValue)
}
}, [coverImageValue])
useEffect(() => {
// 检查用户是否登录且是管理员
if (!user) {
toast({
title: "请先登录",
description: "您需要登录后才能访问管理页面",
variant: "destructive",
})
router.push("/login")
return
}
if (!user.isAdmin) {
toast({
title: "权限不足",
description: "您没有管理员权限",
variant: "destructive",
})
router.push("/")
return
}
// 获取出版社列表
const fetchPublishers = async () => {
try {
const response = await fetchWithAuth("publisher/all")
const result = await response.json()
if (result.code === 0) {
setPublishers(result.data)
} else {
toast({
variant: "destructive",
title: "获取出版社失败",
description: result.msg || "无法获取出版社信息",
})
}
} catch (error) {
toast({
variant: "destructive",
title: "获取出版社失败",
description: "服务器连接错误,请稍后再试",
})
}
}
// 获取图书详情
const fetchBookDetail = async () => {
try {
const response = await fetchWithAuth(`book/${bookId}`)
const result = await response.json()
if (result.code === 0) {
setBook(result.data)
setCoverPreview(result.data.coverImage)
// 设置表单默认值
form.reset({
title: result.data.title,
isbn: result.data.isbn,
price: result.data.price,
stock: result.data.stock,
publishDate: new Date(result.data.publishDate),
publisherId: result.data.publisherId,
description: result.data.description || "",
coverImage: result.data.coverImage || "",
authors: result.data.author.map((name: string) => ({ name })),
})
} else {
toast({
variant: "destructive",
title: "获取图书失败",
description: result.msg || "无法获取图书信息",
})
router.push("/admin/books")
}
} catch (error) {
toast({
variant: "destructive",
title: "获取图书失败",
description: "服务器连接错误,请稍后再试",
})
router.push("/admin/books")
} finally {
setLoading(false)
}
}
Promise.all([fetchPublishers(), fetchBookDetail()])
}, [user, router, toast, bookId, form])
const onSubmit = async (values: z.infer<typeof formSchema>) => {
if (!book) return
setSubmitting(true)
try {
const bookData = {
...values,
authors: values.authors.map((author) => author.name),
}
const response = await fetchWithAuth(`book/admin/update/${book.bookId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(bookData),
})
const result = await response.json()
if (result.code === 0) {
toast({
title: "更新成功",
description: "图书信息已成功更新",
})
router.push("/admin/books")
} else {
toast({
variant: "destructive",
title: "更新失败",
description: result.msg || "无法更新图书信息",
})
}
} catch (error) {
toast({
variant: "destructive",
title: "更新失败",
description: "服务器连接错误,请稍后再试",
})
} finally {
setSubmitting(false)
}
}
if (!user || !user.isAdmin) {
return null
}
return (
<div className="flex min-h-screen flex-col">
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-16 items-center justify-between">
<div className="flex items-center gap-2 font-semibold">
<BookOpen className="h-6 w-6" />
<span> - </span>
</div>
<div className="flex items-center gap-4">
<ThemeToggle />
<Button variant="ghost" onClick={() => router.push("/")}>
</Button>
</div>
</div>
</header>
<div className="flex flex-1">
<AdminNav />
<main className="flex-1 p-6">
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" onClick={() => router.push("/admin/books")}>
<ArrowLeft className="h-4 w-4" />
</Button>
<h1 className="text-3xl font-bold"></h1>
</div>
{!loading && book && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">ID: {book.bookId}</span>
</div>
)}
</div>
{loading ? (
<div className="space-y-6">
<Card>
<CardHeader>
<Skeleton className="h-8 w-1/3" />
<Skeleton className="h-4 w-1/2" />
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-6 md:grid-cols-2">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-10 w-full" />
</div>
))}
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-32 w-full" />
</div>
</CardContent>
</Card>
</div>
) : (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid gap-6 md:grid-cols-2">
<Card className="md:col-span-1">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="输入图书标题" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="isbn"
render={({ field }) => (
<FormItem>
<FormLabel>ISBN</FormLabel>
<FormControl>
<Input placeholder="输入ISBN" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="price"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="number" step="0.01" placeholder="输入价格" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="stock"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="number" placeholder="输入库存" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="publishDate"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel></FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button variant={"outline"} className="w-full pl-3 text-left font-normal">
{field.value ? format(field.value, "yyyy-MM-dd") : <span></span>}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={field.value}
onSelect={field.onChange}
initialFocus
/>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="publisherId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel></FormLabel>
<Combobox
items={publishers.map((publisher) => ({
label: publisher.name,
value: publisher.publisherId.toString(),
}))}
value={field.value.toString()}
onChange={(value) => field.onChange(Number.parseInt(value))}
placeholder="选择出版社"
/>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card className="md:col-span-1">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="coverImage"
render={({ field }) => (
<FormItem>
<FormLabel>URL</FormLabel>
<FormControl>
<Input placeholder="输入封面图片URL" {...field} />
</FormControl>
<FormDescription>URL地址</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="mt-4">
<p className="text-sm font-medium mb-2"></p>
<div className="border rounded-md p-4 flex justify-center bg-muted/30">
{coverPreview ? (
<img
src={coverPreview || "/placeholder.svg"}
alt="封面预览"
className="max-h-[200px] object-contain rounded-md"
onError={() => setCoverPreview(null)}
/>
) : (
<div className="flex flex-col items-center justify-center h-[200px] w-full text-muted-foreground">
<ImageIcon className="h-12 w-12 mb-2" />
<p className="text-sm"></p>
</div>
)}
</div>
</div>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Textarea placeholder="输入图书描述" className="min-h-[120px]" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{fields.map((field, index) => (
<div key={field.id} className="flex items-center gap-2">
<FormField
control={form.control}
name={`authors.${index}.name`}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel className={index !== 0 ? "sr-only" : ""}>
{index === 0 ? "作者" : `作者 ${index + 1}`}
</FormLabel>
<FormControl>
<Input placeholder="作者姓名" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{index > 0 && (
<Button
type="button"
variant="destructive"
size="icon"
className="mt-7"
onClick={() => remove(index)}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
className="mt-2"
onClick={() => append({ name: "" })}
>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
</CardContent>
<CardFooter className="flex justify-between border-t px-6 py-4">
<Button variant="outline" type="button" onClick={() => router.push("/admin/books")}>
</Button>
<Button type="submit" disabled={submitting}>
{submitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
</>
)}
</Button>
</CardFooter>
</Card>
</form>
</Form>
)}
</div>
</main>
</div>
</div>
)
}