ALIEZ 1 miesiąc temu
rodzic
commit
ef3a374f29

+ 11 - 1
messages/en.json

@@ -102,7 +102,17 @@
     "videoNoPlayableUrl": "No playable URL",
     "videoPrevPage": "Previous",
     "videoNextPage": "Next",
-    "videoCurrentPage": "Page {page}"
+    "videoCurrentPage": "Page {page}",
+    "fileListTitle": "File list",
+    "fileListLoading": "Loading files...",
+    "fileListEmpty": "No files yet",
+    "fileNoCover": "No cover",
+    "fileNoIntro": "No description yet",
+    "fileDownload": "Download file",
+    "filePreview": "Preview file",
+    "filePreviewing": "Previewing",
+    "fileOpenNewWindow": "Open in new window",
+    "fileClosePreview": "Close preview"
   },
   "checkout": {
     "title": "Checkout",

+ 11 - 1
messages/zh.json

@@ -102,7 +102,17 @@
     "videoNoPlayableUrl": "暂无可播放地址",
     "videoPrevPage": "上一页",
     "videoNextPage": "下一页",
-    "videoCurrentPage": "第 {page} 页"
+    "videoCurrentPage": "第 {page} 页",
+    "fileListTitle": "文件列表",
+    "fileListLoading": "文件加载中...",
+    "fileListEmpty": "暂无文件数据",
+    "fileNoCover": "暂无封面",
+    "fileNoIntro": "暂无简介",
+    "fileDownload": "下载文件",
+    "filePreview": "预览文件",
+    "filePreviewing": "预览中",
+    "fileOpenNewWindow": "新窗口打开",
+    "fileClosePreview": "关闭预览"
   },
   "checkout": {
     "title": "选择存款方式",

+ 68 - 15
src/app/[locale]/account/withdraw-apply/page.tsx

@@ -122,11 +122,20 @@ export default function WithdrawApplyPage() {
   const [cardExpiryInput, setCardExpiryInput] = useState("");
 
   const [submitting, setSubmitting] = useState(false);
-  const [error, setError] = useState<string | null>(null);
-  const [success, setSuccess] = useState<string | null>(null);
   const [confirmOpen, setConfirmOpen] = useState(false);
   const [expandedGroup, setExpandedGroup] = useState<string>("数字货币");
   const [applyDialogOpen, setApplyDialogOpen] = useState(false);
+  const [resultDialog, setResultDialog] = useState<{
+    open: boolean;
+    status: "success" | "error";
+    title: string;
+    message: string;
+  }>({
+    open: false,
+    status: "success",
+    title: "",
+    message: "",
+  });
 
   const selectedChannel = useMemo(
     () => channels.find((item) => item.id === selectedChannelId) ?? null,
@@ -340,14 +349,17 @@ export default function WithdrawApplyPage() {
       payload.expiryYearMonth = cardExpiryInput.trim();
     }
     setSubmitting(true);
-    setError(null);
-    setSuccess(null);
     try {
       await submitWithdrawApply({
         requestUrl: selectedChannel.requestUrl,
         payload,
       });
-      setSuccess("提款申请提交成功,请等待审核。");
+      setResultDialog({
+        open: true,
+        status: "success",
+        title: "提交成功",
+        message: "提款申请已提交,请等待平台审核。",
+      });
       setAmount("");
       setAddress("");
       setAgree(false);
@@ -369,7 +381,12 @@ export default function WithdrawApplyPage() {
       setCardExpiryInput("");
     } catch (e) {
       const err = e as Error;
-      setError(err.message || "提款申请失败,请稍后重试。");
+      setResultDialog({
+        open: true,
+        status: "error",
+        title: "提交失败",
+        message: err.message || "提款申请失败,请稍后重试。",
+      });
     } finally {
       setSubmitting(false);
       setConfirmOpen(false);
@@ -825,22 +842,19 @@ export default function WithdrawApplyPage() {
               </label>
             }
 
-            {
-              <>
-                {error ? <p className="mt-3 text-sm text-rose-700">{error}</p> : null}
-                {success ? <p className="mt-3 text-sm text-emerald-700">{success}</p> : null}
-              </>
-            }
-
             {
               <button
                 type="button"
                 disabled={submitting}
                 onClick={() => {
-                  setError(null);
                   const msg = validate();
                   if (msg) {
-                    setError(msg);
+                    setResultDialog({
+                      open: true,
+                      status: "error",
+                      title: "请检查输入信息",
+                      message: msg,
+                    });
                     return;
                   }
                   setConfirmOpen(true);
@@ -853,6 +867,45 @@ export default function WithdrawApplyPage() {
           </section>
         </ModalShell>
       ) : null}
+
+      <ModalShell open={resultDialog.open} className="max-w-md" zIndexClassName="z-[80]">
+        <div className="w-full overflow-hidden rounded-2xl border border-[var(--border)] bg-[var(--card)] shadow-2xl">
+          <div
+            className={`px-5 py-4 ${
+              resultDialog.status === "success"
+                ? "bg-gradient-to-r from-emerald-500/10 to-emerald-400/5"
+                : "bg-gradient-to-r from-rose-500/10 to-rose-400/5"
+            }`}
+          >
+            <div className="flex items-center gap-3">
+              <span
+                className={`inline-flex h-8 w-8 items-center justify-center rounded-full text-base font-bold ${
+                  resultDialog.status === "success"
+                    ? "bg-emerald-100 text-emerald-700"
+                    : "bg-rose-100 text-rose-700"
+                }`}
+              >
+                {resultDialog.status === "success" ? "✓" : "!"}
+              </span>
+              <p className="text-base font-semibold text-[var(--navy)]">{resultDialog.title}</p>
+            </div>
+          </div>
+          <div className="px-5 py-4">
+            <p className="text-sm leading-6 text-[var(--muted)]">{resultDialog.message}</p>
+            <button
+              type="button"
+              onClick={() => setResultDialog((prev) => ({ ...prev, open: false }))}
+              className={`ui-interactive-btn mt-5 w-full rounded-full py-2.5 text-sm font-semibold text-white ${
+                resultDialog.status === "success"
+                  ? "bg-emerald-600 hover:bg-emerald-700"
+                  : "bg-[var(--navy)] hover:bg-[var(--navy-soft)]"
+              }`}
+            >
+              我知道了
+            </button>
+          </div>
+        </div>
+      </ModalShell>
     </div>
   );
 }

+ 27 - 5
src/app/[locale]/auth/forgot-password/page.tsx

@@ -3,11 +3,34 @@
 import { useTranslations } from "next-intl";
 import { Link } from "@/i18n/navigation";
 import { useState } from "react";
+import { ApiError } from "@/lib/api";
+import { sendForgotPasswordEmail } from "@/lib/auth-api";
 
 export default function ForgotPasswordPage() {
   const t = useTranslations("auth");
   const [email, setEmail] = useState("");
   const [done, setDone] = useState(false);
+  const [submitting, setSubmitting] = useState(false);
+  const [err, setErr] = useState<string | null>(null);
+
+  async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
+    e.preventDefault();
+    if (submitting) return;
+    setErr(null);
+    try {
+      setSubmitting(true);
+      await sendForgotPasswordEmail(email.trim());
+      setDone(true);
+    } catch (error) {
+      if (error instanceof ApiError && error.message.trim()) {
+        setErr(error.message);
+      } else {
+        setErr(t("errorApi"));
+      }
+    } finally {
+      setSubmitting(false);
+    }
+  }
 
   return (
     <div className="auth-page-shell">
@@ -15,10 +38,7 @@ export default function ForgotPasswordPage() {
       <p className="mt-2 text-sm text-[var(--muted)]">{t("forgotHint")}</p>
       {!done ? (
         <form
-          onSubmit={(e) => {
-            e.preventDefault();
-            setDone(true);
-          }}
+          onSubmit={onSubmit}
           className="mt-8 space-y-4"
         >
           <div>
@@ -33,10 +53,12 @@ export default function ForgotPasswordPage() {
           </div>
           <button
             type="submit"
+            disabled={submitting}
             className="w-full rounded-full bg-[var(--navy)] py-3 text-sm font-semibold text-white"
           >
-            {t("resetBtn")}
+            {submitting ? "发送中..." : t("resetBtn")}
           </button>
+          {err ? <p className="text-sm text-red-600">{err}</p> : null}
         </form>
       ) : (
         <p className="mt-8 text-sm text-emerald-700">发送重置邮件。</p>

+ 243 - 0
src/app/[locale]/courses/[slug]/files-list-client.tsx

@@ -0,0 +1,243 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useTranslations } from "next-intl";
+import { Link } from "@/i18n/navigation";
+import { fetchCourseFiles, type CourseFile } from "@/data/courses";
+
+type Props = {
+  goodsId: string;
+  allHref?: string;
+  allLabel?: string;
+};
+
+export function CourseFilesListClient({ goodsId, allHref, allLabel }: Props) {
+  const pageSize = 10;
+  const [page, setPage] = useState(1);
+  const [files, setFiles] = useState<CourseFile[] | null>(null);
+  const [total, setTotal] = useState(0);
+  const [activeFile, setActiveFile] = useState<CourseFile | null>(null);
+  const [isPreviewVisible, setIsPreviewVisible] = useState(false);
+  const t = useTranslations("courses");
+
+  useEffect(() => {
+    let cancelled = false;
+    void fetchCourseFiles(goodsId, { current: page, row: pageSize })
+      .then((result) => {
+        if (cancelled) return;
+        setFiles(result.list);
+        setTotal(result.page.total);
+      })
+      .catch(() => {
+        if (cancelled) return;
+        setFiles([]);
+        setTotal(0);
+      });
+
+    return () => {
+      cancelled = true;
+    };
+  }, [goodsId, page]);
+
+  const loading = files === null;
+  const canPrev = page > 1 && !loading;
+  const canNext = !loading && page * pageSize < total;
+  const previewDurationMs = 220;
+
+  function openPreview(file: CourseFile) {
+    setActiveFile(file);
+    requestAnimationFrame(() => setIsPreviewVisible(true));
+  }
+
+  function closePreview() {
+    setIsPreviewVisible(false);
+    window.setTimeout(() => {
+      setActiveFile(null);
+    }, previewDurationMs);
+  }
+
+  async function handleDownload(url: string, title: string) {
+    const fallbackOpen = () => {
+      window.open(url, "_blank", "noopener,noreferrer");
+    };
+    try {
+      const response = await fetch(url, { mode: "cors" });
+      if (!response.ok) {
+        fallbackOpen();
+        return;
+      }
+      const blob = await response.blob();
+      const objectUrl = URL.createObjectURL(blob);
+      const fromPath = url.split("/").pop()?.split("?")[0]?.trim() ?? "";
+      const safeTitle = title.trim() || "file";
+      const defaultName = fromPath || `${safeTitle}.pdf`;
+      const anchor = document.createElement("a");
+      anchor.href = objectUrl;
+      anchor.download = decodeURIComponent(defaultName);
+      document.body.appendChild(anchor);
+      anchor.click();
+      anchor.remove();
+      URL.revokeObjectURL(objectUrl);
+    } catch {
+      fallbackOpen();
+    }
+  }
+
+  return (
+    <div className="mt-10">
+      <div className="flex items-center justify-between gap-3">
+        <h2 className="font-serif text-2xl font-bold text-[var(--navy)]">
+          {t("fileListTitle")}
+        </h2>
+        {allHref ? (
+          <Link
+            href={allHref}
+            className="inline-flex rounded-full border-2 border-slate-200 px-4 py-2 text-sm font-semibold text-slate-800 transition hover:border-blue-200 hover:bg-slate-50"
+          >
+            {allLabel ?? t("filterAll")}
+          </Link>
+        ) : null}
+      </div>
+      {loading ? (
+        <p className="mt-4 text-sm text-slate-500">{t("fileListLoading")}</p>
+      ) : files.length > 0 ? (
+        <ul className="mt-6 grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
+          {files.map((file) => (
+            <li
+              key={`${file.id}-${file.downloadUrl}`}
+              className="shadow-card flex w-full min-h-0 flex-col overflow-hidden rounded-xl border border-slate-200/90 bg-white"
+            >
+              <div className="relative flex h-24 w-full shrink-0 items-center justify-center overflow-hidden bg-slate-100">
+                <button
+                  type="button"
+                  onClick={() => openPreview(file)}
+                  className="absolute right-2 top-2 inline-flex h-6 w-6 items-center justify-center rounded-md border border-slate-300/90 bg-white/90 text-slate-600 transition hover:bg-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
+                  aria-label={t("filePreview")}
+                  title={t("filePreview")}
+                >
+                  <svg aria-hidden="true" viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none">
+                    <path
+                      d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6z"
+                      stroke="currentColor"
+                      strokeWidth="1.8"
+                      strokeLinecap="round"
+                      strokeLinejoin="round"
+                    />
+                    <circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="1.8" />
+                  </svg>
+                </button>
+                <svg
+                  aria-hidden="true"
+                  viewBox="0 0 64 64"
+                  className="h-12 w-12 text-amber-400 drop-shadow-sm"
+                  fill="currentColor"
+                >
+                  <path d="M8 18a6 6 0 0 1 6-6h14c2.6 0 4.9 1.4 6.1 3.6L36 18h14a6 6 0 0 1 6 6v22a6 6 0 0 1-6 6H14a6 6 0 0 1-6-6V18z" />
+                  <path
+                    d="M8 26a6 6 0 0 1 6-6h36a6 6 0 0 1 6 6v4H8v-4z"
+                    className="text-amber-300"
+                  />
+                </svg>
+              </div>
+              <div className="flex min-h-0 min-w-0 flex-1 flex-col items-center border-t border-slate-100 px-3 pb-3 pt-2 text-center">
+                <h3 className="font-serif text-base font-bold leading-snug tracking-tight text-[var(--navy)] break-words">
+                  {file.title}
+                </h3>
+                <p className="mt-2 line-clamp-2 text-xs leading-relaxed text-slate-500 break-words">
+                  {file.introduction || t("fileNoIntro")}
+                </p>
+                <div className="mt-3">
+                  <button
+                    type="button"
+                    onClick={() => void handleDownload(file.downloadUrl, file.title)}
+                    className="inline-flex h-8 min-w-[5.5rem] items-center justify-center rounded-full bg-gradient-to-r from-blue-600 to-blue-700 px-3 text-xs font-semibold text-white shadow-md shadow-blue-600/20"
+                  >
+                    {t("fileDownload")}
+                  </button>
+                </div>
+              </div>
+            </li>
+          ))}
+        </ul>
+      ) : (
+        <p className="mt-4 rounded-xl border border-dashed border-slate-300 bg-slate-50 px-4 py-3 text-sm text-slate-500">
+          {t("fileListEmpty")}
+        </p>
+      )}
+      <div className="mt-4 flex items-center justify-end gap-3">
+        <button
+          type="button"
+          onClick={() => {
+            if (!canPrev) return;
+            setFiles(null);
+            setPage((p) => Math.max(1, p - 1));
+          }}
+          disabled={!canPrev}
+          className="rounded-full border border-slate-300 px-3 py-1.5 text-sm text-slate-700 disabled:cursor-not-allowed disabled:opacity-50"
+        >
+          {t("videoPrevPage")}
+        </button>
+        <span className="text-sm text-slate-500">
+          {t("videoCurrentPage", { page })}
+        </span>
+        <button
+          type="button"
+          onClick={() => {
+            if (!canNext) return;
+            setFiles(null);
+            setPage((p) => p + 1);
+          }}
+          disabled={!canNext}
+          className="rounded-full border border-slate-300 px-3 py-1.5 text-sm text-slate-700 disabled:cursor-not-allowed disabled:opacity-50"
+        >
+          {t("videoNextPage")}
+        </button>
+      </div>
+      {activeFile ? (
+        <div
+          className={`fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-[2px] transition-opacity duration-200 ${
+            isPreviewVisible ? "bg-slate-950/70 opacity-100" : "bg-slate-950/0 opacity-0"
+          }`}
+          onClick={closePreview}
+        >
+          <div
+            className={`w-full max-w-5xl overflow-hidden rounded-2xl border border-slate-200/20 bg-white shadow-2xl transition duration-200 ${
+              isPreviewVisible
+                ? "translate-y-0 scale-100 opacity-100"
+                : "translate-y-2 scale-95 opacity-0"
+            }`}
+            onClick={(e) => e.stopPropagation()}
+          >
+            <div className="flex items-center justify-between gap-3 border-b border-slate-200 bg-slate-50 px-4 py-3">
+              <p className="truncate text-sm font-semibold text-slate-800">
+                {t("filePreviewing")}: {activeFile.title}
+              </p>
+              <div className="flex items-center gap-2">
+                <a
+                  href={activeFile.downloadUrl}
+                  target="_blank"
+                  rel="noopener noreferrer"
+                  className="inline-flex rounded-full border border-slate-300 px-3 py-1 text-xs font-medium text-slate-700 hover:bg-slate-100"
+                >
+                  {t("fileOpenNewWindow")}
+                </a>
+                <button
+                  type="button"
+                  onClick={closePreview}
+                  className="rounded-full border border-slate-300 px-3 py-1 text-xs font-medium text-slate-700 hover:bg-slate-100"
+                >
+                  {t("fileClosePreview")}
+                </button>
+              </div>
+            </div>
+            <iframe
+              title={activeFile.title}
+              src={activeFile.downloadUrl}
+              className="h-[65vh] w-full bg-white"
+            />
+          </div>
+        </div>
+      ) : null}
+    </div>
+  );
+}

+ 19 - 7
src/app/[locale]/courses/[slug]/page.tsx

@@ -6,17 +6,25 @@ import {
   shouldShowBuyButton,
 } from "@/data/courses";
 import { CourseVideosListClient } from "@/app/[locale]/courses/[slug]/videos-list-client";
+import { CourseFilesListClient } from "@/app/[locale]/courses/[slug]/files-list-client";
 import { CourseBuyButton } from "@/components/course-buy-button";
 
-type Props = { params: Promise<{ locale: string; slug: string }> };
+type SearchParams = Promise<{ cat?: string }>;
 
-export default async function CourseDetailPage({ params }: Props) {
+type DetailProps = {
+  params: Promise<{ locale: string; slug: string }>;
+  searchParams: SearchParams;
+};
+
+export default async function CourseDetailPage({ params, searchParams }: DetailProps) {
   const { locale, slug } = await params;
+  const query = await searchParams;
   setRequestLocale(locale);
   const onlineCourses = await fetchCourses();
   const course =
     onlineCourses.find((c) => c.id === slug || c.slug === slug) ?? getCourseBySlug(slug);
   const goodsId = course?.id ?? slug;
+  const isStrategy = course?.category === "strategy" || query?.cat === "strategy";
 
   const t = await getTranslations("courses");
   const tn = await getTranslations("nav");
@@ -85,11 +93,15 @@ export default async function CourseDetailPage({ params }: Props) {
             </div>
           </>
         ) : null}
-        <CourseVideosListClient
-          goodsId={goodsId}
-          allHref="/courses"
-          allLabel={t("filterAll")}
-        />
+        {isStrategy ? (
+          <CourseFilesListClient goodsId={goodsId} allHref="/courses" allLabel={t("filterAll")} />
+        ) : (
+          <CourseVideosListClient
+            goodsId={goodsId}
+            allHref="/courses"
+            allLabel={t("filterAll")}
+          />
+        )}
         <div className="mt-10 flex flex-wrap gap-4">
           {course && shouldShowBuyButton(course) ? (
             <CourseBuyButton

+ 1 - 1
src/app/[locale]/courses/courses-list-client.tsx

@@ -94,7 +94,7 @@ export function CoursesListClient({ active, initialCourses }: Props) {
               </p>
               <div className="mt-auto flex flex-wrap justify-end gap-2 border-t border-slate-100 pt-4">
                 <Link
-                  href={`/courses/${c.id}`}
+                  href={`/courses/${c.id}?cat=${c.category}`}
                   className="ui-interactive-btn inline-flex h-10 min-w-[7.5rem] items-center justify-center rounded-full border-2 border-slate-200 px-4 text-sm font-semibold text-slate-700 transition hover:border-blue-200 hover:bg-blue-50"
                 >
                   {t("detail")}

+ 123 - 22
src/components/course-buy-button.tsx

@@ -1,9 +1,12 @@
 "use client";
 
+import { useEffect, useRef, useState } from "react";
 import { useTranslations } from "next-intl";
 import { useRouter } from "@/i18n/navigation";
 import { cn } from "@/lib/utils";
 import { useAuth } from "@/providers/auth-provider";
+import { generateOrderByGoodId } from "@/lib/order-api";
+import { ModalShell } from "@/components/ui/modal-shell";
 
 type Props = {
   courseSlug: string;
@@ -23,34 +26,132 @@ export function CourseBuyButton({
   const t = useTranslations("courses");
   const { user, isReady } = useAuth();
   const router = useRouter();
+  const refreshTimerRef = useRef<number | null>(null);
+  const closeDialogTimerRef = useRef<number | null>(null);
+  const [freeOrderDialog, setFreeOrderDialog] = useState<{
+    open: boolean;
+    status: "loading" | "success" | "error";
+    message: string;
+  }>({
+    open: false,
+    status: "loading",
+    message: "",
+  });
+  const [isFreeOrderSubmitting, setIsFreeOrderSubmitting] = useState(false);
   const checkoutPath =
     `/checkout?course=${encodeURIComponent(courseSlug)}` +
     `&id=${encodeURIComponent(courseId)}` +
     `&title=${encodeURIComponent(courseTitle)}` +
     `&price=${encodeURIComponent(String(coursePrice))}`;
 
+  useEffect(() => {
+    return () => {
+      if (refreshTimerRef.current !== null) {
+        window.clearTimeout(refreshTimerRef.current);
+      }
+      if (closeDialogTimerRef.current !== null) {
+        window.clearTimeout(closeDialogTimerRef.current);
+      }
+    };
+  }, []);
+
+  const handleBuy = async () => {
+    if (!isReady) return;
+    if (!user) {
+      const next = encodeURIComponent(checkoutPath);
+      router.push(`/auth/login?next=${next}`);
+      return;
+    }
+    if (coursePrice > 0) {
+      router.push(checkoutPath);
+      return;
+    }
+
+    try {
+      setIsFreeOrderSubmitting(true);
+      setFreeOrderDialog({
+        open: true,
+        status: "loading",
+        message: "正在为你创建免费订单,请稍候...",
+      });
+      await generateOrderByGoodId(courseId);
+      setFreeOrderDialog({
+        open: true,
+        status: "success",
+        message: "购买成功,正在刷新页面...",
+      });
+      closeDialogTimerRef.current = window.setTimeout(() => {
+        setFreeOrderDialog((prev) => ({ ...prev, open: false }));
+      }, 900);
+      refreshTimerRef.current = window.setTimeout(() => {
+        router.refresh();
+      }, 1200);
+    } catch (error) {
+      const message = error instanceof Error && error.message.trim() ? error.message : "购买失败,请稍后重试。";
+      setFreeOrderDialog({
+        open: true,
+        status: "error",
+        message,
+      });
+    } finally {
+      setIsFreeOrderSubmitting(false);
+    }
+  };
+
   return (
-    <button
-      type="button"
-      disabled={!isReady}
-      onClick={() => {
-        if (!isReady) return;
-        if (user) {
-          router.push(checkoutPath);
-          return;
-        }
-        const next = encodeURIComponent(checkoutPath);
-        router.push(`/auth/login?next=${next}`);
-      }}
-      className={cn(
-        "cursor-pointer select-none transition-[transform,filter] duration-150 ease-out",
-        "hover:scale-[1.02] hover:brightness-105",
-        "active:scale-[0.96] active:translate-y-px active:brightness-95",
-        "disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:scale-100 disabled:hover:brightness-100 disabled:active:scale-100 disabled:active:translate-y-0 disabled:active:brightness-100",
-        className,
-      )}
-    >
-      {t("buy")}
-    </button>
+    <>
+      <button
+        type="button"
+        disabled={!isReady || isFreeOrderSubmitting}
+        onClick={() => void handleBuy()}
+        className={cn(
+          "cursor-pointer select-none transition-[transform,filter] duration-150 ease-out",
+          "hover:scale-[1.02] hover:brightness-105",
+          "active:scale-[0.96] active:translate-y-px active:brightness-95",
+          "disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:scale-100 disabled:hover:brightness-100 disabled:active:scale-100 disabled:active:translate-y-0 disabled:active:brightness-100",
+          className,
+        )}
+      >
+        {isFreeOrderSubmitting ? "处理中..." : t("buy")}
+      </button>
+      {freeOrderDialog.open ? (
+        <ModalShell open={freeOrderDialog.open} className="max-w-sm">
+          <div className="w-full rounded-2xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-xl">
+            <div className="flex items-start gap-3">
+              {freeOrderDialog.status === "loading" ? (
+                <span className="mt-0.5 inline-block h-5 w-5 animate-spin rounded-full border-2 border-slate-300 border-t-[var(--navy)]" />
+              ) : freeOrderDialog.status === "success" ? (
+                <span className="mt-0.5 inline-flex h-5 w-5 items-center justify-center rounded-full bg-emerald-100 text-xs text-emerald-700">
+                  ✓
+                </span>
+              ) : (
+                <span className="mt-0.5 inline-flex h-5 w-5 items-center justify-center rounded-full bg-rose-100 text-xs text-rose-700">
+                  !
+                </span>
+              )}
+              <div>
+                <p className="text-sm font-semibold text-[var(--navy)]">
+                  {freeOrderDialog.status === "loading"
+                    ? "处理中"
+                    : freeOrderDialog.status === "success"
+                      ? "购买成功"
+                      : "购买失败"}
+                </p>
+                <p className="mt-1 text-sm text-[var(--muted)]">{freeOrderDialog.message}</p>
+              </div>
+            </div>
+            {freeOrderDialog.status === "error" ? (
+              <button
+                type="button"
+                onClick={() => setFreeOrderDialog((prev) => ({ ...prev, open: false }))}
+                className="mt-5 w-full rounded-lg border border-[var(--border)] px-3 py-2 text-sm hover:bg-[var(--background)]"
+              >
+                知道了
+              </button>
+            ) : null}
+          </div>
+        </ModalShell>
+      ) : null}
+    </>
   );
 }

+ 145 - 0
src/data/courses.ts

@@ -24,6 +24,7 @@ export type Course = {
   currency: "USD";
   coverGradient: string;
   coverUrl?: string;
+  downloadUrl?: string;
 };
 
 export type CourseVideo = {
@@ -45,6 +46,23 @@ export type CourseVideoPageResult = {
   };
 };
 
+export type CourseFile = {
+  id: string;
+  title: string;
+  introduction: string;
+  frontUrl?: string;
+  downloadUrl: string;
+};
+
+export type CourseFilePageResult = {
+  list: CourseFile[];
+  page: {
+    current: number;
+    row: number;
+    total: number;
+  };
+};
+
 export const COURSE_CATEGORIES: {
   id: CourseCategory;
   labelKey: string;
@@ -145,6 +163,7 @@ type GoodsSearchListItem = {
   amount?: number;
   currency?: string;
   frontUrl?: string | null;
+  download?: string | null;
   coverGradient?: string;
 };
 
@@ -158,6 +177,12 @@ type GoodsSearchListResponse = {
       };
   list?: GoodsSearchListItem[];
   records?: GoodsSearchListItem[];
+  page?: {
+    current?: number;
+    row?: number;
+    size?: number;
+    total?: number;
+  };
 };
 
 type GoodsVideoListItem = {
@@ -260,6 +285,10 @@ function normalizeCourse(item: GoodsSearchListItem): Course | null {
     goodsPriceParsed !== undefined && Number.isFinite(goodsPriceParsed)
       ? goodsPriceParsed
       : undefined;
+  const downloadUrl =
+    typeof item.download === "string" && item.download.trim()
+      ? item.download.trim()
+      : undefined;
 
   return {
     id,
@@ -274,6 +303,7 @@ function normalizeCourse(item: GoodsSearchListItem): Course | null {
     price: Number.isFinite(price) ? price : 0,
     currency,
     coverUrl: typeof item.frontUrl === "string" ? item.frontUrl : undefined,
+    downloadUrl,
     coverGradient: item.coverGradient ?? "from-slate-700 via-slate-800 to-slate-900",
   };
 }
@@ -363,6 +393,87 @@ function extractVideoPage(payload: GoodsVideoListResponse): {
   };
 }
 
+function extractSearchPage(payload: GoodsSearchListResponse): {
+  current: number;
+  row: number;
+  total: number;
+} {
+  const current = Number(payload.page?.current);
+  const row = Number(payload.page?.row ?? payload.page?.size);
+  const total = Number(payload.page?.total);
+  return {
+    current: Number.isFinite(current) && current > 0 ? current : 1,
+    row: Number.isFinite(row) && row > 0 ? row : 10,
+    total: Number.isFinite(total) && total >= 0 ? total : 0,
+  };
+}
+
+function parseDownloadUrls(rawDownload: unknown): string[] {
+  if (typeof rawDownload !== "string") return [];
+  const input = rawDownload.trim();
+  if (!input) return [];
+
+  const normalize = (value: string): string =>
+    value
+      .trim()
+      .replace(/^['"]+|['"]+$/g, "")
+      .trim();
+
+  const parseFromJson = (source: string): string[] | null => {
+    try {
+      const parsed: unknown = JSON.parse(source);
+      if (Array.isArray(parsed)) {
+        return parsed
+          .filter((entry): entry is string => typeof entry === "string")
+          .map(normalize)
+          .filter(Boolean);
+      }
+      if (typeof parsed === "string") {
+        return [normalize(parsed)].filter(Boolean);
+      }
+      return null;
+    } catch {
+      return null;
+    }
+  };
+
+  const direct = parseFromJson(input);
+  if (direct && direct.length > 0) return direct;
+
+  const unescaped = input.replace(/\\"/g, '"');
+  const secondTry = parseFromJson(unescaped);
+  if (secondTry && secondTry.length > 0) return secondTry;
+
+  const trimmedBrackets = unescaped.replace(/^\[/, "").replace(/\]$/, "");
+  return trimmedBrackets
+    .split(",")
+    .map(normalize)
+    .filter(Boolean);
+}
+
+function normalizeFile(item: GoodsSearchListItem): CourseFile[] {
+  const id = String(item.goodsId ?? item.id ?? "").trim();
+  if (!id) return [];
+  const title = String(item.title ?? item.goodsName ?? item.name ?? "").trim();
+  if (!title) return [];
+  const downloadUrls = parseDownloadUrls(item.download);
+  if (downloadUrls.length === 0) return [];
+  const introduction = String(
+    item.introduction ?? item.subtitle ?? item.subTitle ?? item.intro ?? "",
+  ).trim();
+  const frontUrl =
+    typeof item.frontUrl === "string" && item.frontUrl.trim()
+      ? item.frontUrl.trim()
+      : undefined;
+  return downloadUrls.map((downloadUrl, index) => ({
+    id: `${id}-${index + 1}`,
+    title,
+    introduction,
+    frontUrl,
+    downloadUrl,
+  }));
+}
+
 export async function fetchCourses(category?: CourseCategory): Promise<Course[]> {
   try {
     const payload = await apiPost<
@@ -424,3 +535,37 @@ export async function fetchCourseVideos(
     return { list: [], page: { current, row, total: 0 } };
   }
 }
+
+export async function fetchCourseFiles(
+  goodsId: string,
+  page: { current: number; row: number } = { current: 1, row: 10 },
+): Promise<CourseFilePageResult> {
+  const normalizedGoodsId = goodsId.trim();
+  const current = Number.isFinite(page.current) ? Math.max(1, page.current) : 1;
+  const row = Number.isFinite(page.row) ? Math.max(1, page.row) : 10;
+  try {
+    const payload = await apiPost<
+      GoodsSearchListResponse,
+      { goodsType: GoodsType; page: { current: number; row: number } }
+    >("/goods/search/list", {
+      goodsType: 5,
+      page: { current, row },
+    });
+    const list = extractList(payload).flatMap(normalizeFile);
+    const matched = normalizedGoodsId
+      ? list.filter((f) => f.id.startsWith(`${normalizedGoodsId}-`))
+      : [];
+    const finalList = matched.length > 0 ? matched : list;
+    const serverPage = extractSearchPage(payload);
+    return {
+      list: finalList,
+      page: {
+        current: serverPage.current || current,
+        row: serverPage.row || row,
+        total: matched.length > 0 ? matched.length : serverPage.total || finalList.length,
+      },
+    };
+  } catch {
+    return { list: [], page: { current, row, total: 0 } };
+  }
+}

+ 4 - 0
src/lib/auth-api.ts

@@ -52,3 +52,7 @@ export async function updateLoginPassword(input: {
     newPassword: input.newPassword,
   });
 }
+
+export async function sendForgotPasswordEmail(email: string): Promise<void> {
+  await apiPost("/update/password/send/email", { email });
+}

+ 4 - 0
src/lib/order-api.ts

@@ -154,3 +154,7 @@ export async function fetchOrderList(page: {
 export async function cancelOrder(id: string): Promise<void> {
   await apiPost("/order/cancel", { id });
 }
+
+export async function generateOrderByGoodId(goodsId: string): Promise<void> {
+  await apiPost("/order/generated", { goodsId });
+}