ALIEZ 1 месяц назад
Родитель
Сommit
49a578aff6

+ 5 - 3
.env.development

@@ -1,12 +1,14 @@
 
 NEXT_PUBLIC_APP_ENV=local
 
-
+# 主业务 :8005 | 汇款/出款/存款支付 :8504(与 src/lib/remittance-client、withdrawal-api、checkout-api 一致)
 NEXT_PUBLIC_API_BASE_URL_LOCAL=/api-backend
 API_PROXY_TARGET=http://192.168.0.33:8005
+# API_PROXY_TARGET=http://103.158.191.66:4000
 NEXT_PUBLIC_REMITTANCE_API_BASE_URL=/api-backend-remittance
 API_PROXY_TARGET_REMITTANCE=http://192.168.0.33:8504
 
-NEXT_PUBLIC_API_BASE_URL_TEST=https://api-test.example.com
-NEXT_PUBLIC_API_BASE_URL_PRODUCTION=https://api.example.com
+# 与当前访问站点同源(浏览器用页面域名;服务端需配 NEXT_PUBLIC_SITE_URL)
+NEXT_PUBLIC_API_BASE_URL_TEST=__ORIGIN__
+NEXT_PUBLIC_API_BASE_URL_PRODUCTION=__ORIGIN__
 

+ 3 - 2
.env.production

@@ -1,7 +1,8 @@
 NEXT_PUBLIC_APP_ENV=production
 
 NEXT_PUBLIC_API_BASE_URL_LOCAL=http://127.0.0.1:8000
-NEXT_PUBLIC_API_BASE_URL_TEST=https://api-test.example.com
-NEXT_PUBLIC_API_BASE_URL_PRODUCTION=https://api.example.com
+# 与当前访问站点同源(浏览器用页面域名;SSR/Node 请求需配 NEXT_PUBLIC_SITE_URL)
+NEXT_PUBLIC_API_BASE_URL_TEST=__ORIGIN__
+NEXT_PUBLIC_API_BASE_URL_PRODUCTION=__ORIGIN__
 
 

+ 3 - 0
ecosystem.config.cjs

@@ -10,6 +10,9 @@ const path = require("path");
  *
  * NEXT_PUBLIC_*(如 API 地址)在 next build 时已打入前端包;PM2 里再改不会更新浏览器里的接口域名。
  * 测试 / 生产请分别用 npm run build:test 与 npm run build,或在构建前注入相同的环境变量。
+ *
+ * 若 API 使用 __ORIGIN__(与站点同源),服务端渲染发请求时还需在构建或运行环境提供
+ * NEXT_PUBLIC_SITE_URL(例如 https://你的前端域名),否则服务端拿不到页面域名。
  */
 
 module.exports = {

+ 5 - 0
messages/en.json

@@ -10,6 +10,7 @@
     "about": "About",
     "account": "Account",
     "purchase": "Purchase",
+    "faq": "FAQ",
     "login": "Log in",
     "register": "Sign up",
     "accountSettings": "Account settings",
@@ -233,6 +234,10 @@
   },
   "faq": {
     "title": "FAQ",
+    "subtitle": "Tap a question to reveal the answer",
+    "loading": "Loading…",
+    "loadError": "Could not load content. Please try again later.",
+    "empty": "No questions yet.",
     "q1": "How do I start?",
     "a1": "Begin with the free trial, then choose practical or topic courses.",
     "q2": "Purchase protection?",

+ 6 - 1
messages/zh.json

@@ -10,6 +10,7 @@
     "about": "关于我们",
     "account": "用户中心",
     "purchase": "购买课程",
+    "faq": "常见问题",
     "login": "登录",
     "register": "注册",
     "accountSettings": "修改密码",
@@ -153,7 +154,7 @@
     "registerSuccessHint": "即将跳转到登录页,请使用邮箱与密码登录。",
     "loginAfterRegister": "注册成功,请使用邮箱与密码登录。",
     "registerBtn": "注册",
-    "resetBtn": "发送重置邮件(演示)",
+    "resetBtn": "发送重置邮件",
     "changeBtn": "保存新密码",
     "toRegister": "没有账号?去注册",
     "toLogin": "已有账号?去登录",
@@ -236,6 +237,10 @@
   },
   "faq": {
     "title": "常见问题",
+    "subtitle": "点击问题查看解答",
+    "loading": "加载中…",
+    "loadError": "内容加载失败,请稍后重试。",
+    "empty": "暂无常见问题。",
     "q1": "如何开始学习?",
     "a1": "可从免费试听入手,再按需求选择实盘体系课或专题内容。",
     "q2": "购课是否有保障?",

+ 12 - 24
src/app/[locale]/account/withdraw-apply/page.tsx

@@ -11,6 +11,8 @@ import {
   type WithdrawBankOption,
   type WithdrawChannel,
 } from "@/lib/withdrawal-api";
+import { InlineLoading } from "@/components/ui/loading-state";
+import { ModalShell } from "@/components/ui/modal-shell";
 
 function channelGroupLabel(channel: WithdrawChannel): string {
   const type = channel.type;
@@ -391,7 +393,7 @@ export default function WithdrawApplyPage() {
 
       <section className="mt-6 rounded-2xl border border-[var(--border)] bg-[var(--card)] p-4">
         <h2 className="text-sm font-semibold text-[var(--navy)]">提款通道</h2>
-        {channelsLoading ? <p className="mt-2 text-sm text-[var(--muted)]">通道加载中...</p> : null}
+        {channelsLoading ? <InlineLoading text="通道加载中..." className="mt-2" /> : null}
         {channelsError ? <p className="mt-2 text-sm text-rose-700">{channelsError}</p> : null}
         {!channelsLoading && !channelsError && channelGroups.length === 0 ? (
           <p className="mt-2 text-sm text-[var(--muted)]">暂无可用通道</p>
@@ -462,7 +464,7 @@ export default function WithdrawApplyPage() {
                                 setSelectedChannelId(item.id);
                                 setApplyDialogOpen(true);
                               }}
-                              className={`rounded border px-4 py-1 text-xs font-semibold ${
+                              className={`ui-interactive-btn rounded border px-4 py-1 text-xs font-semibold ${
                                 selectedChannelId === item.id
                                   ? "border-[var(--navy)] bg-[var(--navy)] text-white"
                                   : "border-[var(--border)] bg-white text-[var(--navy)] hover:bg-slate-50"
@@ -496,16 +498,8 @@ export default function WithdrawApplyPage() {
       </p>
 
       {selectedChannel ? (
-        <div
-          className={`fixed inset-0 z-[70] flex items-center justify-center px-4 transition-all duration-250 ${
-            confirmOpen ? "pointer-events-auto bg-black/40 opacity-100" : "pointer-events-none bg-black/0 opacity-0"
-          }`}
-        >
-          <div
-            className={`w-full max-w-md rounded-2xl border border-[var(--border)] bg-[var(--card)] p-5 shadow-xl transition-all duration-250 ${
-              confirmOpen ? "translate-y-0 scale-100 opacity-100" : "translate-y-2 scale-[0.98] opacity-0"
-            }`}
-          >
+        <ModalShell open={confirmOpen} className="max-w-md" zIndexClassName="z-[70]">
+          <div className="w-full rounded-2xl border border-[var(--border)] bg-[var(--card)] p-5 shadow-xl">
             <p className="text-base font-semibold text-[var(--navy)]">确认提交提款申请?</p>
             <div className="mt-3 space-y-1 text-sm text-[var(--muted)]">
               <p>提款通道:{selectedChannel.name || selectedChannel.enName || selectedChannel.code}</p>
@@ -530,26 +524,20 @@ export default function WithdrawApplyPage() {
               </button>
             </div>
           </div>
-        </div>
+        </ModalShell>
       ) : null}
 
       {selectedChannel ? (
-        <div
-          className={`fixed inset-0 z-[60] flex items-center justify-center px-4 transition-all duration-300 ${
-            applyDialogOpen ? "pointer-events-auto bg-black/40 opacity-100" : "pointer-events-none bg-black/0 opacity-0"
-          }`}
-        >
+        <ModalShell open={applyDialogOpen} className="max-w-2xl" zIndexClassName="z-[60]">
           <section
-            className={`w-full max-w-2xl rounded-2xl border border-[var(--border)] bg-[var(--card)] p-4 shadow-xl transition-all duration-300 ${
-              applyDialogOpen ? "translate-y-0 scale-100 opacity-100" : "translate-y-3 scale-[0.98] opacity-0"
-            }`}
+            className="w-full rounded-2xl border border-[var(--border)] bg-[var(--card)] p-4 shadow-xl"
           >
             <div className="flex items-center justify-between">
               <h2 className="text-sm font-semibold text-[var(--navy)]">填写提款信息</h2>
               <button
                 type="button"
                 onClick={() => setApplyDialogOpen(false)}
-                className="rounded border border-[var(--border)] px-2 py-1 text-xs text-[var(--muted)]"
+                className="ui-interactive-btn rounded border border-[var(--border)] px-2 py-1 text-xs text-[var(--muted)]"
               >
                 关闭
               </button>
@@ -857,13 +845,13 @@ export default function WithdrawApplyPage() {
                   }
                   setConfirmOpen(true);
                 }}
-                className="mt-4 w-full rounded-full bg-[var(--navy)] py-3 text-sm font-semibold text-white disabled:opacity-60"
+                className="ui-interactive-btn mt-4 w-full rounded-full bg-[var(--navy)] py-3 text-sm font-semibold text-white disabled:opacity-60"
               >
                 {submitting ? "提交中..." : "提交提款申请"}
               </button>
             }
           </section>
-        </div>
+        </ModalShell>
       ) : null}
     </div>
   );

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

@@ -39,7 +39,7 @@ export default function ForgotPasswordPage() {
           </button>
         </form>
       ) : (
-        <p className="mt-8 text-sm text-emerald-700">(演示)已模拟发送重置邮件。</p>
+        <p className="mt-8 text-sm text-emerald-700">发送重置邮件。</p>
       )}
       <p className="mt-8 text-center text-sm">
         <Link href="/auth/login" className="text-[var(--accent)] hover:underline">

+ 12 - 12
src/app/[locale]/checkout/checkout-form.tsx

@@ -13,6 +13,8 @@ import {
   type BankChannelOption,
   type RemittanceChannel,
 } from "@/lib/checkout-api";
+import { InlineLoading } from "@/components/ui/loading-state";
+import { ModalShell } from "@/components/ui/modal-shell";
 
 export function CheckoutForm() {
   const t = useTranslations("checkout");
@@ -215,9 +217,7 @@ export function CheckoutForm() {
   return (
     <form onSubmit={handleSubmit} className="space-y-8">
       <div className="rounded-2xl border border-[var(--border)] bg-[var(--card)] p-6">
-        {channelsLoading ? (
-          <p className="mt-4 text-sm text-[var(--muted)]">通道加载中…</p>
-        ) : null}
+        {channelsLoading ? <InlineLoading text="通道加载中..." className="mt-4" /> : null}
         {channelsError ? (
           <p className="mt-4 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
             {channelsError}
@@ -285,7 +285,7 @@ export function CheckoutForm() {
                                     setSelectedChannelId(channel.id);
                                     setIsPaymentModalOpen(true);
                                   }}
-                                  className={`rounded px-4 py-1.5 text-xs font-semibold ${
+                                className={`ui-interactive-btn rounded px-4 py-1.5 text-xs font-semibold ${
                                     selected
                                       ? "bg-[var(--navy)] text-white"
                                       : "border border-[var(--border)] text-[var(--navy)] hover:bg-[var(--navy)]/5"
@@ -332,8 +332,8 @@ export function CheckoutForm() {
       {msg && <p className="text-sm text-emerald-700">{msg}</p>}
 
       {submitDialog.open ? (
-        <div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/45 px-4">
-          <div className="w-full max-w-sm rounded-2xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-xl">
+        <ModalShell open={submitDialog.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">
               {submitDialog.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)]" />
@@ -367,12 +367,12 @@ export function CheckoutForm() {
               </button>
             ) : null}
           </div>
-        </div>
+        </ModalShell>
       ) : null}
 
       {isPaymentModalOpen && selectedChannelId ? (
-        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/45 px-4">
-          <div className="w-full max-w-2xl rounded-2xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-xl">
+        <ModalShell open={isPaymentModalOpen} className="max-w-2xl" zIndexClassName="z-50">
+          <div className="w-full rounded-2xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-xl">
             <div className="flex items-start justify-between gap-4">
               <div>
                 <p className="text-base font-semibold text-[var(--navy)]">{t("payMethod")}</p>
@@ -383,7 +383,7 @@ export function CheckoutForm() {
               <button
                 type="button"
                 onClick={handleClosePaymentModal}
-                className="rounded border border-[var(--border)] px-3 py-1 text-sm text-[var(--muted)] hover:bg-[var(--background)]"
+                className="ui-interactive-btn rounded border border-[var(--border)] px-3 py-1 text-sm text-[var(--muted)] hover:bg-[var(--background)]"
               >
                 关闭
               </button>
@@ -484,13 +484,13 @@ export function CheckoutForm() {
                   !phone.trim() ||
                   !(Number(depositAmount) > 0)
                 }
-                className="w-full rounded-full bg-[var(--navy)] py-3 text-sm font-semibold text-white hover:bg-[var(--navy-soft)] disabled:cursor-not-allowed disabled:opacity-50"
+                className="ui-interactive-btn w-full rounded-full bg-[var(--navy)] py-3 text-sm font-semibold text-white hover:bg-[var(--navy-soft)] disabled:cursor-not-allowed disabled:opacity-50"
               >
                 {submitting ? "提交中..." : t("submit")}
               </button>
             </div>
           </div>
-        </div>
+        </ModalShell>
       ) : null}
     </form>
   );

+ 10 - 19
src/app/[locale]/courses/[slug]/videos-list-client.tsx

@@ -42,8 +42,7 @@ export function CourseVideosListClient({ goodsId, allHref, allLabel }: Props) {
   const loading = videos === null;
   const canPrev = page > 1 && !loading;
   const canNext = !loading && page * pageSize < total;
-  const playUrl = activeVideo?.playUrl ?? "";
-  const canUseNativeVideo = /\.(mp4|webm|ogg|m3u8)(\?|#|$)/i.test(playUrl);
+  const playUrl = activeVideo?.playUrl?.trim() ?? "";
   const modalDurationMs = 220;
 
   function openPlayer(video: CourseVideo) {
@@ -215,23 +214,15 @@ export function CourseVideosListClient({ goodsId, allHref, allLabel }: Props) {
                 {t("videoClosePlayer")}
               </button>
             </div>
-            {canUseNativeVideo ? (
-              <video
-                key={playUrl}
-                src={playUrl}
-                controls
-                autoPlay
-                className="h-[230px] w-full bg-black md:h-[520px]"
-              />
-            ) : (
-              <iframe
-                key={playUrl}
-                src={playUrl}
-                allow="autoplay; fullscreen; picture-in-picture"
-                allowFullScreen
-                className="h-[230px] w-full bg-black md:h-[520px]"
-              />
-            )}
+            <video
+              key={playUrl}
+              src={playUrl || undefined}
+              controls
+              playsInline
+              preload="metadata"
+              autoPlay
+              className="h-[230px] w-full bg-black md:h-[520px]"
+            />
           </div>
         </div>
       ) : null}

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

@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
 import { useTranslations } from "next-intl";
 import { Link } from "@/i18n/navigation";
 import { CourseBuyButton } from "@/components/course-buy-button";
+import { InlineLoading, SkeletonBlock } from "@/components/ui/loading-state";
 import { fetchCourses, type Course, type CourseCategory } from "@/data/courses";
 
 type Props = {
@@ -17,6 +18,7 @@ function shouldShowBuyButton(course: Course): boolean {
 
 export function CoursesListClient({ active, initialCourses }: Props) {
   const [courses, setCourses] = useState(initialCourses);
+  const [loading, setLoading] = useState(true);
   const t = useTranslations("courses");
   const tn = useTranslations("nav");
 
@@ -29,6 +31,9 @@ export function CoursesListClient({ active, initialCourses }: Props) {
       })
       .catch(() => {
         // Keep server-rendered fallback data.
+      })
+      .finally(() => {
+        if (!cancelled) setLoading(false);
       });
     return () => {
       cancelled = true;
@@ -36,10 +41,22 @@ export function CoursesListClient({ active, initialCourses }: Props) {
   }, [active]);
 
   return (
-    <ul className="mt-6 grid auto-rows-fr gap-6 md:grid-cols-2 md:gap-8">
+    <div>
+      {loading ? <InlineLoading text="课程列表加载中..." className="mt-2" /> : null}
+      <ul className="mt-6 grid auto-rows-fr gap-6 md:grid-cols-2 md:gap-8">
+      {loading && courses.length === 0
+        ? Array.from({ length: 4 }).map((_, i) => (
+            <li key={`sk-${i}`} className="rounded-3xl border border-slate-200/90 bg-white p-5 shadow-card">
+              <SkeletonBlock className="h-28 w-full" />
+              <SkeletonBlock className="mt-4 h-5 w-3/4" />
+              <SkeletonBlock className="mt-2 h-4 w-full" />
+              <SkeletonBlock className="mt-2 h-4 w-2/3" />
+            </li>
+          ))
+        : null}
       {courses.map((c) => (
         <li key={c.slug} className="flex min-h-0">
-          <article className="shadow-card hover:shadow-card-hover group flex h-full min-h-[240px] w-full min-w-0 flex-col overflow-hidden rounded-3xl border border-slate-200/90 bg-white transition duration-300 hover:-translate-y-0.5 sm:flex-row">
+          <article className="ui-interactive-card shadow-card group flex h-full min-h-[240px] w-full min-w-0 flex-col overflow-hidden rounded-3xl border border-slate-200/90 bg-white sm:flex-row">
             <div
               className={`relative h-36 w-full shrink-0 bg-gradient-to-br sm:h-full sm:min-h-36 sm:w-36 sm:max-w-[40%] md:w-44 md:max-w-none ${
                 c.coverUrl ? "" : c.coverGradient
@@ -78,7 +95,7 @@ export function CoursesListClient({ active, initialCourses }: Props) {
               <div className="mt-auto flex flex-wrap justify-end gap-2 border-t border-slate-100 pt-4">
                 <Link
                   href={`/courses/${c.id}`}
-                  className="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"
+                  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")}
                 </Link>
@@ -96,6 +113,7 @@ export function CoursesListClient({ active, initialCourses }: Props) {
           </article>
         </li>
       ))}
-    </ul>
+      </ul>
+    </div>
   );
 }

+ 125 - 0
src/app/[locale]/faq/faq-client.tsx

@@ -0,0 +1,125 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useTranslations } from "next-intl";
+import { fetchCommonQuestions, type FaqItem } from "@/lib/faq-api";
+import { InlineLoading } from "@/components/ui/loading-state";
+import { cn } from "@/lib/utils";
+
+function Chevron({ className }: { className?: string }) {
+  return (
+    <svg
+      aria-hidden
+      viewBox="0 0 24 24"
+      className={cn("h-5 w-5 shrink-0 text-slate-400", className)}
+      fill="none"
+      stroke="currentColor"
+      strokeWidth="2"
+      strokeLinecap="round"
+      strokeLinejoin="round"
+    >
+      <path d="m6 9 6 6 6-6" />
+    </svg>
+  );
+}
+
+export function FaqClient() {
+  const t = useTranslations("faq");
+  const [items, setItems] = useState<FaqItem[] | null>(null);
+  const [failed, setFailed] = useState(false);
+  const [openIndex, setOpenIndex] = useState<number | null>(null);
+
+  useEffect(() => {
+    let cancelled = false;
+    void fetchCommonQuestions().then(({ items: next, failed: err }) => {
+      if (cancelled) return;
+      setItems(next);
+      setFailed(err);
+    });
+    return () => {
+      cancelled = true;
+    };
+  }, []);
+
+  return (
+    <div className="mx-auto max-w-3xl">
+      <div className="text-center sm:text-left">
+        <h1 className="font-serif text-3xl font-semibold tracking-tight text-[var(--navy)] md:text-4xl">
+          {t("title")}
+        </h1>
+        <p className="mt-2 text-sm text-[var(--muted)]">{t("subtitle")}</p>
+      </div>
+
+      {items === null ? (
+        <div className="mt-10 flex justify-center sm:justify-start">
+          <InlineLoading text={t("loading")} />
+        </div>
+      ) : failed ? (
+        <p className="mt-10 rounded-xl border border-rose-200/80 bg-rose-50/80 px-4 py-3 text-sm text-rose-800">
+          {t("loadError")}
+        </p>
+      ) : items.length === 0 ? (
+        <p className="mt-10 rounded-xl border border-dashed border-[var(--border)] bg-white/60 px-4 py-8 text-center text-sm text-[var(--muted)]">
+          {t("empty")}
+        </p>
+      ) : (
+        <div className="mt-10 overflow-hidden rounded-2xl border border-[var(--border)] bg-[var(--card)] shadow-card">
+          <ul className="divide-y divide-[var(--border)]" role="list">
+            {items.map((item, index) => {
+              const open = openIndex === index;
+              return (
+                <li key={`${item.id}-${index}`}>
+                  <button
+                    type="button"
+                    id={`faq-trigger-${index}`}
+                    aria-expanded={open}
+                    aria-controls={`faq-panel-${index}`}
+                    className={cn(
+                      "flex w-full items-start justify-between gap-4 px-5 py-4 text-left transition-colors sm:px-6 sm:py-5",
+                      open
+                        ? "bg-gradient-to-r from-blue-50/90 to-slate-50/40"
+                        : "hover:bg-slate-50/80",
+                    )}
+                    onClick={() => setOpenIndex(open ? null : index)}
+                  >
+                    <span
+                      className={cn(
+                        "min-w-0 flex-1 text-[15px] font-semibold leading-snug sm:text-base",
+                        open ? "text-[var(--navy)]" : "text-slate-800",
+                      )}
+                    >
+                      {item.question}
+                    </span>
+                    <Chevron
+                      className={cn(
+                        "mt-0.5 transition-transform duration-300 ease-out",
+                        open && "rotate-180 text-blue-600",
+                      )}
+                    />
+                  </button>
+                  <div
+                    id={`faq-panel-${index}`}
+                    role="region"
+                    aria-labelledby={`faq-trigger-${index}`}
+                    className={cn(
+                      "grid transition-[grid-template-rows] duration-300 ease-out",
+                      open ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
+                    )}
+                  >
+                    <div className="min-h-0 overflow-hidden">
+                      <div className="border-t border-[var(--border)] bg-[var(--surface-muted)]/50 px-5 py-4 sm:px-6 sm:py-5">
+                        <p className="whitespace-pre-wrap text-[15px] leading-relaxed text-slate-600">
+                          {item.answer}
+                        </p>
+                      </div>
+                    </div>
+                  </div>
+                </li>
+              );
+            })}
+          </ul>
+        </div>
+      )}
+    </div>
+  );
+}

+ 3 - 17
src/app/[locale]/faq/page.tsx

@@ -1,29 +1,15 @@
-import { getTranslations, setRequestLocale } from "next-intl/server";
+import { setRequestLocale } from "next-intl/server";
+import { FaqClient } from "./faq-client";
 
 type Props = { params: Promise<{ locale: string }> };
 
 export default async function FaqPage({ params }: Props) {
   const { locale } = await params;
   setRequestLocale(locale);
-  const t = await getTranslations("faq");
-
-  const items = [
-    { q: t("q1"), a: t("a1") },
-    { q: t("q2"), a: t("a2") },
-    { q: t("q3"), a: t("a3") },
-  ];
 
   return (
     <div className="page-shell">
-      <h1 className="font-serif text-3xl font-semibold text-[var(--navy)]">{t("title")}</h1>
-      <ul className="mt-10 space-y-8">
-        {items.map((item) => (
-          <li key={item.q}>
-            <h2 className="font-medium text-[var(--navy)]">{item.q}</h2>
-            <p className="mt-2 text-[var(--muted)]">{item.a}</p>
-          </li>
-        ))}
-      </ul>
+      <FaqClient />
     </div>
   );
 }

+ 5 - 0
src/app/[locale]/loading.tsx

@@ -0,0 +1,5 @@
+import { PageTransitionLoader } from "@/components/ui/page-transition-loader";
+
+export default function LocaleLoading() {
+  return <PageTransitionLoader />;
+}

+ 11 - 11
src/app/[locale]/page.tsx

@@ -51,7 +51,7 @@ export default async function HomePage({ params }: Props) {
   return (
     <div>
       {/* Hero — Edulan 式:分栏 + 装饰卡片 */}
-      <section className="relative overflow-hidden border-b border-slate-200/80 bg-gradient-to-br from-slate-900 via-[#0c1929] to-slate-900 text-white">
+      <section className="ui-enter relative overflow-hidden border-b border-slate-200/80 bg-gradient-to-br from-slate-900 via-[#0c1929] to-slate-900 text-white">
         <div className="hero-grid pointer-events-none absolute inset-0 opacity-60" />
         <div className="pointer-events-none absolute -right-20 top-1/2 h-[420px] w-[420px] -translate-y-1/2 rounded-full bg-blue-500/20 blur-3xl" />
         <div className="pointer-events-none absolute -left-32 bottom-0 h-72 w-72 rounded-full bg-amber-500/15 blur-3xl" />
@@ -70,13 +70,13 @@ export default async function HomePage({ params }: Props) {
             <div className="mt-10 flex flex-wrap gap-4">
               <Link
                 href="/courses"
-                className="inline-flex items-center justify-center rounded-full bg-gradient-to-r from-amber-400 to-amber-500 px-8 py-3.5 text-sm font-bold text-[var(--navy)] shadow-xl shadow-amber-500/20 transition hover:from-amber-300 hover:to-amber-400"
+                className="ui-interactive-btn inline-flex items-center justify-center rounded-full bg-gradient-to-r from-amber-400 to-amber-500 px-8 py-3.5 text-sm font-bold text-[var(--navy)] shadow-xl shadow-amber-500/20 transition hover:from-amber-300 hover:to-amber-400"
               >
                 {t("ctaCourses")}
               </Link>
               <Link
                 href="/about"
-                className="inline-flex items-center justify-center rounded-full border-2 border-white/25 bg-white/5 px-8 py-3.5 text-sm font-semibold text-white backdrop-blur-sm transition hover:border-white/40 hover:bg-white/10"
+                className="ui-interactive-btn inline-flex items-center justify-center rounded-full border-2 border-white/25 bg-white/5 px-8 py-3.5 text-sm font-semibold text-white backdrop-blur-sm transition hover:border-white/40 hover:bg-white/10"
               >
                 {t("ctaAbout")}
               </Link>
@@ -127,7 +127,7 @@ export default async function HomePage({ params }: Props) {
       </section>
 
       {/* 三大支柱 */}
-      <section className="site-container section-block">
+      <section className="ui-enter ui-enter-delay-1 site-container section-block">
         <SectionHeading
           eyebrow={t("pillarsEyebrow")}
           title={t("pillarsTitle")}
@@ -139,7 +139,7 @@ export default async function HomePage({ params }: Props) {
             return (
             <div
               key={p.title}
-              className="group shadow-card hover:shadow-card-hover rounded-3xl border border-slate-200/90 bg-white p-8 transition duration-300 hover:-translate-y-1"
+              className="ui-interactive-card group shadow-card rounded-3xl border border-slate-200/90 bg-white p-8"
             >
               <div
                 className={`flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br ${p.accent} text-white shadow-lg`}
@@ -155,7 +155,7 @@ export default async function HomePage({ params }: Props) {
       </section>
 
       {/* 精选课程 */}
-      <section className="border-y border-slate-200/90 bg-gradient-to-b from-slate-50/80 to-white py-20">
+      <section className="ui-enter ui-enter-delay-2 border-y border-slate-200/90 bg-gradient-to-b from-slate-50/80 to-white py-20">
         <div className="site-container">
           <div className="flex flex-col items-start justify-between gap-6 md:flex-row md:items-end">
             <div className="min-w-0 flex-1">
@@ -167,7 +167,7 @@ export default async function HomePage({ params }: Props) {
             </div>
             <Link
               href="/courses"
-              className="shrink-0 rounded-full border-2 border-blue-600 px-6 py-2.5 text-sm font-bold text-blue-700 transition hover:bg-blue-600 hover:text-white"
+              className="ui-interactive-btn shrink-0 rounded-full border-2 border-blue-600 px-6 py-2.5 text-sm font-bold text-blue-700 transition hover:bg-blue-600 hover:text-white"
             >
               {t("viewAll")}
             </Link>
@@ -178,7 +178,7 @@ export default async function HomePage({ params }: Props) {
               <Link
                 key={c.slug}
                 href={`/courses?cat=${c.category}`}
-                className="group shadow-card hover:shadow-card-hover overflow-hidden rounded-3xl border border-slate-200/90 bg-white transition duration-300 hover:-translate-y-1"
+                className="ui-interactive-card group shadow-card overflow-hidden rounded-3xl border border-slate-200/90 bg-white"
               >
                 <div className="relative h-36 overflow-hidden">
                   {/* eslint-disable-next-line @next/next/no-img-element */}
@@ -210,7 +210,7 @@ export default async function HomePage({ params }: Props) {
       </section>
 
       {/* CTA 带 */}
-      <section className="site-container section-block">
+      <section className="ui-enter ui-enter-delay-3 site-container section-block">
         <div className="relative overflow-hidden rounded-[2rem] bg-gradient-to-r from-blue-700 via-blue-800 to-indigo-900 px-8 py-14 text-center text-white shadow-xl shadow-blue-900/20 md:px-16">
           <div className="pointer-events-none absolute -right-24 -top-24 h-64 w-64 rounded-full bg-white/10 blur-3xl" />
           <div className="pointer-events-none absolute -bottom-16 -left-16 h-48 w-48 rounded-full bg-amber-400/20 blur-3xl" />
@@ -221,13 +221,13 @@ export default async function HomePage({ params }: Props) {
           <div className="relative mt-10 flex flex-wrap justify-center gap-4">
             <Link
               href="/courses"
-              className="inline-flex rounded-full bg-white px-8 py-3.5 text-sm font-bold text-blue-900 shadow-lg transition hover:bg-amber-50"
+              className="ui-interactive-btn inline-flex rounded-full bg-white px-8 py-3.5 text-sm font-bold text-blue-900 shadow-lg transition hover:bg-amber-50"
             >
               {t("ctaBandPrimary")}
             </Link>
             <Link
               href="/contact"
-              className="inline-flex rounded-full border-2 border-white/40 px-8 py-3.5 text-sm font-semibold text-white transition hover:bg-white/10"
+              className="ui-interactive-btn inline-flex rounded-full border-2 border-white/40 px-8 py-3.5 text-sm font-semibold text-white transition hover:bg-white/10"
             >
               {t("ctaBandSecondary")}
             </Link>

+ 67 - 0
src/app/globals.css

@@ -187,3 +187,70 @@ body {
 .footer-wave {
   background: linear-gradient(180deg, var(--background) 0%, var(--navy) 100%);
 }
+
+/* Reusable interaction primitives */
+@keyframes ui-fade-up {
+  from {
+    opacity: 0;
+    transform: translateY(10px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+@keyframes ui-skeleton-shimmer {
+  0% {
+    background-position: 200% 0;
+  }
+  100% {
+    background-position: -200% 0;
+  }
+}
+
+.ui-enter {
+  animation: ui-fade-up 0.45s ease both;
+}
+
+.ui-enter-delay-1 {
+  animation-delay: 0.08s;
+}
+
+.ui-enter-delay-2 {
+  animation-delay: 0.16s;
+}
+
+.ui-enter-delay-3 {
+  animation-delay: 0.24s;
+}
+
+.ui-interactive-card {
+  transition: transform 280ms ease, box-shadow 280ms ease, border-color 280ms ease;
+  transform: translateY(0);
+}
+
+.ui-interactive-card:hover {
+  transform: translateY(-4px);
+  box-shadow: var(--shadow-card-hover);
+  border-color: rgb(148 163 184 / 0.35);
+}
+
+.ui-interactive-btn {
+  transition: transform 220ms ease, box-shadow 220ms ease, filter 220ms ease;
+}
+
+.ui-interactive-btn:hover {
+  transform: translateY(-1px);
+  filter: brightness(1.03);
+}
+
+.ui-interactive-btn:active {
+  transform: translateY(0);
+}
+
+.ui-skeleton {
+  background: linear-gradient(90deg, #e8edf5 25%, #f3f6fb 37%, #e8edf5 63%);
+  background-size: 400% 100%;
+  animation: ui-skeleton-shimmer 1.4s ease infinite;
+}

+ 14 - 0
src/components/site-header.tsx

@@ -225,6 +225,13 @@ export function SiteHeader() {
               )}
             </div>
 
+            <Link
+              href="/faq"
+              className="rounded-xl px-4 py-2.5 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 hover:text-[var(--navy)]"
+            >
+              {t("faq")}
+            </Link>
+
             <Link
               href="/courses"
               className="rounded-xl px-4 py-2.5 text-sm font-semibold text-slate-600 transition hover:bg-slate-50 hover:text-[var(--navy)]"
@@ -330,6 +337,13 @@ export function SiteHeader() {
                 <span>{t("about")}</span>
                 <span className="text-xl text-slate-400">+</span>
               </Link>
+              <Link
+                href="/faq"
+                className="border-b border-slate-200/90 py-3 text-2xl font-semibold leading-snug text-slate-900 transition-colors hover:text-blue-700"
+                onClick={() => setOpen(false)}
+              >
+                {t("faq")}
+              </Link>
               <Link
                 href="/account"
                 className="border-b border-slate-200/90 py-3 text-2xl font-semibold leading-snug text-slate-900 transition-colors hover:text-blue-700"

+ 26 - 0
src/components/ui/loading-state.tsx

@@ -0,0 +1,26 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+
+export function InlineLoading({
+  text = "加载中...",
+  className,
+}: {
+  text?: string;
+  className?: string;
+}) {
+  return (
+    <div className={cn("inline-flex items-center gap-2 text-sm text-[var(--muted)]", className)}>
+      <span className="h-4 w-4 animate-spin rounded-full border-2 border-slate-300 border-t-blue-600" />
+      <span>{text}</span>
+    </div>
+  );
+}
+
+export function SkeletonBlock({
+  className,
+}: {
+  className?: string;
+}) {
+  return <div className={cn("ui-skeleton rounded-xl", className)} aria-hidden />;
+}

+ 35 - 0
src/components/ui/modal-shell.tsx

@@ -0,0 +1,35 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+
+export function ModalShell({
+  open,
+  children,
+  className,
+  zIndexClassName = "z-[60]",
+}: {
+  open: boolean;
+  children: React.ReactNode;
+  className?: string;
+  zIndexClassName?: string;
+}) {
+  return (
+    <div
+      className={cn(
+        "fixed inset-0 flex items-center justify-center px-4 transition-all duration-300",
+        zIndexClassName,
+        open ? "pointer-events-auto bg-black/45 opacity-100" : "pointer-events-none bg-black/0 opacity-0",
+      )}
+    >
+      <div
+        className={cn(
+          "w-full transition-all duration-300",
+          open ? "translate-y-0 scale-100 opacity-100" : "translate-y-3 scale-[0.98] opacity-0",
+          className,
+        )}
+      >
+        {children}
+      </div>
+    </div>
+  );
+}

+ 18 - 0
src/components/ui/page-transition-loader.tsx

@@ -0,0 +1,18 @@
+import { SkeletonBlock } from "@/components/ui/loading-state";
+
+export function PageTransitionLoader() {
+  return (
+    <div className="site-container section-block">
+      <div className="rounded-3xl border border-slate-200/80 bg-white/80 p-6 shadow-card backdrop-blur-sm">
+        <div className="mb-5">
+          <SkeletonBlock className="h-7 w-48" />
+        </div>
+        <div className="grid gap-4 md:grid-cols-2">
+          <SkeletonBlock className="h-24 w-full" />
+          <SkeletonBlock className="h-24 w-full" />
+          <SkeletonBlock className="h-24 w-full md:col-span-2" />
+        </div>
+      </div>
+    </div>
+  );
+}

+ 25 - 3
src/lib/api.ts

@@ -15,16 +15,38 @@ import { getApiBaseUrl } from "@/lib/env";
 export const API_TOKEN_STORAGE_KEY = "jchl_api_token";
 export const API_ACCESS_TOKEN_HEADER = "access-token";
 
-const baseURL = getApiBaseUrl();
-
 export const api: AxiosInstance = axios.create({
-  baseURL: baseURL || undefined,
   timeout: 30_000,
   headers: {
     "Content-Type": "application/json",
   },
 });
 
+/**
+ * 每次请求解析 baseURL,以便 `__ORIGIN__` 与 window / 部署域名一致。
+ * - `baseURL: ""`:走当前站点根路径(如 `/api/...` 的 Route Handler)。
+ * - 调用方已传入非空 `baseURL`(如 {@link postRemittance} 的 `/api-backend-remittance`):不得覆盖,否则会误打到主后端 `/api-backend`。
+ */
+api.interceptors.request.use((config) => {
+  if (config.baseURL === "") {
+    config.baseURL =
+      typeof window !== "undefined"
+        ? undefined
+        : (process.env.NEXT_PUBLIC_SITE_URL?.trim().replace(/\/$/, "") || undefined);
+    return config;
+  }
+  const callerBase =
+    config.baseURL !== undefined &&
+    config.baseURL !== null &&
+    String(config.baseURL).trim() !== "";
+  if (callerBase) {
+    return config;
+  }
+  const base = getApiBaseUrl();
+  config.baseURL = base || undefined;
+  return config;
+});
+
 api.interceptors.request.use((config) => {
   if (typeof window !== "undefined") {
     const token = localStorage.getItem(API_TOKEN_STORAGE_KEY);

+ 11 - 10
src/lib/checkout-api.ts

@@ -1,4 +1,4 @@
-import { api } from "@/lib/api";
+import { postRemittance } from "@/lib/remittance-client";
 
 export type RemittanceChannel = {
   id: string;
@@ -161,11 +161,12 @@ function normalizeChannelList(raw: unknown): RemittanceChannel[] {
 }
 
 export async function fetchRemittanceChannels(): Promise<RemittanceChannel[]> {
-  const { data: raw } = await api.post<unknown>(
-    "/api/remittance/channel/list",
-    {},
-    { baseURL: "" },
-  );
+  let raw: unknown;
+  try {
+    raw = await postRemittance<unknown>("/remit/channel/list", {});
+  } catch {
+    raw = await postRemittance<unknown>("/remittance/channel/list", {});
+  }
   return normalizeChannelList(raw);
 }
 
@@ -194,7 +195,7 @@ function normalizeBankChannelList(raw: unknown): BankChannelOption[] {
 
 export async function fetchBankChannelOptions(channelCode?: string): Promise<BankChannelOption[]> {
   const body = channelCode ? { channelCode } : {};
-  const { data: raw } = await api.post<unknown>("/api/channel/bank/list", body, { baseURL: "" });
+  const raw = await postRemittance<unknown>("/channel/bank/list", body);
   return normalizeBankChannelList(raw);
 }
 
@@ -209,14 +210,14 @@ export async function submitXfgPayOrder(input: {
   const normalizedRequestUrl = `/${input.requestUrl.replace(/^\/+|\/+$/g, "")}`;
   const amount = String(input.amount);
   const path = input.bankCode
-    ? `/api/xfgpay/pay${normalizedRequestUrl}/1/${encodeURIComponent(amount)}/${encodeURIComponent(input.bankCode)}/0`
-    : `/api/xfgpay/pay${normalizedRequestUrl}/1/${encodeURIComponent(amount)}/0`;
+    ? `${normalizedRequestUrl}/1/${encodeURIComponent(amount)}/${encodeURIComponent(input.bankCode)}/0`
+    : `${normalizedRequestUrl}/1/${encodeURIComponent(amount)}/0`;
   const body = {
     goodIds: input.goodIds,
     payName: input.payName,
     payPhone: input.payPhone,
   };
-  const { data } = await api.post<unknown>(path, body, { baseURL: "" });
+  const data = await postRemittance<unknown>(path, body);
   return { raw: data, resultUrl: pickResultUrl(data) };
 }
 

+ 37 - 2
src/lib/env.ts

@@ -4,11 +4,46 @@
  * - test:测试环境(构建时用 build:test,或 CI 注入 NEXT_PUBLIC_APP_ENV=test)
  * - production:生产(next build 默认读 .env.production)
  *
+ * 端口约定(与 next.config rewrites 一致):
+ * - `getApiBaseUrl()` / `API_PROXY_TARGET`:主业务(如 :8005)
+ * - `getRemittanceApiBaseUrl()` / `API_PROXY_TARGET_REMITTANCE`:取款申请、结账存款支付等(如 :8504)
+ *
  * 优先使用 NEXT_PUBLIC_API_BASE_URL 覆盖(CI / 临时调试)。
+ *
+ * 动态域名:将对应环境的地址设为字面量 `__ORIGIN__`(不区分大小写)时,
+ * - 浏览器:使用 `window.location.origin`(与当前访问域名一致)
+ * - 服务端(RSC / Route Handler 等发 axios):需配置 `NEXT_PUBLIC_SITE_URL`(完整站点根,如 https://app.example.com);
+ *   部署在 Vercel 时可回退使用 `VERCEL_URL`(自动加 https://)
  */
 
+const ORIGIN_PLACEHOLDER = "__origin__";
+
 export type AppEnv = "local" | "test" | "production";
 
+function resolvePublicSiteOrigin(): string {
+  const site = process.env.NEXT_PUBLIC_SITE_URL?.trim().replace(/\/$/, "");
+  if (site) return site;
+  const vercel = process.env.VERCEL_URL?.trim().replace(/\/$/, "");
+  if (vercel) {
+    const host = vercel.replace(/^https?:\/\//i, "");
+    return `https://${host}`;
+  }
+  return "";
+}
+
+/** 将 `__ORIGIN__` 解析为当前站点 origin;非占位符则原样返回(已 trim)。 */
+function resolveApiBaseUrlValue(raw: string | undefined): string {
+  const s = raw?.trim() ?? "";
+  if (!s) return "";
+  if (s.toLowerCase() === ORIGIN_PLACEHOLDER) {
+    if (typeof window !== "undefined" && window.location?.origin) {
+      return window.location.origin;
+    }
+    return resolvePublicSiteOrigin();
+  }
+  return s;
+}
+
 export function resolveAppEnv(): AppEnv {
   const fromEnv = process.env.NEXT_PUBLIC_APP_ENV;
   if (fromEnv === "local" || fromEnv === "test" || fromEnv === "production") {
@@ -20,7 +55,7 @@ export function resolveAppEnv(): AppEnv {
 
 export function getApiBaseUrl(): string {
   const explicit = process.env.NEXT_PUBLIC_API_BASE_URL?.trim();
-  if (explicit) return explicit.replace(/\/$/, "");
+  if (explicit) return resolveApiBaseUrlValue(explicit).replace(/\/$/, "");
 
   const env = resolveAppEnv();
   const map: Record<AppEnv, string | undefined> = {
@@ -28,7 +63,7 @@ export function getApiBaseUrl(): string {
     test: process.env.NEXT_PUBLIC_API_BASE_URL_TEST,
     production: process.env.NEXT_PUBLIC_API_BASE_URL_PRODUCTION,
   };
-  return (map[env]?.trim() ?? "").replace(/\/$/, "");
+  return resolveApiBaseUrlValue(map[env]?.trim() ?? "").replace(/\/$/, "");
 }
 
 export function getRemittanceApiBaseUrl(): string {

+ 81 - 0
src/lib/faq-api.ts

@@ -0,0 +1,81 @@
+import { apiPost } from "@/lib/api";
+
+export type FaqItem = {
+  id: string;
+  question: string;
+  answer: string;
+};
+
+function pickString(record: Record<string, unknown>, keys: string[]): string {
+  for (const key of keys) {
+    const value = record[key];
+    if (typeof value === "string" && value.trim()) return value.trim();
+  }
+  return "";
+}
+
+function pickList(raw: unknown): unknown[] {
+  if (!raw || typeof raw !== "object") return [];
+  const o = raw as Record<string, unknown>;
+  const inner = o.data ?? o.list ?? o.rows ?? o.records;
+  if (Array.isArray(inner)) return inner;
+  if (inner && typeof inner === "object") {
+    const x = inner as Record<string, unknown>;
+    if (Array.isArray(x.list)) return x.list;
+    if (Array.isArray(x.records)) return x.records;
+    if (Array.isArray(x.rows)) return x.rows;
+  }
+  return [];
+}
+
+function normalizeFaqRow(row: unknown, index: number): FaqItem | null {
+  if (!row || typeof row !== "object") return null;
+  const r = row as Record<string, unknown>;
+  const question = pickString(r, [
+    "title",
+    "question",
+    "questionTitle",
+    "name",
+    "q",
+    "subject",
+  ]);
+  const answer = pickString(r, [
+    "content",
+    "answer",
+    "reply",
+    "description",
+    "a",
+    "questionContent",
+    "detail",
+    "remark",
+  ]);
+  if (!question && !answer) return null;
+  const id = String(r.id ?? r.questionId ?? index).trim() || String(index);
+  return {
+    id,
+    question: question || answer.slice(0, 80) || "—",
+    answer: answer || "—",
+  };
+}
+
+export async function fetchCommonQuestions(
+  page: { current: number; row: number } = { current: 1, row: 100 },
+): Promise<{ items: FaqItem[]; failed: boolean }> {
+  const current = Number.isFinite(page.current) ? Math.max(1, page.current) : 1;
+  const row = Number.isFinite(page.row) ? Math.max(1, Math.min(200, page.row)) : 100;
+  try {
+    const payload = await apiPost<unknown, { page: { current: number; row: number } }>(
+      "/common/question/search/list",
+      { page: { current, row } },
+    );
+    const list = pickList(payload);
+    const items: FaqItem[] = [];
+    list.forEach((item, i) => {
+      const n = normalizeFaqRow(item, i);
+      if (n) items.push(n);
+    });
+    return { items, failed: false };
+  } catch {
+    return { items: [], failed: true };
+  }
+}

+ 13 - 0
src/lib/remittance-client.ts

@@ -0,0 +1,13 @@
+import { api } from "@/lib/api";
+import { getRemittanceApiBaseUrl } from "@/lib/env";
+
+/**
+ * 汇款 / 出款 / 存款支付相关接口(与 `API_PROXY_TARGET_REMITTANCE` 一致,如 :8504)。
+ * 主业务接口请用默认 `api`(`API_PROXY_TARGET`,如 :8005)。
+ */
+export async function postRemittance<T = unknown>(path: string, body: unknown): Promise<T> {
+  const { data } = await api.post<T>(path, body, {
+    baseURL: getRemittanceApiBaseUrl() || undefined,
+  });
+  return data;
+}

+ 1 - 8
src/lib/withdrawal-api.ts

@@ -1,5 +1,5 @@
 import { api } from "@/lib/api";
-import { getRemittanceApiBaseUrl } from "@/lib/env";
+import { postRemittance } from "@/lib/remittance-client";
 
 export type WithdrawalRecord = {
   id: string;
@@ -153,13 +153,6 @@ function pickList(raw: unknown): unknown[] {
   return [];
 }
 
-async function postRemittance(path: string, body: unknown): Promise<unknown> {
-  const { data } = await api.post<unknown>(path, body, {
-    baseURL: getRemittanceApiBaseUrl() || undefined,
-  });
-  return data;
-}
-
 export async function fetchWithdrawAccounts(): Promise<WithdrawAccount[]> {
   const raw = await postRemittance("/custom/dropdown", { platform: "" });
   const list = pickList(raw);