diff --git a/README.md b/README.md
index abe1d81..9255ef1 100644
--- a/README.md
+++ b/README.md
@@ -112,6 +112,8 @@ docker compose up -d
| 🎯 启动金目标 | 存款进度追踪,预估出发时间 |
| 🚀 旅居跑道 | 存款 ÷ 月开销,算出还能飞多久 |
| 🤝 会议黄金时段 | 双时区工作重叠可视化 |
+| ☀️ 日照办公窗 | 日出日落与深度工作建议 |
+| ⚖️ 行李重量 | 对照航司限额估算装备重量 |
| ✨ 今日金句 | 每日游民灵感语录 |
| 😴 时差估算 | 时差跨度与恢复期建议 |
| 🏡 住宿指南 | Villa / Coliving / 公寓方案对比 |
diff --git a/frontend/src/app/changelog/page.tsx b/frontend/src/app/changelog/page.tsx
index 137a479..6a6ebf7 100644
--- a/frontend/src/app/changelog/page.tsx
+++ b/frontend/src/app/changelog/page.tsx
@@ -12,9 +12,11 @@ const LOGS = [
date: "2026-08-29",
tag: "新工具",
items: [
+ "日照办公窗:按城市月份估算日出日落与深度工作时段",
+ "行李重量估算:勾选装备对照航司限额",
"旅居跑道计算器:存款 + 月开销一眼算出还能飞多久",
"会议黄金时段:双时区工作重叠可视化时间轴",
- "快捷工具条更新,直达跑道 / 会议 / 汇率",
+ "快捷工具条更新,直达日照 / 行李 / 跑道 / 会议",
],
},
{
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index 371c9a7..1356810 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -7872,3 +7872,243 @@ img { max-width: 100%; display: block; }
.meeting-bars { gap: 2px; }
.meeting-cell { min-height: 36px; }
}
+
+
+/* ===== Daylight Work Hours ===== */
+.daylight-card {
+ padding: 24px;
+ background: var(--bg-card);
+ border: var(--border-glass);
+ border-radius: var(--radius-xl);
+ backdrop-filter: blur(16px);
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+.daylight-controls {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+.daylight-controls label {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ max-width: 320px;
+}
+.daylight-controls label > span,
+.bag-extra > span {
+ font-size: 0.85rem;
+ color: var(--text-secondary);
+}
+.daylight-controls select {
+ background: rgba(255,255,255,0.04);
+ border: 1px solid rgba(255,255,255,0.1);
+ border-radius: var(--radius-sm);
+ color: var(--text-primary);
+ padding: 10px 12px;
+}
+.daylight-months {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+.daylight-month {
+ padding: 6px 10px;
+ border-radius: 999px;
+ border: 1px solid rgba(255,255,255,0.08);
+ background: rgba(255,255,255,0.03);
+ color: var(--text-secondary);
+ cursor: pointer;
+ font-size: 0.8rem;
+ transition: all 0.2s;
+}
+.daylight-month:hover,
+.daylight-month.active {
+ color: var(--text-primary);
+ border-color: rgba(255, 230, 109, 0.45);
+ background: rgba(255, 230, 109, 0.12);
+}
+.daylight-sky {
+ position: relative;
+ height: 72px;
+ border-radius: var(--radius-md);
+ background: linear-gradient(90deg, #0b1224, #1a2744 40%, #2a1f3d 70%, #0b1224);
+ overflow: hidden;
+ border: 1px solid rgba(255,255,255,0.06);
+}
+.daylight-band {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ background: linear-gradient(180deg, rgba(255,230,109,0.45), rgba(255,107,107,0.15));
+}
+.daylight-deep {
+ position: absolute;
+ top: 18px;
+ bottom: 18px;
+ background: rgba(78, 205, 196, 0.55);
+ border-radius: 6px;
+ box-shadow: 0 0 16px rgba(78, 205, 196, 0.35);
+}
+.daylight-markers {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+}
+.daylight-markers span {
+ position: absolute;
+ bottom: 6px;
+ transform: translateX(-50%);
+ font-size: 0.65rem;
+ color: rgba(241,245,249,0.7);
+}
+.daylight-stats {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 10px;
+ margin-top: 12px;
+}
+.daylight-stats small {
+ display: block;
+ color: var(--text-muted);
+ font-size: 0.75rem;
+}
+.daylight-stats strong {
+ font-family: var(--font-display);
+ font-size: 1.25rem;
+}
+.daylight-tips {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+}
+.daylight-tip {
+ display: flex;
+ gap: 12px;
+ padding: 14px;
+ border-radius: var(--radius-md);
+ background: rgba(255,255,255,0.03);
+ border: 1px solid rgba(255,255,255,0.06);
+}
+.daylight-tip > span { font-size: 1.4rem; }
+.daylight-tip strong { display: block; margin-bottom: 4px; font-size: 0.95rem; }
+.daylight-tip p { margin: 0; color: var(--text-secondary); font-size: 0.85rem; line-height: 1.45; }
+
+/* ===== Bag Weight ===== */
+.bag-card {
+ padding: 24px;
+ background: var(--bg-card);
+ border: var(--border-glass);
+ border-radius: var(--radius-xl);
+ backdrop-filter: blur(16px);
+ display: flex;
+ flex-direction: column;
+ gap: 18px;
+}
+.bag-gauge {
+ height: 10px;
+ border-radius: 999px;
+ background: rgba(255,255,255,0.08);
+ overflow: hidden;
+}
+.bag-gauge-fill {
+ height: 100%;
+ border-radius: 999px;
+ transition: width 0.35s ease, background 0.25s;
+}
+.bag-meter-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 12px;
+ margin-top: 10px;
+}
+.bag-meter-row strong {
+ font-family: var(--font-display);
+ font-size: 1.8rem;
+}
+.bag-meter-row small { color: var(--text-muted); }
+.bag-status { font-weight: 700; }
+.bag-tip { color: var(--text-secondary); font-size: 0.9rem; margin: 6px 0 0; }
+.bag-limits {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 8px;
+}
+.bag-limit {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 4px;
+ padding: 10px 6px;
+ border-radius: var(--radius-md);
+ border: 1px solid rgba(255,255,255,0.08);
+ background: rgba(255,255,255,0.03);
+ cursor: pointer;
+ color: inherit;
+ transition: all 0.2s;
+}
+.bag-limit:hover,
+.bag-limit.active {
+ border-color: rgba(78, 205, 196, 0.4);
+ background: rgba(78, 205, 196, 0.1);
+}
+.bag-limit em { font-style: normal; font-size: 0.8rem; font-weight: 600; }
+.bag-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 8px;
+}
+.bag-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 12px;
+ border-radius: var(--radius-md);
+ border: 1px solid rgba(255,255,255,0.08);
+ background: rgba(255,255,255,0.03);
+ cursor: pointer;
+ color: inherit;
+ text-align: left;
+ transition: all 0.2s;
+}
+.bag-item:hover { transform: translateY(-1px); }
+.bag-item.on {
+ border-color: rgba(78, 205, 196, 0.4);
+ background: rgba(78, 205, 196, 0.1);
+}
+.bag-item > span { font-size: 1.25rem; }
+.bag-item strong { display: block; font-size: 0.88rem; }
+.bag-item small { color: var(--text-muted); }
+.bag-item i {
+ margin-left: auto;
+ font-style: normal;
+ width: 22px;
+ height: 22px;
+ border-radius: 50%;
+ display: grid;
+ place-items: center;
+ background: rgba(255,255,255,0.08);
+ font-size: 0.75rem;
+}
+.bag-item.on i { background: var(--accent-3); color: #0a0e17; font-weight: 700; }
+.bag-extra {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ gap: 12px;
+ align-items: center;
+}
+.bag-extra input[type=range] { width: 100%; accent-color: var(--accent-3); }
+.bag-extra em { font-style: normal; font-weight: 600; color: var(--accent-3); min-width: 3.5rem; text-align: right; }
+
+@media (max-width: 900px) {
+ .daylight-tips { grid-template-columns: 1fr; }
+ .daylight-stats { grid-template-columns: repeat(2, 1fr); }
+ .bag-grid { grid-template-columns: repeat(2, 1fr); }
+ .bag-limits { grid-template-columns: repeat(2, 1fr); }
+}
+@media (max-width: 560px) {
+ .bag-grid { grid-template-columns: 1fr; }
+ .bag-extra { grid-template-columns: 1fr; }
+}
diff --git a/frontend/src/app/tools/page.tsx b/frontend/src/app/tools/page.tsx
index 174a3b4..a3bff3c 100644
--- a/frontend/src/app/tools/page.tsx
+++ b/frontend/src/app/tools/page.tsx
@@ -5,7 +5,7 @@ import Link from "next/link";
import { TOOL_CATEGORIES, TOOL_LINKS } from "@/lib/tools";
import SiteShell from "@/components/SiteShell";
-const FEATURED = ["runway", "meetings", "trip", "calculator", "visa", "focus"];
+const FEATURED = ["daylight", "bag-weight", "runway", "meetings", "trip", "focus"];
export default function ToolsHubPage() {
const [filter, setFilter] = useState("all");
@@ -41,7 +41,7 @@ export default function ToolsHubPage() {
✨ 本周推荐
- 新上线 · 跑道 & 会议窗口
+ 新上线 · 日照窗 & 行李重量
{featured.map((t) => (
diff --git a/frontend/src/components/BagWeightEstimator.tsx b/frontend/src/components/BagWeightEstimator.tsx
new file mode 100644
index 0000000..11a2632
--- /dev/null
+++ b/frontend/src/components/BagWeightEstimator.tsx
@@ -0,0 +1,136 @@
+"use client";
+
+import { useMemo, useState } from "react";
+
+interface Gear {
+ id: string;
+ emoji: string;
+ label: string;
+ kg: number;
+ group: "tech" | "wear" | "daily";
+}
+
+const GEAR: Gear[] = [
+ { id: "laptop", emoji: "💻", label: "笔记本电脑", kg: 1.4, group: "tech" },
+ { id: "charger", emoji: "🔌", label: "充电套装", kg: 0.4, group: "tech" },
+ { id: "tablet", emoji: "📱", label: "平板 / 手机", kg: 0.5, group: "tech" },
+ { id: "headset", emoji: "🎧", label: "耳机", kg: 0.3, group: "tech" },
+ { id: "camera", emoji: "📷", label: "相机", kg: 0.8, group: "tech" },
+ { id: "clothes", emoji: "👕", label: "衣物一周", kg: 2.5, group: "wear" },
+ { id: "shoes", emoji: "👟", label: "鞋 x2", kg: 1.2, group: "wear" },
+ { id: "jacket", emoji: "🧥", label: "外套", kg: 0.9, group: "wear" },
+ { id: "toiletry", emoji: "🧴", label: "洗漱包", kg: 0.6, group: "daily" },
+ { id: "meds", emoji: "💊", label: "药品杂物", kg: 0.4, group: "daily" },
+ { id: "book", emoji: "📖", label: "书籍 / 周边", kg: 0.7, group: "daily" },
+ { id: "bottle", emoji: "💧", label: "水瓶(空)", kg: 0.3, group: "daily" },
+];
+
+const LIMITS = [
+ { key: "cabin7", label: "随身 7kg", max: 7, emoji: "🎒" },
+ { key: "cabin10", label: "随身 10kg", max: 10, emoji: "🧳" },
+ { key: "check20", label: "托运 20kg", max: 20, emoji: "✈️" },
+ { key: "check23", label: "托运 23kg", max: 23, emoji: "🛫" },
+];
+
+export default function BagWeightEstimator() {
+ const [selected, setSelected] = useState
(["laptop", "charger", "headset", "clothes", "shoes", "toiletry"]);
+ const [limit, setLimit] = useState("cabin10");
+ const [extra, setExtra] = useState(0.5);
+
+ const total = useMemo(() => {
+ const base = GEAR.filter((g) => selected.includes(g.id)).reduce((s, g) => s + g.kg, 0);
+ return Math.round((base + extra) * 10) / 10;
+ }, [selected, extra]);
+
+ const lim = LIMITS.find((l) => l.key === limit)!;
+ const pct = Math.min((total / lim.max) * 100, 100);
+ const over = total > lim.max;
+ const room = Math.round((lim.max - total) * 10) / 10;
+
+ const toggle = (id: string) => {
+ setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
+ };
+
+ const status = over
+ ? { label: "超重预警", color: "#FF6B6B", tip: `超出约 ${Math.abs(room)} kg,建议精简或升舱额` }
+ : room <= 1
+ ? { label: "接近上限", color: "#FFE66D", tip: "余量很少,登机前再称一次" }
+ : { label: "轻装通过", color: "#4ECDC4", tip: `还可再装约 ${room} kg` };
+
+ return (
+
+
+
+
⚖️ BAG WEIGHT
+
行李重量估算
+
勾选装备,对照航司限额,出发前心里有数
+
+
+
+
+
+
+
+ {total} kg
+ / {lim.max} kg · {lim.emoji} {lim.label}
+
+
{status.label}
+
+
{status.tip}
+
+
+
+ {LIMITS.map((l) => (
+
+ ))}
+
+
+
+ {GEAR.map((g) => {
+ const on = selected.includes(g.id);
+ return (
+
+ );
+ })}
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/DaylightWorkHours.tsx b/frontend/src/components/DaylightWorkHours.tsx
new file mode 100644
index 0000000..08d251e
--- /dev/null
+++ b/frontend/src/components/DaylightWorkHours.tsx
@@ -0,0 +1,151 @@
+"use client";
+
+import { useMemo, useState } from "react";
+import type { Destination } from "@/lib/types";
+
+/** Approximate daylight by latitude + month (no API). */
+const CITY_META: Record = {
+ chiangmai: { lat: 18.8, tip: "热带全年日照长,午后雷阵雨多见" },
+ bali: { lat: -8.4, tip: "赤道附近昼夜接近 12h,早晚温和好出门" },
+ lisbon: { lat: 38.7, tip: "冬短夏长,夏季晚上 9 点仍明亮" },
+ barcelona: { lat: 41.4, tip: "地中海日照充沛,冬日仍可户外办公" },
+ mexico: { lat: 19.4, tip: "高原日照强,注意防晒与补水" },
+ tokyo: { lat: 35.7, tip: "四季分明,冬季日落早适合室内专注" },
+};
+
+function approxDayLength(lat: number, month: number): number {
+ const m = month - 1;
+ const decl = 23.44 * Math.sin(((284 + month * 30.4) * Math.PI) / 180);
+ const latRad = (lat * Math.PI) / 180;
+ const declRad = (decl * Math.PI) / 180;
+ const cosH = -Math.tan(latRad) * Math.tan(declRad);
+ const clamped = Math.max(-1, Math.min(1, cosH));
+ const h = Math.acos(clamped);
+ void m;
+ return (2 * h * 12) / Math.PI;
+}
+
+function fmtHour(h: number): string {
+ const hh = Math.floor(h) % 24;
+ const mm = Math.round((h - Math.floor(h)) * 60);
+ return `${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}`;
+}
+
+interface Props {
+ destinations: Destination[];
+}
+
+export default function DaylightWorkHours({ destinations }: Props) {
+ const [slug, setSlug] = useState(destinations[0]?.slug || "chiangmai");
+ const [month, setMonth] = useState(new Date().getMonth() + 1);
+
+ const meta = CITY_META[slug] || { lat: 20, tip: "选择城市查看日照与办公建议" };
+ const dayLen = useMemo(() => approxDayLength(meta.lat, month), [meta.lat, month]);
+ const sunrise = 12 - dayLen / 2;
+ const sunset = 12 + dayLen / 2;
+ const deepStart = Math.max(sunrise + 1.5, 8);
+ const deepEnd = Math.min(sunset - 1, 17);
+ const outdoorOk = dayLen >= 11;
+
+ const months = Array.from({ length: 12 }, (_, i) => i + 1);
+ const dest = destinations.find((d) => d.slug === slug);
+
+ const score =
+ dayLen >= 13 ? { label: "长日照", color: "#FFE66D" } :
+ dayLen >= 11 ? { label: "均衡", color: "#4ECDC4" } :
+ { label: "短日照", color: "#A78BFA" };
+
+ return (
+
+
+
+
☀️ DAYLIGHT
+
日照办公窗
+
按城市与月份估算日出日落,安排深度工作与户外时间
+
+
+
+
+
+
+ {months.map((m) => (
+
+ ))}
+
+
+
+
+
+
+
+
+ {[0, 6, 12, 18, 24].map((h) => (
+ {fmtHour(h)}
+ ))}
+
+
+
+
+
+ 日出
+ {fmtHour(sunrise)}
+
+
+ 日落
+ {fmtHour(sunset)}
+
+
+ 昼长
+ {dayLen.toFixed(1)}h
+
+
+ 状态
+ {score.label}
+
+
+
+
+
+
+
💻
+
+
深度工作建议
+
{fmtHour(deepStart)} – {fmtHour(deepEnd)} · 自然光充足且会议较少的时段
+
+
+
+
{outdoorOk ? "🚶" : "🏠"}
+
+
{outdoorOk ? "适合户外散步会议" : "偏室内节奏"}
+
{meta.tip}{dest ? ` · ${dest.emoji} ${dest.name}` : ""}
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/HomeClient.tsx b/frontend/src/components/HomeClient.tsx
index 5595693..77bc88f 100644
--- a/frontend/src/components/HomeClient.tsx
+++ b/frontend/src/components/HomeClient.tsx
@@ -42,6 +42,7 @@ const TaxDayCounter = lazy(() => import("./TaxDayCounter"));
const FlightCost = lazy(() => import("./FlightCost"));
const CurrencyConverter = lazy(() => import("./CurrencyConverter"));
const MeetingWindow = lazy(() => import("./MeetingWindow"));
+const DaylightWorkHours = lazy(() => import("./DaylightWorkHours"));
const WifiWorkScore = lazy(() => import("./WifiWorkScore"));
const FocusTimer = lazy(() => import("./FocusTimer"));
const NomadScore = lazy(() => import("./NomadScore"));
@@ -59,6 +60,7 @@ const PowerPlugGuide = lazy(() => import("./PowerPlugGuide"));
const CafeGuide = lazy(() => import("./CafeGuide"));
const SeasonGuide = lazy(() => import("./SeasonGuide"));
const PackingChecklist = lazy(() => import("./PackingChecklist"));
+const BagWeightEstimator = lazy(() => import("./BagWeightEstimator"));
const ArrivalChecklist = lazy(() => import("./ArrivalChecklist"));
const Phrasebook = lazy(() => import("./Phrasebook"));
const CityNotes = lazy(() => import("./CityNotes"));
@@ -173,6 +175,7 @@ export default function HomeClient(props: Props) {
+
@@ -190,6 +193,7 @@ export default function HomeClient(props: Props) {
+
diff --git a/frontend/src/components/ToolsStrip.tsx b/frontend/src/components/ToolsStrip.tsx
index 56ea1a2..2444c55 100644
--- a/frontend/src/components/ToolsStrip.tsx
+++ b/frontend/src/components/ToolsStrip.tsx
@@ -4,12 +4,12 @@ import Link from "next/link";
const QUICK = [
{ href: "/#runway", emoji: "🚀", label: "跑道" },
+ { href: "/#daylight", emoji: "☀️", label: "日照" },
{ href: "/#meetings", emoji: "🤝", label: "会议" },
+ { href: "/#bag-weight", emoji: "⚖️", label: "行李" },
{ href: "/#trip", emoji: "🗓️", label: "行程" },
{ href: "/#calculator", emoji: "🧮", label: "费用" },
- { href: "/#visa", emoji: "📋", label: "签证" },
{ href: "/#focus", emoji: "⏱️", label: "专注" },
- { href: "/#currency", emoji: "💱", label: "汇率" },
{ href: "/tools", emoji: "🛠️", label: "全部" },
];
diff --git a/frontend/src/lib/tools.ts b/frontend/src/lib/tools.ts
index 8d5ef32..0202f88 100644
--- a/frontend/src/lib/tools.ts
+++ b/frontend/src/lib/tools.ts
@@ -12,6 +12,7 @@ export const TOOL_LINKS: ToolLink[] = [
{ id: "spin", emoji: "🎲", title: "命运转盘", desc: "随机抽下一站", href: "/#spin", category: "plan" },
{ id: "matcher", emoji: "🎯", title: "智能匹配", desc: "问卷推荐目的地", href: "/#destinations", category: "plan" },
{ id: "season", emoji: "🌤️", title: "最佳月份", desc: "避开雨季与旺季", href: "/#season", category: "plan" },
+ { id: "daylight", emoji: "☀️", title: "日照办公窗", desc: "日出日落与深度工作", href: "/#daylight", category: "work" },
{ id: "calculator", emoji: "🧮", title: "费用计算器", desc: "按住宿档次估算", href: "/#calculator", category: "money" },
{ id: "cost-compare", emoji: "💰", title: "省钱对比", desc: "家乡 vs 旅居成本", href: "/#cost-compare", category: "money" },
{ id: "savings", emoji: "🎯", title: "启动金目标", desc: "存款进度追踪", href: "/#savings", category: "money" },
@@ -32,6 +33,7 @@ export const TOOL_LINKS: ToolLink[] = [
{ id: "sim", emoji: "📱", title: "上网方案", desc: "SIM / eSIM 推荐", href: "/#sim", category: "life" },
{ id: "plugs", emoji: "🔌", title: "电源插座", desc: "电压与插头类型", href: "/#plugs", category: "life" },
{ id: "packing", emoji: "🧳", title: "行李清单", desc: "出发前逐项勾选", href: "/#packing", category: "life" },
+ { id: "bag-weight", emoji: "⚖️", title: "行李重量", desc: "对照航司限额估算", href: "/#bag-weight", category: "life" },
{ id: "arrival", emoji: "🛬", title: "到站清单", desc: "落地第一周", href: "/#arrival", category: "life" },
{ id: "phrases", emoji: "🗣️", title: "生存短语", desc: "常用语一键复制", href: "/#phrases", category: "life" },
{ id: "notes", emoji: "📝", title: "城市笔记", desc: "本地私密记录", href: "/#notes", category: "life" },