s
This commit is contained in:
@@ -1,10 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "@/app/digital/i18n/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useTranslation } from "@/app/digital/i18n/navigation";
|
||||
import DigitalThemeIcon, { type IconName } from "@/app/digital/components/DigitalThemeIcon";
|
||||
|
||||
type BeforeInstallPromptEvent = Event & {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: "accepted" | "dismissed"; platform: string }>;
|
||||
};
|
||||
|
||||
export default function AppDownloadContent() {
|
||||
const { t } = useTranslation("appDownload");
|
||||
const [installPrompt, setInstallPrompt] = useState<BeforeInstallPromptEvent | null>(null);
|
||||
const [installState, setInstallState] = useState<"idle" | "available" | "installed" | "manual">("idle");
|
||||
|
||||
useEffect(() => {
|
||||
window.history.scrollRestoration = "manual";
|
||||
@@ -14,55 +22,150 @@ export default function AppDownloadContent() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onBeforeInstallPrompt = (event: Event) => {
|
||||
event.preventDefault();
|
||||
setInstallPrompt(event as BeforeInstallPromptEvent);
|
||||
setInstallState("available");
|
||||
};
|
||||
const onInstalled = () => {
|
||||
setInstallPrompt(null);
|
||||
setInstallState("installed");
|
||||
};
|
||||
|
||||
window.addEventListener("beforeinstallprompt", onBeforeInstallPrompt);
|
||||
window.addEventListener("appinstalled", onInstalled);
|
||||
return () => {
|
||||
window.removeEventListener("beforeinstallprompt", onBeforeInstallPrompt);
|
||||
window.removeEventListener("appinstalled", onInstalled);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const features = [
|
||||
{ key: "resources", icon: "📚" },
|
||||
{ key: "community", icon: "👥" },
|
||||
{ key: "tools", icon: "🛠️" },
|
||||
{ key: "resources", icon: "book", emoji: "📚" },
|
||||
{ key: "community", icon: "globe", emoji: "👥" },
|
||||
{ key: "tools", icon: "tool", emoji: "🛠️" },
|
||||
] as const;
|
||||
|
||||
const installApp = async () => {
|
||||
if (!installPrompt) {
|
||||
setInstallState("manual");
|
||||
return;
|
||||
}
|
||||
|
||||
await installPrompt.prompt();
|
||||
const result = await installPrompt.userChoice;
|
||||
setInstallPrompt(null);
|
||||
setInstallState(result.outcome === "accepted" ? "installed" : "manual");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-950">
|
||||
<section className="py-20 sm:py-28">
|
||||
<div className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center">
|
||||
<div className="mb-6 inline-flex h-24 w-24 items-center justify-center rounded-2xl bg-gradient-to-br from-sky-400 to-cyan-500 text-5xl shadow-lg shadow-sky-200/50 dark:shadow-sky-900/30">
|
||||
📱
|
||||
<section className="pt-24 pb-20 sm:pt-28 sm:pb-28">
|
||||
<div className="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid items-center gap-10 lg:grid-cols-[1.05fr_0.95fr]">
|
||||
<div>
|
||||
<div className="dn-badge mb-5 inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 shadow-sm dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300">
|
||||
<DigitalThemeIcon name="phone" emoji="📱" className="h-4 w-4" />
|
||||
{t("badge")}
|
||||
</div>
|
||||
<h1 className="dn-section-title text-4xl font-bold tracking-tight text-slate-900 dark:text-slate-100 sm:text-5xl">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="mt-4 max-w-2xl text-lg leading-8 text-slate-600 dark:text-slate-400">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
|
||||
<Link
|
||||
href="/"
|
||||
className="dn-button-primary inline-flex items-center justify-center gap-2 rounded-full bg-[var(--dn-primary)] px-6 py-3 font-semibold text-white transition hover:bg-[var(--dn-primary-strong)]"
|
||||
>
|
||||
<DigitalThemeIcon name="app" emoji="📱" className="h-5 w-5" />
|
||||
{t("openWeb")}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={installApp}
|
||||
className="dn-button-secondary inline-flex cursor-pointer items-center justify-center gap-2 rounded-full border border-slate-200 bg-white px-6 py-3 font-semibold text-slate-700 transition hover:border-[var(--dn-primary)] hover:text-[var(--dn-primary)] dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200"
|
||||
>
|
||||
<DigitalThemeIcon name="phone" emoji="📱" className="h-5 w-5" />
|
||||
{installState === "available" ? t("installReady") : t("install")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-sm text-slate-500 dark:text-slate-400">
|
||||
{installState === "installed"
|
||||
? t("installed")
|
||||
: installState === "manual"
|
||||
? t("manualInstall")
|
||||
: t("installHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto w-full max-w-sm">
|
||||
<div
|
||||
className="dn-card relative overflow-hidden rounded-[2rem] border border-slate-200 bg-white p-4 shadow-xl dark:border-slate-800 dark:bg-slate-900"
|
||||
aria-label={t("previewLabel")}
|
||||
>
|
||||
<div className="rounded-[1.5rem] bg-slate-950 p-3">
|
||||
<div className="overflow-hidden rounded-[1.15rem] bg-slate-50 dark:bg-slate-900">
|
||||
<div className="border-b border-slate-200 bg-white px-4 py-3 dark:border-slate-800 dark:bg-slate-950">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-bold text-slate-900 dark:text-slate-100">{t("previewTitle")}</span>
|
||||
<span className="h-2 w-2 rounded-full bg-[var(--dn-primary)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 p-4">
|
||||
<div className="rounded-xl bg-[var(--dn-primary)] p-4 text-white">
|
||||
<p className="text-xs font-medium text-white/75">{t("previewKicker")}</p>
|
||||
<p className="mt-1 text-lg font-bold">{t("previewRoute")}</p>
|
||||
</div>
|
||||
{features.map(({ key, icon, emoji }) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-start gap-3 rounded-xl border border-slate-200 bg-white p-3 dark:border-slate-800 dark:bg-slate-950"
|
||||
>
|
||||
<span className="dn-icon-box flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-slate-50 text-[var(--dn-primary)] dark:bg-slate-800">
|
||||
<DigitalThemeIcon name={icon as IconName} emoji={emoji} className="h-5 w-5" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-bold text-slate-900 dark:text-slate-100">
|
||||
{t(`features.${key}.title`)}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs leading-5 text-slate-500 dark:text-slate-400">
|
||||
{t(`features.${key}.desc`)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-slate-900 dark:text-slate-100 sm:text-5xl">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="mx-auto mt-4 max-w-2xl text-lg text-slate-600 dark:text-slate-400">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 grid gap-6 sm:grid-cols-3">
|
||||
{features.map(({ key, icon }) => (
|
||||
<div className="mt-12 grid gap-4 sm:grid-cols-3">
|
||||
{features.map(({ key, icon, emoji }) => (
|
||||
<div
|
||||
key={key}
|
||||
className="card-hover flex flex-col items-center rounded-2xl border border-slate-100 bg-white p-6 text-center shadow-sm dark:border-slate-800 dark:bg-slate-900"
|
||||
className="dn-card flex items-start gap-4 rounded-xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900"
|
||||
>
|
||||
<span className="mb-4 flex h-14 w-14 items-center justify-center rounded-xl bg-slate-50 text-2xl dark:bg-slate-800">
|
||||
{icon}
|
||||
<span className="dn-icon-box flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-slate-50 text-[var(--dn-primary)] dark:bg-slate-800">
|
||||
<DigitalThemeIcon name={icon as IconName} emoji={emoji} className="h-5 w-5" />
|
||||
</span>
|
||||
<span>
|
||||
<span className="block font-bold text-slate-900 dark:text-slate-100">
|
||||
{t(`features.${key}.title`)}
|
||||
</span>
|
||||
<span className="mt-1 block text-sm leading-6 text-slate-500 dark:text-slate-400">
|
||||
{t(`features.${key}.desc`)}
|
||||
</span>
|
||||
</span>
|
||||
<h3 className="text-lg font-bold text-slate-900 dark:text-slate-100">
|
||||
{t(`features.${key}.title`)}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
|
||||
{t(`features.${key}.desc`)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-16 text-center">
|
||||
<div className="inline-flex items-center gap-2 rounded-full bg-amber-100 px-4 py-2 text-sm font-medium text-amber-800 dark:bg-amber-900/50 dark:text-amber-200">
|
||||
{t("comingSoon")}
|
||||
</div>
|
||||
<p className="mt-4 text-slate-500 dark:text-slate-400">
|
||||
{t("comingSoonDesc")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -2,15 +2,23 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Link, useTranslation } from "@/app/digital/i18n/navigation";
|
||||
import { Link } from "@/app/digital/i18n/navigation";
|
||||
import { VideoPlayer } from "@/app/digital/components/course/VideoPlayer";
|
||||
import { LessonMarkdown } from "./LessonMarkdown";
|
||||
import { getLesson } from "@/config/digital/lessons";
|
||||
import { getLesson, type LessonDetail } from "@/config/digital/lessons";
|
||||
import { CURRICULUM_CONFIG, DOWNLOAD_CONFIG } from "@/config/digital/course";
|
||||
import { canWatchLesson, isFreeTrial } from "@/app/digital/lib/course-payment";
|
||||
import { useAuthAndVip } from "@/app/digital/lib/useAuthAndVip";
|
||||
import { getStoredToken, getStoredUser } from "@/app/digital/lib/pocketbase";
|
||||
import { setLastWatched, markCompleted } from "@/app/digital/lib/learning";
|
||||
import { DOWNLOAD_CONFIG } from "@/config/digital/course";
|
||||
import {
|
||||
getCompletedLessons,
|
||||
getNote,
|
||||
isBookmarked,
|
||||
markCompleted,
|
||||
setLastWatched,
|
||||
setNote as saveNote,
|
||||
toggleBookmark,
|
||||
} from "@/app/digital/lib/learning";
|
||||
import Header from "@/app/digital/components/Header";
|
||||
import AuthModal from "@/app/digital/components/AuthModal";
|
||||
import {
|
||||
@@ -18,23 +26,191 @@ import {
|
||||
redirectToPay,
|
||||
getDeviceFromEnv,
|
||||
} from "@/app/digital/lib/payment";
|
||||
|
||||
const COURSE_LESSONS = CURRICULUM_CONFIG.modules.flatMap(({ lessons }, moduleIndex) =>
|
||||
lessons.map((_, lessonIndex) => ({ moduleIndex, lessonIndex }))
|
||||
);
|
||||
|
||||
type DeckSlide = {
|
||||
title: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
function buildDeckSlides(lesson: LessonDetail): DeckSlide[] {
|
||||
const sections = lesson.markdown
|
||||
.split("\n## ")
|
||||
.slice(1)
|
||||
.map((section) => {
|
||||
const [rawTitle, ...bodyLines] = section.split("\n");
|
||||
return {
|
||||
title: rawTitle.trim(),
|
||||
body: bodyLines
|
||||
.filter((line) => line.trim())
|
||||
.slice(0, 5)
|
||||
.join("\n")
|
||||
.replace(/^- /gm, "")
|
||||
.replace(/^\d+\. /gm, ""),
|
||||
};
|
||||
})
|
||||
.filter((slide) => slide.title && slide.body);
|
||||
|
||||
return [
|
||||
{
|
||||
title: "本节导览",
|
||||
body: `${lesson.name}\n预计学习 ${lesson.duration}\n完成课件后可标记进度并保存笔记。`,
|
||||
},
|
||||
...sections,
|
||||
];
|
||||
}
|
||||
|
||||
function LessonDeck({
|
||||
lesson,
|
||||
onComplete,
|
||||
}: {
|
||||
lesson: LessonDetail;
|
||||
onComplete: () => void;
|
||||
}) {
|
||||
const slides = buildDeckSlides(lesson);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const activeSlide = slides[activeIndex] ?? slides[0];
|
||||
const progress = slides.length > 0 ? Math.round(((activeIndex + 1) / slides.length) * 100) : 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!playing) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
setActiveIndex((current) => {
|
||||
if (current >= slides.length - 1) {
|
||||
setPlaying(false);
|
||||
onComplete();
|
||||
return current;
|
||||
}
|
||||
return current + 1;
|
||||
});
|
||||
}, 6500);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [activeIndex, onComplete, playing, slides.length]);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--card)] shadow-xl md:rounded-2xl">
|
||||
<div className="flex min-h-[min(58vw,420px)] flex-col bg-slate-950 text-white">
|
||||
<div className="flex items-center justify-between gap-4 border-b border-white/10 px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-white/50">Interactive Lesson</p>
|
||||
<h2 className="truncate text-base font-semibold sm:text-lg">{activeSlide.title}</h2>
|
||||
</div>
|
||||
<span className="shrink-0 rounded-full bg-white/10 px-3 py-1 text-xs text-white/70">
|
||||
{activeIndex + 1} / {slides.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 items-center px-5 py-8 sm:px-8">
|
||||
<p className="whitespace-pre-line text-base leading-8 text-white/85 sm:text-lg sm:leading-9">
|
||||
{activeSlide.body}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/10 px-5 py-4">
|
||||
<div className="mb-4 h-1.5 overflow-hidden rounded-full bg-white/15">
|
||||
<div className="h-full rounded-full bg-[var(--accent)] transition-all" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveIndex((index) => Math.max(0, index - 1))}
|
||||
disabled={activeIndex === 0}
|
||||
className="rounded-lg border border-white/15 px-3 py-2 text-sm text-white/80 transition hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveIndex((index) => Math.min(slides.length - 1, index + 1))}
|
||||
disabled={activeIndex === slides.length - 1}
|
||||
className="rounded-lg border border-white/15 px-3 py-2 text-sm text-white/80 transition hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPlaying((value) => !value)}
|
||||
className="rounded-lg bg-white px-4 py-2 text-sm font-semibold text-slate-950 transition hover:bg-white/90"
|
||||
>
|
||||
{playing ? "暂停" : "播放课件"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onComplete}
|
||||
className="rounded-lg bg-[var(--accent)] px-4 py-2 text-sm font-semibold text-[var(--accent-foreground)] transition hover:opacity-90"
|
||||
>
|
||||
完成本节
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LessonPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation("community");
|
||||
const { user, vip, refetch } = useAuthAndVip();
|
||||
const [toast, setToast] = useState(false);
|
||||
const { vip, refetch } = useAuthAndVip();
|
||||
const [authOpen, setAuthOpen] = useState(false);
|
||||
const [payRedirecting, setPayRedirecting] = useState(false);
|
||||
const [bookmarked, setBookmarked] = useState(false);
|
||||
const [completed, setCompleted] = useState(false);
|
||||
const [lessonNote, setLessonNote] = useState("");
|
||||
const [statusMessage, setStatusMessage] = useState("");
|
||||
const moduleIndex = Number(params.moduleIndex);
|
||||
const lessonIndex = Number(params.lessonIndex);
|
||||
|
||||
const lesson = getLesson(moduleIndex, lessonIndex);
|
||||
const unlocked = canWatchLesson(moduleIndex, lessonIndex, vip);
|
||||
const lessonName = lesson?.name;
|
||||
const currentLessonIndex = COURSE_LESSONS.findIndex(
|
||||
(item) => item.moduleIndex === moduleIndex && item.lessonIndex === lessonIndex
|
||||
);
|
||||
const previousLesson = currentLessonIndex > 0 ? COURSE_LESSONS[currentLessonIndex - 1] : null;
|
||||
const nextLesson =
|
||||
currentLessonIndex >= 0 && currentLessonIndex < COURSE_LESSONS.length - 1
|
||||
? COURSE_LESSONS[currentLessonIndex + 1]
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (lesson && unlocked) setLastWatched(moduleIndex, lessonIndex, lesson.name);
|
||||
}, [moduleIndex, lessonIndex, lesson?.name, unlocked]);
|
||||
if (lessonName && unlocked) setLastWatched(moduleIndex, lessonIndex, lessonName);
|
||||
}, [moduleIndex, lessonIndex, lessonName, unlocked]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lesson) return;
|
||||
const key = `${moduleIndex}-${lessonIndex}`;
|
||||
setBookmarked(isBookmarked(moduleIndex, lessonIndex));
|
||||
setCompleted(getCompletedLessons().has(key));
|
||||
setLessonNote(getNote(moduleIndex, lessonIndex));
|
||||
setStatusMessage("");
|
||||
}, [moduleIndex, lessonIndex, lesson]);
|
||||
|
||||
const handleBookmark = () => {
|
||||
const active = toggleBookmark(moduleIndex, lessonIndex);
|
||||
setBookmarked(active);
|
||||
setStatusMessage(active ? "已收藏本节" : "已取消收藏");
|
||||
};
|
||||
|
||||
const handleMarkCompleted = () => {
|
||||
markCompleted(moduleIndex, lessonIndex);
|
||||
setCompleted(true);
|
||||
setStatusMessage("本节已完成");
|
||||
};
|
||||
|
||||
const handleSaveNote = () => {
|
||||
saveNote(moduleIndex, lessonIndex, lessonNote);
|
||||
setStatusMessage("笔记已保存");
|
||||
};
|
||||
|
||||
const startPay = (userId: string) => {
|
||||
const env = getPayEnv();
|
||||
@@ -155,17 +331,24 @@ export default function LessonPage() {
|
||||
|
||||
<div className="relative mb-12">
|
||||
{unlocked ? (
|
||||
<VideoPlayer
|
||||
src={lesson.videoUrl}
|
||||
title={lesson.name}
|
||||
videoId={`course-${moduleIndex}-${lessonIndex}`}
|
||||
className="shadow-xl"
|
||||
onComplete={() => markCompleted(moduleIndex, lessonIndex)}
|
||||
/>
|
||||
lesson.videoUrl ? (
|
||||
<VideoPlayer
|
||||
src={lesson.videoUrl}
|
||||
title={lesson.name}
|
||||
videoId={`course-${moduleIndex}-${lessonIndex}`}
|
||||
className="shadow-xl"
|
||||
onComplete={handleMarkCompleted}
|
||||
/>
|
||||
) : (
|
||||
<LessonDeck key={`${moduleIndex}-${lessonIndex}`} lesson={lesson} onComplete={handleMarkCompleted} />
|
||||
)
|
||||
) : (
|
||||
<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>
|
||||
<svg className="h-14 w-14 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M8 11V7a4 4 0 0 1 8 0v4" />
|
||||
<rect x="5" y="11" width="14" height="10" rx="2" strokeWidth={1.8} />
|
||||
</svg>
|
||||
<p className="text-lg text-white/90">报名解锁本章节</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -180,6 +363,55 @@ export default function LessonPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{unlocked && (
|
||||
<div className="mb-8 grid gap-3 sm:grid-cols-[1fr_1fr_auto]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBookmark}
|
||||
className="inline-flex cursor-pointer items-center justify-center gap-2 rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 text-sm font-semibold transition hover:border-[var(--accent)] hover:text-[var(--accent)]"
|
||||
>
|
||||
<svg className="h-4 w-4" fill={bookmarked ? "currentColor" : "none"} stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="m12 3 2.9 5.88 6.5.95-4.7 4.58 1.1 6.47L12 17.83l-5.8 3.05 1.1-6.47-4.7-4.58 6.5-.95L12 3z" />
|
||||
</svg>
|
||||
{bookmarked ? "已收藏" : "收藏本节"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMarkCompleted}
|
||||
className="inline-flex cursor-pointer items-center justify-center gap-2 rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 text-sm font-semibold transition hover:border-[var(--accent)] hover:text-[var(--accent)]"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
{completed ? "已完成" : "标记完成"}
|
||||
</button>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{previousLesson && (
|
||||
<Link
|
||||
href={`/course/${previousLesson.moduleIndex}/${previousLesson.lessonIndex}`}
|
||||
className="inline-flex items-center justify-center rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 text-sm font-semibold transition hover:border-[var(--accent)] hover:text-[var(--accent)]"
|
||||
>
|
||||
上一节
|
||||
</Link>
|
||||
)}
|
||||
{nextLesson && (
|
||||
<Link
|
||||
href={`/course/${nextLesson.moduleIndex}/${nextLesson.lessonIndex}`}
|
||||
className="inline-flex items-center justify-center rounded-xl bg-[var(--accent)] px-4 py-3 text-sm font-semibold text-[var(--accent-foreground)] transition hover:opacity-90"
|
||||
>
|
||||
下一节
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{statusMessage && (
|
||||
<p className="mb-6 rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 text-sm text-[var(--muted-foreground)]" role="status">
|
||||
{statusMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{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">
|
||||
@@ -192,6 +424,33 @@ export default function LessonPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 20h9" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
|
||||
</svg>
|
||||
学习笔记
|
||||
</h2>
|
||||
<textarea
|
||||
value={lessonNote}
|
||||
onChange={(event) => setLessonNote(event.target.value)}
|
||||
className="min-h-32 w-full rounded-xl border border-[var(--border)] bg-[var(--background)] px-4 py-3 text-sm leading-6 outline-none transition focus:border-[var(--accent)] focus:ring-2 focus:ring-[var(--accent)]/20"
|
||||
placeholder="记录本节重点、行动清单或自己的实践结果"
|
||||
/>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveNote}
|
||||
className="inline-flex cursor-pointer items-center justify-center rounded-full bg-[var(--accent)] px-6 py-3 text-sm font-semibold text-[var(--accent-foreground)] transition hover:opacity-90"
|
||||
>
|
||||
保存笔记
|
||||
</button>
|
||||
</div>
|
||||
</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">
|
||||
@@ -200,7 +459,7 @@ export default function LessonPage() {
|
||||
</svg>
|
||||
{DOWNLOAD_CONFIG.title}
|
||||
</h2>
|
||||
<p className="mb-4 text-sm text-[var(--muted-foreground)]">课程配套资料已上传至网盘,报名后可前往下载</p>
|
||||
<p className="mb-4 text-sm text-[var(--muted-foreground)]">课程配套手册可直接下载,便于和本节笔记一起复盘</p>
|
||||
<a
|
||||
href={DOWNLOAD_CONFIG.url}
|
||||
target="_blank"
|
||||
@@ -216,14 +475,6 @@ export default function LessonPage() {
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
{toast && (
|
||||
<div
|
||||
className="fixed bottom-8 left-1/2 z-50 -translate-x-1/2 rounded-lg bg-slate-800 px-6 py-3 text-sm font-medium text-white shadow-lg dark:bg-slate-700"
|
||||
role="alert"
|
||||
>
|
||||
{t("updating")}
|
||||
</div>
|
||||
)}
|
||||
<AuthModal
|
||||
isOpen={authOpen}
|
||||
onClose={() => setAuthOpen(false)}
|
||||
|
||||
@@ -11,7 +11,7 @@ const positionOptions = [
|
||||
{ value: "other", labelZh: "其他", labelEn: "Other" },
|
||||
];
|
||||
|
||||
interface FormData {
|
||||
interface JobFormData {
|
||||
position: string;
|
||||
name: string;
|
||||
email: string;
|
||||
@@ -52,7 +52,7 @@ function ChevronDown() {
|
||||
export default function JobsContent() {
|
||||
const { t } = useTranslation("jobs");
|
||||
const locale = useLocale();
|
||||
const [form, setForm] = useState<FormData>({
|
||||
const [form, setForm] = useState<JobFormData>({
|
||||
position: "",
|
||||
name: "",
|
||||
email: "",
|
||||
@@ -61,23 +61,51 @@ export default function JobsContent() {
|
||||
});
|
||||
const [resume, setResume] = useState<File | null>(null);
|
||||
const [resumeError, setResumeError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitMessage, setSubmitMessage] = useState("");
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const set = (key: keyof FormData, value: string) =>
|
||||
const set = (key: keyof JobFormData, value: string) =>
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setResumeError(null);
|
||||
setSubmitMessage("");
|
||||
if (!resume) {
|
||||
setResumeError(locale === "zh" ? "请上传简历(PDF 或 Word)" : "Please upload your resume (PDF or Word)");
|
||||
return;
|
||||
}
|
||||
// 暂不提交,无业务逻辑
|
||||
if (locale === "zh") {
|
||||
alert("表单已填写完成,提交功能即将上线。");
|
||||
} else {
|
||||
alert("Form completed. Submit feature coming soon.");
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const uploadForm = new window.FormData();
|
||||
uploadForm.append("file", resume);
|
||||
const uploadRes = await fetch("/api/digital/upload-media", {
|
||||
method: "POST",
|
||||
body: uploadForm,
|
||||
});
|
||||
const uploadData = await uploadRes.json().catch(() => ({}));
|
||||
if (!uploadRes.ok || !uploadData?.url) {
|
||||
throw new Error(uploadData?.error || (locale === "zh" ? "简历上传失败" : "Resume upload failed"));
|
||||
}
|
||||
|
||||
const submitRes = await fetch("/api/digital/jobs", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...form, resumeUrl: uploadData.url }),
|
||||
});
|
||||
const submitData = await submitRes.json().catch(() => ({}));
|
||||
if (!submitRes.ok || !submitData?.ok) {
|
||||
throw new Error(submitData?.error || (locale === "zh" ? "提交失败" : "Submit failed"));
|
||||
}
|
||||
|
||||
setForm({ position: "", name: "", email: "", phone: "", intro: "" });
|
||||
removeResume();
|
||||
setSubmitMessage(locale === "zh" ? "申请已提交,我们会尽快联系你。" : "Application submitted. We will contact you soon.");
|
||||
} catch (error) {
|
||||
setResumeError(error instanceof Error ? error.message : (locale === "zh" ? "提交失败" : "Submit failed"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -137,6 +165,12 @@ export default function JobsContent() {
|
||||
onSubmit={handleSubmit}
|
||||
className="mt-0 bg-white px-5 pb-8 pt-2 shadow-sm sm:mt-5 sm:rounded-2xl sm:px-8 sm:py-8 sm:shadow-md"
|
||||
>
|
||||
{submitMessage && (
|
||||
<div className="mb-4 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||
{submitMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-0 sm:grid-cols-2 sm:gap-6">
|
||||
<Field label={t("position")}>
|
||||
<select
|
||||
@@ -270,9 +304,10 @@ export default function JobsContent() {
|
||||
<div className="mt-8 sm:mt-10">
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded-full bg-amber-500 px-8 py-4 text-base font-semibold text-white transition-colors hover:bg-amber-600"
|
||||
disabled={submitting}
|
||||
className="w-full rounded-full bg-amber-500 px-8 py-4 text-base font-semibold text-white transition-colors hover:bg-amber-600 disabled:cursor-not-allowed disabled:bg-slate-300"
|
||||
>
|
||||
{t("submit")}
|
||||
{submitting ? (locale === "zh" ? "提交中..." : "Submitting...") : t("submit")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user