feat(search): add api-backed filtering and sorting
This commit is contained in:
+215
-190
@@ -14,7 +14,7 @@ import {
|
||||
} from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { Suspense, useEffect, useMemo, useState, useDeferredValue } from "react"
|
||||
import { Suspense, useCallback, useEffect, useState } from "react"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -41,11 +41,34 @@ import {
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { listGames, listPlayers, listServices, listShops } from "@/lib/api"
|
||||
import { listGames } from "@/lib/api"
|
||||
import { searchCatalog } from "@/lib/api/search"
|
||||
import { GameIcon } from "@/lib/game-icons"
|
||||
import type { Game, Player, Shop } from "@/lib/types"
|
||||
import type { SearchResponse, SearchResultItem, SearchSort } from "@/lib/search/types"
|
||||
import type { Game, Player } from "@/lib/types"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
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":
|
||||
@@ -165,15 +188,9 @@ function PlayerCard({ player }: { player: Player }) {
|
||||
)
|
||||
}
|
||||
|
||||
interface ShopResultItem {
|
||||
shop: Shop
|
||||
minPrice: number
|
||||
unit: string
|
||||
games: string[]
|
||||
hasAvailable: boolean
|
||||
}
|
||||
type ShopSearchItem = Extract<SearchResultItem, { type: "shop" }>
|
||||
|
||||
function ShopCard({ item }: { item: ShopResultItem }) {
|
||||
function ShopCard({ item }: { item: ShopSearchItem }) {
|
||||
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">
|
||||
@@ -236,24 +253,6 @@ function ShopCard({ item }: { item: ShopResultItem }) {
|
||||
)
|
||||
}
|
||||
|
||||
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[]
|
||||
@@ -375,166 +374,183 @@ 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 selected = searchParams.getAll("game")
|
||||
return [...new Set(selected)]
|
||||
})
|
||||
const [priceRange, setPriceRange] = useState<{ min: string; max: string }>({
|
||||
min: "",
|
||||
max: "",
|
||||
min: searchParams.get("min") || "",
|
||||
max: searchParams.get("max") || "",
|
||||
})
|
||||
const deferredPriceRange = useDeferredValue(priceRange)
|
||||
const [onlyOnline, setOnlyOnline] = useState(false)
|
||||
const [minRating, setMinRating] = useState("0")
|
||||
const [sortBy, setSortBy] = useState("composite")
|
||||
const [visibleCount, setVisibleCount] = useState(12)
|
||||
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 params = new URLSearchParams(searchParams.toString())
|
||||
if (searchQuery) {
|
||||
params.set("q", searchQuery)
|
||||
} else {
|
||||
params.delete("q")
|
||||
}
|
||||
router.replace(`/search?${params.toString()}`, { scroll: false })
|
||||
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)
|
||||
}, [searchQuery, router, searchParams])
|
||||
}, [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) => (checked ? [...prev, game] : prev.filter((g) => g !== game)))
|
||||
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) => ({ ...prev, [type]: value }))
|
||||
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 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,
|
||||
}
|
||||
const handleOnlineChange = (checked: boolean) => {
|
||||
setOnlyOnline(checked)
|
||||
replaceUrl((params) => {
|
||||
if (checked) params.set("online", "1")
|
||||
else params.delete("online")
|
||||
resetPagination(params)
|
||||
})
|
||||
}, [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 = deferredPriceRange.min ? Number(deferredPriceRange.min) : 0
|
||||
const maxP = deferredPriceRange.max ? Number(deferredPriceRange.max) : Infinity
|
||||
const playerMinPrice = Math.min(...player.services.map((s) => s.price))
|
||||
|
||||
if (playerMinPrice < minP) return false
|
||||
if (deferredPriceRange.max && playerMinPrice > maxP) return false
|
||||
|
||||
if (onlyOnline && player.status !== "available") return false
|
||||
|
||||
if (player.rating < Number(minRating)) return false
|
||||
|
||||
return true
|
||||
const handleRatingChange = (value: string) => {
|
||||
setMinRating(value)
|
||||
replaceUrl((params) => {
|
||||
if (value && value !== "0") params.set("minRating", value)
|
||||
else params.delete("minRating")
|
||||
resetPagination(params)
|
||||
})
|
||||
}, [minRating, onlyOnline, players, deferredPriceRange, 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 = deferredPriceRange.min ? Number(deferredPriceRange.min) : 0
|
||||
const maxP = deferredPriceRange.max ? Number(deferredPriceRange.max) : Infinity
|
||||
if (item.minPrice < minP) return false
|
||||
if (deferredPriceRange.max && item.minPrice > maxP) return false
|
||||
|
||||
if (onlyOnline && !item.hasAvailable) return false
|
||||
if (item.shop.rating < Number(minRating)) return false
|
||||
|
||||
return true
|
||||
const setSortAndSync = (next: SearchSort) => {
|
||||
setSortBy(next)
|
||||
replaceUrl((params) => {
|
||||
if (next === "composite") params.delete("sort")
|
||||
else params.set("sort", next)
|
||||
resetPagination(params)
|
||||
})
|
||||
}, [shopResultItems, searchQuery, selectedGames, deferredPriceRange, 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
|
||||
}
|
||||
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)
|
||||
})
|
||||
}, [filteredPlayers, filteredShops, sortBy])
|
||||
}
|
||||
|
||||
const displayedResults = sortedResults.slice(0, visibleCount)
|
||||
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">
|
||||
@@ -575,14 +591,14 @@ function SearchPageContent() {
|
||||
priceRange={priceRange}
|
||||
onPriceChange={handlePriceChange}
|
||||
onlyOnline={onlyOnline}
|
||||
onOnlineChange={setOnlyOnline}
|
||||
onOnlineChange={handleOnlineChange}
|
||||
minRating={minRating}
|
||||
onRatingChange={setMinRating}
|
||||
onRatingChange={handleRatingChange}
|
||||
/>
|
||||
</div>
|
||||
<SheetFooter>
|
||||
<SheetClose asChild>
|
||||
<Button className="w-full">查看 {sortedResults.length} 个结果</Button>
|
||||
<Button className="w-full">查看 {total} 个结果</Button>
|
||||
</SheetClose>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
@@ -606,10 +622,7 @@ function SearchPageContent() {
|
||||
size="sm"
|
||||
className="h-auto p-0 text-xs text-muted-foreground hover:text-primary"
|
||||
onClick={() => {
|
||||
setSelectedGames([])
|
||||
setPriceRange({ min: "", max: "" })
|
||||
setOnlyOnline(false)
|
||||
setMinRating("0")
|
||||
resetFilters()
|
||||
}}
|
||||
>
|
||||
重置
|
||||
@@ -625,9 +638,9 @@ function SearchPageContent() {
|
||||
priceRange={priceRange}
|
||||
onPriceChange={handlePriceChange}
|
||||
onlyOnline={onlyOnline}
|
||||
onOnlineChange={setOnlyOnline}
|
||||
onOnlineChange={handleOnlineChange}
|
||||
minRating={minRating}
|
||||
onRatingChange={setMinRating}
|
||||
onRatingChange={handleRatingChange}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -639,7 +652,7 @@ function SearchPageContent() {
|
||||
<Button
|
||||
variant={sortBy === "composite" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setSortBy("composite")}
|
||||
onClick={() => setSortAndSync("composite")}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
综合排序
|
||||
@@ -647,7 +660,7 @@ function SearchPageContent() {
|
||||
<Button
|
||||
variant={sortBy === "rating" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setSortBy("rating")}
|
||||
onClick={() => setSortAndSync("rating")}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
评分最高
|
||||
@@ -655,7 +668,7 @@ function SearchPageContent() {
|
||||
<Button
|
||||
variant={sortBy === "orders" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setSortBy("orders")}
|
||||
onClick={() => setSortAndSync("orders")}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
接单最多
|
||||
@@ -663,7 +676,7 @@ function SearchPageContent() {
|
||||
<Button
|
||||
variant={sortBy.startsWith("price") ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setSortBy(sortBy === "price_asc" ? "price_desc" : "price_asc")}
|
||||
onClick={() => setSortAndSync(sortBy === "price_asc" ? "price_desc" : "price_asc")}
|
||||
className="whitespace-nowrap gap-1"
|
||||
>
|
||||
价格
|
||||
@@ -671,18 +684,28 @@ function SearchPageContent() {
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground hidden sm:block">
|
||||
共找到 {sortedResults.length} 位陪玩
|
||||
共找到 {total} 位陪玩
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{displayedResults.length > 0 ? (
|
||||
{!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">
|
||||
{displayedResults.map((result) => (
|
||||
<div key={`${result.type}-${result.id}`}>
|
||||
{items.map((result) => (
|
||||
<div
|
||||
key={
|
||||
result.type === "player"
|
||||
? `player-${result.player.id}`
|
||||
: `shop-${result.shop.id}`
|
||||
}
|
||||
>
|
||||
{result.type === "player" ? (
|
||||
<PlayerCard player={result.item} />
|
||||
<PlayerCard player={result.player} />
|
||||
) : (
|
||||
<ShopCard item={result.item} />
|
||||
<ShopCard item={result} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -694,17 +717,13 @@ function SearchPageContent() {
|
||||
</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"
|
||||
onClick={() => {
|
||||
setSearchQuery("")
|
||||
setSelectedGames([])
|
||||
setPriceRange({ min: "", max: "" })
|
||||
setOnlyOnline(false)
|
||||
setMinRating("0")
|
||||
clearAllFilters()
|
||||
}}
|
||||
>
|
||||
清除所有筛选
|
||||
@@ -712,12 +731,18 @@ function SearchPageContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sortedResults.length > visibleCount && (
|
||||
{canLoadMore && (
|
||||
<div className="mt-8 text-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={() => setVisibleCount((prev) => prev + 12)}
|
||||
onClick={() => {
|
||||
const currentLimit = numberParam(searchParams.get("limit"), DEFAULT_LIMIT)
|
||||
replaceUrl((params) => {
|
||||
params.set("limit", String(currentLimit + DEFAULT_LIMIT))
|
||||
params.set("offset", "0")
|
||||
})
|
||||
}}
|
||||
>
|
||||
加载更多
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user