ALIEZ 1 kuukausi sitten
vanhempi
commit
63b91af099

+ 1 - 1
messages/zh.json

@@ -153,7 +153,7 @@
     "countryRequired": "请选择国家/地区",
     "selectCountry": "请选择",
     "loadingCountries": "加载国家列表…",
-    "registerEmailOnlyHint": "请使用邮箱完成注册,并选择所在国家/地区。",
+    "registerEmailOnlyHint": "请使用邮箱完成注册。",
     "errorApi": "请求失败,请稍后重试",
     "oauth": "第三方登录(演示)",
     "apple": "Apple",

+ 0 - 6
src/app/[locale]/account/change-password/page.tsx

@@ -85,12 +85,6 @@ export default function ChangePasswordPage() {
         >
           {submitting ? "提交中..." : t("changeBtn")}
         </button>
-        <Link
-          href="/account/withdraw-apply"
-          className="block w-full rounded-full border border-[var(--border)] py-3 text-center text-sm font-semibold text-[var(--navy)] transition hover:bg-slate-50"
-        >
-          提款申请
-        </Link>
       </form>
       {error ? <p className="mt-4 text-sm text-rose-700">{error}</p> : null}
       {ok ? <p className="mt-4 text-sm text-emerald-700">密码修改成功。</p> : null}

+ 6 - 1
src/app/[locale]/account/page.tsx

@@ -159,6 +159,11 @@ export default function AccountPage() {
     }
   }
 
+  function getPurchasedCourseHref(course: PurchasedCourse): string {
+    const baseHref = `/courses/${course.goodsId}`;
+    return course.goodsType === 5 ? `${baseHref}?cat=strategy` : baseHref;
+  }
+
   if (!isReady) {
     return (
       <div className="page-shell py-16 text-center text-[var(--muted)]">
@@ -247,7 +252,7 @@ export default function AccountPage() {
                       {c.introduction || "-"}
                     </p>
                     <Link
-                      href={`/courses/${c.goodsId}`}
+                      href={getPurchasedCourseHref(c)}
                       className="mt-3 inline-flex rounded-full border border-[var(--border)] px-3 py-1.5 text-xs font-medium text-[var(--navy)] hover:bg-slate-50"
                     >
                       查看课程

+ 6 - 1
src/app/[locale]/account/purchased-courses/page.tsx

@@ -101,6 +101,11 @@ export default function PurchasedCoursesPage() {
     }
   }
 
+  function getPurchasedCourseHref(course: PurchasedCourse): string {
+    const baseHref = `/courses/${course.goodsId}`;
+    return course.goodsType === 5 ? `${baseHref}?cat=strategy` : baseHref;
+  }
+
   if (!isReady) {
     return <div className="page-shell py-16 text-center text-[var(--muted)]">…</div>;
   }
@@ -168,7 +173,7 @@ export default function PurchasedCoursesPage() {
                   {c.introduction || "-"}
                 </p>
                 <Link
-                  href={`/courses/${c.goodsId}`}
+                  href={getPurchasedCourseHref(c)}
                   className="mt-3 inline-flex rounded-full border border-[var(--border)] px-3 py-1.5 text-xs font-medium text-[var(--navy)] hover:bg-slate-50"
                 >
                   查看课程

+ 17 - 1
src/app/[locale]/account/withdraw-apply/page.tsx

@@ -2,6 +2,7 @@
 
 import { Link } from "@/i18n/navigation";
 import { useEffect, useMemo, useState } from "react";
+import { fetchWalletBalance } from "@/lib/account-api";
 import {
   fetchSavedWithdrawAccounts,
   fetchWithdrawBankOptions,
@@ -95,6 +96,8 @@ export default function WithdrawApplyPage() {
   const [channelsLoading, setChannelsLoading] = useState(false);
   const [channelsError, setChannelsError] = useState<string | null>(null);
   const [savedAccountsError, setSavedAccountsError] = useState<string | null>(null);
+  const [walletBalance, setWalletBalance] = useState<number | null>(null);
+  const [walletBalanceLoading, setWalletBalanceLoading] = useState(false);
 
   const [savedAccounts, setSavedAccounts] = useState<SavedWithdrawAccount[]>([]);
   const [bankOptions, setBankOptions] = useState<WithdrawBankOption[]>([]);
@@ -185,19 +188,26 @@ export default function WithdrawApplyPage() {
   useEffect(() => {
     let cancelled = false;
     async function loadBase() {
+      setWalletBalanceLoading(true);
       setChannelsLoading(true);
       setChannelsError(null);
       try {
-        const channelsResult = await fetchWithdrawChannels();
+        const [balanceResult, channelsResult] = await Promise.all([
+          fetchWalletBalance(),
+          fetchWithdrawChannels(),
+        ]);
         if (cancelled) return;
+        setWalletBalance(balanceResult);
         setChannels(channelsResult);
       } catch (e) {
         if (cancelled) return;
         const err = e as Error;
         setChannelsError(err?.message || "提款通道加载失败");
         setChannels([]);
+        setWalletBalance(null);
       } finally {
         if (!cancelled) {
+          setWalletBalanceLoading(false);
           setChannelsLoading(false);
         }
       }
@@ -576,6 +586,12 @@ export default function WithdrawApplyPage() {
                 关闭
               </button>
             </div>
+            <div className="mt-3 rounded-lg border border-[var(--border)] bg-slate-50 px-3 py-2 text-sm text-[var(--navy)]">
+              钱包余额:
+              <span className="ml-1 font-semibold tabular-nums">
+                {walletBalanceLoading ? "加载中..." : `$${(walletBalance ?? 0).toFixed(2)}`}
+              </span>
+            </div>
 
             {selectedChannel.introduce || selectedChannel.enIntroduce ? (
               <div

+ 3 - 61
src/app/[locale]/auth/register/page.tsx

@@ -6,13 +6,8 @@ import { Link } from "@/i18n/navigation";
 import { useEffect, useState } from "react";
 import { useAuth } from "@/providers/auth-provider";
 import { ApiError } from "@/lib/api";
-import { CountryCombobox } from "@/components/country-combobox";
 import { isRegisterPasswordValid } from "@/lib/password-rules";
-import {
-  fetchRegisterCountries,
-  sendRegisterVerificationCode,
-  type CountryOption,
-} from "@/lib/register-api";
+import { sendRegisterVerificationCode } from "@/lib/register-api";
 
 const SEND_CODE_COOLDOWN_SEC = 60;
 
@@ -20,12 +15,8 @@ export default function RegisterPage() {
   const t = useTranslations("auth");
   const router = useRouter();
   const { register, logout } = useAuth();
-  const [countries, setCountries] = useState<CountryOption[]>([]);
-  const [countriesLoading, setCountriesLoading] = useState(true);
-  const [country, setCountry] = useState("");
   const [email, setEmail] = useState("");
   const [password, setPassword] = useState("");
-  const [name, setName] = useState("");
   const [code, setCode] = useState("");
   const [err, setErr] = useState<string | null>(null);
   const [sendCooldown, setSendCooldown] = useState(0);
@@ -35,30 +26,11 @@ export default function RegisterPage() {
   const emailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim());
   const canSendCode = emailValid && !sendingCode && sendCooldown <= 0;
   const canSubmit =
-    country.length > 0 &&
     email.trim().length > 0 &&
     code.trim().length > 0 &&
-    name.trim().length > 0 &&
     password.length > 0 &&
     !submitting;
 
-  useEffect(() => {
-    let cancelled = false;
-    (async () => {
-      try {
-        const list = await fetchRegisterCountries();
-        if (!cancelled) setCountries(list);
-      } catch {
-        if (!cancelled) setCountries([]);
-      } finally {
-        if (!cancelled) setCountriesLoading(false);
-      }
-    })();
-    return () => {
-      cancelled = true;
-    };
-  }, []);
-
   useEffect(() => {
     if (sendCooldown <= 0) return;
     const id = window.setInterval(() => {
@@ -104,10 +76,10 @@ export default function RegisterPage() {
     }
     setSubmitting(true);
     const r = await register({
-      country,
+      country: "CN",
       email: email.trim(),
       password,
-      name: name.trim(),
+      name: "学员",
       code: code.trim(),
     });
     if (!r.ok) {
@@ -147,22 +119,6 @@ export default function RegisterPage() {
         <h1 className="font-serif text-2xl font-semibold text-[var(--navy)]">{t("registerTitle")}</h1>
         <p className="mt-2 text-sm text-[var(--muted)]">{t("registerEmailOnlyHint")}</p>
         <form onSubmit={onSubmit} className="mt-8 space-y-4">
-          <div>
-            <label className="text-sm font-medium">{t("country")}</label>
-            <CountryCombobox
-              countries={countries}
-              value={country}
-              onValueChange={setCountry}
-              loading={countriesLoading}
-              disabled={!countriesLoading && countries.length === 0}
-              labels={{
-                placeholder: t("selectCountry"),
-                loading: t("loadingCountries"),
-                searchPlaceholder: t("countrySearchPlaceholder"),
-                noResults: t("countryNoResults"),
-              }}
-            />
-          </div>
           <div>
             <label className="text-sm font-medium">{t("email")}</label>
             <input
@@ -208,20 +164,6 @@ export default function RegisterPage() {
               </button>
             </div>
           </div>
-          <div>
-            <label className="text-sm font-medium">{t("name")}</label>
-            <input
-              required
-              value={name}
-              onChange={(e) => {
-                setName(e.target.value);
-                if (err) setErr(null);
-              }}
-              onBlur={() => setName((v) => v.trim())}
-              autoComplete="name"
-              className="mt-1 w-full rounded-lg border border-[var(--border)] px-3 py-2 text-sm"
-            />
-          </div>
           <div>
             <label className="text-sm font-medium">{t("password")}</label>
             <input

+ 26 - 5
src/app/[locale]/courses/[slug]/files-list-client.tsx

@@ -114,6 +114,9 @@ export function CourseFilesListClient({ goodsId, allHref, allLabel }: Props) {
       ) : 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) => (
+            (() => {
+              const canAccess = file.payType === 0 && Boolean(file.downloadUrl);
+              return (
             <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"
@@ -121,10 +124,18 @@ export function CourseFilesListClient({ goodsId, allHref, allLabel }: Props) {
               <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"
+                  onClick={() => {
+                    if (!canAccess) return;
+                    openPreview(file);
+                  }}
+                  disabled={!canAccess}
+                  className={`absolute right-2 top-2 inline-flex h-6 w-6 items-center justify-center rounded-md border transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 ${
+                    canAccess
+                      ? "border-slate-300/90 bg-white/90 text-slate-600 hover:bg-white focus-visible:outline-blue-600"
+                      : "cursor-not-allowed border-slate-200 bg-slate-100 text-slate-300"
+                  }`}
                   aria-label={t("filePreview")}
-                  title={t("filePreview")}
+                  title={canAccess ? t("filePreview") : "请先购买后查看"}
                 >
                   <svg aria-hidden="true" viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none">
                     <path
@@ -160,14 +171,24 @@ export function CourseFilesListClient({ goodsId, allHref, allLabel }: Props) {
                 <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"
+                    onClick={() => {
+                      if (!canAccess) return;
+                      void handleDownload(file.downloadUrl, file.title);
+                    }}
+                    disabled={!canAccess}
+                    className={`inline-flex h-8 min-w-[5.5rem] items-center justify-center rounded-full px-3 text-xs font-semibold ${
+                      canAccess
+                        ? "bg-gradient-to-r from-blue-600 to-blue-700 text-white shadow-md shadow-blue-600/20"
+                        : "cursor-not-allowed bg-slate-200 text-slate-400"
+                    }`}
                   >
                     {t("fileDownload")}
                   </button>
                 </div>
               </div>
             </li>
+              );
+            })()
           ))}
         </ul>
       ) : (

+ 11 - 8
src/data/courses.ts

@@ -53,6 +53,7 @@ export type CourseFile = {
   introduction: string;
   frontUrl?: string;
   downloadUrl: string;
+  payType: 0 | 1;
 };
 
 export type CourseFilePageResult = {
@@ -499,12 +500,11 @@ function toAbsoluteFileUrl(input: string): string {
 }
 
 function normalizeFileFromVideo(item: GoodsVideoListItem): CourseFile[] {
-  const id = String(item.videoId ?? item.id ?? "").trim();
-  if (!id) return [];
-  const title = String(item.title ?? item.videoName ?? item.videoTitle ?? item.name ?? "").trim();
-  if (!title) return [];
   const downloadUrls = parseDownloadUrls(item.fileUrl);
-  if (downloadUrls.length === 0) return [];
+  const normalizedUrls = downloadUrls.map(toAbsoluteFileUrl).filter(Boolean);
+  const title = String(item.title ?? item.videoName ?? item.videoTitle ?? item.name ?? "").trim() || "未命名文件";
+  const rawId = String(item.videoId ?? item.id ?? "").trim();
+  const idBase = rawId || normalizedUrls[0] || title;
   const introduction = String(
     item.introduction ?? item.description ?? item.intro ?? "",
   ).trim();
@@ -512,12 +512,15 @@ function normalizeFileFromVideo(item: GoodsVideoListItem): CourseFile[] {
     typeof item.frontUrl === "string" && item.frontUrl.trim()
       ? item.frontUrl.trim()
       : undefined;
-  return downloadUrls.map((downloadUrl, index) => ({
-    id: `${id}-${index + 1}`,
+  const payType: 0 | 1 = item.payType === 1 ? 1 : 0;
+  const finalUrls = normalizedUrls.length > 0 ? normalizedUrls : [""];
+  return finalUrls.map((downloadUrl, index) => ({
+    id: `${idBase}-${index + 1}`,
     title,
     introduction,
     frontUrl,
-    downloadUrl: toAbsoluteFileUrl(downloadUrl),
+    downloadUrl,
+    payType,
   }));
 }
 

+ 2 - 2
src/lib/auth-api.ts

@@ -47,12 +47,12 @@ export async function updateLoginPassword(input: {
   oldPassword: string;
   newPassword: string;
 }): Promise<void> {
-  await apiPost("/update/login/password", {
+  await apiPost("/custom/update/login/password", {
     oldPassword: input.oldPassword,
     newPassword: input.newPassword,
   });
 }
 
 export async function sendForgotPasswordEmail(email: string): Promise<void> {
-  await apiPost("/update/password/send/email", { email });
+  await apiPost("/custom/update/password/send/email", { email });
 }

+ 4 - 1
src/lib/goods-order-api.ts

@@ -3,6 +3,7 @@ import { apiPost } from "@/lib/api";
 export type PurchasedCourse = {
   id: string;
   goodsId: string;
+  goodsType: number | null;
   title: string;
   introduction: string;
   coverUrl: string;
@@ -46,11 +47,13 @@ export async function fetchPurchasedCourses(page: {
     const id = String(o.id ?? o.orderId ?? o.serial ?? "").trim();
     const goodsId = String(o.goodsId ?? o.id ?? "").trim();
     if (!id || !goodsId) continue;
+    const parsedGoodsType = Number(o.goodsType ?? o.goods_type ?? NaN);
+    const goodsType = Number.isFinite(parsedGoodsType) ? parsedGoodsType : null;
     const title = String(o.title ?? o.goodsName ?? o.courseName ?? o.details ?? "-").trim() || "-";
     const introduction =
       String(o.introduction ?? o.intro ?? o.subtitle ?? o.desc ?? "-").trim() || "-";
     const coverUrl = String(o.frontUrl ?? o.coverUrl ?? o.download ?? "").trim();
-    out.push({ id, goodsId, title, introduction, coverUrl });
+    out.push({ id, goodsId, goodsType, title, introduction, coverUrl });
   }
   return out;
 }