diff --git a/app/cloudphone/components/VideoPlayer.tsx b/app/cloudphone/components/VideoPlayer.tsx index d35b2d7..e28f8b7 100644 --- a/app/cloudphone/components/VideoPlayer.tsx +++ b/app/cloudphone/components/VideoPlayer.tsx @@ -227,46 +227,52 @@ export function VideoPlayer({ src, poster, title, className = "", videoId, onCom playsInline /> - {showResumePrompt && ( -
-

- 上次看到 {formatResumeTime(savedTime)} -

-
- - -
-
- )} {!playing && ( - + + + + ) : ( + +
+ + + +
+ + )} + )}
s + m.lessons.length, 0); export default function LessonPage() { const params = useParams(); - const router = useRouter(); const moduleIndex = Number(params.moduleIndex); const lessonIndex = Number(params.lessonIndex); const lesson = getLesson(moduleIndex, lessonIndex); const unlocked = canWatchLesson(moduleIndex, lessonIndex); const isFreeTrial = moduleIndex === 0 && lessonIndex < 2; + const [completed, setCompleted] = useState(() => getCompletedLessons().size); + const { prev: prevLesson, next: nextLesson } = getPrevNextLesson( + CURRICULUM_CONFIG.modules, + moduleIndex, + lessonIndex + ); useEffect(() => { if (lesson && unlocked) setLastWatched(moduleIndex, lessonIndex, lesson.name); }, [moduleIndex, lessonIndex, lesson?.name, unlocked]); + useEffect(() => { + const onProgress = () => setCompleted(getCompletedLessons().size); + window.addEventListener("learning:progress", onProgress); + return () => window.removeEventListener("learning:progress", onProgress); + }, []); + if (!lesson || isNaN(moduleIndex) || isNaN(lessonIndex)) { return (
@@ -32,9 +48,7 @@ export default function LessonPage() {

章节不存在

-

- 请检查链接或返回课程首页 -

+

请检查链接或返回课程首页

-
-
- - -

- {lesson.name} - {isFreeTrial && ( - - 试看 - - )} -

-

- 时长 {lesson.duration} -

- -
- {unlocked ? ( - markCompleted(moduleIndex, lessonIndex)} - /> - ) : ( -
-
- 🔒 -

报名解锁本章节

- - 立即报名 - -
+ +
+ {unlocked ? ( + markCompleted(moduleIndex, lessonIndex)} + /> + ) : ( +
+
+ 🔒 +

报名解锁本章节

+ + 立即报名 +
- )} -
- - {unlocked && ( -
-

- - - - 配套文档 -

- -
- )} - - {DOWNLOAD_CONFIG.enabled && unlocked && ( -
-

- - - - {DOWNLOAD_CONFIG.title} -

-

- 课程配套资料已上传至网盘,报名后可前往下载 -

- - {DOWNLOAD_CONFIG.buttonText} - - - -
)}
-
+ + {unlocked && ( +
+

+ + + + 配套文档 +

+ +
+ )} + + {DOWNLOAD_CONFIG.enabled && unlocked && ( +
+

+ + + + {DOWNLOAD_CONFIG.title} +

+

课程配套资料已上传至网盘,报名后可前往下载

+ + {DOWNLOAD_CONFIG.buttonText} + + + + +
+ )} +
); } diff --git a/app/components/content/EbookViewer.module.css b/app/components/content/EbookViewer.module.css new file mode 100644 index 0000000..b17562e --- /dev/null +++ b/app/components/content/EbookViewer.module.css @@ -0,0 +1,294 @@ +/* 电子书阅读器 - 支持深色主题,可复用到 digital/nomadvip/nomadlms */ + +.readingProgress { + position: fixed; + top: 0; + left: 0; + height: 4px; + background: linear-gradient(90deg, var(--accent) 0%, #00f2fe 100%); + z-index: 201; + transition: width 0.15s linear; + border-radius: 0 4px 4px 0; +} + +.readingTime { + font-size: 12px; + font-weight: 400; + color: var(--muted-foreground); + margin-left: 4px; +} + +.headerSpacer { + width: 100%; + height: 88px; +} + +.header { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 100; + height: 88px; + background: var(--card); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-bottom: 1px solid var(--border); + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; +} + +.headerLeft { + width: 100px; + display: flex; + align-items: center; + justify-content: center; + color: var(--foreground); + cursor: pointer; +} + +.headerLeft:hover { + color: var(--accent); +} + +.headerMiddle { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + font-weight: 600; + color: var(--foreground); + letter-spacing: 2px; +} + +.headerRight { + width: 40px; + height: 40px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--accent); + cursor: pointer; + margin-right: 8px; + background: var(--accent-muted, rgba(245, 158, 11, 0.15)); + border-radius: 50%; + transition: all 0.2s ease; +} + +.headerRight:hover { + background: var(--accent-muted, rgba(245, 158, 11, 0.25)); +} + +.headerRightActive { + background: var(--accent) !important; + color: var(--accent-foreground) !important; +} + +.headerRightText { + display: none; + margin-left: 4px; +} + +.chunkEnter { + animation: chunkAppear 0.5s ease-out both; +} + +@keyframes chunkAppear { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +.chunkPlaceholder { + background: var(--muted); + background-size: 200% 100%; + animation: shimmer 1.8s ease infinite; + border-radius: 12px; + margin: 24px 0; +} + +@keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +.backToTop { + position: fixed; + bottom: 120px; + right: 30px; + width: 56px; + height: 56px; + background: var(--card); + backdrop-filter: blur(10px); + border: 1px solid var(--border); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); + cursor: pointer; + z-index: 50; + color: var(--accent); + transition: transform 0.2s, box-shadow 0.2s; +} + +.backToTop:hover { + transform: translateY(-2px); + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.15); +} + +.book { + padding: 30px; + max-width: 100%; +} + +.markdownBody { + font-size: 16px !important; + line-height: 1.85 !important; + color: var(--foreground) !important; + word-wrap: break-word; +} + +.markdownBody h1 { + font-size: 24px !important; + font-weight: 800; + color: var(--foreground); + margin: 28px 0 10px; +} + +.markdownBody h1::after { + content: ""; + display: block; + width: 40px; + height: 3px; + background: linear-gradient(90deg, var(--accent), #00f2fe); + border-radius: 3px; + margin-top: 8px; +} + +.markdownBody h2 { + font-size: 21px !important; + font-weight: 700; + color: var(--foreground); + margin: 22px 0 9px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border); +} + +.markdownBody h3 { font-size: 18px !important; font-weight: 600; margin: 18px 0 7px; } +.markdownBody h4, .markdownBody h5, .markdownBody h6 { font-size: 16px !important; margin: 14px 0 6px; } +.markdownBody p { font-size: 16px !important; margin-bottom: 10px; line-height: 1.9; } + +.markdownBody blockquote { + margin: 28px 0; + padding: 20px 24px 20px 30px; + background: var(--muted); + border-radius: 0 12px 12px 0; + color: var(--muted-foreground); + border-left: 4px solid var(--accent); +} + +.markdownBody pre { + margin: 28px 0; + padding: 28px 24px; + background: var(--background); + border: 1px solid var(--border); + border-radius: 12px; + overflow-x: auto; +} + +.markdownBody pre code { + font-family: var(--font-geist-mono), monospace; + font-size: 13px !important; + color: var(--foreground); + background: none !important; +} + +.markdownBody code { + font-family: var(--font-geist-mono), monospace; + font-size: 14px !important; + padding: 2px 6px; + background: var(--muted); + color: var(--accent); + border-radius: 6px; +} + +.markdownBody a { color: var(--accent); } +.markdownBody a:hover { text-decoration: underline; } +.markdownBody img { display: block; margin: 28px auto; max-width: 100%; border-radius: 8px; } +.markdownBody ul, .markdownBody ol { margin: 16px 0; padding-left: 40px; } +.markdownBody table { width: 100%; margin: 28px 0; border-collapse: collapse; border-radius: 8px; } +.markdownBody th { background: var(--muted); font-weight: 600; padding: 16px 20px; border: 1px solid var(--border); } +.markdownBody td { padding: 14px 20px; border: 1px solid var(--border); } + +.drawer { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: 280px; + max-width: 85vw; + background: var(--card); + border-left: 1px solid var(--border); + box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15); + z-index: 150; + padding-top: 88px; + overflow-y: auto; + transform: translateX(100%); + transition: transform 0.3s ease; +} + +.drawerOpen { transform: translateX(0); } + +.drawerMask { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + z-index: 140; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s ease; +} + +.drawerMaskOpen { + opacity: 1; + pointer-events: auto; +} + +.drawerItem { + padding: 16px 20px; + border-bottom: 1px solid var(--border); + color: var(--foreground); + cursor: pointer; + font-size: 15px; + transition: background 0.15s; +} + +.drawerItem:hover { + background: var(--muted); +} + +@media (min-width: 768px) { + .headerSpacer { height: 56px; } + .header { height: 56px; } + .progressBar { top: 56px; } + .headerMiddle { font-size: 18px; } + .headerRight { + width: auto; + height: 34px; + padding: 0 14px; + margin-right: 16px; + border-radius: 17px; + font-size: 13px; + } + .headerRightText { display: inline; } + .book { padding: 30px 60px; } + .markdownBody { max-width: 800px; margin: 0 auto; } + .markdownBody h1 { font-size: 28px !important; } + .markdownBody h2 { font-size: 23px !important; } + .drawer { padding-top: 56px; width: 300px; } + .backToTop { width: 44px; height: 44px; bottom: 40px; right: 40px; } +} diff --git a/app/components/content/EbookViewer.tsx b/app/components/content/EbookViewer.tsx new file mode 100644 index 0000000..300d4ca --- /dev/null +++ b/app/components/content/EbookViewer.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { useEffect, useState, useRef, useCallback } from "react"; +import Link from "next/link"; +import type { EbookData } from "@/lib/content/ebook"; +import type { EbookConfig } from "@/lib/content/config"; +import "github-markdown-css/github-markdown.css"; +import styles from "./EbookViewer.module.css"; + +function LazyChunk({ + html, + estimatedHeight, + forceVisible, +}: { + html: string; + estimatedHeight: number; + forceVisible: boolean; +}) { + const [visible, setVisible] = useState(forceVisible); + const ref = useRef(null); + const show = forceVisible || visible; + + useEffect(() => { + if (forceVisible || visible) return; + const el = ref.current; + if (!el) return; + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setVisible(true); + observer.disconnect(); + } + }, + { rootMargin: "1200px 0px" } + ); + observer.observe(el); + return () => observer.disconnect(); + }, [forceVisible, visible]); + + if (show) { + return ( +
+ ); + } + return ( +
+ ); +} + +type EbookViewerProps = { + data: EbookData; + config: EbookConfig; +}; + +export function EbookViewer({ data, config }: EbookViewerProps) { + const [showDrawer, setShowDrawer] = useState(false); + const [readProgress, setReadProgress] = useState(0); + const [showBackTop, setShowBackTop] = useState(false); + const [forceShowAll, setForceShowAll] = useState(false); + const [readingTimeLeft, setReadingTimeLeft] = useState(null); + const contentRef = useRef(null); + + const { chunks, headers } = data; + const estimatedMin = config.estimatedMinutes ?? null; + + useEffect(() => { + let ticking = false; + const onScroll = () => { + if (ticking) return; + ticking = true; + requestAnimationFrame(() => { + const scrollTop = window.scrollY || document.documentElement.scrollTop; + const docHeight = document.documentElement.scrollHeight - window.innerHeight; + setReadProgress(docHeight > 0 ? Math.min(100, (scrollTop / docHeight) * 100) : 0); + setShowBackTop(scrollTop > 600); + if (estimatedMin && docHeight > 0) { + const remaining = Math.max(0, estimatedMin * (1 - scrollTop / docHeight)); + setReadingTimeLeft(Math.ceil(remaining)); + } + ticking = false; + }); + }; + window.addEventListener("scroll", onScroll, { passive: true }); + return () => window.removeEventListener("scroll", onScroll); + }, [estimatedMin]); + + const handleJump = useCallback((index: number) => { + setForceShowAll(true); + setShowDrawer(false); + const run = () => { + const root = contentRef.current; + if (!root) return; + const h1s = root.querySelectorAll("h1"); + const h2s = root.querySelectorAll("h2"); + const targets = h1s.length > 0 ? h1s : h2s; + const el = targets[index] as HTMLElement; + if (el) { + try { + el.scrollIntoView({ behavior: "smooth", block: "start" }); + } catch { + const top = + el.getBoundingClientRect().top + + (window.pageYOffset || document.documentElement.scrollTop) - + 60; + window.scrollTo({ top: Math.max(0, top), behavior: "smooth" }); + } + } + }; + run(); + setTimeout(run, 100); + setTimeout(run, 300); + setTimeout(run, 600); + }, []); + + return ( + <> +
+ +
+
+ + + + + +
+ {config.title} + {readingTimeLeft !== null && readingTimeLeft > 0 && ( + · 约 {readingTimeLeft} 分钟 + )} +
+ +
+ +
+
+ {chunks.map((chunk, i) => ( + + ))} +
+
+ +
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) + +## 低成本工作室- 硬件篇 + +> `开源硬件`才是超级杠杆 + +999554b9-1a4b-4360-8bbf-79d8cf0be394_20251231_015301_20521.jpg + +试想一下,如果你不能从事现在的工作了,你最好的归宿是什么? + +我们都会从最初的青涩,到迷茫,到现在的迷惑 + +所以有一阵子,就一直不停地问AI (chatgpt/grok/gemini ),得到的结论就是`工作室` + +> 极致的低成本`创业/副业` + +三四线城市或者小镇乡村,最佳的资源杠杆,在`人力资源`排名之前的是: + +- 便宜的租房 /家用宽带 /居民电价 + +只需要再添一个`物理服务器`,就可以打开`互联网搬砖`的通道,成本仅此, + +而设备最佳的业务形态是:`云手机`=>开启`无人直播` + +交流可后台私信: `加群` + +当然,出海赚美金的业务,不止一种,可参考: + +1. Tiktok无人直播 + +2. Youtube自媒体 + +3. web (saas+web3) + +image_20251231_015321_20586.jpg + +--- + +## 云手机 + +> 自建方案 + +`商业运营平台`,以及二手安卓`工作机`(硬改直供电),或者`主板机`,均不在后续的阐述里,本篇的核心主题是个人如何利用`低成本开源硬件自建云手机` + +为什么要这么折腾?`开箱就用`才是赚钱效率的真谛不是吗? + +别急,看完你也许会有答案 + +20251231001912_20251231_015340_20645.png + +> 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小时持续带来美金收益,即使在睡觉 + +20251230235454_20251231_015401_20717.png + +> ARM开发板的aosp安卓系统 + +现在的开发板,除了linux,大多也支持aosp安卓系统,`有源码,可以自行编译` + +如果缺少组件/功能,就可以随时`开发/定制` + +比如:不支持uvc摄像头=>编译HAL,不支持google全家桶=>安装,把apk应用/脚本=>系统内置 + +一个开发板的价格:`仅二百多`,就可以搭建一台云手机 + +如下图所示: + +- `RPA转播脚本=>自建TK云手机=>真机查看效果` + +20251230234249_20251231_015420_20779.png + +--- + +## 软路由 + +> 出海网络的基础设备 + +云手机只是业务形态:直播 + +但是出海业务千千万万,最离不开的网络基础设施:`软路由` + +没有它,可谓寸步难行 + +20251231002830_20251231_015441_20847.png + +家用的路由器,只不过是`残缺`的系统,是`被阉割`的 + +要想勇闯互联网,你必须了解`真正的网络`是什么!! + +比如:`openwrt` + +知道什么是`科学代理,P2P组网,内网穿透,tun中继,DNS解析,mqtt通信`等等 + +如同瑞士军刀,为业务披荆斩棘 + +20251230235757_20251231_015459_20906.png + +简单来说,你必须拥有`科学姿势上网`的能力,且必须是`静态住宅ip` + +科学姿势,只能让你扩大眼界,静态住宅ip才是赚美金的`门票` + +可参考出海导航: `nomadro.com` + +拥有一个软路由,你就找到了通往世界探索的隐秘入口 + +--- + +## ESP32 + +> 智能实验室homelab控制 + +在前面的`数字游民系列`,提到过esp32+传感器+执行器可以用于直播互动游戏 + +比如: `生态造景=>云养鱼` + +简单来说,观众可以看到 鱼缸/草苔/宠物等 + +下图为调试场景: + +498e7c59a7a7c1494a596b4010e1fe55_20251231_015517_20965.jpg + +通过弹幕评论 (比吃/666) /礼物打赏 (鲜花/钻石) ,发出指令,驱动效果: + +- 喂食器自动出粮 + +- 灯带色彩/音乐变化 + +- 氧气泵开启/关闭 + +实现的原理是,就是利用python代码监听直播间事件 + +mqtt网关驱动esp32控制传感器+执行器,进行互动 + +`盈利逻辑` + +- 观众参与`互动的氛围感`=>礼物打赏 + +- 直播带货`:鱼缸(好看/鱼粮(营养),橱窗直接下单 + +- 用户关注=>涨粉丝: 接商业广告 + +整个过程,既不是搬运,也是AI数字人,是`真实环境` + +所以,你看,无人直播,也可以是直接插入`usb摄像头`,无惧电池爆炸,24小时实时直播 + +自动收打赏/带货/涨粉 + +> Homelab工作室自动化 + +在实际的运营业务中,还需要很多的配件支持 + +比如 路由器,网线,视频采集卡,风扇,插座等普通配件 + +也有`显卡欺骗器,5g通信模块,可刷写的esim实体卡`这些特殊硬件 + +qwqwqwwq_20251231_015544_21050.jpg + +`云手机+软路由+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)以恢复权益

-
- setUserId(e.target.value)} - placeholder="例如:user1234567890" - disabled={loading} - style={{ - width: "100%", - padding: "0.75rem 1rem", - fontSize: "1rem", - border: "1px solid var(--border)", - borderRadius: "8px", - background: "var(--background)", - color: "var(--foreground)", - marginBottom: "0.75rem", - }} - /> - {error && ( -

- {error} -

- )} - -
+
); 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 ( +
+ setUserId(e.target.value)} + placeholder="例如:user1234567890" + disabled={loading} + style={{ + width: "100%", + padding: "0.75rem 1rem", + fontSize: "1rem", + border: "1px solid var(--border)", + borderRadius: "8px", + background: "var(--background)", + color: "var(--foreground)", + marginBottom: "0.75rem", + }} + /> + {error && ( +

+ {error} +

+ )} + +
+ ); +} 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)); +}