This commit is contained in:
eric
2026-03-10 11:48:50 -05:00
parent eb33ed9360
commit 296d703307
41 changed files with 9072 additions and 121 deletions

View File

@@ -0,0 +1,407 @@
/* 云手机 - video.hackrobot.cn 课程落地页风格 */
.root {
min-height: 100vh;
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 50%, #e2e8f0 100%);
color: #1e293b;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
max-width: 1100px;
margin: 0 auto;
}
.logo {
font-size: 1.25rem;
font-weight: 700;
color: #0f172a;
text-decoration: none;
}
.logo:hover {
color: #0284c7;
}
.backLink {
font-size: 0.9rem;
color: #64748b;
text-decoration: none;
}
.backLink:hover {
color: #0284c7;
}
/* Hero */
.hero {
text-align: center;
padding: 4rem 1.5rem 3rem;
max-width: 800px;
margin: 0 auto;
}
.heroTitle {
font-size: clamp(2rem, 5vw, 3rem);
font-weight: 800;
color: #0f172a;
margin: 0 0 1rem;
letter-spacing: -0.02em;
}
.heroSubtitle {
font-size: 1.1rem;
color: #475569;
margin: 0 0 2rem;
line-height: 1.6;
}
.stats {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 2rem;
margin-bottom: 2rem;
}
.stat {
text-align: center;
}
.statValue {
display: block;
font-size: 2rem;
font-weight: 800;
color: #0284c7;
margin-bottom: 0.25rem;
}
.statLabel {
font-size: 0.9rem;
color: #64748b;
}
.ctaBtn {
display: inline-block;
padding: 0.9rem 2rem;
font-size: 1rem;
font-weight: 600;
color: white;
background: linear-gradient(135deg, #0284c7 0%, #0369a1 100%);
border-radius: 9999px;
text-decoration: none;
box-shadow: 0 4px 14px rgba(2, 132, 199, 0.4);
transition: transform 0.15s, box-shadow 0.15s;
}
.ctaBtn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(2, 132, 199, 0.5);
}
/* Sections */
.section {
max-width: 1100px;
margin: 0 auto;
padding: 3rem 1.5rem;
}
.section h2 {
font-size: 1.75rem;
font-weight: 700;
color: #0f172a;
text-align: center;
margin: 0 0 0.75rem;
}
.sectionDesc {
text-align: center;
color: #64748b;
margin: 0 0 2rem;
line-height: 1.6;
}
/* 这门课适合谁 */
.audienceGrid {
display: grid;
grid-template-columns: repeat(1, 1fr);
gap: 1.25rem;
}
@media (min-width: 640px) {
.audienceGrid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
.audienceGrid {
grid-template-columns: repeat(4, 1fr);
}
}
.audienceCard {
padding: 1.5rem;
background: white;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
border: 1px solid #f1f5f9;
transition: box-shadow 0.2s, transform 0.2s;
}
.audienceCard:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
}
.audienceIcon {
font-size: 2rem;
display: block;
margin-bottom: 0.75rem;
}
.audienceCard h3 {
font-size: 1.05rem;
font-weight: 600;
color: #0f172a;
margin: 0 0 0.5rem;
}
.audienceCard p {
font-size: 0.9rem;
color: #64748b;
margin: 0;
line-height: 1.5;
}
/* 学完你将获得 */
.benefitsList {
display: flex;
flex-direction: column;
gap: 1rem;
}
.benefitItem {
display: flex;
gap: 1rem;
padding: 1.25rem;
background: white;
border-radius: 12px;
border: 1px solid #f1f5f9;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.benefitNum {
flex-shrink: 0;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0284c7 0%, #0369a1 100%);
color: white;
font-weight: 700;
font-size: 1rem;
border-radius: 10px;
}
.benefitItem h3 {
font-size: 1.05rem;
font-weight: 600;
color: #0f172a;
margin: 0 0 0.35rem;
}
.benefitItem p {
font-size: 0.9rem;
color: #64748b;
margin: 0;
line-height: 1.5;
}
/* 实战课程 - 复刻 video.hackrobot.cn 样式 */
.curriculum {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.curriculumBlock {
background: white;
border-radius: 12px;
border: 1px solid #e2e8f0;
overflow: hidden;
transition: box-shadow 0.2s, border-color 0.2s;
}
.curriculumBlock:hover {
border-color: #cbd5e1;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.curriculumHeader {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem 1.25rem;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border-bottom: 1px solid #e2e8f0;
cursor: default;
}
.curriculumHeaderIcon {
font-size: 1rem;
color: #0284c7;
flex-shrink: 0;
}
.curriculumTitle {
flex: 1;
font-size: 1rem;
font-weight: 600;
color: #0f172a;
margin: 0;
}
.curriculumCount {
font-size: 0.8rem;
font-weight: 500;
color: #64748b;
background: #e2e8f0;
padding: 0.2rem 0.5rem;
border-radius: 6px;
}
.curriculumList {
margin: 0;
padding: 0.5rem 0;
list-style: none;
}
.curriculumItem {
margin: 0;
padding: 0;
}
.curriculumItemLink {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.65rem 1.25rem;
color: #334155;
text-decoration: none;
font-size: 0.95rem;
transition: background 0.15s, color 0.15s;
}
.curriculumItemLink:hover {
background: #f0f9ff;
color: #0284c7;
}
.curriculumItemLink::before {
content: "";
width: 5px;
height: 5px;
border-radius: 50%;
background: #cbd5e1;
flex-shrink: 0;
}
.curriculumItemLink:hover::before {
background: #0284c7;
}
.curriculumTryBadge {
font-size: 0.75rem;
color: #0284c7;
margin-left: 0.25rem;
flex-shrink: 0;
}
.curriculumArrow {
margin-left: auto;
font-size: 0.85rem;
color: #94a3b8;
}
.curriculumItemLink:hover .curriculumArrow {
color: #0284c7;
}
/* FAQ */
.faq {
max-width: 600px;
margin: 0 auto;
}
.faqItem {
padding: 1rem 1.25rem;
background: white;
border-radius: 10px;
border: 1px solid #e2e8f0;
margin-bottom: 0.75rem;
}
.faqQ {
font-size: 0.95rem;
font-weight: 500;
color: #334155;
}
/* CTA Section */
.ctaSection {
text-align: center;
padding: 4rem 1.5rem;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
color: white;
}
.ctaSection h2 {
font-size: 1.5rem;
font-weight: 700;
color: white;
margin: 0 0 0.5rem;
}
.ctaSection p {
font-size: 1rem;
color: #94a3b8;
margin: 0 0 1rem;
}
.ctaFeatures {
font-size: 0.9rem;
color: #64748b;
margin-bottom: 1.5rem;
}
.ctaSection .ctaBtn {
background: linear-gradient(135deg, #38bdf8 0%, #0284c7 100%);
}
/* Footer */
.footer {
text-align: center;
padding: 2rem 1.5rem;
border-top: 1px solid #e2e8f0;
}
.footer p {
font-size: 0.9rem;
color: #64748b;
margin: 0 0 0.5rem;
}
.footer a {
font-size: 0.85rem;
color: #0284c7;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}

View File

@@ -0,0 +1,41 @@
"use client";
import { useRef, useEffect, useState, type ReactNode } from "react";
type Props = {
children: ReactNode;
delay?: number;
className?: string;
};
export function AnimateOnScroll({ children, delay = 0, className = "" }: Props) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) setVisible(true);
},
{ threshold: 0.08, rootMargin: "0px 0px -40px 0px" }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return (
<div
ref={ref}
className={className}
style={{
opacity: visible ? 1 : 0,
transform: visible ? "translateY(0)" : "translateY(24px)",
transition: `opacity 0.6s ease-out ${delay}ms, transform 0.6s ease-out ${delay}ms`,
}}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,20 @@
"use client";
const CHAT_URL = "https://chatwoot.hackrobot.cn/livechat?user_id=hackrobot";
export function ChatFloatButton() {
return (
<a
href={CHAT_URL}
target="_blank"
rel="noopener noreferrer"
className="animate__animated animate__bounceIn fixed bottom-6 right-6 z-40 flex h-12 w-12 items-center justify-center rounded-full bg-[var(--accent)] text-[var(--accent-foreground)] shadow-lg shadow-[var(--accent)]/30 transition-all hover:scale-110 hover:shadow-xl hover:shadow-[var(--accent)]/40"
style={{ animationDelay: "0.5s", animationFillMode: "both" }}
aria-label="即时问答"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
</a>
);
}

View File

@@ -0,0 +1,37 @@
"use client";
import Link from "next/link";
import { SITE_CONFIG } from "../config/course";
const CHAT_URL = "https://chatwoot.hackrobot.cn/livechat?user_id=hackrobot";
export function Footer() {
return (
<footer className="animate__animated animate__fadeInUp border-t border-[var(--border)] py-6 sm:py-8" style={{ animationDelay: "0.1s", animationFillMode: "both" }}>
<div className="mx-auto max-w-6xl px-4 sm:px-6">
<div className="mb-4 flex flex-wrap items-center justify-center gap-4">
<Link href="/" className="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)]">
返回首页
</Link>
<Link href="/cloudphone" className="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)]">
课程首页
</Link>
<a
href={CHAT_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-full bg-[var(--accent)]/15 px-4 py-2 text-sm font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/25 hover:text-[var(--accent)]"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
即时问答
</a>
</div>
<div className="text-center text-sm text-[var(--muted-foreground)]">
{SITE_CONFIG.footer}
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,59 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { SITE_CONFIG } from "../config/course";
import { isPaid } from "../lib/payment";
import { ThemeToggle } from "@/app/components/ThemeToggle";
export function Header() {
const [paid, setPaid] = useState(false);
useEffect(() => {
setPaid(isPaid());
const onPayUpdate = () => setPaid(isPaid());
window.addEventListener("pay:updated", onPayUpdate);
return () => window.removeEventListener("pay:updated", onPayUpdate);
}, []);
return (
<header className="animate__animated animate__fadeInDown fixed left-0 right-0 top-0 z-50 border-b border-[var(--border)] bg-[var(--background)]/90 backdrop-blur-xl" style={{ animationFillMode: "both" }}>
<div className="mx-auto flex h-12 max-w-6xl items-center justify-between gap-2 px-3 sm:h-14 sm:gap-4 sm:px-4 md:h-16 md:px-6">
<Link href="/cloudphone" className="min-w-0 truncate text-sm font-medium sm:text-base md:text-xl">
{SITE_CONFIG.brand}
</Link>
<div className="flex shrink-0 items-center gap-1.5 sm:gap-3">
<ThemeToggle />
<a
href="https://chatwoot.hackrobot.cn/livechat?user_id=hackrobot"
target="_blank"
rel="noopener noreferrer"
className="hidden items-center gap-1.5 rounded-full border border-[var(--accent)]/40 bg-[var(--accent)]/10 px-3 py-1.5 text-xs font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/20 sm:inline-flex sm:px-4 sm:py-2 sm:text-sm"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
即时问答
</a>
<Link href="/" className="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)]">
首页
</Link>
<Link
href={paid ? "/cloudphone/course/0/0" : "/cloudphone/pay"}
className="hidden rounded-full bg-[var(--accent)] px-3 py-1.5 text-xs font-medium text-[var(--accent-foreground)] transition hover:opacity-90 sm:inline-flex sm:px-4 sm:py-2 sm:text-sm"
>
{paid ? "进入课程 →" : "立即报名 →"}
</Link>
<Link
href={paid ? "/cloudphone/course/0/0" : "/cloudphone/pay"}
className="inline-flex rounded-full bg-[var(--accent)] p-2 text-[var(--accent-foreground)] sm:hidden"
aria-label={paid ? "进入课程" : "立即报名"}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</Link>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,30 @@
"use client";
/**
* 纯 CSS 粒子背景 - 不依赖 @tsparticles
*/
export function ParticleBackground() {
const dots = Array.from({ length: 30 }, (_, i) => i);
return (
<div
className="absolute inset-0 pointer-events-none overflow-hidden"
aria-hidden
>
{dots.map((i) => (
<div
key={i}
className="absolute rounded-full bg-[var(--accent)] opacity-[0.08] animate-pulse"
style={{
width: `${2 + (i % 4)}px`,
height: `${2 + (i % 4)}px`,
left: `${(i * 7) % 100}%`,
top: `${(i * 11) % 100}%`,
animationDelay: `${(i % 5) * 0.5}s`,
animationDuration: `${2 + (i % 3)}s`,
}}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { type ReactNode } from "react";
type SectionProps = {
children: ReactNode;
className?: string;
};
export function Section({ children, className = "" }: SectionProps) {
return (
<section
className={`border-t border-[var(--border)] py-12 md:py-16 lg:py-20 ${className}`}
>
<div className="mx-auto max-w-6xl px-4 sm:px-6">{children}</div>
</section>
);
}

View File

@@ -0,0 +1,361 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
const STORAGE_PREFIX = "lms-cloudphone-video-";
const SAVE_INTERVAL = 5000;
const MIN_PROGRESS_TO_SHOW = 10;
function getStoredProgress(videoId: string): number {
if (typeof window === "undefined") return 0;
try {
const raw = localStorage.getItem(`${STORAGE_PREFIX}${videoId}`);
if (!raw) return 0;
const data = JSON.parse(raw);
return typeof data?.time === "number" ? data.time : 0;
} catch {
return 0;
}
}
function saveProgress(videoId: string, time: number): void {
if (typeof window === "undefined") return;
try {
localStorage.setItem(`${STORAGE_PREFIX}${videoId}`, JSON.stringify({ time }));
} catch {
/* ignore */
}
}
type VideoPlayerProps = {
src: string;
poster?: string;
title?: string;
className?: string;
videoId?: string;
onComplete?: () => void;
};
export function VideoPlayer({ src, poster, title, className = "", videoId, onComplete }: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const [playing, setPlaying] = useState(false);
const [progress, setProgress] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(1);
const [muted, setMuted] = useState(false);
const [speed, setSpeed] = useState(1);
const [showControls, setShowControls] = useState(true);
const [showResumePrompt, setShowResumePrompt] = useState(false);
const [savedTime, setSavedTime] = useState(0);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastSaveRef = useRef(0);
const togglePlay = useCallback(() => {
const v = videoRef.current;
if (!v) return;
if (v.paused) {
v.play();
setPlaying(true);
} else {
v.pause();
setPlaying(false);
}
}, []);
const handleTimeUpdate = useCallback(() => {
const v = videoRef.current;
if (!v) return;
const time = v.currentTime;
const dur = v.duration;
setCurrentTime(time);
setProgress(Number.isFinite(dur) && dur > 0 ? (time / dur) * 100 : 0);
if (videoId && v.currentTime > 0) {
const now = Date.now();
if (now - lastSaveRef.current > SAVE_INTERVAL) {
lastSaveRef.current = now;
saveProgress(videoId, v.currentTime);
}
}
}, [videoId]);
const handleLoadedMetadata = useCallback(() => {
const v = videoRef.current;
if (!v) return;
const dur = v.duration;
if (!Number.isFinite(dur) || dur <= 0) return;
setDuration(dur);
if (videoId) {
const stored = getStoredProgress(videoId);
if (Number.isFinite(stored) && stored >= MIN_PROGRESS_TO_SHOW && stored < dur - 5) {
setSavedTime(stored);
setShowResumePrompt(true);
}
}
}, [videoId]);
const handleEnded = useCallback(() => {
setPlaying(false);
setProgress(0);
setCurrentTime(0);
if (videoId) saveProgress(videoId, 0);
onComplete?.();
}, [videoId, onComplete]);
const handlePause = useCallback(() => {
const v = videoRef.current;
if (videoId && v && v.currentTime > 0) {
saveProgress(videoId, v.currentTime);
}
}, [videoId]);
const handleResume = useCallback(() => {
const v = videoRef.current;
if (!v || savedTime <= 0) return;
const dur = v.duration;
if (!Number.isFinite(dur) || dur <= 0) return;
const time = Math.min(savedTime, dur - 0.1);
v.currentTime = time;
setCurrentTime(time);
setProgress((time / dur) * 100);
v.play();
setPlaying(true);
setShowResumePrompt(false);
}, [savedTime]);
const handleRestart = useCallback(() => {
const v = videoRef.current;
if (!v) return;
if (videoId) saveProgress(videoId, 0);
v.currentTime = 0;
setCurrentTime(0);
setProgress(0);
setShowResumePrompt(false);
}, [videoId]);
const handleSeek = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const v = videoRef.current;
if (!v) return;
const dur = v.duration;
if (!Number.isFinite(dur) || dur <= 0) return;
const rect = e.currentTarget.getBoundingClientRect();
if (rect.width <= 0) return;
const p = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const time = p * dur;
v.currentTime = time;
setCurrentTime(time);
setProgress(p * 100);
}, []);
const toggleMute = useCallback(() => {
const v = videoRef.current;
if (!v) return;
v.muted = !v.muted;
setMuted(v.muted);
}, []);
const handleVolumeChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const v = videoRef.current;
if (!v) return;
const val = parseFloat(e.target.value);
v.volume = val;
setVolume(val);
setMuted(val === 0);
}, []);
const cycleSpeed = useCallback(() => {
const speeds = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
const i = speeds.indexOf(speed);
const next = speeds[(i + 1) % speeds.length];
const v = videoRef.current;
if (v) v.playbackRate = next;
setSpeed(next);
}, [speed]);
const toggleFullscreen = useCallback(() => {
const container = videoRef.current?.parentElement;
if (!container) return;
if (!document.fullscreenElement) {
container.requestFullscreen();
} else {
document.exitFullscreen();
}
}, []);
const showControlsTemporarily = useCallback(() => {
setShowControls(true);
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
hideTimerRef.current = setTimeout(() => setShowControls(false), 3000);
}, []);
useEffect(() => () => { if (hideTimerRef.current) clearTimeout(hideTimerRef.current); }, []);
const formatTime = (s: number) => {
if (!Number.isFinite(s) || s < 0) return "0:00";
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, "0")}`;
};
const formatResumeTime = (s: number) => {
if (!Number.isFinite(s) || s < 0) return "0秒";
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
if (m > 0) return `${m}分${sec}秒`;
return `${sec}秒`;
};
return (
<div
className={`group relative overflow-hidden rounded-xl bg-black md:rounded-2xl ${className}`}
onMouseMove={showControlsTemporarily}
onMouseLeave={() => setShowControls(false)}
>
<video
ref={videoRef}
src={src}
poster={poster}
title={title}
className="aspect-video w-full object-contain"
onClick={togglePlay}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onEnded={handleEnded}
onPause={handlePause}
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"
>
<path d="M8 5v14l11-7z" />
</svg>
</div>
</button>
)}
<div
className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 to-transparent p-3 transition-opacity duration-300 ${
showControls ? "opacity-100" : "opacity-0"
}`}
>
<div
className="mb-3 h-1 cursor-pointer rounded-full bg-white/30"
onClick={handleSeek}
role="slider"
aria-valuenow={progress}
>
<div
className="h-full rounded-full bg-[var(--accent)] transition-all"
style={{ width: `${progress}%` }}
/>
</div>
<div className="flex items-center gap-2 sm:gap-4">
<button
type="button"
onClick={togglePlay}
className="rounded p-1 text-white transition hover:bg-white/20"
aria-label={playing ? "暂停" : "播放"}
>
{playing ? (
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
) : (
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
)}
</button>
<span className="min-w-[4rem] text-xs text-white/90 sm:text-sm">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
<div className="flex items-center gap-1">
<button
type="button"
onClick={toggleMute}
className="rounded p-1 text-white transition hover:bg-white/20"
aria-label={muted ? "取消静音" : "静音"}
>
{muted || volume === 0 ? (
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z" />
</svg>
) : (
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z" />
</svg>
)}
</button>
<input
type="range"
min="0"
max="1"
step="0.1"
value={muted ? 0 : volume}
onChange={handleVolumeChange}
className="h-1 w-16 cursor-pointer accent-[var(--accent)] sm:w-20"
/>
</div>
<button
type="button"
onClick={cycleSpeed}
className="rounded px-2 py-1 text-xs font-medium text-white/90 transition hover:bg-white/20 sm:text-sm"
>
{speed}x
</button>
<button
type="button"
onClick={toggleFullscreen}
className="ml-auto rounded p-1 text-white transition hover:bg-white/20"
aria-label="全屏"
>
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
</svg>
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,11 @@
export { Header } from "./Header";
export { Footer } from "./Footer";
export { Section } from "./Section";
export { AnimateOnScroll } from "./AnimateOnScroll";
export { Hero } from "./sections/Hero";
export { AudienceSection } from "./sections/AudienceSection";
export { BenefitsSection } from "./sections/BenefitsSection";
export { CurriculumSection } from "./sections/CurriculumSection";
export { InstructorsSection } from "./sections/InstructorsSection";
export { FAQSection } from "./sections/FAQSection";
export { CTASection } from "./sections/CTASection";

View File

@@ -0,0 +1,28 @@
import { Section } from "../Section";
import { AUDIENCE_CONFIG } from "../../config/course";
export function AudienceSection() {
return (
<Section>
<h2 className="mb-4 text-center text-xl font-bold sm:text-2xl md:text-3xl">
{AUDIENCE_CONFIG.title}
</h2>
<p className="mb-10 text-center text-[var(--muted-foreground)] sm:mb-12 md:mb-16">
{AUDIENCE_CONFIG.subtitle}
</p>
<div className="grid gap-4 sm:grid-cols-2 sm:gap-6 lg:grid-cols-4">
{AUDIENCE_CONFIG.items.map((a, i) => (
<div
key={a.title}
className="hover-shimmer animate__animated animate__fadeInUp rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 transition-all duration-300 hover:border-[var(--accent)]/30 hover:bg-[var(--muted)] hover:-translate-y-0.5 sm:rounded-2xl sm:p-6"
style={{ animationDelay: `${i * 0.1}s`, animationFillMode: "both" }}
>
<div className="mb-3 text-2xl sm:mb-4 sm:text-3xl">{a.emoji}</div>
<h3 className="mb-2 font-semibold">{a.title}</h3>
<p className="text-sm text-[var(--muted-foreground)]">{a.desc}</p>
</div>
))}
</div>
</Section>
);
}

View File

@@ -0,0 +1,32 @@
import { Section } from "../Section";
import { BENEFITS_CONFIG } from "../../config/course";
export function BenefitsSection() {
return (
<Section>
<h2 className="mb-4 text-center text-xl font-bold sm:text-2xl md:text-3xl">
{BENEFITS_CONFIG.title}
</h2>
<p className="mb-10 text-center text-[var(--muted-foreground)] sm:mb-12 md:mb-16">
{BENEFITS_CONFIG.subtitle}
</p>
<div className="space-y-4 sm:space-y-6">
{BENEFITS_CONFIG.items.map((b, i) => (
<div
key={b.num}
className="hover-shimmer animate__animated animate__fadeInUp flex flex-col gap-4 rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 transition-all duration-300 hover:border-[var(--accent)]/30 hover:-translate-y-0.5 sm:flex-row sm:gap-6 sm:rounded-2xl sm:p-6"
style={{ animationDelay: `${i * 0.08}s`, animationFillMode: "both" }}
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[var(--accent-muted)] text-base font-bold text-[var(--accent)] sm:h-12 sm:w-12 sm:text-lg">
{b.num}
</div>
<div className="min-w-0 flex-1">
<h3 className="mb-1 font-semibold">{b.title}</h3>
<p className="text-sm text-[var(--muted-foreground)]">{b.desc}</p>
</div>
</div>
))}
</div>
</Section>
);
}

View File

@@ -0,0 +1,46 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { CTA_CONFIG } from "../../config/course";
import { isPaid } from "../../lib/payment";
export function CTASection() {
const [paid, setPaid] = useState(false);
useEffect(() => {
setPaid(isPaid());
const onPayUpdate = () => setPaid(isPaid());
window.addEventListener("pay:updated", onPayUpdate);
return () => window.removeEventListener("pay:updated", onPayUpdate);
}, []);
return (
<section className="border-t border-[var(--border)] py-16 sm:py-20 md:py-24">
<div className="mx-auto max-w-3xl px-4 text-center sm:px-6">
<h2 className="mb-4 text-xl font-bold sm:text-2xl md:text-3xl">
{CTA_CONFIG.title}
</h2>
<p className="mb-6 text-[var(--muted-foreground)] sm:mb-8">
{CTA_CONFIG.subtitle}
</p>
<div className="mb-8 flex flex-wrap justify-center gap-4 text-sm text-[var(--muted-foreground)] sm:mb-10 sm:gap-6">
{CTA_CONFIG.badges.map((b, i) => (
<span
key={b}
className="animate-float-badge rounded-full border border-[var(--border)] bg-[var(--card)] px-4 py-2"
style={{ animationDelay: `${i * 0.2}s` }}
>
{b}
</span>
))}
</div>
<Link
href={paid ? "/cloudphone/course/0/0" : "/cloudphone/pay"}
className="inline-flex items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] shadow-lg shadow-[var(--accent)]/25 transition-all duration-300 hover:scale-105 hover:opacity-95 hover:shadow-xl hover:shadow-[var(--accent)]/30 sm:px-10"
>
{paid ? "开始学习 →" : "立即加入 →"}
</Link>
</div>
</section>
);
}

View File

@@ -0,0 +1,140 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { Section } from "../Section";
import { CURRICULUM_CONFIG } from "../../config/course";
import { canWatchLesson } from "../../lib/payment";
import { isBookmarked, toggleBookmark, getBookmarks, getCompletedLessons } from "../../lib/learning";
export function CurriculumSection() {
const [expanded, setExpanded] = useState<Record<number, boolean>>(() =>
Object.fromEntries(CURRICULUM_CONFIG.modules.map((_, i) => [i, i === 0]))
);
const [bookmarks, setBookmarks] = useState<Set<string>>(new Set());
const [completed, setCompleted] = useState<Set<string>>(new Set());
useEffect(() => {
setBookmarks(getBookmarks());
setCompleted(getCompletedLessons());
const onB = () => setBookmarks(getBookmarks());
const onP = () => setCompleted(getCompletedLessons());
window.addEventListener("learning:bookmarks", onB);
window.addEventListener("learning:progress", onP);
return () => {
window.removeEventListener("learning:bookmarks", onB);
window.removeEventListener("learning:progress", onP);
};
}, []);
const toggle = (i: number) => {
setExpanded((prev) => ({ ...prev, [i]: !prev[i] }));
};
const allLessons = CURRICULUM_CONFIG.modules.flatMap((m, mi) =>
m.lessons.map((l, li) => ({ moduleIndex: mi, lessonIndex: li, ...l, moduleTitle: m.title }))
);
const filteredByModule = CURRICULUM_CONFIG.modules.map((m, mi) => ({
...m,
lessons: allLessons.filter((l) => l.moduleIndex === mi),
}));
return (
<Section>
<h2 className="mb-4 text-center text-xl font-bold sm:text-2xl md:text-3xl">
{CURRICULUM_CONFIG.title}
</h2>
<p className="mb-6 text-center text-[var(--muted-foreground)] sm:mb-8 md:mb-10">
{CURRICULUM_CONFIG.subtitle}
</p>
<div className="space-y-2 md:space-y-3">
{filteredByModule.map((c, i) => {
const moduleIndex = CURRICULUM_CONFIG.modules.findIndex((m) => m.title === c.title);
const isOpen = expanded[moduleIndex] ?? true;
return (
<div
key={c.title}
className="animate__animated animate__fadeInUp overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--card)] md:rounded-2xl"
style={{ animationDelay: `${i * 0.06}s`, animationFillMode: "both" }}
>
<button
type="button"
onClick={() => toggle(moduleIndex)}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition hover:bg-[var(--muted)] sm:px-5 sm:py-4"
>
<span className="flex items-center gap-2 text-base font-semibold sm:text-lg">
<span
className={`transition-transform duration-200 ${isOpen ? "rotate-90" : ""}`}
>
▶
</span>
<span>{c.emoji}</span>
{c.title}
</span>
<span className="text-sm text-[var(--muted-foreground)]">
{c.lessons.length} 节
</span>
</button>
{isOpen && (
<ul className="border-t border-[var(--border)]">
{c.lessons.map((l) => {
const free = l.moduleIndex === 0 && l.lessonIndex < 2;
const unlocked = canWatchLesson(l.moduleIndex, l.lessonIndex);
const key = `${l.moduleIndex}-${l.lessonIndex}`;
const bookmarked = isBookmarked(l.moduleIndex, l.lessonIndex);
const isCompleted = completed.has(key);
return (
<li key={key}>
<Link
href={`/cloudphone/course/${l.moduleIndex}/${l.lessonIndex}`}
className="flex flex-col gap-1 px-4 py-3 transition hover:bg-[var(--muted)] sm:flex-row sm:items-center sm:justify-between sm:px-5 sm:py-3"
>
<span className="flex items-center gap-2 truncate sm:gap-3">
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleBookmark(l.moduleIndex, l.lessonIndex);
}}
className="shrink-0 text-xs"
aria-label={bookmarked ? "取消收藏" : "收藏"}
>
{bookmarked ? "⭐" : "☆"}
</button>
<span className={unlocked ? "text-[var(--foreground)]" : "text-[var(--muted-foreground)]"}>
{l.name}
</span>
{free && (
<span className="shrink-0 rounded bg-[var(--accent)]/20 px-1.5 py-0.5 text-xs text-[var(--accent)]">
试看
</span>
)}
{isCompleted && (
<span className="shrink-0 rounded bg-green-500/20 px-1.5 py-0.5 text-xs text-green-600 dark:text-green-400">
✓
</span>
)}
{!unlocked && (
<span className="shrink-0 text-xs text-[var(--muted-foreground)]">
🔒
</span>
)}
</span>
<span className="flex shrink-0 items-center gap-2 text-xs text-[var(--muted-foreground)] sm:text-sm">
{l.duration}
<span className="text-[var(--accent)]">→</span>
</span>
</Link>
</li>
);
})}
</ul>
)}
</div>
);
})}
</div>
</Section>
);
}

View File

@@ -0,0 +1,43 @@
"use client";
import { useState } from "react";
import { Section } from "../Section";
import { FAQ_CONFIG } from "../../config/course";
export function FAQSection() {
const [openIndex, setOpenIndex] = useState<number | null>(null);
return (
<Section>
<h2 className="mb-10 text-center text-xl font-bold sm:text-2xl md:text-3xl">
{FAQ_CONFIG.title}
</h2>
<div className="mx-auto max-w-2xl space-y-2">
{FAQ_CONFIG.items.map((item, i) => (
<div
key={i}
className="overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--card)] transition"
>
<button
type="button"
onClick={() => setOpenIndex(openIndex === i ? null : i)}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition hover:bg-[var(--muted)] sm:px-5"
>
<span className="font-medium">{item.q}</span>
<span
className={`shrink-0 text-[var(--muted-foreground)] transition-transform ${openIndex === i ? "rotate-180" : ""}`}
>
▼
</span>
</button>
{openIndex === i && (
<div className="border-t border-[var(--border)] px-4 py-3 text-sm text-[var(--muted-foreground)] sm:px-5">
{item.a}
</div>
)}
</div>
))}
</div>
</Section>
);
}

View File

@@ -0,0 +1,106 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { HERO_CONFIG, CURRICULUM_CONFIG } from "../../config/course";
import { isPaid } from "../../lib/payment";
import { getLastWatched, getProgress } from "../../lib/learning";
import { ParticleBackground } from "../ParticleBackground";
const TOTAL_LESSONS = CURRICULUM_CONFIG.modules.reduce((s, m) => s + m.lessons.length, 0);
export function Hero() {
const [paid, setPaid] = useState(false);
const [last, setLast] = useState<ReturnType<typeof getLastWatched>>(null);
const [progress, setProgress] = useState({ completed: 0, percent: 0 });
useEffect(() => {
setPaid(isPaid());
setLast(getLastWatched());
setProgress(getProgress(TOTAL_LESSONS));
const onPay = () => setPaid(isPaid());
const onProgress = () => {
setLast(getLastWatched());
setProgress(getProgress(TOTAL_LESSONS));
};
window.addEventListener("pay:updated", onPay);
window.addEventListener("learning:progress", onProgress);
return () => {
window.removeEventListener("pay:updated", onPay);
window.removeEventListener("learning:progress", onProgress);
};
}, []);
return (
<section className="relative overflow-hidden pt-24 pb-16 sm:pt-28 sm:pb-20 md:pt-32 md:pb-24">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,var(--gradient-accent),transparent)] animate-gradient" />
<ParticleBackground />
<div className="relative mx-auto max-w-4xl px-4 text-center sm:px-6">
<h1
className="mb-4 text-3xl font-bold tracking-tight animate__animated animate__fadeInDown sm:text-4xl md:text-5xl lg:text-6xl"
style={{ animationDelay: "0.1s", animationFillMode: "both" }}
>
{HERO_CONFIG.title}
</h1>
<p
className="mb-10 text-lg text-[var(--muted-foreground)] animate__animated animate__fadeInDown sm:mb-12 sm:text-xl md:text-2xl"
style={{ animationDelay: "0.25s", animationFillMode: "both" }}
>
{HERO_CONFIG.subtitle}
</p>
<div
className="mb-10 flex flex-wrap justify-center gap-6 animate__animated animate__fadeInDown sm:mb-12 sm:gap-8"
style={{ animationDelay: "0.4s", animationFillMode: "both" }}
>
{HERO_CONFIG.stats.map((s) => (
<div key={s.label} className="text-center transition-transform duration-300 hover:scale-105">
<div className="text-2xl font-bold text-[var(--accent)] sm:text-3xl md:text-4xl">
{s.value}
</div>
<div className="mt-1 text-xs text-[var(--muted-foreground)] sm:text-sm">
{s.label}
</div>
</div>
))}
</div>
<div
className="flex flex-wrap justify-center gap-3 animate__animated animate__fadeInDown sm:gap-4"
style={{ animationDelay: "0.6s", animationFillMode: "both" }}
>
{paid && last ? (
<Link
href={`/cloudphone/course/${last.moduleIndex}/${last.lessonIndex}`}
className="inline-flex shrink-0 items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] transition hover:opacity-90 sm:px-10"
>
继续学习 →
</Link>
) : (
<Link
href={paid ? "/cloudphone/course/0/0" : "/cloudphone/pay"}
className="inline-flex shrink-0 items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] transition hover:opacity-90 sm:px-10"
>
{paid ? "进入课程 →" : "立即报名 →"}
</Link>
)}
</div>
{paid && progress.completed > 0 && (
<div
className="mt-6 w-full max-w-xs animate__animated animate__fadeInDown"
style={{ animationDelay: "0.7s", animationFillMode: "both" }}
>
<div className="mb-1 flex justify-between text-xs text-[var(--muted-foreground)]">
<span>学习进度</span>
<span>{progress.completed}/{TOTAL_LESSONS} 节</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-[var(--muted)]">
<div
className="h-full rounded-full bg-[var(--accent)] transition-all"
style={{ width: `${progress.percent}%` }}
/>
</div>
</div>
)}
</div>
</section>
);
}

View File

@@ -0,0 +1,37 @@
import { Section } from "../Section";
import { INSTRUCTORS_CONFIG } from "../../config/course";
export function InstructorsSection() {
return (
<Section>
<h2 className="mb-4 text-center text-xl font-bold sm:text-2xl md:text-3xl">
{INSTRUCTORS_CONFIG.title}
</h2>
<p className="mb-10 text-center text-[var(--muted-foreground)] sm:mb-12 md:mb-16">
{INSTRUCTORS_CONFIG.subtitle}
</p>
<div className="grid gap-6 sm:grid-cols-2 md:gap-8 lg:grid-cols-3">
{INSTRUCTORS_CONFIG.items.map((i) => (
<div
key={i.name}
className="hover-shimmer rounded-xl border border-[var(--border)] bg-[var(--card)] p-5 text-center transition-all duration-300 hover:-translate-y-0.5 hover:border-[var(--accent)]/30 sm:rounded-2xl sm:p-6"
>
<div className="mb-3 flex justify-center sm:mb-4">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-[var(--accent-muted)] text-xl font-bold text-[var(--accent)] sm:h-20 sm:w-20 sm:text-2xl">
{i.name.slice(0, 1)}
</div>
</div>
<h3 className="mb-1 font-semibold">{i.name}</h3>
<p className="mb-1 text-sm text-[var(--accent)]">{i.role}</p>
<p className="mb-2 text-sm text-[var(--muted-foreground)]">
{i.desc}
</p>
<span className="inline-block rounded-full bg-[var(--accent-muted)] px-3 py-1 text-xs text-[var(--accent)]">
{i.tag}
</span>
</div>
))}
</div>
</Section>
);
}

View File

@@ -0,0 +1,128 @@
/**
* 云手机实战课 - 课程配置(克隆自 nomadlms)
*/
export const SITE_CONFIG = {
brand: "🌍 云手机",
ctaUrl: "/cloudphone/pay",
footer: "🌍 云手机 · 低成本工作室搭建指南",
} as const;
export const HERO_CONFIG = {
title: "云手机实战课",
subtitle: "低成本工作室搭建指南 · TikTok/Youtube 无人直播,开启 24 小时赚美金",
stats: [
{ value: "13", label: "节实战课程" },
{ value: "170+", label: "分钟视频" },
{ value: "1", label: "位资深导师" },
],
} as const;
export const AUDIENCE_CONFIG = {
title: "这门课适合谁?",
subtitle: "向往低成本自建,想用开源硬件实现无人直播的人",
items: [
{ emoji: "💻", title: "无人直播创业者", desc: "想用低成本硬件搭建 TikTok/Youtube 直播矩阵" },
{ emoji: "⚙️", title: "技术极客", desc: "想自建云手机、Redroid、ARM 开发板玩转自动化" },
{ emoji: "📱", title: "出海副业者", desc: "想摆脱云手机平台高额月费,自建开箱即用" },
{ emoji: "🌴", title: "工作室搭建者", desc: "想低成本打造无人值守 24 小时赚美金" },
],
} as const;
export const BENEFITS_CONFIG = {
title: "学完你将获得",
subtitle: "5大核心能力,助你自建云手机工作室",
items: [
{ num: "1", title: "云手机自建方案", desc: "Redroid、AOSP 安卓、ARM 开发板,从零搭建云手机" },
{ num: "2", title: "无人直播技术栈", desc: "ffmpeg、SRS、油猴脚本,实现 24 小时自动化推流" },
{ num: "3", title: "软路由与网络", desc: "科学上网、静态住宅 IP,出海必备基础设施" },
{ num: "4", title: "硬件选型与成本", desc: "开发板、采集卡、配件,一套成本不过千" },
{ num: "5", title: "VIP 社群支持", desc: "加入异度星球,获取部署教程与即时答疑" },
],
} as const;
export const CURRICULUM_CONFIG = {
title: "实战课程",
subtitle: "从入门到进阶,完整搭建云手机工作室",
modules: [
{
emoji: "🚀",
title: "基础篇 · 云手机入门",
lessons: [
{ name: "01 什么是云手机?自建 vs 商业平台", duration: "08:32" },
{ name: "02 低成本工作室硬件选型", duration: "12:15" },
],
},
{
emoji: "⚙️",
title: "技术篇 · Redroid 与 AOSP",
lessons: [
{ name: "03 Docker 虚拟化安卓 - Redroid", duration: "15:20" },
{ name: "04 ARM 开发板 AOSP 系统", duration: "18:00" },
{ name: "05 相机与推流配置", duration: "14:30" },
],
},
{
emoji: "📺",
title: "直播篇 · 无人推流",
lessons: [
{ name: "06 ffmpeg 拉流转推", duration: "16:00" },
{ name: "07 SRS 直播服务器", duration: "12:45" },
{ name: "08 油猴脚本自动化", duration: "11:20" },
{ name: "09 TikTok/Youtube 实战", duration: "20:00" },
],
},
{
emoji: "🌐",
title: "网络篇 · 出海基础",
lessons: [
{ name: "10 软路由与科学上网", duration: "13:00" },
{ name: "11 静态住宅 IP 配置", duration: "10:30" },
],
},
{
emoji: "🏆",
title: "进阶篇 · 产品化",
lessons: [
{ name: "12 HDMI 辅助板方案", duration: "15:00" },
{ name: "13 多设备群控与远控", duration: "12:00" },
],
},
],
} as const;
export const INSTRUCTORS_CONFIG = {
title: "资深导师授课",
subtitle: "异度世界带你开启云手机工作室之旅",
items: [
{
name: "异度世界",
role: "数字游民先行者",
desc: "5年旅居经验,低成本工作室实践",
tag: "课程主理",
},
],
} as const;
export const CTA_CONFIG = {
title: "准备好开启你的云手机工作室了吗?",
subtitle: "加入异度星球 VIP,解锁云手机实战课 + 电子书 + 即时答疑",
badges: ["实战视频", "永久回看", "社群答疑"],
} as const;
export const FAQ_CONFIG = {
title: "常见问题",
items: [
{ q: "课程可以永久回看吗?", a: "可以,VIP 会员永久有效,随时回看。" },
{ q: "适合零基础吗?", a: "适合。课程从入门到进阶,前 2 节可免费试看。" },
{ q: "有社群答疑吗?", a: "有,VIP 加入异度星球专属社群,即时答疑。" },
{ q: "支持退款吗?", a: "虚拟数字交付,不支持退款。" },
],
} as const;
export const DOWNLOAD_CONFIG = {
enabled: true,
url: "https://pan.baidu.com/s/example",
title: "课程资料下载",
buttonText: "前往网盘下载",
} as const;

View File

@@ -0,0 +1,145 @@
/**
* 云手机课程章节详情配置(克隆自 nomadlms)
*/
const SAMPLE_VIDEO = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4";
const SAMPLE_MARKDOWN = `# 课程概述
本小节将带你理解云手机的核心概念,掌握低成本工作室搭建技能。
## 学习目标
- 理解云手机的本质与自建方案
- 掌握 Redroid、AOSP 等虚拟化技术
- 完成无人直播推流配置
## 核心要点
1. **定义**:云手机 = 虚拟化安卓 + 远程控制 + 自动化推流
2. **技术**:Docker Redroid、ARM 开发板、ffmpeg、SRS
3. **成本**:自建单台成本可控制在千元以内
## 行动清单
- [ ] 评估自己的硬件与网络环境
- [ ] 列出想实现的直播平台(TikTok/Youtube 等)
- [ ] 设定 1 个月内的搭建目标
### 代码示例
\`\`\`bash
# 启动 Redroid 容器
docker run -itd --privileged \\
-v ~/data:/data \\
redroid/redroid:13.0.0-latest
\`\`\`
行内代码示例:\`ffmpeg -re -i input.mp4 -c copy -f flv rtmp://server/live\` 可推流到 RTMP 服务器。
## 注意事项
> 云手机自建需要一定技术基础,建议先完成前 2 节试看再决定是否购买。
| 项目 | 说明 |
|------|------|
| 硬件 | 建议 ARM 开发板或 x86 主机 |
| 网络 | 需科学上网与静态 IP |
| 时间 | 完整搭建约 1-2 天 |
`;
export type LessonDetail = {
name: string;
duration: string;
videoUrl: string;
markdown: string;
};
export const LESSONS_MAP: Record<string, LessonDetail> = {
"0-0": {
name: "01 什么是云手机?自建 vs 商业平台",
duration: "08:32",
videoUrl: SAMPLE_VIDEO,
markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "云手机定义与自建 vs 商业平台对比"),
},
"0-1": {
name: "02 低成本工作室硬件选型",
duration: "12:15",
videoUrl: SAMPLE_VIDEO,
markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "开发板、采集卡、配件选型"),
},
"1-0": {
name: "03 Docker 虚拟化安卓 - Redroid",
duration: "15:20",
videoUrl: SAMPLE_VIDEO,
markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "Redroid 安装与配置"),
},
"1-1": {
name: "04 ARM 开发板 AOSP 系统",
duration: "18:00",
videoUrl: SAMPLE_VIDEO,
markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "AOSP 刷机与系统定制"),
},
"1-2": {
name: "05 相机与推流配置",
duration: "14:30",
videoUrl: SAMPLE_VIDEO,
markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "虚拟相机与 RTMP 推流"),
},
"2-0": {
name: "06 ffmpeg 拉流转推",
duration: "16:00",
videoUrl: SAMPLE_VIDEO,
markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "ffmpeg 拉流与转推"),
},
"2-1": {
name: "07 SRS 直播服务器",
duration: "12:45",
videoUrl: SAMPLE_VIDEO,
markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "SRS 部署与推拉流"),
},
"2-2": {
name: "08 油猴脚本自动化",
duration: "11:20",
videoUrl: SAMPLE_VIDEO,
markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "油猴脚本实现自动化操作"),
},
"2-3": {
name: "09 TikTok/Youtube 实战",
duration: "20:00",
videoUrl: SAMPLE_VIDEO,
markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "TikTok/Youtube 无人直播实战"),
},
"3-0": {
name: "10 软路由与科学上网",
duration: "13:00",
videoUrl: SAMPLE_VIDEO,
markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "软路由配置与科学上网"),
},
"3-1": {
name: "11 静态住宅 IP 配置",
duration: "10:30",
videoUrl: SAMPLE_VIDEO,
markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "静态住宅 IP 与代理配置"),
},
"4-0": {
name: "12 HDMI 辅助板方案",
duration: "15:00",
videoUrl: SAMPLE_VIDEO,
markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "HDMI 采集与辅助板"),
},
"4-1": {
name: "13 多设备群控与远控",
duration: "12:00",
videoUrl: SAMPLE_VIDEO,
markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "群控与远程控制方案"),
},
};
export function getLessonKey(moduleIndex: number, lessonIndex: number): string {
return `${moduleIndex}-${lessonIndex}`;
}
export function getLesson(moduleIndex: number, lessonIndex: number): LessonDetail | null {
return LESSONS_MAP[getLessonKey(moduleIndex, lessonIndex)] ?? null;
}

View File

@@ -0,0 +1,150 @@
"use client";
import { useEffect, useState, useCallback, useRef } from "react";
import { marked } from "marked";
import styles from "./lesson-markdown.module.css";
type TocItem = { level: number; text: string; id: string };
function slugify(text: string): string {
return text
.replace(/[^\p{L}\p{N}\s-]/gu, "")
.replace(/\s+/g, "-")
.toLowerCase();
}
function extractToc(content: string): TocItem[] {
const items: TocItem[] = [];
const lines = content.split("\n");
let idCounter = 0;
for (const line of lines) {
const h1 = line.match(/^# (.+)$/);
const h2 = line.match(/^## (.+)$/);
if (h1) {
items.push({ level: 1, text: h1[1].replace(/#{1,6}\s/g, "").trim(), id: `h-${idCounter++}` });
} else if (h2) {
items.push({ level: 2, text: h2[1].replace(/#{1,6}\s/g, "").trim(), id: `h-${idCounter++}` });
}
}
return items;
}
function markdownToHtml(content: string): string {
const raw = marked.parse(content, { breaks: true, gfm: true }) as string;
let html = raw
.replace(/<img /g, '<img loading="lazy" decoding="async" ')
.replace(/<a href=/g, '<a href=');
// 给 h1/h2 添加 id
html = html.replace(/<h1>([^<]*)<\/h1>/gi, (_, text) => {
const t = text.replace(/<[^>]+>/g, "").trim();
return `<h1 id="${slugify(t)}" class="scroll-mt-24">${text}</h1>`;
});
html = html.replace(/<h2>([^<]*)<\/h2>/gi, (_, text) => {
const t = text.replace(/<[^>]+>/g, "").trim();
return `<h2 id="${slugify(t)}" class="scroll-mt-24">${text}</h2>`;
});
html = html.replace(/<a href="([^"]+)"([^>]*)>/g, '<a href="$1" target="_blank" rel="noopener noreferrer"$2>');
html = html.replace(/<pre>\s*<code([^>]*)>/g, `<pre class="${styles.codeBlock}"><code$1>`);
return html;
}
export function LessonMarkdown({ content }: { content: string }) {
const [readProgress, setReadProgress] = useState(0);
const [showBackTop, setShowBackTop] = useState(false);
const [showToc, setShowToc] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const toc = extractToc(content);
const html = markdownToHtml(content);
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 > 400);
ticking = false;
});
};
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
const handleTocJump = useCallback((index: number) => {
setShowToc(false);
const root = contentRef.current;
if (!root) return;
const headings = root.querySelectorAll("h1, h2");
const el = headings[index] as HTMLElement;
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "start" });
}
}, []);
return (
<>
<div className={styles.readingProgress} style={{ width: `${readProgress}%` }} />
<div
ref={contentRef}
className={`${styles.markdownBody} markdown-body`}
dangerouslySetInnerHTML={{ __html: html }}
/>
<div
className={`${styles.drawerMask} ${showToc ? styles.drawerMaskOpen : ""}`}
onClick={() => setShowToc(false)}
aria-hidden="true"
/>
<aside className={`${styles.drawer} ${showToc ? styles.drawerOpen : ""}`}>
<div className={styles.drawerTitle}>目录</div>
{toc.map((item, i) => (
<div
key={item.id}
role="button"
tabIndex={0}
className={item.level === 2 ? `${styles.drawerItem} ${styles.drawerItemH2}` : styles.drawerItem}
onClick={() => handleTocJump(i)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") handleTocJump(i);
}}
>
{item.text}
</div>
))}
</aside>
{showBackTop && (
<>
{toc.length > 0 && (
<button
type="button"
className={styles.tocButton}
onClick={() => setShowToc(true)}
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>
</button>
)}
<button
type="button"
className={styles.backToTop}
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
aria-label="回到顶部"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M18 15l-6-6-6 6" />
</svg>
</button>
</>
)}
</>
);
}

View File

@@ -0,0 +1,333 @@
.readingProgress {
position: fixed;
top: 0;
left: 0;
height: 3px;
background: linear-gradient(90deg, var(--accent) 0%, #fbbf24 100%);
z-index: 100;
transition: width 0.15s linear;
border-radius: 0 3px 3px 0;
}
.backToTop {
position: fixed;
bottom: 24px;
right: 24px;
width: 48px;
height: 48px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px 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 20px rgba(0, 0, 0, 0.15);
}
.tocButton {
position: fixed;
bottom: 24px;
right: 84px;
width: 48px;
height: 48px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
cursor: pointer;
z-index: 50;
color: var(--accent);
transition: transform 0.2s, box-shadow 0.2s;
}
.tocButton:hover {
transform: translateY(-2px);
}
.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;
}
.drawer {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 280px;
max-width: 85vw;
background: var(--background);
border-left: 1px solid var(--border);
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15);
z-index: 150;
padding: 80px 0 24px;
overflow-y: auto;
transform: translateX(100%);
transition: transform 0.3s ease;
}
.drawerOpen {
transform: translateX(0);
}
.drawerTitle {
padding: 0 20px 12px;
font-size: 14px;
font-weight: 600;
color: var(--muted-foreground);
border-bottom: 1px solid var(--border);
margin-bottom: 8px;
}
.drawerItem {
padding: 10px 20px;
font-size: 14px;
color: var(--foreground);
cursor: pointer;
transition: background 0.15s;
}
.drawerItem:hover {
background: var(--muted);
}
.drawerItemH2 {
padding-left: 28px;
font-size: 13px;
color: var(--muted-foreground);
}
.markdownBody {
font-size: 16px;
line-height: 1.85;
color: var(--foreground);
word-wrap: break-word;
overflow-wrap: break-word;
}
.markdownBody h1 {
font-size: 1.5rem;
font-weight: 800;
color: var(--foreground);
margin: 28px 0 12px;
padding-bottom: 0;
border-bottom: none;
line-height: 1.3;
}
.markdownBody h1:first-child {
margin-top: 0;
}
.markdownBody h1::after {
content: "";
display: block;
width: 40px;
height: 3px;
background: linear-gradient(90deg, var(--accent), #fbbf24);
border-radius: 3px;
margin-top: 8px;
}
.markdownBody h2 {
font-size: 1.25rem;
font-weight: 700;
color: var(--foreground);
margin: 24px 0 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
line-height: 1.35;
}
.markdownBody h3 {
font-size: 1.1rem;
font-weight: 600;
color: var(--foreground);
margin: 20px 0 8px;
}
.markdownBody h4,
.markdownBody h5,
.markdownBody h6 {
font-size: 1rem;
font-weight: 600;
color: var(--foreground);
margin: 16px 0 6px;
}
.markdownBody p {
font-size: 1rem;
margin-bottom: 14px;
line-height: 1.9;
}
.markdownBody blockquote {
position: relative;
margin: 24px 0;
padding: 16px 20px 16px 24px;
border-left: none;
background: var(--muted);
border-radius: 0 12px 12px 0;
color: var(--muted-foreground);
}
.markdownBody blockquote::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: var(--accent);
border-radius: 0 2px 2px 0;
}
.markdownBody pre.codeBlock {
margin: 24px 0;
padding: 0;
overflow-x: auto;
border-radius: 8px;
background: #282c34;
}
.markdownBody pre.codeBlock code {
display: block;
padding: 16px;
font-size: 0.875em;
line-height: 1.6;
color: #abb2bf;
}
.markdownBody p code,
.markdownBody li code {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
font-size: 0.9em;
padding: 0.2em 0.4em;
background: var(--muted);
color: var(--foreground);
border-radius: 6px;
}
.markdownBody a {
color: var(--accent);
text-decoration: none;
}
.markdownBody a:hover {
text-decoration: underline;
text-underline-offset: 2px;
}
.markdownBody img {
display: block;
margin: 28px auto;
max-width: 100%;
height: auto;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.markdownBody ul,
.markdownBody ol {
margin: 16px 0;
padding-left: 28px;
}
.markdownBody li {
margin-bottom: 6px;
}
.markdownBody table {
width: 100%;
margin: 24px 0;
border-collapse: collapse;
border-radius: 8px;
overflow: hidden;
}
.markdownBody th {
background: var(--muted);
font-weight: 600;
padding: 12px 16px;
text-align: left;
border: 1px solid var(--border);
}
.markdownBody td {
padding: 12px 16px;
border: 1px solid var(--border);
}
.markdownBody tr:nth-child(even) {
background: var(--muted);
}
.markdownBody hr {
border: none;
height: 1px;
background: var(--border);
margin: 28px 0;
}
.markdownBody strong {
font-weight: 700;
}
.markdownBody input[type="checkbox"] {
margin-right: 8px;
}
@media (min-width: 768px) {
.markdownBody {
font-size: 17px;
line-height: 1.8;
}
.markdownBody h1 {
font-size: 1.75rem;
margin: 32px 0 14px;
}
.markdownBody h2 {
font-size: 1.35rem;
margin: 28px 0 12px;
}
.markdownBody h3 {
font-size: 1.2rem;
margin: 24px 0 10px;
}
.backToTop,
.tocButton {
bottom: 32px;
right: 32px;
}
.tocButton {
right: 92px;
}
}

View File

@@ -0,0 +1,149 @@
"use client";
import { useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { Header } from "../../../components";
import { VideoPlayer } from "../../../components/VideoPlayer";
import { LessonMarkdown } from "./LessonMarkdown";
import { getLesson } from "../../../config/lessons";
import { canWatchLesson } from "../../../lib/payment";
import { setLastWatched, markCompleted } from "../../../lib/learning";
import { DOWNLOAD_CONFIG } from "../../../config/course";
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;
useEffect(() => {
if (lesson && unlocked) setLastWatched(moduleIndex, lessonIndex, lesson.name);
}, [moduleIndex, lessonIndex, lesson?.name, unlocked]);
if (!lesson || isNaN(moduleIndex) || isNaN(lessonIndex)) {
return (
<div className="min-h-screen bg-[var(--background)]">
<Header />
<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>
<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"
>
← 返回首页
</Link>
</div>
</main>
</div>
);
}
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>
</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>
</div>
);
}

20
app/cloudphone/layout.tsx Normal file
View File

@@ -0,0 +1,20 @@
import type { Metadata } from "next";
import { ChatFloatButton } from "./components/ChatFloatButton";
export const metadata: Metadata = {
title: "云手机 | 低成本工作室搭建指南",
description: "云手机实战课:用开源硬件打造云手机,TikTok无人直播、Youtube运营自动化。",
};
export default function CloudphoneLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
{children}
<ChatFloatButton />
</>
);
}

View File

@@ -0,0 +1,141 @@
/**
* 学习进度、继续学习、收藏、笔记 - localStorage(克隆自 nomadlms)
*/
const PREFIX = "lms-cloudphone-";
const KEY_LAST = `${PREFIX}last`;
const KEY_COMPLETED = `${PREFIX}completed`;
const KEY_BOOKMARKS = `${PREFIX}bookmarks`;
const KEY_NOTES = `${PREFIX}notes`;
const KEY_RATING = `${PREFIX}rating`;
export type LastWatched = { moduleIndex: number; lessonIndex: number; name: string };
export function getLastWatched(): LastWatched | null {
if (typeof window === "undefined") return null;
try {
const raw = localStorage.getItem(KEY_LAST);
if (!raw) return null;
const d = JSON.parse(raw);
if (typeof d?.moduleIndex !== "number" || typeof d?.lessonIndex !== "number") return null;
return { moduleIndex: d.moduleIndex, lessonIndex: d.lessonIndex, name: d.name || "" };
} catch {
return null;
}
}
export function setLastWatched(m: number, l: number, name: string): void {
if (typeof window === "undefined") return;
try {
localStorage.setItem(KEY_LAST, JSON.stringify({ moduleIndex: m, lessonIndex: l, name }));
} catch {
/* ignore */
}
}
export function getCompletedLessons(): Set<string> {
if (typeof window === "undefined") return new Set();
try {
const raw = localStorage.getItem(KEY_COMPLETED);
if (!raw) return new Set();
const arr = JSON.parse(raw);
return new Set(Array.isArray(arr) ? arr : []);
} catch {
return new Set();
}
}
export function markCompleted(moduleIndex: number, lessonIndex: number): void {
if (typeof window === "undefined") return;
try {
const set = getCompletedLessons();
set.add(`${moduleIndex}-${lessonIndex}`);
localStorage.setItem(KEY_COMPLETED, JSON.stringify([...set]));
window.dispatchEvent(new CustomEvent("learning:progress"));
} catch {
/* ignore */
}
}
export function getProgress(totalLessons: number): { completed: number; percent: number } {
const set = getCompletedLessons();
const completed = set.size;
return { completed, percent: totalLessons > 0 ? Math.round((completed / totalLessons) * 100) : 0 };
}
export function getBookmarks(): Set<string> {
if (typeof window === "undefined") return new Set();
try {
const raw = localStorage.getItem(KEY_BOOKMARKS);
if (!raw) return new Set();
const arr = JSON.parse(raw);
return new Set(Array.isArray(arr) ? arr : []);
} catch {
return new Set();
}
}
export function toggleBookmark(moduleIndex: number, lessonIndex: number): boolean {
if (typeof window === "undefined") return false;
try {
const set = getBookmarks();
const key = `${moduleIndex}-${lessonIndex}`;
if (set.has(key)) set.delete(key);
else set.add(key);
localStorage.setItem(KEY_BOOKMARKS, JSON.stringify([...set]));
window.dispatchEvent(new CustomEvent("learning:bookmarks"));
return set.has(key);
} catch {
return false;
}
}
export function isBookmarked(moduleIndex: number, lessonIndex: number): boolean {
return getBookmarks().has(`${moduleIndex}-${lessonIndex}`);
}
export function getNote(moduleIndex: number, lessonIndex: number): string {
if (typeof window === "undefined") return "";
try {
const raw = localStorage.getItem(KEY_NOTES);
if (!raw) return "";
const map = JSON.parse(raw);
return map[`${moduleIndex}-${lessonIndex}`] ?? "";
} catch {
return "";
}
}
export function setNote(moduleIndex: number, lessonIndex: number, text: string): void {
if (typeof window === "undefined") return;
try {
const raw = localStorage.getItem(KEY_NOTES);
const map = raw ? JSON.parse(raw) : {};
map[`${moduleIndex}-${lessonIndex}`] = text;
localStorage.setItem(KEY_NOTES, JSON.stringify(map));
} catch {
/* ignore */
}
}
export function getRating(): { stars: number; comment: string } | null {
if (typeof window === "undefined") return null;
try {
const raw = localStorage.getItem(KEY_RATING);
if (!raw) return null;
const d = JSON.parse(raw);
return { stars: d?.stars ?? 0, comment: d?.comment ?? "" };
} catch {
return null;
}
}
export function setRating(stars: number, comment: string): void {
if (typeof window === "undefined") return;
try {
localStorage.setItem(KEY_RATING, JSON.stringify({ stars, comment }));
window.dispatchEvent(new CustomEvent("learning:rating"));
} catch {
/* ignore */
}
}

View File

@@ -0,0 +1,21 @@
/**
* 支付状态 - 适配 nomadvip paidType(克隆自 nomadlms)
*/
/** nomadvip 使用 paidType=vip 表示已支付 */
export function isPaid(): boolean {
if (typeof window === "undefined") return false;
return localStorage.getItem("paidType") === "vip";
}
/** 前 2 节为试看,无需支付 */
export function isFreeTrial(moduleIndex: number, lessonIndex: number): boolean {
if (moduleIndex === 0 && lessonIndex < 2) return true;
return false;
}
/** 章节是否可观看 */
export function canWatchLesson(moduleIndex: number, lessonIndex: number): boolean {
if (isFreeTrial(moduleIndex, lessonIndex)) return true;
return isPaid();
}

32
app/cloudphone/page.tsx Normal file
View File

@@ -0,0 +1,32 @@
"use client";
import {
Header,
Hero,
Footer,
AudienceSection,
BenefitsSection,
CurriculumSection,
AnimateOnScroll,
} from "./components";
export default function CloudphonePage() {
return (
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)]">
<Header />
<main>
<Hero />
<AnimateOnScroll delay={0}>
<AudienceSection />
</AnimateOnScroll>
<AnimateOnScroll delay={100}>
<BenefitsSection />
</AnimateOnScroll>
<AnimateOnScroll delay={0}>
<CurriculumSection />
</AnimateOnScroll>
</main>
<Footer />
</div>
);
}

View File

@@ -0,0 +1,64 @@
"use client";
import { Suspense, useEffect } from "react";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import { Header } from "../components";
function PayContent() {
const searchParams = useSearchParams();
useEffect(() => {
if (typeof window !== "undefined" && searchParams.get("test") === "ok") {
localStorage.setItem("paidType", "vip");
window.dispatchEvent(new CustomEvent("pay:updated"));
window.location.href = "/cloudphone";
}
}, [searchParams]);
if (searchParams.get("test") === "ok") {
return (
<div className="flex min-h-screen items-center justify-center bg-[var(--background)]">
<p className="text-[var(--muted-foreground)]">正在跳转...</p>
</div>
);
}
return (
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)]">
<Header />
<main className="flex min-h-[80vh] flex-col items-center justify-center px-4">
<h1 className="animate__animated animate__fadeInDown mb-2 text-2xl font-bold" style={{ animationDelay: "0.1s", animationFillMode: "both" }}>报名云手机实战课</h1>
<p className="animate__animated animate__fadeInUp mb-6 text-[var(--muted-foreground)]" style={{ animationDelay: "0.2s", animationFillMode: "both" }}>
加入异度星球 VIP,解锁全部 13 节视频 + 电子书 + 社群答疑
</p>
<div className="animate__animated animate__zoomIn mb-8 flex flex-col gap-3 sm:flex-row sm:gap-4" style={{ animationDelay: "0.35s", animationFillMode: "both" }}>
<Link
href="/"
className="inline-flex items-center justify-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] transition hover:opacity-90 sm:px-10"
>
前往首页完成支付 →
</Link>
</div>
<p className="mb-4 text-xs text-[var(--muted-foreground)]">
测试:<Link href="/cloudphone/pay?test=ok" className="text-[var(--accent)] underline">点击模拟支付成功</Link>
</p>
<Link href="/cloudphone" className="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)]">
返回课程首页
</Link>
</main>
</div>
);
}
export default function PayPage() {
return (
<Suspense fallback={
<div className="flex min-h-screen items-center justify-center bg-[var(--background)]">
<p className="text-[var(--muted-foreground)]">加载中...</p>
</div>
}>
<PayContent />
</Suspense>
);
}