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

424 lines
16 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 { useEffect, useState } from "react"
import { MainNav } from "@/components/main-nav"
import { SearchBar } from "@/components/search-bar"
import { ThemeToggle } from "@/components/theme-toggle"
import { ShoppingCart } from "@/components/shopping-cart"
import { UserNav } from "@/components/user-nav"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { useSearchParams } from "next/navigation"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { useToast } from "@/components/ui/use-toast"
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination"
import Link from "next/link"
import { ShoppingCartIcon, BookOpen, Search, Filter, BookOpenCheck, TrendingUp } from "lucide-react"
import { useCart } from "@/context/cart-context"
import { fetchWithAuth } from "@/lib/api"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { motion } from "framer-motion"
import { Badge } from "@/components/ui/badge"
import { notify } from "@/lib/event-bus"
interface Book {
bookId: number
title: string
isbn: string
price: number
stock: number
publishDate: string
publisherName: string
description: string
coverImage: string
author: string[]
}
interface BooksResponse {
pageNum: number
pageSize: number
total: number
data: Book[]
}
export default function BooksPage() {
const [books, setBooks] = useState<BooksResponse | null>(null)
const [loading, setLoading] = useState(true)
const [currentPage, setCurrentPage] = useState(1)
const { toast } = useToast()
const { addToCart } = useCart()
const searchParams = useSearchParams()
const searchQuery = searchParams?.get("search") || ""
const [activeTab, setActiveTab] = useState("all")
const [sortBy, setSortBy] = useState<string>("default")
useEffect(() => {
const fetchBooks = async () => {
setLoading(true)
try {
let url = `book/all?pageNum=${currentPage}&pageSize=8`
// 如果有搜索查询,使用搜索接口
if (searchQuery) {
url = `book/search/title?title=${encodeURIComponent(searchQuery)}&pageNum=${currentPage}&pageSize=8`
} else if (activeTab === "new") {
// 获取最新图书(按出版日期排序)
url = `book/all?pageNum=${currentPage}&pageSize=8&sort=publishDate`
} else if (activeTab === "popular") {
// 这里假设后端有一个接口可以获取热门图书
url = `book/popular?pageNum=${currentPage}&pageSize=8`
}
const response = await fetchWithAuth(url)
const result = await response.json()
if (result.code === 0) {
// 根据排序选项处理数据
const sortedData = [...result.data.data]
if (sortBy === "priceAsc") {
sortedData.sort((a, b) => a.price - b.price)
} else if (sortBy === "priceDesc") {
sortedData.sort((a, b) => b.price - a.price)
} else if (sortBy === "dateDesc") {
sortedData.sort((a, b) => new Date(b.publishDate).getTime() - new Date(a.publishDate).getTime())
}
setBooks({
...result.data,
data: sortedData,
})
} else {
toast({
variant: "destructive",
title: "获取图书失败",
description: result.msg || "无法获取图书信息",
})
}
} catch (error) {
toast({
variant: "destructive",
title: "获取图书失败",
description: "服务器连接错误,请稍后再试",
})
} finally {
setLoading(false)
}
}
fetchBooks()
}, [currentPage, toast, searchQuery, activeTab, sortBy])
const handlePageChange = (page: number) => {
setCurrentPage(page)
window.scrollTo({ top: 0, behavior: "smooth" })
}
const handleAddToCart = (book: Book) => {
addToCart({
id: book.bookId,
title: book.title,
price: book.price,
quantity: 1,
coverImage: book.coverImage || "/placeholder.svg?height=100&width=80",
})
// 使用事件总线通知
notify({
title: "已添加到购物车",
message: book.title,
type: "success",
duration: 3000,
})
}
const handleTabChange = (value: string) => {
setActiveTab(value)
setCurrentPage(1) // 重置页码
}
const handleSortChange = (value: string) => {
setSortBy(value)
}
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">
<MainNav />
<div className="flex items-center gap-4">
<SearchBar />
<ShoppingCart />
<ThemeToggle />
<UserNav />
</div>
</div>
</header>
<main className="flex-1">
<div className="container py-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="flex flex-col space-y-6"
>
<div className="flex flex-col space-y-2">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold tracking-tight">
{searchQuery ? (
<div className="flex items-center">
<Search className="mr-2 h-5 w-5 text-primary" />
<span>: </span>
<Badge variant="outline" className="ml-2 text-base font-normal">
{searchQuery}
</Badge>
</div>
) : (
"图书列表"
)}
</h1>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-1.5">
<Filter className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() => handleSortChange("default")}
className="flex items-center cursor-pointer"
>
<span className={sortBy === "default" ? "font-medium text-primary" : ""}></span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleSortChange("priceAsc")}
className="flex items-center cursor-pointer"
>
<span className={sortBy === "priceAsc" ? "font-medium text-primary" : ""}></span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleSortChange("priceDesc")}
className="flex items-center cursor-pointer"
>
<span className={sortBy === "priceDesc" ? "font-medium text-primary" : ""}></span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleSortChange("dateDesc")}
className="flex items-center cursor-pointer"
>
<span className={sortBy === "dateDesc" ? "font-medium text-primary" : ""}></span>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
{searchQuery ? (
<p className="text-muted-foreground"> {books?.total || 0} </p>
) : (
<p className="text-muted-foreground"></p>
)}
</div>
{!searchQuery && (
<Tabs defaultValue="all" value={activeTab} onValueChange={handleTabChange} className="w-full">
<TabsList className="w-full justify-start">
<TabsTrigger value="all" className="gap-1.5">
<BookOpen className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="new" className="gap-1.5">
<BookOpenCheck className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="popular" className="gap-1.5">
<TrendingUp className="h-4 w-4" />
</TabsTrigger>
</TabsList>
<TabsContent value="all" className="pt-6">
{renderBookList()}
</TabsContent>
<TabsContent value="new" className="pt-6">
{renderBookList()}
</TabsContent>
<TabsContent value="popular" className="pt-6">
{renderBookList()}
</TabsContent>
</Tabs>
)}
{searchQuery && renderBookList()}
</motion.div>
</div>
</main>
<footer className="border-t py-6 md:py-0">
<div className="container flex flex-col items-center justify-between gap-4 md:h-24 md:flex-row">
<p className="text-center text-sm leading-loose text-muted-foreground md:text-left">
&copy; {new Date().getFullYear()} . .
</p>
</div>
</footer>
</div>
)
function renderBookList() {
if (loading) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6 py-6">
{Array.from({ length: 8 }).map((_, index) => (
<Card key={index} className="overflow-hidden loading-shimmer">
<div className="aspect-[3/4] bg-muted/30" />
<CardContent className="p-4">
<div className="h-4 bg-muted/30 rounded mb-2" />
<div className="h-4 bg-muted/30 rounded w-2/3" />
</CardContent>
<CardContent className="p-4 pt-0 flex justify-between">
<div className="h-4 bg-muted/30 rounded w-1/4" />
<div className="h-8 bg-muted/30 rounded w-1/3" />
</CardContent>
</Card>
))}
</div>
)
}
if (!books || books.data.length === 0) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5 }}
className="flex flex-col items-center justify-center py-12"
>
<BookOpen className="h-16 w-16 text-muted-foreground mb-4" />
<h2 className="text-xl font-semibold">{searchQuery ? "未找到相关图书" : "暂无图书"}</h2>
<p className="text-muted-foreground mt-2">{searchQuery ? "请尝试其他搜索词" : "请稍后再来查看"}</p>
{searchQuery && (
<Button className="mt-6" variant="outline" asChild>
<Link href="/books">
<Search className="mr-2 h-4 w-4" />
</Link>
</Button>
)}
</motion.div>
)
}
return (
<div className="space-y-6 py-6">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
{books.data.map((book, index) => (
<motion.div
key={book.bookId}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: index * 0.05 }}
>
<Card className="overflow-hidden book-card flex flex-col h-full">
<Link href={`/books/${book.bookId}`}>
<div className="aspect-[3/4] relative overflow-hidden book-cover">
<img
src={book.coverImage || "/placeholder.svg?height=400&width=300"}
alt={book.title}
className="object-cover w-full h-full"
/>
</div>
</Link>
<CardContent className="p-4 flex-grow book-info">
<Link href={`/books/${book.bookId}`}>
<h3 className="font-semibold hover:underline line-clamp-2 transition-colors hover:text-primary">
{book.title}
</h3>
</Link>
<p className="text-sm text-muted-foreground mt-1">{book.author.join(", ")}</p>
</CardContent>
<CardContent className="p-4 pt-0 flex justify-between items-center">
<div className="price-tag">¥{book.price.toFixed(2)}</div>
<Button
size="sm"
onClick={() => handleAddToCart(book)}
disabled={book.stock <= 0}
className="btn-hover-effect"
>
<ShoppingCartIcon className="h-4 w-4 mr-2" />
{book.stock > 0 ? "加入购物车" : "缺货"}
</Button>
</CardContent>
</Card>
</motion.div>
))}
</div>
{books.total > books.pageSize && (
<Pagination className="mt-8">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => {
e.preventDefault()
if (currentPage > 1) handlePageChange(currentPage - 1)
}}
className={currentPage === 1 ? "pointer-events-none opacity-50" : ""}
/>
</PaginationItem>
{Array.from({ length: Math.min(5, Math.ceil(books.total / books.pageSize)) }).map((_, index) => {
let pageNumber = currentPage - 2 + index
if (pageNumber < 1) pageNumber = index + 1
if (pageNumber > Math.ceil(books.total / books.pageSize))
pageNumber = Math.ceil(books.total / books.pageSize) - (4 - index)
return (
<PaginationItem key={index}>
<PaginationLink
href="#"
onClick={(e) => {
e.preventDefault()
handlePageChange(pageNumber)
}}
isActive={currentPage === pageNumber}
>
{pageNumber}
</PaginationLink>
</PaginationItem>
)
})}
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => {
e.preventDefault()
if (currentPage < Math.ceil(books.total / books.pageSize)) {
handlePageChange(currentPage + 1)
}
}}
className={
currentPage >= Math.ceil(books.total / books.pageSize) ? "pointer-events-none opacity-50" : ""
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
)
}
}