setShowDrawer(false)}
+ aria-hidden="true"
+ />
+
+
+ {showBackTop && (
+
+ )}
+ >
+ );
+}
diff --git a/app/components/content/LessonLayout.tsx b/app/components/content/LessonLayout.tsx
new file mode 100644
index 0000000..5d168af
--- /dev/null
+++ b/app/components/content/LessonLayout.tsx
@@ -0,0 +1,151 @@
+"use client";
+
+import { useEffect, useCallback } from "react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import type { CourseConfig } from "@/lib/content/config";
+
+type LessonLayoutProps = {
+ courseConfig: CourseConfig;
+ moduleIndex: number;
+ lessonIndex: number;
+ lessonName: string;
+ lessonDuration: string;
+ isFreeTrial: boolean;
+ unlocked: boolean;
+ totalLessons: number;
+ completedCount: number;
+ prevLesson: { moduleIndex: number; lessonIndex: number } | null;
+ nextLesson: { moduleIndex: number; lessonIndex: number } | null;
+ courseHomeHref: string;
+ payHref: string;
+ children: React.ReactNode;
+};
+
+export function LessonLayout({
+ courseConfig,
+ moduleIndex,
+ lessonIndex,
+ lessonName,
+ lessonDuration,
+ isFreeTrial,
+ unlocked,
+ totalLessons,
+ completedCount,
+ prevLesson,
+ nextLesson,
+ courseHomeHref,
+ payHref,
+ children,
+}: LessonLayoutProps) {
+ const router = useRouter();
+ const courseBase = courseHomeHref.replace(/\/$/, "");
+ const lessonBase = courseBase.includes("/course") ? courseBase : `${courseBase}/course`;
+ const progressPercent = totalLessons > 0 ? Math.round((completedCount / totalLessons) * 100) : 0;
+
+ const handleKeyDown = useCallback(
+ (e: KeyboardEvent) => {
+ if (e.target && (e.target as HTMLElement).tagName === "INPUT") return;
+ if (e.key === "ArrowLeft" && prevLesson) {
+ router.push(`${lessonBase}/${prevLesson.moduleIndex}/${prevLesson.lessonIndex}`);
+ } else if (e.key === "ArrowRight" && nextLesson) {
+ router.push(`${lessonBase}/${nextLesson.moduleIndex}/${nextLesson.lessonIndex}`);
+ }
+ },
+ [prevLesson, nextLesson, lessonBase, router]
+ );
+
+ useEffect(() => {
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [handleKeyDown]);
+
+ return (
+
+
+
+ {/* 进度条 */}
+
+
+ 学习进度
+ {completedCount}/{totalLessons} 节 · {progressPercent}%
+
+
+
+
+ {/* 导航 */}
+
+
+ {/* 标题 */}
+
+ {lessonName}
+ {isFreeTrial && (
+
+ 试看
+
+ )}
+
+
时长 {lessonDuration}
+
+ {/* 内容区 */}
+ {children}
+
+ {/* 上/下一节 */}
+
+ {prevLesson ? (
+
+
+ 上一节
+
+ ) : (
+
+ )}
+ {nextLesson ? (
+
+ 下一节
+
+
+ ) : (
+
+ 返回课程首页
+
+ )}
+
+
+
+
+ );
+}
diff --git a/app/data/cloudphone.md b/app/data/cloudphone.md
new file mode 100644
index 0000000..e44e94a
--- /dev/null
+++ b/app/data/cloudphone.md
@@ -0,0 +1,185 @@
+# 云手机搭建
+
+数字游民:低成本工作室:云手机,无人直播tiktok/youtube,赚美金 (7)
+
+## 低成本工作室- 硬件篇
+
+> `开源硬件`才是超级杠杆
+
+

+
+试想一下,如果你不能从事现在的工作了,你最好的归宿是什么?
+
+我们都会从最初的青涩,到迷茫,到现在的迷惑
+
+所以有一阵子,就一直不停地问AI (chatgpt/grok/gemini ),得到的结论就是`工作室`
+
+> 极致的低成本`创业/副业`
+
+三四线城市或者小镇乡村,最佳的资源杠杆,在`人力资源`排名之前的是:
+
+- 便宜的租房 /家用宽带 /居民电价
+
+只需要再添一个`物理服务器`,就可以打开`互联网搬砖`的通道,成本仅此,
+
+而设备最佳的业务形态是:`云手机`=>开启`无人直播`
+
+交流可后台私信: `加群`
+
+当然,出海赚美金的业务,不止一种,可参考:
+
+1. Tiktok无人直播
+
+2. Youtube自媒体
+
+3. web (saas+web3)
+
+

+
+---
+
+## 云手机
+
+> 自建方案
+
+`商业运营平台`,以及二手安卓`工作机`(硬改直供电),或者`主板机`,均不在后续的阐述里,本篇的核心主题是个人如何利用`低成本开源硬件自建云手机`
+
+为什么要这么折腾?`开箱就用`才是赚钱效率的真谛不是吗?
+
+别急,看完你也许会有答案
+
+

+
+> Redroid
+
+*Redroid*(*远程*安卓*系统*)是一款基于GPU加速的云端Android(AIC)解决方案。可以在Linux主机(例如`Docker`Linux、`podman`Linux`k8s`等)上启动多个实例。Redroid同时支持Linux*和*`arm64`Android`amd64`架构。Redroid 适用于云游戏、虚拟化手机、自动化测试等应用场景
+
+简单来说,只要你有一台linux系统主机,随便就能`虚拟化N个安卓系统`
+
+但它有个不好解决的痛点:`缺乏相机支持`
+
+除非有公司团队去专门编译源码,否则在公开的网络平台,几乎找不到支持默认相机的docker镜像,毕竟它也不是传统的aosp安卓架构
+
+所以在直播领域,个人给它的应用场景是:
+
+- Youtube live : 网页studio无人直播
+
+具体的措施就是
+
+- `ffmpeg拉流并转发推流`,
+
+- chrome浏览器打开studio网页后台
+
+- `油猴脚本Greasemonkey`实现`自动开播断播`
+
+如图:依次是=>左(tiktok)/中(Youtube)/右(Youtube)
+
+它们24小时持续带来美金收益,即使在睡觉
+
+

+
+> ARM开发板的aosp安卓系统
+
+现在的开发板,除了linux,大多也支持aosp安卓系统,`有源码,可以自行编译`
+
+如果缺少组件/功能,就可以随时`开发/定制`
+
+比如:不支持uvc摄像头=>编译HAL,不支持google全家桶=>安装,把apk应用/脚本=>系统内置
+
+一个开发板的价格:`仅二百多`,就可以搭建一台云手机
+
+如下图所示:
+
+- `RPA转播脚本=>自建TK云手机=>真机查看效果`
+
+

+
+---
+
+## 软路由
+
+> 出海网络的基础设备
+
+云手机只是业务形态:直播
+
+但是出海业务千千万万,最离不开的网络基础设施:`软路由`
+
+没有它,可谓寸步难行
+
+

+
+家用的路由器,只不过是`残缺`的系统,是`被阉割`的
+
+要想勇闯互联网,你必须了解`真正的网络`是什么!!
+
+比如:`openwrt`
+
+知道什么是`科学代理,P2P组网,内网穿透,tun中继,DNS解析,mqtt通信`等等
+
+如同瑞士军刀,为业务披荆斩棘
+
+

+
+简单来说,你必须拥有`科学姿势上网`的能力,且必须是`静态住宅ip`
+
+科学姿势,只能让你扩大眼界,静态住宅ip才是赚美金的`门票`
+
+可参考出海导航: `nomadro.com`
+
+拥有一个软路由,你就找到了通往世界探索的隐秘入口
+
+---
+
+## ESP32
+
+> 智能实验室homelab控制
+
+在前面的`数字游民系列`,提到过esp32+传感器+执行器可以用于直播互动游戏
+
+比如: `生态造景=>云养鱼`
+
+简单来说,观众可以看到 鱼缸/草苔/宠物等
+
+下图为调试场景:
+
+

+
+通过弹幕评论 (比吃/666) /礼物打赏 (鲜花/钻石) ,发出指令,驱动效果:
+
+- 喂食器自动出粮
+
+- 灯带色彩/音乐变化
+
+- 氧气泵开启/关闭
+
+实现的原理是,就是利用python代码监听直播间事件
+
+mqtt网关驱动esp32控制传感器+执行器,进行互动
+
+`盈利逻辑`
+
+- 观众参与`互动的氛围感`=>礼物打赏
+
+- 直播带货`:鱼缸(好看/鱼粮(营养),橱窗直接下单
+
+- 用户关注=>涨粉丝: 接商业广告
+
+整个过程,既不是搬运,也是AI数字人,是`真实环境`
+
+所以,你看,无人直播,也可以是直接插入`usb摄像头`,无惧电池爆炸,24小时实时直播
+
+自动收打赏/带货/涨粉
+
+> Homelab工作室自动化
+
+在实际的运营业务中,还需要很多的配件支持
+
+比如 路由器,网线,视频采集卡,风扇,插座等普通配件
+
+也有`显卡欺骗器,5g通信模块,可刷写的esim实体卡`这些特殊硬件
+
+

+
+`云手机+软路由+esp32+配件`,一套成本`不过千`,就可以开始副业之旅
+
+数字游民:低成本工作室:云手机,无人直播tiktok/youtube,赚美金 (8)
diff --git a/app/data/vip.mock.ts b/app/data/vip.mock.ts
index 9a4dcc1..f3e87df 100644
--- a/app/data/vip.mock.ts
+++ b/app/data/vip.mock.ts
@@ -3,11 +3,15 @@
* 后续可替换为 api.hackrobot.cn 真实接口
*/
+import { CURRICULUM_CONFIG } from "@/app/cloudphone/config/course";
+
+/** 视频课程总数:从所有课程配置汇总计算 */
+const TOTAL_VIDEOS = CURRICULUM_CONFIG.modules.reduce((s, m) => s + m.lessons.length, 0);
+
export const ASSET_STATS = {
topics: 4,
ebooks: 2,
- videos: 12,
- templates: 8,
+ videos: TOTAL_VIDEOS,
updateFrequency: "每周",
} as const;
@@ -16,14 +20,14 @@ export const TOPICS = [
id: "ebook",
icon: "📖",
title: "电子书",
- desc: "地理套利·自动化",
+ desc: "知识沉淀·随时阅读",
href: "/ebook",
},
{
id: "cloudphone",
icon: "🎬",
title: "视频教程",
- desc: "云手机·无人直播",
+ desc: "实战演练·即学即用",
href: "/cloudphone",
},
{
@@ -36,32 +40,33 @@ export const TOPICS = [
{
id: "tools",
icon: "🛒",
- title: "设备自助",
+ title: "设备自助下单",
desc: "开箱即用",
- href: null,
+ href: "https://store.hackrobot.cn/",
+ locked: true,
},
] as const;
-/** 模块展示 - 首页展示用 */
+/** 模块展示 - 首页展示用,电子书/视频教程点击滚动到同页对应区块 */
export const TOPICS_DISPLAY = [
- { id: "ebook", icon: "📖", title: "电子书", desc: "地理套利·自动化杠杆", href: "/ebook" },
- { id: "cloudphone", icon: "🎬", title: "视频教程", desc: "云手机·无人直播", href: "/cloudphone" },
- { id: "community", icon: "👥", title: "私密社群", desc: "抱团出海", href: "https://qun.hackrobot.cn" },
- { id: "tools", icon: "🛒", title: "设备自助", desc: "开箱即用", href: null },
+ { id: "ebook", icon: "📖", title: "电子书", desc: "知识沉淀·随时阅读", href: "#ebooks" },
+ { id: "cloudphone", icon: "🎬", title: "视频教程", desc: "实战演练·即学即用", href: "#video-courses" },
+ { id: "community", icon: "👥", title: "私密社群", desc: "抱团出海", href: "https://qun.hackrobot.cn", locked: true },
+ { id: "tools", icon: "🛒", title: "设备自助下单", desc: "开箱即用", href: "https://store.hackrobot.cn/", locked: true },
] as const;
/** 电子书展示 - 首页展示用 */
export const EBOOKS_DISPLAY = [
{ id: "1", title: "数字游民", desc: "地理套利与自动化杠杆", href: "/ebook" },
- { id: "2", title: "云手机", desc: "低成本工作室搭建指南", href: "/cloudphone" },
+ { id: "2", title: "云手机", desc: "低成本工作室搭建指南", href: "/ebook/cloudphone" },
] as const;
/** 视频教程展示 - 参考 digital.hackrobot.cn 资源导航专题,至少 4 个 */
export const VIDEO_COURSES_DISPLAY = [
- { id: "cloudphone", icon: "☁️", title: "云手机模块", desc: "低成本自建云手机,TikTok/YouTube 运营搭建指南", href: "/topic/cloudphone" },
- { id: "livestream", icon: "📺", title: "无人直播模块", desc: "TikTok/YouTube 无人直播,24 小时自动化", href: "/topic/livestream" },
- { id: "indie", icon: "🛠️", title: "独立开发模块", desc: "从想法到产品,独立开发者全流程指南", href: "/topic/indie" },
- { id: "aiagent", icon: "🤖", title: "AI Agent 模块", desc: "AIGC 与智能体,Vibe Coding 跨界创造", href: "/topic/aiagent" },
+ { id: "cloudphone", icon: "☁️", title: "云手机", desc: "低成本自建云手机,TikTok/YouTube 运营搭建指南", href: "/cloudphone" },
+ { id: "livestream", icon: "📺", title: "无人直播", desc: "TikTok/YouTube 无人直播,24 小时自动化", href: "/topic/livestream" },
+ { id: "indie", icon: "🛠️", title: "独立开发", desc: "从想法到产品,独立开发者全流程指南", href: "/topic/indie" },
+ { id: "aiagent", icon: "🤖", title: "AI Agent", desc: "AIGC 与智能体,Vibe Coding 跨界创造", href: "/topic/aiagent" },
] as const;
export const COMPARE_ITEMS = [
@@ -81,7 +86,7 @@ export const COMMUNITY_PREVIEW = [
export const FAQ_ITEMS = [
{
q: "会员包含哪些内容?",
- a: "电子书、视频教程、私密社群、即时问答、设备自助等全部解锁,并享受持续更新。",
+ a: "电子书、视频教程、私密社群、即时问答、设备自助下单等全部解锁,并享受持续更新。",
},
{
q: "如何加入?",
diff --git a/app/ebook/cloudphone/page.tsx b/app/ebook/cloudphone/page.tsx
new file mode 100644
index 0000000..61b0941
--- /dev/null
+++ b/app/ebook/cloudphone/page.tsx
@@ -0,0 +1,23 @@
+import { readFileSync } from "fs";
+import path from "path";
+import { parseEbookMarkdown, estimateReadingMinutes } from "@/lib/content/ebook";
+import { EbookViewer } from "@/app/components/content/EbookViewer";
+import { CLOUDPHONE_EBOOK_CONFIG } from "@/config/content.config";
+
+export const metadata = {
+ title: "云手机 | 低成本工作室搭建指南",
+ description: "云手机:低成本自建云手机,TikTok/YouTube 无人直播,Redroid、AOSP、ffmpeg、SRS 从零搭建。",
+};
+
+export default function CloudphoneEbookPage() {
+ const mdPath = path.join(process.cwd(), "app/data/cloudphone.md");
+ const source = readFileSync(mdPath, "utf-8");
+ const data = parseEbookMarkdown(source);
+ const textLen = data.chunks.reduce((s, c) => s + c.html.replace(/<[^>]+>/g, "").length, 0);
+ const config = {
+ ...CLOUDPHONE_EBOOK_CONFIG,
+ estimatedMinutes: estimateReadingMinutes(textLen),
+ };
+
+ return
;
+}
diff --git a/app/ebook/page.tsx b/app/ebook/page.tsx
index db7c104..2eacb25 100644
--- a/app/ebook/page.tsx
+++ b/app/ebook/page.tsx
@@ -1,7 +1,8 @@
import { readFileSync } from "fs";
import path from "path";
-import { parseEbookMarkdown } from "../lib/ebook";
-import EbookClient from "./EbookClient";
+import { parseEbookMarkdown, estimateReadingMinutes } from "@/lib/content/ebook";
+import { EbookViewer } from "@/app/components/content/EbookViewer";
+import { EBOOK_CONFIG } from "@/config/content.config";
export const metadata = {
title: "电子书 | 数字游民",
@@ -12,6 +13,11 @@ export default function EbookPage() {
const mdPath = path.join(process.cwd(), "app/data/ebook.md");
const source = readFileSync(mdPath, "utf-8");
const data = parseEbookMarkdown(source);
+ const textLen = data.chunks.reduce((s, c) => s + c.html.replace(/<[^>]+>/g, "").length, 0);
+ const config = {
+ ...EBOOK_CONFIG,
+ estimatedMinutes: estimateReadingMinutes(textLen),
+ };
- return
;
+ return
;
}
diff --git a/app/globals.css b/app/globals.css
index feb64b6..64834b9 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -69,6 +69,10 @@
--font-mono: var(--font-geist-mono);
}
+html {
+ scroll-behavior: smooth;
+}
+
body {
background: var(--background);
color: var(--foreground);
diff --git a/app/page.tsx b/app/page.tsx
index 2f45852..b24951b 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -259,10 +259,6 @@ export default function Home() {
}
}, [getOrCreateUserId]);
- const handleLogin = useCallback(() => {
- document.getElementById("login")?.scrollIntoView({ behavior: "smooth" });
- }, []);
-
const handleVerifySuccess = useCallback(() => {
window.location.reload();
}, []);
@@ -366,7 +362,6 @@ export default function Home() {
) : (
diff --git a/app/topic/[topicId]/layout.tsx b/app/topic/[topicId]/layout.tsx
index 16961e9..122c999 100644
--- a/app/topic/[topicId]/layout.tsx
+++ b/app/topic/[topicId]/layout.tsx
@@ -7,10 +7,10 @@ export async function generateMetadata({
}): Promise
{
const { topicId } = await params;
const titles: Record = {
- cloudphone: "云手机模块 | 低成本自建云手机",
- livestream: "无人直播模块 | TikTok/YouTube 24小时自动化",
- indie: "独立开发模块 | 即将上线",
- aiagent: "AI Agent 模块 | 即将上线",
+ cloudphone: "云手机 | 低成本自建云手机",
+ livestream: "无人直播 | TikTok/YouTube 24小时自动化",
+ indie: "独立开发 | 即将上线",
+ aiagent: "AI Agent | 即将上线",
};
return {
title: titles[topicId] ?? "模块 | 异度星球",
diff --git a/app/topic/config.ts b/app/topic/config.ts
index fe40ef5..09863b9 100644
--- a/app/topic/config.ts
+++ b/app/topic/config.ts
@@ -23,7 +23,7 @@ export const TOPIC_CONFIGS: Record = {
cloudphone: {
id: "cloudphone",
icon: "☁️",
- title: "云手机模块",
+ title: "云手机",
subtitle: "低成本自建云手机,TikTok/YouTube 运营搭建指南",
desc: "用开源硬件打造云手机,Redroid、AOSP、ffmpeg、SRS,从零搭建无人直播工作室",
stats: [
@@ -36,7 +36,7 @@ export const TOPIC_CONFIGS: Record = {
livestream: {
id: "livestream",
icon: "📺",
- title: "无人直播模块",
+ title: "无人直播",
subtitle: "TikTok/YouTube 无人直播,24 小时自动化",
desc: "ffmpeg 拉流转推、SRS 直播服务器、油猴脚本自动化,实现 24 小时无人值守推流",
stats: [
@@ -50,7 +50,7 @@ export const TOPIC_CONFIGS: Record = {
indie: {
id: "indie",
icon: "🛠️",
- title: "独立开发模块",
+ title: "独立开发",
subtitle: "从想法到产品,独立开发者全流程指南",
desc: "即将上线,敬请期待。可先前往数字游民指南了解更多。",
stats: [
@@ -63,7 +63,7 @@ export const TOPIC_CONFIGS: Record = {
aiagent: {
id: "aiagent",
icon: "🤖",
- title: "AI Agent 模块",
+ title: "AI Agent",
subtitle: "AIGC 与智能体,Vibe Coding 跨界创造",
desc: "即将上线,敬请期待。可先前往数字游民指南了解更多。",
stats: [
diff --git a/app/vip/components/LandingPage.tsx b/app/vip/components/LandingPage.tsx
index 38b795e..e0f9653 100644
--- a/app/vip/components/LandingPage.tsx
+++ b/app/vip/components/LandingPage.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useState } from "react";
import { LandingHero } from "./landing/LandingHero";
import { AssetStats } from "./landing/AssetStats";
import { TopicEbookShowcase } from "./landing/TopicEbookShowcase";
@@ -7,31 +8,36 @@ import { CompareSection } from "./landing/CompareSection";
import { CommunityPreview } from "./landing/CommunityPreview";
import { FAQSection } from "./landing/FAQSection";
import { LandingCTA } from "./landing/LandingCTA";
-import { LoginBlock } from "./landing/LoginBlock";
+import { LoginModal } from "./landing/LoginModal";
type LandingPageProps = {
onJoin: () => void;
- onLogin: () => void;
onVerify: (userId: string) => Promise;
onVerifySuccess: () => void;
};
export function LandingPage({
onJoin,
- onLogin,
onVerify,
onVerifySuccess,
}: LandingPageProps) {
+ const [loginModalOpen, setLoginModalOpen] = useState(false);
+
return (
<>
-
+ setLoginModalOpen(true)} />
-
-
+ setLoginModalOpen(true)} />
+ setLoginModalOpen(false)}
+ onVerify={onVerify}
+ onSuccess={onVerifySuccess}
+ />
>
);
}
diff --git a/app/vip/components/dashboard/UnlockedStats.tsx b/app/vip/components/dashboard/UnlockedStats.tsx
index 44d0071..1c5a1ac 100644
--- a/app/vip/components/dashboard/UnlockedStats.tsx
+++ b/app/vip/components/dashboard/UnlockedStats.tsx
@@ -7,7 +7,6 @@ const STAT_ITEMS = [
{ key: "topics", label: "已解锁模块" },
{ key: "ebooks", label: "电子书" },
{ key: "videos", label: "视频" },
- { key: "templates", label: "模板" },
] as const;
export function UnlockedStats() {
diff --git a/app/vip/components/landing/AssetStats.tsx b/app/vip/components/landing/AssetStats.tsx
index d272d1b..fe00933 100644
--- a/app/vip/components/landing/AssetStats.tsx
+++ b/app/vip/components/landing/AssetStats.tsx
@@ -7,13 +7,12 @@ const STAT_ITEMS = [
{ key: "topics", label: "模块" },
{ key: "ebooks", label: "电子书" },
{ key: "videos", label: "视频" },
- { key: "templates", label: "模板" },
{ key: "updateFrequency", label: "更新" },
] as const;
export function AssetStats() {
return (
-
+
+
- 一次付费,解锁电子书、视频教程、私密社群、即时问答,享受每周更新
+ 一次付费,解锁电子书、视频教程、私密社群、即时问答,每周更新
);
diff --git a/app/vip/components/landing/LoginBlock.tsx b/app/vip/components/landing/LoginBlock.tsx
index 3863a9c..21d6446 100644
--- a/app/vip/components/landing/LoginBlock.tsx
+++ b/app/vip/components/landing/LoginBlock.tsx
@@ -1,7 +1,8 @@
"use client";
-import { useState, useRef, useEffect } from "react";
+import { useState, useEffect } from "react";
import styles from "../vip.module.css";
+import { LoginForm } from "./LoginForm";
type LoginBlockProps = {
onVerify: (userId: string) => Promise
;
@@ -9,41 +10,11 @@ type LoginBlockProps = {
};
export function LoginBlock({ onVerify, onSuccess }: LoginBlockProps) {
- const [userId, setUserId] = useState("");
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
- const inputRef = useRef(null);
-
+ const [hash, setHash] = useState("");
useEffect(() => {
- const hash = typeof window !== "undefined" ? window.location.hash : "";
- if (hash === "#login") {
- inputRef.current?.focus();
- }
+ setHash(typeof window !== "undefined" ? window.location.hash : "");
}, []);
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
- const trimmed = userId.trim();
- if (!trimmed) {
- setError("请输入您的账号");
- return;
- }
- setError(null);
- setLoading(true);
- try {
- const ok = await onVerify(trimmed);
- if (ok) {
- onSuccess();
- } else {
- setError("未找到该账号的会员记录,请确认账号正确");
- }
- } catch {
- setError("验证失败,请稍后重试");
- } finally {
- setLoading(false);
- }
- };
-
return (
@@ -51,39 +22,7 @@ export function LoginBlock({ onVerify, onSuccess }: LoginBlockProps) {
请输入您的会员账号(支付时使用的 user_id)以恢复权益
-
+
);
diff --git a/app/vip/components/landing/LoginForm.tsx b/app/vip/components/landing/LoginForm.tsx
new file mode 100644
index 0000000..ae49a7e
--- /dev/null
+++ b/app/vip/components/landing/LoginForm.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+import { useState, useRef, useEffect } from "react";
+import styles from "../vip.module.css";
+
+type LoginFormProps = {
+ onVerify: (userId: string) => Promise;
+ onSuccess: () => void;
+ autoFocus?: boolean;
+};
+
+export function LoginForm({ onVerify, onSuccess, autoFocus }: LoginFormProps) {
+ const [userId, setUserId] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ if (autoFocus) inputRef.current?.focus();
+ }, [autoFocus]);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ const trimmed = userId.trim();
+ if (!trimmed) {
+ setError("请输入您的账号");
+ return;
+ }
+ setError(null);
+ setLoading(true);
+ try {
+ const ok = await onVerify(trimmed);
+ if (ok) {
+ onSuccess();
+ } else {
+ setError("未找到该账号的会员记录,请确认账号正确");
+ }
+ } catch {
+ setError("验证失败,请稍后重试");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/app/vip/components/landing/LoginModal.tsx b/app/vip/components/landing/LoginModal.tsx
new file mode 100644
index 0000000..cb16048
--- /dev/null
+++ b/app/vip/components/landing/LoginModal.tsx
@@ -0,0 +1,81 @@
+"use client";
+
+import { useEffect } from "react";
+import styles from "../vip.module.css";
+import { LoginForm } from "./LoginForm";
+
+type LoginModalProps = {
+ open: boolean;
+ onClose: () => void;
+ onVerify: (userId: string) => Promise;
+ onSuccess: () => void;
+};
+
+export function LoginModal({ open, onClose, onVerify, onSuccess }: LoginModalProps) {
+ useEffect(() => {
+ if (open) {
+ document.body.style.overflow = "hidden";
+ } else {
+ document.body.style.overflow = "";
+ }
+ return () => {
+ document.body.style.overflow = "";
+ };
+ }, [open]);
+
+ useEffect(() => {
+ if (!open) return;
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Escape") onClose();
+ };
+ window.addEventListener("keydown", onKeyDown);
+ return () => window.removeEventListener("keydown", onKeyDown);
+ }, [open, onClose]);
+
+ const handleSuccess = () => {
+ onClose();
+ onSuccess();
+ };
+
+ if (!open) return null;
+
+ return (
+
+ {/* 背景遮罩 */}
+
+ {/* 弹窗内容 */}
+
e.stopPropagation()}
+ >
+
+
+ 请输入您的会员账号(支付时使用的 user_id)以恢复权益
+
+
+
+
+ );
+}
diff --git a/app/vip/components/landing/TopicEbookShowcase.tsx b/app/vip/components/landing/TopicEbookShowcase.tsx
index 2c288d6..834f525 100644
--- a/app/vip/components/landing/TopicEbookShowcase.tsx
+++ b/app/vip/components/landing/TopicEbookShowcase.tsx
@@ -4,9 +4,13 @@ import Link from "next/link";
import { TOPICS_DISPLAY, EBOOKS_DISPLAY, VIDEO_COURSES_DISPLAY } from "@/app/data/vip.mock";
import styles from "../vip.module.css";
-export function TopicEbookShowcase() {
+type TopicEbookShowcaseProps = {
+ onLockedClick?: () => void;
+};
+
+export function TopicEbookShowcase({ onLockedClick }: TopicEbookShowcaseProps) {
return (
-
+
{/* 模块展示 */}
模块
{TOPICS_DISPLAY.map((t) => {
+ const isLocked = "locked" in t && t.locked && onLockedClick;
const content = (
- <>
+
+ {isLocked && (
+ 🔒
+ )}
{t.icon}
{t.title}
{t.desc}
- >
+
);
+ if (isLocked) {
+ return (
+
+ );
+ }
if (t.href) {
return (
{/* 视频教程展示 - 参考 digital 资源导航,至少 4 个 */}
-
视频教程
+
视频教程
- {VIDEO_COURSES_DISPLAY.map((v) => (
-
-
{v.icon}
-
{v.title}
-
{v.desc}
-
- ))}
+ {VIDEO_COURSES_DISPLAY.map((v) =>
+ v.href ? (
+
+
{v.icon}
+
{v.title}
+
{v.desc}
+
+ ) : (
+
+ {v.icon}
+ {v.title}
+ {v.desc}
+
+ )
+ )}
{/* 电子书展示 */}
-
电子书
+
电子书
- {EBOOKS_DISPLAY.map((e) => (
-
-
📖
-
- 《{e.title}》
+ {EBOOKS_DISPLAY.map((e) => {
+ const cardContent = (
+ <>
+
📖
+
+ 《{e.title}》
+
+
{e.desc}
+
+ 阅读 →
+
+ >
+ );
+ return e.href ? (
+
+ {cardContent}
+
+ ) : (
+
+ {cardContent}
-
{e.desc}
-
- 阅读 →
-
-
- ))}
+ );
+ })}
);
diff --git a/app/vip/components/vip.module.css b/app/vip/components/vip.module.css
index 54dac01..8b2d9d5 100644
--- a/app/vip/components/vip.module.css
+++ b/app/vip/components/vip.module.css
@@ -71,6 +71,72 @@
padding: 1.5rem 0;
}
+/* PC 首页:Hero + 统计 模块上方 */
+.heroSection {
+ padding-top: 2rem;
+ padding-bottom: 1rem;
+}
+
+@media (min-width: 768px) {
+ .heroSection {
+ padding-top: 3rem;
+ padding-bottom: 1.5rem;
+ }
+ .heroSection p:first-of-type {
+ margin-bottom: 1.25rem;
+ }
+ .heroSection h1 {
+ font-size: clamp(2.25rem, 4vw, 3.25rem) !important;
+ margin-bottom: 0.75rem;
+ }
+ .heroSection p:last-of-type {
+ font-size: 1.25rem;
+ max-width: 600px;
+ margin-left: auto;
+ margin-right: auto;
+ }
+}
+
+@media (min-width: 1024px) {
+ .heroSection {
+ padding-top: 4rem;
+ padding-bottom: 2rem;
+ }
+}
+
+.statsSection {
+ padding-top: 2rem;
+ padding-bottom: 2rem;
+}
+
+@media (min-width: 768px) {
+ .statsSection {
+ padding-top: 2.5rem;
+ padding-bottom: 2.5rem;
+ }
+}
+
+@media (min-width: 1024px) {
+ .statsSection {
+ padding-top: 3rem;
+ padding-bottom: 3rem;
+ }
+}
+
+.topicsSection .sectionTitle {
+ margin-bottom: 1.25rem;
+}
+
+@media (min-width: 768px) {
+ .topicsSection {
+ padding-top: 2.5rem;
+ }
+ .topicsSection .sectionTitle {
+ font-size: 1.5rem;
+ margin-bottom: 1.5rem;
+ }
+}
+
.sectionTitle {
font-size: 1.25rem;
font-weight: 600;
@@ -174,7 +240,7 @@
@media (min-width: 640px) {
.statsGrid {
- grid-template-columns: repeat(5, 1fr);
+ grid-template-columns: repeat(4, 1fr);
}
}
@@ -204,9 +270,11 @@
}
.topicCard {
+ position: relative;
display: flex;
flex-direction: column;
align-items: center;
+ justify-content: center;
gap: 0.5rem;
padding: 1.25rem 1rem;
background: var(--card);
@@ -217,6 +285,14 @@
transition: border-color 0.2s, transform 0.2s;
}
+.topicCardInner {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.5rem;
+ width: 100%;
+}
+
.topicCard:hover {
border-color: rgba(245, 158, 11, 0.4);
transform: translateY(-2px);
@@ -238,6 +314,14 @@
color: var(--muted-foreground);
}
+.topicLock {
+ position: absolute;
+ top: 0.75rem;
+ right: 0.75rem;
+ font-size: 0.85rem;
+ opacity: 0.85;
+}
+
/* Loading */
.loading {
min-height: 100vh;
diff --git a/config/content.config.ts b/config/content.config.ts
new file mode 100644
index 0000000..45b2ee3
--- /dev/null
+++ b/config/content.config.ts
@@ -0,0 +1,30 @@
+/**
+ * nomadvip 内容模块配置 - 电子书 + 课程
+ * digital / nomadlms 可复用此结构,仅改 href 等
+ */
+import type { EbookConfig, CourseConfig } from "@/lib/content/config";
+
+export const EBOOK_CONFIG: EbookConfig = {
+ title: "数字游民",
+ backHref: "/",
+ requireAuth: false,
+ estimatedMinutes: 45,
+ storagePrefix: "nomadvip-ebook",
+};
+
+export const CLOUDPHONE_EBOOK_CONFIG: EbookConfig = {
+ title: "云手机",
+ backHref: "/",
+ requireAuth: false,
+ estimatedMinutes: 25,
+ storagePrefix: "nomadvip-ebook-cloudphone",
+};
+
+export const CLOUDPHONE_COURSE_CONFIG: CourseConfig = {
+ courseId: "cloudphone",
+ courseHomeHref: "/cloudphone",
+ payHref: "/cloudphone/pay",
+ freeTrial: (m, l) => m === 0 && l < 2,
+ videoStoragePrefix: "lms-cloudphone-video-",
+ learningStoragePrefix: "lms-cloudphone-",
+};
diff --git a/lib/content/config.ts b/lib/content/config.ts
new file mode 100644
index 0000000..5de3807
--- /dev/null
+++ b/lib/content/config.ts
@@ -0,0 +1,31 @@
+/**
+ * 共享内容模块配置 - 可标准应用到 digital / nomadvip / nomadlms
+ */
+
+export type EbookConfig = {
+ title: string;
+ backHref: string;
+ /** 是否需权限,免费则 false */
+ requireAuth?: boolean;
+ /** 预计阅读分钟数,用于倒计时/展示 */
+ estimatedMinutes?: number;
+ /** 存储 key 前缀,区分项目 */
+ storagePrefix?: string;
+};
+
+export type CourseConfig = {
+ courseId: string;
+ courseHomeHref: string;
+ payHref: string;
+ /** 试看:[[moduleIndex, lessonIndex], ...] 或 (m,l)=>boolean */
+ freeTrial?: [number, number][] | ((m: number, l: number) => boolean);
+ /** 视频进度存储前缀 */
+ videoStoragePrefix?: string;
+ /** 学习进度存储前缀 */
+ learningStoragePrefix?: string;
+};
+
+export type ContentConfig = {
+ ebook: EbookConfig;
+ course: CourseConfig;
+};
diff --git a/lib/content/course-utils.ts b/lib/content/course-utils.ts
new file mode 100644
index 0000000..0557994
--- /dev/null
+++ b/lib/content/course-utils.ts
@@ -0,0 +1,26 @@
+/**
+ * 课程工具 - 获取上一节/下一节
+ */
+
+export type LessonRef = { moduleIndex: number; lessonIndex: number };
+
+export function getPrevNextLesson(
+ modules: readonly { lessons: readonly unknown[] }[],
+ moduleIndex: number,
+ lessonIndex: number
+): { prev: LessonRef | null; next: LessonRef | null } {
+ const allLessons: LessonRef[] = [];
+ modules.forEach((m, mi) => {
+ m.lessons.forEach((_, li) => {
+ allLessons.push({ moduleIndex: mi, lessonIndex: li });
+ });
+ });
+
+ const idx = allLessons.findIndex((l) => l.moduleIndex === moduleIndex && l.lessonIndex === lessonIndex);
+ if (idx < 0) return { prev: null, next: null };
+
+ return {
+ prev: idx > 0 ? allLessons[idx - 1] : null,
+ next: idx < allLessons.length - 1 ? allLessons[idx + 1] : null,
+ };
+}
diff --git a/lib/content/ebook.ts b/lib/content/ebook.ts
new file mode 100644
index 0000000..8edbd5c
--- /dev/null
+++ b/lib/content/ebook.ts
@@ -0,0 +1,51 @@
+/**
+ * 电子书 Markdown 解析 - 共享模块
+ */
+import { marked } from "marked";
+
+export interface EbookChunk {
+ html: string;
+ estimatedHeight: number;
+}
+
+export interface EbookData {
+ chunks: EbookChunk[];
+ headers: string[];
+}
+
+export function parseEbookMarkdown(source: string): EbookData {
+ const html = marked.parse(source, { breaks: true, gfm: true }) as string;
+ const lazyHtml = html.replace(/
![]()
])/i).filter((c) => c.trim());
+ if (rawChunks.length <= 1) {
+ rawChunks = lazyHtml.split(/(?=
])/i).filter((c) => c.trim());
+ }
+
+ const headers: string[] = [];
+ const headerRegex = /]*>(.*?)<\/h1>/gi;
+ let m;
+ while ((m = headerRegex.exec(lazyHtml)) !== null) {
+ headers.push(m[1].replace(/<[^>]+>/g, ""));
+ }
+ if (headers.length === 0) {
+ const h2Regex = /]*>(.*?)<\/h2>/gi;
+ while ((m = h2Regex.exec(lazyHtml)) !== null) {
+ headers.push(m[1].replace(/<[^>]+>/g, ""));
+ }
+ }
+
+ const chunks: EbookChunk[] = rawChunks.map((chunkHtml) => {
+ const imgCount = (chunkHtml.match(/
]+>/g, "").length;
+ const estimatedHeight = Math.max(300, Math.round(textLen * 0.6 + imgCount * 350));
+ return { html: chunkHtml, estimatedHeight };
+ });
+
+ return { chunks, headers };
+}
+
+/** 估算阅读时间(字/分钟,中文约 300-500) */
+export function estimateReadingMinutes(textLength: number, wpm = 400): number {
+ return Math.max(1, Math.ceil(textLength / wpm));
+}