From a1daa040ef2b191a6c56263ea6d00ca08c857e70 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 7 Jun 2026 18:06:52 +0800 Subject: [PATCH] s --- app/[locale]/app/page.tsx | 19 +- app/[locale]/changelog/page.tsx | 81 ++++ app/[locale]/dashboard/page.tsx | 8 - app/[locale]/dating/page.tsx | 9 +- .../digital/app/AppDownloadContent.tsx | 175 +++++++-- .../[moduleIndex]/[lessonIndex]/page.tsx | 303 +++++++++++++-- app/[locale]/digital/jobs/JobsContent.tsx | 57 ++- app/[locale]/ebooks/[slug]/page.tsx | 12 +- app/[locale]/page.tsx | 80 +++- app/[locale]/videos/[slug]/page.tsx | 10 +- app/api/digital/jobs/route.ts | 112 ++++++ app/components/CityModal.tsx | 5 +- app/components/ContentSubmissionModal.tsx | 352 ++++++++++++++++++ app/components/Sidebar.tsx | 28 +- app/components/SiteMenu.tsx | 12 +- app/components/ThemeToggle.tsx | 18 - app/context/ThemeContext.tsx | 11 +- app/digital/components/Community.tsx | 39 +- app/digital/components/Footer.tsx | 14 +- app/digital/components/ResourceNavigation.tsx | 36 +- app/digital/lib/course-payment.ts | 6 +- app/globals.css | 285 ++++++++------ app/manifest.ts | 28 ++ backend/init_db.py | 25 ++ backend/main.py | 223 ++++++++++- backend/seed_data.py | 4 +- config/digital/course.ts | 4 +- config/digital/lessons.ts | 314 +++++++++++----- config/digital/visual-theme.config.ts | 40 +- .../themes/digital-nomad/messages/en.json | 14 +- .../themes/digital-nomad/messages/zh.json | 14 +- messages/en.json | 10 +- messages/zh.json | 8 +- 33 files changed, 1917 insertions(+), 439 deletions(-) create mode 100644 app/[locale]/changelog/page.tsx create mode 100644 app/api/digital/jobs/route.ts create mode 100644 app/components/ContentSubmissionModal.tsx create mode 100644 app/manifest.ts diff --git a/app/[locale]/app/page.tsx b/app/[locale]/app/page.tsx index 8c1064a..ab927f5 100644 --- a/app/[locale]/app/page.tsx +++ b/app/[locale]/app/page.tsx @@ -17,6 +17,22 @@ interface ContentItem { chapters?: { title: string; icon?: string }[]; } +function normalizeDownloadUrl(url?: string): string { + const fallback = "/api/downloads/nomadcna-app-demo.zip"; + if (!url) return fallback; + if (url.startsWith("/")) return url; + + try { + const parsed = new URL(url); + if ((parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost") && parsed.pathname.startsWith("/api/")) { + return `${parsed.pathname}${parsed.search}`; + } + return url; + } catch { + return fallback; + } +} + export default function AppLandingPage() { const locale = useLocale(); const [item, setItem] = useState(null); @@ -30,6 +46,7 @@ export default function AppLandingPage() { 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) : ""; + const downloadUrl = normalizeDownloadUrl(item?.mediaUrl); return (
@@ -41,7 +58,7 @@ export default function AppLandingPage() {

{subtitle}

{description}

- + 下载客户端包 diff --git a/app/[locale]/changelog/page.tsx b/app/[locale]/changelog/page.tsx new file mode 100644 index 0000000..dfa4371 --- /dev/null +++ b/app/[locale]/changelog/page.tsx @@ -0,0 +1,81 @@ +import Footer from "@/app/components/Footer"; + +const entries = [ + { + date: "2026-06-07", + title: "数字游民指南视觉统一", + items: [ + "将数字指南页对齐首页品牌色、导航、按钮与卡片风格。", + "修复全量 lint 中的 React hook error。", + "补齐主菜单与数字页 footer 的占位链接。", + ], + }, + { + date: "2026-06-01", + title: "社区工作流联通", + items: [ + "上线站内消息、活动报名、反馈、赏金任务与服务咨询入口。", + "完善城市详情弹窗的活动、讨论、成本和成员信息模块。", + ], + }, + { + date: "2026-05-20", + title: "数据化下一站", + items: [ + "新增下一站推荐、城市数据、成员地图和年度报告页面。", + "接入后端 API 与 PocketBase 数据源,保留前端降级数据。", + ], + }, +]; + +export const metadata = { + title: "更新日志 | 游牧中国", + description: "游牧中国产品更新、修复和功能上线记录。", +}; + +export default function ChangelogPage() { + return ( + <> +
+
+
+

+ + 更新日志 +

+

+ 记录产品功能、体验优化、线上修复和数据联通进展。 +

+
+ +
+ {entries.map((entry) => ( +
+
+

+ {entry.title} +

+ +
+
    + {entry.items.map((item) => ( +
  • + + {item} +
  • + ))} +
+
+ ))} +
+
+
+
+ + ); +} diff --git a/app/[locale]/dashboard/page.tsx b/app/[locale]/dashboard/page.tsx index 7efdad5..c310633 100644 --- a/app/[locale]/dashboard/page.tsx +++ b/app/[locale]/dashboard/page.tsx @@ -91,7 +91,6 @@ export default function DashboardPage() { const [recommendedCities, setRecommendedCities] = useState([]); const [meetups, setMeetups] = useState([]); const [routes, setRoutes] = useState([]); - const [loading, setLoading] = useState(false); useEffect(() => { apiFetch<{ items: RecommendedCity[] }>("/api/cities") @@ -129,7 +128,6 @@ export default function DashboardPage() { 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 }), @@ -141,9 +139,6 @@ export default function DashboardPage() { }) .catch(() => { if (!cancelled) setRecommendedCities(fallbackRecommendations); - }) - .finally(() => { - if (!cancelled) setLoading(false); }); return () => { cancelled = true; @@ -299,9 +294,6 @@ export default function DashboardPage() {

{t("search")}

- {loading && ( - 正在计算 - )}
{displayCities.map((city, index) => ( diff --git a/app/[locale]/dating/page.tsx b/app/[locale]/dating/page.tsx index f3a8f66..2d39605 100644 --- a/app/[locale]/dating/page.tsx +++ b/app/[locale]/dating/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useMemo, useRef, useState } from "react"; -import { useTranslation } from "@/i18n/navigation"; +import { Link, useTranslation } from "@/i18n/navigation"; import Footer from "@/app/components/Footer"; import { apiFetch } from "@/app/lib/api-client"; @@ -258,7 +258,12 @@ export default function DatingPage() {
{badgeText} - {t("report")} + + {t("report")} +
{currentProfile.name} diff --git a/app/[locale]/digital/app/AppDownloadContent.tsx b/app/[locale]/digital/app/AppDownloadContent.tsx index d65aa71..b8d3a81 100644 --- a/app/[locale]/digital/app/AppDownloadContent.tsx +++ b/app/[locale]/digital/app/AppDownloadContent.tsx @@ -1,10 +1,18 @@ "use client"; -import { useEffect } from "react"; -import { useTranslation } from "@/app/digital/i18n/navigation"; +import { useEffect, useState } from "react"; +import { Link, useTranslation } from "@/app/digital/i18n/navigation"; +import DigitalThemeIcon, { type IconName } from "@/app/digital/components/DigitalThemeIcon"; + +type BeforeInstallPromptEvent = Event & { + prompt: () => Promise; + userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>; +}; export default function AppDownloadContent() { const { t } = useTranslation("appDownload"); + const [installPrompt, setInstallPrompt] = useState(null); + const [installState, setInstallState] = useState<"idle" | "available" | "installed" | "manual">("idle"); useEffect(() => { window.history.scrollRestoration = "manual"; @@ -14,55 +22,150 @@ export default function AppDownloadContent() { }; }, []); + useEffect(() => { + const onBeforeInstallPrompt = (event: Event) => { + event.preventDefault(); + setInstallPrompt(event as BeforeInstallPromptEvent); + setInstallState("available"); + }; + const onInstalled = () => { + setInstallPrompt(null); + setInstallState("installed"); + }; + + window.addEventListener("beforeinstallprompt", onBeforeInstallPrompt); + window.addEventListener("appinstalled", onInstalled); + return () => { + window.removeEventListener("beforeinstallprompt", onBeforeInstallPrompt); + window.removeEventListener("appinstalled", onInstalled); + }; + }, []); + const features = [ - { key: "resources", icon: "📚" }, - { key: "community", icon: "👥" }, - { key: "tools", icon: "🛠️" }, + { key: "resources", icon: "book", emoji: "📚" }, + { key: "community", icon: "globe", emoji: "👥" }, + { key: "tools", icon: "tool", emoji: "🛠️" }, ] as const; + const installApp = async () => { + if (!installPrompt) { + setInstallState("manual"); + return; + } + + await installPrompt.prompt(); + const result = await installPrompt.userChoice; + setInstallPrompt(null); + setInstallState(result.outcome === "accepted" ? "installed" : "manual"); + }; + return (
-
-
-
-
- 📱 +
+
+
+
+
+ + {t("badge")} +
+

+ {t("title")} +

+

+ {t("subtitle")} +

+ +
+ + + {t("openWeb")} + + +
+ +

+ {installState === "installed" + ? t("installed") + : installState === "manual" + ? t("manualInstall") + : t("installHint")} +

+
+ +
+
+
+
+
+
+ {t("previewTitle")} + +
+
+
+
+

{t("previewKicker")}

+

{t("previewRoute")}

+
+ {features.map(({ key, icon, emoji }) => ( +
+ + + + + + {t(`features.${key}.title`)} + + + {t(`features.${key}.desc`)} + + +
+ ))} +
+
+
+
-

- {t("title")} -

-

- {t("subtitle")} -

-
- {features.map(({ key, icon }) => ( +
+ {features.map(({ key, icon, emoji }) => (
- - {icon} + + + + + + {t(`features.${key}.title`)} + + + {t(`features.${key}.desc`)} + -

- {t(`features.${key}.title`)} -

-

- {t(`features.${key}.desc`)} -

))}
- -
-
- {t("comingSoon")} -
-

- {t("comingSoonDesc")} -

-
diff --git a/app/[locale]/digital/course/[moduleIndex]/[lessonIndex]/page.tsx b/app/[locale]/digital/course/[moduleIndex]/[lessonIndex]/page.tsx index c6d0815..3fb987b 100644 --- a/app/[locale]/digital/course/[moduleIndex]/[lessonIndex]/page.tsx +++ b/app/[locale]/digital/course/[moduleIndex]/[lessonIndex]/page.tsx @@ -2,15 +2,23 @@ import { useEffect, useState } from "react"; import { useParams, useRouter } from "next/navigation"; -import { Link, useTranslation } from "@/app/digital/i18n/navigation"; +import { Link } from "@/app/digital/i18n/navigation"; import { VideoPlayer } from "@/app/digital/components/course/VideoPlayer"; import { LessonMarkdown } from "./LessonMarkdown"; -import { getLesson } from "@/config/digital/lessons"; +import { getLesson, type LessonDetail } from "@/config/digital/lessons"; +import { CURRICULUM_CONFIG, DOWNLOAD_CONFIG } from "@/config/digital/course"; import { canWatchLesson, isFreeTrial } from "@/app/digital/lib/course-payment"; import { useAuthAndVip } from "@/app/digital/lib/useAuthAndVip"; import { getStoredToken, getStoredUser } from "@/app/digital/lib/pocketbase"; -import { setLastWatched, markCompleted } from "@/app/digital/lib/learning"; -import { DOWNLOAD_CONFIG } from "@/config/digital/course"; +import { + getCompletedLessons, + getNote, + isBookmarked, + markCompleted, + setLastWatched, + setNote as saveNote, + toggleBookmark, +} from "@/app/digital/lib/learning"; import Header from "@/app/digital/components/Header"; import AuthModal from "@/app/digital/components/AuthModal"; import { @@ -18,23 +26,191 @@ import { redirectToPay, getDeviceFromEnv, } from "@/app/digital/lib/payment"; + +const COURSE_LESSONS = CURRICULUM_CONFIG.modules.flatMap(({ lessons }, moduleIndex) => + lessons.map((_, lessonIndex) => ({ moduleIndex, lessonIndex })) +); + +type DeckSlide = { + title: string; + body: string; +}; + +function buildDeckSlides(lesson: LessonDetail): DeckSlide[] { + const sections = lesson.markdown + .split("\n## ") + .slice(1) + .map((section) => { + const [rawTitle, ...bodyLines] = section.split("\n"); + return { + title: rawTitle.trim(), + body: bodyLines + .filter((line) => line.trim()) + .slice(0, 5) + .join("\n") + .replace(/^- /gm, "") + .replace(/^\d+\. /gm, ""), + }; + }) + .filter((slide) => slide.title && slide.body); + + return [ + { + title: "本节导览", + body: `${lesson.name}\n预计学习 ${lesson.duration}\n完成课件后可标记进度并保存笔记。`, + }, + ...sections, + ]; +} + +function LessonDeck({ + lesson, + onComplete, +}: { + lesson: LessonDetail; + onComplete: () => void; +}) { + const slides = buildDeckSlides(lesson); + const [activeIndex, setActiveIndex] = useState(0); + const [playing, setPlaying] = useState(false); + const activeSlide = slides[activeIndex] ?? slides[0]; + const progress = slides.length > 0 ? Math.round(((activeIndex + 1) / slides.length) * 100) : 0; + + useEffect(() => { + if (!playing) return; + const timer = window.setTimeout(() => { + setActiveIndex((current) => { + if (current >= slides.length - 1) { + setPlaying(false); + onComplete(); + return current; + } + return current + 1; + }); + }, 6500); + + return () => window.clearTimeout(timer); + }, [activeIndex, onComplete, playing, slides.length]); + + return ( +
+
+
+
+

Interactive Lesson

+

{activeSlide.title}

+
+ + {activeIndex + 1} / {slides.length} + +
+ +
+

+ {activeSlide.body} +

+
+ +
+
+
+
+
+
+ + +
+
+ + +
+
+
+
+
+ ); +} + export default function LessonPage() { const params = useParams(); const router = useRouter(); - const { t } = useTranslation("community"); - const { user, vip, refetch } = useAuthAndVip(); - const [toast, setToast] = useState(false); + const { vip, refetch } = useAuthAndVip(); const [authOpen, setAuthOpen] = useState(false); const [payRedirecting, setPayRedirecting] = useState(false); + const [bookmarked, setBookmarked] = useState(false); + const [completed, setCompleted] = useState(false); + const [lessonNote, setLessonNote] = useState(""); + const [statusMessage, setStatusMessage] = useState(""); const moduleIndex = Number(params.moduleIndex); const lessonIndex = Number(params.lessonIndex); const lesson = getLesson(moduleIndex, lessonIndex); const unlocked = canWatchLesson(moduleIndex, lessonIndex, vip); + const lessonName = lesson?.name; + const currentLessonIndex = COURSE_LESSONS.findIndex( + (item) => item.moduleIndex === moduleIndex && item.lessonIndex === lessonIndex + ); + const previousLesson = currentLessonIndex > 0 ? COURSE_LESSONS[currentLessonIndex - 1] : null; + const nextLesson = + currentLessonIndex >= 0 && currentLessonIndex < COURSE_LESSONS.length - 1 + ? COURSE_LESSONS[currentLessonIndex + 1] + : null; useEffect(() => { - if (lesson && unlocked) setLastWatched(moduleIndex, lessonIndex, lesson.name); - }, [moduleIndex, lessonIndex, lesson?.name, unlocked]); + if (lessonName && unlocked) setLastWatched(moduleIndex, lessonIndex, lessonName); + }, [moduleIndex, lessonIndex, lessonName, unlocked]); + + useEffect(() => { + if (!lesson) return; + const key = `${moduleIndex}-${lessonIndex}`; + setBookmarked(isBookmarked(moduleIndex, lessonIndex)); + setCompleted(getCompletedLessons().has(key)); + setLessonNote(getNote(moduleIndex, lessonIndex)); + setStatusMessage(""); + }, [moduleIndex, lessonIndex, lesson]); + + const handleBookmark = () => { + const active = toggleBookmark(moduleIndex, lessonIndex); + setBookmarked(active); + setStatusMessage(active ? "已收藏本节" : "已取消收藏"); + }; + + const handleMarkCompleted = () => { + markCompleted(moduleIndex, lessonIndex); + setCompleted(true); + setStatusMessage("本节已完成"); + }; + + const handleSaveNote = () => { + saveNote(moduleIndex, lessonIndex, lessonNote); + setStatusMessage("笔记已保存"); + }; const startPay = (userId: string) => { const env = getPayEnv(); @@ -155,17 +331,24 @@ export default function LessonPage() {
{unlocked ? ( - markCompleted(moduleIndex, lessonIndex)} - /> + lesson.videoUrl ? ( + + ) : ( + + ) ) : (
- 🔒 +

报名解锁本章节

+ +
+ {previousLesson && ( + + 上一节 + + )} + {nextLesson && ( + + 下一节 + + )} +
+
+ )} + + {statusMessage && ( +

+ {statusMessage} +

+ )} + {unlocked && (

@@ -192,6 +424,33 @@ export default function LessonPage() {

)} + {unlocked && ( +
+

+ + 学习笔记 +

+