s''
This commit is contained in:
@@ -1,17 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useTranslation } from "@/i18n/navigation";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { cities } from "@/app/data/cities";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useTranslation } from "@/i18n/navigation";
|
||||
import CityCard from "@/app/components/CityCard";
|
||||
import CityModal from "@/app/components/CityModal";
|
||||
import Footer from "@/app/components/Footer";
|
||||
import type { City } from "@/app/data/cities";
|
||||
import { cities as fallbackCities, type City } from "@/app/data/cities";
|
||||
import { apiFetch } from "@/app/lib/api-client";
|
||||
|
||||
const TAGS = ["远程工作者", "创业者", "设计师", "开发者", "内容创作者", "带宠物"];
|
||||
type RecommendedCity = City & {
|
||||
matchScore?: number;
|
||||
matchReasons?: string[];
|
||||
idNumber?: number;
|
||||
};
|
||||
|
||||
interface Meetup {
|
||||
id: string;
|
||||
city: string;
|
||||
emoji?: string;
|
||||
date?: string;
|
||||
time?: string;
|
||||
venue?: string;
|
||||
rsvpCount?: number;
|
||||
isUpcoming?: boolean;
|
||||
}
|
||||
|
||||
interface NomadRoute {
|
||||
id?: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
titleEn?: string;
|
||||
durationDays: number;
|
||||
budget: number;
|
||||
description: string;
|
||||
stops: string[];
|
||||
cities?: RecommendedCity[];
|
||||
}
|
||||
|
||||
const CITY_TAGS = ["便宜", "宜居", "文化", "美食", "高速网络", "友好社区", "海滨", "都市"];
|
||||
const TIMEZONES = ["UTC+8 中国", "UTC+7 东南亚", "UTC+1 欧洲", "UTC-5 美东", "UTC-8 美西", "任意"];
|
||||
const VISA_OPTIONS = ["免签优先", "落地签可", "需提前办", "任意"];
|
||||
const CLIMATE_OPTIONS = [
|
||||
{ value: "any", label: "任意气候" },
|
||||
{ value: "mild", label: "温和" },
|
||||
{ value: "warm", label: "偏暖" },
|
||||
{ value: "cool", label: "偏凉" },
|
||||
];
|
||||
|
||||
function mapCity(item: Partial<RecommendedCity>, index: number): RecommendedCity {
|
||||
return {
|
||||
...item,
|
||||
id: Number(item.idNumber || item.id || index + 1),
|
||||
name: item.name || "未知城市",
|
||||
country: item.country || "中国",
|
||||
countryEmoji: item.countryEmoji || "🇨🇳",
|
||||
overallScore: Number(item.overallScore || 0),
|
||||
costPerMonth: Number(item.costPerMonth || 0),
|
||||
internetSpeed: Number(item.internetSpeed || 0),
|
||||
safety: item.safety || "很好",
|
||||
liked: item.liked || "很好",
|
||||
temperature: Number(item.temperature || 0),
|
||||
humidity: Number(item.humidity || 0),
|
||||
nomadsNow: Number(item.nomadsNow || 0),
|
||||
description: item.description || "",
|
||||
gradientFrom: item.gradientFrom || "#ff4d4f",
|
||||
gradientTo: item.gradientTo || "#ff7a45",
|
||||
icon: item.icon || "📍",
|
||||
tags: Array.isArray(item.tags) ? item.tags : [],
|
||||
} as RecommendedCity;
|
||||
}
|
||||
|
||||
function formatMeetupDate(date?: string, time?: string): string {
|
||||
if (!date) return time || "时间待定";
|
||||
const day = date.slice(5, 10).replace("-", "月") + "日";
|
||||
return time ? `${day} ${time.slice(0, 5)}` : day;
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { t } = useTranslation("dashboard");
|
||||
@@ -19,19 +82,83 @@ export default function DashboardPage() {
|
||||
const [internet, setInternet] = useState(50);
|
||||
const [timezone, setTimezone] = useState("UTC+8 中国");
|
||||
const [visa, setVisa] = useState("任意");
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [climate, setClimate] = useState("mild");
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>(["宜居"]);
|
||||
const [cityModal, setCityModal] = useState<City | null>(null);
|
||||
const [baseCities, setBaseCities] = useState<RecommendedCity[]>(
|
||||
fallbackCities.map((city, index) => mapCity(city, index))
|
||||
);
|
||||
const [recommendedCities, setRecommendedCities] = useState<RecommendedCity[]>([]);
|
||||
const [meetups, setMeetups] = useState<Meetup[]>([]);
|
||||
const [routes, setRoutes] = useState<NomadRoute[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const filteredCities = useMemo(() => {
|
||||
return [...cities]
|
||||
.filter((c) => c.costPerMonth <= budget * 1.2 && c.internetSpeed >= internet)
|
||||
.sort((a, b) => b.overallScore - a.overallScore)
|
||||
useEffect(() => {
|
||||
apiFetch<{ items: RecommendedCity[] }>("/api/cities")
|
||||
.then((data) => {
|
||||
if (data.items?.length) {
|
||||
setBaseCities(data.items.map(mapCity));
|
||||
}
|
||||
})
|
||||
.catch(() => setBaseCities(fallbackCities.map((city, index) => mapCity(city, index))));
|
||||
|
||||
apiFetch<{ items: Meetup[] }>("/api/meetups")
|
||||
.then((data) => setMeetups((data.items || []).slice(0, 8)))
|
||||
.catch(() => setMeetups([]));
|
||||
|
||||
apiFetch<{ items: NomadRoute[] }>("/api/routes")
|
||||
.then((data) => setRoutes(data.items || []))
|
||||
.catch(() => setRoutes([]));
|
||||
}, []);
|
||||
|
||||
const fallbackRecommendations = useMemo(() => {
|
||||
return [...baseCities]
|
||||
.filter((city) => city.costPerMonth <= budget * 1.25 && city.internetSpeed >= internet * 0.8)
|
||||
.map((city) => ({
|
||||
...city,
|
||||
matchScore: Math.round(city.overallScore * 20 + Math.max(0, 20 - city.costPerMonth / 500)),
|
||||
matchReasons: [
|
||||
city.costPerMonth <= budget ? "预算内" : "略超预算",
|
||||
city.internetSpeed >= internet ? "网络达标" : "需确认住处网络",
|
||||
city.nomadsNow >= 300 ? "社区活跃" : "适合安静工作",
|
||||
],
|
||||
}))
|
||||
.sort((a, b) => (b.matchScore || 0) - (a.matchScore || 0))
|
||||
.slice(0, 12);
|
||||
}, [budget, internet]);
|
||||
}, [baseCities, budget, internet]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
apiFetch<{ items: RecommendedCity[] }>("/api/recommendations/next-stop", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ budget, internet, climate, tags: selectedTags, limit: 12 }),
|
||||
})
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setRecommendedCities((data.items || []).map(mapCity));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setRecommendedCities(fallbackRecommendations);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [budget, climate, fallbackRecommendations, internet, selectedTags]);
|
||||
|
||||
const displayCities = recommendedCities.length ? recommendedCities : fallbackRecommendations;
|
||||
const bestCity = displayCities[0];
|
||||
const topCost = Math.max(...displayCities.slice(0, 6).map((city) => city.costPerMonth), 1);
|
||||
const topNomads = Math.max(...displayCities.slice(0, 6).map((city) => city.nomadsNow), 1);
|
||||
const upcomingMeetups = meetups.filter((meetup) => meetup.isUpcoming !== false).slice(0, 6);
|
||||
|
||||
const toggleTag = (tag: string) => {
|
||||
setSelectedTags((prev) =>
|
||||
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
|
||||
prev.includes(tag) ? prev.filter((item) => item !== tag) : [...prev, tag]
|
||||
);
|
||||
};
|
||||
|
||||
@@ -42,9 +169,7 @@ export default function DashboardPage() {
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="mt-1 text-gray-500 dark:text-gray-400">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
<p className="mt-1 text-gray-500 dark:text-gray-400">{t("subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
@@ -59,7 +184,7 @@ export default function DashboardPage() {
|
||||
max="15000"
|
||||
step="500"
|
||||
value={budget}
|
||||
onChange={(e) => setBudget(Number(e.target.value))}
|
||||
onChange={(event) => setBudget(Number(event.target.value))}
|
||||
className="w-full accent-[#ff4d4f]"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
@@ -73,11 +198,30 @@ export default function DashboardPage() {
|
||||
</label>
|
||||
<select
|
||||
value={timezone}
|
||||
onChange={(e) => setTimezone(e.target.value)}
|
||||
onChange={(event) => setTimezone(event.target.value)}
|
||||
className="w-full rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{TIMEZONES.map((z) => (
|
||||
<option key={z} value={z}>{z}</option>
|
||||
{TIMEZONES.map((zone) => (
|
||||
<option key={zone} value={zone}>
|
||||
{zone}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4 sm:p-6">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
气候偏好
|
||||
</label>
|
||||
<select
|
||||
value={climate}
|
||||
onChange={(event) => setClimate(event.target.value)}
|
||||
className="w-full rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{CLIMATE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
@@ -88,11 +232,13 @@ export default function DashboardPage() {
|
||||
</label>
|
||||
<select
|
||||
value={visa}
|
||||
onChange={(e) => setVisa(e.target.value)}
|
||||
onChange={(event) => setVisa(event.target.value)}
|
||||
className="w-full rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{VISA_OPTIONS.map((v) => (
|
||||
<option key={v} value={v}>{v}</option>
|
||||
{VISA_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
@@ -107,22 +253,21 @@ export default function DashboardPage() {
|
||||
max="150"
|
||||
step="10"
|
||||
value={internet}
|
||||
onChange={(e) => setInternet(Number(e.target.value))}
|
||||
onChange={(event) => setInternet(Number(event.target.value))}
|
||||
className="w-full accent-[#ff4d4f]"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{internet} Mbps
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{internet} Mbps</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4 sm:p-6">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
{t("tags")}
|
||||
城市偏好
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TAGS.map((tag) => (
|
||||
{CITY_TAGS.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
onClick={() => toggleTag(tag)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
||||
selectedTags.includes(tag)
|
||||
@@ -141,67 +286,129 @@ export default function DashboardPage() {
|
||||
🤖 {t("aiTip")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-amber-700 dark:text-amber-300">
|
||||
你和 80% 大理用户重合度高,推荐 3 月去清迈…
|
||||
{bestCity
|
||||
? `${bestCity.name} 当前匹配度最高:${(bestCity.matchReasons || []).join("、") || "综合分领先"}。`
|
||||
: `正在按 ${timezone} / ${visa} 重新计算。`}
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="lg:col-span-2">
|
||||
<div className="rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4 sm:p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
|
||||
{t("search")}
|
||||
</h2>
|
||||
<div className="flex items-center justify-between gap-3 mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{t("search")}
|
||||
</h2>
|
||||
{loading && (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">正在计算</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3" style={{ gridAutoRows: "160px" }}>
|
||||
{filteredCities.map((city, i) => (
|
||||
<div key={city.id} className="h-full min-h-[140px]">
|
||||
{displayCities.map((city, index) => (
|
||||
<div key={`${city.slug || city.id}-${index}`} className="h-full min-h-[140px]">
|
||||
<CityCard
|
||||
city={city}
|
||||
rank={i + 1}
|
||||
rank={index + 1}
|
||||
onClick={() => setCityModal(city)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{filteredCities.length === 0 && (
|
||||
<p className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
{t("noMatch")}
|
||||
</p>
|
||||
{displayCities.length === 0 && (
|
||||
<p className="text-center py-8 text-gray-500 dark:text-gray-400">{t("noMatch")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4 h-40 flex items-center justify-center">
|
||||
<span className="text-gray-400 dark:text-gray-500 text-sm">
|
||||
{t("costCurve")} {t("devSuffix")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4 h-40 flex items-center justify-center">
|
||||
<span className="text-gray-400 dark:text-gray-500 text-sm">
|
||||
{t("nomadHeatmap")} {t("devSuffix")}
|
||||
</span>
|
||||
</div>
|
||||
<section className="rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4 min-h-40">
|
||||
<h3 className="font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||
{t("costCurve")}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{displayCities.slice(0, 6).map((city) => (
|
||||
<div key={`cost-${city.slug || city.id}`}>
|
||||
<div className="mb-1 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>{city.name}</span>
|
||||
<span>¥{city.costPerMonth.toLocaleString()}/月</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-gray-100 dark:bg-gray-800 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-[#ff4d4f]"
|
||||
style={{ width: `${Math.max(10, (city.costPerMonth / topCost) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4 min-h-40">
|
||||
<h3 className="font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||
{t("nomadHeatmap")}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{displayCities.slice(0, 6).map((city) => (
|
||||
<div key={`community-${city.slug || city.id}`}>
|
||||
<div className="mb-1 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>{city.icon} {city.name}</span>
|
||||
<span>{city.nomadsNow.toLocaleString()} 人</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-gray-100 dark:bg-gray-800 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-emerald-500"
|
||||
style={{ width: `${Math.max(10, (city.nomadsNow / topNomads) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4">
|
||||
{routes.length > 0 && (
|
||||
<section className="mt-6 rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="font-medium text-gray-900 dark:text-gray-100">推荐路线</h3>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">{routes.length} 条路线</span>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{routes.slice(0, 3).map((route) => (
|
||||
<Link
|
||||
key={route.slug}
|
||||
href={`/dashboard?route=${encodeURIComponent(route.slug)}`}
|
||||
className="rounded-xl border border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800 p-3 hover:border-[#ff4d4f]/40 transition-colors"
|
||||
>
|
||||
<p className="font-semibold text-sm text-gray-900 dark:text-gray-100 line-clamp-1">
|
||||
{route.title}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{route.durationDays} 天 · ¥{route.budget.toLocaleString()}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
|
||||
{route.stops?.join(" → ") || route.description}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="mt-6 rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4">
|
||||
<h3 className="font-medium text-gray-900 dark:text-gray-100 mb-2">
|
||||
{t("nearbyEvents")}
|
||||
</h3>
|
||||
<div className="flex gap-2 overflow-x-auto pb-2">
|
||||
{[
|
||||
{ city: "大理", date: "3月9日", emoji: "🏯" },
|
||||
{ city: "成都", date: "3月12日", emoji: "🐼" },
|
||||
{ city: "深圳", date: "3月14日", emoji: "🌃" },
|
||||
].map((e) => (
|
||||
{(upcomingMeetups.length ? upcomingMeetups : meetups).slice(0, 6).map((meetup) => (
|
||||
<Link
|
||||
key={e.city}
|
||||
key={meetup.id}
|
||||
href="/meetups"
|
||||
className="shrink-0 px-4 py-2 rounded-xl bg-gray-100 dark:bg-gray-800 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
{e.emoji} {e.city} {e.date}
|
||||
{meetup.emoji || "📍"} {meetup.city} {formatMeetupDate(meetup.date, meetup.time)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user