This commit is contained in:
eric
2026-03-12 00:34:50 -05:00
parent 0c2ccadb6b
commit 5caf970c7e
26 changed files with 1528 additions and 288 deletions

View File

@@ -227,46 +227,52 @@ export function VideoPlayer({ src, poster, title, className = "", videoId, onCom
playsInline
/>
{showResumePrompt && (
<div className="absolute bottom-20 left-3 z-10 max-w-[280px] rounded-lg bg-black/85 px-4 py-3 shadow-xl backdrop-blur sm:left-4 sm:bottom-24">
<p className="mb-3 text-sm text-white/90">
<span className="font-semibold text-white">{formatResumeTime(savedTime)}</span>
</p>
<div className="flex gap-2">
<button
type="button"
onClick={handleResume}
className="flex-1 rounded-md bg-[var(--accent)] px-3 py-2 text-sm font-medium text-black transition hover:opacity-90"
>
</button>
<button
type="button"
onClick={handleRestart}
className="rounded-md border border-white/30 bg-white/10 px-3 py-2 text-sm text-white transition hover:bg-white/20"
>
</button>
</div>
</div>
)}
{!playing && (
<button
type="button"
onClick={togglePlay}
className="absolute inset-0 flex items-center justify-center bg-black/20 transition hover:bg-black/30"
aria-label="播放"
>
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-white/90 shadow-lg transition hover:scale-110 sm:h-20 sm:w-20">
<svg
className="ml-1 h-8 w-8 text-black sm:h-10 sm:w-10"
fill="currentColor"
viewBox="0 0 24 24"
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-black/20 transition hover:bg-black/30">
{showResumePrompt ? (
<>
<p className="text-center text-sm text-white/90 sm:text-base">
<span className="font-semibold text-white">{formatResumeTime(savedTime)}</span>
</p>
<div className="flex flex-col gap-3 sm:flex-row sm:gap-4">
<button
type="button"
onClick={handleResume}
className="flex items-center justify-center gap-2 rounded-xl bg-[var(--accent)] px-6 py-3.5 text-base font-medium text-black shadow-lg transition hover:scale-105 hover:opacity-95 sm:px-8"
>
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</button>
<button
type="button"
onClick={handleRestart}
className="rounded-xl border border-white/40 bg-white/10 px-5 py-3 text-sm font-medium text-white backdrop-blur transition hover:bg-white/20"
>
</button>
</div>
</>
) : (
<button
type="button"
onClick={togglePlay}
className="flex items-center justify-center"
aria-label="播放"
>
<path d="M8 5v14l11-7z" />
</svg>
</div>
</button>
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-white/90 shadow-lg transition hover:scale-110 sm:h-20 sm:w-20">
<svg
className="ml-1 h-8 w-8 text-black sm:h-10 sm:w-10"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M8 5v14l11-7z" />
</svg>
</div>
</button>
)}
</div>
)}
<div

View File

@@ -1,30 +1,46 @@
"use client";
import { useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import { Header } from "../../../components";
import { VideoPlayer } from "../../../components/VideoPlayer";
import { LessonMarkdown } from "./LessonMarkdown";
import { LessonLayout } from "@/app/components/content/LessonLayout";
import { getLesson } from "../../../config/lessons";
import { canWatchLesson } from "../../../lib/payment";
import { setLastWatched, markCompleted } from "../../../lib/learning";
import { DOWNLOAD_CONFIG } from "../../../config/course";
import { setLastWatched, markCompleted, getCompletedLessons } from "../../../lib/learning";
import { CURRICULUM_CONFIG, DOWNLOAD_CONFIG } from "../../../config/course";
import { CLOUDPHONE_COURSE_CONFIG } from "@/config/content.config";
import { getPrevNextLesson } from "@/lib/content/course-utils";
const TOTAL_LESSONS = CURRICULUM_CONFIG.modules.reduce((s, m) => 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 (
<div className="min-h-screen bg-[var(--background)]">
@@ -32,9 +48,7 @@ export default function LessonPage() {
<main className="pt-20 pb-16">
<div className="mx-auto max-w-4xl px-4 py-16 text-center">
<h1 className="mb-4 text-2xl font-bold"></h1>
<p className="mb-6 text-[var(--muted-foreground)]">
</p>
<p className="mb-6 text-[var(--muted-foreground)]"></p>
<Link
href="/cloudphone"
className="inline-flex items-center gap-2 rounded-full bg-[var(--accent)] px-6 py-3 font-medium text-[var(--accent-foreground)] transition hover:opacity-90"
@@ -50,100 +64,81 @@ export default function LessonPage() {
return (
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)]">
<Header />
<main className="pt-14 pb-16 md:pt-16">
<div className="mx-auto max-w-4xl px-4 sm:px-6">
<nav className="animate__animated animate__fadeInDown mb-6 flex items-center gap-2 text-sm" style={{ animationFillMode: "both" }}>
<button
type="button"
onClick={() => router.back()}
className="flex items-center gap-1 text-[var(--muted-foreground)] transition hover:text-[var(--foreground)]"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="text-[var(--muted-foreground)]">/</span>
<Link href="/cloudphone" className="text-[var(--muted-foreground)] transition hover:text-[var(--foreground)]">
</Link>
</nav>
<h1 className="animate__animated animate__fadeInUp mb-2 flex items-center gap-2 text-xl font-bold sm:text-2xl md:text-3xl" style={{ animationDelay: "0.1s", animationFillMode: "both" }}>
{lesson.name}
{isFreeTrial && (
<span className="rounded bg-[var(--accent)]/20 px-2 py-0.5 text-sm font-normal text-[var(--accent)]">
</span>
)}
</h1>
<p className="mb-8 text-sm text-[var(--muted-foreground)]">
{lesson.duration}
</p>
<div className="animate__animated animate__fadeInUp relative mb-12" style={{ animationDelay: "0.2s", animationFillMode: "both" }}>
{unlocked ? (
<VideoPlayer
src={lesson.videoUrl}
title={lesson.name}
videoId={`cloudphone-${moduleIndex}-${lessonIndex}`}
className="shadow-xl"
onComplete={() => markCompleted(moduleIndex, lessonIndex)}
/>
) : (
<div className="relative overflow-hidden rounded-xl bg-black md:rounded-2xl">
<div className="aspect-video flex flex-col items-center justify-center gap-4 bg-zinc-900">
<span className="text-6xl">🔒</span>
<p className="text-lg text-white/90"></p>
<Link
href="/cloudphone/pay"
className="rounded-full bg-[var(--accent)] px-6 py-3 font-medium text-[var(--accent-foreground)] transition hover:opacity-90"
>
</Link>
</div>
<LessonLayout
courseConfig={CLOUDPHONE_COURSE_CONFIG}
moduleIndex={moduleIndex}
lessonIndex={lessonIndex}
lessonName={lesson.name}
lessonDuration={lesson.duration}
isFreeTrial={isFreeTrial}
unlocked={unlocked}
totalLessons={TOTAL_LESSONS}
completedCount={completed}
prevLesson={prevLesson}
nextLesson={nextLesson}
courseHomeHref="/cloudphone"
payHref="/cloudphone/pay"
>
<div className="animate__animated animate__fadeInUp relative mb-12" style={{ animationDelay: "0.2s", animationFillMode: "both" }}>
{unlocked ? (
<VideoPlayer
src={lesson.videoUrl}
title={lesson.name}
videoId={`cloudphone-${moduleIndex}-${lessonIndex}`}
className="shadow-xl"
onComplete={() => markCompleted(moduleIndex, lessonIndex)}
/>
) : (
<div className="relative overflow-hidden rounded-xl bg-black md:rounded-2xl">
<div className="aspect-video flex flex-col items-center justify-center gap-4 bg-zinc-900">
<span className="text-6xl">🔒</span>
<p className="text-lg text-white/90"></p>
<Link
href="/cloudphone/pay"
className="rounded-full bg-[var(--accent)] px-6 py-3 font-medium text-[var(--accent-foreground)] transition hover:opacity-90"
>
</Link>
</div>
)}
</div>
{unlocked && (
<div className="animate__animated animate__fadeInUp relative rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm md:rounded-2xl md:p-8" style={{ animationDelay: "0.3s", animationFillMode: "both" }}>
<h2 className="mb-6 flex items-center gap-2 text-lg font-semibold">
<svg className="h-5 w-5 text-[var(--accent)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</h2>
<LessonMarkdown content={lesson.markdown} />
</div>
)}
{DOWNLOAD_CONFIG.enabled && unlocked && (
<div className="mt-8 rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm md:rounded-2xl md:p-8">
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold">
<svg className="h-5 w-5 text-[var(--accent)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
{DOWNLOAD_CONFIG.title}
</h2>
<p className="mb-4 text-sm text-[var(--muted-foreground)]">
</p>
<Link
href={DOWNLOAD_CONFIG.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-full bg-[var(--accent)] px-6 py-3 font-medium text-[var(--accent-foreground)] transition hover:opacity-90"
>
{DOWNLOAD_CONFIG.buttonText}
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</Link>
</div>
)}
</div>
</main>
{unlocked && (
<div className="animate__animated animate__fadeInUp relative rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm md:rounded-2xl md:p-8" style={{ animationDelay: "0.3s", animationFillMode: "both" }}>
<h2 className="mb-6 flex items-center gap-2 text-lg font-semibold">
<svg className="h-5 w-5 text-[var(--accent)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</h2>
<LessonMarkdown content={lesson.markdown} />
</div>
)}
{DOWNLOAD_CONFIG.enabled && unlocked && (
<div className="mt-8 rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm md:rounded-2xl md:p-8">
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold">
<svg className="h-5 w-5 text-[var(--accent)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
{DOWNLOAD_CONFIG.title}
</h2>
<p className="mb-4 text-sm text-[var(--muted-foreground)]"></p>
<Link
href={DOWNLOAD_CONFIG.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-full bg-[var(--accent)] px-6 py-3 font-medium text-[var(--accent-foreground)] transition hover:opacity-90"
>
{DOWNLOAD_CONFIG.buttonText}
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</Link>
</div>
)}
</LessonLayout>
</div>
);
}

View File

@@ -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; }
}

View File

@@ -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<HTMLDivElement>(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 (
<div
className={`${styles.chunkEnter} animate__animated animate__fadeInUp`}
style={{ animationDuration: "0.5s", animationFillMode: "both" }}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
return (
<div ref={ref} className={styles.chunkPlaceholder} style={{ minHeight: estimatedHeight }} />
);
}
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<number | null>(null);
const contentRef = useRef<HTMLDivElement>(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 (
<>
<div className={styles.readingProgress} style={{ width: `${readProgress}%` }} />
<div className={styles.headerSpacer} />
<header className={`${styles.header} animate__animated animate__fadeInDown`} style={{ animationFillMode: "both" }}>
<Link href={config.backHref} className={styles.headerLeft} aria-label="返回">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 18l-6-6 6-6" />
</svg>
</Link>
<div className={styles.headerMiddle}>
{config.title}
{readingTimeLeft !== null && readingTimeLeft > 0 && (
<span className={styles.readingTime}> · {readingTimeLeft} </span>
)}
</div>
<button
type="button"
className={`${styles.headerRight} ${showDrawer ? styles.headerRightActive : ""}`}
onClick={() => setShowDrawer(!showDrawer)}
aria-label="目录"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
<span className={styles.headerRightText}></span>
</button>
</header>
<div className={styles.book}>
<div ref={contentRef} className={`markdown-body ${styles.markdownBody}`}>
{chunks.map((chunk, i) => (
<LazyChunk
key={i}
html={chunk.html}
estimatedHeight={chunk.estimatedHeight}
forceVisible={forceShowAll || i === 0}
/>
))}
</div>
</div>
<div
className={`${styles.drawerMask} ${showDrawer ? styles.drawerMaskOpen : ""}`}
onClick={() => setShowDrawer(false)}
aria-hidden="true"
/>
<aside className={`${styles.drawer} ${showDrawer ? styles.drawerOpen : ""}`}>
{headers.map((titleItem, index) => (
<div
key={index}
role="button"
tabIndex={0}
className={styles.drawerItem}
onClick={() => handleJump(index)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") handleJump(index);
}}
>
{titleItem}
</div>
))}
</aside>
{showBackTop && (
<button
type="button"
className={styles.backToTop}
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
aria-label="回到顶部"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 15l-6-6-6 6" />
</svg>
</button>
)}
</>
);
}

View File

@@ -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 (
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)]">
<main className="pt-14 pb-16 md:pt-16">
<div className="mx-auto max-w-4xl px-4 sm:px-6">
{/* 进度条 */}
<div className="mb-6">
<div className="flex justify-between text-xs text-[var(--muted-foreground)] mb-1">
<span></span>
<span>{completedCount}/{totalLessons} · {progressPercent}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-[var(--muted)]">
<div
className="h-full rounded-full bg-[var(--accent)] transition-all duration-300"
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
{/* 导航 */}
<nav className="mb-6 flex items-center gap-2 text-sm">
<button
type="button"
onClick={() => router.back()}
className="flex items-center gap-1 text-[var(--muted-foreground)] transition hover:text-[var(--foreground)]"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="text-[var(--muted-foreground)]">/</span>
<Link href={courseHomeHref} className="text-[var(--muted-foreground)] transition hover:text-[var(--foreground)]">
</Link>
</nav>
{/* 标题 */}
<h1 className="mb-2 flex items-center gap-2 text-xl font-bold sm:text-2xl md:text-3xl">
{lessonName}
{isFreeTrial && (
<span className="rounded bg-[var(--accent)]/20 px-2 py-0.5 text-sm font-normal text-[var(--accent)]">
</span>
)}
</h1>
<p className="mb-8 text-sm text-[var(--muted-foreground)]"> {lessonDuration}</p>
{/* 内容区 */}
{children}
{/* 上/下一节 */}
<div className="mt-12 flex flex-col gap-3 sm:flex-row sm:justify-between">
{prevLesson ? (
<Link
href={`${courseBase}/course/${prevLesson.moduleIndex}/${prevLesson.lessonIndex}`}
className="flex items-center gap-2 rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 text-sm font-medium transition hover:border-[var(--accent)]/50"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</Link>
) : (
<div />
)}
{nextLesson ? (
<Link
href={`${courseBase}/course/${nextLesson.moduleIndex}/${nextLesson.lessonIndex}`}
className="flex items-center gap-2 rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 text-sm font-medium transition hover:border-[var(--accent)]/50 ml-auto"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
) : (
<Link
href={courseHomeHref}
className="flex items-center gap-2 rounded-xl bg-[var(--accent)] px-4 py-3 text-sm font-medium text-[var(--accent-foreground)] transition hover:opacity-90 ml-auto"
>
</Link>
)}
</div>
</div>
</main>
</div>
);
}

185
app/data/cloudphone.md Normal file
View File

@@ -0,0 +1,185 @@
# 云手机搭建
数字游民:低成本工作室:云手机,无人直播tiktok/youtube,赚美金 (7)
## 低成本工作室- 硬件篇
> `开源硬件`才是超级杠杆
<img src="https://minioweb.hackrobot.cn/hackrobot/999554b9-1a4b-4360-8bbf-79d8cf0be394_20251231_015301_20521.jpg" title="" alt="999554b9-1a4b-4360-8bbf-79d8cf0be394_20251231_015301_20521.jpg" width="401">
试想一下,如果你不能从事现在的工作了,你最好的归宿是什么?
我们都会从最初的青涩,到迷茫,到现在的迷惑
所以有一阵子,就一直不停地问AI (chatgpt/grok/gemini ),得到的结论就是`工作室`
> 极致的低成本`创业/副业`
三四线城市或者小镇乡村,最佳的资源杠杆,在`人力资源`排名之前的是:
- 便宜的租房 /家用宽带 /居民电价
只需要再添一个`物理服务器`,就可以打开`互联网搬砖`的通道,成本仅此,
而设备最佳的业务形态是:`云手机`=>开启`无人直播`
交流可后台私信: `加群`
当然,出海赚美金的业务,不止一种,可参考:
1. Tiktok无人直播
2. Youtube自媒体
3. web (saas+web3)
<img src="https://minioweb.hackrobot.cn/hackrobot/image_20251231_015321_20586.jpg" title="" alt="image_20251231_015321_20586.jpg" width="431">
---
## 云手机
> 自建方案
`商业运营平台`,以及二手安卓`工作机`(硬改直供电),或者`主板机`,均不在后续的阐述里,本篇的核心主题是个人如何利用`低成本开源硬件自建云手机`
为什么要这么折腾?`开箱就用`才是赚钱效率的真谛不是吗?
别急,看完你也许会有答案
<img src="https://minioweb.hackrobot.cn/hackrobot/20251231001912_20251231_015340_20645.png" title="" alt="20251231001912_20251231_015340_20645.png" width="425">
> Redroid
*Redroid**远程*安卓*系统*是一款基于GPU加速的云端AndroidAIC解决方案。可以在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小时持续带来美金收益,即使在睡觉
<img src="https://minioweb.hackrobot.cn/hackrobot/20251230235454_20251231_015401_20717.png" title="" alt="20251230235454_20251231_015401_20717.png" width="439">
> ARM开发板的aosp安卓系统
现在的开发板,除了linux,大多也支持aosp安卓系统,`有源码,可以自行编译`
如果缺少组件/功能,就可以随时`开发/定制`
比如:不支持uvc摄像头=>编译HAL,不支持google全家桶=>安装,把apk应用/脚本=>系统内置
一个开发板的价格:`仅二百多`,就可以搭建一台云手机
如下图所示:
- `RPA转播脚本=>自建TK云手机=>真机查看效果`
<img src="https://minioweb.hackrobot.cn/hackrobot/20251230234249_20251231_015420_20779.png" title="" alt="20251230234249_20251231_015420_20779.png" width="410">
---
## 软路由
> 出海网络的基础设备
云手机只是业务形态:直播
但是出海业务千千万万,最离不开的网络基础设施:`软路由`
没有它,可谓寸步难行
<img src="https://minioweb.hackrobot.cn/hackrobot/20251231002830_20251231_015441_20847.png" title="" alt="20251231002830_20251231_015441_20847.png" width="417">
家用的路由器,只不过是`残缺`的系统,是`被阉割`
要想勇闯互联网,你必须了解`真正的网络`是什么!!
比如:`openwrt`
知道什么是`科学代理,P2P组网,内网穿透,tun中继,DNS解析,mqtt通信`等等
如同瑞士军刀,为业务披荆斩棘
<img src="https://minioweb.hackrobot.cn/hackrobot/20251230235757_20251231_015459_20906.png" title="" alt="20251230235757_20251231_015459_20906.png" width="432">
简单来说,你必须拥有`科学姿势上网`的能力,且必须是`静态住宅ip`
科学姿势,只能让你扩大眼界,静态住宅ip才是赚美金的`门票`
可参考出海导航: `nomadro.com`
拥有一个软路由,你就找到了通往世界探索的隐秘入口
---
## ESP32
> 智能实验室homelab控制
在前面的`数字游民系列`,提到过esp32+传感器+执行器可以用于直播互动游戏
比如: `生态造景=>云养鱼`
简单来说,观众可以看到 鱼缸/草苔/宠物等
下图为调试场景:
<img src="https://minioweb.hackrobot.cn/hackrobot/498e7c59a7a7c1494a596b4010e1fe55_20251231_015517_20965.jpg" title="" alt="498e7c59a7a7c1494a596b4010e1fe55_20251231_015517_20965.jpg" width="411">
通过弹幕评论 (比吃/666) /礼物打赏 (鲜花/钻石) ,发出指令,驱动效果:
- 喂食器自动出粮
- 灯带色彩/音乐变化
- 氧气泵开启/关闭
实现的原理是,就是利用python代码监听直播间事件
mqtt网关驱动esp32控制传感器+执行器,进行互动
`盈利逻辑`
- 观众参与`互动的氛围感`=>礼物打赏
- 直播带货`:鱼缸(好看/鱼粮(营养),橱窗直接下单
- 用户关注=>涨粉丝: 接商业广告
整个过程,既不是搬运,也是AI数字人,是`真实环境`
所以,你看,无人直播,也可以是直接插入`usb摄像头`,无惧电池爆炸,24小时实时直播
自动收打赏/带货/涨粉
> Homelab工作室自动化
在实际的运营业务中,还需要很多的配件支持
比如 路由器,网线,视频采集卡,风扇,插座等普通配件
也有`显卡欺骗器,5g通信模块,可刷写的esim实体卡`这些特殊硬件
<img src="https://minioweb.hackrobot.cn/hackrobot/qwqwqwwq_20251231_015544_21050.jpg" title="" alt="qwqwqwwq_20251231_015544_21050.jpg" width="396">
`云手机+软路由+esp32+配件`,一套成本`不过千`,就可以开始副业之旅
数字游民:低成本工作室:云手机,无人直播tiktok/youtube,赚美金 (8)

View File

@@ -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: "如何加入?",

View File

@@ -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 <EbookViewer data={data} config={config} />;
}

View File

@@ -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 <EbookClient data={data} title="数字游民" backHref="/" />;
return <EbookViewer data={data} config={config} />;
}

View File

@@ -69,6 +69,10 @@
--font-mono: var(--font-geist-mono);
}
html {
scroll-behavior: smooth;
}
body {
background: var(--background);
color: var(--foreground);

View File

@@ -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() {
) : (
<LandingPage
onJoin={handlePay}
onLogin={handleLogin}
onVerify={checkPaymentFromPB}
onVerifySuccess={handleVerifySuccess}
/>

View File

@@ -7,10 +7,10 @@ export async function generateMetadata({
}): Promise<Metadata> {
const { topicId } = await params;
const titles: Record<string, string> = {
cloudphone: "云手机模块 | 低成本自建云手机",
livestream: "无人直播模块 | TikTok/YouTube 24小时自动化",
indie: "独立开发模块 | 即将上线",
aiagent: "AI Agent 模块 | 即将上线",
cloudphone: "云手机 | 低成本自建云手机",
livestream: "无人直播 | TikTok/YouTube 24小时自动化",
indie: "独立开发 | 即将上线",
aiagent: "AI Agent | 即将上线",
};
return {
title: titles[topicId] ?? "模块 | 异度星球",

View File

@@ -23,7 +23,7 @@ export const TOPIC_CONFIGS: Record<string, TopicConfig> = {
cloudphone: {
id: "cloudphone",
icon: "☁️",
title: "云手机模块",
title: "云手机",
subtitle: "低成本自建云手机TikTok/YouTube 运营搭建指南",
desc: "用开源硬件打造云手机Redroid、AOSP、ffmpeg、SRS从零搭建无人直播工作室",
stats: [
@@ -36,7 +36,7 @@ export const TOPIC_CONFIGS: Record<string, TopicConfig> = {
livestream: {
id: "livestream",
icon: "📺",
title: "无人直播模块",
title: "无人直播",
subtitle: "TikTok/YouTube 无人直播24 小时自动化",
desc: "ffmpeg 拉流转推、SRS 直播服务器、油猴脚本自动化,实现 24 小时无人值守推流",
stats: [
@@ -50,7 +50,7 @@ export const TOPIC_CONFIGS: Record<string, TopicConfig> = {
indie: {
id: "indie",
icon: "🛠️",
title: "独立开发模块",
title: "独立开发",
subtitle: "从想法到产品,独立开发者全流程指南",
desc: "即将上线,敬请期待。可先前往数字游民指南了解更多。",
stats: [
@@ -63,7 +63,7 @@ export const TOPIC_CONFIGS: Record<string, TopicConfig> = {
aiagent: {
id: "aiagent",
icon: "🤖",
title: "AI Agent 模块",
title: "AI Agent",
subtitle: "AIGC 与智能体Vibe Coding 跨界创造",
desc: "即将上线,敬请期待。可先前往数字游民指南了解更多。",
stats: [

View File

@@ -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<boolean>;
onVerifySuccess: () => void;
};
export function LandingPage({
onJoin,
onLogin,
onVerify,
onVerifySuccess,
}: LandingPageProps) {
const [loginModalOpen, setLoginModalOpen] = useState(false);
return (
<>
<LandingHero />
<AssetStats />
<TopicEbookShowcase />
<TopicEbookShowcase onLockedClick={() => setLoginModalOpen(true)} />
<CompareSection />
<CommunityPreview />
<FAQSection />
<LandingCTA onJoin={onJoin} onLogin={onLogin} />
<LoginBlock onVerify={onVerify} onSuccess={onVerifySuccess} />
<LandingCTA onJoin={onJoin} onLogin={() => setLoginModalOpen(true)} />
<LoginModal
open={loginModalOpen}
onClose={() => setLoginModalOpen(false)}
onVerify={onVerify}
onSuccess={onVerifySuccess}
/>
</>
);
}

View File

@@ -7,7 +7,6 @@ const STAT_ITEMS = [
{ key: "topics", label: "已解锁模块" },
{ key: "ebooks", label: "电子书" },
{ key: "videos", label: "视频" },
{ key: "templates", label: "模板" },
] as const;
export function UnlockedStats() {

View File

@@ -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 (
<section className={styles.section}>
<section className={`${styles.section} ${styles.statsSection}`}>
<div
className={`${styles.statsGrid} animate__animated animate__fadeInUp`}
style={{ animationDelay: "0.2s", animationFillMode: "both" }}

View File

@@ -4,7 +4,7 @@ import styles from "../vip.module.css";
export function LandingHero() {
return (
<section className={styles.section} style={{ paddingTop: "2rem", textAlign: "center" }}>
<section className={`${styles.section} ${styles.heroSection}`} style={{ textAlign: "center" }}>
<p
className="animate__animated animate__fadeInDown"
style={{
@@ -42,13 +42,14 @@ export function LandingHero() {
fontSize: "1.125rem",
color: "var(--muted-foreground)",
maxWidth: "540px",
margin: "0",
margin: "0 auto",
lineHeight: 1.6,
textAlign: "center",
animationDelay: "0.3s",
animationFillMode: "both",
}}
>
</p>
</section>
);

View File

@@ -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<boolean>;
@@ -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<string | null>(null);
const inputRef = useRef<HTMLInputElement>(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 (
<section id="login" className={styles.section} style={{ paddingTop: "2rem" }}>
<div className={styles.card} style={{ maxWidth: "400px", margin: "0 auto" }}>
@@ -51,39 +22,7 @@ export function LoginBlock({ onVerify, onSuccess }: LoginBlockProps) {
<p className={styles.sectionDesc}>
使 user_id
</p>
<form onSubmit={handleSubmit}>
<input
ref={inputRef}
type="text"
value={userId}
onChange={(e) => 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 && (
<p style={{ fontSize: "0.85rem", color: "#ef4444", marginBottom: "0.75rem" }}>
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className={`${styles.btnPrimary} ${styles.btnLarge}`}
style={{ width: "100%" }}
>
{loading ? "验证中..." : "验证并登录"}
</button>
</form>
<LoginForm onVerify={onVerify} onSuccess={onSuccess} autoFocus={hash === "#login"} />
</div>
</section>
);

View File

@@ -0,0 +1,80 @@
"use client";
import { useState, useRef, useEffect } from "react";
import styles from "../vip.module.css";
type LoginFormProps = {
onVerify: (userId: string) => Promise<boolean>;
onSuccess: () => void;
autoFocus?: boolean;
};
export function LoginForm({ onVerify, onSuccess, autoFocus }: LoginFormProps) {
const [userId, setUserId] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(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 (
<form onSubmit={handleSubmit}>
<input
ref={inputRef}
type="text"
value={userId}
onChange={(e) => 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 && (
<p style={{ fontSize: "0.85rem", color: "#ef4444", marginBottom: "0.75rem" }}>
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className={`${styles.btnPrimary} ${styles.btnLarge}`}
style={{ width: "100%" }}
>
{loading ? "验证中..." : "验证并登录"}
</button>
</form>
);
}

View File

@@ -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<boolean>;
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 (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-labelledby="login-modal-title"
>
{/* 背景遮罩 */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* 弹窗内容 */}
<div
className="relative w-full max-w-[400px] rounded-2xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-2xl animate__animated animate__fadeIn animate__faster"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<h2 id="login-modal-title" className={styles.sectionTitle} style={{ margin: 0 }}>
</h2>
<button
type="button"
onClick={onClose}
className="rounded-lg p-2 text-[var(--muted-foreground)] transition hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
aria-label="关闭"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<p className={styles.sectionDesc} style={{ marginBottom: "1rem" }}>
使 user_id
</p>
<LoginForm onVerify={onVerify} onSuccess={handleSuccess} autoFocus />
</div>
</div>
);
}

View File

@@ -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 (
<section id="topics" className={styles.section}>
<section id="topics" className={`${styles.section} ${styles.topicsSection}`}>
{/* 模块展示 */}
<h2 className={styles.sectionTitle}></h2>
<div
@@ -14,13 +18,30 @@ export function TopicEbookShowcase() {
style={{ animationDelay: "0.2s", animationFillMode: "both", marginBottom: "1.5rem" }}
>
{TOPICS_DISPLAY.map((t) => {
const isLocked = "locked" in t && t.locked && onLockedClick;
const content = (
<>
<span className={styles.topicCardInner}>
{isLocked && (
<span className={styles.topicLock} title="会员专享">🔒</span>
)}
<span className={styles.topicIcon}>{t.icon}</span>
<span className={styles.topicTitle}>{t.title}</span>
<span className={styles.topicDesc}>{t.desc}</span>
</>
</span>
);
if (isLocked) {
return (
<button
key={t.id}
type="button"
className={styles.topicCard}
onClick={onLockedClick}
style={{ cursor: "pointer", border: "none", font: "inherit" }}
>
{content}
</button>
);
}
if (t.href) {
return (
<Link
@@ -43,28 +64,36 @@ export function TopicEbookShowcase() {
</div>
{/* 视频教程展示 - 参考 digital 资源导航,至少 4 个 */}
<h2 className={styles.sectionTitle}></h2>
<h2 id="video-courses" className={styles.sectionTitle}></h2>
<div
className={`${styles.topicGrid} animate__animated animate__fadeInUp`}
style={{ animationDelay: "0.22s", animationFillMode: "both", marginBottom: "1.5rem" }}
>
{VIDEO_COURSES_DISPLAY.map((v) => (
<Link
key={v.id}
href={v.href}
className={styles.topicCard}
target={v.href.startsWith("http") ? "_blank" : undefined}
rel={v.href.startsWith("http") ? "noopener noreferrer" : undefined}
>
<span className={styles.topicIcon}>{v.icon}</span>
<span className={styles.topicTitle}>{v.title}</span>
<span className={styles.topicDesc}>{v.desc}</span>
</Link>
))}
{VIDEO_COURSES_DISPLAY.map((v) =>
v.href ? (
<Link
key={v.id}
href={v.href}
className={styles.topicCard}
target={v.href.startsWith("http") ? "_blank" : undefined}
rel={v.href.startsWith("http") ? "noopener noreferrer" : undefined}
>
<span className={styles.topicIcon}>{v.icon}</span>
<span className={styles.topicTitle}>{v.title}</span>
<span className={styles.topicDesc}>{v.desc}</span>
</Link>
) : (
<div key={v.id} className={styles.topicCard} style={{ cursor: "default", opacity: 0.9 }}>
<span className={styles.topicIcon}>{v.icon}</span>
<span className={styles.topicTitle}>{v.title}</span>
<span className={styles.topicDesc}>{v.desc}</span>
</div>
)
)}
</div>
{/* 电子书展示 */}
<h2 className={styles.sectionTitle}></h2>
<h2 id="ebooks" className={styles.sectionTitle}></h2>
<div
className="animate__animated animate__fadeInUp"
style={{
@@ -75,28 +104,48 @@ export function TopicEbookShowcase() {
animationFillMode: "both",
}}
>
{EBOOKS_DISPLAY.map((e) => (
<Link
key={e.id}
href={e.href}
className={styles.card}
style={{
display: "block",
padding: "1.25rem",
textDecoration: "none",
color: "inherit",
}}
>
<span style={{ fontSize: "1.5rem", marginBottom: "0.5rem", display: "block" }}>📖</span>
<div style={{ fontWeight: 600, color: "var(--foreground)", marginBottom: "0.25rem" }}>
{e.title}
{EBOOKS_DISPLAY.map((e) => {
const cardContent = (
<>
<span style={{ fontSize: "1.5rem", marginBottom: "0.5rem", display: "block" }}>📖</span>
<div style={{ fontWeight: 600, color: "var(--foreground)", marginBottom: "0.25rem" }}>
{e.title}
</div>
<div style={{ fontSize: "0.9rem", color: "var(--muted-foreground)" }}>{e.desc}</div>
<div style={{ marginTop: "0.75rem", fontSize: "0.85rem", color: "var(--muted-foreground)" }}>
</div>
</>
);
return e.href ? (
<Link
key={e.id}
href={e.href}
className={styles.card}
style={{
display: "block",
padding: "1.25rem",
textDecoration: "none",
color: "inherit",
}}
>
{cardContent}
</Link>
) : (
<div
key={e.id}
className={styles.card}
style={{
display: "block",
padding: "1.25rem",
cursor: "default",
opacity: 0.9,
}}
>
{cardContent}
</div>
<div style={{ fontSize: "0.9rem", color: "var(--muted-foreground)" }}>{e.desc}</div>
<div style={{ marginTop: "0.75rem", fontSize: "0.85rem", color: "var(--accent)" }}>
</div>
</Link>
))}
);
})}
</div>
</section>
);

View File

@@ -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;

30
config/content.config.ts Normal file
View File

@@ -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-",
};

31
lib/content/config.ts Normal file
View File

@@ -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;
};

View File

@@ -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,
};
}

51
lib/content/ebook.ts Normal file
View File

@@ -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(/<img /g, '<img loading="lazy" decoding="async" ');
let rawChunks = lazyHtml.split(/(?=<h1[\s>])/i).filter((c) => c.trim());
if (rawChunks.length <= 1) {
rawChunks = lazyHtml.split(/(?=<h2[\s>])/i).filter((c) => c.trim());
}
const headers: string[] = [];
const headerRegex = /<h1[^>]*>(.*?)<\/h1>/gi;
let m;
while ((m = headerRegex.exec(lazyHtml)) !== null) {
headers.push(m[1].replace(/<[^>]+>/g, ""));
}
if (headers.length === 0) {
const h2Regex = /<h2[^>]*>(.*?)<\/h2>/gi;
while ((m = h2Regex.exec(lazyHtml)) !== null) {
headers.push(m[1].replace(/<[^>]+>/g, ""));
}
}
const chunks: EbookChunk[] = rawChunks.map((chunkHtml) => {
const imgCount = (chunkHtml.match(/<img\b/g) || []).length;
const textLen = chunkHtml.replace(/<[^>]+>/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));
}