Files
juwan-frontend/app/(main)/search/page.tsx
T

772 lines
26 KiB
TypeScript

"use client"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import { IconInput } from "@/components/ui/icon-input"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet"
import { Switch } from "@/components/ui/switch"
import { listGames } from "@/lib/api"
import { searchCatalog } from "@/lib/api/search"
import { GameIcon } from "@/lib/game-icons"
import type { SearchResponse, SearchResultItem, SearchSort } from "@/lib/search/types"
import type { Game, Player } from "@/lib/types"
import { cn } from "@/lib/utils"
import {
CheckCircle2,
Clock,
Filter,
Gamepad2,
Search,
SlidersHorizontal,
Star,
Store,
User,
XCircle,
} from "lucide-react"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { Suspense, useCallback, useEffect, useState } from "react"
const SEARCH_SORTS: ReadonlySet<SearchSort> = new Set([
"composite",
"rating",
"orders",
"price_asc",
"price_desc",
])
const DEFAULT_LIMIT = 12
function numberParam(value: string | null, fallback: number) {
if (value === null) return fallback
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : fallback
}
function resetPagination(params: URLSearchParams) {
params.set("limit", String(DEFAULT_LIMIT))
params.set("offset", "0")
}
function StatusBadge({ status }: { status: Player["status"] }) {
switch (status) {
case "available":
return (
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200 gap-1">
<CheckCircle2 className="w-3 h-3" />
</Badge>
)
case "busy":
return (
<Badge variant="outline" className="bg-yellow-50 text-yellow-700 border-yellow-200 gap-1">
<Clock className="w-3 h-3" />
</Badge>
)
case "offline":
return (
<Badge variant="outline" className="bg-gray-50 text-gray-500 border-gray-200 gap-1">
<XCircle className="w-3 h-3" />
线
</Badge>
)
default:
return null
}
}
function PlayerCard({ player }: { player: Player }) {
const minPrice =
!player.services || player.services.length === 0
? 0
: Math.min(...player.services.map((s) => s.price))
const unit =
!player.services || player.services.length === 0
? "局"
: player.services.reduce((prev, curr) => (prev.price < curr.price ? prev : curr)).unit
return (
<Link href={`/player/${player.id}`} className="block h-full">
<Card className="h-full overflow-hidden flex flex-col p-0 gap-0">
<CardHeader className="p-4 pb-2 flex flex-row items-start justify-between space-y-0">
<div className="flex items-center gap-3">
<Avatar className="h-12 w-12 border-2 border-background shadow-sm">
<AvatarImage src={player.user.avatar} alt={player.user.nickname} />
<AvatarFallback>{player.user.nickname.slice(0, 2)}</AvatarFallback>
</Avatar>
<div>
<h3 className="font-semibold text-base leading-none mb-1">{player.user.nickname}</h3>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
{player.shopName ? (
<span className="flex items-center gap-0.5 text-blue-600 bg-blue-50 px-1.5 py-0.5 rounded-full">
<Store className="w-3 h-3" />
{player.shopName}
</span>
) : (
<span className="flex items-center gap-0.5 text-gray-600 bg-gray-100 px-1.5 py-0.5 rounded-full">
<User className="w-3 h-3" />
</span>
)}
</div>
</div>
</div>
<StatusBadge status={player.status} />
</CardHeader>
<CardContent className="p-4 pt-2 flex-grow">
<div className="flex items-center gap-4 mb-3 text-sm">
<div className="flex items-center gap-1 text-yellow-500 font-medium">
<Star className="w-4 h-4 fill-current" />
{player.rating.toFixed(1)}
</div>
<div className="text-muted-foreground"> {player.totalOrders}</div>
</div>
<div className="flex flex-wrap gap-1.5 mb-3">
{player.games.slice(0, 3).map((game) => (
<Badge key={game} variant="secondary" className="text-xs font-normal">
{game}
</Badge>
))}
{player.games.length > 3 && (
<Badge variant="secondary" className="text-xs font-normal">
+{player.games.length - 3}
</Badge>
)}
</div>
<div className="flex flex-wrap gap-1">
{player.tags.slice(0, 2).map((tag) => (
<span
key={tag}
className="text-[10px] px-1.5 py-0.5 bg-muted rounded-full text-muted-foreground"
>
{tag}
</span>
))}
</div>
</CardContent>
<Separator />
<CardFooter className="p-3 bg-muted/20 flex items-center justify-between">
<div className="flex items-baseline gap-1">
<span className="text-xs text-muted-foreground"></span>
<span className="text-lg font-bold text-primary">¥{minPrice}</span>
<span className="text-xs text-muted-foreground">/{unit}</span>
</div>
<Button size="sm" variant="ghost" className="h-8 px-3 text-xs rounded-full">
</Button>
</CardFooter>
</Card>
</Link>
)
}
type ShopSearchItem = Extract<SearchResultItem, { type: "shop" }>
function ShopCard({ item }: { item: ShopSearchItem }) {
return (
<Link href={`/shop/${item.shop.id}`} className="block h-full">
<Card className="h-full overflow-hidden flex flex-col p-0 gap-0">
<CardHeader className="p-4 pb-2 flex flex-row items-start justify-between space-y-0">
<div className="flex items-center gap-3">
<Avatar className="h-12 w-12 border-2 border-background shadow-sm">
<AvatarImage src={item.shop.owner.avatar} alt={item.shop.name} />
<AvatarFallback>{item.shop.name.slice(0, 2)}</AvatarFallback>
</Avatar>
<div>
<h3 className="font-semibold text-base leading-none mb-1">{item.shop.name}</h3>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<span className="flex items-center gap-0.5 text-blue-600 bg-blue-50 px-1.5 py-0.5 rounded-full">
<Store className="w-3 h-3" />
</span>
</div>
</div>
</div>
<StatusBadge status={item.hasAvailable ? "available" : "busy"} />
</CardHeader>
<CardContent className="p-4 pt-2 flex-grow">
<div className="flex items-center gap-4 mb-3 text-sm">
<div className="flex items-center gap-1 text-yellow-500 font-medium">
<Star className="w-4 h-4 fill-current" />
{item.shop.rating.toFixed(1)}
</div>
<div className="text-muted-foreground"> {item.shop.totalOrders}</div>
</div>
<div className="flex flex-wrap gap-1.5 mb-3">
{item.games.slice(0, 3).map((game) => (
<Badge key={game} variant="secondary" className="text-xs font-normal">
{game}
</Badge>
))}
{item.games.length > 3 && (
<Badge variant="secondary" className="text-xs font-normal">
+{item.games.length - 3}
</Badge>
)}
</div>
</CardContent>
<Separator />
<CardFooter className="p-3 bg-muted/20 flex items-center justify-between">
<div className="flex items-baseline gap-1">
<span className="text-xs text-muted-foreground"></span>
<span className="text-lg font-bold text-primary">¥{item.minPrice}</span>
<span className="text-xs text-muted-foreground">/{item.unit}</span>
</div>
<Button size="sm" variant="ghost" className="h-8 px-3 text-xs rounded-full">
</Button>
</CardFooter>
</Card>
</Link>
)
}
interface FilterProps {
games: Game[]
selectedGames: string[]
onGameChange: (game: string, checked: boolean) => void
priceRange: { min: string; max: string }
onPriceChange: (type: "min" | "max", value: string) => void
onlyOnline: boolean
onOnlineChange: (checked: boolean) => void
minRating: string
onRatingChange: (value: string) => void
className?: string
}
function FilterSection({
games,
selectedGames,
onGameChange,
priceRange,
onPriceChange,
onlyOnline,
onOnlineChange,
minRating,
onRatingChange,
className,
}: FilterProps) {
return (
<div className={cn("space-y-6", className)}>
<div className="space-y-3">
<h3 className="font-medium text-sm flex items-center gap-2">
<Gamepad2 className="w-4 h-4" />
</h3>
<div className="space-y-2">
{games.map((game) => (
<div key={game.id} className="flex items-center space-x-2">
<Checkbox
id={`game-${game.id}`}
checked={selectedGames.includes(game.name)}
onCheckedChange={(checked) => onGameChange(game.name, checked as boolean)}
/>
<Label
htmlFor={`game-${game.id}`}
className="text-sm font-normal cursor-pointer flex items-center gap-2"
>
<GameIcon name={game.icon} className="h-4 w-4" />
{game.name}
</Label>
</div>
))}
</div>
</div>
<Separator />
<div className="space-y-3">
<h3 className="font-medium text-sm flex items-center gap-2">
()
{(priceRange.min || priceRange.max) && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 font-normal">
¥{priceRange.min || "-"} - ¥{priceRange.max || "-"}
</Badge>
)}
</h3>
<div className="flex items-center gap-2">
<Input
type="number"
placeholder="最低"
className="h-8 text-sm"
value={priceRange.min}
onChange={(e) => onPriceChange("min", e.target.value)}
min={0}
/>
<span className="text-muted-foreground">-</span>
<Input
type="number"
placeholder="最高"
className="h-8 text-sm"
value={priceRange.max}
onChange={(e) => onPriceChange("max", e.target.value)}
min={0}
/>
</div>
</div>
<Separator />
<div className="flex items-center justify-between">
<Label htmlFor="online-mode" className="font-medium text-sm cursor-pointer">
线
</Label>
<Switch id="online-mode" checked={onlyOnline} onCheckedChange={onOnlineChange} />
</div>
<Separator />
<div className="space-y-3">
<h3 className="font-medium text-sm flex items-center gap-2">
<Star className="w-4 h-4" />
</h3>
<Select value={minRating} onValueChange={onRatingChange}>
<SelectTrigger className="h-9 rounded-lg">
<SelectValue placeholder="选择评分" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0"></SelectItem>
<SelectItem value="4.0">4.0</SelectItem>
<SelectItem value="4.5">4.5</SelectItem>
<SelectItem value="4.8">4.8</SelectItem>
<SelectItem value="5.0">5.0</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)
}
function SearchPageContent() {
const searchParams = useSearchParams()
const router = useRouter()
const games = listGames()
const [searchQuery, setSearchQuery] = useState(searchParams.get("q") || "")
const [selectedGames, setSelectedGames] = useState<string[]>(() => {
const selected = searchParams.getAll("game")
return [...new Set(selected)]
})
const [priceRange, setPriceRange] = useState<{ min: string; max: string }>({
min: searchParams.get("min") || "",
max: searchParams.get("max") || "",
})
const [onlyOnline, setOnlyOnline] = useState((searchParams.get("online") ?? "0") === "1")
const [minRating, setMinRating] = useState(searchParams.get("minRating") ?? "0")
const [sortBy, setSortBy] = useState<SearchSort>(() => {
const sortParam = (searchParams.get("sort") ?? "composite") as SearchSort
return SEARCH_SORTS.has(sortParam) ? sortParam : "composite"
})
const [response, setResponse] = useState<SearchResponse | null>(null)
const [hasLoaded, setHasLoaded] = useState(false)
const [error, setError] = useState<string | null>(null)
const replaceUrl = useCallback(
(updater: (params: URLSearchParams) => void) => {
const params = new URLSearchParams(searchParams.toString())
updater(params)
router.replace(`/search?${params.toString()}`, { scroll: false })
},
[router, searchParams],
)
useEffect(() => {
const timer = setTimeout(() => {
const urlQ = searchParams.get("q") ?? ""
if (searchQuery === urlQ) return
replaceUrl((params) => {
if (searchQuery) params.set("q", searchQuery)
else params.delete("q")
resetPagination(params)
})
}, 500)
return () => clearTimeout(timer)
}, [replaceUrl, searchParams, searchQuery])
useEffect(() => {
const controller = new AbortController()
const q = searchParams.get("q") || undefined
const selected = searchParams.getAll("game")
const min = searchParams.get("min") || undefined
const max = searchParams.get("max") || undefined
const onlyOnlineParam = (searchParams.get("online") ?? "0") === "1"
const minRatingParam = searchParams.get("minRating") ?? "0"
const sortParam = (searchParams.get("sort") ?? "composite") as SearchSort
const sort: SearchSort = SEARCH_SORTS.has(sortParam) ? sortParam : "composite"
const limit = numberParam(searchParams.get("limit"), 12)
const offset = numberParam(searchParams.get("offset"), 0)
searchCatalog({
q,
selectedGames: selected,
min,
max,
onlyOnline: onlyOnlineParam,
minRating: minRatingParam,
sort,
limit,
offset,
signal: controller.signal,
})
.then((res) => {
if (controller.signal.aborted) return
setResponse(res)
setHasLoaded(true)
setError(null)
})
.catch((err: unknown) => {
if (controller.signal.aborted) return
setError(err instanceof Error ? err.message : "Search request failed")
setHasLoaded(true)
})
return () => controller.abort()
}, [searchParams])
const handleGameChange = (game: string, checked: boolean) => {
setSelectedGames((prev) => {
const next = checked ? [...new Set([...prev, game])] : prev.filter((g) => g !== game)
replaceUrl((params) => {
params.delete("game")
for (const g of next) params.append("game", g)
resetPagination(params)
})
return next
})
}
const handlePriceChange = (type: "min" | "max", value: string) => {
setPriceRange((prev) => {
const next = { ...prev, [type]: value }
replaceUrl((params) => {
if (next.min) params.set("min", next.min)
else params.delete("min")
if (next.max) params.set("max", next.max)
else params.delete("max")
resetPagination(params)
})
return next
})
}
const handleOnlineChange = (checked: boolean) => {
setOnlyOnline(checked)
replaceUrl((params) => {
if (checked) params.set("online", "1")
else params.delete("online")
resetPagination(params)
})
}
const handleRatingChange = (value: string) => {
setMinRating(value)
replaceUrl((params) => {
if (value && value !== "0") params.set("minRating", value)
else params.delete("minRating")
resetPagination(params)
})
}
const setSortAndSync = (next: SearchSort) => {
setSortBy(next)
replaceUrl((params) => {
if (next === "composite") params.delete("sort")
else params.set("sort", next)
resetPagination(params)
})
}
const resetFilters = () => {
setSelectedGames([])
setPriceRange({ min: "", max: "" })
setOnlyOnline(false)
setMinRating("0")
replaceUrl((params) => {
params.delete("game")
params.delete("min")
params.delete("max")
params.delete("online")
params.delete("minRating")
resetPagination(params)
})
}
const clearAllFilters = () => {
setSearchQuery("")
setSelectedGames([])
setPriceRange({ min: "", max: "" })
setOnlyOnline(false)
setMinRating("0")
replaceUrl((params) => {
params.delete("q")
params.delete("game")
params.delete("min")
params.delete("max")
params.delete("online")
params.delete("minRating")
resetPagination(params)
})
}
const items = response?.items ?? []
const total = response?.meta.total ?? 0
const canLoadMore = response ? response.items.length < response.meta.total : false
return (
<div className="container mx-auto px-4 py-6 min-h-screen">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
<div>
<h1 className="text-3xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground mt-1"></p>
</div>
<div className="flex items-center gap-2 w-full md:w-auto">
<div className="flex-1 md:w-80">
<IconInput
inputSize="lg"
icon={<Search />}
type="search"
placeholder="搜索陪玩、游戏、标签..."
className="border-border bg-card shadow-[var(--shadow-card)] transition-shadow focus:shadow-[var(--shadow-card-hover)]"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<Sheet>
<SheetTrigger asChild>
<Button variant="outline" size="icon" className="md:hidden shrink-0 rounded-full">
<Filter className="h-4 w-4" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-[300px] sm:w-[400px] overflow-y-auto">
<SheetHeader>
<SheetTitle></SheetTitle>
<SheetDescription></SheetDescription>
</SheetHeader>
<div className="py-6">
<FilterSection
games={games}
selectedGames={selectedGames}
onGameChange={handleGameChange}
priceRange={priceRange}
onPriceChange={handlePriceChange}
onlyOnline={onlyOnline}
onOnlineChange={handleOnlineChange}
minRating={minRating}
onRatingChange={handleRatingChange}
/>
</div>
<SheetFooter>
<SheetClose asChild>
<Button className="w-full rounded-full"> {total} </Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
</div>
</div>
<div className="flex flex-col md:flex-row gap-8">
<aside className="hidden md:block w-64 shrink-0 space-y-6">
<Card className="sticky top-24">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<h3 className="font-semibold"></h3>
{(selectedGames.length > 0 ||
priceRange.min ||
priceRange.max ||
onlyOnline ||
minRating !== "0") && (
<Button
variant="ghost"
size="sm"
className="h-auto p-0 text-xs text-muted-foreground hover:text-primary"
onClick={() => {
resetFilters()
}}
>
</Button>
)}
</div>
</CardHeader>
<CardContent>
<FilterSection
games={games}
selectedGames={selectedGames}
onGameChange={handleGameChange}
priceRange={priceRange}
onPriceChange={handlePriceChange}
onlyOnline={onlyOnline}
onOnlineChange={handleOnlineChange}
minRating={minRating}
onRatingChange={handleRatingChange}
/>
</CardContent>
</Card>
</aside>
<main className="flex-1">
<div className="flex items-center justify-between mb-6 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 sticky top-0 z-10 py-2 md:static md:bg-transparent md:p-0">
<div className="flex items-center gap-2 overflow-x-auto pb-2 md:pb-0 no-scrollbar">
<Button
variant={sortBy === "composite" ? "default" : "ghost"}
size="sm"
onClick={() => setSortAndSync("composite")}
className="whitespace-nowrap rounded-full"
>
</Button>
<Button
variant={sortBy === "rating" ? "default" : "ghost"}
size="sm"
onClick={() => setSortAndSync("rating")}
className="whitespace-nowrap rounded-full"
>
</Button>
<Button
variant={sortBy === "orders" ? "default" : "ghost"}
size="sm"
onClick={() => setSortAndSync("orders")}
className="whitespace-nowrap rounded-full"
>
</Button>
<Button
variant={sortBy.startsWith("price") ? "default" : "ghost"}
size="sm"
onClick={() => setSortAndSync(sortBy === "price_asc" ? "price_desc" : "price_asc")}
className="whitespace-nowrap gap-1 rounded-full"
>
<SlidersHorizontal className="w-3 h-3" />
</Button>
</div>
<div className="text-sm text-muted-foreground hidden sm:block">
{total}
</div>
</div>
{!hasLoaded ? (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
) : items.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{items.map((result) => (
<div
key={
result.type === "player"
? `player-${result.player.id}`
: `shop-${result.shop.id}`
}
>
{result.type === "player" ? (
<PlayerCard player={result.player} />
) : (
<ShopCard item={result} />
)}
</div>
))}
</div>
) : (
<div className="text-center py-20 bg-muted/30 rounded-lg border border-dashed">
<div className="mx-auto w-12 h-12 rounded-full bg-muted flex items-center justify-center mb-4">
<Search className="w-6 h-6 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium"></h3>
<p className="text-muted-foreground mt-1 max-w-sm mx-auto">
{error ? "请求失败,请稍后重试" : "尝试调整筛选条件或更换搜索关键词"}
</p>
<Button
variant="outline"
className="mt-4 rounded-full"
onClick={() => {
clearAllFilters()
}}
>
</Button>
</div>
)}
{canLoadMore && (
<div className="mt-8 text-center">
<Button
variant="outline"
size="lg"
className="rounded-full px-8"
onClick={() => {
const currentLimit = numberParam(searchParams.get("limit"), DEFAULT_LIMIT)
replaceUrl((params) => {
params.set("limit", String(currentLimit + DEFAULT_LIMIT))
params.set("offset", "0")
})
}}
>
</Button>
</div>
)}
</main>
</div>
</div>
)
}
export default function SearchPage() {
return (
<Suspense
fallback={
<div className="container mx-auto px-4 py-6 flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
}
>
<SearchPageContent />
</Suspense>
)
}