Add /plan move hub: timeline, budget, visa stay hints, and readiness checklist.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
d857d5b9b2
commit
b6df330a18
@ -96,7 +96,7 @@ docker compose up -d
|
|||||||
| 🌍 目的地 | 筛选/搜索/排序,收藏,最多 4 城对比 |
|
| 🌍 目的地 | 筛选/搜索/排序,收藏,最多 4 城对比 |
|
||||||
| 🎯 智能匹配 | 4 步问卷,推荐最适合的旅居城市 |
|
| 🎯 智能匹配 | 4 步问卷,推荐最适合的旅居城市 |
|
||||||
| 🕐 时区看板 | 实时时钟 + 与国内团队工作时间重叠分析 |
|
| 🕐 时区看板 | 实时时钟 + 与国内团队工作时间重叠分析 |
|
||||||
| 🗓️ 行程规划 | 多城市规划,预算汇总,分享/导入链接 |
|
| 🗓️ 行程规划 | 首页快速规划;完整能力见「旅居计划中心」 |
|
||||||
| 🧮 费用计算器 | 按目的地、住宿档次、月数估算预算 |
|
| 🧮 费用计算器 | 按目的地、住宿档次、月数估算预算 |
|
||||||
| 💱 多币种换算 | CNY/USD/EUR/THB 等 8 种货币互转 |
|
| 💱 多币种换算 | CNY/USD/EUR/THB 等 8 种货币互转 |
|
||||||
| 📊 就绪度测评 | 5 题评估你的数字游民准备程度 |
|
| 📊 就绪度测评 | 5 题评估你的数字游民准备程度 |
|
||||||
@ -136,6 +136,7 @@ docker compose up -d
|
|||||||
| 📝 城市笔记 | 按城本地记录避坑与灵感 |
|
| 📝 城市笔记 | 按城本地记录避坑与灵感 |
|
||||||
| ⏱️ 专注时钟 | 番茄钟深度工作计时 |
|
| ⏱️ 专注时钟 | 番茄钟深度工作计时 |
|
||||||
| 🛠️ 工具箱页 | `/tools` 分类搜索全部工具 |
|
| 🛠️ 工具箱页 | `/tools` 分类搜索全部工具 |
|
||||||
|
| 🗓️ 旅居计划中心 | `/plan` 时间轴、预算、签证提醒、出发清单、分享导出 |
|
||||||
| 📜 更新日志 | `/changelog` 迭代记录 |
|
| 📜 更新日志 | `/changelog` 迭代记录 |
|
||||||
| 🏧 取现避坑 | ATM / DCC / 换汇建议 |
|
| 🏧 取现避坑 | ATM / DCC / 换汇建议 |
|
||||||
| 🥗 饮食饮水 | 各城饮水与街头饮食注意 |
|
| 🥗 饮食饮水 | 各城饮水与街头饮食注意 |
|
||||||
@ -193,7 +194,9 @@ docker compose up -d
|
|||||||
| `/` | 首页(全部板块) |
|
| `/` | 首页(全部板块) |
|
||||||
| `/login` | 登录 / 注册 |
|
| `/login` | 登录 / 注册 |
|
||||||
| `/profile` | 用户中心 |
|
| `/profile` | 用户中心 |
|
||||||
| `/destinations/[slug]` | 目的地详情(城市画像、加入行程) |
|
| `/destinations/[slug]` | 目的地详情(城市画像、加入计划) |
|
||||||
|
| `/plan` | 旅居计划中心(时间轴 / 预算 / 清单 / 分享) |
|
||||||
|
| `/tools` | 工具箱聚合 |
|
||||||
| `/blog/[slug]` | 博客详情(阅读进度) |
|
| `/blog/[slug]` | 博客详情(阅读进度) |
|
||||||
|
|
||||||
## 环境变量
|
## 环境变量
|
||||||
|
|||||||
@ -8,6 +8,15 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const LOGS = [
|
const LOGS = [
|
||||||
|
{
|
||||||
|
date: "2026-08-29",
|
||||||
|
tag: "核心功能",
|
||||||
|
items: [
|
||||||
|
"全新「旅居计划中心」/plan:多城时间轴、出发月推算、站内备注、停留签证提醒",
|
||||||
|
"月预算上限对照、出发就绪清单、行程内城市对比、分享/导出 Markdown",
|
||||||
|
"导航与目的地「加入计划」、个人中心、全局搜索统一指向计划中心",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
date: "2026-08-29",
|
date: "2026-08-29",
|
||||||
tag: "大更新",
|
tag: "大更新",
|
||||||
|
|||||||
@ -8680,3 +8680,320 @@ img { max-width: 100%; display: block; }
|
|||||||
.kit-row, .deposit-top, .deposit-grid, .kit-grid-3 { grid-template-columns: 1fr; }
|
.kit-row, .deposit-top, .deposit-grid, .kit-grid-3 { grid-template-columns: 1fr; }
|
||||||
.kit-stat { text-align: left; }
|
.kit-stat { text-align: left; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Move Plan Hub (/plan) ===== */
|
||||||
|
.plan-page {
|
||||||
|
padding: 100px 0 80px;
|
||||||
|
min-height: 70vh;
|
||||||
|
}
|
||||||
|
.plan-loading {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 48px 0;
|
||||||
|
}
|
||||||
|
.plan-hero {
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.plan-hero p {
|
||||||
|
max-width: 640px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 8px 0 20px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.plan-title-input {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 720px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: clamp(1.8rem, 4vw, 2.6rem);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
padding: 4px 0;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
}
|
||||||
|
.plan-title-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-bottom-color: var(--accent-2);
|
||||||
|
}
|
||||||
|
.plan-meta-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.plan-meta-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.plan-meta-field input {
|
||||||
|
min-width: 180px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: var(--border-glass);
|
||||||
|
background: var(--bg-card);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.plan-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.plan-stat {
|
||||||
|
padding: 16px 18px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: var(--border-glass);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.plan-stat span {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.plan-stat strong {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
.plan-warn-text { color: var(--accent-1) !important; }
|
||||||
|
.plan-alert {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--accent-1) 40%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--accent-1) 12%, transparent);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.plan-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.6fr) minmax(280px, 0.9fr);
|
||||||
|
gap: 24px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.plan-panel {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: var(--border-glass);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
padding: 22px 24px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
.plan-panel h2 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.plan-panel-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.plan-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.plan-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 16px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.plan-empty-icon { font-size: 2.8rem; display: block; margin-bottom: 8px; }
|
||||||
|
.plan-empty .btn { margin-top: 12px; }
|
||||||
|
.plan-timeline {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.plan-stop {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 44px 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.plan-stop-rail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.plan-stop-dot {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: var(--bg-secondary, rgba(255,255,255,0.06));
|
||||||
|
border: var(--border-glass);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.plan-stop-line {
|
||||||
|
width: 2px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 24px;
|
||||||
|
background: linear-gradient(to bottom, var(--accent-2), transparent);
|
||||||
|
opacity: 0.5;
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
.plan-stop-body {
|
||||||
|
padding-bottom: 22px;
|
||||||
|
}
|
||||||
|
.plan-stop-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.plan-stop-top strong a {
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.plan-stop-top strong a:hover { color: var(--accent-2); }
|
||||||
|
.plan-stop-country { font-weight: 500; color: var(--text-secondary); }
|
||||||
|
.plan-stop-range {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--accent-2);
|
||||||
|
}
|
||||||
|
.plan-stop-tools {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.plan-stop-tools button {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: var(--border-glass);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.plan-stop-tools button:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||||
|
.plan-stop-tools button:not(:disabled):hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--accent-2);
|
||||||
|
}
|
||||||
|
.plan-remove:hover { color: var(--accent-1) !important; }
|
||||||
|
.plan-stop-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 10px 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.plan-stop-controls input {
|
||||||
|
width: 56px;
|
||||||
|
margin: 0 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: var(--border-glass);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.plan-stop-cost {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.plan-stop-unit { font-size: 0.8rem; }
|
||||||
|
.plan-note {
|
||||||
|
width: 100%;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 56px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: var(--border-glass);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font: inherit;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.plan-visa {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.plan-visa.warn {
|
||||||
|
color: var(--accent-1);
|
||||||
|
}
|
||||||
|
.plan-compare-wrap { overflow-x: auto; }
|
||||||
|
.plan-compare {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
.plan-compare th,
|
||||||
|
.plan-compare td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 8px;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--text-secondary) 20%, transparent);
|
||||||
|
}
|
||||||
|
.plan-compare th { color: var(--text-secondary); font-weight: 500; }
|
||||||
|
.plan-ready-pill {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--accent-2);
|
||||||
|
}
|
||||||
|
.plan-ready-bar {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 99px;
|
||||||
|
background: color-mix(in srgb, var(--text-secondary) 20%, transparent);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.plan-ready-bar > div {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(90deg, var(--accent-1), var(--accent-2));
|
||||||
|
transition: width 0.25s ease;
|
||||||
|
}
|
||||||
|
.plan-check-group { margin-bottom: 14px; }
|
||||||
|
.plan-check-group h3 {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
.plan-check-group ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.plan-check-group label {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.plan-check-group input { margin-top: 3px; accent-color: var(--accent-2); }
|
||||||
|
.plan-check-group strong { display: block; font-weight: 600; }
|
||||||
|
.plan-check-group small {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.plan-side .btn { width: 100%; margin-top: 8px; }
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.plan-stats { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.plan-layout { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.plan-stats { grid-template-columns: 1fr 1fr; }
|
||||||
|
.plan-page { padding-top: 88px; }
|
||||||
|
}
|
||||||
|
|||||||
27
frontend/src/app/plan/page.tsx
Normal file
27
frontend/src/app/plan/page.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { Destination } from "@/lib/types";
|
||||||
|
import MovePlanClient from "@/components/MovePlanClient";
|
||||||
|
import SiteShell from "@/components/SiteShell";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "旅居计划中心 · nomadro",
|
||||||
|
description: "多城时间轴、预算与月均、签证停留提醒、出发就绪清单——一页做完旅居规划",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const revalidate = 60;
|
||||||
|
|
||||||
|
export default async function PlanPage() {
|
||||||
|
let destinations: Destination[] = [];
|
||||||
|
try {
|
||||||
|
destinations = await api.getDestinations();
|
||||||
|
} catch {
|
||||||
|
destinations = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SiteShell showFooter>
|
||||||
|
<MovePlanClient destinations={destinations} />
|
||||||
|
</SiteShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -144,7 +144,7 @@ export default function ProfilePage() {
|
|||||||
<div className="profile-empty">
|
<div className="profile-empty">
|
||||||
<span style={{ fontSize: "2.5rem" }}>🗺️</span>
|
<span style={{ fontSize: "2.5rem" }}>🗺️</span>
|
||||||
<p>还没有规划行程</p>
|
<p>还没有规划行程</p>
|
||||||
<Link href="/#trip" className="btn btn-primary">去规划 →</Link>
|
<Link href="/plan" className="btn btn-primary">去规划 →</Link>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="profile-trip-list">
|
<div className="profile-trip-list">
|
||||||
@ -162,7 +162,7 @@ export default function ProfilePage() {
|
|||||||
总计 <strong>{trip.reduce((s, t) => s + t.months, 0)} 个月</strong> ·
|
总计 <strong>{trip.reduce((s, t) => s + t.months, 0)} 个月</strong> ·
|
||||||
<strong className="gradient-text"> ¥{trip.reduce((s, t) => s + t.cost * t.months, 0).toLocaleString()}</strong>
|
<strong className="gradient-text"> ¥{trip.reduce((s, t) => s + t.cost * t.months, 0).toLocaleString()}</strong>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/#trip" className="btn btn-ghost">编辑行程 →</Link>
|
<Link href="/plan" className="btn btn-ghost">打开旅居计划中心 →</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@ -171,10 +171,10 @@ export default function ProfilePage() {
|
|||||||
<h2>🚀 快捷入口</h2>
|
<h2>🚀 快捷入口</h2>
|
||||||
<div className="profile-quick-grid">
|
<div className="profile-quick-grid">
|
||||||
<Link href="/#destinations" className="quick-card">🌍 浏览目的地</Link>
|
<Link href="/#destinations" className="quick-card">🌍 浏览目的地</Link>
|
||||||
|
<Link href="/plan" className="quick-card">🗓️ 旅居计划</Link>
|
||||||
<Link href="/#visa" className="quick-card">📋 签证指南</Link>
|
<Link href="/#visa" className="quick-card">📋 签证指南</Link>
|
||||||
<Link href="/#coworking" className="quick-card">💻 联合办公</Link>
|
<Link href="/#coworking" className="quick-card">💻 联合办公</Link>
|
||||||
<Link href="/#blog" className="quick-card">📝 阅读博客</Link>
|
<Link href="/#blog" className="quick-card">📝 阅读博客</Link>
|
||||||
<Link href="/#trip" className="quick-card">🗓️ 行程规划</Link>
|
|
||||||
<Link href="/#nomad-score" className="quick-card">📊 就绪度测评</Link>
|
<Link href="/#nomad-score" className="quick-card">📊 就绪度测评</Link>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -42,10 +42,10 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
|
|||||||
toast("该城市已在行程中", "info");
|
toast("该城市已在行程中", "info");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
trip.push({ slug: dest.slug, name: dest.name, country: dest.country, emoji: dest.emoji, cost: dest.cost, months: 1 });
|
trip.push({ slug: dest.slug, name: dest.name, country: dest.country, emoji: dest.emoji, cost: dest.cost, months: 1, note: "" });
|
||||||
saveTrip(trip);
|
saveTrip(trip);
|
||||||
setAdded(true);
|
setAdded(true);
|
||||||
toast(`${dest.emoji} ${dest.name} 已加入行程 🗓️`);
|
toast(`${dest.emoji} ${dest.name} 已加入旅居计划`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const share = async () => {
|
const share = async () => {
|
||||||
@ -66,12 +66,14 @@ export default function DestinationDetailClient({ dest, allDestinations }: Props
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="dest-detail-actions">
|
<div className="dest-detail-actions">
|
||||||
<button className={`btn ${added ? "btn-ghost" : "btn-primary"}`} onClick={addToTrip} disabled={added}>
|
{added ? (
|
||||||
{added ? "✓ 已在行程中" : "🗓️ 加入行程"}
|
<Link href="/plan" className="btn btn-primary">✓ 已在计划中 · 打开 →</Link>
|
||||||
</button>
|
) : (
|
||||||
|
<button className="btn btn-primary" onClick={addToTrip}>🗓️ 加入旅居计划</button>
|
||||||
|
)}
|
||||||
<button className="btn btn-ghost" onClick={share}>📤 分享</button>
|
<button className="btn btn-ghost" onClick={share}>📤 分享</button>
|
||||||
|
<Link href="/plan" className="btn btn-ghost">🗓️ 计划中心</Link>
|
||||||
<Link href={`/#calculator`} className="btn btn-ghost">🧮 算费用</Link>
|
<Link href={`/#calculator`} className="btn btn-ghost">🧮 算费用</Link>
|
||||||
<Link href={`/#timezone`} className="btn btn-ghost">🕐 看时区</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dest-detail-extras">
|
<div className="dest-detail-extras">
|
||||||
|
|||||||
@ -16,6 +16,7 @@ const TYPE_LABELS: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const PAGE_RESULTS: SearchResult[] = [
|
const PAGE_RESULTS: SearchResult[] = [
|
||||||
|
{ type: "page", title: "旅居计划中心", subtitle: "时间轴、预算、签证提醒与出发清单", emoji: "🗓️", url: "/plan" },
|
||||||
{ type: "page", title: "工具箱", subtitle: "全部旅居工具入口", emoji: "🛠️", url: "/tools" },
|
{ type: "page", title: "工具箱", subtitle: "全部旅居工具入口", emoji: "🛠️", url: "/tools" },
|
||||||
{ type: "page", title: "关于 nomadro", subtitle: "品牌与联系方式", emoji: "🌏", url: "/about" },
|
{ type: "page", title: "关于 nomadro", subtitle: "品牌与联系方式", emoji: "🌏", url: "/about" },
|
||||||
{ type: "page", title: "更新日志", subtitle: "功能迭代记录", emoji: "📜", url: "/changelog" },
|
{ type: "page", title: "更新日志", subtitle: "功能迭代记录", emoji: "📜", url: "/changelog" },
|
||||||
|
|||||||
@ -11,6 +11,7 @@ const SHORTCUTS = [
|
|||||||
{ keys: ["M"], label: "智能匹配", icon: "🎯" },
|
{ keys: ["M"], label: "智能匹配", icon: "🎯" },
|
||||||
{ keys: ["G"], label: "命运转盘", icon: "🎲" },
|
{ keys: ["G"], label: "命运转盘", icon: "🎲" },
|
||||||
{ keys: ["T"], label: "打开工具箱", icon: "🛠️" },
|
{ keys: ["T"], label: "打开工具箱", icon: "🛠️" },
|
||||||
|
{ keys: ["P"], label: "旅居计划中心", icon: "🗓️" },
|
||||||
{ keys: ["?"], label: "快捷键帮助", icon: "⌨️" },
|
{ keys: ["?"], label: "快捷键帮助", icon: "⌨️" },
|
||||||
{ keys: ["Esc"], label: "关闭弹窗", icon: "✕" },
|
{ keys: ["Esc"], label: "关闭弹窗", icon: "✕" },
|
||||||
{ keys: ["↑", "↓"], label: "搜索导航", icon: "↕️" },
|
{ keys: ["↑", "↓"], label: "搜索导航", icon: "↕️" },
|
||||||
@ -41,6 +42,10 @@ export default function KeyboardShortcuts({ onOpenMatcher }: Props) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
window.location.href = "/tools";
|
window.location.href = "/tools";
|
||||||
}
|
}
|
||||||
|
if (e.key === "p" && !typing && !e.metaKey && !e.ctrlKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
window.location.href = "/plan";
|
||||||
|
}
|
||||||
if (e.key === "Escape" && open) setOpen(false);
|
if (e.key === "Escape" && open) setOpen(false);
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
|
|||||||
446
frontend/src/components/MovePlanClient.tsx
Normal file
446
frontend/src/components/MovePlanClient.tsx
Normal file
@ -0,0 +1,446 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useToast } from "@/lib/toast";
|
||||||
|
import { loadTrip, saveTrip } from "@/lib/tripStorage";
|
||||||
|
import {
|
||||||
|
loadPlanMeta,
|
||||||
|
savePlanMeta,
|
||||||
|
rangeLabel,
|
||||||
|
stayHint,
|
||||||
|
MOVE_CHECKLIST,
|
||||||
|
type PlanMeta,
|
||||||
|
} from "@/lib/planMeta";
|
||||||
|
import type { Destination, TripItem } from "@/lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
destinations: Destination[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreDest(d: Destination) {
|
||||||
|
const nomads = parseInt(d.nomads_count.replace(/[^0-9]/g, ""), 10) || 0;
|
||||||
|
return {
|
||||||
|
value: Math.round((12000 - d.cost) / 120),
|
||||||
|
speed: Math.round(d.speed / 2),
|
||||||
|
climate: Math.round(100 - Math.abs(d.temperature - 24) * 4),
|
||||||
|
rating: Math.round(d.rating * 10),
|
||||||
|
community: Math.min(Math.round(nomads / 150), 100),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MovePlanClient({ destinations }: Props) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [trip, setTrip] = useState<TripItem[]>([]);
|
||||||
|
const [meta, setMeta] = useState<PlanMeta>(() => ({
|
||||||
|
title: "我的旅居计划",
|
||||||
|
startMonth: "",
|
||||||
|
monthlyBudget: 0,
|
||||||
|
checklist: {},
|
||||||
|
}));
|
||||||
|
const [selected, setSelected] = useState("");
|
||||||
|
const [months, setMonths] = useState(1);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const shared = params.get("trip");
|
||||||
|
if (shared) {
|
||||||
|
try {
|
||||||
|
const decoded = JSON.parse(atob(shared)) as TripItem[];
|
||||||
|
if (Array.isArray(decoded) && decoded.length > 0) {
|
||||||
|
setTrip(decoded);
|
||||||
|
saveTrip(decoded);
|
||||||
|
toast("已导入分享的行程");
|
||||||
|
window.history.replaceState({}, "", "/plan");
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
} else {
|
||||||
|
setTrip(loadTrip<TripItem>());
|
||||||
|
}
|
||||||
|
setMeta(loadPlanMeta());
|
||||||
|
setReady(true);
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
|
const persistTrip = (items: TripItem[]) => {
|
||||||
|
setTrip(items);
|
||||||
|
saveTrip(items);
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistMeta = (next: PlanMeta) => {
|
||||||
|
setMeta(next);
|
||||||
|
savePlanMeta(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalCost = trip.reduce((s, t) => s + t.cost * t.months, 0);
|
||||||
|
const totalMonths = trip.reduce((s, t) => s + t.months, 0);
|
||||||
|
const avgMonth = totalMonths > 0 ? Math.round(totalCost / totalMonths) : 0;
|
||||||
|
|
||||||
|
const checkedCount = MOVE_CHECKLIST.filter((c) => meta.checklist[c.id]).length;
|
||||||
|
const readiness = Math.round((checkedCount / MOVE_CHECKLIST.length) * 100);
|
||||||
|
|
||||||
|
const budgetOk = meta.monthlyBudget <= 0 || avgMonth <= 0 || avgMonth <= meta.monthlyBudget;
|
||||||
|
|
||||||
|
const timeline = useMemo(() => {
|
||||||
|
let offset = 0;
|
||||||
|
return trip.map((t) => {
|
||||||
|
const label = rangeLabel(meta.startMonth, offset, t.months);
|
||||||
|
const row = { ...t, range: label, offset, visa: stayHint(t.country, t.months) };
|
||||||
|
offset += t.months;
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
}, [trip, meta.startMonth]);
|
||||||
|
|
||||||
|
const compareRows = useMemo(() => {
|
||||||
|
return trip
|
||||||
|
.map((t) => destinations.find((d) => d.slug === t.slug))
|
||||||
|
.filter((d): d is Destination => !!d)
|
||||||
|
.map((d) => ({ dest: d, scores: scoreDest(d) }));
|
||||||
|
}, [trip, destinations]);
|
||||||
|
|
||||||
|
const checklistByCat = useMemo(() => {
|
||||||
|
const map = new Map<string, typeof MOVE_CHECKLIST>();
|
||||||
|
for (const item of MOVE_CHECKLIST) {
|
||||||
|
const list = map.get(item.category) || [];
|
||||||
|
list.push(item);
|
||||||
|
map.set(item.category, list);
|
||||||
|
}
|
||||||
|
return [...map.entries()];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addCity = () => {
|
||||||
|
const dest = destinations.find((d) => d.slug === selected);
|
||||||
|
if (!dest || trip.some((t) => t.slug === dest.slug)) return;
|
||||||
|
persistTrip([
|
||||||
|
...trip,
|
||||||
|
{
|
||||||
|
slug: dest.slug,
|
||||||
|
name: dest.name,
|
||||||
|
country: dest.country,
|
||||||
|
emoji: dest.emoji,
|
||||||
|
cost: dest.cost,
|
||||||
|
months,
|
||||||
|
note: "",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setSelected("");
|
||||||
|
setMonths(1);
|
||||||
|
toast(`${dest.emoji} ${dest.name} 已加入计划`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateItem = (slug: string, patch: Partial<TripItem>) =>
|
||||||
|
persistTrip(trip.map((t) => (t.slug === slug ? { ...t, ...patch } : t)));
|
||||||
|
|
||||||
|
const removeCity = (slug: string) => persistTrip(trip.filter((t) => t.slug !== slug));
|
||||||
|
|
||||||
|
const move = (index: number, dir: -1 | 1) => {
|
||||||
|
const next = index + dir;
|
||||||
|
if (next < 0 || next >= trip.length) return;
|
||||||
|
const copy = [...trip];
|
||||||
|
[copy[index], copy[next]] = [copy[next], copy[index]];
|
||||||
|
persistTrip(copy);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCheck = (id: string) => {
|
||||||
|
persistMeta({
|
||||||
|
...meta,
|
||||||
|
checklist: { ...meta.checklist, [id]: !meta.checklist[id] },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const shareTrip = async () => {
|
||||||
|
const encoded = btoa(JSON.stringify(trip));
|
||||||
|
const url = `${window.location.origin}/plan?trip=${encoded}`;
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
toast("分享链接已复制");
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportTrip = () => {
|
||||||
|
const lines = [
|
||||||
|
`# ${meta.title}`,
|
||||||
|
meta.startMonth ? `出发月:${meta.startMonth}` : "",
|
||||||
|
"",
|
||||||
|
...timeline.map((t, i) => {
|
||||||
|
const range = t.range ? `(${t.range})` : "";
|
||||||
|
const note = t.note ? `\n 备注:${t.note}` : "";
|
||||||
|
return `${i + 1}. ${t.emoji} **${t.name}, ${t.country}** — ${t.months} 个月 · ¥${(t.cost * t.months).toLocaleString()}${range}${note}`;
|
||||||
|
}),
|
||||||
|
"",
|
||||||
|
`> 总计 **${totalMonths} 个月** · **¥${totalCost.toLocaleString()}** · 均月 ¥${avgMonth.toLocaleString()}`,
|
||||||
|
`> 出发就绪度 **${readiness}%**(${checkedCount}/${MOVE_CHECKLIST.length})`,
|
||||||
|
"",
|
||||||
|
`_由 nomadro 旅居计划中心生成 · ${new Date().toLocaleDateString("zh-CN")}_`,
|
||||||
|
].filter(Boolean);
|
||||||
|
const blob = new Blob([lines.join("\n")], { type: "text/markdown;charset=utf-8" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `nomadro-plan-${Date.now()}.md`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
toast("已下载 Markdown 计划");
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyPlain = async () => {
|
||||||
|
const text = timeline
|
||||||
|
.map((t, i) => `${i + 1}. ${t.emoji} ${t.name}, ${t.country} — ${t.months}个月 ¥${(t.cost * t.months).toLocaleString()}${t.range ? ` (${t.range})` : ""}`)
|
||||||
|
.join("\n") + `\n\n总计: ${totalMonths}个月 · ¥${totalCost.toLocaleString()} · 就绪 ${readiness}%`;
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast("计划已复制");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!ready) {
|
||||||
|
return (
|
||||||
|
<div className="plan-page">
|
||||||
|
<div className="container">
|
||||||
|
<p className="plan-loading">加载计划中…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="plan-page">
|
||||||
|
<div className="container">
|
||||||
|
<nav className="detail-nav">
|
||||||
|
<Link href="/">← 返回首页</Link>
|
||||||
|
<Link href="/#destinations">浏览目的地</Link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<header className="plan-hero">
|
||||||
|
<span className="section-tag">🗓️ MOVE PLAN</span>
|
||||||
|
<input
|
||||||
|
className="plan-title-input"
|
||||||
|
value={meta.title}
|
||||||
|
onChange={(e) => persistMeta({ ...meta, title: e.target.value || "我的旅居计划" })}
|
||||||
|
aria-label="计划标题"
|
||||||
|
/>
|
||||||
|
<p>把多城路线、预算、签证提醒和出发清单放在一页——真正能拿去执行的旅居计划。</p>
|
||||||
|
<div className="plan-meta-row">
|
||||||
|
<label className="plan-meta-field">
|
||||||
|
<span>预计出发月</span>
|
||||||
|
<input
|
||||||
|
type="month"
|
||||||
|
value={meta.startMonth}
|
||||||
|
onChange={(e) => persistMeta({ ...meta, startMonth: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="plan-meta-field">
|
||||||
|
<span>月预算上限(¥)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={500}
|
||||||
|
placeholder="如 8000"
|
||||||
|
value={meta.monthlyBudget || ""}
|
||||||
|
onChange={(e) => persistMeta({ ...meta, monthlyBudget: Math.max(0, +e.target.value || 0) })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="plan-stats">
|
||||||
|
<div className="plan-stat">
|
||||||
|
<span>城市</span>
|
||||||
|
<strong>{trip.length}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="plan-stat">
|
||||||
|
<span>总时长</span>
|
||||||
|
<strong>{totalMonths} 月</strong>
|
||||||
|
</div>
|
||||||
|
<div className="plan-stat">
|
||||||
|
<span>总预算</span>
|
||||||
|
<strong className="gradient-text">¥{totalCost.toLocaleString()}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="plan-stat">
|
||||||
|
<span>均月开销</span>
|
||||||
|
<strong className={!budgetOk ? "plan-warn-text" : undefined}>
|
||||||
|
¥{avgMonth.toLocaleString()}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="plan-stat">
|
||||||
|
<span>出发就绪</span>
|
||||||
|
<strong>{readiness}%</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!budgetOk && (
|
||||||
|
<div className="plan-alert" role="status">
|
||||||
|
均月开销 ¥{avgMonth.toLocaleString()} 已超过你的月预算上限 ¥{meta.monthlyBudget.toLocaleString()}。
|
||||||
|
可缩短高消费城市停留,或调高预算。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="plan-layout">
|
||||||
|
<div className="plan-main">
|
||||||
|
<section className="plan-panel">
|
||||||
|
<div className="plan-panel-head">
|
||||||
|
<h2>路线时间轴</h2>
|
||||||
|
<div className="plan-actions">
|
||||||
|
<button type="button" className="btn btn-ghost btn-sm" onClick={copyPlain} disabled={!trip.length}>复制</button>
|
||||||
|
<button type="button" className="btn btn-ghost btn-sm" onClick={exportTrip} disabled={!trip.length}>导出 MD</button>
|
||||||
|
<button type="button" className="btn btn-ghost btn-sm" onClick={shareTrip} disabled={!trip.length}>分享</button>
|
||||||
|
<button type="button" className="btn btn-ghost btn-sm" onClick={() => { persistTrip([]); toast("已清空行程"); }} disabled={!trip.length}>清空</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{trip.length === 0 ? (
|
||||||
|
<div className="plan-empty">
|
||||||
|
<span className="plan-empty-icon">🗺️</span>
|
||||||
|
<p>还没有城市。从目的地加入,或在下方添加第一站。</p>
|
||||||
|
<Link href="/#destinations" className="btn btn-primary">去选目的地 →</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ol className="plan-timeline">
|
||||||
|
{timeline.map((t, i) => (
|
||||||
|
<li key={t.slug} className="plan-stop">
|
||||||
|
<div className="plan-stop-rail">
|
||||||
|
<span className="plan-stop-dot">{t.emoji}</span>
|
||||||
|
{i < timeline.length - 1 && <span className="plan-stop-line" />}
|
||||||
|
</div>
|
||||||
|
<div className="plan-stop-body">
|
||||||
|
<div className="plan-stop-top">
|
||||||
|
<div>
|
||||||
|
<strong>
|
||||||
|
<Link href={`/destinations/${t.slug}`}>{t.name}</Link>
|
||||||
|
<span className="plan-stop-country">, {t.country}</span>
|
||||||
|
</strong>
|
||||||
|
{t.range && <span className="plan-stop-range">{t.range}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="plan-stop-tools">
|
||||||
|
<button type="button" aria-label="上移" disabled={i === 0} onClick={() => move(i, -1)}>↑</button>
|
||||||
|
<button type="button" aria-label="下移" disabled={i === trip.length - 1} onClick={() => move(i, 1)}>↓</button>
|
||||||
|
<button type="button" className="plan-remove" aria-label="移除" onClick={() => removeCity(t.slug)}>✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="plan-stop-controls">
|
||||||
|
<label>
|
||||||
|
停留
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={24}
|
||||||
|
value={t.months}
|
||||||
|
onChange={(e) => updateItem(t.slug, { months: Math.max(1, Math.min(24, +e.target.value || 1)) })}
|
||||||
|
/>
|
||||||
|
月
|
||||||
|
</label>
|
||||||
|
<span className="plan-stop-cost">¥{(t.cost * t.months).toLocaleString()}</span>
|
||||||
|
<span className="plan-stop-unit">约 ¥{t.cost.toLocaleString()}/月</span>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
className="plan-note"
|
||||||
|
rows={2}
|
||||||
|
placeholder="备注:住房、签证节点、必办事项…"
|
||||||
|
value={t.note || ""}
|
||||||
|
onChange={(e) => updateItem(t.slug, { note: e.target.value })}
|
||||||
|
/>
|
||||||
|
{t.visa && (
|
||||||
|
<p className={`plan-visa${t.visa.overLimit ? " warn" : ""}`}>
|
||||||
|
🛂 {t.visa.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{compareRows.length >= 2 && (
|
||||||
|
<section className="plan-panel">
|
||||||
|
<div className="plan-panel-head">
|
||||||
|
<h2>行程内城市对比</h2>
|
||||||
|
</div>
|
||||||
|
<div className="plan-compare-wrap">
|
||||||
|
<table className="plan-compare">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>城市</th>
|
||||||
|
<th>月费</th>
|
||||||
|
<th>网速</th>
|
||||||
|
<th>气候</th>
|
||||||
|
<th>评分</th>
|
||||||
|
<th>社区</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{compareRows.map(({ dest, scores }) => (
|
||||||
|
<tr key={dest.slug}>
|
||||||
|
<td>{dest.emoji} {dest.name}</td>
|
||||||
|
<td>¥{dest.cost.toLocaleString()}</td>
|
||||||
|
<td>{Math.min(scores.speed, 100)}</td>
|
||||||
|
<td>{Math.min(scores.climate, 100)}</td>
|
||||||
|
<td>{Math.min(scores.rating, 100)}</td>
|
||||||
|
<td>{scores.community}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="plan-side">
|
||||||
|
<section className="plan-panel">
|
||||||
|
<h2>添加城市</h2>
|
||||||
|
<div className="calc-field">
|
||||||
|
<label>目的地</label>
|
||||||
|
<select value={selected} onChange={(e) => setSelected(e.target.value)}>
|
||||||
|
<option value="">请选择…</option>
|
||||||
|
{destinations
|
||||||
|
.filter((d) => !trip.some((t) => t.slug === d.slug))
|
||||||
|
.map((d) => (
|
||||||
|
<option key={d.slug} value={d.slug}>
|
||||||
|
{d.emoji} {d.name}, {d.country} · ¥{d.cost.toLocaleString()}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="calc-field">
|
||||||
|
<label>停留月数:{months}</label>
|
||||||
|
<input type="range" min={1} max={12} value={months} onChange={(e) => setMonths(+e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-primary" disabled={!selected} onClick={addCity}>
|
||||||
|
加入时间轴
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="plan-panel">
|
||||||
|
<div className="plan-panel-head">
|
||||||
|
<h2>出发就绪清单</h2>
|
||||||
|
<span className="plan-ready-pill">{checkedCount}/{MOVE_CHECKLIST.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="plan-ready-bar" aria-hidden>
|
||||||
|
<div style={{ width: `${readiness}%` }} />
|
||||||
|
</div>
|
||||||
|
{checklistByCat.map(([cat, items]) => (
|
||||||
|
<div key={cat} className="plan-check-group">
|
||||||
|
<h3>{cat}</h3>
|
||||||
|
<ul>
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.id}>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!meta.checklist[item.id]}
|
||||||
|
onChange={() => toggleCheck(item.id)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>{item.label}</strong>
|
||||||
|
<small>{item.hint}</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -8,7 +8,7 @@ import { useAuth } from "@/lib/auth";
|
|||||||
const LINKS = [
|
const LINKS = [
|
||||||
{ href: "/#map", label: "🗺️ 地图", section: "map" },
|
{ href: "/#map", label: "🗺️ 地图", section: "map" },
|
||||||
{ href: "/#destinations", label: "🌍 目的地", section: "destinations" },
|
{ href: "/#destinations", label: "🌍 目的地", section: "destinations" },
|
||||||
{ href: "/#trip", label: "🗓️ 行程", section: "trip" },
|
{ href: "/plan", label: "🗓️ 计划", section: "plan" },
|
||||||
{ href: "/#calculator", label: "🧮 计算器", section: "calculator" },
|
{ href: "/#calculator", label: "🧮 计算器", section: "calculator" },
|
||||||
{ href: "/tools", label: "🛠️ 工具箱", section: "tools" },
|
{ href: "/tools", label: "🛠️ 工具箱", section: "tools" },
|
||||||
{ href: "/#visa", label: "📋 签证", section: "visa" },
|
{ href: "/#visa", label: "📋 签证", section: "visa" },
|
||||||
@ -86,8 +86,8 @@ export default function Navbar() {
|
|||||||
{LINKS.map((l) => (
|
{LINKS.map((l) => (
|
||||||
<Link key={l.section} href={l.href} data-section={l.section}
|
<Link key={l.section} href={l.href} data-section={l.section}
|
||||||
className={
|
className={
|
||||||
l.href === "/tools"
|
l.href === "/tools" || l.href === "/plan"
|
||||||
? (pathname === "/tools" ? "active" : "")
|
? (pathname === l.href ? "active" : "")
|
||||||
: (isHome && active === l.section ? "active" : "")
|
: (isHome && active === l.section ? "active" : "")
|
||||||
}
|
}
|
||||||
onClick={() => setMenuOpen(false)}>{l.label}</Link>
|
onClick={() => setMenuOpen(false)}>{l.label}</Link>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import { useToast } from "@/lib/toast";
|
import { useToast } from "@/lib/toast";
|
||||||
import { loadTrip, saveTrip } from "@/lib/tripStorage";
|
import { loadTrip, saveTrip } from "@/lib/tripStorage";
|
||||||
import type { Destination, TripItem } from "@/lib/types";
|
import type { Destination, TripItem } from "@/lib/types";
|
||||||
@ -41,7 +42,7 @@ export default function TripPlanner({ destinations }: Props) {
|
|||||||
if (!dest || trip.some((t) => t.slug === dest.slug)) return;
|
if (!dest || trip.some((t) => t.slug === dest.slug)) return;
|
||||||
save([...trip, {
|
save([...trip, {
|
||||||
slug: dest.slug, name: dest.name, country: dest.country,
|
slug: dest.slug, name: dest.name, country: dest.country,
|
||||||
emoji: dest.emoji, cost: dest.cost, months,
|
emoji: dest.emoji, cost: dest.cost, months, note: "",
|
||||||
}]);
|
}]);
|
||||||
setSelected("");
|
setSelected("");
|
||||||
setMonths(1);
|
setMonths(1);
|
||||||
@ -56,7 +57,7 @@ export default function TripPlanner({ destinations }: Props) {
|
|||||||
|
|
||||||
const shareTrip = async () => {
|
const shareTrip = async () => {
|
||||||
const encoded = btoa(JSON.stringify(trip));
|
const encoded = btoa(JSON.stringify(trip));
|
||||||
const url = `${window.location.origin}/?trip=${encoded}#trip`;
|
const url = `${window.location.origin}/plan?trip=${encoded}`;
|
||||||
await navigator.clipboard.writeText(url);
|
await navigator.clipboard.writeText(url);
|
||||||
toast("分享链接已复制!发送给好友即可导入行程 🔗");
|
toast("分享链接已复制!发送给好友即可导入行程 🔗");
|
||||||
};
|
};
|
||||||
@ -97,7 +98,10 @@ export default function TripPlanner({ destinations }: Props) {
|
|||||||
<div className="section-header reveal">
|
<div className="section-header reveal">
|
||||||
<span className="section-tag">🗓️ TRIP PLANNER</span>
|
<span className="section-tag">🗓️ TRIP PLANNER</span>
|
||||||
<h2>行程规划器</h2>
|
<h2>行程规划器</h2>
|
||||||
<p>规划你的环球旅居路线,自动计算总预算</p>
|
<p>快速搭路线看预算;完整时间轴、签证提醒与出发清单请用计划中心</p>
|
||||||
|
<Link href="/plan" className="btn btn-primary" style={{ marginTop: 12 }}>
|
||||||
|
打开旅居计划中心 →
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="trip-wrapper reveal">
|
<div className="trip-wrapper reveal">
|
||||||
<div className="trip-form">
|
<div className="trip-form">
|
||||||
|
|||||||
@ -66,7 +66,7 @@ export default function WorldMap({ destinations }: Props) {
|
|||||||
<Link href={`/destinations/${selected.slug}`} className="btn btn-primary btn-sm">
|
<Link href={`/destinations/${selected.slug}`} className="btn btn-primary btn-sm">
|
||||||
查看详情 →
|
查看详情 →
|
||||||
</Link>
|
</Link>
|
||||||
<Link href={`/#trip`} className="btn btn-ghost btn-sm">🗓️ 加入行程</Link>
|
<Link href="/plan" className="btn btn-ghost btn-sm">🗓️ 加入计划</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
125
frontend/src/lib/planMeta.ts
Normal file
125
frontend/src/lib/planMeta.ts
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
const META_KEYS = ["nomadro-plan-meta", "nomadflow-plan-meta"] as const;
|
||||||
|
const PRIMARY = META_KEYS[0];
|
||||||
|
|
||||||
|
export interface PlanMeta {
|
||||||
|
title: string;
|
||||||
|
/** YYYY-MM,用于推算各站大致起止月 */
|
||||||
|
startMonth: string;
|
||||||
|
/** 用户月预算上限(¥),0 表示未设置 */
|
||||||
|
monthlyBudget: number;
|
||||||
|
checklist: Record<string, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChecklistItem {
|
||||||
|
id: string;
|
||||||
|
category: string;
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MOVE_CHECKLIST: ChecklistItem[] = [
|
||||||
|
{ id: "passport", category: "证件签证", label: "护照有效期 ≥ 6 个月", hint: "部分国家按落地日算" },
|
||||||
|
{ id: "visa-research", category: "证件签证", label: "确认入境方式与停留上限", hint: "免签 / 落地签 / DNV" },
|
||||||
|
{ id: "insurance", category: "证件签证", label: "国际医疗保险已生效", hint: "含急诊与转运" },
|
||||||
|
{ id: "flights", category: "行程落地", label: "首段机票 / 大巴已订", hint: "留弹性改签空间" },
|
||||||
|
{ id: "housing", category: "行程落地", label: "首月住宿已确认", hint: "押金与退订政策看清" },
|
||||||
|
{ id: "sim", category: "行程落地", label: "落地 SIM / eSIM 方案就绪", hint: "机场柜台或提前激活" },
|
||||||
|
{ id: "bank-notify", category: "金钱安全", label: "告知银行境外消费", hint: "避免风控锁卡" },
|
||||||
|
{ id: "cash-buffer", category: "金钱安全", label: "现金缓冲 ≥ 2 周开销", hint: "卡挂失时救急" },
|
||||||
|
{ id: "backup-docs", category: "金钱安全", label: "护照/签证云备份", hint: "加密网盘 + 离线照片" },
|
||||||
|
{ id: "client-notice", category: "远程工作", label: "客户已知时区与响应窗口", hint: "写清 overlap 时段" },
|
||||||
|
{ id: "vpn-tools", category: "远程工作", label: "VPN / 2FA / 办公软件可用", hint: "出发前测一遍" },
|
||||||
|
{ id: "emergency", category: "远程工作", label: "紧急联系人与领馆信息", hint: "存在手机与纸质" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const COUNTRY_STAY: Record<string, { days: number; note: string }> = {
|
||||||
|
Thailand: { days: 60, note: "免签常见约 60 天;超期需延期或出境" },
|
||||||
|
Indonesia: { days: 30, note: "落地签常见 30 天,可按规定延期" },
|
||||||
|
Portugal: { days: 90, note: "申根 90/180 天累计,勿只看单次" },
|
||||||
|
Spain: { days: 90, note: "申根 90/180 天累计" },
|
||||||
|
Japan: { days: 90, note: "免签通常最长约 90 天" },
|
||||||
|
Mexico: { days: 180, note: "入境许可常见最长约 180 天" },
|
||||||
|
Vietnam: { days: 45, note: "免签天数常变,出发前务必核实" },
|
||||||
|
Malaysia: { days: 90, note: "多数国籍免签约 90 天" },
|
||||||
|
"South Korea": { days: 90, note: "免签常见约 90 天" },
|
||||||
|
"United States": { days: 90, note: "ESTA 等多为 90 天,不可远程打工身份" },
|
||||||
|
Colombia: { days: 90, note: "常见 90 天,可延长至约 180 天" },
|
||||||
|
Georgia: { days: 365, note: "多数国籍免签最长约 1 年" },
|
||||||
|
"United Arab Emirates": { days: 30, note: "落地签常见短签,远程身份另议" },
|
||||||
|
Germany: { days: 90, note: "申根 90/180;长期需签证" },
|
||||||
|
France: { days: 90, note: "申根 90/180;长期需签证" },
|
||||||
|
Italy: { days: 90, note: "申根 90/180;长期需签证" },
|
||||||
|
Croatia: { days: 90, note: "申根相关规则以出发前官方为准" },
|
||||||
|
Hungary: { days: 90, note: "申根 90/180" },
|
||||||
|
Czechia: { days: 90, note: "申根 90/180" },
|
||||||
|
"Czech Republic": { days: 90, note: "申根 90/180" },
|
||||||
|
Taiwan: { days: 90, note: "免签政策因护照而异" },
|
||||||
|
Singapore: { days: 30, note: "停留偏短,适合中转或短居" },
|
||||||
|
Philippines: { days: 30, note: "常见 30 天,可延期" },
|
||||||
|
Cambodia: { days: 30, note: "落地签常见 30 天" },
|
||||||
|
"Costa Rica": { days: 90, note: "常见 90 天旅游停留" },
|
||||||
|
Argentina: { days: 90, note: "常见 90 天" },
|
||||||
|
Brazil: { days: 90, note: "常见 90 天" },
|
||||||
|
Turkey: { days: 90, note: "常见 90 天,政策因护照而异" },
|
||||||
|
India: { days: 30, note: "电子签常见短签,远程工作另核" },
|
||||||
|
Nepal: { days: 30, note: "落地签常见,按档购买天数" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_META: PlanMeta = {
|
||||||
|
title: "我的旅居计划",
|
||||||
|
startMonth: "",
|
||||||
|
monthlyBudget: 0,
|
||||||
|
checklist: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function loadPlanMeta(): PlanMeta {
|
||||||
|
if (typeof window === "undefined") return { ...DEFAULT_META, checklist: {} };
|
||||||
|
for (const key of META_KEYS) {
|
||||||
|
const raw = localStorage.getItem(key);
|
||||||
|
if (!raw) continue;
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(raw) as Partial<PlanMeta>;
|
||||||
|
const meta: PlanMeta = {
|
||||||
|
title: typeof data.title === "string" && data.title.trim() ? data.title : DEFAULT_META.title,
|
||||||
|
startMonth: typeof data.startMonth === "string" ? data.startMonth : "",
|
||||||
|
monthlyBudget: typeof data.monthlyBudget === "number" ? data.monthlyBudget : 0,
|
||||||
|
checklist: data.checklist && typeof data.checklist === "object" ? data.checklist : {},
|
||||||
|
};
|
||||||
|
if (key !== PRIMARY) {
|
||||||
|
localStorage.setItem(PRIMARY, JSON.stringify(meta));
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
return meta;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
return { ...DEFAULT_META, checklist: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function savePlanMeta(meta: PlanMeta) {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
localStorage.setItem(PRIMARY, JSON.stringify(meta));
|
||||||
|
localStorage.removeItem("nomadflow-plan-meta");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 由出发月 + 各站月数推算大致区间文案 */
|
||||||
|
export function rangeLabel(startMonth: string, offsetMonths: number, durationMonths: number): string {
|
||||||
|
if (!/^\d{4}-\d{2}$/.test(startMonth)) return "";
|
||||||
|
const [y, m] = startMonth.split("-").map(Number);
|
||||||
|
const start = new Date(y, m - 1 + offsetMonths, 1);
|
||||||
|
const end = new Date(y, m - 1 + offsetMonths + durationMonths, 0);
|
||||||
|
const fmt = (d: Date) => `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
return `${fmt(start)} – ${fmt(end)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stayHint(country: string, months: number): { text: string; overLimit: boolean } | null {
|
||||||
|
const info = COUNTRY_STAY[country];
|
||||||
|
if (!info) return null;
|
||||||
|
const plannedDays = Math.round(months * 30);
|
||||||
|
if (plannedDays > info.days) {
|
||||||
|
return {
|
||||||
|
overLimit: true,
|
||||||
|
text: `计划约 ${plannedDays} 天,常见停留上限约 ${info.days} 天。${info.note}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { overLimit: false, text: `常见停留约 ${info.days} 天内 · ${info.note}` };
|
||||||
|
}
|
||||||
@ -125,6 +125,8 @@ export interface TripItem {
|
|||||||
emoji: string;
|
emoji: string;
|
||||||
cost: number;
|
cost: number;
|
||||||
months: number;
|
months: number;
|
||||||
|
/** 该站备注:住房意向、签证节点等 */
|
||||||
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChartData {
|
export interface ChartData {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user