's'
This commit is contained in:
@@ -227,46 +227,52 @@ export function VideoPlayer({ src, poster, title, className = "", videoId, onCom
|
|||||||
playsInline
|
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 && (
|
{!playing && (
|
||||||
<button
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-black/20 transition hover:bg-black/30">
|
||||||
type="button"
|
{showResumePrompt ? (
|
||||||
onClick={togglePlay}
|
<>
|
||||||
className="absolute inset-0 flex items-center justify-center bg-black/20 transition hover:bg-black/30"
|
<p className="text-center text-sm text-white/90 sm:text-base">
|
||||||
aria-label="播放"
|
上次看到 <span className="font-semibold text-white">{formatResumeTime(savedTime)}</span>
|
||||||
>
|
</p>
|
||||||
<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">
|
<div className="flex flex-col gap-3 sm:flex-row sm:gap-4">
|
||||||
<svg
|
<button
|
||||||
className="ml-1 h-8 w-8 text-black sm:h-10 sm:w-10"
|
type="button"
|
||||||
fill="currentColor"
|
onClick={handleResume}
|
||||||
viewBox="0 0 24 24"
|
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" />
|
<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>
|
<svg
|
||||||
</div>
|
className="ml-1 h-8 w-8 text-black sm:h-10 sm:w-10"
|
||||||
</button>
|
fill="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path d="M8 5v14l11-7z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,30 +1,46 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Header } from "../../../components";
|
import { Header } from "../../../components";
|
||||||
import { VideoPlayer } from "../../../components/VideoPlayer";
|
import { VideoPlayer } from "../../../components/VideoPlayer";
|
||||||
import { LessonMarkdown } from "./LessonMarkdown";
|
import { LessonMarkdown } from "./LessonMarkdown";
|
||||||
|
import { LessonLayout } from "@/app/components/content/LessonLayout";
|
||||||
import { getLesson } from "../../../config/lessons";
|
import { getLesson } from "../../../config/lessons";
|
||||||
import { canWatchLesson } from "../../../lib/payment";
|
import { canWatchLesson } from "../../../lib/payment";
|
||||||
import { setLastWatched, markCompleted } from "../../../lib/learning";
|
import { setLastWatched, markCompleted, getCompletedLessons } from "../../../lib/learning";
|
||||||
import { DOWNLOAD_CONFIG } from "../../../config/course";
|
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() {
|
export default function LessonPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
|
||||||
const moduleIndex = Number(params.moduleIndex);
|
const moduleIndex = Number(params.moduleIndex);
|
||||||
const lessonIndex = Number(params.lessonIndex);
|
const lessonIndex = Number(params.lessonIndex);
|
||||||
|
|
||||||
const lesson = getLesson(moduleIndex, lessonIndex);
|
const lesson = getLesson(moduleIndex, lessonIndex);
|
||||||
const unlocked = canWatchLesson(moduleIndex, lessonIndex);
|
const unlocked = canWatchLesson(moduleIndex, lessonIndex);
|
||||||
const isFreeTrial = moduleIndex === 0 && lessonIndex < 2;
|
const isFreeTrial = moduleIndex === 0 && lessonIndex < 2;
|
||||||
|
const [completed, setCompleted] = useState(() => getCompletedLessons().size);
|
||||||
|
const { prev: prevLesson, next: nextLesson } = getPrevNextLesson(
|
||||||
|
CURRICULUM_CONFIG.modules,
|
||||||
|
moduleIndex,
|
||||||
|
lessonIndex
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (lesson && unlocked) setLastWatched(moduleIndex, lessonIndex, lesson.name);
|
if (lesson && unlocked) setLastWatched(moduleIndex, lessonIndex, lesson.name);
|
||||||
}, [moduleIndex, lessonIndex, lesson?.name, unlocked]);
|
}, [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)) {
|
if (!lesson || isNaN(moduleIndex) || isNaN(lessonIndex)) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[var(--background)]">
|
<div className="min-h-screen bg-[var(--background)]">
|
||||||
@@ -32,9 +48,7 @@ export default function LessonPage() {
|
|||||||
<main className="pt-20 pb-16">
|
<main className="pt-20 pb-16">
|
||||||
<div className="mx-auto max-w-4xl px-4 py-16 text-center">
|
<div className="mx-auto max-w-4xl px-4 py-16 text-center">
|
||||||
<h1 className="mb-4 text-2xl font-bold">章节不存在</h1>
|
<h1 className="mb-4 text-2xl font-bold">章节不存在</h1>
|
||||||
<p className="mb-6 text-[var(--muted-foreground)]">
|
<p className="mb-6 text-[var(--muted-foreground)]">请检查链接或返回课程首页</p>
|
||||||
请检查链接或返回课程首页
|
|
||||||
</p>
|
|
||||||
<Link
|
<Link
|
||||||
href="/cloudphone"
|
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"
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)]">
|
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)]">
|
||||||
<Header />
|
<Header />
|
||||||
<main className="pt-14 pb-16 md:pt-16">
|
<LessonLayout
|
||||||
<div className="mx-auto max-w-4xl px-4 sm:px-6">
|
courseConfig={CLOUDPHONE_COURSE_CONFIG}
|
||||||
<nav className="animate__animated animate__fadeInDown mb-6 flex items-center gap-2 text-sm" style={{ animationFillMode: "both" }}>
|
moduleIndex={moduleIndex}
|
||||||
<button
|
lessonIndex={lessonIndex}
|
||||||
type="button"
|
lessonName={lesson.name}
|
||||||
onClick={() => router.back()}
|
lessonDuration={lesson.duration}
|
||||||
className="flex items-center gap-1 text-[var(--muted-foreground)] transition hover:text-[var(--foreground)]"
|
isFreeTrial={isFreeTrial}
|
||||||
>
|
unlocked={unlocked}
|
||||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
totalLessons={TOTAL_LESSONS}
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
completedCount={completed}
|
||||||
</svg>
|
prevLesson={prevLesson}
|
||||||
返回
|
nextLesson={nextLesson}
|
||||||
</button>
|
courseHomeHref="/cloudphone"
|
||||||
<span className="text-[var(--muted-foreground)]">/</span>
|
payHref="/cloudphone/pay"
|
||||||
<Link href="/cloudphone" className="text-[var(--muted-foreground)] transition hover:text-[var(--foreground)]">
|
>
|
||||||
课程首页
|
<div className="animate__animated animate__fadeInUp relative mb-12" style={{ animationDelay: "0.2s", animationFillMode: "both" }}>
|
||||||
</Link>
|
{unlocked ? (
|
||||||
</nav>
|
<VideoPlayer
|
||||||
|
src={lesson.videoUrl}
|
||||||
<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" }}>
|
title={lesson.name}
|
||||||
{lesson.name}
|
videoId={`cloudphone-${moduleIndex}-${lessonIndex}`}
|
||||||
{isFreeTrial && (
|
className="shadow-xl"
|
||||||
<span className="rounded bg-[var(--accent)]/20 px-2 py-0.5 text-sm font-normal text-[var(--accent)]">
|
onComplete={() => markCompleted(moduleIndex, lessonIndex)}
|
||||||
试看
|
/>
|
||||||
</span>
|
) : (
|
||||||
)}
|
<div className="relative overflow-hidden rounded-xl bg-black md:rounded-2xl">
|
||||||
</h1>
|
<div className="aspect-video flex flex-col items-center justify-center gap-4 bg-zinc-900">
|
||||||
<p className="mb-8 text-sm text-[var(--muted-foreground)]">
|
<span className="text-6xl">🔒</span>
|
||||||
时长 {lesson.duration}
|
<p className="text-lg text-white/90">报名解锁本章节</p>
|
||||||
</p>
|
<Link
|
||||||
|
href="/cloudphone/pay"
|
||||||
<div className="animate__animated animate__fadeInUp relative mb-12" style={{ animationDelay: "0.2s", animationFillMode: "both" }}>
|
className="rounded-full bg-[var(--accent)] px-6 py-3 font-medium text-[var(--accent-foreground)] transition hover:opacity-90"
|
||||||
{unlocked ? (
|
>
|
||||||
<VideoPlayer
|
立即报名
|
||||||
src={lesson.videoUrl}
|
</Link>
|
||||||
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>
|
</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>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
294
app/components/content/EbookViewer.module.css
Normal file
294
app/components/content/EbookViewer.module.css
Normal 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; }
|
||||||
|
}
|
||||||
200
app/components/content/EbookViewer.tsx
Normal file
200
app/components/content/EbookViewer.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
app/components/content/LessonLayout.tsx
Normal file
151
app/components/content/LessonLayout.tsx
Normal 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
185
app/data/cloudphone.md
Normal 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加速的云端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小时持续带来美金收益,即使在睡觉
|
||||||
|
|
||||||
|
<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)
|
||||||
@@ -3,11 +3,15 @@
|
|||||||
* 后续可替换为 api.hackrobot.cn 真实接口
|
* 后续可替换为 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 = {
|
export const ASSET_STATS = {
|
||||||
topics: 4,
|
topics: 4,
|
||||||
ebooks: 2,
|
ebooks: 2,
|
||||||
videos: 12,
|
videos: TOTAL_VIDEOS,
|
||||||
templates: 8,
|
|
||||||
updateFrequency: "每周",
|
updateFrequency: "每周",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -16,14 +20,14 @@ export const TOPICS = [
|
|||||||
id: "ebook",
|
id: "ebook",
|
||||||
icon: "📖",
|
icon: "📖",
|
||||||
title: "电子书",
|
title: "电子书",
|
||||||
desc: "地理套利·自动化",
|
desc: "知识沉淀·随时阅读",
|
||||||
href: "/ebook",
|
href: "/ebook",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "cloudphone",
|
id: "cloudphone",
|
||||||
icon: "🎬",
|
icon: "🎬",
|
||||||
title: "视频教程",
|
title: "视频教程",
|
||||||
desc: "云手机·无人直播",
|
desc: "实战演练·即学即用",
|
||||||
href: "/cloudphone",
|
href: "/cloudphone",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -36,32 +40,33 @@ export const TOPICS = [
|
|||||||
{
|
{
|
||||||
id: "tools",
|
id: "tools",
|
||||||
icon: "🛒",
|
icon: "🛒",
|
||||||
title: "设备自助",
|
title: "设备自助下单",
|
||||||
desc: "开箱即用",
|
desc: "开箱即用",
|
||||||
href: null,
|
href: "https://store.hackrobot.cn/",
|
||||||
|
locked: true,
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** 模块展示 - 首页展示用 */
|
/** 模块展示 - 首页展示用,电子书/视频教程点击滚动到同页对应区块 */
|
||||||
export const TOPICS_DISPLAY = [
|
export const TOPICS_DISPLAY = [
|
||||||
{ id: "ebook", icon: "📖", title: "电子书", desc: "地理套利·自动化杠杆", href: "/ebook" },
|
{ id: "ebook", icon: "📖", title: "电子书", desc: "知识沉淀·随时阅读", href: "#ebooks" },
|
||||||
{ id: "cloudphone", icon: "🎬", title: "视频教程", desc: "云手机·无人直播", href: "/cloudphone" },
|
{ id: "cloudphone", icon: "🎬", title: "视频教程", desc: "实战演练·即学即用", href: "#video-courses" },
|
||||||
{ id: "community", icon: "👥", title: "私密社群", desc: "抱团出海", href: "https://qun.hackrobot.cn" },
|
{ id: "community", icon: "👥", title: "私密社群", desc: "抱团出海", href: "https://qun.hackrobot.cn", locked: true },
|
||||||
{ id: "tools", icon: "🛒", title: "设备自助", desc: "开箱即用", href: null },
|
{ id: "tools", icon: "🛒", title: "设备自助下单", desc: "开箱即用", href: "https://store.hackrobot.cn/", locked: true },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** 电子书展示 - 首页展示用 */
|
/** 电子书展示 - 首页展示用 */
|
||||||
export const EBOOKS_DISPLAY = [
|
export const EBOOKS_DISPLAY = [
|
||||||
{ id: "1", title: "数字游民", desc: "地理套利与自动化杠杆", href: "/ebook" },
|
{ id: "1", title: "数字游民", desc: "地理套利与自动化杠杆", href: "/ebook" },
|
||||||
{ id: "2", title: "云手机", desc: "低成本工作室搭建指南", href: "/cloudphone" },
|
{ id: "2", title: "云手机", desc: "低成本工作室搭建指南", href: "/ebook/cloudphone" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** 视频教程展示 - 参考 digital.hackrobot.cn 资源导航专题,至少 4 个 */
|
/** 视频教程展示 - 参考 digital.hackrobot.cn 资源导航专题,至少 4 个 */
|
||||||
export const VIDEO_COURSES_DISPLAY = [
|
export const VIDEO_COURSES_DISPLAY = [
|
||||||
{ id: "cloudphone", icon: "☁️", title: "云手机模块", desc: "低成本自建云手机,TikTok/YouTube 运营搭建指南", href: "/topic/cloudphone" },
|
{ id: "cloudphone", icon: "☁️", title: "云手机", desc: "低成本自建云手机,TikTok/YouTube 运营搭建指南", href: "/cloudphone" },
|
||||||
{ id: "livestream", icon: "📺", title: "无人直播模块", desc: "TikTok/YouTube 无人直播,24 小时自动化", href: "/topic/livestream" },
|
{ id: "livestream", icon: "📺", title: "无人直播", desc: "TikTok/YouTube 无人直播,24 小时自动化", href: "/topic/livestream" },
|
||||||
{ id: "indie", icon: "🛠️", title: "独立开发模块", desc: "从想法到产品,独立开发者全流程指南", href: "/topic/indie" },
|
{ id: "indie", icon: "🛠️", title: "独立开发", desc: "从想法到产品,独立开发者全流程指南", href: "/topic/indie" },
|
||||||
{ id: "aiagent", icon: "🤖", title: "AI Agent 模块", desc: "AIGC 与智能体,Vibe Coding 跨界创造", href: "/topic/aiagent" },
|
{ id: "aiagent", icon: "🤖", title: "AI Agent", desc: "AIGC 与智能体,Vibe Coding 跨界创造", href: "/topic/aiagent" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const COMPARE_ITEMS = [
|
export const COMPARE_ITEMS = [
|
||||||
@@ -81,7 +86,7 @@ export const COMMUNITY_PREVIEW = [
|
|||||||
export const FAQ_ITEMS = [
|
export const FAQ_ITEMS = [
|
||||||
{
|
{
|
||||||
q: "会员包含哪些内容?",
|
q: "会员包含哪些内容?",
|
||||||
a: "电子书、视频教程、私密社群、即时问答、设备自助等全部解锁,并享受持续更新。",
|
a: "电子书、视频教程、私密社群、即时问答、设备自助下单等全部解锁,并享受持续更新。",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
q: "如何加入?",
|
q: "如何加入?",
|
||||||
|
|||||||
23
app/ebook/cloudphone/page.tsx
Normal file
23
app/ebook/cloudphone/page.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { parseEbookMarkdown } from "../lib/ebook";
|
import { parseEbookMarkdown, estimateReadingMinutes } from "@/lib/content/ebook";
|
||||||
import EbookClient from "./EbookClient";
|
import { EbookViewer } from "@/app/components/content/EbookViewer";
|
||||||
|
import { EBOOK_CONFIG } from "@/config/content.config";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "电子书 | 数字游民",
|
title: "电子书 | 数字游民",
|
||||||
@@ -12,6 +13,11 @@ export default function EbookPage() {
|
|||||||
const mdPath = path.join(process.cwd(), "app/data/ebook.md");
|
const mdPath = path.join(process.cwd(), "app/data/ebook.md");
|
||||||
const source = readFileSync(mdPath, "utf-8");
|
const source = readFileSync(mdPath, "utf-8");
|
||||||
const data = parseEbookMarkdown(source);
|
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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,10 @@
|
|||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
|
|||||||
@@ -259,10 +259,6 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
}, [getOrCreateUserId]);
|
}, [getOrCreateUserId]);
|
||||||
|
|
||||||
const handleLogin = useCallback(() => {
|
|
||||||
document.getElementById("login")?.scrollIntoView({ behavior: "smooth" });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleVerifySuccess = useCallback(() => {
|
const handleVerifySuccess = useCallback(() => {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -366,7 +362,6 @@ export default function Home() {
|
|||||||
) : (
|
) : (
|
||||||
<LandingPage
|
<LandingPage
|
||||||
onJoin={handlePay}
|
onJoin={handlePay}
|
||||||
onLogin={handleLogin}
|
|
||||||
onVerify={checkPaymentFromPB}
|
onVerify={checkPaymentFromPB}
|
||||||
onVerifySuccess={handleVerifySuccess}
|
onVerifySuccess={handleVerifySuccess}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ export async function generateMetadata({
|
|||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const { topicId } = await params;
|
const { topicId } = await params;
|
||||||
const titles: Record<string, string> = {
|
const titles: Record<string, string> = {
|
||||||
cloudphone: "云手机模块 | 低成本自建云手机",
|
cloudphone: "云手机 | 低成本自建云手机",
|
||||||
livestream: "无人直播模块 | TikTok/YouTube 24小时自动化",
|
livestream: "无人直播 | TikTok/YouTube 24小时自动化",
|
||||||
indie: "独立开发模块 | 即将上线",
|
indie: "独立开发 | 即将上线",
|
||||||
aiagent: "AI Agent 模块 | 即将上线",
|
aiagent: "AI Agent | 即将上线",
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
title: titles[topicId] ?? "模块 | 异度星球",
|
title: titles[topicId] ?? "模块 | 异度星球",
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const TOPIC_CONFIGS: Record<string, TopicConfig> = {
|
|||||||
cloudphone: {
|
cloudphone: {
|
||||||
id: "cloudphone",
|
id: "cloudphone",
|
||||||
icon: "☁️",
|
icon: "☁️",
|
||||||
title: "云手机模块",
|
title: "云手机",
|
||||||
subtitle: "低成本自建云手机,TikTok/YouTube 运营搭建指南",
|
subtitle: "低成本自建云手机,TikTok/YouTube 运营搭建指南",
|
||||||
desc: "用开源硬件打造云手机,Redroid、AOSP、ffmpeg、SRS,从零搭建无人直播工作室",
|
desc: "用开源硬件打造云手机,Redroid、AOSP、ffmpeg、SRS,从零搭建无人直播工作室",
|
||||||
stats: [
|
stats: [
|
||||||
@@ -36,7 +36,7 @@ export const TOPIC_CONFIGS: Record<string, TopicConfig> = {
|
|||||||
livestream: {
|
livestream: {
|
||||||
id: "livestream",
|
id: "livestream",
|
||||||
icon: "📺",
|
icon: "📺",
|
||||||
title: "无人直播模块",
|
title: "无人直播",
|
||||||
subtitle: "TikTok/YouTube 无人直播,24 小时自动化",
|
subtitle: "TikTok/YouTube 无人直播,24 小时自动化",
|
||||||
desc: "ffmpeg 拉流转推、SRS 直播服务器、油猴脚本自动化,实现 24 小时无人值守推流",
|
desc: "ffmpeg 拉流转推、SRS 直播服务器、油猴脚本自动化,实现 24 小时无人值守推流",
|
||||||
stats: [
|
stats: [
|
||||||
@@ -50,7 +50,7 @@ export const TOPIC_CONFIGS: Record<string, TopicConfig> = {
|
|||||||
indie: {
|
indie: {
|
||||||
id: "indie",
|
id: "indie",
|
||||||
icon: "🛠️",
|
icon: "🛠️",
|
||||||
title: "独立开发模块",
|
title: "独立开发",
|
||||||
subtitle: "从想法到产品,独立开发者全流程指南",
|
subtitle: "从想法到产品,独立开发者全流程指南",
|
||||||
desc: "即将上线,敬请期待。可先前往数字游民指南了解更多。",
|
desc: "即将上线,敬请期待。可先前往数字游民指南了解更多。",
|
||||||
stats: [
|
stats: [
|
||||||
@@ -63,7 +63,7 @@ export const TOPIC_CONFIGS: Record<string, TopicConfig> = {
|
|||||||
aiagent: {
|
aiagent: {
|
||||||
id: "aiagent",
|
id: "aiagent",
|
||||||
icon: "🤖",
|
icon: "🤖",
|
||||||
title: "AI Agent 模块",
|
title: "AI Agent",
|
||||||
subtitle: "AIGC 与智能体,Vibe Coding 跨界创造",
|
subtitle: "AIGC 与智能体,Vibe Coding 跨界创造",
|
||||||
desc: "即将上线,敬请期待。可先前往数字游民指南了解更多。",
|
desc: "即将上线,敬请期待。可先前往数字游民指南了解更多。",
|
||||||
stats: [
|
stats: [
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { LandingHero } from "./landing/LandingHero";
|
import { LandingHero } from "./landing/LandingHero";
|
||||||
import { AssetStats } from "./landing/AssetStats";
|
import { AssetStats } from "./landing/AssetStats";
|
||||||
import { TopicEbookShowcase } from "./landing/TopicEbookShowcase";
|
import { TopicEbookShowcase } from "./landing/TopicEbookShowcase";
|
||||||
@@ -7,31 +8,36 @@ import { CompareSection } from "./landing/CompareSection";
|
|||||||
import { CommunityPreview } from "./landing/CommunityPreview";
|
import { CommunityPreview } from "./landing/CommunityPreview";
|
||||||
import { FAQSection } from "./landing/FAQSection";
|
import { FAQSection } from "./landing/FAQSection";
|
||||||
import { LandingCTA } from "./landing/LandingCTA";
|
import { LandingCTA } from "./landing/LandingCTA";
|
||||||
import { LoginBlock } from "./landing/LoginBlock";
|
import { LoginModal } from "./landing/LoginModal";
|
||||||
|
|
||||||
type LandingPageProps = {
|
type LandingPageProps = {
|
||||||
onJoin: () => void;
|
onJoin: () => void;
|
||||||
onLogin: () => void;
|
|
||||||
onVerify: (userId: string) => Promise<boolean>;
|
onVerify: (userId: string) => Promise<boolean>;
|
||||||
onVerifySuccess: () => void;
|
onVerifySuccess: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function LandingPage({
|
export function LandingPage({
|
||||||
onJoin,
|
onJoin,
|
||||||
onLogin,
|
|
||||||
onVerify,
|
onVerify,
|
||||||
onVerifySuccess,
|
onVerifySuccess,
|
||||||
}: LandingPageProps) {
|
}: LandingPageProps) {
|
||||||
|
const [loginModalOpen, setLoginModalOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<LandingHero />
|
<LandingHero />
|
||||||
<AssetStats />
|
<AssetStats />
|
||||||
<TopicEbookShowcase />
|
<TopicEbookShowcase onLockedClick={() => setLoginModalOpen(true)} />
|
||||||
<CompareSection />
|
<CompareSection />
|
||||||
<CommunityPreview />
|
<CommunityPreview />
|
||||||
<FAQSection />
|
<FAQSection />
|
||||||
<LandingCTA onJoin={onJoin} onLogin={onLogin} />
|
<LandingCTA onJoin={onJoin} onLogin={() => setLoginModalOpen(true)} />
|
||||||
<LoginBlock onVerify={onVerify} onSuccess={onVerifySuccess} />
|
<LoginModal
|
||||||
|
open={loginModalOpen}
|
||||||
|
onClose={() => setLoginModalOpen(false)}
|
||||||
|
onVerify={onVerify}
|
||||||
|
onSuccess={onVerifySuccess}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ const STAT_ITEMS = [
|
|||||||
{ key: "topics", label: "已解锁模块" },
|
{ key: "topics", label: "已解锁模块" },
|
||||||
{ key: "ebooks", label: "电子书" },
|
{ key: "ebooks", label: "电子书" },
|
||||||
{ key: "videos", label: "视频" },
|
{ key: "videos", label: "视频" },
|
||||||
{ key: "templates", label: "模板" },
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function UnlockedStats() {
|
export function UnlockedStats() {
|
||||||
|
|||||||
@@ -7,13 +7,12 @@ const STAT_ITEMS = [
|
|||||||
{ key: "topics", label: "模块" },
|
{ key: "topics", label: "模块" },
|
||||||
{ key: "ebooks", label: "电子书" },
|
{ key: "ebooks", label: "电子书" },
|
||||||
{ key: "videos", label: "视频" },
|
{ key: "videos", label: "视频" },
|
||||||
{ key: "templates", label: "模板" },
|
|
||||||
{ key: "updateFrequency", label: "更新" },
|
{ key: "updateFrequency", label: "更新" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function AssetStats() {
|
export function AssetStats() {
|
||||||
return (
|
return (
|
||||||
<section className={styles.section}>
|
<section className={`${styles.section} ${styles.statsSection}`}>
|
||||||
<div
|
<div
|
||||||
className={`${styles.statsGrid} animate__animated animate__fadeInUp`}
|
className={`${styles.statsGrid} animate__animated animate__fadeInUp`}
|
||||||
style={{ animationDelay: "0.2s", animationFillMode: "both" }}
|
style={{ animationDelay: "0.2s", animationFillMode: "both" }}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import styles from "../vip.module.css";
|
|||||||
|
|
||||||
export function LandingHero() {
|
export function LandingHero() {
|
||||||
return (
|
return (
|
||||||
<section className={styles.section} style={{ paddingTop: "2rem", textAlign: "center" }}>
|
<section className={`${styles.section} ${styles.heroSection}`} style={{ textAlign: "center" }}>
|
||||||
<p
|
<p
|
||||||
className="animate__animated animate__fadeInDown"
|
className="animate__animated animate__fadeInDown"
|
||||||
style={{
|
style={{
|
||||||
@@ -42,13 +42,14 @@ export function LandingHero() {
|
|||||||
fontSize: "1.125rem",
|
fontSize: "1.125rem",
|
||||||
color: "var(--muted-foreground)",
|
color: "var(--muted-foreground)",
|
||||||
maxWidth: "540px",
|
maxWidth: "540px",
|
||||||
margin: "0",
|
margin: "0 auto",
|
||||||
lineHeight: 1.6,
|
lineHeight: 1.6,
|
||||||
|
textAlign: "center",
|
||||||
animationDelay: "0.3s",
|
animationDelay: "0.3s",
|
||||||
animationFillMode: "both",
|
animationFillMode: "both",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
一次付费,解锁电子书、视频教程、私密社群、即时问答,享受每周更新
|
一次付费,解锁电子书、视频教程、私密社群、即时问答,每周更新
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import styles from "../vip.module.css";
|
import styles from "../vip.module.css";
|
||||||
|
import { LoginForm } from "./LoginForm";
|
||||||
|
|
||||||
type LoginBlockProps = {
|
type LoginBlockProps = {
|
||||||
onVerify: (userId: string) => Promise<boolean>;
|
onVerify: (userId: string) => Promise<boolean>;
|
||||||
@@ -9,41 +10,11 @@ type LoginBlockProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function LoginBlock({ onVerify, onSuccess }: LoginBlockProps) {
|
export function LoginBlock({ onVerify, onSuccess }: LoginBlockProps) {
|
||||||
const [userId, setUserId] = useState("");
|
const [hash, setHash] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hash = typeof window !== "undefined" ? window.location.hash : "";
|
setHash(typeof window !== "undefined" ? window.location.hash : "");
|
||||||
if (hash === "#login") {
|
|
||||||
inputRef.current?.focus();
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<section id="login" className={styles.section} style={{ paddingTop: "2rem" }}>
|
<section id="login" className={styles.section} style={{ paddingTop: "2rem" }}>
|
||||||
<div className={styles.card} style={{ maxWidth: "400px", margin: "0 auto" }}>
|
<div className={styles.card} style={{ maxWidth: "400px", margin: "0 auto" }}>
|
||||||
@@ -51,39 +22,7 @@ export function LoginBlock({ onVerify, onSuccess }: LoginBlockProps) {
|
|||||||
<p className={styles.sectionDesc}>
|
<p className={styles.sectionDesc}>
|
||||||
请输入您的会员账号(支付时使用的 user_id)以恢复权益
|
请输入您的会员账号(支付时使用的 user_id)以恢复权益
|
||||||
</p>
|
</p>
|
||||||
<form onSubmit={handleSubmit}>
|
<LoginForm onVerify={onVerify} onSuccess={onSuccess} autoFocus={hash === "#login"} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
80
app/vip/components/landing/LoginForm.tsx
Normal file
80
app/vip/components/landing/LoginForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
app/vip/components/landing/LoginModal.tsx
Normal file
81
app/vip/components/landing/LoginModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,9 +4,13 @@ import Link from "next/link";
|
|||||||
import { TOPICS_DISPLAY, EBOOKS_DISPLAY, VIDEO_COURSES_DISPLAY } from "@/app/data/vip.mock";
|
import { TOPICS_DISPLAY, EBOOKS_DISPLAY, VIDEO_COURSES_DISPLAY } from "@/app/data/vip.mock";
|
||||||
import styles from "../vip.module.css";
|
import styles from "../vip.module.css";
|
||||||
|
|
||||||
export function TopicEbookShowcase() {
|
type TopicEbookShowcaseProps = {
|
||||||
|
onLockedClick?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TopicEbookShowcase({ onLockedClick }: TopicEbookShowcaseProps) {
|
||||||
return (
|
return (
|
||||||
<section id="topics" className={styles.section}>
|
<section id="topics" className={`${styles.section} ${styles.topicsSection}`}>
|
||||||
{/* 模块展示 */}
|
{/* 模块展示 */}
|
||||||
<h2 className={styles.sectionTitle}>模块</h2>
|
<h2 className={styles.sectionTitle}>模块</h2>
|
||||||
<div
|
<div
|
||||||
@@ -14,13 +18,30 @@ export function TopicEbookShowcase() {
|
|||||||
style={{ animationDelay: "0.2s", animationFillMode: "both", marginBottom: "1.5rem" }}
|
style={{ animationDelay: "0.2s", animationFillMode: "both", marginBottom: "1.5rem" }}
|
||||||
>
|
>
|
||||||
{TOPICS_DISPLAY.map((t) => {
|
{TOPICS_DISPLAY.map((t) => {
|
||||||
|
const isLocked = "locked" in t && t.locked && onLockedClick;
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<span className={styles.topicCardInner}>
|
||||||
|
{isLocked && (
|
||||||
|
<span className={styles.topicLock} title="会员专享">🔒</span>
|
||||||
|
)}
|
||||||
<span className={styles.topicIcon}>{t.icon}</span>
|
<span className={styles.topicIcon}>{t.icon}</span>
|
||||||
<span className={styles.topicTitle}>{t.title}</span>
|
<span className={styles.topicTitle}>{t.title}</span>
|
||||||
<span className={styles.topicDesc}>{t.desc}</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) {
|
if (t.href) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@@ -43,28 +64,36 @@ export function TopicEbookShowcase() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 视频教程展示 - 参考 digital 资源导航,至少 4 个 */}
|
{/* 视频教程展示 - 参考 digital 资源导航,至少 4 个 */}
|
||||||
<h2 className={styles.sectionTitle}>视频教程</h2>
|
<h2 id="video-courses" className={styles.sectionTitle}>视频教程</h2>
|
||||||
<div
|
<div
|
||||||
className={`${styles.topicGrid} animate__animated animate__fadeInUp`}
|
className={`${styles.topicGrid} animate__animated animate__fadeInUp`}
|
||||||
style={{ animationDelay: "0.22s", animationFillMode: "both", marginBottom: "1.5rem" }}
|
style={{ animationDelay: "0.22s", animationFillMode: "both", marginBottom: "1.5rem" }}
|
||||||
>
|
>
|
||||||
{VIDEO_COURSES_DISPLAY.map((v) => (
|
{VIDEO_COURSES_DISPLAY.map((v) =>
|
||||||
<Link
|
v.href ? (
|
||||||
key={v.id}
|
<Link
|
||||||
href={v.href}
|
key={v.id}
|
||||||
className={styles.topicCard}
|
href={v.href}
|
||||||
target={v.href.startsWith("http") ? "_blank" : undefined}
|
className={styles.topicCard}
|
||||||
rel={v.href.startsWith("http") ? "noopener noreferrer" : undefined}
|
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.topicIcon}>{v.icon}</span>
|
||||||
<span className={styles.topicDesc}>{v.desc}</span>
|
<span className={styles.topicTitle}>{v.title}</span>
|
||||||
</Link>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* 电子书展示 */}
|
{/* 电子书展示 */}
|
||||||
<h2 className={styles.sectionTitle}>电子书</h2>
|
<h2 id="ebooks" className={styles.sectionTitle}>电子书</h2>
|
||||||
<div
|
<div
|
||||||
className="animate__animated animate__fadeInUp"
|
className="animate__animated animate__fadeInUp"
|
||||||
style={{
|
style={{
|
||||||
@@ -75,28 +104,48 @@ export function TopicEbookShowcase() {
|
|||||||
animationFillMode: "both",
|
animationFillMode: "both",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{EBOOKS_DISPLAY.map((e) => (
|
{EBOOKS_DISPLAY.map((e) => {
|
||||||
<Link
|
const cardContent = (
|
||||||
key={e.id}
|
<>
|
||||||
href={e.href}
|
<span style={{ fontSize: "1.5rem", marginBottom: "0.5rem", display: "block" }}>📖</span>
|
||||||
className={styles.card}
|
<div style={{ fontWeight: 600, color: "var(--foreground)", marginBottom: "0.25rem" }}>
|
||||||
style={{
|
《{e.title}》
|
||||||
display: "block",
|
</div>
|
||||||
padding: "1.25rem",
|
<div style={{ fontSize: "0.9rem", color: "var(--muted-foreground)" }}>{e.desc}</div>
|
||||||
textDecoration: "none",
|
<div style={{ marginTop: "0.75rem", fontSize: "0.85rem", color: "var(--muted-foreground)" }}>
|
||||||
color: "inherit",
|
阅读 →
|
||||||
}}
|
</div>
|
||||||
>
|
</>
|
||||||
<span style={{ fontSize: "1.5rem", marginBottom: "0.5rem", display: "block" }}>📖</span>
|
);
|
||||||
<div style={{ fontWeight: 600, color: "var(--foreground)", marginBottom: "0.25rem" }}>
|
return e.href ? (
|
||||||
《{e.title}》
|
<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>
|
||||||
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -71,6 +71,72 @@
|
|||||||
padding: 1.5rem 0;
|
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 {
|
.sectionTitle {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -174,7 +240,7 @@
|
|||||||
|
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
.statsGrid {
|
.statsGrid {
|
||||||
grid-template-columns: repeat(5, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,9 +270,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.topicCard {
|
.topicCard {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 1.25rem 1rem;
|
padding: 1.25rem 1rem;
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
@@ -217,6 +285,14 @@
|
|||||||
transition: border-color 0.2s, transform 0.2s;
|
transition: border-color 0.2s, transform 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topicCardInner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.topicCard:hover {
|
.topicCard:hover {
|
||||||
border-color: rgba(245, 158, 11, 0.4);
|
border-color: rgba(245, 158, 11, 0.4);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
@@ -238,6 +314,14 @@
|
|||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topicLock {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.75rem;
|
||||||
|
right: 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
/* Loading */
|
/* Loading */
|
||||||
.loading {
|
.loading {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
|||||||
30
config/content.config.ts
Normal file
30
config/content.config.ts
Normal 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
31
lib/content/config.ts
Normal 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;
|
||||||
|
};
|
||||||
26
lib/content/course-utils.ts
Normal file
26
lib/content/course-utils.ts
Normal 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
51
lib/content/ebook.ts
Normal 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));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user