741 lines
26 KiB
TypeScript
741 lines
26 KiB
TypeScript
"use client"
|
|
|
|
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, useEffect, useMemo, useState } from "react"
|
|
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 { 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, listPlayers, listServices, listShops } from "@/lib/api"
|
|
import { GameIcon } from "@/lib/game-icons"
|
|
import type { Game, Player, Shop } from "@/lib/types"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
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 hover:shadow-md transition-shadow duration-200 overflow-hidden flex flex-col">
|
|
<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-white 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">
|
|
<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">
|
|
<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 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">
|
|
查看详情
|
|
</Button>
|
|
</CardFooter>
|
|
</Card>
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
interface ShopResultItem {
|
|
shop: Shop
|
|
minPrice: number
|
|
unit: string
|
|
games: string[]
|
|
hasAvailable: boolean
|
|
}
|
|
|
|
function ShopCard({ item }: { item: ShopResultItem }) {
|
|
return (
|
|
<Link href={`/shop/${item.shop.id}`} className="block h-full">
|
|
<Card className="h-full hover:shadow-md transition-shadow duration-200 overflow-hidden flex flex-col">
|
|
<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-white 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">
|
|
<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">
|
|
查看详情
|
|
</Button>
|
|
</CardFooter>
|
|
</Card>
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
type SearchResult =
|
|
| {
|
|
type: "player"
|
|
id: string
|
|
rating: number
|
|
orders: number
|
|
minPrice: number
|
|
item: Player
|
|
}
|
|
| {
|
|
type: "shop"
|
|
id: string
|
|
rating: number
|
|
orders: number
|
|
minPrice: number
|
|
item: ShopResultItem
|
|
}
|
|
|
|
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">价格区间 (元)</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">
|
|
<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 players = listPlayers()
|
|
const services = listServices()
|
|
const shops = listShops()
|
|
|
|
const [searchQuery, setSearchQuery] = useState(searchParams.get("q") || "")
|
|
const [selectedGames, setSelectedGames] = useState<string[]>(() => {
|
|
const game = searchParams.get("game")
|
|
return game ? [game] : []
|
|
})
|
|
const [priceRange, setPriceRange] = useState<{ min: string; max: string }>({ min: "", max: "" })
|
|
const [onlyOnline, setOnlyOnline] = useState(false)
|
|
const [minRating, setMinRating] = useState("0")
|
|
const [sortBy, setSortBy] = useState("composite")
|
|
const [visibleCount, setVisibleCount] = useState(12)
|
|
|
|
useEffect(() => {
|
|
const q = searchParams.get("q")
|
|
if (q !== null && q !== searchQuery) {
|
|
setSearchQuery(q)
|
|
}
|
|
}, [searchParams, searchQuery])
|
|
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => {
|
|
const params = new URLSearchParams(searchParams.toString())
|
|
if (searchQuery) {
|
|
params.set("q", searchQuery)
|
|
} else {
|
|
params.delete("q")
|
|
}
|
|
router.replace(`/search?${params.toString()}`, { scroll: false })
|
|
}, 500)
|
|
return () => clearTimeout(timer)
|
|
}, [searchQuery, router, searchParams])
|
|
|
|
const handleGameChange = (game: string, checked: boolean) => {
|
|
setSelectedGames((prev) => (checked ? [...prev, game] : prev.filter((g) => g !== game)))
|
|
}
|
|
|
|
const handlePriceChange = (type: "min" | "max", value: string) => {
|
|
setPriceRange((prev) => ({ ...prev, [type]: value }))
|
|
}
|
|
|
|
const shopResultItems = useMemo<ShopResultItem[]>(() => {
|
|
return shops.map((shop) => {
|
|
const shopPlayers = players.filter((player) => player.shopId === shop.id)
|
|
const playerIds = new Set(shopPlayers.map((player) => player.id))
|
|
const shopServices = services.filter((service) => playerIds.has(service.playerId))
|
|
const minPrice =
|
|
shopServices.length > 0 ? Math.min(...shopServices.map((service) => service.price)) : 0
|
|
const unit =
|
|
shopServices.length > 0
|
|
? shopServices.reduce((prev, curr) => (prev.price < curr.price ? prev : curr)).unit
|
|
: "局"
|
|
const shopGames = [...new Set(shopServices.map((service) => service.gameName))]
|
|
const hasAvailable = shopPlayers.some((player) => player.status === "available")
|
|
return {
|
|
shop,
|
|
minPrice,
|
|
unit,
|
|
games: shopGames,
|
|
hasAvailable,
|
|
}
|
|
})
|
|
}, [players, services, shops])
|
|
|
|
const filteredPlayers = useMemo(() => {
|
|
return players.filter((player) => {
|
|
if (searchQuery) {
|
|
const query = searchQuery.toLowerCase()
|
|
const matchName = player.user.nickname.toLowerCase().includes(query)
|
|
const matchTags = player.tags.some((tag) => tag.toLowerCase().includes(query))
|
|
const matchGames = player.games.some((game) => game.toLowerCase().includes(query))
|
|
if (!matchName && !matchTags && !matchGames) return false
|
|
}
|
|
|
|
if (selectedGames.length > 0) {
|
|
const hasGame = player.games.some((game) => selectedGames.includes(game))
|
|
if (!hasGame) return false
|
|
}
|
|
|
|
const minP = priceRange.min ? Number(priceRange.min) : 0
|
|
const maxP = priceRange.max ? Number(priceRange.max) : Infinity
|
|
const playerMinPrice = Math.min(...player.services.map((s) => s.price))
|
|
|
|
if (playerMinPrice < minP) return false
|
|
if (priceRange.max && playerMinPrice > maxP) return false
|
|
|
|
if (onlyOnline && player.status !== "available") return false
|
|
|
|
if (player.rating < Number(minRating)) return false
|
|
|
|
return true
|
|
})
|
|
}, [minRating, onlyOnline, players, priceRange, searchQuery, selectedGames])
|
|
|
|
const filteredShops = useMemo(() => {
|
|
return shopResultItems.filter((item) => {
|
|
if (searchQuery) {
|
|
const query = searchQuery.toLowerCase()
|
|
const matchName = item.shop.name.toLowerCase().includes(query)
|
|
const matchDescription = item.shop.description.toLowerCase().includes(query)
|
|
const matchGames = item.games.some((game) => game.toLowerCase().includes(query))
|
|
if (!matchName && !matchDescription && !matchGames) return false
|
|
}
|
|
|
|
if (selectedGames.length > 0) {
|
|
const hasGame = item.games.some((game) => selectedGames.includes(game))
|
|
if (!hasGame) return false
|
|
}
|
|
|
|
const minP = priceRange.min ? Number(priceRange.min) : 0
|
|
const maxP = priceRange.max ? Number(priceRange.max) : Infinity
|
|
if (item.minPrice < minP) return false
|
|
if (priceRange.max && item.minPrice > maxP) return false
|
|
|
|
if (onlyOnline && !item.hasAvailable) return false
|
|
if (item.shop.rating < Number(minRating)) return false
|
|
|
|
return true
|
|
})
|
|
}, [shopResultItems, searchQuery, selectedGames, priceRange, onlyOnline, minRating])
|
|
|
|
const sortedResults = useMemo<SearchResult[]>(() => {
|
|
const playerResults: SearchResult[] = filteredPlayers.map((player) => ({
|
|
type: "player",
|
|
id: player.id,
|
|
rating: player.rating,
|
|
orders: player.totalOrders,
|
|
minPrice: Math.min(...player.services.map((service) => service.price)),
|
|
item: player,
|
|
}))
|
|
const shopResults: SearchResult[] = filteredShops.map((item) => ({
|
|
type: "shop",
|
|
id: item.shop.id,
|
|
rating: item.shop.rating,
|
|
orders: item.shop.totalOrders,
|
|
minPrice: item.minPrice,
|
|
item,
|
|
}))
|
|
|
|
return [...playerResults, ...shopResults].sort((a, b) => {
|
|
switch (sortBy) {
|
|
case "rating":
|
|
return b.rating - a.rating
|
|
case "price_asc":
|
|
return a.minPrice - b.minPrice
|
|
case "price_desc":
|
|
return b.minPrice - a.minPrice
|
|
case "orders":
|
|
return b.orders - a.orders
|
|
case "composite": {
|
|
const scoreA = a.rating * Math.log10(a.orders + 1)
|
|
const scoreB = b.rating * Math.log10(b.orders + 1)
|
|
return scoreB - scoreA
|
|
}
|
|
default:
|
|
return 0
|
|
}
|
|
})
|
|
}, [filteredPlayers, filteredShops, sortBy])
|
|
|
|
const displayedResults = sortedResults.slice(0, visibleCount)
|
|
|
|
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="relative flex-1 md:w-80">
|
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
type="search"
|
|
placeholder="搜索陪玩、游戏、标签..."
|
|
className="pl-9"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<Sheet>
|
|
<SheetTrigger asChild>
|
|
<Button variant="outline" size="icon" className="md:hidden shrink-0">
|
|
<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={setOnlyOnline}
|
|
minRating={minRating}
|
|
onRatingChange={setMinRating}
|
|
/>
|
|
</div>
|
|
<SheetFooter>
|
|
<SheetClose asChild>
|
|
<Button className="w-full">查看 {sortedResults.length} 个结果</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={() => {
|
|
setSelectedGames([])
|
|
setPriceRange({ min: "", max: "" })
|
|
setOnlyOnline(false)
|
|
setMinRating("0")
|
|
}}
|
|
>
|
|
重置
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<FilterSection
|
|
games={games}
|
|
selectedGames={selectedGames}
|
|
onGameChange={handleGameChange}
|
|
priceRange={priceRange}
|
|
onPriceChange={handlePriceChange}
|
|
onlyOnline={onlyOnline}
|
|
onOnlineChange={setOnlyOnline}
|
|
minRating={minRating}
|
|
onRatingChange={setMinRating}
|
|
/>
|
|
</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" ? "secondary" : "ghost"}
|
|
size="sm"
|
|
onClick={() => setSortBy("composite")}
|
|
className="whitespace-nowrap"
|
|
>
|
|
综合排序
|
|
</Button>
|
|
<Button
|
|
variant={sortBy === "rating" ? "secondary" : "ghost"}
|
|
size="sm"
|
|
onClick={() => setSortBy("rating")}
|
|
className="whitespace-nowrap"
|
|
>
|
|
评分最高
|
|
</Button>
|
|
<Button
|
|
variant={sortBy === "orders" ? "secondary" : "ghost"}
|
|
size="sm"
|
|
onClick={() => setSortBy("orders")}
|
|
className="whitespace-nowrap"
|
|
>
|
|
接单最多
|
|
</Button>
|
|
<Button
|
|
variant={sortBy.startsWith("price") ? "secondary" : "ghost"}
|
|
size="sm"
|
|
onClick={() => setSortBy(sortBy === "price_asc" ? "price_desc" : "price_asc")}
|
|
className="whitespace-nowrap gap-1"
|
|
>
|
|
价格
|
|
<SlidersHorizontal className="w-3 h-3" />
|
|
</Button>
|
|
</div>
|
|
<div className="text-sm text-muted-foreground hidden sm:block">
|
|
共找到 {sortedResults.length} 位陪玩
|
|
</div>
|
|
</div>
|
|
|
|
{displayedResults.length > 0 ? (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
{displayedResults.map((result) => (
|
|
<div key={`${result.type}-${result.id}`}>
|
|
{result.type === "player" ? (
|
|
<PlayerCard player={result.item} />
|
|
) : (
|
|
<ShopCard item={result.item} />
|
|
)}
|
|
</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">
|
|
尝试调整筛选条件或更换搜索关键词
|
|
</p>
|
|
<Button
|
|
variant="outline"
|
|
className="mt-4"
|
|
onClick={() => {
|
|
setSearchQuery("")
|
|
setSelectedGames([])
|
|
setPriceRange({ min: "", max: "" })
|
|
setOnlyOnline(false)
|
|
setMinRating("0")
|
|
}}
|
|
>
|
|
清除所有筛选
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{sortedResults.length > visibleCount && (
|
|
<div className="mt-8 text-center">
|
|
<Button
|
|
variant="outline"
|
|
size="lg"
|
|
onClick={() => setVisibleCount((prev) => prev + 12)}
|
|
>
|
|
加载更多
|
|
</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>
|
|
)
|
|
}
|