feat: 支持创建房间时指定位置搜索餐厅

新增位置搜索框,通过高德输入提示 API 提供地点联想,
用户可选择指定位置或使用默认当前定位来查询周边餐厅。
This commit is contained in:
2026-02-24 18:06:48 +08:00
parent 48e74c03e6
commit 4e2d11f0a5
2 changed files with 200 additions and 6 deletions
+52
View File
@@ -0,0 +1,52 @@
import { NextResponse } from "next/server";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const keywords = searchParams.get("keywords")?.trim();
if (!keywords) {
return NextResponse.json([]);
}
const apiKey = process.env.AMAP_API_KEY;
if (!apiKey) {
return NextResponse.json(
{ error: "AMAP_API_KEY not configured" },
{ status: 500 },
);
}
try {
const url = new URL("https://restapi.amap.com/v3/assistant/inputtips");
url.searchParams.set("key", apiKey);
url.searchParams.set("keywords", keywords);
url.searchParams.set("datatype", "poi");
const res = await fetch(url.toString());
const data = await res.json();
if (data.status !== "1" || !data.tips) {
return NextResponse.json([]);
}
const suggestions = data.tips
.filter((t: { location?: string }) => t.location && t.location !== "")
.slice(0, 8)
.map((t: { id: string; name: string; district?: string; address?: string; location: string }) => {
const [lng, lat] = t.location.split(",").map(Number);
return {
id: t.id,
name: t.name,
district: t.district || "",
address: t.address || "",
lat,
lng,
};
});
return NextResponse.json(suggestions);
} catch (e) {
console.error("Location suggest error:", e);
return NextResponse.json([]);
}
}
+147 -5
View File
@@ -1,11 +1,20 @@
"use client";
import { useState } from "react";
import { useState, useRef, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { motion } from "framer-motion";
import { Plus, LogIn, Utensils, Loader2 } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import { Plus, LogIn, Utensils, Loader2, MapPin, Navigation, X } from "lucide-react";
import { getUserId } from "@/lib/userId";
interface LocationSuggestion {
id: string;
name: string;
district: string;
address: string;
lat: number;
lng: number;
}
const SHANGHAI_COORDS = { lat: 31.2222, lng: 121.4764 };
function getLocation(): Promise<{ lat: number; lng: number }> {
@@ -35,6 +44,64 @@ export default function LandingPage() {
const [loadingText, setLoadingText] = useState("");
const [error, setError] = useState("");
const [locationQuery, setLocationQuery] = useState("");
const [suggestions, setSuggestions] = useState<LocationSuggestion[]>([]);
const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedLocation, setSelectedLocation] = useState<LocationSuggestion | null>(null);
const [fetchingSuggestions, setFetchingSuggestions] = useState(false);
const suggestRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
const fetchSuggestions = useCallback(async (query: string) => {
if (query.length < 1) {
setSuggestions([]);
setShowSuggestions(false);
return;
}
setFetchingSuggestions(true);
try {
const res = await fetch(`/api/location/suggest?keywords=${encodeURIComponent(query)}`);
const data: LocationSuggestion[] = await res.json();
setSuggestions(data);
setShowSuggestions(data.length > 0);
} catch {
setSuggestions([]);
} finally {
setFetchingSuggestions(false);
}
}, []);
const handleLocationInput = (val: string) => {
setLocationQuery(val);
setSelectedLocation(null);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => fetchSuggestions(val), 300);
};
const handleSelectLocation = (loc: LocationSuggestion) => {
setSelectedLocation(loc);
setLocationQuery(loc.name);
setShowSuggestions(false);
setSuggestions([]);
};
const clearLocation = () => {
setSelectedLocation(null);
setLocationQuery("");
setSuggestions([]);
setShowSuggestions(false);
};
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (suggestRef.current && !suggestRef.current.contains(e.target as Node)) {
setShowSuggestions(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const joinRoom = async (roomId: string) => {
const userId = getUserId();
const res = await fetch(`/api/room/${roomId}/join`, {
@@ -49,12 +116,18 @@ export default function LandingPage() {
const handleCreate = async () => {
setLoading(true);
setError("");
setLoadingText("正在获取位置...");
try {
const coords = await getLocation();
let coords: { lat: number; lng: number };
if (selectedLocation) {
coords = { lat: selectedLocation.lat, lng: selectedLocation.lng };
setLoadingText("正在搜索周边美食...");
} else {
setLoadingText("正在获取位置...");
coords = await getLocation();
setLoadingText("正在搜索周边美食...");
}
const res = await fetch("/api/room/create", {
method: "POST",
@@ -127,6 +200,75 @@ export default function LandingPage() {
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.5, delay: 0.15 }}
>
<div ref={suggestRef} className="relative">
<div className="relative flex items-center">
<MapPin size={16} className="absolute left-3 text-zinc-400" />
<input
type="text"
placeholder="搜索位置(默认当前位置)"
value={locationQuery}
onChange={(e) => handleLocationInput(e.target.value)}
onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
disabled={loading}
className="h-10 w-full rounded-xl border border-zinc-200 bg-white pl-9 pr-9 text-sm text-zinc-700 outline-none transition-colors placeholder:text-zinc-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100 disabled:opacity-50"
/>
{(selectedLocation || locationQuery) && !loading && (
<button
onClick={clearLocation}
className="absolute right-2.5 flex h-5 w-5 items-center justify-center rounded-full text-zinc-400 hover:text-zinc-600"
>
<X size={14} />
</button>
)}
{fetchingSuggestions && (
<Loader2 size={14} className="absolute right-3 animate-spin text-zinc-300" />
)}
</div>
{selectedLocation && (
<div className="mt-1.5 flex items-center gap-1.5 px-1">
<Navigation size={12} className="shrink-0 text-emerald-500" />
<span className="truncate text-xs text-emerald-600">
{selectedLocation.district} {selectedLocation.address || selectedLocation.name}
</span>
</div>
)}
{!selectedLocation && !locationQuery && (
<div className="mt-1.5 flex items-center gap-1.5 px-1">
<Navigation size={12} className="shrink-0 text-zinc-400" />
<span className="text-xs text-zinc-400">使</span>
</div>
)}
<AnimatePresence>
{showSuggestions && (
<motion.ul
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.15 }}
className="absolute left-0 right-0 top-full z-50 mt-1 max-h-56 overflow-y-auto rounded-xl border border-zinc-100 bg-white py-1 shadow-lg"
>
{suggestions.map((s) => (
<li key={s.id}>
<button
onClick={() => handleSelectLocation(s)}
className="flex w-full items-start gap-2.5 px-3 py-2.5 text-left transition-colors hover:bg-emerald-50"
>
<MapPin size={14} className="mt-0.5 shrink-0 text-zinc-400" />
<div className="min-w-0">
<p className="truncate text-sm font-medium text-zinc-800">{s.name}</p>
<p className="truncate text-xs text-zinc-400">{s.district} {s.address}</p>
</div>
</button>
</li>
))}
</motion.ul>
)}
</AnimatePresence>
</div>
<button
onClick={handleCreate}
disabled={loading}