feat(search): add api-backed filtering and sorting
This commit is contained in:
+217
-192
@@ -14,7 +14,7 @@ import {
|
|||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
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 { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -41,11 +41,34 @@ import {
|
|||||||
SheetTrigger,
|
SheetTrigger,
|
||||||
} from "@/components/ui/sheet"
|
} from "@/components/ui/sheet"
|
||||||
import { Switch } from "@/components/ui/switch"
|
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 { 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"
|
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"] }) {
|
function StatusBadge({ status }: { status: Player["status"] }) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "available":
|
case "available":
|
||||||
@@ -165,15 +188,9 @@ function PlayerCard({ player }: { player: Player }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ShopResultItem {
|
type ShopSearchItem = Extract<SearchResultItem, { type: "shop" }>
|
||||||
shop: Shop
|
|
||||||
minPrice: number
|
|
||||||
unit: string
|
|
||||||
games: string[]
|
|
||||||
hasAvailable: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function ShopCard({ item }: { item: ShopResultItem }) {
|
function ShopCard({ item }: { item: ShopSearchItem }) {
|
||||||
return (
|
return (
|
||||||
<Link href={`/shop/${item.shop.id}`} className="block h-full">
|
<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">
|
<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 {
|
interface FilterProps {
|
||||||
games: Game[]
|
games: Game[]
|
||||||
selectedGames: string[]
|
selectedGames: string[]
|
||||||
@@ -375,166 +374,183 @@ function SearchPageContent() {
|
|||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const games = listGames()
|
const games = listGames()
|
||||||
const players = listPlayers()
|
|
||||||
const services = listServices()
|
|
||||||
const shops = listShops()
|
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState(searchParams.get("q") || "")
|
const [searchQuery, setSearchQuery] = useState(searchParams.get("q") || "")
|
||||||
const [selectedGames, setSelectedGames] = useState<string[]>(() => {
|
const [selectedGames, setSelectedGames] = useState<string[]>(() => {
|
||||||
const game = searchParams.get("game")
|
const selected = searchParams.getAll("game")
|
||||||
return game ? [game] : []
|
return [...new Set(selected)]
|
||||||
})
|
})
|
||||||
const [priceRange, setPriceRange] = useState<{ min: string; max: string }>({
|
const [priceRange, setPriceRange] = useState<{ min: string; max: string }>({
|
||||||
min: "",
|
min: searchParams.get("min") || "",
|
||||||
max: "",
|
max: searchParams.get("max") || "",
|
||||||
})
|
})
|
||||||
const deferredPriceRange = useDeferredValue(priceRange)
|
const [onlyOnline, setOnlyOnline] = useState((searchParams.get("online") ?? "0") === "1")
|
||||||
const [onlyOnline, setOnlyOnline] = useState(false)
|
const [minRating, setMinRating] = useState(searchParams.get("minRating") ?? "0")
|
||||||
const [minRating, setMinRating] = useState("0")
|
const [sortBy, setSortBy] = useState<SearchSort>(() => {
|
||||||
const [sortBy, setSortBy] = useState("composite")
|
const sortParam = (searchParams.get("sort") ?? "composite") as SearchSort
|
||||||
const [visibleCount, setVisibleCount] = useState(12)
|
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(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
const params = new URLSearchParams(searchParams.toString())
|
const urlQ = searchParams.get("q") ?? ""
|
||||||
if (searchQuery) {
|
if (searchQuery === urlQ) return
|
||||||
params.set("q", searchQuery)
|
|
||||||
} else {
|
replaceUrl((params) => {
|
||||||
params.delete("q")
|
if (searchQuery) params.set("q", searchQuery)
|
||||||
}
|
else params.delete("q")
|
||||||
router.replace(`/search?${params.toString()}`, { scroll: false })
|
resetPagination(params)
|
||||||
|
})
|
||||||
}, 500)
|
}, 500)
|
||||||
|
|
||||||
return () => clearTimeout(timer)
|
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) => {
|
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) => {
|
const handlePriceChange = (type: "min" | "max", value: string) => {
|
||||||
setPriceRange((prev) => ({ ...prev, [type]: value }))
|
setPriceRange((prev) => {
|
||||||
}
|
const next = { ...prev, [type]: value }
|
||||||
|
replaceUrl((params) => {
|
||||||
const shopResultItems = useMemo<ShopResultItem[]>(() => {
|
if (next.min) params.set("min", next.min)
|
||||||
return shops.map((shop) => {
|
else params.delete("min")
|
||||||
const shopPlayers = players.filter((player) => player.shopId === shop.id)
|
if (next.max) params.set("max", next.max)
|
||||||
const playerIds = new Set(shopPlayers.map((player) => player.id))
|
else params.delete("max")
|
||||||
const shopServices = services.filter((service) => playerIds.has(service.playerId))
|
resetPagination(params)
|
||||||
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])
|
return next
|
||||||
|
|
||||||
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
|
|
||||||
})
|
})
|
||||||
}, [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 handleOnlineChange = (checked: boolean) => {
|
||||||
const hasGame = item.games.some((game) => selectedGames.includes(game))
|
setOnlyOnline(checked)
|
||||||
if (!hasGame) return false
|
replaceUrl((params) => {
|
||||||
}
|
if (checked) params.set("online", "1")
|
||||||
|
else params.delete("online")
|
||||||
const minP = deferredPriceRange.min ? Number(deferredPriceRange.min) : 0
|
resetPagination(params)
|
||||||
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
|
|
||||||
})
|
})
|
||||||
}, [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 handleRatingChange = (value: string) => {
|
||||||
|
setMinRating(value)
|
||||||
|
replaceUrl((params) => {
|
||||||
|
if (value && value !== "0") params.set("minRating", value)
|
||||||
|
else params.delete("minRating")
|
||||||
|
resetPagination(params)
|
||||||
})
|
})
|
||||||
}, [filteredPlayers, filteredShops, sortBy])
|
}
|
||||||
|
|
||||||
const displayedResults = sortedResults.slice(0, visibleCount)
|
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 (
|
return (
|
||||||
<div className="container mx-auto px-4 py-6 min-h-screen">
|
<div className="container mx-auto px-4 py-6 min-h-screen">
|
||||||
@@ -575,14 +591,14 @@ function SearchPageContent() {
|
|||||||
priceRange={priceRange}
|
priceRange={priceRange}
|
||||||
onPriceChange={handlePriceChange}
|
onPriceChange={handlePriceChange}
|
||||||
onlyOnline={onlyOnline}
|
onlyOnline={onlyOnline}
|
||||||
onOnlineChange={setOnlyOnline}
|
onOnlineChange={handleOnlineChange}
|
||||||
minRating={minRating}
|
minRating={minRating}
|
||||||
onRatingChange={setMinRating}
|
onRatingChange={handleRatingChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<SheetFooter>
|
<SheetFooter>
|
||||||
<SheetClose asChild>
|
<SheetClose asChild>
|
||||||
<Button className="w-full">查看 {sortedResults.length} 个结果</Button>
|
<Button className="w-full">查看 {total} 个结果</Button>
|
||||||
</SheetClose>
|
</SheetClose>
|
||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
@@ -606,10 +622,7 @@ function SearchPageContent() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="h-auto p-0 text-xs text-muted-foreground hover:text-primary"
|
className="h-auto p-0 text-xs text-muted-foreground hover:text-primary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedGames([])
|
resetFilters()
|
||||||
setPriceRange({ min: "", max: "" })
|
|
||||||
setOnlyOnline(false)
|
|
||||||
setMinRating("0")
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
重置
|
重置
|
||||||
@@ -625,9 +638,9 @@ function SearchPageContent() {
|
|||||||
priceRange={priceRange}
|
priceRange={priceRange}
|
||||||
onPriceChange={handlePriceChange}
|
onPriceChange={handlePriceChange}
|
||||||
onlyOnline={onlyOnline}
|
onlyOnline={onlyOnline}
|
||||||
onOnlineChange={setOnlyOnline}
|
onOnlineChange={handleOnlineChange}
|
||||||
minRating={minRating}
|
minRating={minRating}
|
||||||
onRatingChange={setMinRating}
|
onRatingChange={handleRatingChange}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -639,7 +652,7 @@ function SearchPageContent() {
|
|||||||
<Button
|
<Button
|
||||||
variant={sortBy === "composite" ? "secondary" : "ghost"}
|
variant={sortBy === "composite" ? "secondary" : "ghost"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setSortBy("composite")}
|
onClick={() => setSortAndSync("composite")}
|
||||||
className="whitespace-nowrap"
|
className="whitespace-nowrap"
|
||||||
>
|
>
|
||||||
综合排序
|
综合排序
|
||||||
@@ -647,7 +660,7 @@ function SearchPageContent() {
|
|||||||
<Button
|
<Button
|
||||||
variant={sortBy === "rating" ? "secondary" : "ghost"}
|
variant={sortBy === "rating" ? "secondary" : "ghost"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setSortBy("rating")}
|
onClick={() => setSortAndSync("rating")}
|
||||||
className="whitespace-nowrap"
|
className="whitespace-nowrap"
|
||||||
>
|
>
|
||||||
评分最高
|
评分最高
|
||||||
@@ -655,7 +668,7 @@ function SearchPageContent() {
|
|||||||
<Button
|
<Button
|
||||||
variant={sortBy === "orders" ? "secondary" : "ghost"}
|
variant={sortBy === "orders" ? "secondary" : "ghost"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setSortBy("orders")}
|
onClick={() => setSortAndSync("orders")}
|
||||||
className="whitespace-nowrap"
|
className="whitespace-nowrap"
|
||||||
>
|
>
|
||||||
接单最多
|
接单最多
|
||||||
@@ -663,7 +676,7 @@ function SearchPageContent() {
|
|||||||
<Button
|
<Button
|
||||||
variant={sortBy.startsWith("price") ? "secondary" : "ghost"}
|
variant={sortBy.startsWith("price") ? "secondary" : "ghost"}
|
||||||
size="sm"
|
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"
|
className="whitespace-nowrap gap-1"
|
||||||
>
|
>
|
||||||
价格
|
价格
|
||||||
@@ -671,18 +684,28 @@ function SearchPageContent() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground hidden sm:block">
|
<div className="text-sm text-muted-foreground hidden sm:block">
|
||||||
共找到 {sortedResults.length} 位陪玩
|
共找到 {total} 位陪玩
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
{displayedResults.map((result) => (
|
{items.map((result) => (
|
||||||
<div key={`${result.type}-${result.id}`}>
|
<div
|
||||||
|
key={
|
||||||
|
result.type === "player"
|
||||||
|
? `player-${result.player.id}`
|
||||||
|
: `shop-${result.shop.id}`
|
||||||
|
}
|
||||||
|
>
|
||||||
{result.type === "player" ? (
|
{result.type === "player" ? (
|
||||||
<PlayerCard player={result.item} />
|
<PlayerCard player={result.player} />
|
||||||
) : (
|
) : (
|
||||||
<ShopCard item={result.item} />
|
<ShopCard item={result} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -694,17 +717,13 @@ function SearchPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-medium">未找到相关陪玩</h3>
|
<h3 className="text-lg font-medium">未找到相关陪玩</h3>
|
||||||
<p className="text-muted-foreground mt-1 max-w-sm mx-auto">
|
<p className="text-muted-foreground mt-1 max-w-sm mx-auto">
|
||||||
尝试调整筛选条件或更换搜索关键词
|
{error ? "请求失败,请稍后重试" : "尝试调整筛选条件或更换搜索关键词"}
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="mt-4"
|
className="mt-4"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSearchQuery("")
|
clearAllFilters()
|
||||||
setSelectedGames([])
|
|
||||||
setPriceRange({ min: "", max: "" })
|
|
||||||
setOnlyOnline(false)
|
|
||||||
setMinRating("0")
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
清除所有筛选
|
清除所有筛选
|
||||||
@@ -712,12 +731,18 @@ function SearchPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{sortedResults.length > visibleCount && (
|
{canLoadMore && (
|
||||||
<div className="mt-8 text-center">
|
<div className="mt-8 text-center">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="lg"
|
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>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { mockPlayers, mockServices, mockShops } from "@/lib/mock"
|
||||||
|
import { searchCatalog } from "@/lib/search/search-catalog"
|
||||||
|
import type { SearchSort } from "@/lib/search/types"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
const SEARCH_SORTS: ReadonlySet<SearchSort> = new Set([
|
||||||
|
"composite",
|
||||||
|
"rating",
|
||||||
|
"orders",
|
||||||
|
"price_asc",
|
||||||
|
"price_desc",
|
||||||
|
])
|
||||||
|
|
||||||
|
function numberParam(value: string | null, fallback: number) {
|
||||||
|
if (value === null) return fallback
|
||||||
|
const parsed = Number(value)
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
|
||||||
|
const q = searchParams.get("q") ?? undefined
|
||||||
|
const selectedGames = searchParams.getAll("game")
|
||||||
|
const min = searchParams.get("min") ?? ""
|
||||||
|
const max = searchParams.get("max") ?? ""
|
||||||
|
const onlyOnline = (searchParams.get("online") ?? "0") === "1"
|
||||||
|
const minRating = 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)
|
||||||
|
|
||||||
|
const response = searchCatalog(
|
||||||
|
{
|
||||||
|
q,
|
||||||
|
selectedGames,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
onlyOnline,
|
||||||
|
minRating,
|
||||||
|
sort,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
players: mockPlayers,
|
||||||
|
shops: mockShops,
|
||||||
|
services: mockServices,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json(response, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { SearchResponse, SearchSort } from "@/lib/search/types"
|
||||||
|
|
||||||
|
export interface SearchCatalogParams {
|
||||||
|
q?: string
|
||||||
|
selectedGames?: string[]
|
||||||
|
min?: string
|
||||||
|
max?: string
|
||||||
|
onlyOnline?: boolean
|
||||||
|
minRating?: string
|
||||||
|
sort?: SearchSort
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
signal?: AbortSignal
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchCatalog(params: SearchCatalogParams): Promise<SearchResponse> {
|
||||||
|
const searchParams = new URLSearchParams()
|
||||||
|
|
||||||
|
if (params.q) searchParams.set("q", params.q)
|
||||||
|
|
||||||
|
for (const game of params.selectedGames ?? []) {
|
||||||
|
searchParams.append("game", game)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.min) searchParams.set("min", params.min)
|
||||||
|
if (params.max) searchParams.set("max", params.max)
|
||||||
|
if (params.onlyOnline) searchParams.set("online", "1")
|
||||||
|
if (params.minRating && params.minRating !== "0") searchParams.set("minRating", params.minRating)
|
||||||
|
if (params.sort && params.sort !== "composite") searchParams.set("sort", params.sort)
|
||||||
|
|
||||||
|
if (params.limit !== undefined) searchParams.set("limit", String(params.limit))
|
||||||
|
if (params.offset !== undefined) searchParams.set("offset", String(params.offset))
|
||||||
|
|
||||||
|
const res = await fetch(`/api/search?${searchParams.toString()}`, {
|
||||||
|
cache: "no-store",
|
||||||
|
signal: params.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Search API request failed: ${res.status} ${res.statusText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await res.json()) as SearchResponse
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import type { Player, PlayerService, Shop } from "@/lib/types"
|
||||||
|
import type { SearchResponse, SearchResultItem, SearchSort } from "@/lib/search/types"
|
||||||
|
|
||||||
|
export interface SearchCatalogParams {
|
||||||
|
q?: string
|
||||||
|
selectedGames?: string[]
|
||||||
|
min?: string
|
||||||
|
max?: string
|
||||||
|
onlyOnline?: boolean
|
||||||
|
minRating?: string
|
||||||
|
sort?: SearchSort
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchCatalogData {
|
||||||
|
players: Player[]
|
||||||
|
shops: Shop[]
|
||||||
|
services: PlayerService[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type InternalResultItem = SearchResultItem & { __index: number }
|
||||||
|
|
||||||
|
function minPriceFromServices(services: PlayerService[]) {
|
||||||
|
return services.length === 0 ? 0 : Math.min(...services.map((s) => s.price))
|
||||||
|
}
|
||||||
|
|
||||||
|
function unitFromCheapestService(services: PlayerService[]): PlayerService["unit"] {
|
||||||
|
if (services.length === 0) return "局"
|
||||||
|
return services.reduce((prev, curr) => (prev.price < curr.price ? prev : curr)).unit
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchCatalog(
|
||||||
|
params: SearchCatalogParams,
|
||||||
|
data: SearchCatalogData,
|
||||||
|
): SearchResponse {
|
||||||
|
const q = params.q ?? ""
|
||||||
|
const query = q ? q.toLowerCase() : ""
|
||||||
|
const selectedGames = params.selectedGames ?? []
|
||||||
|
|
||||||
|
const min = params.min ?? ""
|
||||||
|
const max = params.max ?? ""
|
||||||
|
const minP = min ? Number(min) : 0
|
||||||
|
const maxP = max ? Number(max) : Infinity
|
||||||
|
|
||||||
|
const onlyOnline = params.onlyOnline ?? false
|
||||||
|
const minRating = Number(params.minRating ?? "0")
|
||||||
|
const sort = params.sort ?? "composite"
|
||||||
|
|
||||||
|
const offset = Math.max(0, params.offset ?? 0)
|
||||||
|
const limit = Math.max(0, params.limit ?? 12)
|
||||||
|
|
||||||
|
const shopDerivedById = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
minPrice: number
|
||||||
|
unit: PlayerService["unit"]
|
||||||
|
games: string[]
|
||||||
|
hasAvailable: boolean
|
||||||
|
}
|
||||||
|
>()
|
||||||
|
|
||||||
|
for (const shop of data.shops) {
|
||||||
|
const shopPlayers = data.players.filter((player) => player.shopId === shop.id)
|
||||||
|
const playerIds = new Set(shopPlayers.map((player) => player.id))
|
||||||
|
const shopServices = data.services.filter((service) => playerIds.has(service.playerId))
|
||||||
|
const shopMinPrice = shopServices.length > 0 ? Math.min(...shopServices.map((s) => s.price)) : 0
|
||||||
|
const shopUnit = shopServices.length > 0 ? unitFromCheapestService(shopServices) : "局"
|
||||||
|
const shopGames = [...new Set(shopServices.map((service) => service.gameName))]
|
||||||
|
const hasAvailable = shopPlayers.some((player) => player.status === "available")
|
||||||
|
|
||||||
|
shopDerivedById.set(shop.id, {
|
||||||
|
minPrice: shopMinPrice,
|
||||||
|
unit: shopUnit,
|
||||||
|
games: shopGames,
|
||||||
|
hasAvailable,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextIndex = 0
|
||||||
|
const results: InternalResultItem[] = []
|
||||||
|
|
||||||
|
for (const player of data.players) {
|
||||||
|
results.push({
|
||||||
|
__index: nextIndex++,
|
||||||
|
type: "player",
|
||||||
|
player,
|
||||||
|
rating: player.rating,
|
||||||
|
orders: player.totalOrders,
|
||||||
|
minPrice: minPriceFromServices(player.services),
|
||||||
|
unit: unitFromCheapestService(player.services),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const shop of data.shops) {
|
||||||
|
const derived = shopDerivedById.get(shop.id)
|
||||||
|
if (!derived) continue
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
__index: nextIndex++,
|
||||||
|
type: "shop",
|
||||||
|
shop,
|
||||||
|
rating: shop.rating,
|
||||||
|
orders: shop.totalOrders,
|
||||||
|
minPrice: derived.minPrice,
|
||||||
|
unit: derived.unit,
|
||||||
|
games: derived.games,
|
||||||
|
hasAvailable: derived.hasAvailable,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = results.filter((item) => {
|
||||||
|
if (query) {
|
||||||
|
if (item.type === "player") {
|
||||||
|
const matchName = item.player.user.nickname.toLowerCase().includes(query)
|
||||||
|
const matchTags = item.player.tags.some((tag) => tag.toLowerCase().includes(query))
|
||||||
|
const matchGames = item.player.games.some((game) => game.toLowerCase().includes(query))
|
||||||
|
if (!matchName && !matchTags && !matchGames) return false
|
||||||
|
} else {
|
||||||
|
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) {
|
||||||
|
if (item.type === "player") {
|
||||||
|
const hasGame = item.player.games.some((game) => selectedGames.includes(game))
|
||||||
|
if (!hasGame) return false
|
||||||
|
} else {
|
||||||
|
const hasGame = item.games.some((game) => selectedGames.includes(game))
|
||||||
|
if (!hasGame) return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.minPrice < minP) return false
|
||||||
|
if (max && item.minPrice > maxP) return false
|
||||||
|
|
||||||
|
if (onlyOnline) {
|
||||||
|
if (item.type === "player") {
|
||||||
|
if (item.player.status !== "available") return false
|
||||||
|
} else {
|
||||||
|
if (!item.hasAvailable) return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.type === "player") {
|
||||||
|
if (item.player.rating < minRating) return false
|
||||||
|
} else {
|
||||||
|
if (item.shop.rating < minRating) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
let diff = 0
|
||||||
|
switch (sort) {
|
||||||
|
case "rating":
|
||||||
|
diff = b.rating - a.rating
|
||||||
|
break
|
||||||
|
case "price_asc":
|
||||||
|
diff = a.minPrice - b.minPrice
|
||||||
|
break
|
||||||
|
case "price_desc":
|
||||||
|
diff = b.minPrice - a.minPrice
|
||||||
|
break
|
||||||
|
case "orders":
|
||||||
|
diff = b.orders - a.orders
|
||||||
|
break
|
||||||
|
case "composite": {
|
||||||
|
const scoreA = a.rating * Math.log10(a.orders + 1)
|
||||||
|
const scoreB = b.rating * Math.log10(b.orders + 1)
|
||||||
|
diff = scoreB - scoreA
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
diff = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diff !== 0) return diff
|
||||||
|
return a.__index - b.__index
|
||||||
|
})
|
||||||
|
|
||||||
|
const total = filtered.length
|
||||||
|
const items = filtered.slice(offset, offset + limit).map(({ __index, ...item }) => item)
|
||||||
|
return { items, meta: { total, offset, limit } }
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { Player, PlayerService, Shop } from "@/lib/types"
|
||||||
|
|
||||||
|
export type SearchSort = "composite" | "rating" | "orders" | "price_asc" | "price_desc"
|
||||||
|
|
||||||
|
export type SearchResultItem =
|
||||||
|
| {
|
||||||
|
type: "player"
|
||||||
|
player: Player
|
||||||
|
minPrice: number
|
||||||
|
unit: PlayerService["unit"]
|
||||||
|
rating: number
|
||||||
|
orders: number
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "shop"
|
||||||
|
shop: Shop
|
||||||
|
minPrice: number
|
||||||
|
unit: PlayerService["unit"]
|
||||||
|
rating: number
|
||||||
|
orders: number
|
||||||
|
games: string[]
|
||||||
|
hasAvailable: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResponse {
|
||||||
|
items: SearchResultItem[]
|
||||||
|
meta: { total: number; offset: number; limit: number }
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
import { searchCatalog } from "@/lib/search/search-catalog"
|
||||||
|
import { mockPlayers } from "@/lib/mock/players"
|
||||||
|
import { mockShops } from "@/lib/mock/shops"
|
||||||
|
import { mockServices } from "@/lib/mock/services"
|
||||||
|
import type { SearchCatalogData } from "@/lib/search/search-catalog"
|
||||||
|
|
||||||
|
const data: SearchCatalogData = {
|
||||||
|
players: mockPlayers,
|
||||||
|
shops: mockShops,
|
||||||
|
services: mockServices,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("searchCatalog", () => {
|
||||||
|
describe("q matching (case-insensitive)", () => {
|
||||||
|
it("matches player nickname", () => {
|
||||||
|
const res = searchCatalog({ q: "winter", limit: 50 }, data)
|
||||||
|
const playerItems = res.items.filter((i) => i.type === "player")
|
||||||
|
expect(playerItems.some((i) => i.type === "player" && i.player.id === "u6")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("matches player nickname case-insensitively", () => {
|
||||||
|
const res = searchCatalog({ q: "WINTER", limit: 50 }, data)
|
||||||
|
const playerItems = res.items.filter((i) => i.type === "player")
|
||||||
|
expect(playerItems.some((i) => i.type === "player" && i.player.id === "u6")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("matches shop name", () => {
|
||||||
|
const res = searchCatalog({ q: "yuki", limit: 50 }, data)
|
||||||
|
const shopItems = res.items.filter((i) => i.type === "shop")
|
||||||
|
expect(shopItems.some((i) => i.type === "shop" && i.shop.id === "shop2")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("matches player game name", () => {
|
||||||
|
const res = searchCatalog({ q: "cs2", limit: 50 }, data)
|
||||||
|
const playerItems = res.items.filter((i) => i.type === "player")
|
||||||
|
// u8 has CS2 in games
|
||||||
|
expect(playerItems.some((i) => i.type === "player" && i.player.id === "u8")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("matches shop derived games", () => {
|
||||||
|
const res = searchCatalog({ q: "cs2", limit: 50 }, data)
|
||||||
|
const shopItems = res.items.filter((i) => i.type === "shop")
|
||||||
|
// shop1 has CS2 via u5's service s3
|
||||||
|
expect(shopItems.some((i) => i.type === "shop" && i.shop.id === "shop1")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns all items when q is empty", () => {
|
||||||
|
const res = searchCatalog({ limit: 50 }, data)
|
||||||
|
expect(res.meta.total).toBe(mockPlayers.length + mockShops.length)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("selectedGames ANY-match", () => {
|
||||||
|
it("filters players by game", () => {
|
||||||
|
const res = searchCatalog({ selectedGames: ["CS2"], limit: 50 }, data)
|
||||||
|
const playerItems = res.items.filter((i) => i.type === "player")
|
||||||
|
expect(playerItems.every((i) => i.type === "player" && i.player.games.includes("CS2"))).toBe(
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
expect(playerItems.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("filters shops by derived games from services", () => {
|
||||||
|
const res = searchCatalog({ selectedGames: ["CS2"], limit: 50 }, data)
|
||||||
|
const shopItems = res.items.filter((i) => i.type === "shop")
|
||||||
|
// shop1 has CS2 via services
|
||||||
|
expect(shopItems.some((i) => i.type === "shop" && i.shop.id === "shop1")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("uses ANY-match (OR) for multiple games", () => {
|
||||||
|
const res = searchCatalog({ selectedGames: ["CS2", "王者荣耀"], limit: 50 }, data)
|
||||||
|
// Should include players with either game
|
||||||
|
expect(res.meta.total).toBeGreaterThan(0)
|
||||||
|
const playerItems = res.items.filter((i) => i.type === "player")
|
||||||
|
for (const item of playerItems) {
|
||||||
|
if (item.type === "player") {
|
||||||
|
expect(item.player.games.includes("CS2") || item.player.games.includes("王者荣耀")).toBe(
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("onlyOnline semantics", () => {
|
||||||
|
it("filters players by status === available", () => {
|
||||||
|
const res = searchCatalog({ onlyOnline: true, limit: 50 }, data)
|
||||||
|
const playerItems = res.items.filter((i) => i.type === "player")
|
||||||
|
for (const item of playerItems) {
|
||||||
|
if (item.type === "player") {
|
||||||
|
expect(item.player.status).toBe("available")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// u7 is busy, u9 is offline — they should be excluded
|
||||||
|
expect(playerItems.some((i) => i.type === "player" && i.player.id === "u7")).toBe(false)
|
||||||
|
expect(playerItems.some((i) => i.type === "player" && i.player.id === "u9")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("filters shops by hasAvailable (any player available)", () => {
|
||||||
|
const res = searchCatalog({ onlyOnline: true, limit: 50 }, data)
|
||||||
|
const shopItems = res.items.filter((i) => i.type === "shop")
|
||||||
|
for (const item of shopItems) {
|
||||||
|
if (item.type === "shop") {
|
||||||
|
expect(item.hasAvailable).toBe(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// shop3 has only u9 (offline) → excluded
|
||||||
|
expect(shopItems.some((i) => i.type === "shop" && i.shop.id === "shop3")).toBe(false)
|
||||||
|
// shop1 has u5 (available) → included
|
||||||
|
expect(shopItems.some((i) => i.type === "shop" && i.shop.id === "shop1")).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("price min/max parsing", () => {
|
||||||
|
it("filters by min price", () => {
|
||||||
|
const res = searchCatalog({ min: "25", limit: 50 }, data)
|
||||||
|
for (const item of res.items) {
|
||||||
|
expect(item.minPrice).toBeGreaterThanOrEqual(25)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("filters by max price", () => {
|
||||||
|
const res = searchCatalog({ max: "20", limit: 50 }, data)
|
||||||
|
for (const item of res.items) {
|
||||||
|
expect(item.minPrice).toBeLessThanOrEqual(20)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("NaN min does not filter (min='abc' → minP=NaN)", () => {
|
||||||
|
const resAll = searchCatalog({ limit: 50 }, data)
|
||||||
|
const resNaN = searchCatalog({ min: "abc", limit: 50 }, data)
|
||||||
|
// NaN comparison: item.minPrice < NaN is always false, so nothing is excluded by min
|
||||||
|
// But items with minPrice < 0 would pass too — effectively no min filter
|
||||||
|
expect(resNaN.meta.total).toBe(resAll.meta.total)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("NaN max does not filter (max='abc' → maxP=NaN)", () => {
|
||||||
|
const resAll = searchCatalog({ limit: 50 }, data)
|
||||||
|
const resNaN = searchCatalog({ max: "abc", limit: 50 }, data)
|
||||||
|
// max is truthy ("abc") so the max filter runs, but item.minPrice > NaN is always false
|
||||||
|
expect(resNaN.meta.total).toBe(resAll.meta.total)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("sort options", () => {
|
||||||
|
it("sorts by rating descending", () => {
|
||||||
|
const res = searchCatalog({ sort: "rating", limit: 50 }, data)
|
||||||
|
for (let i = 1; i < res.items.length; i++) {
|
||||||
|
expect(res.items[i - 1].rating).toBeGreaterThanOrEqual(res.items[i].rating)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("sorts by orders descending", () => {
|
||||||
|
const res = searchCatalog({ sort: "orders", limit: 50 }, data)
|
||||||
|
for (let i = 1; i < res.items.length; i++) {
|
||||||
|
expect(res.items[i - 1].orders).toBeGreaterThanOrEqual(res.items[i].orders)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("sorts by price ascending", () => {
|
||||||
|
const res = searchCatalog({ sort: "price_asc", limit: 50 }, data)
|
||||||
|
for (let i = 1; i < res.items.length; i++) {
|
||||||
|
expect(res.items[i - 1].minPrice).toBeLessThanOrEqual(res.items[i].minPrice)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("sorts by price descending", () => {
|
||||||
|
const res = searchCatalog({ sort: "price_desc", limit: 50 }, data)
|
||||||
|
for (let i = 1; i < res.items.length; i++) {
|
||||||
|
expect(res.items[i - 1].minPrice).toBeGreaterThanOrEqual(res.items[i].minPrice)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("sorts by composite formula descending", () => {
|
||||||
|
const res = searchCatalog({ sort: "composite", limit: 50 }, data)
|
||||||
|
const scores = res.items.map((i) => i.rating * Math.log10(i.orders + 1))
|
||||||
|
for (let i = 1; i < scores.length; i++) {
|
||||||
|
expect(scores[i - 1]).toBeGreaterThanOrEqual(scores[i])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("uses stable tie-breaker by insertion order", () => {
|
||||||
|
// Create data with identical ratings to test tie-breaking
|
||||||
|
const res = searchCatalog({ sort: "rating", limit: 50 }, data)
|
||||||
|
// Items with same rating should preserve insertion order (players before shops)
|
||||||
|
const sameRating = res.items.filter((i) => i.rating === res.items[0].rating)
|
||||||
|
if (sameRating.length > 1) {
|
||||||
|
// They should be in original insertion order
|
||||||
|
expect(sameRating.length).toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("pagination offset/limit", () => {
|
||||||
|
it("returns correct meta", () => {
|
||||||
|
const res = searchCatalog({ limit: 2, offset: 0 }, data)
|
||||||
|
expect(res.meta.limit).toBe(2)
|
||||||
|
expect(res.meta.offset).toBe(0)
|
||||||
|
expect(res.items.length).toBe(2)
|
||||||
|
expect(res.meta.total).toBe(mockPlayers.length + mockShops.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("offset skips items", () => {
|
||||||
|
const all = searchCatalog({ limit: 50 }, data)
|
||||||
|
const page2 = searchCatalog({ limit: 2, offset: 2 }, data)
|
||||||
|
expect(page2.items[0]).toEqual(all.items[2])
|
||||||
|
expect(page2.items[1]).toEqual(all.items[3])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("offset beyond total returns empty items", () => {
|
||||||
|
const res = searchCatalog({ limit: 10, offset: 100 }, data)
|
||||||
|
expect(res.items.length).toBe(0)
|
||||||
|
expect(res.meta.total).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
import { GET } from "@/app/api/search/route"
|
||||||
|
|
||||||
|
describe("GET /api/search", () => {
|
||||||
|
it("returns 200 with items and meta", async () => {
|
||||||
|
const request = new Request("http://x/api/search")
|
||||||
|
const response = await GET(request)
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
const json = await response.json()
|
||||||
|
expect(Array.isArray(json.items)).toBe(true)
|
||||||
|
expect(json.meta).toHaveProperty("total")
|
||||||
|
expect(json.meta).toHaveProperty("offset")
|
||||||
|
expect(json.meta).toHaveProperty("limit")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("sets Cache-Control to no-store", async () => {
|
||||||
|
const request = new Request("http://x/api/search")
|
||||||
|
const response = await GET(request)
|
||||||
|
|
||||||
|
expect(response.headers.get("Cache-Control")).toBe("no-store")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("filters by q param", async () => {
|
||||||
|
const request = new Request("http://x/api/search?q=winter")
|
||||||
|
const response = await GET(request)
|
||||||
|
const json = await response.json()
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(json.items.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("respects limit and offset", async () => {
|
||||||
|
const request = new Request("http://x/api/search?limit=2&offset=1")
|
||||||
|
const response = await GET(request)
|
||||||
|
const json = await response.json()
|
||||||
|
|
||||||
|
expect(json.items.length).toBeLessThanOrEqual(2)
|
||||||
|
expect(json.meta.limit).toBe(2)
|
||||||
|
expect(json.meta.offset).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles game filter", async () => {
|
||||||
|
const request = new Request("http://x/api/search?game=CS2")
|
||||||
|
const response = await GET(request)
|
||||||
|
const json = await response.json()
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(json.items.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user