refactor(P1): 5 项代码质量改进 — 消除重复、拆分巨型组件、统一基础设施
Task 4: 统一 amap.ts 为完整 API 客户端 - 扩展 amap.ts 为统一客户端(amapFetch 8s 超时 + 错误处理) - 导出 searchPlaceText/searchPlaceAround/getInputTips/reverseGeocode/getTransitDirection - 精简 4 个 location route 为单行调用,blindboxPlanGen 删除 ~80 行内联 API 代码 Task 2: 抽取 ShareCardShell 消除三兄弟重复 - 新建 ShareCardShell.tsx 共享外框/背景/品牌头/QR 底部 - RestaurantShareCard 406→268 行,BlindboxShareCard 341→173 行,BlindboxPlanShareCard 277→159 行 Task 3: 拆分 BlindboxPlan.tsx (742→371 行) - 提取 planUtils.ts (guessCategory + formatDuration) - 提取 PoiSearchField / SortablePlanItem / PlanItemEditModal 三个独立组件 Task 1: 拆分 blindbox/[code]/page.tsx 上帝组件 (1300→509 行) - 提取 useBlindboxRoom / useBlindboxIdeas / useBlindboxPlan / useBlindboxDraw 四个 hooks - 提取 BlindboxPoolPhase / BlindboxRevealPhase 两个子组件 - 主页面仅保留 phase 协调 + hook 组装 + 子组件渲染 Task 5: 统一 SWR 数据获取层 - 新建 fetcher.ts (FetchError 携带 status,401 不重试) - 新建 useBlindboxRooms / useAchievements / useFavorites SWR hooks - useRoomPolling 改用共享 fetcher - blindbox 大厅/成就/个人中心页面删除手写 fetch 样板代码 - JWT 过期时自动弹出登录框而非反复重试
This commit is contained in:
@@ -19,17 +19,11 @@ import ContractHistoryItem from "@/components/ContractHistoryItem";
|
||||
import EmptyState from "@/components/EmptyState";
|
||||
import { Skeleton, RecordItemSkeleton } from "@/components/Skeleton";
|
||||
import { buildNavUrl } from "@/lib/navigation";
|
||||
import type { DecisionRecord, ContractRecord, Restaurant } from "@/types";
|
||||
import { useAchievements } from "@/hooks/useAchievements";
|
||||
import type { Restaurant } from "@/types";
|
||||
|
||||
type Tab = "decisions" | "contracts";
|
||||
|
||||
interface Stats {
|
||||
totalDecisions: number;
|
||||
totalContracts: number;
|
||||
completedContracts: number;
|
||||
completionRate: number;
|
||||
}
|
||||
|
||||
function firstImage(r: Restaurant): string {
|
||||
if (r.images?.length > 0) return r.images[0];
|
||||
const legacy = (r as unknown as Record<string, unknown>).image;
|
||||
@@ -38,16 +32,8 @@ function firstImage(r: Restaurant): string {
|
||||
|
||||
export default function AchievementsPage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tab, setTab] = useState<Tab>("decisions");
|
||||
const [stats, setStats] = useState<Stats>({
|
||||
totalDecisions: 0,
|
||||
totalContracts: 0,
|
||||
completedContracts: 0,
|
||||
completionRate: 0,
|
||||
});
|
||||
const [decisions, setDecisions] = useState<DecisionRecord[]>([]);
|
||||
const [contracts, setContracts] = useState<ContractRecord[]>([]);
|
||||
const [userId, setUserId] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRegistered()) {
|
||||
@@ -55,21 +41,11 @@ export default function AchievementsPage() {
|
||||
return;
|
||||
}
|
||||
const p = getCachedProfile();
|
||||
if (!p) return;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/user/achievements?userId=${p.id}`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
setStats(data.stats);
|
||||
setDecisions(data.decisions);
|
||||
setContracts(data.contracts);
|
||||
} catch (e) { console.error("AchievementsPage: fetch failed:", e); }
|
||||
finally { setLoading(false); }
|
||||
})();
|
||||
if (p) setUserId(p.id);
|
||||
}, [router]);
|
||||
|
||||
const { stats, decisions, contracts, isLoading: loading } = useAchievements(userId);
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
label: "决策记录",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiHandler, ApiError } from "@/lib/api";
|
||||
import { suggestAlternativeItems } from "@/lib/ai";
|
||||
import { searchPois } from "@/lib/blindboxPlanGen";
|
||||
import { searchPois } from "@/lib/amap";
|
||||
|
||||
export const POST = apiHandler(async (req) => {
|
||||
const { activity, time, location } = await req.json();
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Only available in development.
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireAmapApiKey } from "@/lib/amap";
|
||||
import { getTransitDirection } from "@/lib/amap";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
if (process.env.NODE_ENV !== "development") {
|
||||
@@ -22,69 +22,17 @@ export async function GET(req: NextRequest) {
|
||||
return NextResponse.json({ error: "需要 olat/olng/dlat/dlng 参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
const apiKey = requireAmapApiKey();
|
||||
const url = new URL("https://restapi.amap.com/v3/direction/transit/integrated");
|
||||
url.searchParams.set("key", apiKey);
|
||||
url.searchParams.set("origin", `${oLng},${oLat}`);
|
||||
url.searchParams.set("destination", `${dLng},${dLat}`);
|
||||
url.searchParams.set("city", city);
|
||||
url.searchParams.set("cityd", city);
|
||||
url.searchParams.set("output", "json");
|
||||
|
||||
const res = await fetch(url.toString());
|
||||
const raw = await res.json();
|
||||
|
||||
if (raw.status !== "1" || !raw.route?.transits?.length) {
|
||||
return NextResponse.json({ error: "未找到路线", raw });
|
||||
}
|
||||
|
||||
const transit = raw.route.transits[0];
|
||||
const durationMin = Math.ceil(Number(transit.duration) / 60);
|
||||
const distanceKm = Math.round(Number(raw.route.distance) / 100) / 10;
|
||||
|
||||
// Parse segments the same way as production code
|
||||
const parts: string[] = [];
|
||||
const segmentDebug: unknown[] = [];
|
||||
for (const seg of (transit.segments ?? []) as Record<string, unknown>[]) {
|
||||
const bus = seg.bus as { buslines?: Record<string, unknown>[] } | undefined;
|
||||
segmentDebug.push({
|
||||
hasWalking: !!seg.walking,
|
||||
hasBus: !!seg.bus,
|
||||
buslines: bus?.buslines?.map((l) => ({
|
||||
name: l.name,
|
||||
type: l.type,
|
||||
via_num: l.via_num,
|
||||
departure: (l.departure_stop as Record<string, unknown> | undefined)?.name,
|
||||
arrival: (l.arrival_stop as Record<string, unknown> | undefined)?.name,
|
||||
})),
|
||||
const result = await getTransitDirection({
|
||||
originLat: oLat,
|
||||
originLng: oLng,
|
||||
destLat: dLat,
|
||||
destLng: dLng,
|
||||
city,
|
||||
});
|
||||
if (!bus?.buslines?.length) continue;
|
||||
for (const line of bus.buslines) {
|
||||
const name = String(line.name ?? "");
|
||||
const viaNum = Number(line.via_num ?? 0);
|
||||
parts.push(viaNum > 0 ? `${name}(${viaNum}站)` : name);
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
return NextResponse.json({ error: "未找到路线" });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
parsed: {
|
||||
durationMin,
|
||||
distanceKm,
|
||||
description: parts.join(" → ") || "步行",
|
||||
},
|
||||
segmentDebug,
|
||||
rawTransit: {
|
||||
duration: transit.duration,
|
||||
nightflag: transit.nightflag,
|
||||
segmentCount: transit.segments?.length,
|
||||
},
|
||||
// First 3 routes for comparison
|
||||
allRoutes: raw.route.transits.slice(0, 3).map((t: Record<string, unknown>) => ({
|
||||
durationMin: Math.ceil(Number(t.duration) / 60),
|
||||
segments: (t.segments as Record<string, unknown>[] ?? []).map((s) => {
|
||||
const b = s.bus as { buslines?: Record<string, unknown>[] } | undefined;
|
||||
return b?.buslines?.map((l) => `${l.name}(${l.via_num}站)`) ?? ["步行"];
|
||||
}),
|
||||
})),
|
||||
});
|
||||
return NextResponse.json({ parsed: result });
|
||||
}
|
||||
|
||||
@@ -3,13 +3,11 @@ import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-u
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
|
||||
|
||||
const mockReverseGeocode = vi.fn();
|
||||
vi.mock("@/lib/amap", () => ({
|
||||
requireAmapApiKey: vi.fn().mockReturnValue("test-key"),
|
||||
reverseGeocode: (...args: unknown[]) => mockReverseGeocode(...args),
|
||||
}));
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
import { GET } from "./route";
|
||||
|
||||
const mockCtx = { params: Promise.resolve({}) };
|
||||
@@ -20,19 +18,9 @@ beforeEach(() => {
|
||||
|
||||
describe("GET /api/location/regeo", () => {
|
||||
it("returns reverse geocoded location", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
status: "1",
|
||||
regeocode: {
|
||||
formatted_address: "上海市黄浦区人民大道",
|
||||
addressComponent: {
|
||||
district: "黄浦区",
|
||||
township: "南京东路街道",
|
||||
neighborhood: { name: "人民广场" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
mockReverseGeocode.mockResolvedValue({
|
||||
name: "黄浦区 南京东路街道 人民广场",
|
||||
formatted: "上海市黄浦区人民大道",
|
||||
});
|
||||
|
||||
const req = createRequest("/api/location/regeo?lat=31.23&lng=121.47");
|
||||
@@ -45,9 +33,7 @@ describe("GET /api/location/regeo", () => {
|
||||
});
|
||||
|
||||
it("returns null name when API returns no result", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
json: () => Promise.resolve({ status: "0" }),
|
||||
});
|
||||
mockReverseGeocode.mockResolvedValue({ name: null, formatted: null });
|
||||
|
||||
const req = createRequest("/api/location/regeo?lat=31.23&lng=121.47");
|
||||
const res = await GET(req, mockCtx);
|
||||
@@ -62,7 +48,8 @@ describe("GET /api/location/regeo", () => {
|
||||
});
|
||||
|
||||
it("returns 503 when API unavailable", async () => {
|
||||
mockFetch.mockRejectedValue(new Error("network"));
|
||||
const { ApiError } = await import("@/lib/api");
|
||||
mockReverseGeocode.mockRejectedValue(new ApiError("位置服务暂时不可用,请稍后重试", 503));
|
||||
|
||||
const req = createRequest("/api/location/regeo?lat=31.23&lng=121.47");
|
||||
const res = await GET(req, mockCtx);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiHandler, ApiError } from "@/lib/api";
|
||||
import { requireAmapApiKey } from "@/lib/amap";
|
||||
import { reverseGeocode } from "@/lib/amap";
|
||||
|
||||
export const GET = apiHandler(async (req) => {
|
||||
const lat = req.nextUrl.searchParams.get("lat");
|
||||
@@ -8,37 +8,6 @@ export const GET = apiHandler(async (req) => {
|
||||
|
||||
if (!lat || !lng) throw new ApiError("lat and lng are required");
|
||||
|
||||
const apiKey = requireAmapApiKey();
|
||||
|
||||
const url = new URL("https://restapi.amap.com/v3/geocode/regeo");
|
||||
url.searchParams.set("key", apiKey);
|
||||
url.searchParams.set("location", `${lng},${lat}`);
|
||||
url.searchParams.set("extensions", "base");
|
||||
|
||||
let data;
|
||||
try {
|
||||
const res = await fetch(url.toString());
|
||||
data = await res.json();
|
||||
} catch {
|
||||
throw new ApiError("位置服务暂时不可用,请稍后重试", 503);
|
||||
}
|
||||
|
||||
if (data.status !== "1" || !data.regeocode) {
|
||||
return NextResponse.json({ name: null });
|
||||
}
|
||||
|
||||
const comp = data.regeocode.addressComponent;
|
||||
const district = comp?.district || comp?.city || "";
|
||||
const township = comp?.township || "";
|
||||
const neighborhood = comp?.neighborhood?.name || "";
|
||||
|
||||
const name = [district, township, neighborhood]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.trim();
|
||||
|
||||
return NextResponse.json({
|
||||
name: name || data.regeocode.formatted_address || null,
|
||||
formatted: data.regeocode.formatted_address || null,
|
||||
});
|
||||
const result = await reverseGeocode({ lat: Number(lat), lng: Number(lng) });
|
||||
return NextResponse.json(result);
|
||||
});
|
||||
|
||||
@@ -3,13 +3,11 @@ import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-u
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
|
||||
|
||||
const mockSearchPlaceText = vi.fn();
|
||||
vi.mock("@/lib/amap", () => ({
|
||||
requireAmapApiKey: vi.fn().mockReturnValue("test-key"),
|
||||
searchPlaceText: (...args: unknown[]) => mockSearchPlaceText(...args),
|
||||
}));
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
import { GET } from "./route";
|
||||
|
||||
const mockCtx = { params: Promise.resolve({}) };
|
||||
@@ -20,21 +18,17 @@ beforeEach(() => {
|
||||
|
||||
describe("GET /api/location/search", () => {
|
||||
it("returns search results", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
status: "1",
|
||||
pois: [
|
||||
mockSearchPlaceText.mockResolvedValue([
|
||||
{
|
||||
id: "poi-1",
|
||||
name: "星巴克",
|
||||
address: "南京路1号",
|
||||
location: "121.4,31.2",
|
||||
business: { rating: "4.5", cost: "40" },
|
||||
lat: 31.2,
|
||||
lng: 121.4,
|
||||
rating: 4.5,
|
||||
cost: 40,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
]);
|
||||
|
||||
const req = createRequest("/api/location/search?keywords=星巴克");
|
||||
const res = await GET(req, mockCtx);
|
||||
@@ -48,9 +42,7 @@ describe("GET /api/location/search", () => {
|
||||
});
|
||||
|
||||
it("returns empty when no results", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
json: () => Promise.resolve({ status: "1", pois: [] }),
|
||||
});
|
||||
mockSearchPlaceText.mockResolvedValue([]);
|
||||
|
||||
const req = createRequest("/api/location/search?keywords=不存在的地方");
|
||||
const res = await GET(req, mockCtx);
|
||||
@@ -65,7 +57,8 @@ describe("GET /api/location/search", () => {
|
||||
});
|
||||
|
||||
it("returns 503 when API unavailable", async () => {
|
||||
mockFetch.mockRejectedValue(new Error("network error"));
|
||||
const { ApiError } = await import("@/lib/api");
|
||||
mockSearchPlaceText.mockRejectedValue(new ApiError("位置服务暂时不可用,请稍后重试", 503));
|
||||
|
||||
const req = createRequest("/api/location/search?keywords=test");
|
||||
const res = await GET(req, mockCtx);
|
||||
|
||||
@@ -1,19 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiHandler, ApiError } from "@/lib/api";
|
||||
import { requireAmapApiKey } from "@/lib/amap";
|
||||
|
||||
interface AmapPoiV5 {
|
||||
id: string;
|
||||
name: string;
|
||||
address?: string;
|
||||
location?: string;
|
||||
type?: string;
|
||||
business?: {
|
||||
rating?: string;
|
||||
cost?: string;
|
||||
tel?: string;
|
||||
};
|
||||
}
|
||||
import { searchPlaceText } from "@/lib/amap";
|
||||
|
||||
export const GET = apiHandler(async (req) => {
|
||||
const keywords = req.nextUrl.searchParams.get("keywords")?.trim();
|
||||
@@ -22,48 +9,6 @@ export const GET = apiHandler(async (req) => {
|
||||
const city = req.nextUrl.searchParams.get("city")?.trim();
|
||||
const types = req.nextUrl.searchParams.get("types")?.trim();
|
||||
|
||||
const apiKey = requireAmapApiKey();
|
||||
|
||||
const url = new URL("https://restapi.amap.com/v5/place/text");
|
||||
url.searchParams.set("key", apiKey);
|
||||
url.searchParams.set("keywords", keywords);
|
||||
url.searchParams.set("show_fields", "business");
|
||||
url.searchParams.set("page_size", "10");
|
||||
|
||||
if (city) url.searchParams.set("region", city);
|
||||
if (types) url.searchParams.set("types", types);
|
||||
|
||||
let data;
|
||||
try {
|
||||
const res = await fetch(url.toString());
|
||||
data = await res.json();
|
||||
} catch {
|
||||
throw new ApiError("位置服务暂时不可用,请稍后重试", 503);
|
||||
}
|
||||
|
||||
if (data.status !== "1" || !data.pois?.length) {
|
||||
return NextResponse.json([]);
|
||||
}
|
||||
|
||||
const results = data.pois
|
||||
.filter((poi: AmapPoiV5) => poi.location)
|
||||
.map((poi: AmapPoiV5) => {
|
||||
const [lng, lat] = (poi.location ?? "0,0").split(",").map(Number);
|
||||
const ratingStr = poi.business?.rating;
|
||||
const rating = ratingStr && ratingStr !== "[]" ? parseFloat(ratingStr) || null : null;
|
||||
const costStr = poi.business?.cost;
|
||||
const cost = costStr && costStr !== "[]" && costStr !== "0" ? Number(costStr) : null;
|
||||
|
||||
return {
|
||||
id: poi.id,
|
||||
name: poi.name,
|
||||
address: poi.address || "",
|
||||
lat,
|
||||
lng,
|
||||
rating,
|
||||
cost,
|
||||
};
|
||||
});
|
||||
|
||||
const results = await searchPlaceText({ keywords, city: city || undefined, types: types || undefined });
|
||||
return NextResponse.json(results);
|
||||
});
|
||||
|
||||
@@ -3,13 +3,11 @@ import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-u
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({ prisma: {} }));
|
||||
|
||||
const mockGetInputTips = vi.fn();
|
||||
vi.mock("@/lib/amap", () => ({
|
||||
requireAmapApiKey: vi.fn().mockReturnValue("test-key"),
|
||||
getInputTips: (...args: unknown[]) => mockGetInputTips(...args),
|
||||
}));
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
import { GET } from "./route";
|
||||
|
||||
const mockCtx = { params: Promise.resolve({}) };
|
||||
@@ -20,21 +18,16 @@ beforeEach(() => {
|
||||
|
||||
describe("GET /api/location/suggest", () => {
|
||||
it("returns suggestions", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
status: "1",
|
||||
tips: [
|
||||
mockGetInputTips.mockResolvedValue([
|
||||
{
|
||||
id: "tip-1",
|
||||
name: "人民广场",
|
||||
district: "黄浦区",
|
||||
address: "人民大道",
|
||||
location: "121.4737,31.2304",
|
||||
lat: 31.2304,
|
||||
lng: 121.4737,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
]);
|
||||
|
||||
const req = createRequest("/api/location/suggest?keywords=人民广场");
|
||||
const res = await GET(req, mockCtx);
|
||||
@@ -53,16 +46,9 @@ describe("GET /api/location/suggest", () => {
|
||||
});
|
||||
|
||||
it("filters tips without location", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
status: "1",
|
||||
tips: [
|
||||
{ id: "tip-1", name: "有位置", location: "121.4,31.2" },
|
||||
{ id: "tip-2", name: "无位置", location: "" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
mockGetInputTips.mockResolvedValue([
|
||||
{ id: "tip-1", name: "有位置", district: "", address: "", lat: 31.2, lng: 121.4 },
|
||||
]);
|
||||
|
||||
const req = createRequest("/api/location/suggest?keywords=test");
|
||||
const res = await GET(req, mockCtx);
|
||||
@@ -72,7 +58,8 @@ describe("GET /api/location/suggest", () => {
|
||||
});
|
||||
|
||||
it("returns 503 when API fails", async () => {
|
||||
mockFetch.mockRejectedValue(new Error("network"));
|
||||
const { ApiError } = await import("@/lib/api");
|
||||
mockGetInputTips.mockRejectedValue(new ApiError("位置服务暂时不可用,请稍后重试", 503));
|
||||
|
||||
const req = createRequest("/api/location/suggest?keywords=test");
|
||||
const res = await GET(req, mockCtx);
|
||||
|
||||
@@ -1,47 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiHandler, ApiError } from "@/lib/api";
|
||||
import { requireAmapApiKey } from "@/lib/amap";
|
||||
import { apiHandler } from "@/lib/api";
|
||||
import { getInputTips } from "@/lib/amap";
|
||||
|
||||
export const GET = apiHandler(async (req) => {
|
||||
const keywords = req.nextUrl.searchParams.get("keywords")?.trim();
|
||||
if (!keywords) return NextResponse.json([]);
|
||||
|
||||
const apiKey = requireAmapApiKey();
|
||||
|
||||
const location = req.nextUrl.searchParams.get("location");
|
||||
|
||||
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");
|
||||
if (location) {
|
||||
url.searchParams.set("location", location);
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
const res = await fetch(url.toString());
|
||||
data = await res.json();
|
||||
} catch {
|
||||
throw new ApiError("位置服务暂时不可用,请稍后重试", 503);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
const location = req.nextUrl.searchParams.get("location") || undefined;
|
||||
const suggestions = await getInputTips({ keywords, location });
|
||||
return NextResponse.json(suggestions);
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+23
-51
@@ -23,6 +23,7 @@ import Button from "@/components/Button";
|
||||
import Input from "@/components/Input";
|
||||
import { BlindboxListSkeleton } from "@/components/Skeleton";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import { useBlindboxRooms } from "@/hooks/useBlindboxRooms";
|
||||
import type { UserProfile } from "@/types";
|
||||
|
||||
interface RoomSummary {
|
||||
@@ -169,77 +170,48 @@ export default function BlindboxLobbyPage() {
|
||||
const [loggedIn, setLoggedIn] = useState(false);
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [showAuth, setShowAuth] = useState(false);
|
||||
const [rooms, setRooms] = useState<RoomSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const toast = useToast();
|
||||
const { rooms: swrRooms, isLoading: swrLoading, isUnauthorized, error: swrError, mutate: mutateRooms } = useBlindboxRooms(
|
||||
loggedIn && profile ? profile.id : undefined,
|
||||
);
|
||||
const rooms = swrRooms;
|
||||
const loading = !loggedIn ? false : swrLoading;
|
||||
|
||||
// JWT 过期时,重置登录状态让用户重新登录
|
||||
useEffect(() => {
|
||||
if (isUnauthorized && loggedIn) {
|
||||
setLoggedIn(false);
|
||||
setProfile(null);
|
||||
setShowAuth(true);
|
||||
}
|
||||
}, [isUnauthorized, loggedIn]);
|
||||
const createNameRef = useRef<HTMLInputElement>(null);
|
||||
const joinCodeRef = useRef<HTMLInputElement>(null);
|
||||
const [joinCodeLength, setJoinCodeLength] = useState(0);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [joining, setJoining] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [loadError, setLoadError] = useState<string | false>(false);
|
||||
const loadError = swrError ? (swrError instanceof Error ? swrError.message : "未知错误") : false;
|
||||
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
const fetchRooms = useCallback(async (silent = false) => {
|
||||
const p = getCachedProfile();
|
||||
if (!p) return;
|
||||
if (!silent) {
|
||||
setLoading(true);
|
||||
setLoadError(false);
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/blindbox/rooms?userId=${p.id}`, { cache: "no-store" });
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => null);
|
||||
throw new Error(body?.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
setRooms(Array.isArray(data.rooms) ? data.rooms : []);
|
||||
setLoadError(false);
|
||||
} catch (e) {
|
||||
if (!silent) setLoadError(e instanceof Error ? e.message : "未知错误");
|
||||
} finally {
|
||||
if (!silent) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const registered = isRegistered();
|
||||
setLoggedIn(registered);
|
||||
if (registered) {
|
||||
setProfile(getCachedProfile());
|
||||
fetchRooms();
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
if (registered) setProfile(getCachedProfile());
|
||||
setHydrated(true);
|
||||
}, [fetchRooms]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
const registered = isRegistered();
|
||||
setLoggedIn(registered);
|
||||
setProfile(registered ? getCachedProfile() : null);
|
||||
if (registered) fetchRooms();
|
||||
};
|
||||
window.addEventListener("nowhatever_auth", handler);
|
||||
return () => window.removeEventListener("nowhatever_auth", handler);
|
||||
}, [fetchRooms]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loggedIn) return;
|
||||
const refresh = () => fetchRooms(true);
|
||||
window.addEventListener("focus", refresh);
|
||||
window.addEventListener("pageshow", refresh);
|
||||
return () => {
|
||||
window.removeEventListener("focus", refresh);
|
||||
window.removeEventListener("pageshow", refresh);
|
||||
};
|
||||
}, [loggedIn, fetchRooms]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (rooms.length > 0) setJoinCodeLength(0);
|
||||
@@ -249,8 +221,8 @@ export default function BlindboxLobbyPage() {
|
||||
setProfile(p);
|
||||
setLoggedIn(true);
|
||||
setShowAuth(false);
|
||||
fetchRooms();
|
||||
}, [fetchRooms]);
|
||||
mutateRooms();
|
||||
}, [mutateRooms]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (creating || !profile) return;
|
||||
@@ -307,7 +279,7 @@ export default function BlindboxLobbyPage() {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "操作失败");
|
||||
}
|
||||
setRooms((prev) => prev.filter((r) => r.id !== room.id));
|
||||
mutateRooms((prev) => prev ? { rooms: prev.rooms.filter((r) => r.id !== room.id) } : prev, false);
|
||||
setConfirmDeleteId(null);
|
||||
toast.show(room.creatorId === profile.id ? "房间已删除" : "已退出房间");
|
||||
} catch (e) {
|
||||
@@ -441,7 +413,7 @@ export default function BlindboxLobbyPage() {
|
||||
<p className="max-w-xs text-center text-[11px] text-muted/60 break-all">{loadError}</p>
|
||||
)}
|
||||
<button
|
||||
onClick={() => fetchRooms()}
|
||||
onClick={() => mutateRooms()}
|
||||
className="mt-1 text-xs font-medium text-purple-400 active:text-purple-300"
|
||||
>
|
||||
点击重试
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
@@ -22,10 +22,11 @@ import Card from "@/components/Card";
|
||||
import Input from "@/components/Input";
|
||||
import ProfileFavoritesCard from "@/components/ProfileFavoritesCard";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import { useFavorites } from "@/hooks/useFavorites";
|
||||
import { ProfileCardSkeleton, RecordItemSkeleton } from "@/components/Skeleton";
|
||||
import { getUserId, getCachedProfile, setCachedProfile, setCachedPreferences, logout } from "@/lib/userId";
|
||||
import { getAvatarBg, AVATARS } from "@/lib/avatars";
|
||||
import type { UserProfile, UserPreferences, FavoriteRecord } from "@/types";
|
||||
import type { UserProfile, UserPreferences } from "@/types";
|
||||
|
||||
export default function ProfilePage() {
|
||||
const router = useRouter();
|
||||
@@ -33,8 +34,7 @@ export default function ProfilePage() {
|
||||
const [profile, setProfile] = useState<(UserProfile & { email?: string; preferences?: UserPreferences; decisionCount?: number }) | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [favorites, setFavorites] = useState<FavoriteRecord[]>([]);
|
||||
const [favLoading, setFavLoading] = useState(false);
|
||||
const { favorites, isLoading: favLoading, mutate: mutateFavorites } = useFavorites(userId || undefined);
|
||||
|
||||
const [editingUsername, setEditingUsername] = useState(false);
|
||||
const [newUsername, setNewUsername] = useState("");
|
||||
@@ -87,17 +87,6 @@ export default function ProfilePage() {
|
||||
.finally(() => setLoading(false));
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
|
||||
setFavLoading(true);
|
||||
fetch(`/api/user/favorite?userId=${userId}`)
|
||||
.then((r) => { if (!r.ok) throw new Error(); return r.json(); })
|
||||
.then((data) => setFavorites(Array.isArray(data) ? data : []))
|
||||
.catch((e) => { console.error("ProfilePage: fetch favorites failed:", e); })
|
||||
.finally(() => setFavLoading(false));
|
||||
}, [userId]);
|
||||
|
||||
const handleSaveUsername = async () => {
|
||||
const trimmed = newUsername.trim();
|
||||
if (trimmed.length < 2 || trimmed.length > 16) {
|
||||
@@ -220,7 +209,7 @@ export default function ProfilePage() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId, favoriteId: favId }),
|
||||
});
|
||||
setFavorites((f) => f.filter((x) => x.id !== favId));
|
||||
mutateFavorites((prev) => prev?.filter((x) => x.id !== favId), false);
|
||||
toast.show("已取消收藏");
|
||||
} catch {
|
||||
toast.show("操作失败");
|
||||
|
||||
+22
-393
@@ -3,8 +3,6 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
MapPin,
|
||||
Clock,
|
||||
Navigation,
|
||||
Share2,
|
||||
RefreshCw,
|
||||
@@ -12,9 +10,6 @@ import {
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
CornerDownLeft,
|
||||
GripVertical,
|
||||
Pencil,
|
||||
X,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
@@ -27,26 +22,15 @@ import {
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
arrayMove,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
|
||||
import { CategoryBadge } from "@/components/BlindboxMyIdeas";
|
||||
import Button from "@/components/Button";
|
||||
import Modal from "@/components/Modal";
|
||||
import SortablePlanItem from "@/components/SortablePlanItem";
|
||||
import PlanItemEditModal from "@/components/PlanItemEditModal";
|
||||
import type { WeekendPlanData, PlanItem } from "@/types";
|
||||
|
||||
interface AltSuggestion {
|
||||
activity: string;
|
||||
poi: string;
|
||||
address: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface BlindboxPlanProps {
|
||||
days: WeekendPlanData[];
|
||||
onAccept: () => void;
|
||||
@@ -56,217 +40,11 @@ interface BlindboxPlanProps {
|
||||
accepted?: boolean;
|
||||
regenerating?: boolean;
|
||||
onDaysChange?: (newDays: WeekendPlanData[]) => void;
|
||||
/** "lng,lat" 格式,用于 POI 搜索附近优先 */
|
||||
location?: string;
|
||||
/** 出发地名称,用于显示在出发/返回连接器上 */
|
||||
startLocationLabel?: string;
|
||||
onRefine?: (instruction: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function guessCategory(activity: string): string | null {
|
||||
const lower = activity.toLowerCase();
|
||||
if (/吃|餐|饭|火锅|烧烤|面|菜|厨|食/.test(lower)) return "dining";
|
||||
if (/手作|工坊|烘焙|插花|陶艺|DIY|体验/.test(lower)) return "experience";
|
||||
if (/露营|徒步|赶海|农场|自然|野|营地/.test(lower)) return "nature";
|
||||
if (/公园|山|湖|海|户外|骑/.test(lower)) return "outdoor";
|
||||
if (/电影|KTV|密室|游戏|桌游|剧/.test(lower)) return "entertainment";
|
||||
if (/逛街|购物|商场|买/.test(lower)) return "shopping";
|
||||
if (/运动|健身|球|跑|游泳|瑜伽/.test(lower)) return "sports";
|
||||
if (/博物馆|展览|美术|书/.test(lower)) return "culture";
|
||||
if (/咖啡|茶|SPA|按摩|下午茶/.test(lower)) return "relaxation";
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatDuration(minutes: number): string {
|
||||
if (minutes >= 60) {
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
return m > 0 ? `${h}h${m}min` : `${h}h`;
|
||||
}
|
||||
return `${minutes}min`;
|
||||
}
|
||||
|
||||
interface PoiSuggestion {
|
||||
id: string;
|
||||
name: string;
|
||||
district: string;
|
||||
address: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
interface PoiSearchFieldProps {
|
||||
poi: string;
|
||||
address: string;
|
||||
onSelect: (s: PoiSuggestion) => void;
|
||||
location?: string;
|
||||
}
|
||||
|
||||
function PoiSearchField({ poi, address, onSelect, location }: PoiSearchFieldProps) {
|
||||
const [query, setQuery] = useState(poi);
|
||||
const [suggestions, setSuggestions] = useState<PoiSuggestion[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const lastSelectedRef = useRef(poi);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
if (!query.trim() || query === lastSelectedRef.current) {
|
||||
setSuggestions([]);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
timerRef.current = setTimeout(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({ keywords: query });
|
||||
if (location) params.set("location", location);
|
||||
const res = await fetch(`/api/location/suggest?${params}`);
|
||||
if (res.ok) {
|
||||
const data: PoiSuggestion[] = await res.json();
|
||||
setSuggestions(data);
|
||||
setOpen(data.length > 0);
|
||||
}
|
||||
} catch (e) { console.error("PoiSearchField fetch failed:", e); }
|
||||
finally { setLoading(false); }
|
||||
}, 400);
|
||||
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
|
||||
}, [query]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[11px] font-medium text-muted">地点</span>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="搜索地点名称..."
|
||||
className="h-9 w-full rounded-lg bg-elevated px-3 pr-8 text-sm text-foreground outline-none ring-1 ring-border focus:ring-purple-600"
|
||||
/>
|
||||
{loading && (
|
||||
<Loader2 size={14} className="absolute right-2.5 top-2.5 animate-spin text-muted" />
|
||||
)}
|
||||
</div>
|
||||
{open && suggestions.length > 0 && (
|
||||
<div className="overflow-hidden rounded-lg bg-elevated ring-1 ring-border">
|
||||
{suggestions.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
lastSelectedRef.current = s.name;
|
||||
setQuery(s.name);
|
||||
setOpen(false);
|
||||
setSuggestions([]);
|
||||
onSelect(s);
|
||||
}}
|
||||
className="flex w-full flex-col border-b border-border/40 px-3 py-2 text-left last:border-0 active:bg-purple-600/10"
|
||||
>
|
||||
<span className="text-xs font-medium text-foreground">{s.name}</span>
|
||||
<span className="truncate text-[10px] text-muted">{s.district} {s.address}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{address && !open && (
|
||||
<p className="flex items-center gap-1 text-[10px] text-dim">
|
||||
<MapPin size={9} className="shrink-0" />
|
||||
{address}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SortablePlanItemProps {
|
||||
id: string;
|
||||
item: PlanItem;
|
||||
index: number;
|
||||
canEdit: boolean;
|
||||
onEdit: () => void;
|
||||
}
|
||||
|
||||
function SortablePlanItem({ id, item, canEdit, onEdit }: SortablePlanItemProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||
useSortable({ id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className="relative mb-5">
|
||||
<div className="absolute -left-6 top-4 flex h-[18px] w-[18px] items-center justify-center rounded-full bg-purple-600/20 ring-2 ring-background">
|
||||
<div className="h-2 w-2 rounded-full bg-purple-400" />
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-surface/80 p-4 ring-1 ring-border/80">
|
||||
<div className="flex items-start gap-2.5">
|
||||
{canEdit && (
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="mt-1 shrink-0 touch-none cursor-grab text-muted/40 active:cursor-grabbing active:text-muted"
|
||||
aria-label="拖拽排序"
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</button>
|
||||
)}
|
||||
<span className="mt-0.5 min-w-[38px] text-sm font-black text-purple-400">{item.time}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CategoryBadge category={guessCategory(item.activity)} />
|
||||
<p className="truncate text-sm font-bold text-heading">{item.activity}</p>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-1 text-[11px] text-muted">
|
||||
<MapPin size={10} className="shrink-0" />
|
||||
<span className="truncate">{item.poi}</span>
|
||||
</div>
|
||||
{item.address && (
|
||||
<p className="mt-1 truncate text-[10px] text-dim">{item.address}</p>
|
||||
)}
|
||||
<div className="mt-2.5 flex items-center gap-3">
|
||||
<span className="flex items-center gap-1 text-[10px] text-dim">
|
||||
<Clock size={9} />
|
||||
{formatDuration(item.duration)}
|
||||
</span>
|
||||
{item.lat !== 0 && item.lng !== 0 && (
|
||||
<a
|
||||
href={`https://uri.amap.com/marker?position=${item.lng},${item.lat}&name=${encodeURIComponent(item.poi)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-[10px] font-medium text-purple-400/70 active:text-purple-400"
|
||||
>
|
||||
<Navigation size={9} />
|
||||
导航
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{item.reason && (
|
||||
<p className="mt-2.5 border-t border-border/30 pt-2 text-[10px] leading-relaxed text-dim italic">
|
||||
{item.reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="mt-0.5 shrink-0 text-muted/40 active:text-purple-400"
|
||||
aria-label="编辑"
|
||||
>
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BlindboxPlan({
|
||||
days,
|
||||
onAccept,
|
||||
@@ -291,8 +69,6 @@ export default function BlindboxPlan({
|
||||
const [draft, setDraft] = useState<PlanItem | null>(null);
|
||||
const [refineInput, setRefineInput] = useState("");
|
||||
const [refining, setRefining] = useState(false);
|
||||
const [suggestingAlt, setSuggestingAlt] = useState(false);
|
||||
const [altSuggestions, setAltSuggestions] = useState<AltSuggestion[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||
@@ -332,20 +108,14 @@ export default function BlindboxPlan({
|
||||
onDaysChange(newDays);
|
||||
setEditingItem(null);
|
||||
setDraft(null);
|
||||
setAltSuggestions([]);
|
||||
}
|
||||
|
||||
// Cross-day move: immediately call onDaysChange
|
||||
function handleMoveToDayIndex(targetDayIndex: number) {
|
||||
if (!draft || !editingItem || !onDaysChange) return;
|
||||
if (targetDayIndex === editingItem.dayIndex) return;
|
||||
|
||||
const newDays = days.map((day, di) => {
|
||||
if (di === editingItem.dayIndex) {
|
||||
return {
|
||||
...day,
|
||||
items: day.items.filter((_, ii) => ii !== editingItem.itemIndex),
|
||||
};
|
||||
return { ...day, items: day.items.filter((_, ii) => ii !== editingItem.itemIndex) };
|
||||
}
|
||||
if (di === targetDayIndex) {
|
||||
return { ...day, items: [...day.items, draft] };
|
||||
@@ -368,28 +138,11 @@ export default function BlindboxPlan({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSuggestAlt() {
|
||||
if (!draft || suggestingAlt) return;
|
||||
setSuggestingAlt(true);
|
||||
try {
|
||||
const res = await fetch("/api/blindbox/plan/suggest-item", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ activity: draft.activity, time: draft.time, location }),
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
setAltSuggestions(data.suggestions ?? []);
|
||||
} finally {
|
||||
setSuggestingAlt(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentDay) return null;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{/* Day header — sticky top */}
|
||||
{/* Day header */}
|
||||
<div className="shrink-0 pb-3 text-center">
|
||||
<motion.div
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-purple-600/15 px-3 py-1 text-xs font-bold text-purple-400"
|
||||
@@ -430,10 +183,7 @@ export default function BlindboxPlan({
|
||||
</div>
|
||||
|
||||
{/* Scrollable timeline */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="min-h-0 flex-1 overflow-y-auto scrollbar-none"
|
||||
>
|
||||
<div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto scrollbar-none">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={dayIndex}
|
||||
@@ -455,7 +205,7 @@ export default function BlindboxPlan({
|
||||
items={currentDay.items.map((_, i) => `${dayIndex}-${i}`)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{/* Transit from home to first activity */}
|
||||
{/* Transit from home */}
|
||||
{currentDay.transitFromStart != null && (
|
||||
<div className="flex items-start gap-1.5 py-2 pl-1">
|
||||
<Navigation size={9} className="mt-0.5 shrink-0 text-purple-400/40" />
|
||||
@@ -480,14 +230,13 @@ export default function BlindboxPlan({
|
||||
<SortablePlanItem
|
||||
id={`${dayIndex}-${i}`}
|
||||
item={item}
|
||||
index={i}
|
||||
canEdit={canEdit}
|
||||
onEdit={() => {
|
||||
setEditingItem({ dayIndex, itemIndex: i });
|
||||
setDraft({ ...item });
|
||||
}}
|
||||
/>
|
||||
{/* Transit connector to next activity */}
|
||||
{/* Transit connector */}
|
||||
{item.transitToNext != null && i < currentDay.items.length - 1 && (
|
||||
<div className="flex items-start gap-1.5 py-2 pl-1">
|
||||
<Navigation size={9} className="mt-0.5 shrink-0 text-purple-400/40" />
|
||||
@@ -501,7 +250,7 @@ export default function BlindboxPlan({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Transit back home after last activity */}
|
||||
{/* Transit back home */}
|
||||
{i === currentDay.items.length - 1 && currentDay.transitToEnd != null && (
|
||||
<div className="flex items-start gap-1.5 py-2 pl-1">
|
||||
<Navigation size={9} className="mt-0.5 shrink-0 text-purple-400/40" />
|
||||
@@ -523,7 +272,7 @@ export default function BlindboxPlan({
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Back to pool — at end of scroll content */}
|
||||
{/* Back to pool */}
|
||||
<div className="mt-6 flex justify-center pb-4">
|
||||
<button
|
||||
onClick={onBack}
|
||||
@@ -535,9 +284,8 @@ export default function BlindboxPlan({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fixed bottom bar — actions + day navigation */}
|
||||
{/* Fixed bottom bar */}
|
||||
<div className="shrink-0 border-t border-border/40 bg-background/80 pt-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] backdrop-blur-lg">
|
||||
{/* Refine input (Plan A) */}
|
||||
{onRefine && (
|
||||
<div className="mx-auto flex max-w-sm items-center gap-2 px-4 pb-2">
|
||||
<input
|
||||
@@ -558,7 +306,6 @@ export default function BlindboxPlan({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Day navigation */}
|
||||
{days.length > 1 && (
|
||||
<div className="mx-auto mb-2.5 flex max-w-sm items-center justify-center gap-2 px-4">
|
||||
{hasPrev && (
|
||||
@@ -589,34 +336,17 @@ export default function BlindboxPlan({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="mx-auto flex max-w-sm items-center justify-center gap-3 px-4">
|
||||
{accepted ? (
|
||||
<Button
|
||||
onClick={onShare}
|
||||
variant="purple"
|
||||
shape="pill"
|
||||
icon={<Share2 size={14} />}
|
||||
>
|
||||
<Button onClick={onShare} variant="purple" shape="pill" icon={<Share2 size={14} />}>
|
||||
分享计划
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
onClick={onAccept}
|
||||
variant="purple"
|
||||
shape="pill"
|
||||
icon={<Sparkles size={14} />}
|
||||
>
|
||||
<Button onClick={onAccept} variant="purple" shape="pill" icon={<Sparkles size={14} />}>
|
||||
接受契约
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onRegenerate}
|
||||
variant="secondary"
|
||||
shape="pill"
|
||||
loading={regenerating}
|
||||
icon={<RefreshCw size={14} />}
|
||||
>
|
||||
<Button onClick={onRegenerate} variant="secondary" shape="pill" loading={regenerating} icon={<RefreshCw size={14} />}>
|
||||
换一个方案
|
||||
</Button>
|
||||
</>
|
||||
@@ -625,118 +355,17 @@ export default function BlindboxPlan({
|
||||
</div>
|
||||
|
||||
{/* Edit item modal */}
|
||||
<Modal open={!!editingItem && !!draft} onClose={() => { setEditingItem(null); setDraft(null); setAltSuggestions([]); }} variant="sheet">
|
||||
{draft && editingItem && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-bold text-heading">编辑活动</h3>
|
||||
<button
|
||||
onClick={() => { setEditingItem(null); setDraft(null); setAltSuggestions([]); }}
|
||||
className="text-muted active:text-foreground"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-[11px] font-medium text-muted">活动名称</span>
|
||||
<input
|
||||
type="text"
|
||||
value={draft.activity}
|
||||
onChange={(e) => setDraft({ ...draft, activity: e.target.value })}
|
||||
className="h-9 rounded-lg bg-elevated px-3 text-sm text-foreground outline-none ring-1 ring-border focus:ring-purple-600"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<label className="flex flex-1 flex-col gap-1">
|
||||
<span className="text-[11px] font-medium text-muted">时间</span>
|
||||
<input
|
||||
type="time"
|
||||
value={draft.time}
|
||||
onChange={(e) => setDraft({ ...draft, time: e.target.value })}
|
||||
className="h-9 rounded-lg bg-elevated px-3 text-sm text-foreground outline-none ring-1 ring-border focus:ring-purple-600"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-1 flex-col gap-1">
|
||||
<span className="text-[11px] font-medium text-muted">时长(分钟)</span>
|
||||
<input
|
||||
type="number"
|
||||
step={15}
|
||||
min={15}
|
||||
value={draft.duration}
|
||||
onChange={(e) => setDraft({ ...draft, duration: Number(e.target.value) })}
|
||||
className="h-9 rounded-lg bg-elevated px-3 text-sm text-foreground outline-none ring-1 ring-border focus:ring-purple-600"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<PoiSearchField
|
||||
key={`${editingItem.dayIndex}-${editingItem.itemIndex}`}
|
||||
poi={draft.poi}
|
||||
address={draft.address}
|
||||
<PlanItemEditModal
|
||||
open={!!editingItem && !!draft}
|
||||
editingItem={editingItem}
|
||||
draft={draft}
|
||||
days={days}
|
||||
location={location}
|
||||
onSelect={(s) => setDraft({ ...draft, poi: s.name, address: s.address, lat: s.lat, lng: s.lng })}
|
||||
onDraftChange={setDraft}
|
||||
onSave={handleSaveDraft}
|
||||
onClose={() => { setEditingItem(null); setDraft(null); }}
|
||||
onMoveToDayIndex={handleMoveToDayIndex}
|
||||
/>
|
||||
|
||||
{/* AI 推荐替代 (Plan B) */}
|
||||
{altSuggestions.length === 0 ? (
|
||||
<button
|
||||
onClick={handleSuggestAlt}
|
||||
disabled={suggestingAlt}
|
||||
className="flex items-center gap-1.5 self-start text-xs font-medium text-purple-400/70 active:text-purple-400 disabled:opacity-40"
|
||||
>
|
||||
{suggestingAlt
|
||||
? <><Loader2 size={12} className="animate-spin" /> 正在推荐...</>
|
||||
: <><Sparkles size={12} /> AI 推荐替代方案</>}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-[11px] font-medium text-muted">选一个替代方案</span>
|
||||
{altSuggestions.map((alt, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => {
|
||||
setDraft({ ...draft, ...alt });
|
||||
setAltSuggestions([]);
|
||||
}}
|
||||
className="rounded-lg bg-elevated px-3 py-2 text-left ring-1 ring-border active:ring-purple-500"
|
||||
>
|
||||
<p className="text-xs font-bold text-heading">{alt.activity}</p>
|
||||
<p className="truncate text-[10px] text-muted">{alt.poi}</p>
|
||||
<p className="text-[10px] text-dim italic">{alt.reason}</p>
|
||||
</button>
|
||||
))}
|
||||
<button onClick={() => setAltSuggestions([])} className="self-start text-[10px] text-dim">取消</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{days.length > 1 && (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-[11px] font-medium text-muted">移动到其他天</span>
|
||||
<select
|
||||
value={editingItem.dayIndex}
|
||||
onChange={(e) => handleMoveToDayIndex(Number(e.target.value))}
|
||||
className="h-9 rounded-lg bg-elevated px-3 text-sm text-foreground outline-none ring-1 ring-border focus:ring-purple-600"
|
||||
>
|
||||
{days.map((day, i) => (
|
||||
<option key={day.date} value={i}>
|
||||
{day.date}
|
||||
{i === editingItem.dayIndex ? "(当前)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSaveDraft} variant="purple" shape="pill">
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import type { WeekendPlanData } from "@/types";
|
||||
import ShareCardShell from "./ShareCardShell";
|
||||
import type { ShareCardTheme } from "./ShareCardShell";
|
||||
|
||||
export interface PlanShareData {
|
||||
type: "plan";
|
||||
@@ -7,6 +8,18 @@ export interface PlanShareData {
|
||||
roomName: string;
|
||||
}
|
||||
|
||||
const THEME: ShareCardTheme = {
|
||||
emoji: "📋",
|
||||
tagline: "别说随便 · WEEKEND PLAN",
|
||||
bgColor: "#0a0810",
|
||||
gradientBorder: "linear-gradient(160deg, #7c3aed, #6366f140, #7c3aed30)",
|
||||
accentLine: "linear-gradient(to right, transparent, rgba(167,139,250,0.25), transparent)",
|
||||
glows: [
|
||||
{ top: -30, right: -20, width: 140, height: 140, color: "rgba(124,58,237,0.2)" },
|
||||
],
|
||||
qrFgColor: "#0a0810",
|
||||
};
|
||||
|
||||
export default function BlindboxPlanShareCard({
|
||||
data,
|
||||
cardRef,
|
||||
@@ -17,103 +30,9 @@ export default function BlindboxPlanShareCard({
|
||||
bgDataUrl?: string | null;
|
||||
}) {
|
||||
const { days, roomName } = data;
|
||||
const shareUrl =
|
||||
typeof window !== "undefined" ? window.location.origin : "nowhatever.app";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
style={{
|
||||
width: 340,
|
||||
padding: 1.5,
|
||||
borderRadius: 20,
|
||||
background: "linear-gradient(160deg, #7c3aed, #6366f140, #7c3aed30)",
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 18.5,
|
||||
background: "#0a0810",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Background image */}
|
||||
{bgDataUrl && (
|
||||
<img
|
||||
src={bgDataUrl}
|
||||
alt=""
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
opacity: 0.12,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Decorative glows */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -30,
|
||||
right: -20,
|
||||
width: 140,
|
||||
height: 140,
|
||||
borderRadius: "50%",
|
||||
background: "radial-gradient(circle, rgba(124,58,237,0.2), transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Brand header */}
|
||||
<div
|
||||
style={{
|
||||
padding: "14px 20px 12px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 18 }}>📋</span>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 800,
|
||||
color: "#ffffff",
|
||||
letterSpacing: "0.02em",
|
||||
}}
|
||||
>
|
||||
NoWhatever
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 9,
|
||||
fontWeight: 600,
|
||||
color: "rgba(255,255,255,0.3)",
|
||||
letterSpacing: "0.15em",
|
||||
marginTop: 1,
|
||||
}}
|
||||
>
|
||||
别说随便 · WEEKEND PLAN
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thin accent line */}
|
||||
<div
|
||||
style={{
|
||||
height: 1,
|
||||
margin: "0 20px",
|
||||
background: "linear-gradient(to right, transparent, rgba(167,139,250,0.25), transparent)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<ShareCardShell theme={THEME} cardRef={cardRef} bgDataUrl={bgDataUrl}>
|
||||
{/* Each day */}
|
||||
{days.map((day, dayIdx) => (
|
||||
<div key={day.date}>
|
||||
@@ -235,43 +154,6 @@ export default function BlindboxPlanShareCard({
|
||||
>
|
||||
此契约一旦开启,绝不反悔
|
||||
</div>
|
||||
|
||||
{/* QR footer */}
|
||||
<div
|
||||
style={{
|
||||
padding: "14px 20px 16px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 14,
|
||||
borderTop: "1px solid rgba(255,255,255,0.04)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: 5,
|
||||
borderRadius: 8,
|
||||
background: "#ffffff",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<QRCodeSVG
|
||||
value={shareUrl}
|
||||
size={52}
|
||||
level="M"
|
||||
bgColor="#ffffff"
|
||||
fgColor="#0a0810"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: "rgba(255,255,255,0.7)" }}>
|
||||
扫码一起「别说随便」
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: "rgba(255,255,255,0.2)", marginTop: 3 }}>
|
||||
{shareUrl.replace(/^https?:\/\//, "")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ShareCardShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
Send,
|
||||
Loader2,
|
||||
Flame,
|
||||
Calendar,
|
||||
Sparkles,
|
||||
ChevronRight,
|
||||
Lightbulb,
|
||||
Shuffle,
|
||||
} from "lucide-react";
|
||||
import type { WeekendPlanData } from "@/types";
|
||||
|
||||
interface BlindboxPoolPhaseProps {
|
||||
input: string;
|
||||
setInput: (v: string) => void;
|
||||
submitting: boolean;
|
||||
suggestions: string[];
|
||||
suggestionsLoading: boolean;
|
||||
suggestionsSource: "static" | "ai";
|
||||
poolCount: number;
|
||||
error: string;
|
||||
setError: (e: string) => void;
|
||||
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||
planDays: WeekendPlanData[];
|
||||
planAccepted: boolean;
|
||||
hasLocation: boolean;
|
||||
onSubmit: () => void;
|
||||
onDraw: () => void;
|
||||
onPlanStart: () => void;
|
||||
onRefreshSuggestions: () => void;
|
||||
onShowPlan: () => void;
|
||||
onLocationMissing: () => void;
|
||||
}
|
||||
|
||||
export default function BlindboxPoolPhase({
|
||||
input,
|
||||
setInput,
|
||||
submitting,
|
||||
suggestions,
|
||||
suggestionsLoading,
|
||||
suggestionsSource,
|
||||
poolCount,
|
||||
error,
|
||||
setError,
|
||||
inputRef,
|
||||
planDays,
|
||||
planAccepted,
|
||||
hasLocation,
|
||||
onSubmit,
|
||||
onDraw,
|
||||
onPlanStart,
|
||||
onRefreshSuggestions,
|
||||
onShowPlan,
|
||||
onLocationMissing,
|
||||
}: BlindboxPoolPhaseProps) {
|
||||
return (
|
||||
<motion.div
|
||||
key="pool"
|
||||
className="mt-8 flex w-full max-w-sm flex-col items-center gap-5"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
>
|
||||
<div className="flex w-full gap-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="塞入一个疯狂的周末想法..."
|
||||
value={input}
|
||||
onChange={(e) => { setInput(e.target.value); setError(""); }}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") onSubmit(); }}
|
||||
maxLength={200}
|
||||
disabled={submitting}
|
||||
className="h-12 flex-1 rounded-xl border-none bg-surface px-4 text-sm text-foreground outline-none ring-1 ring-border transition-all placeholder:text-dim focus:ring-2 focus:ring-purple-600 disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={!input.trim() || submitting}
|
||||
aria-label="提交想法"
|
||||
className="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-600 text-white transition-colors hover:bg-purple-500 disabled:opacity-30"
|
||||
>
|
||||
{submitting ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!input && (
|
||||
<div className="flex w-full flex-wrap items-center gap-1.5">
|
||||
{suggestionsSource === "ai" ? (
|
||||
<Sparkles size={13} className="mr-0.5 shrink-0 text-purple-400/80" />
|
||||
) : (
|
||||
<Lightbulb size={13} className="mr-0.5 shrink-0 text-amber-500/80" />
|
||||
)}
|
||||
{suggestionsLoading ? (
|
||||
<>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-6 w-20 animate-pulse rounded-full bg-surface/60 ring-1 ring-border/40" />
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
suggestions.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => { setInput(s); inputRef.current?.focus(); }}
|
||||
className="rounded-full bg-surface/80 px-2.5 py-1 text-xs text-secondary ring-1 ring-border/60 transition-all hover:bg-purple-600/10 hover:text-purple-400 hover:ring-purple-600/30 active:scale-95"
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
<button
|
||||
onClick={onRefreshSuggestions}
|
||||
disabled={suggestionsLoading}
|
||||
aria-label="换一批灵感"
|
||||
className="ml-auto flex h-6 w-6 items-center justify-center rounded-full text-muted transition-colors hover:bg-surface hover:text-secondary disabled:opacity-30"
|
||||
>
|
||||
{suggestionsLoading ? <Loader2 size={12} className="animate-spin" /> : <Shuffle size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex w-full gap-2">
|
||||
<motion.button
|
||||
onClick={onDraw}
|
||||
disabled={poolCount === 0}
|
||||
className="relative flex h-14 flex-1 items-center justify-center gap-2 overflow-hidden rounded-2xl bg-linear-to-r from-red-600 to-rose-500 text-sm font-black text-white shadow-lg shadow-red-900/40 transition-shadow hover:shadow-xl hover:shadow-red-900/50 disabled:opacity-40 disabled:shadow-none"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-linear-to-r from-transparent via-white/10 to-transparent -translate-x-full animate-[shimmer_3s_infinite]" />
|
||||
<Flame size={18} />
|
||||
抽一个
|
||||
</motion.button>
|
||||
<motion.button
|
||||
onClick={() => {
|
||||
if (!hasLocation) { onLocationMissing(); return; }
|
||||
onPlanStart();
|
||||
}}
|
||||
disabled={poolCount < 2}
|
||||
className="relative flex h-14 flex-1 items-center justify-center gap-2 overflow-hidden rounded-2xl bg-linear-to-r from-purple-600 to-indigo-600 text-sm font-black text-white shadow-lg shadow-purple-900/40 transition-shadow hover:shadow-xl hover:shadow-purple-900/50 disabled:opacity-40 disabled:shadow-none"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
>
|
||||
<Calendar size={18} />
|
||||
周末计划
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<motion.p
|
||||
className="text-center text-xs font-medium text-rose-400"
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
{error}
|
||||
</motion.p>
|
||||
)}
|
||||
|
||||
{planDays.length > 0 && !planAccepted && (
|
||||
<motion.button
|
||||
onClick={onShowPlan}
|
||||
className="flex w-full items-center justify-between rounded-xl bg-purple-600/10 px-4 py-2.5 ring-1 ring-purple-500/30 active:bg-purple-600/20"
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles size={14} className="text-purple-400" />
|
||||
<span className="text-xs font-bold text-purple-300">有一个待确认的计划</span>
|
||||
</div>
|
||||
<ChevronRight size={14} className="text-purple-400" />
|
||||
</motion.button>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { Share2 } from "lucide-react";
|
||||
import Button from "@/components/Button";
|
||||
import type { DrawnIdea } from "@/components/BlindboxDrawnHistory";
|
||||
|
||||
interface BlindboxRevealPhaseProps {
|
||||
idea: DrawnIdea;
|
||||
onShare: () => void;
|
||||
onContinue: () => void;
|
||||
}
|
||||
|
||||
export default function BlindboxRevealPhase({ idea, onShare, onContinue }: BlindboxRevealPhaseProps) {
|
||||
return (
|
||||
<motion.div
|
||||
key="reveal"
|
||||
className="mt-8 flex w-full max-w-sm flex-col items-center gap-5"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ type: "spring", damping: 15, stiffness: 200 }}
|
||||
>
|
||||
<div className="relative w-full overflow-hidden rounded-2xl bg-linear-to-br from-purple-900 via-indigo-900 to-purple-950 p-6 shadow-2xl shadow-purple-900/50 ring-1 ring-purple-600/30">
|
||||
<div className="absolute left-3 top-3 h-6 w-6 border-l-2 border-t-2 border-purple-400/30 rounded-tl-sm" />
|
||||
<div className="absolute right-3 top-3 h-6 w-6 border-r-2 border-t-2 border-purple-400/30 rounded-tr-sm" />
|
||||
<div className="absolute bottom-3 left-3 h-6 w-6 border-b-2 border-l-2 border-purple-400/30 rounded-bl-sm" />
|
||||
<div className="absolute bottom-3 right-3 h-6 w-6 border-b-2 border-r-2 border-purple-400/30 rounded-br-sm" />
|
||||
|
||||
<div className="relative z-10 text-center">
|
||||
<p className="text-xs font-bold tracking-[0.3em] text-purple-400/70">
|
||||
✦ 周末契约 ✦
|
||||
</p>
|
||||
<motion.p
|
||||
className="mt-4 text-xl font-black leading-relaxed text-white"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
{idea.content}
|
||||
</motion.p>
|
||||
<div className="mx-auto mt-4 h-px w-16 bg-linear-to-r from-transparent via-purple-400/50 to-transparent" />
|
||||
|
||||
<div className="mt-3 flex items-center justify-center gap-2 text-[11px] text-purple-400/50">
|
||||
{idea.user && (
|
||||
<span>{idea.user.avatar} {idea.user.username} 投入</span>
|
||||
)}
|
||||
{idea.drawnBy && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{idea.drawnBy.avatar} {idea.drawnBy.username} 抽中</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-[10px] font-medium text-purple-400/40">
|
||||
此契约一旦开启,绝不反悔
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={onShare}
|
||||
variant="purple"
|
||||
shape="pill"
|
||||
icon={<Share2 size={14} />}
|
||||
>
|
||||
分享契约
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onContinue}
|
||||
variant="secondary"
|
||||
shape="pill"
|
||||
>
|
||||
继续投入想法
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import ShareCardShell from "./ShareCardShell";
|
||||
import type { ShareCardTheme } from "./ShareCardShell";
|
||||
|
||||
export interface BlindboxShareData {
|
||||
type: "blindbox";
|
||||
@@ -8,6 +9,19 @@ export interface BlindboxShareData {
|
||||
roomName: string;
|
||||
}
|
||||
|
||||
const THEME: ShareCardTheme = {
|
||||
emoji: "🎁",
|
||||
tagline: "别说随便 · ADVENTURE ROULETTE",
|
||||
bgColor: "#0a0810",
|
||||
gradientBorder: "linear-gradient(160deg, #7c3aed, #6366f140, #7c3aed30)",
|
||||
accentLine: "linear-gradient(to right, transparent, rgba(167,139,250,0.25), transparent)",
|
||||
glows: [
|
||||
{ top: -30, right: -20, width: 140, height: 140, color: "rgba(124,58,237,0.2)" },
|
||||
{ top: 9999, right: 9999, width: 120, height: 120, color: "rgba(99,102,241,0.12)" },
|
||||
],
|
||||
qrFgColor: "#0a0810",
|
||||
};
|
||||
|
||||
export default function BlindboxShareCard({
|
||||
data,
|
||||
cardRef,
|
||||
@@ -18,57 +32,10 @@ export default function BlindboxShareCard({
|
||||
bgDataUrl?: string | null;
|
||||
}) {
|
||||
const { idea, submitter, drawer, roomName } = data;
|
||||
const shareUrl =
|
||||
typeof window !== "undefined" ? window.location.origin : "nowhatever.app";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
style={{
|
||||
width: 340,
|
||||
padding: 1.5,
|
||||
borderRadius: 20,
|
||||
background: "linear-gradient(160deg, #7c3aed, #6366f140, #7c3aed30)",
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 18.5,
|
||||
background: "#0a0810",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Background image */}
|
||||
{bgDataUrl && (
|
||||
<img
|
||||
src={bgDataUrl}
|
||||
alt=""
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
opacity: 0.12,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Decorative glows */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -30,
|
||||
right: -20,
|
||||
width: 140,
|
||||
height: 140,
|
||||
borderRadius: "50%",
|
||||
background: "radial-gradient(circle, rgba(124,58,237,0.2), transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
<ShareCardShell theme={THEME} cardRef={cardRef} bgDataUrl={bgDataUrl}>
|
||||
{/* Bottom-left glow */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
@@ -81,51 +48,6 @@ export default function BlindboxShareCard({
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Brand header */}
|
||||
<div
|
||||
style={{
|
||||
padding: "14px 20px 12px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 18 }}>🎁</span>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 800,
|
||||
color: "#ffffff",
|
||||
letterSpacing: "0.02em",
|
||||
}}
|
||||
>
|
||||
NoWhatever
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 9,
|
||||
fontWeight: 600,
|
||||
color: "rgba(255,255,255,0.3)",
|
||||
letterSpacing: "0.15em",
|
||||
marginTop: 1,
|
||||
}}
|
||||
>
|
||||
别说随便 · ADVENTURE ROULETTE
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thin accent line */}
|
||||
<div
|
||||
style={{
|
||||
height: 1,
|
||||
margin: "0 20px",
|
||||
background: "linear-gradient(to right, transparent, rgba(167,139,250,0.25), transparent)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Room name badge */}
|
||||
<div
|
||||
style={{
|
||||
@@ -158,54 +80,14 @@ export default function BlindboxShareCard({
|
||||
}}
|
||||
>
|
||||
{/* Corner decorations */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 10,
|
||||
left: 10,
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderLeft: "2px solid rgba(167,139,250,0.25)",
|
||||
borderTop: "2px solid rgba(167,139,250,0.25)",
|
||||
borderTopLeftRadius: 3,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 10,
|
||||
right: 10,
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRight: "2px solid rgba(167,139,250,0.25)",
|
||||
borderTop: "2px solid rgba(167,139,250,0.25)",
|
||||
borderTopRightRadius: 3,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 10,
|
||||
left: 10,
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderLeft: "2px solid rgba(167,139,250,0.25)",
|
||||
borderBottom: "2px solid rgba(167,139,250,0.25)",
|
||||
borderBottomLeftRadius: 3,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 10,
|
||||
right: 10,
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRight: "2px solid rgba(167,139,250,0.25)",
|
||||
borderBottom: "2px solid rgba(167,139,250,0.25)",
|
||||
borderBottomRightRadius: 3,
|
||||
}}
|
||||
/>
|
||||
{[
|
||||
{ top: 10, left: 10, borderLeft: "2px solid rgba(167,139,250,0.25)", borderTop: "2px solid rgba(167,139,250,0.25)", borderTopLeftRadius: 3 },
|
||||
{ top: 10, right: 10, borderRight: "2px solid rgba(167,139,250,0.25)", borderTop: "2px solid rgba(167,139,250,0.25)", borderTopRightRadius: 3 },
|
||||
{ bottom: 10, left: 10, borderLeft: "2px solid rgba(167,139,250,0.25)", borderBottom: "2px solid rgba(167,139,250,0.25)", borderBottomLeftRadius: 3 },
|
||||
{ bottom: 10, right: 10, borderRight: "2px solid rgba(167,139,250,0.25)", borderBottom: "2px solid rgba(167,139,250,0.25)", borderBottomRightRadius: 3 },
|
||||
].map((style, i) => (
|
||||
<div key={i} style={{ position: "absolute", width: 14, height: 14, ...style }} />
|
||||
))}
|
||||
|
||||
<div
|
||||
style={{
|
||||
@@ -225,8 +107,7 @@ export default function BlindboxShareCard({
|
||||
width: 48,
|
||||
height: 1,
|
||||
margin: "16px auto",
|
||||
background:
|
||||
"linear-gradient(to right, transparent, rgba(167,139,250,0.4), transparent)",
|
||||
background: "linear-gradient(to right, transparent, rgba(167,139,250,0.4), transparent)",
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -287,55 +168,6 @@ export default function BlindboxShareCard({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* QR footer */}
|
||||
<div
|
||||
style={{
|
||||
padding: "14px 20px 16px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 14,
|
||||
borderTop: "1px solid rgba(255,255,255,0.04)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: 5,
|
||||
borderRadius: 8,
|
||||
background: "#ffffff",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<QRCodeSVG
|
||||
value={shareUrl}
|
||||
size={52}
|
||||
level="M"
|
||||
bgColor="#ffffff"
|
||||
fgColor="#0a0810"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: "rgba(255,255,255,0.7)",
|
||||
}}
|
||||
>
|
||||
扫码一起「别说随便」
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: "rgba(255,255,255,0.2)",
|
||||
marginTop: 3,
|
||||
}}
|
||||
>
|
||||
{shareUrl.replace(/^https?:\/\//, "")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ShareCardShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { X, Sparkles, Loader2 } from "lucide-react";
|
||||
import Modal from "@/components/Modal";
|
||||
import Button from "@/components/Button";
|
||||
import PoiSearchField from "@/components/PoiSearchField";
|
||||
import type { PlanItem, WeekendPlanData } from "@/types";
|
||||
|
||||
interface AltSuggestion {
|
||||
activity: string;
|
||||
poi: string;
|
||||
address: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface PlanItemEditModalProps {
|
||||
open: boolean;
|
||||
editingItem: { dayIndex: number; itemIndex: number } | null;
|
||||
draft: PlanItem | null;
|
||||
days: WeekendPlanData[];
|
||||
location?: string;
|
||||
onDraftChange: (draft: PlanItem) => void;
|
||||
onSave: () => void;
|
||||
onClose: () => void;
|
||||
onMoveToDayIndex: (targetDayIndex: number) => void;
|
||||
}
|
||||
|
||||
export default function PlanItemEditModal({
|
||||
open,
|
||||
editingItem,
|
||||
draft,
|
||||
days,
|
||||
location,
|
||||
onDraftChange,
|
||||
onSave,
|
||||
onClose,
|
||||
onMoveToDayIndex,
|
||||
}: PlanItemEditModalProps) {
|
||||
const [suggestingAlt, setSuggestingAlt] = useState(false);
|
||||
const [altSuggestions, setAltSuggestions] = useState<AltSuggestion[]>([]);
|
||||
|
||||
async function handleSuggestAlt() {
|
||||
if (!draft || suggestingAlt) return;
|
||||
setSuggestingAlt(true);
|
||||
try {
|
||||
const res = await fetch("/api/blindbox/plan/suggest-item", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ activity: draft.activity, time: draft.time, location }),
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
setAltSuggestions(data.suggestions ?? []);
|
||||
} finally {
|
||||
setSuggestingAlt(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
setAltSuggestions([]);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={handleClose} variant="sheet">
|
||||
{draft && editingItem && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-bold text-heading">编辑活动</h3>
|
||||
<button onClick={handleClose} className="text-muted active:text-foreground">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-[11px] font-medium text-muted">活动名称</span>
|
||||
<input
|
||||
type="text"
|
||||
value={draft.activity}
|
||||
onChange={(e) => onDraftChange({ ...draft, activity: e.target.value })}
|
||||
className="h-9 rounded-lg bg-elevated px-3 text-sm text-foreground outline-none ring-1 ring-border focus:ring-purple-600"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<label className="flex flex-1 flex-col gap-1">
|
||||
<span className="text-[11px] font-medium text-muted">时间</span>
|
||||
<input
|
||||
type="time"
|
||||
value={draft.time}
|
||||
onChange={(e) => onDraftChange({ ...draft, time: e.target.value })}
|
||||
className="h-9 rounded-lg bg-elevated px-3 text-sm text-foreground outline-none ring-1 ring-border focus:ring-purple-600"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-1 flex-col gap-1">
|
||||
<span className="text-[11px] font-medium text-muted">时长(分钟)</span>
|
||||
<input
|
||||
type="number"
|
||||
step={15}
|
||||
min={15}
|
||||
value={draft.duration}
|
||||
onChange={(e) => onDraftChange({ ...draft, duration: Number(e.target.value) })}
|
||||
className="h-9 rounded-lg bg-elevated px-3 text-sm text-foreground outline-none ring-1 ring-border focus:ring-purple-600"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<PoiSearchField
|
||||
key={`${editingItem.dayIndex}-${editingItem.itemIndex}`}
|
||||
poi={draft.poi}
|
||||
address={draft.address}
|
||||
location={location}
|
||||
onSelect={(s) => onDraftChange({ ...draft, poi: s.name, address: s.address, lat: s.lat, lng: s.lng })}
|
||||
/>
|
||||
|
||||
{/* AI alternative suggestions */}
|
||||
{altSuggestions.length === 0 ? (
|
||||
<button
|
||||
onClick={handleSuggestAlt}
|
||||
disabled={suggestingAlt}
|
||||
className="flex items-center gap-1.5 self-start text-xs font-medium text-purple-400/70 active:text-purple-400 disabled:opacity-40"
|
||||
>
|
||||
{suggestingAlt
|
||||
? <><Loader2 size={12} className="animate-spin" /> 正在推荐...</>
|
||||
: <><Sparkles size={12} /> AI 推荐替代方案</>}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-[11px] font-medium text-muted">选一个替代方案</span>
|
||||
{altSuggestions.map((alt, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => {
|
||||
onDraftChange({ ...draft, ...alt });
|
||||
setAltSuggestions([]);
|
||||
}}
|
||||
className="rounded-lg bg-elevated px-3 py-2 text-left ring-1 ring-border active:ring-purple-500"
|
||||
>
|
||||
<p className="text-xs font-bold text-heading">{alt.activity}</p>
|
||||
<p className="truncate text-[10px] text-muted">{alt.poi}</p>
|
||||
<p className="text-[10px] text-dim italic">{alt.reason}</p>
|
||||
</button>
|
||||
))}
|
||||
<button onClick={() => setAltSuggestions([])} className="self-start text-[10px] text-dim">取消</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{days.length > 1 && (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-[11px] font-medium text-muted">移动到其他天</span>
|
||||
<select
|
||||
value={editingItem.dayIndex}
|
||||
onChange={(e) => onMoveToDayIndex(Number(e.target.value))}
|
||||
className="h-9 rounded-lg bg-elevated px-3 text-sm text-foreground outline-none ring-1 ring-border focus:ring-purple-600"
|
||||
>
|
||||
{days.map((day, i) => (
|
||||
<option key={day.date} value={i}>
|
||||
{day.date}
|
||||
{i === editingItem.dayIndex ? "(当前)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button onClick={onSave} variant="purple" shape="pill">
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { MapPin, Loader2 } from "lucide-react";
|
||||
|
||||
export interface PoiSuggestion {
|
||||
id: string;
|
||||
name: string;
|
||||
district: string;
|
||||
address: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
interface PoiSearchFieldProps {
|
||||
poi: string;
|
||||
address: string;
|
||||
onSelect: (s: PoiSuggestion) => void;
|
||||
location?: string;
|
||||
}
|
||||
|
||||
export default function PoiSearchField({ poi, address, onSelect, location }: PoiSearchFieldProps) {
|
||||
const [query, setQuery] = useState(poi);
|
||||
const [suggestions, setSuggestions] = useState<PoiSuggestion[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const lastSelectedRef = useRef(poi);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
if (!query.trim() || query === lastSelectedRef.current) {
|
||||
setSuggestions([]);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
timerRef.current = setTimeout(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({ keywords: query });
|
||||
if (location) params.set("location", location);
|
||||
const res = await fetch(`/api/location/suggest?${params}`);
|
||||
if (res.ok) {
|
||||
const data: PoiSuggestion[] = await res.json();
|
||||
setSuggestions(data);
|
||||
setOpen(data.length > 0);
|
||||
}
|
||||
} catch (e) { console.error("PoiSearchField fetch failed:", e); }
|
||||
finally { setLoading(false); }
|
||||
}, 400);
|
||||
return () => { if (timerRef.current) clearTimeout(timerRef.current); };
|
||||
}, [query, location]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[11px] font-medium text-muted">地点</span>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="搜索地点名称..."
|
||||
className="h-9 w-full rounded-lg bg-elevated px-3 pr-8 text-sm text-foreground outline-none ring-1 ring-border focus:ring-purple-600"
|
||||
/>
|
||||
{loading && (
|
||||
<Loader2 size={14} className="absolute right-2.5 top-2.5 animate-spin text-muted" />
|
||||
)}
|
||||
</div>
|
||||
{open && suggestions.length > 0 && (
|
||||
<div className="overflow-hidden rounded-lg bg-elevated ring-1 ring-border">
|
||||
{suggestions.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
lastSelectedRef.current = s.name;
|
||||
setQuery(s.name);
|
||||
setOpen(false);
|
||||
setSuggestions([]);
|
||||
onSelect(s);
|
||||
}}
|
||||
className="flex w-full flex-col border-b border-border/40 px-3 py-2 text-left last:border-0 active:bg-purple-600/10"
|
||||
>
|
||||
<span className="text-xs font-medium text-foreground">{s.name}</span>
|
||||
<span className="truncate text-[10px] text-muted">{s.district} {s.address}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{address && !open && (
|
||||
<p className="flex items-center gap-1 text-[10px] text-dim">
|
||||
<MapPin size={9} className="shrink-0" />
|
||||
{address}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Star, MapPin, Zap } from "lucide-react";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import type { Restaurant, MatchType, SceneType } from "@/types";
|
||||
import { getSceneConfig } from "@/lib/sceneConfig";
|
||||
import ShareCardShell from "./ShareCardShell";
|
||||
import type { ShareCardTheme } from "./ShareCardShell";
|
||||
|
||||
export interface RestaurantShareData {
|
||||
type: "restaurant";
|
||||
@@ -12,6 +13,25 @@ export interface RestaurantShareData {
|
||||
scene?: SceneType;
|
||||
}
|
||||
|
||||
function buildTheme(isUnanimous: boolean): ShareCardTheme & { accentText: string; accentBg: string } {
|
||||
const accentFrom = isUnanimous ? "#059669" : "#b45309";
|
||||
const accentTo = isUnanimous ? "#34d399" : "#fbbf24";
|
||||
return {
|
||||
emoji: "⚡",
|
||||
tagline: "别说随便 · PANIC MODE",
|
||||
bgColor: "#08080a",
|
||||
gradientBorder: `linear-gradient(160deg, ${accentFrom}, ${accentTo}40, ${accentFrom}30)`,
|
||||
accentLine: `linear-gradient(to right, transparent, ${accentTo}30, transparent)`,
|
||||
glows: [
|
||||
{ top: -40, right: -30, width: 160, height: 160, color: `${accentFrom}25` },
|
||||
{ top: 9999, right: 9999, width: 120, height: 120, color: `${accentTo}12` },
|
||||
],
|
||||
qrFgColor: "#08080a",
|
||||
accentText: isUnanimous ? "#6ee7b7" : "#fcd34d",
|
||||
accentBg: isUnanimous ? "rgba(16, 185, 129, 0.12)" : "rgba(245, 158, 11, 0.12)",
|
||||
};
|
||||
}
|
||||
|
||||
export default function RestaurantShareCard({
|
||||
data,
|
||||
cardRef,
|
||||
@@ -26,63 +46,16 @@ export default function RestaurantShareCard({
|
||||
const { restaurant, matchType, matchLikes, userCount, scene } = data;
|
||||
const isUnanimous = matchType === "unanimous";
|
||||
const verb = getSceneConfig(scene ?? "eat").verb;
|
||||
const shareUrl =
|
||||
typeof window !== "undefined" ? window.location.origin : "nowhatever.app";
|
||||
const accentFrom = isUnanimous ? "#059669" : "#b45309";
|
||||
const theme = buildTheme(isUnanimous);
|
||||
const { accentText, accentBg } = theme;
|
||||
const accentTo = isUnanimous ? "#34d399" : "#fbbf24";
|
||||
const accentText = isUnanimous ? "#6ee7b7" : "#fcd34d";
|
||||
const accentBg = isUnanimous
|
||||
? "rgba(16, 185, 129, 0.12)"
|
||||
: "rgba(245, 158, 11, 0.12)";
|
||||
|
||||
// Fix the second glow position (bottom-left, not the placeholder)
|
||||
theme.glows[1] = { top: 9999, right: 9999, width: 120, height: 120, color: `${accentTo}12` };
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
style={{
|
||||
width: 340,
|
||||
padding: 1.5,
|
||||
borderRadius: 20,
|
||||
background: `linear-gradient(160deg, ${accentFrom}, ${accentTo}40, ${accentFrom}30)`,
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 18.5,
|
||||
background: "#08080a",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Background image */}
|
||||
{bgDataUrl && (
|
||||
<img
|
||||
src={bgDataUrl}
|
||||
alt=""
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
opacity: 0.12,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Decorative glows */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -40,
|
||||
right: -30,
|
||||
width: 160,
|
||||
height: 160,
|
||||
borderRadius: "50%",
|
||||
background: `radial-gradient(circle, ${accentFrom}25, transparent 70%)`,
|
||||
}}
|
||||
/>
|
||||
<ShareCardShell theme={theme} cardRef={cardRef} bgDataUrl={bgDataUrl}>
|
||||
{/* Second glow override (bottom-left) */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
@@ -95,53 +68,6 @@ export default function RestaurantShareCard({
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Brand header */}
|
||||
<div
|
||||
style={{
|
||||
padding: "14px 20px 12px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ fontSize: 18 }}>⚡</span>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 800,
|
||||
color: "#ffffff",
|
||||
letterSpacing: "0.02em",
|
||||
}}
|
||||
>
|
||||
NoWhatever
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 9,
|
||||
fontWeight: 600,
|
||||
color: "rgba(255,255,255,0.35)",
|
||||
letterSpacing: "0.15em",
|
||||
marginTop: 1,
|
||||
}}
|
||||
>
|
||||
别说随便 · PANIC MODE
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thin accent line */}
|
||||
<div
|
||||
style={{
|
||||
height: 1,
|
||||
margin: "0 20px",
|
||||
background: `linear-gradient(to right, transparent, ${accentTo}30, transparent)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Hero section */}
|
||||
<div
|
||||
style={{
|
||||
@@ -177,27 +103,15 @@ export default function RestaurantShareCard({
|
||||
}}
|
||||
>
|
||||
{isUnanimous && (
|
||||
<Zap
|
||||
size={12}
|
||||
style={{ color: accentText, fill: accentText }}
|
||||
/>
|
||||
<Zap size={12} style={{ color: accentText, fill: accentText }} />
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
color: accentText,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: accentText }}>
|
||||
{isUnanimous
|
||||
? `默契度 100% · ${userCount}人全员一致`
|
||||
: `${matchLikes}/${userCount} 人选了这家`}
|
||||
</span>
|
||||
{isUnanimous && (
|
||||
<Zap
|
||||
size={12}
|
||||
style={{ color: accentText, fill: accentText }}
|
||||
/>
|
||||
<Zap size={12} style={{ color: accentText, fill: accentText }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -280,10 +194,7 @@ export default function RestaurantShareCard({
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<Star
|
||||
size={13}
|
||||
style={{ color: "#fbbf24", fill: "#fbbf24" }}
|
||||
/>
|
||||
<Star size={13} style={{ color: "#fbbf24", fill: "#fbbf24" }} />
|
||||
{restaurant.rating}
|
||||
</span>
|
||||
)}
|
||||
@@ -352,55 +263,6 @@ export default function RestaurantShareCard({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* QR footer */}
|
||||
<div
|
||||
style={{
|
||||
padding: "14px 20px 16px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 14,
|
||||
borderTop: "1px solid rgba(255,255,255,0.04)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: 5,
|
||||
borderRadius: 8,
|
||||
background: "#ffffff",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<QRCodeSVG
|
||||
value={shareUrl}
|
||||
size={52}
|
||||
level="M"
|
||||
bgColor="#ffffff"
|
||||
fgColor="#08080a"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: "rgba(255,255,255,0.7)",
|
||||
}}
|
||||
>
|
||||
扫码一起「别说随便」
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: "rgba(255,255,255,0.25)",
|
||||
marginTop: 3,
|
||||
}}
|
||||
>
|
||||
{shareUrl.replace(/^https?:\/\//, "")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ShareCardShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface ShareCardTheme {
|
||||
emoji: string;
|
||||
tagline: string;
|
||||
bgColor: string;
|
||||
gradientBorder: string;
|
||||
accentLine: string;
|
||||
glows: { top: number; right: number; width: number; height: number; color: string }[];
|
||||
qrFgColor: string;
|
||||
}
|
||||
|
||||
export default function ShareCardShell({
|
||||
theme,
|
||||
cardRef,
|
||||
bgDataUrl,
|
||||
children,
|
||||
}: {
|
||||
theme: ShareCardTheme;
|
||||
cardRef: React.RefObject<HTMLDivElement | null>;
|
||||
bgDataUrl?: string | null;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const shareUrl =
|
||||
typeof window !== "undefined" ? window.location.origin : "nowhatever.app";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
style={{
|
||||
width: 340,
|
||||
padding: 1.5,
|
||||
borderRadius: 20,
|
||||
background: theme.gradientBorder,
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 18.5,
|
||||
background: theme.bgColor,
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Background image */}
|
||||
{bgDataUrl && (
|
||||
<img
|
||||
src={bgDataUrl}
|
||||
alt=""
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
opacity: 0.12,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Decorative glows */}
|
||||
{theme.glows.map((g, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: g.top,
|
||||
right: g.right,
|
||||
width: g.width,
|
||||
height: g.height,
|
||||
borderRadius: "50%",
|
||||
background: `radial-gradient(circle, ${g.color}, transparent 70%)`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Brand header */}
|
||||
<div
|
||||
style={{
|
||||
padding: "14px 20px 12px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span style={{ fontSize: 18 }}>{theme.emoji}</span>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 800,
|
||||
color: "#ffffff",
|
||||
letterSpacing: "0.02em",
|
||||
}}
|
||||
>
|
||||
NoWhatever
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 9,
|
||||
fontWeight: 600,
|
||||
color: "rgba(255,255,255,0.35)",
|
||||
letterSpacing: "0.15em",
|
||||
marginTop: 1,
|
||||
}}
|
||||
>
|
||||
{theme.tagline}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thin accent line */}
|
||||
<div
|
||||
style={{
|
||||
height: 1,
|
||||
margin: "0 20px",
|
||||
background: theme.accentLine,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Card-specific content */}
|
||||
{children}
|
||||
|
||||
{/* QR footer */}
|
||||
<div
|
||||
style={{
|
||||
padding: "14px 20px 16px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 14,
|
||||
borderTop: "1px solid rgba(255,255,255,0.04)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: 5,
|
||||
borderRadius: 8,
|
||||
background: "#ffffff",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<QRCodeSVG
|
||||
value={shareUrl}
|
||||
size={52}
|
||||
level="M"
|
||||
bgColor="#ffffff"
|
||||
fgColor={theme.qrFgColor}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: "rgba(255,255,255,0.7)",
|
||||
}}
|
||||
>
|
||||
扫码一起「别说随便」
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: "rgba(255,255,255,0.25)",
|
||||
marginTop: 3,
|
||||
}}
|
||||
>
|
||||
{shareUrl.replace(/^https?:\/\//, "")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { MapPin, Clock, Navigation, GripVertical, Pencil } from "lucide-react";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { CategoryBadge } from "@/components/BlindboxMyIdeas";
|
||||
import { guessCategory, formatDuration } from "@/lib/planUtils";
|
||||
import type { PlanItem } from "@/types";
|
||||
|
||||
interface SortablePlanItemProps {
|
||||
id: string;
|
||||
item: PlanItem;
|
||||
canEdit: boolean;
|
||||
onEdit: () => void;
|
||||
}
|
||||
|
||||
export default function SortablePlanItem({ id, item, canEdit, onEdit }: SortablePlanItemProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||
useSortable({ id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className="relative mb-5">
|
||||
<div className="absolute -left-6 top-4 flex h-[18px] w-[18px] items-center justify-center rounded-full bg-purple-600/20 ring-2 ring-background">
|
||||
<div className="h-2 w-2 rounded-full bg-purple-400" />
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-surface/80 p-4 ring-1 ring-border/80">
|
||||
<div className="flex items-start gap-2.5">
|
||||
{canEdit && (
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="mt-1 shrink-0 touch-none cursor-grab text-muted/40 active:cursor-grabbing active:text-muted"
|
||||
aria-label="拖拽排序"
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</button>
|
||||
)}
|
||||
<span className="mt-0.5 min-w-[38px] text-sm font-black text-purple-400">{item.time}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CategoryBadge category={guessCategory(item.activity)} />
|
||||
<p className="truncate text-sm font-bold text-heading">{item.activity}</p>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-1 text-[11px] text-muted">
|
||||
<MapPin size={10} className="shrink-0" />
|
||||
<span className="truncate">{item.poi}</span>
|
||||
</div>
|
||||
{item.address && (
|
||||
<p className="mt-1 truncate text-[10px] text-dim">{item.address}</p>
|
||||
)}
|
||||
<div className="mt-2.5 flex items-center gap-3">
|
||||
<span className="flex items-center gap-1 text-[10px] text-dim">
|
||||
<Clock size={9} />
|
||||
{formatDuration(item.duration)}
|
||||
</span>
|
||||
{item.lat !== 0 && item.lng !== 0 && (
|
||||
<a
|
||||
href={`https://uri.amap.com/marker?position=${item.lng},${item.lat}&name=${encodeURIComponent(item.poi)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-[10px] font-medium text-purple-400/70 active:text-purple-400"
|
||||
>
|
||||
<Navigation size={9} />
|
||||
导航
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{item.reason && (
|
||||
<p className="mt-2.5 border-t border-border/30 pt-2 text-[10px] leading-relaxed text-dim italic">
|
||||
{item.reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="mt-0.5 shrink-0 text-muted/40 active:text-purple-400"
|
||||
aria-label="编辑"
|
||||
>
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import useSWR from "swr";
|
||||
import { fetcher } from "@/lib/fetcher";
|
||||
import type { DecisionRecord, ContractRecord } from "@/types";
|
||||
|
||||
interface Stats {
|
||||
totalDecisions: number;
|
||||
totalContracts: number;
|
||||
completedContracts: number;
|
||||
completionRate: number;
|
||||
}
|
||||
|
||||
interface AchievementsResponse {
|
||||
stats: Stats;
|
||||
decisions: DecisionRecord[];
|
||||
contracts: ContractRecord[];
|
||||
}
|
||||
|
||||
export function useAchievements(userId: string | undefined) {
|
||||
const { data, isLoading, error } = useSWR<AchievementsResponse>(
|
||||
userId ? `/api/user/achievements?userId=${userId}` : null,
|
||||
fetcher,
|
||||
);
|
||||
|
||||
return {
|
||||
stats: data?.stats ?? {
|
||||
totalDecisions: 0,
|
||||
totalContracts: 0,
|
||||
completedContracts: 0,
|
||||
completionRate: 0,
|
||||
},
|
||||
decisions: data?.decisions ?? [],
|
||||
contracts: data?.contracts ?? [],
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { useAnimation } from "framer-motion";
|
||||
import confetti from "canvas-confetti";
|
||||
import type { DrawnIdea } from "@/components/BlindboxDrawnHistory";
|
||||
import type { RoomInfo } from "@/hooks/useBlindboxRoom";
|
||||
import type { UserProfile } from "@/types";
|
||||
|
||||
type Phase = "pool" | "shaking" | "reveal" | "time_select" | "planning" | "plan_reveal";
|
||||
|
||||
export function useBlindboxDraw(
|
||||
room: RoomInfo | null,
|
||||
profile: UserProfile | null,
|
||||
poolCount: number,
|
||||
setPoolCount: React.Dispatch<React.SetStateAction<number>>,
|
||||
setDrawnHistory: React.Dispatch<React.SetStateAction<DrawnIdea[]>>,
|
||||
setError: (e: string) => void,
|
||||
setPhase: (p: Phase) => void,
|
||||
) {
|
||||
const [revealedIdea, setRevealedIdea] = useState<DrawnIdea | null>(null);
|
||||
const [showShareCard, setShowShareCard] = useState(false);
|
||||
const boxControls = useAnimation();
|
||||
const confettiAliveRef = useRef(false);
|
||||
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
|
||||
const fireConfetti = useCallback(() => {
|
||||
const colors = ["#a855f7", "#6366f1", "#ec4899", "#f59e0b", "#10b981"];
|
||||
confetti({ particleCount: 100, spread: 120, origin: { y: 0.4 }, colors, startVelocity: 45, ticks: 250 });
|
||||
confettiAliveRef.current = true;
|
||||
const end = Date.now() + 3000;
|
||||
const frame = () => {
|
||||
if (Date.now() > end || !confettiAliveRef.current) return;
|
||||
confetti({ particleCount: 3, angle: 60, spread: 55, origin: { x: 0, y: 0.6 }, colors, startVelocity: 35, ticks: 150 });
|
||||
confetti({ particleCount: 3, angle: 120, spread: 55, origin: { x: 1, y: 0.6 }, colors, startVelocity: 35, ticks: 150 });
|
||||
requestAnimationFrame(frame);
|
||||
};
|
||||
timersRef.current.push(setTimeout(frame, 200));
|
||||
}, []);
|
||||
|
||||
const handleDraw = async () => {
|
||||
if (poolCount === 0 || !profile || !room) {
|
||||
setError("盒子是空的,先往里面塞点想法吧!");
|
||||
return;
|
||||
}
|
||||
|
||||
setPhase("shaking");
|
||||
setError("");
|
||||
|
||||
await boxControls.start({
|
||||
rotate: [0, -8, 8, -10, 10, -12, 12, -8, 8, -4, 4, 0],
|
||||
scale: [1, 1.05, 0.95, 1.08, 0.92, 1.1, 0.9, 1.05, 0.95, 1],
|
||||
transition: { duration: 2.5, ease: "easeInOut" },
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/blindbox/draw", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ roomId: room.id, userId: profile.id }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "抽取失败");
|
||||
}
|
||||
|
||||
const idea = await res.json();
|
||||
setRevealedIdea(idea);
|
||||
setPhase("reveal");
|
||||
setPoolCount((c) => Math.max(0, c - 1));
|
||||
setDrawnHistory((prev) => [idea, ...prev]);
|
||||
fireConfetti();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "抽取失败");
|
||||
setPhase("pool");
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinue = useCallback(() => {
|
||||
setPhase("pool");
|
||||
setRevealedIdea(null);
|
||||
setShowShareCard(false);
|
||||
}, [setPhase]);
|
||||
|
||||
return {
|
||||
revealedIdea,
|
||||
showShareCard,
|
||||
setShowShareCard,
|
||||
boxControls,
|
||||
fireConfetti,
|
||||
handleDraw,
|
||||
handleContinue,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { useAnimation } from "framer-motion";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import type { MyIdea } from "@/components/BlindboxMyIdeas";
|
||||
import type { DrawnIdea } from "@/components/BlindboxDrawnHistory";
|
||||
import type { RoomInfo } from "@/hooks/useBlindboxRoom";
|
||||
import type { UserProfile } from "@/types";
|
||||
|
||||
const IDEA_INSPIRATIONS = [
|
||||
"去城市最高楼看日落",
|
||||
"挑战一人做一道菜",
|
||||
"找一家从没去过的店",
|
||||
"逛一个从没去过的街区",
|
||||
"去公园野餐",
|
||||
"看一场午夜电影",
|
||||
"在家做一顿异国料理",
|
||||
"骑车去郊外探险",
|
||||
"去二手市场淘宝",
|
||||
"去博物馆逛半天",
|
||||
"试一项没玩过的运动",
|
||||
"随机坐公交到终点站",
|
||||
"一起画画或做手工",
|
||||
"找个天台看星星",
|
||||
"去一家评分最低的餐厅",
|
||||
"穿最好看的衣服去拍照",
|
||||
"交换手机玩一个小时",
|
||||
"一起去做志愿者",
|
||||
];
|
||||
|
||||
function pickRandom<T>(arr: T[], n: number): T[] {
|
||||
const shuffled = [...arr].sort(() => Math.random() - 0.5);
|
||||
return shuffled.slice(0, n);
|
||||
}
|
||||
|
||||
export function useBlindboxIdeas(room: RoomInfo | null, profile: UserProfile | null) {
|
||||
const toast = useToast();
|
||||
const boxControls = useAnimation();
|
||||
|
||||
const [input, setInput] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [suggestions, setSuggestions] = useState(() => pickRandom(IDEA_INSPIRATIONS, 4));
|
||||
const [suggestionsLoading, setSuggestionsLoading] = useState(false);
|
||||
const [suggestionsSource, setSuggestionsSource] = useState<"static" | "ai">("static");
|
||||
const [poolCount, setPoolCount] = useState(0);
|
||||
const [myIdeas, setMyIdeas] = useState<MyIdea[]>([]);
|
||||
const [drawnHistory, setDrawnHistory] = useState<DrawnIdea[]>([]);
|
||||
const [submitFlash, setSubmitFlash] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
|
||||
const fetchIdeas = useCallback(async () => {
|
||||
if (!room || !profile) return;
|
||||
try {
|
||||
const res = await fetch(`/api/blindbox?roomId=${room.id}&userId=${profile.id}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPoolCount(data.poolCount ?? 0);
|
||||
setMyIdeas(data.myIdeas ?? []);
|
||||
setDrawnHistory(data.drawn ?? []);
|
||||
}
|
||||
} catch (e) { console.error("fetchIdeas failed:", e); }
|
||||
}, [room, profile]);
|
||||
|
||||
const fetchSuggestions = useCallback(async () => {
|
||||
if (!room || !profile) return;
|
||||
setSuggestionsLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/blindbox/suggest?roomId=${room.id}&userId=${profile.id}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.suggestions?.length > 0) {
|
||||
setSuggestions(data.suggestions);
|
||||
setSuggestionsSource("ai");
|
||||
setSuggestionsLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) { console.error("fetchSuggestions failed:", e); }
|
||||
setSuggestions(pickRandom(IDEA_INSPIRATIONS, 4));
|
||||
setSuggestionsSource("static");
|
||||
setSuggestionsLoading(false);
|
||||
}, [room, profile]);
|
||||
|
||||
const refreshSuggestions = useCallback(() => {
|
||||
if (suggestionsSource === "ai") {
|
||||
fetchSuggestions();
|
||||
} else {
|
||||
setSuggestions(pickRandom(IDEA_INSPIRATIONS, 4));
|
||||
}
|
||||
}, [suggestionsSource, fetchSuggestions]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const text = input.trim();
|
||||
if (!text || submitting || !profile || !room) return;
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
const res = await fetch("/api/blindbox", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ roomId: room.id, userId: profile.id, content: text }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "提交失败");
|
||||
}
|
||||
const data = await res.json();
|
||||
setInput("");
|
||||
setPoolCount((c) => c + 1);
|
||||
setMyIdeas((prev) => [{
|
||||
id: data.id,
|
||||
content: text,
|
||||
createdAt: new Date().toISOString(),
|
||||
...data.tags && {
|
||||
category: data.tags.category,
|
||||
timeSlot: data.tags.timeSlot,
|
||||
estimatedMinutes: data.tags.estimatedMinutes,
|
||||
costLevel: data.tags.costLevel,
|
||||
intensity: data.tags.intensity,
|
||||
needsBooking: data.tags.needsBooking,
|
||||
searchQuery: data.tags.searchQuery,
|
||||
searchType: data.tags.searchType,
|
||||
},
|
||||
}, ...prev]);
|
||||
setSubmitFlash(true);
|
||||
timersRef.current.push(setTimeout(() => setSubmitFlash(false), 600));
|
||||
boxControls.start({
|
||||
scale: [1, 1.08, 1],
|
||||
rotate: [0, -3, 3, 0],
|
||||
transition: { duration: 0.5 },
|
||||
});
|
||||
fetchSuggestions();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "提交失败");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditIdea = useCallback(async (ideaId: string, newContent: string) => {
|
||||
if (!profile) return;
|
||||
const trimmed = newContent.trim();
|
||||
if (!trimmed || trimmed.length > 200) return;
|
||||
try {
|
||||
const res = await fetch("/api/blindbox", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ideaId, userId: profile.id, content: trimmed }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "编辑失败");
|
||||
}
|
||||
const data = await res.json();
|
||||
setMyIdeas((prev) => prev.map((i) => (i.id === ideaId ? {
|
||||
...i,
|
||||
content: trimmed,
|
||||
...data.tags && {
|
||||
category: data.tags.category,
|
||||
timeSlot: data.tags.timeSlot,
|
||||
estimatedMinutes: data.tags.estimatedMinutes,
|
||||
costLevel: data.tags.costLevel,
|
||||
intensity: data.tags.intensity,
|
||||
needsBooking: data.tags.needsBooking,
|
||||
searchQuery: data.tags.searchQuery,
|
||||
searchType: data.tags.searchType,
|
||||
},
|
||||
} : i)));
|
||||
} catch (e) {
|
||||
toast.show(e instanceof Error ? e.message : "编辑失败");
|
||||
}
|
||||
}, [profile, toast]);
|
||||
|
||||
const handleDeleteIdea = useCallback(async (ideaId: string) => {
|
||||
if (!profile) return;
|
||||
try {
|
||||
const res = await fetch("/api/blindbox", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ideaId, userId: profile.id }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "删除失败");
|
||||
}
|
||||
setMyIdeas((prev) => prev.filter((i) => i.id !== ideaId));
|
||||
setPoolCount((c) => Math.max(0, c - 1));
|
||||
} catch (e) {
|
||||
toast.show(e instanceof Error ? e.message : "删除失败");
|
||||
}
|
||||
}, [profile, toast]);
|
||||
|
||||
return {
|
||||
input,
|
||||
setInput,
|
||||
submitting,
|
||||
suggestions,
|
||||
suggestionsLoading,
|
||||
suggestionsSource,
|
||||
poolCount,
|
||||
setPoolCount,
|
||||
myIdeas,
|
||||
drawnHistory,
|
||||
setDrawnHistory,
|
||||
submitFlash,
|
||||
error,
|
||||
setError,
|
||||
inputRef,
|
||||
boxControls,
|
||||
fetchIdeas,
|
||||
fetchSuggestions,
|
||||
refreshSuggestions,
|
||||
handleSubmit,
|
||||
handleEditIdea,
|
||||
handleDeleteIdea,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { getCachedProfile } from "@/lib/userId";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import type { RoomInfo } from "@/hooks/useBlindboxRoom";
|
||||
import type { WeekendPlanData, UserProfile } from "@/types";
|
||||
import type { PendingContract } from "@/components/ContractCompletionModal";
|
||||
|
||||
type Phase = "pool" | "shaking" | "reveal" | "time_select" | "planning" | "plan_reveal";
|
||||
|
||||
const PLAN_STATUS_STEPS = [
|
||||
"正在分析你们的想法...",
|
||||
"正在搜索地点...",
|
||||
"正在规划路线...",
|
||||
"快好了...",
|
||||
];
|
||||
|
||||
export function useBlindboxPlan(
|
||||
room: RoomInfo | null,
|
||||
profile: UserProfile | null,
|
||||
phase: Phase,
|
||||
setPhase: (p: Phase) => void,
|
||||
fireConfetti: () => void,
|
||||
) {
|
||||
const toast = useToast();
|
||||
const [planId, setPlanId] = useState<string | null>(null);
|
||||
const [planDays, setPlanDays] = useState<WeekendPlanData[]>([]);
|
||||
const [planAccepted, setPlanAccepted] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [planStatusMessages, setPlanStatusMessages] = useState<string[]>([]);
|
||||
const [showPlanShareCard, setShowPlanShareCard] = useState(false);
|
||||
const [activeContract, setActiveContract] = useState<{
|
||||
id: string;
|
||||
days: WeekendPlanData[];
|
||||
endTime: string | null;
|
||||
} | null>(null);
|
||||
const [pendingContracts, setPendingContracts] = useState<PendingContract[]>([]);
|
||||
const planLogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (planLogRef.current) {
|
||||
planLogRef.current.scrollTop = planLogRef.current.scrollHeight;
|
||||
}
|
||||
}, [planStatusMessages]);
|
||||
|
||||
const fetchAcceptedPlan = useCallback(async () => {
|
||||
if (!room || !profile) return;
|
||||
try {
|
||||
const res = await fetch(`/api/blindbox/plan?mode=latest&roomId=${room.id}&userId=${profile.id}`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (data.plan) {
|
||||
setActiveContract({
|
||||
id: data.plan.id,
|
||||
days: data.plan.days,
|
||||
endTime: data.plan.endTime ?? null,
|
||||
});
|
||||
}
|
||||
} catch (e) { console.error("fetchAcceptedPlan failed:", e); }
|
||||
}, [room, profile]);
|
||||
|
||||
// Check for expired contracts on load
|
||||
useEffect(() => {
|
||||
if (!profile) return;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/blindbox/plan?mode=pending&userId=${profile.id}`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (data.pending?.length) setPendingContracts(data.pending);
|
||||
} catch (e) { console.error("fetchPendingContracts failed:", e); }
|
||||
})();
|
||||
}, [profile]);
|
||||
|
||||
// Browser notification timer for active contract
|
||||
useEffect(() => {
|
||||
if (!activeContract?.endTime) return;
|
||||
const end = new Date(activeContract.endTime).getTime();
|
||||
const now = Date.now();
|
||||
const ms = end - now;
|
||||
if (ms <= 0) return;
|
||||
|
||||
if (typeof Notification !== "undefined" && Notification.permission === "default") {
|
||||
Notification.requestPermission();
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (typeof Notification !== "undefined" && Notification.permission === "granted") {
|
||||
const n = new Notification("周末契约到期", {
|
||||
body: "你的周末契约已结束,完成了吗?",
|
||||
icon: "/icon-192x192.png",
|
||||
});
|
||||
n.onclick = () => { window.focus(); n.close(); };
|
||||
}
|
||||
const p = getCachedProfile();
|
||||
if (p) {
|
||||
fetch(`/api/blindbox/plan?mode=pending&userId=${p.id}`)
|
||||
.then((r) => r.json())
|
||||
.then((d) => { if (d.pending?.length) setPendingContracts(d.pending); })
|
||||
.catch((e) => { console.error("refreshPendingContracts failed:", e); });
|
||||
}
|
||||
}, ms);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [activeContract?.endTime]);
|
||||
|
||||
const handleGeneratePlan = useCallback(async (timeConfig: { date: string; startHour: number; endHour: number }) => {
|
||||
if (generating || !profile || !room) return;
|
||||
setGenerating(true);
|
||||
setPhase("planning");
|
||||
setPlanStatusMessages([PLAN_STATUS_STEPS[0]]);
|
||||
const payload = { roomId: room.id, userId: profile.id, availableTime: timeConfig };
|
||||
const stepRef = { current: 0 };
|
||||
const fallbackTimer = setInterval(() => {
|
||||
stepRef.current = (stepRef.current + 1) % PLAN_STATUS_STEPS.length;
|
||||
setPlanStatusMessages((prev) => [...prev, PLAN_STATUS_STEPS[stepRef.current]]);
|
||||
}, 2800);
|
||||
try {
|
||||
const res = await fetch("/api/blindbox/plan/stream", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || "生成失败");
|
||||
}
|
||||
const reader = res.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
if (!reader) throw new Error("无法读取响应");
|
||||
let buffer = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const blocks = buffer.split("\n\n");
|
||||
buffer = blocks.pop() ?? "";
|
||||
for (const block of blocks) {
|
||||
let eventType = "";
|
||||
let data = "";
|
||||
for (const line of block.split("\n")) {
|
||||
if (line.startsWith("event:")) eventType = line.slice(6).trim();
|
||||
else if (line.startsWith("data:")) data = line.slice(5).trim();
|
||||
}
|
||||
if (eventType === "status") setPlanStatusMessages((prev) => [...prev, data]);
|
||||
else if (eventType === "plan") {
|
||||
const parsed = JSON.parse(data);
|
||||
setPlanId(parsed.id);
|
||||
setPlanDays(parsed.days);
|
||||
setPlanAccepted(false);
|
||||
setPhase("plan_reveal");
|
||||
fireConfetti();
|
||||
} else if (eventType === "error") {
|
||||
toast.show(data || "生成计划失败");
|
||||
setPhase("pool");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
try {
|
||||
const res = await fetch("/api/blindbox/plan", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || "生成失败");
|
||||
}
|
||||
const data = await res.json();
|
||||
setPlanId(data.id);
|
||||
setPlanDays(data.days);
|
||||
setPlanAccepted(false);
|
||||
setPhase("plan_reveal");
|
||||
fireConfetti();
|
||||
} catch (fallbackErr) {
|
||||
toast.show(fallbackErr instanceof Error ? fallbackErr.message : "生成计划失败");
|
||||
setPhase("pool");
|
||||
}
|
||||
} finally {
|
||||
clearInterval(fallbackTimer);
|
||||
setGenerating(false);
|
||||
}
|
||||
}, [generating, profile, room, setPhase, fireConfetti, toast]);
|
||||
|
||||
const handlePlanDaysChange = useCallback(async (newDays: WeekendPlanData[]) => {
|
||||
if (!planId || !profile) return;
|
||||
const prevDays = planDays;
|
||||
setPlanDays(newDays);
|
||||
if (planAccepted) {
|
||||
setActiveContract((prev) => prev ? { ...prev, days: newDays } : prev);
|
||||
}
|
||||
try {
|
||||
const res = await fetch("/api/blindbox/plan", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ planId, userId: profile.id, action: "update_plan", days: newDays }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "保存失败");
|
||||
} catch (e) {
|
||||
setPlanDays(prevDays);
|
||||
if (planAccepted) setActiveContract((prev) => prev ? { ...prev, days: prevDays } : prev);
|
||||
toast.show(e instanceof Error ? e.message : "保存失败");
|
||||
}
|
||||
}, [planId, profile, planDays, planAccepted, toast]);
|
||||
|
||||
const handleRefine = useCallback(async (instruction: string) => {
|
||||
if (!profile || !planDays.length) return;
|
||||
const prevDays = planDays;
|
||||
try {
|
||||
const res = await fetch("/api/blindbox/plan/refine", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId: profile.id, instruction, days: planDays }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || "AI 调整失败");
|
||||
const data = await res.json();
|
||||
await handlePlanDaysChange(data.days);
|
||||
} catch (e) {
|
||||
setPlanDays(prevDays);
|
||||
toast.show(e instanceof Error ? e.message : "AI 调整失败");
|
||||
}
|
||||
}, [profile, planDays, handlePlanDaysChange, toast]);
|
||||
|
||||
const handleAcceptPlan = useCallback(async () => {
|
||||
setPlanAccepted(true);
|
||||
fireConfetti();
|
||||
if (planId && profile) {
|
||||
try {
|
||||
const res = await fetch("/api/blindbox/plan", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ planId, userId: profile.id, action: "accept" }),
|
||||
});
|
||||
const data = await res.json();
|
||||
setActiveContract({ id: planId, days: planDays, endTime: data.endTime ?? null });
|
||||
} catch (e) { console.error("acceptPlan failed:", e); }
|
||||
}
|
||||
toast.show("契约已接受!");
|
||||
}, [planId, profile, planDays, fireConfetti, toast]);
|
||||
|
||||
const handleRegenerate = useCallback(() => {
|
||||
setPlanId(null);
|
||||
setPlanDays([]);
|
||||
setPlanAccepted(false);
|
||||
setPhase("time_select");
|
||||
}, [setPhase]);
|
||||
|
||||
const showActiveContract = useCallback(() => {
|
||||
if (!activeContract) return;
|
||||
setPlanId(activeContract.id);
|
||||
setPlanDays(activeContract.days);
|
||||
setPlanAccepted(true);
|
||||
setPhase("plan_reveal");
|
||||
}, [activeContract, setPhase]);
|
||||
|
||||
const clearPendingContracts = useCallback(() => {
|
||||
setPendingContracts([]);
|
||||
setActiveContract(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
planId,
|
||||
planDays,
|
||||
setPlanDays,
|
||||
planAccepted,
|
||||
generating,
|
||||
planStatusMessages,
|
||||
showPlanShareCard,
|
||||
setShowPlanShareCard,
|
||||
activeContract,
|
||||
pendingContracts,
|
||||
planLogRef,
|
||||
fetchAcceptedPlan,
|
||||
handleGeneratePlan,
|
||||
handlePlanDaysChange,
|
||||
handleRefine,
|
||||
handleAcceptPlan,
|
||||
handleRegenerate,
|
||||
showActiveContract,
|
||||
clearPendingContracts,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { getCachedProfile, isRegistered } from "@/lib/userId";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import { useShare } from "@/hooks/useShare";
|
||||
import type { UserProfile } from "@/types";
|
||||
|
||||
export interface RoomInfo {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
creatorId: string;
|
||||
city: string | null;
|
||||
address: string | null;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
poolCount: number;
|
||||
members: { id: string; username: string; avatar: string }[];
|
||||
}
|
||||
|
||||
export function useBlindboxRoom(code: string) {
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const { share, copyToClipboard } = useShare();
|
||||
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [room, setRoom] = useState<RoomInfo | null>(null);
|
||||
const [isMember, setIsMember] = useState(false);
|
||||
const [joiningRoom, setJoiningRoom] = useState(false);
|
||||
const [pageLoading, setPageLoading] = useState(true);
|
||||
const [locating, setLocating] = useState(false);
|
||||
const [confirmLeave, setConfirmLeave] = useState(false);
|
||||
const [leaving, setLeaving] = useState(false);
|
||||
const [showInvite, setShowInvite] = useState(false);
|
||||
const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => { timersRef.current.forEach(clearTimeout); };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRegistered()) {
|
||||
router.replace("/blindbox");
|
||||
return;
|
||||
}
|
||||
setProfile(getCachedProfile());
|
||||
}, [router]);
|
||||
|
||||
const fetchRoom = useCallback(async () => {
|
||||
if (!code) return;
|
||||
try {
|
||||
const res = await fetch(`/api/blindbox/room/${code}`);
|
||||
if (!res.ok) { router.replace("/blindbox"); return; }
|
||||
const data: RoomInfo = await res.json();
|
||||
setRoom(data);
|
||||
const p = getCachedProfile();
|
||||
setIsMember(data.members.some((m) => m.id === p?.id));
|
||||
} catch {
|
||||
router.replace("/blindbox");
|
||||
} finally {
|
||||
setPageLoading(false);
|
||||
}
|
||||
}, [code, router]);
|
||||
|
||||
useEffect(() => { fetchRoom(); }, [fetchRoom]);
|
||||
|
||||
const handleJoinRoom = async () => {
|
||||
if (joiningRoom || !profile || !room) return;
|
||||
setJoiningRoom(true);
|
||||
try {
|
||||
const res = await fetch("/api/blindbox/room/join", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId: profile.id, code }),
|
||||
});
|
||||
if (res.ok) { setIsMember(true); fetchRoom(); }
|
||||
} catch (e) { console.error("handleJoinRoom failed:", e); }
|
||||
finally { setJoiningRoom(false); }
|
||||
};
|
||||
|
||||
const handleSetLocation = useCallback(async () => {
|
||||
if (locating || !profile || !room) return;
|
||||
setLocating(true);
|
||||
try {
|
||||
const pos = await new Promise<GeolocationPosition>((resolve, reject) =>
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject, { timeout: 10000 }),
|
||||
);
|
||||
const { latitude: lat, longitude: lng } = pos.coords;
|
||||
const regeoRes = await fetch(`/api/location/regeo?lat=${lat}&lng=${lng}`);
|
||||
const regeo = regeoRes.ok ? await regeoRes.json() : {};
|
||||
const cityName = regeo.name || "未知位置";
|
||||
const addressLabel = regeo.formatted || cityName;
|
||||
const patchRes = await fetch(`/api/blindbox/room/${room.code}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId: profile.id, city: cityName, address: addressLabel, lat, lng }),
|
||||
});
|
||||
if (!patchRes.ok) throw new Error("保存位置失败");
|
||||
setRoom((prev) => prev ? { ...prev, city: cityName, address: addressLabel, lat, lng } : prev);
|
||||
toast.show("位置已设置");
|
||||
} catch {
|
||||
toast.show("获取位置失败,请允许定位权限");
|
||||
} finally {
|
||||
setLocating(false);
|
||||
}
|
||||
}, [locating, profile, room, toast]);
|
||||
|
||||
const handleLeaveOrDelete = async () => {
|
||||
if (!confirmLeave) {
|
||||
setConfirmLeave(true);
|
||||
timersRef.current.push(setTimeout(() => setConfirmLeave(false), 3000));
|
||||
return;
|
||||
}
|
||||
if (leaving || !profile || !room) return;
|
||||
setLeaving(true);
|
||||
try {
|
||||
const res = await fetch(`/api/blindbox/room/${room.code}`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId: profile.id }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "操作失败");
|
||||
}
|
||||
router.push("/blindbox");
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast.show(e instanceof Error ? e.message : "操作失败");
|
||||
setConfirmLeave(false);
|
||||
} finally {
|
||||
setLeaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyCode = useCallback(
|
||||
() => room ? copyToClipboard(room.code, "房间号已复制") : undefined,
|
||||
[room, copyToClipboard],
|
||||
);
|
||||
|
||||
const handleShareRoom = useCallback(() => {
|
||||
if (!room) return;
|
||||
const url = typeof window !== "undefined" ? `${window.location.origin}/blindbox/${room.code}` : "";
|
||||
share(
|
||||
{ title: `周末契约 · ${room.name}`, text: `来和我一起玩周末盲盒吧!房间号:${room.code}`, url },
|
||||
handleCopyCode,
|
||||
);
|
||||
}, [room, share, handleCopyCode]);
|
||||
|
||||
const handleBackToLobby = useCallback(() => {
|
||||
router.push("/blindbox");
|
||||
router.refresh();
|
||||
}, [router]);
|
||||
|
||||
const isCreator = profile?.id === room?.creatorId;
|
||||
|
||||
return {
|
||||
profile,
|
||||
room,
|
||||
isMember,
|
||||
joiningRoom,
|
||||
pageLoading,
|
||||
locating,
|
||||
confirmLeave,
|
||||
leaving,
|
||||
showInvite,
|
||||
setShowInvite,
|
||||
isCreator,
|
||||
handleJoinRoom,
|
||||
handleSetLocation,
|
||||
handleLeaveOrDelete,
|
||||
handleCopyCode,
|
||||
handleShareRoom,
|
||||
handleBackToLobby,
|
||||
fetchRoom,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import useSWR from "swr";
|
||||
import { fetcher, FetchError } from "@/lib/fetcher";
|
||||
|
||||
interface RoomSummary {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
creatorId: string;
|
||||
memberCount: number;
|
||||
poolCount: number;
|
||||
members: { id: string; username: string; avatar: string }[];
|
||||
lastDrawn: { content: string; createdAt: string } | null;
|
||||
}
|
||||
|
||||
interface RoomsResponse {
|
||||
rooms: RoomSummary[];
|
||||
}
|
||||
|
||||
export function useBlindboxRooms(userId: string | undefined) {
|
||||
const { data, error, isLoading, mutate } = useSWR<RoomsResponse>(
|
||||
userId ? `/api/blindbox/rooms?userId=${userId}` : null,
|
||||
fetcher,
|
||||
{
|
||||
revalidateOnFocus: true,
|
||||
revalidateOnReconnect: true,
|
||||
shouldRetryOnError: (err) =>
|
||||
!(err instanceof FetchError && (err.status === 401 || err.status === 403)),
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
rooms: data?.rooms ?? [],
|
||||
isLoading,
|
||||
isUnauthorized: error instanceof FetchError && error.status === 401,
|
||||
error,
|
||||
mutate,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import useSWR from "swr";
|
||||
import { fetcher } from "@/lib/fetcher";
|
||||
import type { FavoriteRecord } from "@/types";
|
||||
|
||||
export function useFavorites(userId: string | undefined) {
|
||||
const { data, isLoading, mutate } = useSWR<FavoriteRecord[]>(
|
||||
userId ? `/api/user/favorite?userId=${userId}` : null,
|
||||
fetcher,
|
||||
);
|
||||
|
||||
return {
|
||||
favorites: data ?? [],
|
||||
isLoading,
|
||||
mutate,
|
||||
};
|
||||
}
|
||||
@@ -2,24 +2,19 @@
|
||||
|
||||
import useSWR from "swr";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { fetcher, FetchError } from "@/lib/fetcher";
|
||||
import { RoomStatus } from "@/types";
|
||||
|
||||
async function fetcher(url: string) {
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) {
|
||||
const err = new Error(r.status === 404 ? "NOT_FOUND" : "FETCH_ERROR");
|
||||
throw err;
|
||||
}
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export function useRoomPolling(roomId: string | undefined) {
|
||||
const { data, error, isLoading, mutate } = useSWR<RoomStatus>(
|
||||
roomId ? `/api/room/${roomId}` : null,
|
||||
fetcher,
|
||||
{
|
||||
revalidateOnFocus: true,
|
||||
shouldRetryOnError: (err) => err?.message !== "NOT_FOUND",
|
||||
shouldRetryOnError: (err) => {
|
||||
if (err instanceof FetchError && (err.status === 401 || err.status === 404)) return false;
|
||||
return err?.message !== "NOT_FOUND";
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
+324
@@ -1,7 +1,331 @@
|
||||
/**
|
||||
* Unified Amap (高德地图) API client.
|
||||
* All external calls go through amapFetch() with consistent timeout + error handling.
|
||||
*/
|
||||
import { ApiError } from "@/lib/api";
|
||||
|
||||
const AMAP_TIMEOUT_MS = 8000;
|
||||
|
||||
export function requireAmapApiKey(): string {
|
||||
const key = process.env.AMAP_API_KEY;
|
||||
if (!key) throw new ApiError("服务配置异常,请稍后重试", 500);
|
||||
return key;
|
||||
}
|
||||
|
||||
async function amapFetch(url: URL): Promise<Record<string, unknown>> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url.toString(), {
|
||||
signal: AbortSignal.timeout(AMAP_TIMEOUT_MS),
|
||||
});
|
||||
} catch {
|
||||
throw new ApiError("位置服务暂时不可用,请稍后重试", 503);
|
||||
}
|
||||
const data = await res.json();
|
||||
return data as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared POI mapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface RawPoi {
|
||||
id?: string;
|
||||
name: string;
|
||||
address?: string;
|
||||
location?: string;
|
||||
type?: string;
|
||||
business?: { rating?: string; cost?: string; tel?: string };
|
||||
}
|
||||
|
||||
export interface PoiResult {
|
||||
id?: string;
|
||||
name: string;
|
||||
address: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
rating?: number | null;
|
||||
cost?: number | null;
|
||||
}
|
||||
|
||||
function mapPois(pois: RawPoi[], opts?: { includeCost?: boolean }): PoiResult[] {
|
||||
return pois
|
||||
.filter((p) => p.location)
|
||||
.map((p) => {
|
||||
const [lng, lat] = (p.location ?? "0,0").split(",").map(Number);
|
||||
const ratingStr = p.business?.rating;
|
||||
const rating =
|
||||
ratingStr && ratingStr !== "[]" ? parseFloat(ratingStr) || null : null;
|
||||
const result: PoiResult = {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
address: p.address || "",
|
||||
lat,
|
||||
lng,
|
||||
rating,
|
||||
};
|
||||
if (opts?.includeCost) {
|
||||
const costStr = p.business?.cost;
|
||||
result.cost =
|
||||
costStr && costStr !== "[]" && costStr !== "0"
|
||||
? Number(costStr)
|
||||
: null;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POI search — text (v5)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function searchPlaceText(params: {
|
||||
keywords: string;
|
||||
city?: string;
|
||||
types?: string;
|
||||
location?: string;
|
||||
pageSize?: number;
|
||||
}): Promise<PoiResult[]> {
|
||||
const url = new URL("https://restapi.amap.com/v5/place/text");
|
||||
url.searchParams.set("key", requireAmapApiKey());
|
||||
url.searchParams.set("keywords", params.keywords);
|
||||
url.searchParams.set("show_fields", "business");
|
||||
url.searchParams.set("page_size", String(params.pageSize ?? 10));
|
||||
if (params.city) url.searchParams.set("region", params.city);
|
||||
if (params.types) url.searchParams.set("types", params.types);
|
||||
if (params.location) url.searchParams.set("location", params.location);
|
||||
|
||||
const data = await amapFetch(url);
|
||||
if (data.status !== "1" || !(data.pois as RawPoi[] | undefined)?.length) {
|
||||
return [];
|
||||
}
|
||||
return mapPois(data.pois as RawPoi[], { includeCost: true });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POI search — around (v5)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function searchPlaceAround(params: {
|
||||
keywords: string;
|
||||
location: string;
|
||||
radius?: number;
|
||||
pageSize?: number;
|
||||
}): Promise<PoiResult[]> {
|
||||
const url = new URL("https://restapi.amap.com/v5/place/around");
|
||||
url.searchParams.set("key", requireAmapApiKey());
|
||||
url.searchParams.set("location", params.location);
|
||||
url.searchParams.set("keywords", params.keywords);
|
||||
url.searchParams.set("radius", String(params.radius ?? 5000));
|
||||
url.searchParams.set("show_fields", "business");
|
||||
url.searchParams.set("page_size", String(params.pageSize ?? 8));
|
||||
|
||||
const data = await amapFetch(url);
|
||||
if (data.status !== "1" || !(data.pois as RawPoi[] | undefined)?.length) {
|
||||
return [];
|
||||
}
|
||||
return mapPois(data.pois as RawPoi[]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input tips (v3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface InputTipResult {
|
||||
id: string;
|
||||
name: string;
|
||||
district: string;
|
||||
address: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
export async function getInputTips(params: {
|
||||
keywords: string;
|
||||
location?: string;
|
||||
}): Promise<InputTipResult[]> {
|
||||
const url = new URL("https://restapi.amap.com/v3/assistant/inputtips");
|
||||
url.searchParams.set("key", requireAmapApiKey());
|
||||
url.searchParams.set("keywords", params.keywords);
|
||||
url.searchParams.set("datatype", "poi");
|
||||
if (params.location) url.searchParams.set("location", params.location);
|
||||
|
||||
const data = await amapFetch(url);
|
||||
if (data.status !== "1" || !data.tips) return [];
|
||||
|
||||
return (
|
||||
data.tips as {
|
||||
id: string;
|
||||
name: string;
|
||||
district?: string;
|
||||
address?: string;
|
||||
location?: string;
|
||||
}[]
|
||||
)
|
||||
.filter((t) => t.location && t.location !== "")
|
||||
.slice(0, 8)
|
||||
.map((t) => {
|
||||
const [lng, lat] = t.location!.split(",").map(Number);
|
||||
return {
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
district: t.district || "",
|
||||
address: t.address || "",
|
||||
lat,
|
||||
lng,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reverse geocode (v3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RegeoResult {
|
||||
name: string | null;
|
||||
formatted: string | null;
|
||||
}
|
||||
|
||||
export async function reverseGeocode(params: {
|
||||
lat: number;
|
||||
lng: number;
|
||||
}): Promise<RegeoResult> {
|
||||
const url = new URL("https://restapi.amap.com/v3/geocode/regeo");
|
||||
url.searchParams.set("key", requireAmapApiKey());
|
||||
url.searchParams.set("location", `${params.lng},${params.lat}`);
|
||||
url.searchParams.set("extensions", "base");
|
||||
|
||||
const data = await amapFetch(url);
|
||||
if (data.status !== "1" || !data.regeocode) {
|
||||
return { name: null, formatted: null };
|
||||
}
|
||||
|
||||
const regeocode = data.regeocode as Record<string, unknown>;
|
||||
const comp = regeocode.addressComponent as Record<string, unknown> | undefined;
|
||||
const district = (comp?.district || comp?.city || "") as string;
|
||||
const township = (comp?.township || "") as string;
|
||||
const neighborhood = (
|
||||
(comp?.neighborhood as Record<string, unknown> | undefined)?.name || ""
|
||||
) as string;
|
||||
|
||||
const name = [district, township, neighborhood]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.trim();
|
||||
|
||||
return {
|
||||
name: name || (regeocode.formatted_address as string) || null,
|
||||
formatted: (regeocode.formatted_address as string) || null,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transit direction (v3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TransitResult {
|
||||
durationMin: number;
|
||||
distanceKm: number;
|
||||
description: string;
|
||||
mode: string;
|
||||
}
|
||||
|
||||
export function parseTransitSegments(
|
||||
segments: Record<string, unknown>[],
|
||||
): { description: string; mode: string } {
|
||||
const parts: string[] = [];
|
||||
let hasSubway = false;
|
||||
let hasBus = false;
|
||||
|
||||
for (const seg of segments) {
|
||||
const bus = seg.bus as
|
||||
| { buslines?: Record<string, unknown>[] }
|
||||
| undefined;
|
||||
if (!bus?.buslines?.length) continue;
|
||||
for (const line of bus.buslines) {
|
||||
const rawName = String(line.name ?? "");
|
||||
const name = rawName.replace(/\(.*?\)$/, "").trim();
|
||||
const viaNum = Number(line.via_num ?? 0);
|
||||
const isSubway =
|
||||
String(line.type ?? "").includes("地铁") ||
|
||||
String(line.type ?? "").includes("轨道");
|
||||
if (isSubway) hasSubway = true;
|
||||
else hasBus = true;
|
||||
const dep = (line.departure_stop as Record<string, unknown> | undefined)
|
||||
?.name;
|
||||
const arr = (line.arrival_stop as Record<string, unknown> | undefined)
|
||||
?.name;
|
||||
const stops = dep && arr ? ` ${dep}→${arr}` : "";
|
||||
parts.push(
|
||||
viaNum > 0 ? `${name}${stops}(${viaNum}站)` : `${name}${stops}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mode = hasSubway ? "地铁" : hasBus ? "公交" : "步行";
|
||||
const description = parts.length > 0 ? parts.join(" → ") : mode;
|
||||
return { description, mode };
|
||||
}
|
||||
|
||||
export async function getTransitDirection(params: {
|
||||
originLat: number;
|
||||
originLng: number;
|
||||
destLat: number;
|
||||
destLng: number;
|
||||
city: string;
|
||||
cityDest?: string;
|
||||
}): Promise<TransitResult | null> {
|
||||
const url = new URL(
|
||||
"https://restapi.amap.com/v3/direction/transit/integrated",
|
||||
);
|
||||
url.searchParams.set("key", requireAmapApiKey());
|
||||
url.searchParams.set("origin", `${params.originLng},${params.originLat}`);
|
||||
url.searchParams.set(
|
||||
"destination",
|
||||
`${params.destLng},${params.destLat}`,
|
||||
);
|
||||
url.searchParams.set("city", params.city);
|
||||
url.searchParams.set("cityd", params.cityDest ?? params.city);
|
||||
url.searchParams.set("output", "json");
|
||||
|
||||
const data = await amapFetch(url);
|
||||
if (data.status !== "1") return null;
|
||||
|
||||
const route = data.route as Record<string, unknown> | undefined;
|
||||
const transits = route?.transits as Record<string, unknown>[] | undefined;
|
||||
if (!transits?.length) return null;
|
||||
|
||||
const transit = transits[0];
|
||||
const durationMin = Math.ceil(Number(transit.duration) / 60);
|
||||
const distanceKm =
|
||||
Math.round(Number(route!.distance) / 100) / 10;
|
||||
const { description, mode } = parseTransitSegments(
|
||||
(transit.segments as Record<string, unknown>[]) ?? [],
|
||||
);
|
||||
return { durationMin, distanceKm, description, mode };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Convenience: POI search used by plan generation (dispatches text vs around)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function searchPois(
|
||||
query: string,
|
||||
searchType: string,
|
||||
anchorLat: number,
|
||||
anchorLng: number,
|
||||
): Promise<PoiResult[]> {
|
||||
const location = `${anchorLng},${anchorLat}`;
|
||||
if (searchType === "category") {
|
||||
return searchPlaceAround({
|
||||
keywords: query,
|
||||
location,
|
||||
pageSize: 8,
|
||||
});
|
||||
}
|
||||
return searchPlaceText({
|
||||
keywords: query,
|
||||
location,
|
||||
pageSize: 8,
|
||||
});
|
||||
}
|
||||
|
||||
+27
-130
@@ -6,7 +6,7 @@
|
||||
* Fallback path: legacy pipeline (runLegacyPlanGeneration)
|
||||
*/
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireAmapApiKey } from "@/lib/amap";
|
||||
import { searchPois, getTransitDirection } from "@/lib/amap";
|
||||
import { generateSchedule, runAgentLoop, type ScheduleContext, type AgentTool } from "@/lib/ai";
|
||||
import { ApiError } from "@/lib/api";
|
||||
|
||||
@@ -110,93 +110,6 @@ function selectIdeasForSlots(ideas: TaggedIdea[], availableHours: number): Tagge
|
||||
return selected;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transit segment parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseTransitSegments(
|
||||
segments: Record<string, unknown>[],
|
||||
): { description: string; mode: string } {
|
||||
const parts: string[] = [];
|
||||
let hasSubway = false;
|
||||
let hasBus = false;
|
||||
|
||||
for (const seg of segments) {
|
||||
const bus = seg.bus as { buslines?: Record<string, unknown>[] } | undefined;
|
||||
if (!bus?.buslines?.length) continue;
|
||||
for (const line of bus.buslines) {
|
||||
const rawName = String(line.name ?? "");
|
||||
const name = rawName.replace(/\(.*?\)$/, "").trim(); // strip terminal info like "(上海南站--江杨北路)"
|
||||
const viaNum = Number(line.via_num ?? 0);
|
||||
const isSubway = String(line.type ?? "").includes("地铁") || String(line.type ?? "").includes("轨道");
|
||||
if (isSubway) hasSubway = true; else hasBus = true;
|
||||
const dep = (line.departure_stop as Record<string, unknown> | undefined)?.name;
|
||||
const arr = (line.arrival_stop as Record<string, unknown> | undefined)?.name;
|
||||
const stops = dep && arr ? ` ${dep}→${arr}` : "";
|
||||
parts.push(viaNum > 0 ? `${name}${stops}(${viaNum}站)` : `${name}${stops}`);
|
||||
}
|
||||
}
|
||||
|
||||
const mode = hasSubway ? "地铁" : hasBus ? "公交" : "步行";
|
||||
const description = parts.length > 0 ? parts.join(" → ") : mode;
|
||||
return { description, mode };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POI search (shared by both paths)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function searchPois(
|
||||
query: string,
|
||||
searchType: string,
|
||||
anchorLat: number,
|
||||
anchorLng: number,
|
||||
): Promise<{ name: string; address: string; lat: number; lng: number; rating?: number }[]> {
|
||||
const apiKey = requireAmapApiKey();
|
||||
|
||||
if (searchType === "category") {
|
||||
const url = new URL("https://restapi.amap.com/v5/place/around");
|
||||
url.searchParams.set("key", apiKey);
|
||||
url.searchParams.set("location", `${anchorLng},${anchorLat}`);
|
||||
url.searchParams.set("keywords", query);
|
||||
url.searchParams.set("radius", "5000");
|
||||
url.searchParams.set("show_fields", "business");
|
||||
url.searchParams.set("page_size", "8");
|
||||
const res = await fetch(url.toString());
|
||||
const data = await res.json();
|
||||
if (data.status !== "1" || !data.pois?.length) return [];
|
||||
return mapPois(data.pois);
|
||||
}
|
||||
|
||||
const url = new URL("https://restapi.amap.com/v5/place/text");
|
||||
url.searchParams.set("key", apiKey);
|
||||
url.searchParams.set("keywords", query);
|
||||
url.searchParams.set("location", `${anchorLng},${anchorLat}`);
|
||||
url.searchParams.set("show_fields", "business");
|
||||
url.searchParams.set("page_size", "8");
|
||||
const res = await fetch(url.toString());
|
||||
const data = await res.json();
|
||||
if (data.status !== "1" || !data.pois?.length) return [];
|
||||
return mapPois(data.pois);
|
||||
}
|
||||
|
||||
function mapPois(
|
||||
pois: { name: string; address?: string; location?: string; business?: { rating?: string } }[],
|
||||
) {
|
||||
return pois
|
||||
.filter((p) => p.location)
|
||||
.map((p) => {
|
||||
const [lng, lat] = (p.location ?? "0,0").split(",").map(Number);
|
||||
const ratingStr = p.business?.rating;
|
||||
return {
|
||||
name: p.name,
|
||||
address: p.address || "",
|
||||
lat,
|
||||
lng,
|
||||
rating: ratingStr && ratingStr !== "[]" ? parseFloat(ratingStr) || undefined : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Progress messages (kept for legacy path)
|
||||
@@ -347,29 +260,16 @@ function buildAgentTools(
|
||||
required: ["origin_lat", "origin_lng", "dest_lat", "dest_lng"],
|
||||
},
|
||||
execute: async (args) => {
|
||||
const oLat = Number(args.origin_lat);
|
||||
const oLng = Number(args.origin_lng);
|
||||
const dLat = Number(args.dest_lat);
|
||||
const dLng = Number(args.dest_lng);
|
||||
const apiKey = requireAmapApiKey();
|
||||
const url = new URL("https://restapi.amap.com/v3/direction/transit/integrated");
|
||||
url.searchParams.set("key", apiKey);
|
||||
url.searchParams.set("origin", `${oLng},${oLat}`);
|
||||
url.searchParams.set("destination", `${dLng},${dLat}`);
|
||||
url.searchParams.set("city", city);
|
||||
url.searchParams.set("cityd", city);
|
||||
url.searchParams.set("output", "json");
|
||||
try {
|
||||
const res = await fetch(url.toString());
|
||||
const data = await res.json();
|
||||
if (data.status !== "1" || !data.route?.transits?.length) {
|
||||
return JSON.stringify({ error: "未找到公交路线" });
|
||||
}
|
||||
const transit = data.route.transits[0];
|
||||
const durationMin = Math.ceil(Number(transit.duration) / 60);
|
||||
const distanceKm = Math.round(Number(data.route.distance) / 100) / 10;
|
||||
const { description, mode } = parseTransitSegments(transit.segments ?? []);
|
||||
return JSON.stringify({ durationMin, distanceKm, description, mode });
|
||||
const result = await getTransitDirection({
|
||||
originLat: Number(args.origin_lat),
|
||||
originLng: Number(args.origin_lng),
|
||||
destLat: Number(args.dest_lat),
|
||||
destLng: Number(args.dest_lng),
|
||||
city,
|
||||
});
|
||||
if (!result) return JSON.stringify({ error: "未找到公交路线" });
|
||||
return JSON.stringify(result);
|
||||
} catch (e) {
|
||||
console.error("getTravelTimeTool failed:", e);
|
||||
return JSON.stringify({ error: "路线查询失败" });
|
||||
@@ -544,9 +444,12 @@ async function runLegacyPlanGeneration(
|
||||
}),
|
||||
);
|
||||
|
||||
const toCandidates = (pois: { name: string; address: string; lat: number; lng: number; rating?: number | null }[]) =>
|
||||
pois.map((p) => ({ ...p, rating: p.rating ?? undefined }));
|
||||
|
||||
const candidates: ScheduleContext["candidates"] = {};
|
||||
for (const result of searchResults) {
|
||||
candidates[result.query] = result.pois;
|
||||
candidates[result.query] = toCandidates(result.pois);
|
||||
}
|
||||
|
||||
const catQueries = [...uniqueByQuery.values()].filter((i) => i.searchType === "category");
|
||||
@@ -570,7 +473,7 @@ async function runLegacyPlanGeneration(
|
||||
}),
|
||||
);
|
||||
for (const result of catResults) {
|
||||
candidates[result.query] = result.pois;
|
||||
candidates[result.query] = toCandidates(result.pois);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -626,7 +529,6 @@ async function runLegacyPlanGeneration(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function queryTransit(
|
||||
apiKey: string,
|
||||
oLng: number,
|
||||
oLat: number,
|
||||
dLng: number,
|
||||
@@ -634,19 +536,15 @@ async function queryTransit(
|
||||
city: string,
|
||||
): Promise<{ durationMin: number; description: string } | null> {
|
||||
try {
|
||||
const url = new URL("https://restapi.amap.com/v3/direction/transit/integrated");
|
||||
url.searchParams.set("key", apiKey);
|
||||
url.searchParams.set("origin", `${oLng},${oLat}`);
|
||||
url.searchParams.set("destination", `${dLng},${dLat}`);
|
||||
url.searchParams.set("city", city);
|
||||
url.searchParams.set("cityd", city);
|
||||
url.searchParams.set("output", "json");
|
||||
const res = await fetch(url.toString());
|
||||
const data = await res.json();
|
||||
if (data.status !== "1" || !data.route?.transits?.length) return null;
|
||||
const transit = data.route.transits[0];
|
||||
const { description } = parseTransitSegments(transit.segments ?? []);
|
||||
return { durationMin: Math.ceil(Number(transit.duration) / 60), description };
|
||||
const result = await getTransitDirection({
|
||||
originLat: oLat,
|
||||
originLng: oLng,
|
||||
destLat: dLat,
|
||||
destLng: dLng,
|
||||
city,
|
||||
});
|
||||
if (!result) return null;
|
||||
return { durationMin: result.durationMin, description: result.description };
|
||||
} catch (e) {
|
||||
console.error("queryTransit failed:", e);
|
||||
return null;
|
||||
@@ -669,7 +567,6 @@ async function enrichTransitInfo(
|
||||
homeLat: number,
|
||||
homeLng: number,
|
||||
): Promise<void> {
|
||||
const apiKey = requireAmapApiKey();
|
||||
const cityParam = city || "上海";
|
||||
|
||||
for (const day of days) {
|
||||
@@ -680,7 +577,7 @@ async function enrichTransitInfo(
|
||||
const dLat = Number(items[0].lat);
|
||||
const dLng = Number(items[0].lng);
|
||||
if (dLat && dLng) {
|
||||
const result = await queryTransit(apiKey, homeLng, homeLat, dLng, dLat, cityParam);
|
||||
const result = await queryTransit(homeLng, homeLat, dLng, dLat, cityParam);
|
||||
if (result) {
|
||||
day.transitFromStart = result.durationMin;
|
||||
day.transitFromStartDescription = result.description;
|
||||
@@ -695,7 +592,7 @@ async function enrichTransitInfo(
|
||||
const dLat = Number(items[i + 1].lat);
|
||||
const dLng = Number(items[i + 1].lng);
|
||||
if (!oLat || !oLng || !dLat || !dLng) continue;
|
||||
const result = await queryTransit(apiKey, oLng, oLat, dLng, dLat, cityParam);
|
||||
const result = await queryTransit(oLng, oLat, dLng, dLat, cityParam);
|
||||
if (result) {
|
||||
items[i].transitToNext = result.durationMin;
|
||||
items[i].transitDescription = result.description;
|
||||
@@ -708,7 +605,7 @@ async function enrichTransitInfo(
|
||||
const oLat = Number(last.lat);
|
||||
const oLng = Number(last.lng);
|
||||
if (oLat && oLng) {
|
||||
const result = await queryTransit(apiKey, oLng, oLat, homeLng, homeLat, cityParam);
|
||||
const result = await queryTransit(oLng, oLat, homeLng, homeLat, cityParam);
|
||||
if (result) {
|
||||
day.transitToEnd = result.durationMin;
|
||||
day.transitToEndDescription = result.description;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Shared SWR fetcher with standard error handling.
|
||||
*/
|
||||
|
||||
export class FetchError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "FetchError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetcher<T = unknown>(url: string): Promise<T> {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new FetchError(
|
||||
res.status === 404 ? "NOT_FOUND" : res.status === 401 ? "UNAUTHORIZED" : "FETCH_ERROR",
|
||||
res.status,
|
||||
);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export function guessCategory(activity: string): string | null {
|
||||
const lower = activity.toLowerCase();
|
||||
if (/吃|餐|饭|火锅|烧烤|面|菜|厨|食/.test(lower)) return "dining";
|
||||
if (/手作|工坊|烘焙|插花|陶艺|DIY|体验/.test(lower)) return "experience";
|
||||
if (/露营|徒步|赶海|农场|自然|野|营地/.test(lower)) return "nature";
|
||||
if (/公园|山|湖|海|户外|骑/.test(lower)) return "outdoor";
|
||||
if (/电影|KTV|密室|游戏|桌游|剧/.test(lower)) return "entertainment";
|
||||
if (/逛街|购物|商场|买/.test(lower)) return "shopping";
|
||||
if (/运动|健身|球|跑|游泳|瑜伽/.test(lower)) return "sports";
|
||||
if (/博物馆|展览|美术|书/.test(lower)) return "culture";
|
||||
if (/咖啡|茶|SPA|按摩|下午茶/.test(lower)) return "relaxation";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatDuration(minutes: number): string {
|
||||
if (minutes >= 60) {
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
return m > 0 ? `${h}h${m}min` : `${h}h`;
|
||||
}
|
||||
return `${minutes}min`;
|
||||
}
|
||||
Reference in New Issue
Block a user