This commit is contained in:
root
2026-06-07 18:06:52 +08:00
parent 9de2465793
commit a1daa040ef
33 changed files with 1917 additions and 439 deletions

View File

@@ -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>

View File

@@ -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)}

View File

@@ -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>