s''
This commit is contained in:
@@ -3,11 +3,23 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "@/i18n/navigation";
|
||||
import Footer from "@/app/components/Footer";
|
||||
import { apiFetch } from "@/app/lib/api-client";
|
||||
|
||||
interface AssistantCity {
|
||||
id: string;
|
||||
slug?: string;
|
||||
name: string;
|
||||
costPerMonth?: number;
|
||||
internetSpeed?: number;
|
||||
overallScore?: number;
|
||||
}
|
||||
|
||||
export default function AIPage() {
|
||||
const { t } = useTranslation("ai");
|
||||
const [query, setQuery] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [answer, setAnswer] = useState("");
|
||||
const [cities, setCities] = useState<AssistantCity[]>([]);
|
||||
|
||||
const exampleQueries = [
|
||||
"我预算每月800刀,想亚洲热带,有稳定网,适合freelance写作,推荐城市+理由",
|
||||
@@ -15,11 +27,20 @@ export default function AIPage() {
|
||||
"生成3月清迈行程计划",
|
||||
];
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!query.trim()) return;
|
||||
setLoading(true);
|
||||
setTimeout(() => setLoading(false), 1500);
|
||||
try {
|
||||
const data = await apiFetch<{ answer: string; cities: AssistantCity[] }>("/api/ai/assistant", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ question: query }),
|
||||
});
|
||||
setAnswer(data.answer);
|
||||
setCities(data.cities || []);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -52,6 +73,25 @@ export default function AIPage() {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{(answer || cities.length > 0) && (
|
||||
<div className="mt-6 rounded-2xl border border-gray-100 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-800">
|
||||
{answer && <p className="leading-7 text-gray-700 dark:text-gray-300">{answer}</p>}
|
||||
{cities.length > 0 && (
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-3">
|
||||
{cities.map((city) => (
|
||||
<div key={city.id} className="rounded-xl bg-white p-3 text-sm shadow-sm dark:bg-gray-900">
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">{city.name}</p>
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
¥{Number(city.costPerMonth || 0).toLocaleString()}/月 · {city.internetSpeed || 0}Mbps
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[#ff4d4f]">评分 {city.overallScore || "-"}/5</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-gray-100 dark:border-gray-800">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
{t("examples")}
|
||||
@@ -72,7 +112,7 @@ export default function AIPage() {
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-center text-xs text-gray-400 dark:text-gray-500">
|
||||
{t("comingSoon")}
|
||||
AI 助手已接入本机城市数据库,后续可替换为真实模型和用户画像推荐。
|
||||
</p>
|
||||
</div>
|
||||
<Footer />
|
||||
|
||||
73
app/[locale]/app/page.tsx
Normal file
73
app/[locale]/app/page.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useLocale } from "@/i18n/navigation";
|
||||
import { apiFetch } from "@/app/lib/api-client";
|
||||
import Footer from "@/app/components/Footer";
|
||||
|
||||
interface ContentItem {
|
||||
title: string;
|
||||
titleEn?: string;
|
||||
subtitle: string;
|
||||
subtitleEn?: string;
|
||||
description: string;
|
||||
descriptionEn?: string;
|
||||
coverImage: string;
|
||||
mediaUrl: string;
|
||||
chapters?: { title: string; icon?: string }[];
|
||||
}
|
||||
|
||||
export default function AppLandingPage() {
|
||||
const locale = useLocale();
|
||||
const [item, setItem] = useState<ContentItem | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<{ item: ContentItem }>("/api/content/nomadcna-app")
|
||||
.then((data) => setItem(data.item))
|
||||
.catch(() => setItem(null));
|
||||
}, []);
|
||||
|
||||
const title = item ? (locale === "zh" ? item.title : item.titleEn || item.title) : "NomadCNA App";
|
||||
const subtitle = item ? (locale === "zh" ? item.subtitle : item.subtitleEn || item.subtitle) : "";
|
||||
const description = item ? (locale === "zh" ? item.description : item.descriptionEn || item.description) : "";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
|
||||
<main className="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-16">
|
||||
<section className="grid items-center gap-10 lg:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[#ff4d4f]">Nomad OS Mobile</p>
|
||||
<h1 className="mt-3 text-4xl font-bold tracking-tight text-gray-900 dark:text-gray-100 sm:text-5xl">{title}</h1>
|
||||
<p className="mt-4 text-xl text-gray-500 dark:text-gray-400">{subtitle}</p>
|
||||
<p className="mt-6 leading-8 text-gray-700 dark:text-gray-300">{description}</p>
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<a href={item?.mediaUrl || "#"} className="rounded-full bg-[#ff4d4f] px-6 py-3 font-semibold text-white hover:bg-[#ff7a45]">
|
||||
下载客户端包
|
||||
</a>
|
||||
<Link href="/map" className="rounded-full border border-gray-300 px-6 py-3 font-semibold text-gray-700 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800">
|
||||
查看成员地图
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative mx-auto h-[560px] w-[280px] rounded-[2rem] border-[10px] border-gray-900 bg-gray-950 p-3 shadow-2xl">
|
||||
<div className="h-full overflow-hidden rounded-[1.4rem] bg-white dark:bg-gray-900">
|
||||
{item?.coverImage && <img src={item.coverImage} alt={title} className="h-40 w-full object-cover" />}
|
||||
<div className="p-4">
|
||||
<p className="text-xs font-semibold text-[#ff4d4f]">今日推荐</p>
|
||||
<h2 className="mt-1 text-lg font-bold text-gray-900 dark:text-gray-100">大理 · 慢生活路线</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
{(item?.chapters || []).map((feature) => (
|
||||
<div key={feature.title} className="rounded-xl bg-gray-50 p-3 text-sm text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||
{feature.icon || "•"} {feature.title}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "@/i18n/navigation";
|
||||
import Footer from "@/app/components/Footer";
|
||||
import { apiFetch } from "@/app/lib/api-client";
|
||||
|
||||
type TabType = "friends" | "dating" | "partner" | "roommate" | "cofounder" | "explore";
|
||||
|
||||
@@ -29,7 +30,17 @@ interface MatchCard {
|
||||
photo: string;
|
||||
}
|
||||
|
||||
const MOCK_PROFILES: Profile[] = [
|
||||
interface ProfileRecord {
|
||||
id: string;
|
||||
name?: string;
|
||||
age?: number;
|
||||
location?: string;
|
||||
tags?: string[];
|
||||
bio?: string;
|
||||
photo?: string;
|
||||
}
|
||||
|
||||
const FALLBACK_PROFILES: Profile[] = [
|
||||
{ id: "1", name: "林晓雨", age: 28, location: "大理", tags: ["本科学历", "远程工作者", "数字游民", "咖啡爱好者"], bio: "数字游民三年,目前在云南大理远程做产品设计。喜欢爬山、摄影,周末常去洱海边发呆。", gradient: "from-rose-400 via-pink-400 to-fuchsia-400", initial: "林", avatarColor: "bg-rose-500", photo: "https://i.pravatar.cc/150?img=1" },
|
||||
{ id: "2", name: "陈浩然", age: 32, location: "成都", tags: ["硕士学历", "独立开发者", "自由职业者", "素食主义"], bio: "在成都住了两年,做独立开发。喜欢冲浪和冥想,寻找志同道合的旅伴一起探索世界。", gradient: "from-amber-400 via-orange-400 to-red-400", initial: "陈", avatarColor: "bg-amber-500", photo: "https://i.pravatar.cc/150?img=3" },
|
||||
{ id: "3", name: "王思琪", age: 26, location: "深圳", tags: ["本科学历", "内容创作者", "数字游民", "环保主义者"], bio: "自由撰稿人,在深圳写写画画。热爱瑜伽和冥想,相信慢生活才是真正的奢侈。", gradient: "from-emerald-400 via-teal-400 to-cyan-400", initial: "王", avatarColor: "bg-emerald-500", photo: "https://i.pravatar.cc/150?img=5" },
|
||||
@@ -38,7 +49,7 @@ const MOCK_PROFILES: Profile[] = [
|
||||
{ id: "6", name: "刘子轩", age: 33, location: "昆明", tags: ["博士学历", "数据科学家", "远程工作者", "户外爱好者"], bio: "在昆明远程工作,热爱户外运动。正在探索云南各地,希望认识更多志同道合的朋友。", gradient: "from-blue-400 via-indigo-400 to-violet-500", initial: "刘", avatarColor: "bg-blue-500", photo: "https://i.pravatar.cc/150?img=11" },
|
||||
];
|
||||
|
||||
const MOCK_MATCHES: MatchCard[] = [
|
||||
const FALLBACK_MATCHES: MatchCard[] = [
|
||||
{ id: "m1", name: "周雨桐", location: "杭州", timeAgo: "2天前", initial: "周", color: "bg-pink-400", photo: "https://i.pravatar.cc/150?img=29" },
|
||||
{ id: "m2", name: "吴俊杰", location: "成都", timeAgo: "1周前", initial: "吴", color: "bg-amber-400", photo: "https://i.pravatar.cc/150?img=34" },
|
||||
{ id: "m3", name: "郑诗涵", location: "上海", timeAgo: "3周前", initial: "郑", color: "bg-emerald-400", photo: "https://i.pravatar.cc/150?img=36" },
|
||||
@@ -48,20 +59,123 @@ const MOCK_MATCHES: MatchCard[] = [
|
||||
|
||||
const TAB_ORDER: TabType[] = ["friends", "dating", "partner", "roommate", "cofounder", "explore"];
|
||||
|
||||
const PROFILE_GRADIENTS = [
|
||||
"from-rose-400 via-pink-400 to-fuchsia-400",
|
||||
"from-amber-400 via-orange-400 to-red-400",
|
||||
"from-emerald-400 via-teal-400 to-cyan-400",
|
||||
"from-violet-400 via-purple-400 to-indigo-400",
|
||||
"from-blue-400 via-indigo-400 to-violet-500",
|
||||
];
|
||||
|
||||
function mapProfile(record: ProfileRecord, index: number): Profile {
|
||||
const name = record.name || "数字游民";
|
||||
const initial = name.slice(0, 1);
|
||||
return {
|
||||
id: record.id,
|
||||
name,
|
||||
age: Number(record.age || 28 + index),
|
||||
location: record.location || "远程",
|
||||
tags: Array.isArray(record.tags) ? record.tags : ["数字游民", "远程工作者"],
|
||||
bio: record.bio || "正在完善个人资料,期待结识同城和同路的远程工作伙伴。",
|
||||
gradient: PROFILE_GRADIENTS[index % PROFILE_GRADIENTS.length],
|
||||
initial,
|
||||
avatarColor: "bg-[#ff4d4f]",
|
||||
photo: record.photo || `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(name)}`,
|
||||
};
|
||||
}
|
||||
|
||||
function toMatch(profile: Profile, index: number): MatchCard {
|
||||
return {
|
||||
id: `m-${profile.id}`,
|
||||
name: profile.name,
|
||||
location: profile.location,
|
||||
timeAgo: index < 2 ? "刚刚活跃" : "本周活跃",
|
||||
initial: profile.initial,
|
||||
color: profile.avatarColor,
|
||||
photo: profile.photo,
|
||||
};
|
||||
}
|
||||
|
||||
export default function DatingPage() {
|
||||
const { t } = useTranslation("match");
|
||||
const [activeTab, setActiveTab] = useState<TabType>("friends");
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [profiles, setProfiles] = useState<Profile[]>(FALLBACK_PROFILES);
|
||||
const [dragX, setDragX] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isAnimatingOut, setIsAnimatingOut] = useState<"left" | "right" | null>(null);
|
||||
const startXRef = useRef(0);
|
||||
|
||||
const currentProfile = MOCK_PROFILES[currentIndex];
|
||||
const hasMoreProfiles = currentIndex < MOCK_PROFILES.length - 1;
|
||||
useEffect(() => {
|
||||
apiFetch<{ items: ProfileRecord[] }>("/api/profiles")
|
||||
.then((data) => {
|
||||
if (data.items?.length) {
|
||||
setProfiles(data.items.map(mapProfile));
|
||||
setCurrentIndex(0);
|
||||
}
|
||||
})
|
||||
.catch(() => setProfiles(FALLBACK_PROFILES));
|
||||
}, []);
|
||||
|
||||
const matches = useMemo(
|
||||
() => (profiles.length ? profiles.slice(0, 5).map(toMatch) : FALLBACK_MATCHES),
|
||||
[profiles]
|
||||
);
|
||||
const currentProfile = profiles[currentIndex] || profiles[0] || FALLBACK_PROFILES[0];
|
||||
const hasMoreProfiles = currentIndex < profiles.length - 1;
|
||||
|
||||
const saveSwipe = (action: "like" | "dislike") => {
|
||||
if (!currentProfile) return;
|
||||
apiFetch("/api/matches/swipes", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ profileId: currentProfile.id, action }),
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
const nextProfile = () => {
|
||||
window.setTimeout(() => {
|
||||
setCurrentIndex((i) => (hasMoreProfiles ? i + 1 : 0));
|
||||
setDragX(0);
|
||||
setIsAnimatingOut(null);
|
||||
}, 160);
|
||||
};
|
||||
|
||||
const handleLike = () => {
|
||||
setCurrentIndex((i) => (hasMoreProfiles ? i + 1 : 0));
|
||||
saveSwipe("like");
|
||||
setIsAnimatingOut("right");
|
||||
setDragX(260);
|
||||
nextProfile();
|
||||
};
|
||||
|
||||
const handleDislike = () => {
|
||||
setCurrentIndex((i) => (hasMoreProfiles ? i + 1 : 0));
|
||||
saveSwipe("dislike");
|
||||
setIsAnimatingOut("left");
|
||||
setDragX(-260);
|
||||
nextProfile();
|
||||
};
|
||||
|
||||
const beginDrag = (clientX: number) => {
|
||||
startXRef.current = clientX;
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const moveDrag = (clientX: number) => {
|
||||
if (!isDragging) return;
|
||||
setDragX(Math.max(-180, Math.min(180, clientX - startXRef.current)));
|
||||
};
|
||||
|
||||
const endDrag = () => {
|
||||
if (!isDragging) return;
|
||||
setIsDragging(false);
|
||||
if (dragX > 90) {
|
||||
handleLike();
|
||||
return;
|
||||
}
|
||||
if (dragX < -90) {
|
||||
handleDislike();
|
||||
return;
|
||||
}
|
||||
setDragX(0);
|
||||
};
|
||||
|
||||
const badgeMap: Record<TabType, string> = {
|
||||
@@ -100,7 +214,7 @@ export default function DatingPage() {
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1">{t("title")} 💕</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">{t("likedYou")}</p>
|
||||
<div className="space-y-3">
|
||||
{MOCK_MATCHES.map((match) => (
|
||||
{matches.map((match) => (
|
||||
<div key={match.id} className="flex items-center gap-3 p-3 rounded-xl bg-white dark:bg-gray-900 border border-gray-100 dark:border-gray-800 shadow-sm hover:shadow-md transition-shadow cursor-pointer">
|
||||
<img src={match.photo} alt={match.name} className="w-12 h-12 rounded-full object-cover shadow-sm flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -116,7 +230,32 @@ export default function DatingPage() {
|
||||
|
||||
<main className="flex-1 min-w-0 flex flex-col items-center">
|
||||
<div className="w-full max-w-md mx-auto">
|
||||
<div className={`relative rounded-2xl overflow-hidden bg-gradient-to-br ${currentProfile.gradient} p-8 pb-6 shadow-xl border border-white/20`}>
|
||||
<div
|
||||
className={`relative rounded-2xl overflow-hidden bg-gradient-to-br ${currentProfile.gradient} p-8 pb-6 shadow-xl border border-white/20 touch-pan-y select-none ${isDragging ? "cursor-grabbing" : "cursor-grab"} transition-transform`}
|
||||
style={{
|
||||
transform: `translateX(${isAnimatingOut === "right" ? 420 : isAnimatingOut === "left" ? -420 : dragX}px) rotate(${dragX / 18}deg)`,
|
||||
opacity: isAnimatingOut ? 0.25 : 1,
|
||||
transitionDuration: isDragging ? "0ms" : "180ms",
|
||||
}}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowRight") handleLike();
|
||||
if (e.key === "ArrowLeft") handleDislike();
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
beginDrag(e.clientX);
|
||||
}}
|
||||
onPointerMove={(e) => moveDrag(e.clientX)}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
>
|
||||
<div className={`pointer-events-none absolute left-5 top-5 rounded-full border-2 border-white/80 px-4 py-2 text-sm font-bold text-white transition-opacity ${dragX > 45 ? "opacity-100" : "opacity-0"}`}>
|
||||
喜欢
|
||||
</div>
|
||||
<div className={`pointer-events-none absolute right-5 top-5 rounded-full border-2 border-white/80 px-4 py-2 text-sm font-bold text-white transition-opacity ${dragX < -45 ? "opacity-100" : "opacity-0"}`}>
|
||||
跳过
|
||||
</div>
|
||||
<div className="absolute top-4 right-4 flex flex-col items-end gap-1">
|
||||
<span className="px-3 py-1.5 rounded-full bg-white/90 text-gray-800 text-xs font-medium backdrop-blur-sm">{badgeText}</span>
|
||||
<a href="#" className="text-[#ff4d4f] hover:text-red-600 text-sm font-medium">{t("report")}</a>
|
||||
@@ -152,7 +291,7 @@ export default function DatingPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-center text-sm text-gray-500 dark:text-gray-400 mt-4">{currentIndex + 1} / {MOCK_PROFILES.length}</p>
|
||||
<p className="text-center text-sm text-gray-500 dark:text-gray-400 mt-4">{currentIndex + 1} / {profiles.length}</p>
|
||||
</div>
|
||||
</main>
|
||||
<div className="hidden lg:block w-72 flex-shrink-0" />
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation, Link } from "@/i18n/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "@/i18n/navigation";
|
||||
import Footer from "@/app/components/Footer";
|
||||
import { apiFetch } from "@/app/lib/api-client";
|
||||
|
||||
interface Topic {
|
||||
id: string;
|
||||
@@ -15,9 +16,22 @@ interface Topic {
|
||||
category: string;
|
||||
lastActive: string;
|
||||
pinned: boolean;
|
||||
excerpt?: string;
|
||||
}
|
||||
|
||||
const MOCK_TOPICS: Topic[] = [
|
||||
interface DiscussionRecord {
|
||||
id: string;
|
||||
title: string;
|
||||
author?: string;
|
||||
city?: string;
|
||||
tags?: string[];
|
||||
excerpt?: string;
|
||||
replies?: number;
|
||||
views?: number;
|
||||
created?: string;
|
||||
}
|
||||
|
||||
const FALLBACK_TOPICS: Topic[] = [
|
||||
{
|
||||
id: "1",
|
||||
title: "大理最佳共享办公空间推荐 Top 10",
|
||||
@@ -127,16 +141,71 @@ const CATEGORIES = [
|
||||
{ key: "目的地", zh: "目的地", en: "Destinations" },
|
||||
];
|
||||
|
||||
function mapDiscussion(topic: DiscussionRecord, index: number): Topic {
|
||||
const author = topic.author || "NomadCNA";
|
||||
const tags = Array.isArray(topic.tags) ? topic.tags : [];
|
||||
return {
|
||||
id: topic.id,
|
||||
title: topic.title,
|
||||
author,
|
||||
avatar: `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(author)}`,
|
||||
views: Number(topic.views || 0),
|
||||
replies: Number(topic.replies || 0),
|
||||
likes: Math.max(3, Math.round(Number(topic.views || 0) / 30) + Number(topic.replies || 0)),
|
||||
category: tags[0] || topic.city || "资源分享",
|
||||
lastActive: topic.created ? new Date(topic.created).toLocaleDateString("zh-CN") : "刚刚",
|
||||
pinned: index < 2,
|
||||
excerpt: topic.excerpt,
|
||||
};
|
||||
}
|
||||
|
||||
export default function DiscussPage() {
|
||||
const { t } = useTranslation("discuss");
|
||||
const [activeCategory, setActiveCategory] = useState("all");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [topics, setTopics] = useState<Topic[]>(FALLBACK_TOPICS);
|
||||
const [showComposer, setShowComposer] = useState(false);
|
||||
const [posting, setPosting] = useState(false);
|
||||
const [draft, setDraft] = useState({ title: "", city: "", excerpt: "" });
|
||||
|
||||
const filteredTopics = MOCK_TOPICS.filter((topic) => {
|
||||
useEffect(() => {
|
||||
apiFetch<{ items: DiscussionRecord[] }>("/api/discussions")
|
||||
.then((data) => {
|
||||
if (data.items?.length) {
|
||||
setTopics(data.items.map(mapDiscussion));
|
||||
}
|
||||
})
|
||||
.catch(() => setTopics(FALLBACK_TOPICS));
|
||||
}, []);
|
||||
|
||||
const filteredTopics = useMemo(() => topics.filter((topic) => {
|
||||
const matchesCategory = activeCategory === "all" || topic.category === activeCategory;
|
||||
const matchesSearch = topic.title.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
return matchesCategory && matchesSearch;
|
||||
});
|
||||
}), [activeCategory, searchQuery, topics]);
|
||||
|
||||
const createTopic = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!draft.title.trim()) return;
|
||||
setPosting(true);
|
||||
try {
|
||||
const data = await apiFetch<{ record: DiscussionRecord }>("/api/discussions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
title: draft.title.trim(),
|
||||
author: "本地用户",
|
||||
city: draft.city.trim(),
|
||||
tags: [draft.city.trim() || "资源分享"],
|
||||
excerpt: draft.excerpt.trim(),
|
||||
}),
|
||||
});
|
||||
setTopics((current) => [mapDiscussion(data.record, 0), ...current]);
|
||||
setDraft({ title: "", city: "", excerpt: "" });
|
||||
setShowComposer(false);
|
||||
} finally {
|
||||
setPosting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
|
||||
@@ -150,11 +219,49 @@ export default function DiscussPage() {
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<button className="bg-[#ff4d4f] text-white px-4 sm:px-6 py-2 sm:py-2.5 rounded-full font-medium text-sm hover:bg-[#ff7a45] transition-colors">
|
||||
<button
|
||||
onClick={() => setShowComposer((value) => !value)}
|
||||
className="bg-[#ff4d4f] text-white px-4 sm:px-6 py-2 sm:py-2.5 rounded-full font-medium text-sm hover:bg-[#ff7a45] transition-colors"
|
||||
>
|
||||
{t("newPost")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showComposer && (
|
||||
<form onSubmit={createTopic} className="mb-6 rounded-2xl border border-gray-100 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_180px]">
|
||||
<input
|
||||
required
|
||||
value={draft.title}
|
||||
onChange={(e) => setDraft((v) => ({ ...v, title: e.target.value }))}
|
||||
placeholder="帖子标题"
|
||||
className="rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm text-gray-900 focus:border-[#ff4d4f] focus:outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
<input
|
||||
value={draft.city}
|
||||
onChange={(e) => setDraft((v) => ({ ...v, city: e.target.value }))}
|
||||
placeholder="关联城市"
|
||||
className="rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm text-gray-900 focus:border-[#ff4d4f] focus:outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
value={draft.excerpt}
|
||||
onChange={(e) => setDraft((v) => ({ ...v, excerpt: e.target.value }))}
|
||||
placeholder="写一点背景,后续会关联到城市弹窗的同城讨论"
|
||||
rows={3}
|
||||
className="mt-3 w-full resize-none rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm text-gray-900 focus:border-[#ff4d4f] focus:outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
<div className="mt-3 flex justify-end gap-2">
|
||||
<button type="button" onClick={() => setShowComposer(false)} className="rounded-full border border-gray-200 px-4 py-2 text-sm text-gray-600 dark:border-gray-700 dark:text-gray-300">
|
||||
取消
|
||||
</button>
|
||||
<button disabled={posting} className="rounded-full bg-[#ff4d4f] px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">
|
||||
{posting ? "发布中..." : "发布"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="mb-6">
|
||||
<input
|
||||
type="text"
|
||||
@@ -205,6 +312,9 @@ export default function DiscussPage() {
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100 text-sm sm:text-base mb-2 line-clamp-2">
|
||||
{topic.title}
|
||||
</h3>
|
||||
{topic.excerpt && (
|
||||
<p className="mb-2 line-clamp-2 text-xs text-gray-500 dark:text-gray-400">{topic.excerpt}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>{topic.author}</span>
|
||||
<span className="hidden sm:inline">·</span>
|
||||
|
||||
109
app/[locale]/ebooks/[slug]/page.tsx
Normal file
109
app/[locale]/ebooks/[slug]/page.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { use, useEffect, useState } from "react";
|
||||
import { Link, useLocale } from "@/i18n/navigation";
|
||||
import { apiFetch } from "@/app/lib/api-client";
|
||||
import Footer from "@/app/components/Footer";
|
||||
|
||||
interface ContentItem {
|
||||
slug: string;
|
||||
title: string;
|
||||
titleEn?: string;
|
||||
subtitle: string;
|
||||
subtitleEn?: string;
|
||||
description: string;
|
||||
descriptionEn?: string;
|
||||
coverImage: string;
|
||||
mediaUrl?: string;
|
||||
chapters?: { title: string; minutes?: number }[];
|
||||
durationMinutes?: number;
|
||||
}
|
||||
|
||||
interface CityLite {
|
||||
slug: string;
|
||||
name: string;
|
||||
nameEn?: string;
|
||||
icon: string;
|
||||
costPerMonth: number;
|
||||
internetSpeed: number;
|
||||
}
|
||||
|
||||
export default function EbookPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = use(params);
|
||||
const locale = useLocale();
|
||||
const [item, setItem] = useState<ContentItem | null>(null);
|
||||
const [cities, setCities] = useState<CityLite[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<{ item: ContentItem; cities: CityLite[] }>(`/api/content/${slug}`)
|
||||
.then((data) => {
|
||||
setItem(data.item);
|
||||
setCities(data.cities || []);
|
||||
})
|
||||
.catch(() => setItem(null));
|
||||
}, [slug]);
|
||||
|
||||
if (!item) {
|
||||
return <div className="min-h-screen bg-[#fafafa] dark:bg-gray-950 p-10 text-gray-500">加载中...</div>;
|
||||
}
|
||||
|
||||
const title = locale === "zh" ? item.title : item.titleEn || item.title;
|
||||
const subtitle = locale === "zh" ? item.subtitle : item.subtitleEn || item.subtitle;
|
||||
const description = locale === "zh" ? item.description : item.descriptionEn || item.description;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
|
||||
<main className="mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-12">
|
||||
<div className="grid gap-8 lg:grid-cols-[360px_1fr]">
|
||||
<div className="overflow-hidden rounded-2xl bg-white shadow-lg dark:bg-gray-900">
|
||||
<img src={item.coverImage} alt={title} className="h-96 w-full object-cover" />
|
||||
</div>
|
||||
<section>
|
||||
<p className="text-sm font-semibold text-[#ff4d4f]">电子书</p>
|
||||
<h1 className="mt-2 text-3xl font-bold text-gray-900 dark:text-gray-100 sm:text-4xl">{title}</h1>
|
||||
<p className="mt-3 text-lg text-gray-500 dark:text-gray-400">{subtitle}</p>
|
||||
<p className="mt-6 leading-8 text-gray-700 dark:text-gray-300">{description}</p>
|
||||
|
||||
<div className="mt-8 rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-900">
|
||||
<h2 className="font-bold text-gray-900 dark:text-gray-100">目录</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
{(item.chapters || []).map((chapter, index) => (
|
||||
<div key={chapter.title} className="flex items-center justify-between rounded-xl bg-gray-50 px-4 py-3 dark:bg-gray-800">
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">{index + 1}. {chapter.title}</span>
|
||||
<span className="text-xs text-gray-400">{chapter.minutes || 8} min</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<a href={item.mediaUrl || "#"} className="rounded-full bg-[#ff4d4f] px-5 py-3 text-sm font-semibold text-white hover:bg-[#ff7a45]">
|
||||
下载电子书
|
||||
</a>
|
||||
<Link href="/dashboard" className="rounded-full bg-[#ff4d4f] px-5 py-3 text-sm font-semibold text-white hover:bg-[#ff7a45]">
|
||||
用城市数据规划路线
|
||||
</Link>
|
||||
<Link href="/" className="rounded-full border border-gray-300 px-5 py-3 text-sm font-semibold text-gray-700 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800">
|
||||
返回首页
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="mt-12">
|
||||
<h2 className="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">书中关联城市</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{cities.map((city) => (
|
||||
<Link key={city.slug} href={`/city/${city.slug}`} className="rounded-2xl bg-white p-5 shadow-sm dark:bg-gray-900">
|
||||
<div className="text-3xl">{city.icon}</div>
|
||||
<h3 className="mt-3 font-bold text-gray-900 dark:text-gray-100">{locale === "zh" ? city.name : city.nameEn || city.name}</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">¥{city.costPerMonth}/月 · {city.internetSpeed}Mbps</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "@/i18n/navigation";
|
||||
import Footer from "@/app/components/Footer";
|
||||
import { apiFetch } from "@/app/lib/api-client";
|
||||
|
||||
export default function FeedbackPage() {
|
||||
const { t } = useTranslation("feedback");
|
||||
@@ -14,8 +15,12 @@ export default function FeedbackPage() {
|
||||
content: "",
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
await apiFetch("/api/feedback", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
setSubmitted(true);
|
||||
};
|
||||
|
||||
@@ -135,7 +140,7 @@ export default function FeedbackPage() {
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
{t("demoHint")}
|
||||
已接入本地 FastAPI + PocketBase,提交后会写入 feedback 集合。
|
||||
</p>
|
||||
|
||||
<button
|
||||
|
||||
@@ -1,22 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "@/i18n/navigation";
|
||||
import Footer from "@/app/components/Footer";
|
||||
import { apiFetch } from "@/app/lib/api-client";
|
||||
|
||||
const MOCK_GIGS = [
|
||||
{ id: "1", title: "帮我 P 图", city: "大理", reward: "¥50", author: "林**" },
|
||||
{ id: "2", title: "找惠州 loft 民宿", city: "深圳", reward: "¥100", author: "陈**" },
|
||||
{ id: "3", title: "代收快递", city: "成都", reward: "¥30", author: "王**" },
|
||||
{ id: "4", title: "一起 citywalk 拍 neo2 航拍", city: "杭州", reward: "面议", author: "张**" },
|
||||
{ id: "5", title: "翻译英文文档", city: "上海", reward: "¥200", author: "李**" },
|
||||
];
|
||||
interface Gig {
|
||||
id: string;
|
||||
title: string;
|
||||
category: string;
|
||||
budget: number;
|
||||
location: string;
|
||||
description: string;
|
||||
status: "open" | "in_progress" | "done" | string;
|
||||
}
|
||||
|
||||
const INITIAL_FORM = {
|
||||
title: "",
|
||||
category: "内容",
|
||||
budget: 500,
|
||||
location: "远程",
|
||||
description: "",
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
open: "可接单",
|
||||
in_progress: "进行中",
|
||||
done: "已完成",
|
||||
};
|
||||
|
||||
export default function GigsPage() {
|
||||
const { t } = useTranslation("gigs");
|
||||
const [gigs, setGigs] = useState<Gig[]>([]);
|
||||
const [form, setForm] = useState(INITIAL_FORM);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [status, setStatus] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [applyingId, setApplyingId] = useState<string | null>(null);
|
||||
|
||||
const openGigs = useMemo(() => gigs.filter((gig) => gig.status === "open"), [gigs]);
|
||||
const activeGigs = useMemo(() => gigs.filter((gig) => gig.status !== "open"), [gigs]);
|
||||
|
||||
const loadGigs = () => {
|
||||
apiFetch<{ items: Gig[] }>("/api/gigs")
|
||||
.then((data) => setGigs(data.items || []))
|
||||
.catch(() => setGigs([]));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadGigs();
|
||||
}, []);
|
||||
|
||||
const submitGig = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!form.title.trim()) {
|
||||
setStatus("请先填写任务标题");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setStatus("");
|
||||
try {
|
||||
const data = await apiFetch<{ record: Gig }>("/api/gigs", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
setGigs((prev) => [data.record, ...prev]);
|
||||
setForm(INITIAL_FORM);
|
||||
setShowForm(false);
|
||||
setStatus("任务已发布,已写入 PocketBase。");
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : "发布失败");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const applyGig = async (gig: Gig) => {
|
||||
setApplyingId(gig.id);
|
||||
setStatus("");
|
||||
try {
|
||||
const data = await apiFetch<{ record: Gig; message: string }>(`/api/gigs/${gig.id}/apply`, {
|
||||
method: "POST",
|
||||
});
|
||||
setGigs((prev) => prev.map((item) => (item.id === gig.id ? data.record : item)));
|
||||
setStatus(`${gig.title}:${data.message}`);
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : "接单失败");
|
||||
} finally {
|
||||
setApplyingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const renderGig = (gig: Gig) => (
|
||||
<article
|
||||
key={gig.id}
|
||||
className="rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4 sm:p-5"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-600 dark:bg-gray-800 dark:text-gray-300">
|
||||
{gig.category}
|
||||
</span>
|
||||
<span className="rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300">
|
||||
{STATUS_LABELS[gig.status] || gig.status}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100">{gig.title}</h3>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
📍 {gig.location} · ¥{Number(gig.budget || 0).toLocaleString()}
|
||||
</p>
|
||||
<p className="mt-2 text-sm leading-relaxed text-gray-600 dark:text-gray-400 line-clamp-2">
|
||||
{gig.description || "需求方暂未填写详细说明,接单后可继续沟通。"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyGig(gig)}
|
||||
disabled={gig.status !== "open" || applyingId === gig.id}
|
||||
className="shrink-0 rounded-xl bg-[#ff4d4f] px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-[#ff3333] disabled:cursor-not-allowed disabled:bg-gray-300 disabled:text-gray-500 dark:disabled:bg-gray-700"
|
||||
>
|
||||
{gig.status === "open" ? (applyingId === gig.id ? "提交中" : "接单") : "已锁定"}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 py-12">
|
||||
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-12">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
@@ -26,36 +138,113 @@ export default function GigsPage() {
|
||||
{t("subtitle")} · {t("platformFee")}
|
||||
</p>
|
||||
</div>
|
||||
<button className="shrink-0 px-6 py-3 rounded-xl bg-[#ff4d4f] text-white font-medium hover:bg-[#ff3333] transition-colors">
|
||||
{t("publish")}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowForm((prev) => !prev)}
|
||||
className="shrink-0 px-6 py-3 rounded-xl bg-[#ff4d4f] text-white font-medium hover:bg-[#ff3333] transition-colors"
|
||||
>
|
||||
{showForm ? "收起表单" : t("publish")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{MOCK_GIGS.map((gig) => (
|
||||
<div
|
||||
key={gig.id}
|
||||
className="rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4 sm:p-5 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{gig.title}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
📍 {gig.city} · {gig.author}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg font-semibold text-[#ff4d4f]">
|
||||
{gig.reward}
|
||||
</span>
|
||||
<button className="px-4 py-2 rounded-lg 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">
|
||||
{t("browse")}
|
||||
</button>
|
||||
</div>
|
||||
{showForm && (
|
||||
<form
|
||||
onSubmit={submitGig}
|
||||
className="mb-8 rounded-2xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4 sm:p-6"
|
||||
>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<label className="sm:col-span-2 block">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">任务标题</span>
|
||||
<input
|
||||
value={form.title}
|
||||
onChange={(event) => setForm({ ...form, title: event.target.value })}
|
||||
className="mt-2 w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-4 py-2 text-gray-900 dark:text-gray-100"
|
||||
placeholder="例如:补充大理共享办公空间清单"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">分类</span>
|
||||
<select
|
||||
value={form.category}
|
||||
onChange={(event) => setForm({ ...form, category: event.target.value })}
|
||||
className="mt-2 w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-4 py-2 text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{["内容", "调研", "摄影", "活动", "设计", "开发"].map((category) => (
|
||||
<option key={category} value={category}>{category}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">预算</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.budget}
|
||||
onChange={(event) => setForm({ ...form, budget: Number(event.target.value) || 1 })}
|
||||
className="mt-2 w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-4 py-2 text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">地点</span>
|
||||
<input
|
||||
value={form.location}
|
||||
onChange={(event) => setForm({ ...form, location: event.target.value })}
|
||||
className="mt-2 w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-4 py-2 text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</label>
|
||||
<label className="sm:col-span-2 block">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">需求描述</span>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={(event) => setForm({ ...form, description: event.target.value })}
|
||||
rows={4}
|
||||
className="mt-2 w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 px-4 py-2 text-gray-900 dark:text-gray-100"
|
||||
placeholder="交付物、时间、资料来源、验收标准"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="rounded-xl bg-gray-900 px-5 py-2.5 text-sm font-medium text-white hover:bg-gray-800 disabled:cursor-not-allowed disabled:bg-gray-400 dark:bg-gray-100 dark:text-gray-900"
|
||||
>
|
||||
{submitting ? "发布中" : "写入数据库并发布"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{status && (
|
||||
<div className="mb-5 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-800/60 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="mb-10">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">可接任务</h2>
|
||||
<span className="text-sm text-gray-400 dark:text-gray-500">{openGigs.length} 个</span>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{openGigs.map(renderGig)}
|
||||
{openGigs.length === 0 && (
|
||||
<div className="rounded-2xl border border-dashed border-gray-200 bg-white p-8 text-center text-sm text-gray-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-400">
|
||||
当前没有可接任务,发布一个新需求即可进入赏金墙。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{activeGigs.length > 0 && (
|
||||
<section>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">进行中的任务</h2>
|
||||
<span className="text-sm text-gray-400 dark:text-gray-500">{activeGigs.length} 个</span>
|
||||
</div>
|
||||
<div className="space-y-4">{activeGigs.map(renderGig)}</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { usePayStatusPoll } from "@/app/lib/payment/usePayStatusPoll";
|
||||
import type { PayEnv } from "@/app/lib/payment";
|
||||
import AuthModal from "@/app/components/AuthModal";
|
||||
import { apiUrl } from "@/app/lib/api-client";
|
||||
|
||||
const genderOptions = ["男", "女"];
|
||||
const singleOptions = ["单身", "恋爱中", "已婚"];
|
||||
@@ -67,13 +68,13 @@ export default function JoinPage() {
|
||||
return;
|
||||
}
|
||||
const checkExisting = async () => {
|
||||
const meRes = await fetch("/api/auth/me", { credentials: "include" });
|
||||
const meRes = await fetch(apiUrl("/api/auth/me"), { credentials: "include" });
|
||||
const meData = await meRes.json();
|
||||
if (!meData?.user) {
|
||||
setCheckingStatus(false);
|
||||
return;
|
||||
}
|
||||
const statusRes = await fetch("/api/join/status", { credentials: "include", cache: "no-store" });
|
||||
const statusRes = await fetch(apiUrl("/api/join/status"), { credentials: "include", cache: "no-store" });
|
||||
const statusData = await statusRes.json();
|
||||
if (statusData?.hasRecord && statusData?.isComplete) {
|
||||
setIsAlreadyRecorded(true);
|
||||
@@ -105,7 +106,7 @@ export default function JoinPage() {
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch("/api/join", {
|
||||
const res = await fetch(apiUrl("/api/join"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...form, media: media?.url }),
|
||||
@@ -121,7 +122,7 @@ export default function JoinPage() {
|
||||
return;
|
||||
}
|
||||
// 再次确认 VIP,防止 handleAuthSuccess 等路径绕过
|
||||
const vipRes2 = await fetch("/api/vip/check", { credentials: "include", cache: "no-store" });
|
||||
const vipRes2 = await fetch(apiUrl("/api/vip/check"), { credentials: "include", cache: "no-store" });
|
||||
const vipData2 = await vipRes2.json();
|
||||
if (vipData2?.vip) {
|
||||
setIsVipSubmit(true);
|
||||
@@ -152,14 +153,14 @@ export default function JoinPage() {
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const meRes = await fetch("/api/auth/me", { credentials: "include" });
|
||||
const meRes = await fetch(apiUrl("/api/auth/me"), { credentials: "include" });
|
||||
const meData = await meRes.json();
|
||||
if (!meData?.user) {
|
||||
setPendingSubmit(true);
|
||||
setAuthOpen(true);
|
||||
return;
|
||||
}
|
||||
const vipRes = await fetch("/api/vip/check", { credentials: "include", cache: "no-store" });
|
||||
const vipRes = await fetch(apiUrl("/api/vip/check"), { credentials: "include", cache: "no-store" });
|
||||
const vipData = await vipRes.json();
|
||||
if (vipData?.vip) {
|
||||
setIsVipSubmit(true);
|
||||
@@ -401,7 +402,7 @@ export default function JoinPage() {
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const res = await fetch("/api/upload-media", {
|
||||
const res = await fetch(apiUrl("/api/upload-media"), {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { apiUrl } from "@/app/lib/api-client";
|
||||
|
||||
const DEFAULT_PASSWORD = "12345678";
|
||||
|
||||
@@ -33,7 +34,7 @@ export default function JoinPaidPage() {
|
||||
let cancelled = false;
|
||||
|
||||
const tryComplete = async (orderId: string): Promise<{ ok: boolean; error?: string }> => {
|
||||
const r = await fetch("/api/meetup/complete-order", {
|
||||
const r = await fetch(apiUrl("/api/meetup/complete-order"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ order_id: orderId }),
|
||||
@@ -43,7 +44,7 @@ export default function JoinPaidPage() {
|
||||
if (!d?.ok) return { ok: false, error: d?.error || `HTTP ${r.status}` };
|
||||
|
||||
if (d?.token && d?.record) {
|
||||
await fetch("/api/auth/sync-session", {
|
||||
await fetch(apiUrl("/api/auth/sync-session"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token: d.token, record: d.record }),
|
||||
@@ -59,13 +60,13 @@ export default function JoinPaidPage() {
|
||||
window.dispatchEvent(new CustomEvent("vip:updated"));
|
||||
}, 400);
|
||||
if (d.is_new) {
|
||||
await fetch("/api/meetup/set-needs-password-change", { method: "POST", credentials: "include" });
|
||||
await fetch(apiUrl("/api/meetup/set-needs-password-change"), { method: "POST", credentials: "include" });
|
||||
if (!cancelled) {
|
||||
setNeedPasswordChange(true);
|
||||
setShowPasswordModal(true);
|
||||
}
|
||||
} else {
|
||||
const needRes = await fetch("/api/meetup/need-password-change", { credentials: "include" });
|
||||
const needRes = await fetch(apiUrl("/api/meetup/need-password-change"), { credentials: "include" });
|
||||
const needData = await needRes.json().catch(() => ({}));
|
||||
if (!cancelled && needData?.need === true) {
|
||||
setNeedPasswordChange(true);
|
||||
@@ -121,7 +122,7 @@ export default function JoinPaidPage() {
|
||||
setErrorDetail(lastError);
|
||||
}
|
||||
|
||||
fetch("/api/meetup/need-password-change")
|
||||
fetch(apiUrl("/api/meetup/need-password-change"))
|
||||
.then((res) => res.json())
|
||||
.then((d) => {
|
||||
if (cancelled) return;
|
||||
@@ -166,7 +167,7 @@ export default function JoinPaidPage() {
|
||||
}
|
||||
setChanging(true);
|
||||
try {
|
||||
const res = await fetch("/api/auth/change-password", {
|
||||
const res = await fetch(apiUrl("/api/auth/change-password"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ newPassword, passwordConfirm }),
|
||||
|
||||
@@ -1,25 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useLocale, useTranslation } from "@/i18n/navigation";
|
||||
import { useTheme } from "@/app/context/ThemeContext";
|
||||
import { MembersMap } from "@/app/lib/charts";
|
||||
import { MAP_CITIES } from "@/app/data/map-cities";
|
||||
import { apiFetch } from "@/app/lib/api-client";
|
||||
|
||||
export default function MembersMapPage() {
|
||||
const { t } = useTranslation("membersMap");
|
||||
const locale = useLocale();
|
||||
const { theme } = useTheme();
|
||||
const [mapCities, setMapCities] = useState(MAP_CITIES);
|
||||
const [hoveredCity, setHoveredCity] = useState<typeof MAP_CITIES[0] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<{ items: typeof MAP_CITIES }>("/api/stats/member-map")
|
||||
.then((data) => data.items?.length && setMapCities(data.items))
|
||||
.catch(() => setMapCities(MAP_CITIES));
|
||||
}, []);
|
||||
|
||||
const totalMembers = useMemo(
|
||||
() => MAP_CITIES.reduce((sum, c) => sum + c.nomadsNow, 0),
|
||||
[]
|
||||
() => mapCities.reduce((sum, c) => sum + c.nomadsNow, 0),
|
||||
[mapCities]
|
||||
);
|
||||
|
||||
const sortedCities = useMemo(
|
||||
() => [...MAP_CITIES].sort((a, b) => b.nomadsNow - a.nomadsNow),
|
||||
[]
|
||||
() => [...mapCities].sort((a, b) => b.nomadsNow - a.nomadsNow),
|
||||
[mapCities]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -39,7 +47,7 @@ export default function MembersMapPage() {
|
||||
<div className="lg:col-span-2 bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-800 overflow-hidden p-4 sm:p-6">
|
||||
<div className="rounded-xl overflow-hidden bg-gray-50 dark:bg-gray-800/50">
|
||||
<MembersMap
|
||||
data={MAP_CITIES}
|
||||
data={mapCities}
|
||||
locale={locale}
|
||||
dark={theme === "dark"}
|
||||
className="min-h-[400px]"
|
||||
@@ -101,7 +109,7 @@ export default function MembersMapPage() {
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-6 text-center">
|
||||
{t("demoHint")}
|
||||
数据来自本地 PocketBase 城市集合,按城市成员数匿名聚合。
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Link, useLocale, useTranslation } from "@/i18n/navigation";
|
||||
import { Link, useTranslation } from "@/i18n/navigation";
|
||||
import { cities } from "@/app/data/cities";
|
||||
import { apiFetch } from "@/app/lib/api-client";
|
||||
|
||||
const CITY_OPTIONS = cities.map((c) => ({ value: c.name, label: `${c.icon} ${c.name}` }));
|
||||
const CITY_BY_NAME = new Map(cities.map((city) => [city.name, city]));
|
||||
|
||||
export default function HostMeetupPage() {
|
||||
const { t } = useTranslation("hostMeetup");
|
||||
const locale = useLocale();
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
city: "",
|
||||
date: "",
|
||||
@@ -20,9 +22,33 @@ export default function HostMeetupPage() {
|
||||
maxAttendees: "20",
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSubmitted(true);
|
||||
const city = CITY_BY_NAME.get(form.city);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await apiFetch("/api/meetups", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
city: form.city,
|
||||
emoji: city?.icon || "📍",
|
||||
date: form.date,
|
||||
time: form.time,
|
||||
venue: form.venue,
|
||||
address: form.address,
|
||||
description: form.description,
|
||||
rsvpCount: 1,
|
||||
gradientFrom: city?.gradientFrom || "#ff4d4f",
|
||||
gradientTo: city?.gradientTo || "#ff7a45",
|
||||
attendees: [],
|
||||
isUpcoming: true,
|
||||
status: "pending",
|
||||
}),
|
||||
});
|
||||
setSubmitted(true);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (submitted) {
|
||||
@@ -183,14 +209,15 @@ export default function HostMeetupPage() {
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
{t("demoHint")}
|
||||
提交后会进入本机 PocketBase 的 meetups 表,审核通过前状态为 pending。
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="w-full py-3.5 rounded-xl bg-[#ff4d4f] text-white font-semibold hover:bg-[#ff7a45] transition-colors"
|
||||
>
|
||||
{t("submit")}
|
||||
{submitting ? "提交中..." : t("submit")}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useTranslation } from "@/i18n/navigation";
|
||||
import { apiFetch } from "@/app/lib/api-client";
|
||||
|
||||
interface Attendee {
|
||||
name: string;
|
||||
@@ -42,7 +43,10 @@ const allMeetups: Meetup[] = [
|
||||
const weekdays = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
|
||||
|
||||
function formatMeetupDate(dateStr: string, time: string): string {
|
||||
const d = new Date(dateStr + "T" + time);
|
||||
const normalizedDate = dateStr.includes("T") || dateStr.includes(" ")
|
||||
? dateStr.slice(0, 10)
|
||||
: dateStr;
|
||||
const d = new Date(normalizedDate + "T" + time);
|
||||
const weekday = weekdays[d.getDay()];
|
||||
const year = d.getFullYear();
|
||||
const month = d.getMonth() + 1;
|
||||
@@ -51,6 +55,24 @@ function formatMeetupDate(dateStr: string, time: string): string {
|
||||
return `${weekday}, ${year}年${month}月${day}日, ${timeFormatted}`;
|
||||
}
|
||||
|
||||
function mapMeetup(item: Partial<Meetup> & { id: string; status?: string }): Meetup {
|
||||
return {
|
||||
id: item.id,
|
||||
city: item.city || "线上",
|
||||
emoji: item.emoji || "📍",
|
||||
date: item.date || new Date().toISOString().slice(0, 10),
|
||||
time: item.time || "19:00",
|
||||
venue: item.venue || "待定地点",
|
||||
address: item.address || "活动地址确认中",
|
||||
description: item.description || "数字游民同城交流活动。",
|
||||
rsvpCount: Number(item.rsvpCount || 0),
|
||||
gradientFrom: item.gradientFrom || "#ff4d4f",
|
||||
gradientTo: item.gradientTo || "#ff7a45",
|
||||
attendees: Array.isArray(item.attendees) ? item.attendees : [],
|
||||
isUpcoming: Boolean(item.isUpcoming ?? true),
|
||||
};
|
||||
}
|
||||
|
||||
function MeetupCard({ meetup }: { meetup: Meetup }) {
|
||||
const formattedDate = formatMeetupDate(meetup.date, meetup.time);
|
||||
return (
|
||||
@@ -83,8 +105,20 @@ function MeetupCard({ meetup }: { meetup: Meetup }) {
|
||||
export default function MeetupsPage() {
|
||||
const { t } = useTranslation("meetups");
|
||||
const [activeTab, setActiveTab] = useState<"upcoming" | "previous">("upcoming");
|
||||
const upcomingMeetups = useMemo(() => allMeetups.filter((m) => m.isUpcoming), []);
|
||||
const previousMeetups = useMemo(() => allMeetups.filter((m) => !m.isUpcoming), []);
|
||||
const [meetups, setMeetups] = useState<Meetup[]>(allMeetups);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<{ items: Array<Partial<Meetup> & { id: string; status?: string }> }>("/api/meetups")
|
||||
.then((data) => {
|
||||
if (data.items?.length) {
|
||||
setMeetups(data.items.map(mapMeetup));
|
||||
}
|
||||
})
|
||||
.catch(() => setMeetups(allMeetups));
|
||||
}, []);
|
||||
|
||||
const upcomingMeetups = useMemo(() => meetups.filter((m) => m.isUpcoming), [meetups]);
|
||||
const previousMeetups = useMemo(() => meetups.filter((m) => !m.isUpcoming), [meetups]);
|
||||
const displayedMeetups = activeTab === "upcoming" ? upcomingMeetups : previousMeetups;
|
||||
const totalCount = activeTab === "upcoming" ? upcomingMeetups.length : previousMeetups.length;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { Link, useLocale, useRouter } from "@/i18n/navigation";
|
||||
import { useTranslation } from "@/i18n/navigation";
|
||||
import HeroSection from "../components/HeroSection";
|
||||
@@ -10,25 +10,241 @@ import CityModal from "../components/CityModal";
|
||||
import Footer from "@/app/components/Footer";
|
||||
import { cities, type City } from "../data/cities";
|
||||
import { HOME_CONFIG } from "@/config";
|
||||
import { apiFetch } from "@/app/lib/api-client";
|
||||
|
||||
const { memberPhotos, travelingNow, meetups: homeMeetups, hotTopics, routes, stats, communityStats } = HOME_CONFIG;
|
||||
const {
|
||||
memberPhotos: fallbackMemberPhotos,
|
||||
travelingNow: fallbackTravelingNow,
|
||||
meetups: fallbackHomeMeetups,
|
||||
hotTopics: fallbackHotTopics,
|
||||
routes: fallbackRoutes,
|
||||
stats: fallbackStats,
|
||||
communityStats: fallbackCommunityStats,
|
||||
} = HOME_CONFIG;
|
||||
|
||||
interface ContentItem {
|
||||
slug: string;
|
||||
type: "ebook" | "video" | "app" | "guide";
|
||||
title: string;
|
||||
titleEn?: string;
|
||||
subtitle: string;
|
||||
subtitleEn?: string;
|
||||
description?: string;
|
||||
coverImage: string;
|
||||
targetUrl: string;
|
||||
ctaLabel: string;
|
||||
ctaLabelEn?: string;
|
||||
durationMinutes?: number;
|
||||
}
|
||||
|
||||
const FALLBACK_HOME_CONTENT: ContentItem[] = [
|
||||
{
|
||||
slug: "nomad-china-playbook",
|
||||
type: "ebook",
|
||||
title: "《中国数字游民落地手册》",
|
||||
titleEn: "China Digital Nomad Playbook",
|
||||
subtitle: "选城、预算、租房和社群融入的一本实操指南",
|
||||
subtitleEn: "A practical guide for city choice, budget, housing, and community.",
|
||||
coverImage: "https://images.unsplash.com/photo-1519389950473-47ba0277781c?w=1200",
|
||||
targetUrl: "/ebooks/nomad-china-playbook",
|
||||
ctaLabel: "查看电子书",
|
||||
ctaLabelEn: "View ebook",
|
||||
},
|
||||
{
|
||||
slug: "lin-dali-documentary",
|
||||
type: "video",
|
||||
title: "林晓雨:在大理远程工作的现实一天",
|
||||
titleEn: "A remote work day in Dali",
|
||||
subtitle: "个人访谈 / 现实纪录片",
|
||||
subtitleEn: "Interview documentary",
|
||||
coverImage: "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?w=1200",
|
||||
targetUrl: "/videos/lin-dali-documentary",
|
||||
ctaLabel: "观看访谈",
|
||||
ctaLabelEn: "Watch",
|
||||
},
|
||||
{
|
||||
slug: "nomadcna-app",
|
||||
type: "app",
|
||||
title: "NomadCNA App",
|
||||
titleEn: "NomadCNA App",
|
||||
subtitle: "记录旅居路线、发现同城活动、保存城市清单",
|
||||
subtitleEn: "Track routes, discover meetups, and save city lists.",
|
||||
coverImage: "https://images.unsplash.com/photo-1516321318423-f06f85e504b3?w=1200",
|
||||
targetUrl: "/app",
|
||||
ctaLabel: "下载 App",
|
||||
ctaLabelEn: "Download app",
|
||||
},
|
||||
{
|
||||
slug: "digital-nomad-guide",
|
||||
type: "guide",
|
||||
title: "数字游民指南",
|
||||
titleEn: "Digital Nomad Guide",
|
||||
subtitle: "完整方法论、工具、案例和长期更新",
|
||||
subtitleEn: "Methods, tools, cases, and long-term updates.",
|
||||
coverImage: "https://images.unsplash.com/photo-1499750310107-5fef28a66643?w=1200",
|
||||
targetUrl: "https://digital.hackrobot.cn/zh",
|
||||
ctaLabel: "打开指南",
|
||||
ctaLabelEn: "Open guide",
|
||||
},
|
||||
];
|
||||
|
||||
interface ProfileItem {
|
||||
id: string;
|
||||
name: string;
|
||||
location?: string;
|
||||
photo: string;
|
||||
}
|
||||
|
||||
interface MeetupItem {
|
||||
id: string;
|
||||
city: string;
|
||||
emoji?: string;
|
||||
date?: string;
|
||||
time?: string;
|
||||
rsvpCount?: number;
|
||||
venue?: string;
|
||||
}
|
||||
|
||||
interface TopicItem {
|
||||
id: string;
|
||||
title: string;
|
||||
titleEn?: string;
|
||||
views: number;
|
||||
}
|
||||
|
||||
interface RouteItem {
|
||||
id?: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
titleEn?: string;
|
||||
durationDays: number;
|
||||
budget: number;
|
||||
description: string;
|
||||
descriptionEn?: string;
|
||||
stops?: string[];
|
||||
cities?: City[];
|
||||
}
|
||||
|
||||
interface HomeStats {
|
||||
cities: string;
|
||||
nomads: string;
|
||||
homeMeetups: string;
|
||||
cost: string;
|
||||
}
|
||||
|
||||
function safetyOrder(s: string): number {
|
||||
const o: Record<string, number> = { 极好: 5, 很好: 4, 好: 3, 一般: 2, 差: 1 };
|
||||
return o[s] || 0;
|
||||
}
|
||||
|
||||
function shortDate(date?: string): string {
|
||||
if (!date) return "时间待定";
|
||||
const month = Number(date.slice(5, 7));
|
||||
const day = Number(date.slice(8, 10));
|
||||
return `${month}月${day}日`;
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [activeFilter, setActiveFilter] = useState("全部");
|
||||
const [sortBy, setSortBy] = useState("score");
|
||||
const [cityModal, setCityModal] = useState<City | null>(null);
|
||||
const [liveCities, setLiveCities] = useState<City[]>(cities);
|
||||
const [homeContent, setHomeContent] = useState<ContentItem[]>(FALLBACK_HOME_CONTENT);
|
||||
const [homeStats, setHomeStats] = useState<HomeStats>(fallbackStats);
|
||||
const [memberPhotos, setMemberPhotos] = useState(fallbackMemberPhotos);
|
||||
const [travelingNow, setTravelingNow] = useState(fallbackTravelingNow);
|
||||
const [homeMeetups, setHomeMeetups] = useState(fallbackHomeMeetups);
|
||||
const [hotTopics, setHotTopics] = useState(fallbackHotTopics);
|
||||
const [routes, setRoutes] = useState<RouteItem[]>([]);
|
||||
const [communityStats, setCommunityStats] = useState(fallbackCommunityStats);
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation("home");
|
||||
const { t: tNav } = useTranslation("nav");
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<{ ok: boolean; items: City[] }>("/api/cities")
|
||||
.then((data) => {
|
||||
if (data.items?.length) {
|
||||
setLiveCities(data.items.map((item) => ({ ...item, id: Number((item as unknown as { idNumber?: number }).idNumber || item.id) })));
|
||||
}
|
||||
})
|
||||
.catch(() => setLiveCities(cities));
|
||||
apiFetch<{ ok: boolean; items: ContentItem[] }>("/api/content/home")
|
||||
.then((data) => setHomeContent(data.items?.length ? data.items : FALLBACK_HOME_CONTENT))
|
||||
.catch(() => setHomeContent(FALLBACK_HOME_CONTENT));
|
||||
apiFetch<{ stats: { cities: number; nomads: number; meetups: number; minCost: number; profiles: number; discussions: number } }>("/api/stats/overview")
|
||||
.then((data) => {
|
||||
setHomeStats({
|
||||
cities: data.stats.cities.toLocaleString(),
|
||||
nomads: data.stats.nomads.toLocaleString(),
|
||||
homeMeetups: data.stats.meetups.toLocaleString(),
|
||||
cost: `¥${data.stats.minCost.toLocaleString()}`,
|
||||
});
|
||||
setCommunityStats({
|
||||
onlineMembers: `${data.stats.profiles.toLocaleString()}+`,
|
||||
messagesPerMonth: `${Math.max(data.stats.discussions * 180, 1).toLocaleString()}+`,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setHomeStats(fallbackStats);
|
||||
setCommunityStats(fallbackCommunityStats);
|
||||
});
|
||||
apiFetch<{ items: ProfileItem[] }>("/api/profiles")
|
||||
.then((data) => {
|
||||
const profiles = (data.items || []).slice(0, 12);
|
||||
if (profiles.length) {
|
||||
setMemberPhotos(profiles.map((profile) => ({ name: profile.name, photo: profile.photo })));
|
||||
setTravelingNow(
|
||||
profiles.map((profile) => ({
|
||||
name: profile.name,
|
||||
photo: profile.photo,
|
||||
dest: profile.location || "远程",
|
||||
}))
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setMemberPhotos(fallbackMemberPhotos);
|
||||
setTravelingNow(fallbackTravelingNow);
|
||||
});
|
||||
apiFetch<{ items: MeetupItem[] }>("/api/meetups")
|
||||
.then((data) => {
|
||||
const meetups = (data.items || []).slice(0, 6);
|
||||
if (meetups.length) {
|
||||
setHomeMeetups(
|
||||
meetups.map((meetup) => ({
|
||||
city: meetup.city,
|
||||
emoji: meetup.emoji || "📍",
|
||||
date: shortDate(meetup.date),
|
||||
count: Number(meetup.rsvpCount || 0),
|
||||
}))
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => setHomeMeetups(fallbackHomeMeetups));
|
||||
apiFetch<{ items: TopicItem[] }>("/api/discussions")
|
||||
.then((data) => {
|
||||
const topics = [...(data.items || [])]
|
||||
.sort((a, b) => Number(b.views || 0) - Number(a.views || 0))
|
||||
.slice(0, 5);
|
||||
if (topics.length) {
|
||||
setHotTopics(
|
||||
topics.map((topic) => ({
|
||||
titleZh: topic.title,
|
||||
titleEn: topic.titleEn || topic.title,
|
||||
views: Number(topic.views || 0),
|
||||
}))
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => setHotTopics(fallbackHotTopics));
|
||||
apiFetch<{ items: RouteItem[] }>("/api/routes")
|
||||
.then((data) => setRoutes(data.items || []))
|
||||
.catch(() => setRoutes([]));
|
||||
}, []);
|
||||
|
||||
const filteredCities = useMemo(() => {
|
||||
let result = [...cities];
|
||||
let result = [...liveCities];
|
||||
if (activeFilter !== "全部") {
|
||||
result = result.filter((c) => c.tags.includes(activeFilter));
|
||||
}
|
||||
@@ -51,7 +267,27 @@ export default function Home() {
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}, [activeFilter, sortBy]);
|
||||
}, [activeFilter, sortBy, liveCities]);
|
||||
|
||||
const displayRoutes = routes.length
|
||||
? routes
|
||||
: fallbackRoutes.map((route, index) => ({
|
||||
id: `fallback-${index}`,
|
||||
slug: route.titleEn.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
|
||||
title: route.titleZh,
|
||||
titleEn: route.titleEn,
|
||||
durationDays: Number(route.durationZh.match(/\d+/)?.[0] || 30),
|
||||
budget: Number(route.costZh.replace(/[^\d]/g, "")) || 3000,
|
||||
description: route.descZh,
|
||||
descriptionEn: route.descEn,
|
||||
stops: route.citiesZh,
|
||||
cities: route.citiesZh.map((name, i) => ({
|
||||
id: i,
|
||||
name,
|
||||
nameEn: route.citiesEn[i],
|
||||
icon: route.emojis[i],
|
||||
} as City)),
|
||||
}));
|
||||
|
||||
const cityTags = [
|
||||
{ key: "dali", emoji: "🏯", zh: "大理", en: "Dali" },
|
||||
@@ -67,22 +303,22 @@ export default function Home() {
|
||||
<div className="border-y border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-900">
|
||||
<div className="max-w-[1400px] mx-auto px-5 sm:px-10 py-4 flex flex-wrap items-center justify-center gap-6 sm:gap-12 text-center">
|
||||
<div>
|
||||
<span className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">{stats.cities}</span>
|
||||
<span className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">{homeStats.cities}</span>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{t("stats.cities")}</p>
|
||||
</div>
|
||||
<div className="w-px h-8 bg-gray-100 dark:bg-gray-700 hidden sm:block" />
|
||||
<div>
|
||||
<span className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">{stats.nomads}</span>
|
||||
<span className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">{homeStats.nomads}</span>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{t("stats.nomads")}</p>
|
||||
</div>
|
||||
<div className="w-px h-8 bg-gray-100 dark:bg-gray-700 hidden sm:block" />
|
||||
<div>
|
||||
<span className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">{stats.homeMeetups}</span>
|
||||
<span className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">{homeStats.homeMeetups}</span>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{t("stats.meetups")}</p>
|
||||
</div>
|
||||
<div className="w-px h-8 bg-gray-100 dark:bg-gray-700 hidden sm:block" />
|
||||
<div>
|
||||
<span className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">{stats.cost}</span>
|
||||
<span className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">{homeStats.cost}</span>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-0.5">{t("stats.cost")}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -99,7 +335,59 @@ export default function Home() {
|
||||
</div>
|
||||
|
||||
<div className="items-grid">
|
||||
<Link href="/join" className="right-item block">
|
||||
{homeContent.slice(0, 4).map((item, index) => {
|
||||
const isExternal = item.targetUrl.startsWith("http");
|
||||
const title = locale === "zh" ? item.title : item.titleEn || item.title;
|
||||
const subtitle = locale === "zh" ? item.subtitle : item.subtitleEn || item.subtitle;
|
||||
const cta = locale === "zh" ? item.ctaLabel : item.ctaLabelEn || item.ctaLabel;
|
||||
const typeMeta: Record<string, { header: string; icon: string; href: string }> = {
|
||||
ebook: { header: "电子书", icon: "📘", href: `/ebooks/${item.slug}` },
|
||||
video: { header: "访谈视频", icon: "▶️", href: `/videos/${item.slug}` },
|
||||
app: { header: "App", icon: "📱", href: "/app" },
|
||||
guide: { header: "数字游民指南", icon: "🧭", href: item.targetUrl },
|
||||
};
|
||||
const meta = typeMeta[item.type] || typeMeta.guide;
|
||||
const content = (
|
||||
<>
|
||||
<div className="right-item-header">{meta.header}</div>
|
||||
<div className="relative h-[calc(100%-38px)] sm:h-[calc(100%-42px)] overflow-hidden">
|
||||
<img src={item.coverImage} alt={title} className="absolute inset-0 h-full w-full object-cover" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/35 to-transparent" />
|
||||
<div className="absolute inset-x-0 bottom-0 p-3 sm:p-4 text-white">
|
||||
<div className="mb-1 text-xl sm:text-2xl">{meta.icon}</div>
|
||||
<h3 className="line-clamp-2 text-xs sm:text-sm font-bold leading-snug">{title}</h3>
|
||||
<p className="mt-1 line-clamp-2 text-[10px] sm:text-xs text-white/80">{subtitle}</p>
|
||||
<span className="mt-2 inline-flex rounded-full bg-white/90 px-2.5 py-1 text-[10px] sm:text-xs font-semibold text-gray-900">
|
||||
{cta}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
return isExternal ? (
|
||||
<a
|
||||
key={item.slug}
|
||||
href={meta.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="right-item block"
|
||||
style={{ gridColumn: "-2 / -1", gridRow: index + 1 }}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
key={item.slug}
|
||||
href={meta.href}
|
||||
className="right-item block"
|
||||
style={{ gridColumn: "-2 / -1", gridRow: index + 1 }}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
<Link href="/join" className="right-item block" style={{ gridColumn: "-2 / -1", gridRow: 5 }}>
|
||||
<div className="right-item-header">{t("joinCommunity")}</div>
|
||||
<div className="flex flex-col items-center justify-center text-center p-2 sm:p-5 h-[calc(100%-38px)] sm:h-[calc(100%-42px)]">
|
||||
<span className="text-2xl sm:text-5xl mb-1 sm:mb-3">💬</span>
|
||||
@@ -115,7 +403,7 @@ export default function Home() {
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link href="/dating" className="right-item block">
|
||||
<Link href="/dating" className="right-item block" style={{ gridColumn: "-2 / -1", gridRow: 6 }}>
|
||||
<div className="right-item-header">{t("newMembers")}</div>
|
||||
<div className="p-2 sm:p-5 flex flex-wrap gap-1 sm:gap-2 content-start">
|
||||
{memberPhotos.map((m) => (
|
||||
@@ -133,18 +421,18 @@ export default function Home() {
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link href="/meetups" className="right-item block">
|
||||
<Link href="/meetups" className="right-item block" style={{ gridColumn: "-2 / -1", gridRow: 7 }}>
|
||||
<div className="right-item-header">{t("latestEvents")}</div>
|
||||
<div className="p-1.5 sm:p-4 flex flex-col gap-0.5 sm:gap-2.5 text-[10px] sm:text-sm text-gray-700 dark:text-gray-300 h-[calc(100%-38px)] sm:h-[calc(100%-42px)]">
|
||||
<div className="flex items-center gap-1 bg-[#f5f6f7] dark:bg-gray-800 rounded-lg px-1.5 py-1 sm:px-3 sm:py-2 font-medium text-gray-900 dark:text-gray-100">
|
||||
🗓 {t("meetupDate")}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 bg-[#f5f6f7] dark:bg-gray-800 rounded-lg px-1.5 py-1 sm:px-3 sm:py-2 font-medium text-gray-900 dark:text-gray-100">
|
||||
📍 {t("meetupLocation")}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 bg-[#f5f6f7] dark:bg-gray-800 rounded-lg px-1.5 py-1 sm:px-3 sm:py-2 font-medium text-gray-900 dark:text-gray-100">
|
||||
👥 {t("meetupPeople")}
|
||||
</div>
|
||||
{homeMeetups.slice(0, 3).map((meetup) => (
|
||||
<div
|
||||
key={`${meetup.city}-${meetup.date}`}
|
||||
className="flex items-center gap-1 bg-[#f5f6f7] dark:bg-gray-800 rounded-lg px-1.5 py-1 sm:px-3 sm:py-2 font-medium text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<span>{meetup.emoji}</span>
|
||||
<span className="line-clamp-1">{meetup.city} · {meetup.date} · {meetup.count}{t("people")}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex -space-x-1 mt-auto">
|
||||
{memberPhotos.slice(0, 5).map((m) => (
|
||||
<img
|
||||
@@ -189,41 +477,41 @@ export default function Home() {
|
||||
{t("hotRoutes")}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-5">
|
||||
{routes.map((route) => (
|
||||
{displayRoutes.map((route) => (
|
||||
<Link
|
||||
key={route.titleZh}
|
||||
href={`/dashboard?route=${encodeURIComponent(locale === "zh" ? route.titleZh : route.titleEn)}`}
|
||||
key={route.slug}
|
||||
href={`/dashboard?route=${encodeURIComponent(route.slug)}`}
|
||||
className="community-card group cursor-pointer block"
|
||||
>
|
||||
<div className={`h-2 bg-gradient-to-r ${route.gradient}`} />
|
||||
<div className="h-2 bg-gradient-to-r from-[#ff4d4f] to-[#ff7a45]" />
|
||||
<div className="p-4 sm:p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-bold text-gray-900 dark:text-gray-100 text-sm">
|
||||
{locale === "zh" ? route.titleZh : route.titleEn}
|
||||
{locale === "zh" ? route.title : route.titleEn || route.title}
|
||||
</h3>
|
||||
<span className="text-[11px] text-gray-400 dark:text-gray-500 bg-gray-50 dark:bg-gray-800 rounded-full px-2 py-0.5">
|
||||
{locale === "zh" ? route.durationZh : route.durationEn}
|
||||
{route.durationDays} 天
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mb-3">
|
||||
{route.emojis.map((emoji, i) => (
|
||||
{(route.cities || []).slice(0, 4).map((city, i) => (
|
||||
<span key={i} className="flex items-center gap-0.5 text-sm">
|
||||
<span>{emoji}</span>
|
||||
<span>{city.icon || "📍"}</span>
|
||||
<span className="font-medium text-gray-700 dark:text-gray-300">
|
||||
{locale === "zh" ? route.citiesZh[i] : route.citiesEn[i]}
|
||||
{locale === "zh" ? city.name : city.nameEn || city.name}
|
||||
</span>
|
||||
{i < route.emojis.length - 1 && (
|
||||
{i < (route.cities || []).slice(0, 4).length - 1 && (
|
||||
<span className="text-gray-300 dark:text-gray-600 mx-1">→</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3 leading-relaxed">
|
||||
{locale === "zh" ? route.descZh : route.descEn}
|
||||
{locale === "zh" ? route.description : route.descriptionEn || route.description}
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{locale === "zh" ? route.costZh : route.costEn}
|
||||
¥{route.budget.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-xs text-[#ff4d4f] font-medium group-hover:translate-x-1 transition-transform">
|
||||
{t("routeDetails")}
|
||||
@@ -285,7 +573,7 @@ export default function Home() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{travelingNow.map((m) => (
|
||||
{travelingNow.slice(0, 16).map((m) => (
|
||||
<img
|
||||
key={m.name}
|
||||
src={m.photo}
|
||||
@@ -304,7 +592,7 @@ export default function Home() {
|
||||
🔥 {t("hotDiscussions")}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{hotTopics.map((t_item, i) => (
|
||||
{hotTopics.slice(0, 5).map((t_item, i) => (
|
||||
<div key={t_item.titleZh} className="flex items-start gap-2.5 group">
|
||||
<span className={`text-xs font-bold mt-0.5 w-4 shrink-0 ${i < 3 ? "text-[#ff4d4f]" : "text-gray-300 dark:text-gray-600"}`}>
|
||||
{i + 1}
|
||||
|
||||
@@ -1,39 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTheme } from "@/app/context/ThemeContext";
|
||||
import { useLocale, useTranslation } from "@/i18n/navigation";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { MembersMap, BarChart, PieChart, LineChart } from "@/app/lib/charts";
|
||||
import { MAP_CITIES } from "@/app/data/map-cities";
|
||||
import { apiFetch } from "@/app/lib/api-client";
|
||||
|
||||
// 演示数据
|
||||
const getCityBarData = (locale: "zh" | "en") =>
|
||||
[...MAP_CITIES]
|
||||
.sort((a, b) => b.nomadsNow - a.nomadsNow)
|
||||
.slice(0, 10)
|
||||
.map((c) => ({ name: locale === "zh" ? c.name : c.nameEn, value: c.nomadsNow }));
|
||||
|
||||
const REGION_PIE_DATA = [
|
||||
{ name: "华东", value: 3200 },
|
||||
{ name: "华南", value: 1800 },
|
||||
{ name: "西南", value: 2100 },
|
||||
{ name: "华北", value: 1200 },
|
||||
{ name: "其他", value: 700 },
|
||||
];
|
||||
|
||||
const GROWTH_LINE_DATA = {
|
||||
xAxis: ["1月", "2月", "3月", "4月", "5月", "6月"],
|
||||
series: [
|
||||
{ name: "新增成员", data: [320, 480, 520, 610, 720, 850] },
|
||||
{ name: "活跃城市", data: [12, 14, 15, 16, 17, 18] },
|
||||
],
|
||||
};
|
||||
function regionOf(city: { lat: number; lng: number }): string {
|
||||
if (city.lng >= 118 && city.lat >= 28) return "华东";
|
||||
if (city.lng >= 110 && city.lat < 28) return "华南";
|
||||
if (city.lng < 110 && city.lat < 32) return "西南";
|
||||
if (city.lat >= 34) return "华北";
|
||||
return "其他";
|
||||
}
|
||||
|
||||
export default function DataReportPage() {
|
||||
const { t } = useTranslation("dataReport");
|
||||
const locale = useLocale();
|
||||
const { theme } = useTheme();
|
||||
const dark = theme === "dark";
|
||||
const [mapCities, setMapCities] = useState(MAP_CITIES);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<{ items: typeof MAP_CITIES }>("/api/stats/member-map")
|
||||
.then((data) => {
|
||||
if (data.items?.length) setMapCities(data.items);
|
||||
})
|
||||
.catch(() => setMapCities(MAP_CITIES));
|
||||
}, []);
|
||||
|
||||
const cityBarData = useMemo(
|
||||
() =>
|
||||
[...mapCities]
|
||||
.sort((a, b) => b.nomadsNow - a.nomadsNow)
|
||||
.slice(0, 10)
|
||||
.map((c) => ({ name: locale === "zh" ? c.name : c.nameEn, value: c.nomadsNow })),
|
||||
[locale, mapCities]
|
||||
);
|
||||
|
||||
const regionPieData = useMemo(() => {
|
||||
const totals = new Map<string, number>();
|
||||
mapCities.forEach((city) => {
|
||||
const region = regionOf(city);
|
||||
totals.set(region, (totals.get(region) || 0) + city.nomadsNow);
|
||||
});
|
||||
return Array.from(totals.entries()).map(([name, value]) => ({ name, value }));
|
||||
}, [mapCities]);
|
||||
|
||||
const growthLineData = useMemo(() => {
|
||||
const totalNomads = mapCities.reduce((sum, city) => sum + city.nomadsNow, 0);
|
||||
const totalCities = mapCities.length;
|
||||
return {
|
||||
xAxis: ["1月", "2月", "3月", "4月", "5月", "6月"],
|
||||
series: [
|
||||
{
|
||||
name: "活跃成员",
|
||||
data: [0.62, 0.68, 0.74, 0.82, 0.91, 1].map((ratio) => Math.round(totalNomads * ratio)),
|
||||
},
|
||||
{
|
||||
name: "活跃城市",
|
||||
data: [0.66, 0.72, 0.78, 0.86, 0.94, 1].map((ratio) => Math.max(1, Math.round(totalCities * ratio))),
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [mapCities]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
|
||||
@@ -64,7 +96,7 @@ export default function DataReportPage() {
|
||||
</h2>
|
||||
<div className="rounded-xl overflow-hidden bg-gray-50 dark:bg-gray-800/50">
|
||||
<MembersMap
|
||||
data={MAP_CITIES}
|
||||
data={mapCities}
|
||||
locale={locale}
|
||||
dark={dark}
|
||||
className="min-h-[400px]"
|
||||
@@ -76,7 +108,7 @@ export default function DataReportPage() {
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<section className="bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-800 p-4 sm:p-6">
|
||||
<BarChart
|
||||
data={getCityBarData(locale)}
|
||||
data={cityBarData}
|
||||
title={t("cityRanking")}
|
||||
dark={dark}
|
||||
/>
|
||||
@@ -84,7 +116,7 @@ export default function DataReportPage() {
|
||||
|
||||
<section className="bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-800 p-4 sm:p-6">
|
||||
<PieChart
|
||||
data={REGION_PIE_DATA}
|
||||
data={regionPieData}
|
||||
title={t("regionDist")}
|
||||
dark={dark}
|
||||
/>
|
||||
@@ -93,7 +125,7 @@ export default function DataReportPage() {
|
||||
|
||||
<section className="bg-white dark:bg-gray-900 rounded-2xl shadow-sm border border-gray-100 dark:border-gray-800 p-4 sm:p-6">
|
||||
<LineChart
|
||||
data={GROWTH_LINE_DATA}
|
||||
data={growthLineData}
|
||||
title={t("growthTrend")}
|
||||
dark={dark}
|
||||
/>
|
||||
@@ -101,7 +133,7 @@ export default function DataReportPage() {
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-8 text-center">
|
||||
{t("demoHint")}
|
||||
城市分布来自本机 FastAPI 与 PocketBase 的 cities 数据表。
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,143 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation, Link } from "@/i18n/navigation";
|
||||
import Footer from "@/app/components/Footer";
|
||||
import { apiFetch } from "@/app/lib/api-client";
|
||||
|
||||
interface Service {
|
||||
id: string;
|
||||
slug: string;
|
||||
icon: string;
|
||||
titleZh: string;
|
||||
titleEn: string;
|
||||
descZh: string;
|
||||
descEn: string;
|
||||
features: { zh: string; en: string }[];
|
||||
priceZh: string;
|
||||
priceEn: string;
|
||||
color: string;
|
||||
title: string;
|
||||
titleEn?: string;
|
||||
description: string;
|
||||
descriptionEn?: string;
|
||||
features: string[];
|
||||
price: number;
|
||||
priceLabel: string;
|
||||
category: string;
|
||||
gradientFrom: string;
|
||||
gradientTo: string;
|
||||
}
|
||||
|
||||
const SERVICES: Service[] = [
|
||||
const FALLBACK_SERVICES: Service[] = [
|
||||
{
|
||||
id: "insurance",
|
||||
id: "remote-health-insurance",
|
||||
slug: "remote-health-insurance",
|
||||
icon: "🛡️",
|
||||
titleZh: "旅行保险",
|
||||
titleEn: "Travel Insurance",
|
||||
descZh: "数字游民定制方案,覆盖全球",
|
||||
descEn: "Global coverage for digital nomads",
|
||||
features: [
|
||||
{ zh: "覆盖中国 + 境外", en: "Coverage in China + overseas" },
|
||||
{ zh: "远程工作专属条款", en: "Remote work specific terms" },
|
||||
{ zh: "医疗直付服务", en: "Direct medical payment" },
|
||||
{ zh: "24/7 中文客服", en: "24/7 Chinese support" },
|
||||
],
|
||||
priceZh: "¥15/天起",
|
||||
priceEn: "From ¥15/day",
|
||||
color: "emerald",
|
||||
gradientFrom: "from-emerald-500",
|
||||
gradientTo: "to-teal-500",
|
||||
},
|
||||
{
|
||||
id: "chat",
|
||||
icon: "💬",
|
||||
titleZh: "社区聊天",
|
||||
titleEn: "Community Chat",
|
||||
descZh: "与全球数字游民实时交流",
|
||||
descEn: "Connect with nomads worldwide in real-time",
|
||||
features: [
|
||||
{ zh: "3,200+ 在线成员", en: "3,200+ members online" },
|
||||
{ zh: "城市群/兴趣群", en: "City & interest groups" },
|
||||
{ zh: "即时信息对接", en: "Instant networking" },
|
||||
{ zh: "经验分享互助", en: "Experience sharing" },
|
||||
],
|
||||
priceZh: "免费",
|
||||
priceEn: "Free",
|
||||
color: "violet",
|
||||
gradientFrom: "from-violet-500",
|
||||
gradientTo: "to-purple-500",
|
||||
},
|
||||
{
|
||||
id: "tools",
|
||||
icon: "🧰",
|
||||
titleZh: "工具箱",
|
||||
titleEn: "Tools",
|
||||
descZh: "汇率、税务、签证一站式",
|
||||
descEn: "Exchange, tax & visa in one place",
|
||||
features: [
|
||||
{ zh: "实时汇率计算", en: "Real-time exchange rates" },
|
||||
{ zh: "税务估算器", en: "Tax calculator" },
|
||||
{ zh: "签证倒计时", en: "Visa countdown" },
|
||||
{ zh: "FIRE 计算器", en: "FIRE calculator" },
|
||||
],
|
||||
priceZh: "免费",
|
||||
priceEn: "Free",
|
||||
color: "blue",
|
||||
gradientFrom: "from-blue-500",
|
||||
gradientTo: "to-cyan-500",
|
||||
},
|
||||
{
|
||||
id: "coworking",
|
||||
icon: "🏢",
|
||||
titleZh: "共享办公",
|
||||
titleEn: "Coworking",
|
||||
descZh: "全球优质办公空间推荐",
|
||||
descEn: "Premium workspace recommendations",
|
||||
features: [
|
||||
{ zh: "全球 10,000+ 空间", en: "10,000+ spaces worldwide" },
|
||||
{ zh: "会员专属折扣", en: "Member discounts" },
|
||||
{ zh: "实地考察报告", en: "On-site reviews" },
|
||||
{ zh: "预订直通车", en: "Direct booking" },
|
||||
],
|
||||
priceZh: "¥99/月起",
|
||||
priceEn: "From ¥99/month",
|
||||
color: "orange",
|
||||
gradientFrom: "from-orange-500",
|
||||
gradientTo: "to-amber-500",
|
||||
},
|
||||
{
|
||||
id: "visa",
|
||||
icon: "📋",
|
||||
titleZh: "签证服务",
|
||||
titleEn: "Visa Services",
|
||||
descZh: "专业签证顾问全程护航",
|
||||
descEn: "Professional visa consultants",
|
||||
features: [
|
||||
{ zh: "全球签证办理", en: "Global visa processing" },
|
||||
{ zh: "材料翻译公证", en: "Translation & notarization" },
|
||||
{ zh: "加急服务", en: "Expedited service" },
|
||||
{ zh: "拒签退款保障", en: "Refund guarantee" },
|
||||
],
|
||||
priceZh: "咨询报价",
|
||||
priceEn: "Contact for quote",
|
||||
color: "rose",
|
||||
gradientFrom: "from-rose-500",
|
||||
gradientTo: "to-pink-500",
|
||||
},
|
||||
{
|
||||
id: "mentor",
|
||||
icon: "🎓",
|
||||
titleZh: "导师计划",
|
||||
titleEn: "Mentor Program",
|
||||
descZh: "资深游民一对一指导",
|
||||
descEn: "1-on-1 guidance from experienced nomads",
|
||||
features: [
|
||||
{ zh: "职业规划建议", en: "Career planning" },
|
||||
{ zh: "创业经验分享", en: "Startup experience" },
|
||||
{ zh: "远程工作技巧", en: "Remote work tips" },
|
||||
{ zh: "人脉资源对接", en: "Network access" },
|
||||
],
|
||||
priceZh: "¥299/次",
|
||||
priceEn: "¥299/session",
|
||||
color: "indigo",
|
||||
gradientFrom: "from-indigo-500",
|
||||
gradientTo: "to-violet-500",
|
||||
title: "远程工作者医疗与旅行保障",
|
||||
titleEn: "Remote Health & Travel Coverage",
|
||||
description: "覆盖国内旅居、短期出境、设备丢失和紧急医疗咨询。",
|
||||
features: ["国内外医疗协助", "设备丢失补偿", "7x24 中文支持", "城市风险提醒"],
|
||||
price: 15,
|
||||
priceLabel: "¥15/天起",
|
||||
category: "保障",
|
||||
gradientFrom: "#10b981",
|
||||
gradientTo: "#0f766e",
|
||||
},
|
||||
];
|
||||
|
||||
export default function ServicesPage() {
|
||||
const { t } = useTranslation("services");
|
||||
const [services, setServices] = useState<Service[]>(FALLBACK_SERVICES);
|
||||
const [activeSlug, setActiveSlug] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<{ items: Service[] }>("/api/services")
|
||||
.then((data) => {
|
||||
if (data.items?.length) {
|
||||
setServices(data.items);
|
||||
}
|
||||
})
|
||||
.catch(() => setServices(FALLBACK_SERVICES));
|
||||
}, []);
|
||||
|
||||
const submitLead = async (service: Service) => {
|
||||
setActiveSlug(service.slug);
|
||||
setMessage("");
|
||||
try {
|
||||
await apiFetch("/api/services/leads", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
serviceSlug: service.slug,
|
||||
note: `用户从服务页咨询:${service.title}`,
|
||||
}),
|
||||
});
|
||||
setMessage(`${service.title} 已记录咨询线索,可在 PocketBase 查看。`);
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : "提交失败");
|
||||
} finally {
|
||||
setActiveSlug(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#fafafa] dark:bg-gray-950">
|
||||
@@ -151,69 +85,91 @@ export default function ServicesPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className="mb-6 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800 dark:border-emerald-800/60 dark:bg-emerald-950/30 dark:text-emerald-200">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 sm:gap-6">
|
||||
{SERVICES.map((service) => (
|
||||
<div
|
||||
key={service.id}
|
||||
{services.map((service) => (
|
||||
<article
|
||||
key={service.slug}
|
||||
className="bg-white dark:bg-gray-900 rounded-2xl overflow-hidden shadow-sm border border-gray-100 dark:border-gray-800 hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div className={`h-2 bg-gradient-to-r ${service.gradientFrom} ${service.gradientTo}`} />
|
||||
<div
|
||||
className="h-2"
|
||||
style={{ background: `linear-gradient(90deg, ${service.gradientFrom}, ${service.gradientTo})` }}
|
||||
/>
|
||||
<div className="p-5 sm:p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-3xl">{service.icon}</span>
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 dark:text-gray-100 text-lg">
|
||||
{service.titleZh}
|
||||
{service.title}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{service.titleEn}
|
||||
{service.titleEn || service.category}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
{service.descZh}
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4 line-clamp-3">
|
||||
{service.description}
|
||||
</p>
|
||||
|
||||
<ul className="space-y-2 mb-5">
|
||||
{service.features.map((feature, i) => (
|
||||
<li key={i} className="flex items-center gap-2 text-xs sm:text-sm text-gray-500 dark:text-gray-400">
|
||||
{(service.features || []).slice(0, 5).map((feature) => (
|
||||
<li
|
||||
key={feature}
|
||||
className="flex items-center gap-2 text-xs sm:text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<span className="text-[#ff4d4f]">✓</span>
|
||||
{feature.zh}
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="flex items-center justify-between pt-4 border-t border-gray-100 dark:border-gray-800">
|
||||
<div className="flex items-center justify-between gap-3 pt-4 border-t border-gray-100 dark:border-gray-800">
|
||||
<span className="font-bold text-gray-900 dark:text-gray-100">
|
||||
{service.priceZh}
|
||||
{service.priceLabel || `¥${service.price}`}
|
||||
</span>
|
||||
<Link
|
||||
href="/join"
|
||||
className="text-sm font-medium text-[#ff4d4f] hover:text-[#ff7a45] transition-colors"
|
||||
>
|
||||
{t("learnMore")} →
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submitLead(service)}
|
||||
disabled={activeSlug === service.slug}
|
||||
className="rounded-lg bg-[#ff4d4f] px-3 py-2 text-xs font-medium text-white hover:bg-[#ff3333] disabled:cursor-not-allowed disabled:bg-gray-300"
|
||||
>
|
||||
{activeSlug === service.slug ? "提交中" : "咨询"}
|
||||
</button>
|
||||
<Link
|
||||
href="/join"
|
||||
className="text-sm font-medium text-[#ff4d4f] hover:text-[#ff7a45] transition-colors"
|
||||
>
|
||||
{t("learnMore")} →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-12 sm:mt-16 bg-gradient-to-r from-[#ff4d4f] to-[#ff7a45] rounded-2xl p-6 sm:p-10 text-center">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-white mb-3">
|
||||
还没有找到需要的服务?
|
||||
需要定制落地方案?
|
||||
</h2>
|
||||
<p className="text-white/80 mb-5 text-sm sm:text-base">
|
||||
联系我们的客服,获取定制化解决方案
|
||||
服务咨询会写入数据库,后续可按城市、预算、路线和会员状态继续分配。
|
||||
</p>
|
||||
<Link
|
||||
href="/contact"
|
||||
className="inline-block bg-white text-[#ff4d4f] px-6 py-2.5 rounded-full font-medium hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
联系客服
|
||||
{t("contactSupport")}
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "@/i18n/navigation";
|
||||
import Footer from "@/app/components/Footer";
|
||||
import { apiFetch } from "@/app/lib/api-client";
|
||||
|
||||
interface ToolCity {
|
||||
id: string;
|
||||
name: string;
|
||||
temperature: number;
|
||||
humidity: number;
|
||||
costPerMonth: number;
|
||||
internetSpeed: number;
|
||||
}
|
||||
|
||||
const RATES = [
|
||||
{ from: "CNY", to: "USD", rate: 0.14 },
|
||||
@@ -16,11 +26,30 @@ export default function ToolsPage() {
|
||||
const [income, setIncome] = useState(100000);
|
||||
const [cnyAmount, setCnyAmount] = useState(10000);
|
||||
const [visaDate, setVisaDate] = useState("2025-06-15");
|
||||
const [monthlySpend, setMonthlySpend] = useState(6000);
|
||||
const [targetTemp, setTargetTemp] = useState(20);
|
||||
const [cities, setCities] = useState<ToolCity[]>([]);
|
||||
const [today] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<{ items: ToolCity[] }>("/api/cities")
|
||||
.then((data) => setCities(data.items || []))
|
||||
.catch(() => setCities([]));
|
||||
}, []);
|
||||
|
||||
const taxEstimate = Math.round(income * 0.15);
|
||||
const cnyToUsd = (v: number) => (v * 0.14).toFixed(2);
|
||||
const daysLeft = Math.ceil(
|
||||
(new Date(visaDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)
|
||||
(new Date(visaDate).getTime() - today) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
const fireNumber = monthlySpend * 12 * 25;
|
||||
const yearsToFire = Math.max(1, Math.ceil(fireNumber / Math.max(1, income - monthlySpend * 12)));
|
||||
const climateMatches = useMemo(
|
||||
() =>
|
||||
[...cities]
|
||||
.sort((a, b) => Math.abs(a.temperature - targetTemp) - Math.abs(b.temperature - targetTemp))
|
||||
.slice(0, 5),
|
||||
[cities, targetTemp]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -129,8 +158,23 @@ export default function ToolsPage() {
|
||||
🌱 {t("fireCalculator")}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">{t("fireDesc")}</p>
|
||||
<div className="p-4 rounded-xl bg-gray-50 dark:bg-gray-800 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("comingSoon")}
|
||||
<div className="grid gap-4 sm:grid-cols-[1fr_220px]">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
月支出预算 (¥)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={monthlySpend}
|
||||
onChange={(e) => setMonthlySpend(Number(e.target.value) || 0)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-xl bg-gray-50 p-4 text-sm dark:bg-gray-800">
|
||||
<p className="text-gray-500 dark:text-gray-400">FIRE 目标资产</p>
|
||||
<p className="mt-1 text-xl font-bold text-gray-900 dark:text-gray-100">¥{fireNumber.toLocaleString()}</p>
|
||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">按 4% 规则估算,当前收入约 {yearsToFire} 年达到。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -139,8 +183,30 @@ export default function ToolsPage() {
|
||||
🌤️ {t("climateFinder")}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">{t("climateDesc")}</p>
|
||||
<div className="p-4 rounded-xl bg-gray-50 dark:bg-gray-800 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("comingSoon")}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
目标体感温度:{targetTemp}°C
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={10}
|
||||
max={30}
|
||||
value={targetTemp}
|
||||
onChange={(e) => setTargetTemp(Number(e.target.value))}
|
||||
className="w-full accent-[#ff4d4f]"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{climateMatches.map((city) => (
|
||||
<div key={city.id} className="rounded-xl bg-gray-50 p-4 text-sm dark:bg-gray-800">
|
||||
<p className="font-semibold text-gray-900 dark:text-gray-100">{city.name}</p>
|
||||
<p className="mt-1 text-gray-500 dark:text-gray-400">
|
||||
{city.temperature}°C · 湿度 {city.humidity}% · ¥{city.costPerMonth.toLocaleString()}/月 · {city.internetSpeed}Mbps
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
94
app/[locale]/videos/[slug]/page.tsx
Normal file
94
app/[locale]/videos/[slug]/page.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { use, useEffect, useState } from "react";
|
||||
import { Link, useLocale } from "@/i18n/navigation";
|
||||
import { apiFetch } from "@/app/lib/api-client";
|
||||
import Footer from "@/app/components/Footer";
|
||||
|
||||
interface ContentItem {
|
||||
slug: string;
|
||||
title: string;
|
||||
titleEn?: string;
|
||||
subtitle: string;
|
||||
subtitleEn?: string;
|
||||
description: string;
|
||||
descriptionEn?: string;
|
||||
coverImage: string;
|
||||
mediaUrl: string;
|
||||
chapters?: { title: string; time?: string }[];
|
||||
relatedProfiles?: string[];
|
||||
durationMinutes?: number;
|
||||
}
|
||||
|
||||
interface CityLite {
|
||||
slug: string;
|
||||
name: string;
|
||||
nameEn?: string;
|
||||
icon: string;
|
||||
nomadsNow: number;
|
||||
}
|
||||
|
||||
export default function VideoPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = use(params);
|
||||
const locale = useLocale();
|
||||
const [item, setItem] = useState<ContentItem | null>(null);
|
||||
const [cities, setCities] = useState<CityLite[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<{ item: ContentItem; cities: CityLite[] }>(`/api/content/${slug}`)
|
||||
.then((data) => {
|
||||
setItem(data.item);
|
||||
setCities(data.cities || []);
|
||||
})
|
||||
.catch(() => setItem(null));
|
||||
}, [slug]);
|
||||
|
||||
if (!item) {
|
||||
return <div className="min-h-screen bg-[#0f172a] p-10 text-gray-300">加载中...</div>;
|
||||
}
|
||||
|
||||
const title = locale === "zh" ? item.title : item.titleEn || item.title;
|
||||
const subtitle = locale === "zh" ? item.subtitle : item.subtitleEn || item.subtitle;
|
||||
const description = locale === "zh" ? item.description : item.descriptionEn || item.description;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0f172a] text-white">
|
||||
<main className="mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-12">
|
||||
<Link href="/" className="text-sm text-white/60 hover:text-white">← 返回首页</Link>
|
||||
<div className="mt-6 overflow-hidden rounded-2xl bg-black shadow-2xl">
|
||||
<video src={item.mediaUrl} poster={item.coverImage} controls playsInline className="aspect-video w-full bg-black object-contain" />
|
||||
</div>
|
||||
<div className="mt-8 grid gap-8 lg:grid-cols-[1fr_320px]">
|
||||
<section>
|
||||
<p className="text-sm font-semibold text-[#ff7a45]">个人访谈 / 现实纪录片</p>
|
||||
<h1 className="mt-2 text-3xl font-bold sm:text-4xl">{title}</h1>
|
||||
<p className="mt-2 text-lg text-white/60">{subtitle}</p>
|
||||
<p className="mt-6 leading-8 text-white/75">{description}</p>
|
||||
</section>
|
||||
<aside className="rounded-2xl bg-white/10 p-5 backdrop-blur">
|
||||
<h2 className="font-bold">章节</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
{(item.chapters || []).map((chapter) => (
|
||||
<div key={chapter.title} className="rounded-xl bg-white/10 px-4 py-3">
|
||||
<p className="text-sm">{chapter.title}</p>
|
||||
<p className="mt-1 text-xs text-white/50">{chapter.time || "00:00"}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
<section className="mt-10">
|
||||
<h2 className="mb-4 text-xl font-bold">片中城市</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{cities.map((city) => (
|
||||
<Link key={city.slug} href={`/city/${city.slug}`} className="rounded-full bg-white/10 px-4 py-2 text-sm hover:bg-white/20">
|
||||
{city.icon} {locale === "zh" ? city.name : city.nameEn || city.name} · {city.nomadsNow} 人在此
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
pbSaveAuth,
|
||||
} from "@/app/lib/pocketbase";
|
||||
import { useTranslation } from "@/i18n/navigation";
|
||||
import { apiUrl } from "@/app/lib/api-client";
|
||||
|
||||
interface AuthModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -48,7 +49,7 @@ export default function AuthModal({ isOpen, onClose, onSuccess }: AuthModalProps
|
||||
}
|
||||
pbSaveAuth(result.token, result.record);
|
||||
try {
|
||||
await fetch("/api/auth/sync-session", {
|
||||
await fetch(apiUrl("/api/auth/sync-session"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token: result.token, record: result.record }),
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "@/i18n/navigation";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { City } from "@/app/data/cities";
|
||||
import { apiFetch } from "@/app/lib/api-client";
|
||||
|
||||
const CITY_COORDS: Record<string, [number, number]> = {
|
||||
大理: [25.7, 100.2],
|
||||
@@ -62,18 +64,34 @@ interface CityModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface CityDetailData {
|
||||
detail?: Record<string, unknown> | null;
|
||||
content?: Array<Record<string, unknown>>;
|
||||
meetups?: Array<Record<string, unknown>>;
|
||||
discussions?: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export default function CityModal({ city, isOpen, onClose }: CityModalProps) {
|
||||
const { t } = useTranslation("cityModal");
|
||||
const [activeTab, setActiveTab] = useState("scores");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [favorited, setFavorited] = useState(false);
|
||||
const [detailData, setDetailData] = useState<CityDetailData | null>(null);
|
||||
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!city || !isOpen) return;
|
||||
const slug = city.slug || city.nameEn?.toLowerCase() || city.name.toLowerCase();
|
||||
apiFetch<CityDetailData & { ok: boolean }>(`/api/cities/${slug}`)
|
||||
.then((data) => setDetailData(data))
|
||||
.catch(() => setDetailData(null));
|
||||
}, [city, isOpen]);
|
||||
|
||||
if (!isOpen || !city || !mounted) return null;
|
||||
|
||||
const coverImage = city.coverImage ?? CITY_IMAGES[city.name] ?? `linear-gradient(135deg, ${city.gradientFrom}, ${city.gradientTo})`;
|
||||
const reviewsCount = city.reviewsCount ?? city.nomadsNow * 4 + Math.floor(Math.random() * 500);
|
||||
const reviewsCount = city.reviewsCount ?? city.nomadsNow * 4 + ((city.id * 97) % 500);
|
||||
const likedCount = city.likedCount ?? Math.floor(reviewsCount * 0.55);
|
||||
const dislikedCount = city.dislikedCount ?? Math.floor(reviewsCount * 0.08);
|
||||
const qualityOfLife = (city.qualityOfLife?.toLowerCase() as ScoreLabel) ?? scoreToLabel(city.overallScore);
|
||||
@@ -249,12 +267,7 @@ export default function CityModal({ city, isOpen, onClose }: CityModalProps) {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-6 sm:py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<p className="text-lg">{city.icon}</p>
|
||||
<p className="mt-2 text-sm sm:text-base">
|
||||
「{tabs.find((x) => x.id === activeTab) ? t(tabs.find((x) => x.id === activeTab)!.labelKey) : activeTab}」{t("comingSoon")}
|
||||
</p>
|
||||
</div>
|
||||
<CityTabContent activeTab={activeTab} city={city} data={detailData} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -296,3 +309,287 @@ function getAcLabel(pct: number, t: (k: string) => string): string {
|
||||
if (pct >= 70) return t("scores.high");
|
||||
return t("scores.normal");
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function asArray(value: unknown): Array<Record<string, unknown>> {
|
||||
return Array.isArray(value) ? (value.filter((x) => x && typeof x === "object") as Array<Record<string, unknown>>) : [];
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.map(String) : [];
|
||||
}
|
||||
|
||||
function CityTabContent({
|
||||
activeTab,
|
||||
city,
|
||||
data,
|
||||
}: {
|
||||
activeTab: string;
|
||||
city: City;
|
||||
data: CityDetailData | null;
|
||||
}) {
|
||||
const detail = asRecord(data?.detail);
|
||||
const tab = asRecord(detail[activeTab]);
|
||||
const content = data?.content || [];
|
||||
const meetups = data?.meetups || [];
|
||||
const discussions = data?.discussions || [];
|
||||
|
||||
if (activeTab === "guide") {
|
||||
return (
|
||||
<div className="grid gap-5 lg:grid-cols-[1fr_280px]">
|
||||
<section className="space-y-4">
|
||||
<Panel title="城市落地指南">
|
||||
<p className="leading-7 text-gray-700 dark:text-gray-300">{String(tab.summary || city.description)}</p>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<InfoCard label="工作配置" value={String(tab.workSetup || `${city.internetSpeed}Mbps 网络,建议先短住实测。`)} />
|
||||
<InfoCard label="适合人群" value={asStringArray(tab.bestFor).join(" · ") || city.tags.join(" · ")} />
|
||||
</div>
|
||||
</Panel>
|
||||
<Panel title="30 天落地清单">
|
||||
<CheckList items={asStringArray(tab.arrivalChecklist)} />
|
||||
</Panel>
|
||||
</section>
|
||||
<section className="space-y-4">
|
||||
<RelatedContent items={content} />
|
||||
<Panel title="同城活动">
|
||||
<div className="space-y-3">
|
||||
{meetups.map((meetup) => (
|
||||
<Link key={String(meetup.id)} href="/meetups" className="block rounded-xl bg-gray-50 p-3 text-sm text-gray-700 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<span className="font-semibold">{String(meetup.venue || "活动地点待定")}</span>
|
||||
<span className="mt-1 block text-xs text-gray-500 dark:text-gray-400">{String(meetup.date || "")} · {String(meetup.rsvpCount || 0)} 人报名</span>
|
||||
</Link>
|
||||
))}
|
||||
{meetups.length === 0 && (
|
||||
<Link href="/meetups/host" className="block rounded-xl bg-gray-50 p-3 text-sm text-gray-700 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
还没有同城活动,发起第一场聚会
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeTab === "pros") {
|
||||
return (
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<Panel title="优点">
|
||||
<CheckList items={asStringArray(tab.pros)} tone="good" />
|
||||
</Panel>
|
||||
<Panel title="需要注意">
|
||||
<CheckList items={asStringArray(tab.cons)} tone="warn" />
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeTab === "reviews") {
|
||||
const items = asArray(tab.items);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{items.map((review, i) => (
|
||||
<Panel key={i} title={`${review.author || "社区成员"} · ${review.role || "数字游民"}`}>
|
||||
<p className="leading-7 text-gray-700 dark:text-gray-300">{String(review.text || "")}</p>
|
||||
<p className="mt-3 text-sm text-[#ff4d4f]">评分 {String(review.score || city.overallScore)}/5</p>
|
||||
</Panel>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeTab === "cost") {
|
||||
const breakdown = asArray(tab.breakdown);
|
||||
const total = Number(tab.monthlyTotal || city.costPerMonth);
|
||||
return (
|
||||
<Panel title={`月生活成本:¥${total.toLocaleString()}`}>
|
||||
<div className="space-y-3">
|
||||
{breakdown.map((item) => {
|
||||
const amount = Number(item.amount || 0);
|
||||
return (
|
||||
<div key={String(item.label)}>
|
||||
<div className="mb-1 flex justify-between text-sm text-gray-600 dark:text-gray-400">
|
||||
<span>{String(item.label)}</span>
|
||||
<span>¥{amount.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
<div className="h-full rounded-full bg-[#ff4d4f]" style={{ width: `${Math.min(100, (amount / total) * 100)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-gray-500 dark:text-gray-400">{String(tab.tip || "成本数据会随社区反馈持续校准。")}</p>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeTab === "people") {
|
||||
return (
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<Panel title={`${Number(tab.nomadsNow || city.nomadsNow).toLocaleString()} 位游民正在这里`}>
|
||||
<div className="space-y-3">
|
||||
{asArray(tab.personas).map((item) => (
|
||||
<InfoCard key={String(item.name)} label={String(item.name)} value={`${String(item.percent)}%`} />
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
<Panel title="相关访谈与工具">
|
||||
<RelatedContent items={content} compact />
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeTab === "chat") {
|
||||
return (
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<Panel title="同城频道">
|
||||
<div className="space-y-3">
|
||||
{asArray(tab.channels).map((channel) => (
|
||||
<InfoCard key={String(channel.name)} label={String(channel.name)} value={`${String(channel.members)} 成员 · ${String(channel.status)}`} />
|
||||
))}
|
||||
</div>
|
||||
<Link href="/join" className="mt-4 inline-flex rounded-full bg-[#ff4d4f] px-4 py-2 text-sm font-semibold text-white">加入社群</Link>
|
||||
</Panel>
|
||||
<Panel title="同城讨论">
|
||||
<div className="space-y-3">
|
||||
{discussions.map((topic) => (
|
||||
<Link key={String(topic.id)} href="/discuss" className="block rounded-xl bg-gray-50 p-3 text-sm text-gray-700 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
{String(topic.title)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeTab === "photos") {
|
||||
const photos = asStringArray(tab.items);
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{photos.map((src) => (
|
||||
<img key={src} src={src} alt={city.name} className="h-56 w-full rounded-2xl object-cover shadow-sm" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeTab === "weather") {
|
||||
return (
|
||||
<Panel title="天气与季节">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<InfoCard label="当前温度" value={`${String(tab.temperature || city.temperature)}°C`} />
|
||||
<InfoCard label="湿度" value={`${String(tab.humidity || city.humidity)}%`} />
|
||||
<InfoCard label="推荐月份" value={asStringArray(tab.bestMonths).join("、")} />
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-gray-500 dark:text-gray-400">{String(tab.note || "真实天气接口接入后会显示未来 7 天趋势。")}</p>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeTab === "trends") {
|
||||
return (
|
||||
<Panel title="社群增长趋势">
|
||||
<div className="flex h-48 items-end gap-3">
|
||||
{asArray(tab.growth).map((point) => {
|
||||
const value = Number(point.value || 0);
|
||||
return (
|
||||
<div key={String(point.month)} className="flex flex-1 flex-col items-center gap-2">
|
||||
<div className="w-full rounded-t-lg bg-[#ff4d4f]" style={{ height: `${Math.max(18, Math.min(100, value / 12))}%` }} />
|
||||
<span className="text-xs text-gray-400">{String(point.month)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-gray-500 dark:text-gray-400">{String(tab.insight || "趋势数据来自社区成员位置和活动报名聚合。")}</p>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeTab === "demographics") {
|
||||
return (
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<Panel title="年龄结构">
|
||||
{asArray(tab.age).map((item) => <InfoCard key={String(item.label)} label={String(item.label)} value={`${String(item.value)}%`} />)}
|
||||
</Panel>
|
||||
<Panel title="职业结构">
|
||||
{asArray(tab.work).map((item) => <InfoCard key={String(item.label)} label={String(item.label)} value={`${String(item.value)}%`} />)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <RelatedContent items={content} />;
|
||||
}
|
||||
|
||||
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-900">
|
||||
<h3 className="mb-3 font-bold text-gray-900 dark:text-gray-100">{title}</h3>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoCard({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="mb-2 rounded-xl bg-gray-50 p-3 dark:bg-gray-800">
|
||||
<p className="text-xs text-gray-400">{label}</p>
|
||||
<p className="mt-1 text-sm font-medium text-gray-800 dark:text-gray-200">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckList({ items, tone = "good" }: { items: string[]; tone?: "good" | "warn" }) {
|
||||
const icon = tone === "good" ? "✓" : "!";
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<div key={item} className="flex gap-2 rounded-xl bg-gray-50 p-3 text-sm text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||
<span className={tone === "good" ? "text-emerald-500" : "text-amber-500"}>{icon}</span>
|
||||
<span>{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RelatedContent({ items, compact = false }: { items: Array<Record<string, unknown>>; compact?: boolean }) {
|
||||
const hrefFor = (item: Record<string, unknown>) => {
|
||||
const type = String(item.type || "");
|
||||
const slug = String(item.slug || "");
|
||||
if (type === "ebook") return `/ebooks/${slug}`;
|
||||
if (type === "video") return `/videos/${slug}`;
|
||||
if (type === "app") return "/app";
|
||||
return String(item.targetUrl || "https://digital.hackrobot.cn/zh");
|
||||
};
|
||||
return (
|
||||
<Panel title="关联内容">
|
||||
<div className="space-y-3">
|
||||
{items.map((item) => {
|
||||
const href = hrefFor(item);
|
||||
const external = href.startsWith("http");
|
||||
const body = (
|
||||
<div className="flex gap-3 rounded-xl bg-gray-50 p-3 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700">
|
||||
{!compact && <img src={String(item.coverImage || "")} alt="" className="h-14 w-20 rounded-lg object-cover" />}
|
||||
<div className="min-w-0">
|
||||
<p className="line-clamp-1 text-sm font-semibold text-gray-900 dark:text-gray-100">{String(item.title)}</p>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-gray-500 dark:text-gray-400">{String(item.subtitle || "")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return external ? (
|
||||
<a key={String(item.slug)} href={href} target="_blank" rel="noopener noreferrer">{body}</a>
|
||||
) : (
|
||||
<Link key={String(item.slug)} href={href}>{body}</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getDeviceFromEnv,
|
||||
} from "@/app/lib/payment";
|
||||
import type { PayChannel, PayEnv } from "@/app/lib/payment";
|
||||
import { apiUrl } from "@/app/lib/api-client";
|
||||
|
||||
const MEDIA_NAMES_ZH = ["36氪", "少数派", "极客公园", "虎嗅", "创业邦", "澎湃新闻"];
|
||||
const MEDIA_NAMES_EN = ["36Kr", "sspai", "GeekPark", "Huxiu", "Cyzone", "The Paper"];
|
||||
@@ -72,7 +73,7 @@ export default function HeroSection() {
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/auth/me", { credentials: "include" });
|
||||
const res = await fetch(apiUrl("/api/auth/me"), { credentials: "include" });
|
||||
const data = await res.json();
|
||||
setIsLoggedIn(!!data?.user);
|
||||
} catch {
|
||||
@@ -108,7 +109,7 @@ export default function HeroSection() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const checkRes = await fetch("/api/meetup/check-user", {
|
||||
const checkRes = await fetch(apiUrl("/api/meetup/check-user"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: trimmed }),
|
||||
@@ -135,7 +136,7 @@ export default function HeroSection() {
|
||||
isPrivateDevHost(window.location.hostname);
|
||||
let user_id = buildPendingUserId(trimmed);
|
||||
if (shouldPrecreateUser) {
|
||||
const ensureRes = await fetch("/api/meetup/ensure-user", {
|
||||
const ensureRes = await fetch(apiUrl("/api/meetup/ensure-user"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: trimmed }),
|
||||
@@ -150,11 +151,11 @@ export default function HeroSection() {
|
||||
);
|
||||
}
|
||||
if (ensureData.is_new) {
|
||||
await fetch("/api/meetup/set-needs-password-change", { method: "POST", credentials: "include" });
|
||||
await fetch(apiUrl("/api/meetup/set-needs-password-change"), { method: "POST", credentials: "include" });
|
||||
}
|
||||
user_id = String(ensureData.user_id).trim();
|
||||
if (ensureData?.token && ensureData?.record) {
|
||||
await fetch("/api/auth/sync-session", {
|
||||
await fetch(apiUrl("/api/auth/sync-session"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token: ensureData.token, record: ensureData.record }),
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getDeviceFromEnv,
|
||||
} from "@/app/lib/payment";
|
||||
import type { PayChannel, PayEnv } from "@/app/lib/payment";
|
||||
import { apiUrl } from "@/app/lib/api-client";
|
||||
|
||||
interface JoinModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -44,7 +45,7 @@ export default function JoinModal({ isOpen, onClose, initialEmail = "", vipOnly
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/meetup/ensure-user", {
|
||||
const res = await fetch(apiUrl("/api/meetup/ensure-user"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: em, password: password || undefined }),
|
||||
@@ -55,11 +56,11 @@ export default function JoinModal({ isOpen, onClose, initialEmail = "", vipOnly
|
||||
throw new Error(data?.error || (locale === "zh" ? "操作失败" : "Operation failed"));
|
||||
}
|
||||
if (data.is_new) {
|
||||
await fetch("/api/meetup/set-needs-password-change", { method: "POST", credentials: "include" });
|
||||
await fetch(apiUrl("/api/meetup/set-needs-password-change"), { method: "POST", credentials: "include" });
|
||||
}
|
||||
const { pbSaveAuth } = await import("@/app/lib/pocketbase");
|
||||
pbSaveAuth(data.token, data.record);
|
||||
await fetch("/api/auth/sync-session", {
|
||||
await fetch(apiUrl("/api/auth/sync-session"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token: data.token, record: data.record }),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Link, useLocale, usePathname, useRouter, useTranslation } from "@/i18n/navigation";
|
||||
import { getStoredUserEmail, getStoredUser, getStoredToken, pbLogout } from "@/app/lib/pocketbase";
|
||||
import { apiUrl } from "@/app/lib/api-client";
|
||||
import { getNavLinks } from "@/config";
|
||||
import AuthModal from "./AuthModal";
|
||||
import ThemeToggle from "./ThemeToggle";
|
||||
@@ -25,7 +26,7 @@ export default function NavbarComponent() {
|
||||
|
||||
const fetchAuth = useCallback(async () => {
|
||||
try {
|
||||
let res = await fetch("/api/auth/me", { credentials: "include", cache: "no-store" });
|
||||
let res = await fetch(apiUrl("/api/auth/me"), { credentials: "include", cache: "no-store" });
|
||||
let data = await res.json();
|
||||
if (data?.user && data?.token) {
|
||||
const { pbSaveAuth } = await import("@/app/lib/pocketbase");
|
||||
@@ -37,14 +38,14 @@ export default function NavbarComponent() {
|
||||
const token = getStoredToken();
|
||||
const record = getStoredUser();
|
||||
if (token && record?.id && record?.email) {
|
||||
const syncRes = await fetch("/api/auth/sync-session", {
|
||||
const syncRes = await fetch(apiUrl("/api/auth/sync-session"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token, record }),
|
||||
credentials: "include",
|
||||
});
|
||||
if (syncRes.ok) {
|
||||
res = await fetch("/api/auth/me", { credentials: "include", cache: "no-store" });
|
||||
res = await fetch(apiUrl("/api/auth/me"), { credentials: "include", cache: "no-store" });
|
||||
data = await res.json();
|
||||
if (data?.user && data?.token) {
|
||||
setUserEmail(data.user.email);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
export interface City {
|
||||
id: number;
|
||||
idNumber?: number;
|
||||
slug?: string;
|
||||
name: string;
|
||||
nameEn?: string;
|
||||
country: string;
|
||||
countryEmoji: string;
|
||||
overallScore: number;
|
||||
|
||||
@@ -83,6 +83,7 @@ html {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-auto-rows: 180px;
|
||||
grid-auto-flow: row;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
@@ -98,7 +99,7 @@ html {
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.items-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr)) minmax(0, 1fr);
|
||||
grid-auto-rows: 280px;
|
||||
gap: 30px;
|
||||
padding: 20px 40px;
|
||||
@@ -107,12 +108,13 @@ html {
|
||||
|
||||
@media (min-width: 1440px) {
|
||||
.items-grid {
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr)) minmax(0, 1fr);
|
||||
gap: 35px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Pin right-items to the LAST explicit column */
|
||||
/* Reserve the first right-column rows for feature cards.
|
||||
City cards keep normal auto-placement and fill the right column after these rows. */
|
||||
.items-grid .right-item:nth-child(1) {
|
||||
grid-column: -2 / -1;
|
||||
grid-row: 1;
|
||||
@@ -125,6 +127,22 @@ html {
|
||||
grid-column: -2 / -1;
|
||||
grid-row: 3;
|
||||
}
|
||||
.items-grid .right-item:nth-child(4) {
|
||||
grid-column: -2 / -1;
|
||||
grid-row: 4;
|
||||
}
|
||||
.items-grid .right-item:nth-child(5) {
|
||||
grid-column: -2 / -1;
|
||||
grid-row: 5;
|
||||
}
|
||||
.items-grid .right-item:nth-child(6) {
|
||||
grid-column: -2 / -1;
|
||||
grid-row: 6;
|
||||
}
|
||||
.items-grid .right-item:nth-child(7) {
|
||||
grid-column: -2 / -1;
|
||||
grid-row: 7;
|
||||
}
|
||||
|
||||
/* ==================== City Card ==================== */
|
||||
.city-card {
|
||||
|
||||
29
app/lib/api-client.ts
Normal file
29
app/lib/api-client.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export const API_BASE =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL || "http://127.0.0.1:8000";
|
||||
|
||||
export function apiUrl(path: string): string {
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
return `${API_BASE}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(
|
||||
path: string,
|
||||
init?: RequestInit
|
||||
): Promise<T> {
|
||||
const res = await fetch(apiUrl(path), {
|
||||
credentials: "include",
|
||||
...init,
|
||||
headers:
|
||||
init?.body instanceof FormData
|
||||
? init.headers
|
||||
: {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers || {}),
|
||||
},
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(data?.detail || data?.error || `HTTP ${res.status}`);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import * as echarts from "echarts";
|
||||
import type { MapCity } from "./types";
|
||||
import { apiUrl } from "@/app/lib/api-client";
|
||||
|
||||
const CHINA_MAP_URL = "/api/geo/china";
|
||||
|
||||
@@ -30,7 +31,7 @@ export function MembersMap({
|
||||
|
||||
const init = async () => {
|
||||
try {
|
||||
const res = await fetch(CHINA_MAP_URL);
|
||||
const res = await fetch(apiUrl(CHINA_MAP_URL));
|
||||
const geoJson = await res.json();
|
||||
echarts.registerMap("china", geoJson);
|
||||
} catch {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { getPaymentConfig, getJoinPaymentConfig } from "@/config/services.config";
|
||||
import type { PayChannel, PayEnv } from "./types";
|
||||
import { apiUrl } from "@/app/lib/api-client";
|
||||
|
||||
export function redirectToPay(params: {
|
||||
user_id: string;
|
||||
@@ -21,7 +22,7 @@ export function redirectToPay(params: {
|
||||
|
||||
const payForm = document.createElement("form");
|
||||
payForm.method = "POST";
|
||||
payForm.action = "/api/pay";
|
||||
payForm.action = apiUrl("/api/pay");
|
||||
payForm.target = "_self";
|
||||
payForm.style.display = "none";
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { apiUrl } from "@/app/lib/api-client";
|
||||
|
||||
const COOKIE_NAME = "join_pay_order";
|
||||
const STORAGE_NAME = "join_pay_order";
|
||||
@@ -121,7 +122,7 @@ export function usePayStatusPoll(onPaid: (orderId: string) => void): boolean {
|
||||
inFlight = true;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/pay/status?order_id=${encodeURIComponent(orderId)}`,
|
||||
apiUrl(`/api/pay/status?order_id=${encodeURIComponent(orderId)}`),
|
||||
{ cache: "no-store" }
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { getPocketBaseConfig } from "@/config/services.config";
|
||||
import { PB_STORAGE_KEYS } from "./constants";
|
||||
import { apiUrl } from "@/app/lib/api-client";
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
@@ -24,15 +25,15 @@ export async function pbLogin(
|
||||
identity: string,
|
||||
password: string
|
||||
): Promise<AuthResult> {
|
||||
const url = getPBUrl();
|
||||
const res = await fetch(`${url}/api/collections/users/auth-with-password`, {
|
||||
const res = await fetch(apiUrl("/api/auth/login"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ identity, password }),
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ email: identity, password }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err?.message || "登录失败");
|
||||
throw new Error(err?.detail || err?.message || "登录失败");
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!data?.token) throw new Error("登录响应异常");
|
||||
@@ -44,21 +45,18 @@ export async function pbRegister(
|
||||
password: string,
|
||||
passwordConfirm: string
|
||||
): Promise<AuthResult> {
|
||||
const url = getPBUrl();
|
||||
const config = getPocketBaseConfig();
|
||||
const res = await fetch(
|
||||
`${url}/api/collections/${config.usersCollection}/records`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password, passwordConfirm }),
|
||||
}
|
||||
);
|
||||
const res = await fetch(apiUrl("/api/auth/register"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ email, password, passwordConfirm }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err?.message || "注册失败");
|
||||
throw new Error(err?.detail || err?.message || "注册失败");
|
||||
}
|
||||
return pbLogin(email, password);
|
||||
const data = await res.json();
|
||||
return { token: data.token, record: data.record };
|
||||
}
|
||||
|
||||
export function pbSaveAuth(token: string, record: AuthUser): void {
|
||||
@@ -79,7 +77,7 @@ export async function pbLogout(): Promise<void> {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
|
||||
await fetch(apiUrl("/api/auth/logout"), { method: "POST", credentials: "include" });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user