'init'
This commit is contained in:
195
app/course/[moduleIndex]/[lessonIndex]/LessonMarkdown.tsx
Normal file
195
app/course/[moduleIndex]/[lessonIndex]/LessonMarkdown.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeHighlight from "rehype-highlight";
|
||||
import "highlight.js/styles/atom-one-dark.min.css";
|
||||
import styles from "./lesson-markdown.module.css";
|
||||
|
||||
type TocItem = { level: number; text: string; id: string };
|
||||
|
||||
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 slugify(text: string): string {
|
||||
return text
|
||||
.replace(/[^\p{L}\p{N}\s-]/gu, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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`}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[[rehypeHighlight, { detect: true }]]}
|
||||
components={{
|
||||
h1: ({ children }) => {
|
||||
const text = String(children);
|
||||
const id = slugify(text);
|
||||
return (
|
||||
<h1 id={id} className="scroll-mt-24">
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
},
|
||||
h2: ({ children }) => {
|
||||
const text = String(children);
|
||||
const id = slugify(text);
|
||||
return (
|
||||
<h2 id={id} className="scroll-mt-24">
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
},
|
||||
h3: ({ children }) => <h3 className="scroll-mt-20">{children}</h3>,
|
||||
h4: ({ children }) => <h4>{children}</h4>,
|
||||
h5: ({ children }) => <h5>{children}</h5>,
|
||||
h6: ({ children }) => <h6>{children}</h6>,
|
||||
p: ({ children }) => <p>{children}</p>,
|
||||
ul: ({ children }) => <ul>{children}</ul>,
|
||||
ol: ({ children }) => <ol>{children}</ol>,
|
||||
li: ({ children, className }) => (
|
||||
<li className={className?.includes("task-list-item") ? "flex items-start gap-2" : ""}>
|
||||
{children}
|
||||
</li>
|
||||
),
|
||||
pre: ({ children }) => (
|
||||
<pre className={styles.codeBlock}>{children}</pre>
|
||||
),
|
||||
code: ({ className, children }) => (
|
||||
<code className={className}>{children}</code>
|
||||
),
|
||||
blockquote: ({ children }) => <blockquote>{children}</blockquote>,
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
img: ({ src, alt }) => (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={src || ""} alt={alt || ""} loading="lazy" decoding="async" />
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto">
|
||||
<table>{children}</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }) => <th>{children}</th>,
|
||||
td: ({ children }) => <td>{children}</td>,
|
||||
hr: () => <hr />,
|
||||
strong: ({ children }) => <strong>{children}</strong>,
|
||||
em: ({ children }) => <em>{children}</em>,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
{/* 目录抽屉 */}
|
||||
<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,362 @@
|
||||
/* 参考 digital ebook 样式,适配日间/夜间主题 */
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Markdown 正文样式 */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 代码块 - highlight.js 主题已通过 atom-one-dark 提供语法高亮 */
|
||||
.markdownBody pre.codeBlock {
|
||||
margin: 24px 0;
|
||||
padding: 0;
|
||||
overflow-x: auto;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.markdownBody pre.codeBlock code {
|
||||
display: block;
|
||||
padding: 16px;
|
||||
font-size: 0.875em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 行内代码 - 与代码块区分 */
|
||||
.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;
|
||||
}
|
||||
|
||||
.light .markdownBody p code,
|
||||
.light .markdownBody li code {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
color: #c41e3a;
|
||||
}
|
||||
|
||||
.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 img:hover {
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.markdownBody ul,
|
||||
.markdownBody ol {
|
||||
margin: 16px 0;
|
||||
padding-left: 28px;
|
||||
}
|
||||
|
||||
.markdownBody li {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.markdownBody li > ul,
|
||||
.markdownBody li > ol {
|
||||
margin-top: 6px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.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 h1::after {
|
||||
width: 50px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.markdownBody h2 {
|
||||
font-size: 1.35rem;
|
||||
margin: 28px 0 12px;
|
||||
}
|
||||
|
||||
.markdownBody h3 {
|
||||
font-size: 1.2rem;
|
||||
margin: 24px 0 10px;
|
||||
}
|
||||
|
||||
.markdownBody p {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.backToTop,
|
||||
.tocButton {
|
||||
bottom: 32px;
|
||||
right: 32px;
|
||||
}
|
||||
|
||||
.tocButton {
|
||||
right: 92px;
|
||||
}
|
||||
}
|
||||
154
app/course/[moduleIndex]/[lessonIndex]/page.tsx
Normal file
154
app/course/[moduleIndex]/[lessonIndex]/page.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Header } from "@/app/components";
|
||||
import { VideoPlayer } from "@/app/components/VideoPlayer";
|
||||
import { LessonMarkdown } from "./LessonMarkdown";
|
||||
import { getLesson } from "@/config/lessons";
|
||||
import { canWatchLesson } from "@/app/lib/payment";
|
||||
import { setLastWatched, markCompleted } from "@/app/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="/"
|
||||
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="mb-6 flex items-center gap-2 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="flex items-center gap-1 text-[var(--muted-foreground)] transition hover:text-[var(--foreground)]"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
返回
|
||||
</button>
|
||||
<span className="text-[var(--muted-foreground)]">/</span>
|
||||
<Link href="/" className="text-[var(--muted-foreground)] transition hover:text-[var(--foreground)]">
|
||||
课程首页
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* 标题 */}
|
||||
<h1 className="mb-2 flex items-center gap-2 text-xl font-bold sm:text-2xl md:text-3xl">
|
||||
{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>
|
||||
|
||||
{/* 视频播放器 / 锁定 overlay */}
|
||||
<div className="relative mb-12">
|
||||
{unlocked ? (
|
||||
<VideoPlayer
|
||||
src={lesson.videoUrl}
|
||||
title={lesson.name}
|
||||
videoId={`course-${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="/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="relative rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm md:rounded-2xl md:p-8">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user