's'
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
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 真实接口
|
||||
*/
|
||||
|
||||
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: "如何加入?",
|
||||
|
||||
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 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} />;
|
||||
}
|
||||
|
||||
@@ -69,6 +69,10 @@
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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] ?? "模块 | 异度星球",
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ const STAT_ITEMS = [
|
||||
{ key: "topics", label: "已解锁模块" },
|
||||
{ key: "ebooks", label: "电子书" },
|
||||
{ key: "videos", label: "视频" },
|
||||
{ key: "templates", label: "模板" },
|
||||
] as const;
|
||||
|
||||
export function UnlockedStats() {
|
||||
|
||||
@@ -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" }}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
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 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>
|
||||
);
|
||||
|
||||
@@ -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
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