's'
This commit is contained in:
407
app/cloudphone/cloudphone.module.css
Normal file
407
app/cloudphone/cloudphone.module.css
Normal 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;
|
||||
}
|
||||
41
app/cloudphone/components/AnimateOnScroll.tsx
Normal file
41
app/cloudphone/components/AnimateOnScroll.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
app/cloudphone/components/ChatFloatButton.tsx
Normal file
20
app/cloudphone/components/ChatFloatButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
app/cloudphone/components/Footer.tsx
Normal file
37
app/cloudphone/components/Footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
app/cloudphone/components/Header.tsx
Normal file
59
app/cloudphone/components/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
app/cloudphone/components/ParticleBackground.tsx
Normal file
30
app/cloudphone/components/ParticleBackground.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
app/cloudphone/components/Section.tsx
Normal file
16
app/cloudphone/components/Section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
361
app/cloudphone/components/VideoPlayer.tsx
Normal file
361
app/cloudphone/components/VideoPlayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
app/cloudphone/components/index.ts
Normal file
11
app/cloudphone/components/index.ts
Normal 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";
|
||||
28
app/cloudphone/components/sections/AudienceSection.tsx
Normal file
28
app/cloudphone/components/sections/AudienceSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
app/cloudphone/components/sections/BenefitsSection.tsx
Normal file
32
app/cloudphone/components/sections/BenefitsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
app/cloudphone/components/sections/CTASection.tsx
Normal file
46
app/cloudphone/components/sections/CTASection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
140
app/cloudphone/components/sections/CurriculumSection.tsx
Normal file
140
app/cloudphone/components/sections/CurriculumSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
app/cloudphone/components/sections/FAQSection.tsx
Normal file
43
app/cloudphone/components/sections/FAQSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
app/cloudphone/components/sections/Hero.tsx
Normal file
106
app/cloudphone/components/sections/Hero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
app/cloudphone/components/sections/InstructorsSection.tsx
Normal file
37
app/cloudphone/components/sections/InstructorsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
128
app/cloudphone/config/course.ts
Normal file
128
app/cloudphone/config/course.ts
Normal 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;
|
||||
145
app/cloudphone/config/lessons.ts
Normal file
145
app/cloudphone/config/lessons.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
149
app/cloudphone/course/[moduleIndex]/[lessonIndex]/page.tsx
Normal file
149
app/cloudphone/course/[moduleIndex]/[lessonIndex]/page.tsx
Normal 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
20
app/cloudphone/layout.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
141
app/cloudphone/lib/learning.ts
Normal file
141
app/cloudphone/lib/learning.ts
Normal 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 */
|
||||
}
|
||||
}
|
||||
21
app/cloudphone/lib/payment.ts
Normal file
21
app/cloudphone/lib/payment.ts
Normal 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
32
app/cloudphone/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
app/cloudphone/pay/page.tsx
Normal file
64
app/cloudphone/pay/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user