Ver código fonte

Initialize project codebase and deploy configuration.

Set up the Next.js app structure, account-related pages, localization resources, and PM2/server runtime files for test and production environments.

Made-with: Cursor
ALIEZ 1 mês atrás
pai
commit
b7c9f124f1
76 arquivos alterados com 8805 adições e 175 exclusões
  1. 2 0
      .gitignore
  2. 48 0
      ecosystem.config.cjs
  3. 1 0
      eslint.config.mjs
  4. 251 0
      messages/en.json
  5. 254 0
      messages/zh.json
  6. 26 2
      next.config.ts
  7. 751 75
      package-lock.json
  8. 19 3
      package.json
  9. BIN
      public/专题视频-状态--锁定、解锁.png
  10. BIN
      public/免费试听--状态--锁定、解锁.png
  11. BIN
      public/拆书章节视频--状态--锁定、解锁.png
  12. BIN
      public/策略报告--状态--锁定、解锁.png
  13. BIN
      public/系统课程--状态--锁定、解锁.png
  14. 96 0
      scripts/predev-lock-check.mjs
  15. 32 0
      server.js
  16. 66 0
      src/app/[locale]/about/page.tsx
  17. 104 0
      src/app/[locale]/account/change-password/page.tsx
  18. 194 0
      src/app/[locale]/account/orders/page.tsx
  19. 405 0
      src/app/[locale]/account/page.tsx
  20. 259 0
      src/app/[locale]/account/purchased-courses/page.tsx
  21. 870 0
      src/app/[locale]/account/withdraw-apply/page.tsx
  22. 141 0
      src/app/[locale]/account/withdrawals/page.tsx
  23. 51 0
      src/app/[locale]/auth/forgot-password/page.tsx
  24. 148 0
      src/app/[locale]/auth/login/page.tsx
  25. 265 0
      src/app/[locale]/auth/register/page.tsx
  26. 497 0
      src/app/[locale]/checkout/checkout-form.tsx
  27. 23 0
      src/app/[locale]/checkout/page.tsx
  28. 16 0
      src/app/[locale]/contact/page.tsx
  29. 107 0
      src/app/[locale]/courses/[slug]/page.tsx
  30. 240 0
      src/app/[locale]/courses/[slug]/videos-list-client.tsx
  31. 101 0
      src/app/[locale]/courses/courses-list-client.tsx
  32. 78 0
      src/app/[locale]/courses/page.tsx
  33. 29 0
      src/app/[locale]/faq/page.tsx
  34. 53 0
      src/app/[locale]/layout.tsx
  35. 16 0
      src/app/[locale]/legal/copyright/page.tsx
  36. 16 0
      src/app/[locale]/legal/privacy/page.tsx
  37. 16 0
      src/app/[locale]/legal/terms/page.tsx
  38. 239 0
      src/app/[locale]/page.tsx
  39. 16 0
      src/app/[locale]/purchase-guarantee/page.tsx
  40. 172 0
      src/app/[locale]/quiz/page.tsx
  41. 35 0
      src/app/api/channel/bank/list/route.ts
  42. 14 0
      src/app/api/courses/route.ts
  43. 35 0
      src/app/api/remittance/channel/list/route.ts
  44. 65 0
      src/app/api/xfgpay/pay/[...segments]/route.ts
  45. 174 11
      src/app/globals.css
  46. 17 19
      src/app/layout.tsx
  47. 0 65
      src/app/page.tsx
  48. 192 0
      src/components/country-combobox.tsx
  49. 56 0
      src/components/course-buy-button.tsx
  50. 38 0
      src/components/icons.tsx
  51. 12 0
      src/components/locale-html-lang.tsx
  52. 29 0
      src/components/locale-switcher.tsx
  53. 38 0
      src/components/section-heading.tsx
  54. 101 0
      src/components/site-footer.tsx
  55. 403 0
      src/components/site-header.tsx
  56. 426 0
      src/data/courses.ts
  57. 16 0
      src/hooks/use-now.ts
  58. 5 0
      src/i18n/navigation.ts
  59. 15 0
      src/i18n/request.ts
  60. 14 0
      src/i18n/routing.ts
  61. 33 0
      src/lib/account-api.ts
  62. 140 0
      src/lib/api.ts
  63. 54 0
      src/lib/auth-api.ts
  64. 60 0
      src/lib/auth-types.ts
  65. 240 0
      src/lib/checkout-api.ts
  66. 50 0
      src/lib/env.ts
  67. 56 0
      src/lib/goods-order-api.ts
  68. 156 0
      src/lib/order-api.ts
  69. 11 0
      src/lib/password-rules.ts
  70. 52 0
      src/lib/quiz-rules.ts
  71. 71 0
      src/lib/register-api.ts
  72. 51 0
      src/lib/reward-api.ts
  73. 6 0
      src/lib/utils.ts
  74. 288 0
      src/lib/withdrawal-api.ts
  75. 234 0
      src/providers/auth-provider.tsx
  76. 16 0
      src/proxy.ts

+ 2 - 0
.gitignore

@@ -32,6 +32,8 @@ yarn-error.log*
 
 # env files (can opt-in for committing if needed)
 .env*
+!.env.development
+!.env.production
 
 # vercel
 .vercel

+ 48 - 0
ecosystem.config.cjs

@@ -0,0 +1,48 @@
+const path = require("path");
+
+/**
+ * PM2 多环境说明:
+ * - jchl-test:测试环境(默认端口 3100)
+ * - jchl-prod:生产环境(默认端口 3000)
+ *
+ * 端口可在下面 env 中改,或由服务器上的 .env.production / 系统环境变量覆盖。
+ * 敏感信息不要写进本文件,用 PM2 --env 或服务器环境变量注入。
+ *
+ * NEXT_PUBLIC_*(如 API 地址)在 next build 时已打入前端包;PM2 里再改不会更新浏览器里的接口域名。
+ * 测试 / 生产请分别用 npm run build:test 与 npm run build,或在构建前注入相同的环境变量。
+ */
+
+module.exports = {
+  apps: [
+    {
+      name: "jchl-test",
+      cwd: path.resolve(__dirname),
+      script: "server.js",
+      interpreter: "node",
+      instances: 1,
+      exec_mode: "fork",
+      autorestart: true,
+      watch: false,
+      max_memory_restart: "800M",
+      env: {
+        NODE_ENV: "production",
+        PORT: 3100,
+      },
+    },
+    {
+      name: "jchl-prod",
+      cwd: path.resolve(__dirname),
+      script: "server.js",
+      interpreter: "node",
+      instances: 1,
+      exec_mode: "fork",
+      autorestart: true,
+      watch: false,
+      max_memory_restart: "1G",
+      env: {
+        NODE_ENV: "production",
+        PORT: 3000,
+      },
+    },
+  ],
+};

+ 1 - 0
eslint.config.mjs

@@ -12,6 +12,7 @@ const eslintConfig = defineConfig([
     "out/**",
     "build/**",
     "next-env.d.ts",
+    "ecosystem.config.cjs",
   ]),
 ]);
 

+ 251 - 0
messages/en.json

@@ -0,0 +1,251 @@
+{
+  "metadata": {
+    "title": "Jin Ce Hong Lun · Systematic Trading Education",
+    "description": "Structured trading courses, topic videos, and strategy reports—with member quizzes and services."
+  },
+  "nav": {
+    "brand": "Jin Ce Hong Lun",
+    "courses": "Courses",
+    "quiz": "Prize Quiz",
+    "about": "About",
+    "account": "Account",
+    "purchase": "Purchase",
+    "login": "Log in",
+    "register": "Sign up",
+    "accountSettings": "Account settings",
+    "userMenu": "User menu",
+    "categories": {
+      "trial": "Free trial",
+      "practical": "Practical (50 lessons)",
+      "book": "Book chapters (12)",
+      "topic": "Topic videos",
+      "strategy": "Strategy reports"
+    },
+    "quizSub": {
+      "challenge": "Knowledge challenge",
+      "festival": "Wisdom festival quiz",
+      "journey": "Exploration journey",
+      "empowerment": "Empowerment plan"
+    },
+    "aboutSub": {
+      "ip": "Personal IP",
+      "philosophy": "Teaching philosophy",
+      "media": "Press"
+    }
+  },
+  "footer": {
+    "quick": "Quick links",
+    "legal": "Legal",
+    "contact": "Contact",
+    "faq": "FAQ",
+    "guarantee": "Purchase guarantee",
+    "contactUs": "Contact us",
+    "terms": "Terms of service",
+    "privacy": "Privacy policy",
+    "copyright": "Copyright",
+    "phone": "Phone",
+    "email": "Email",
+    "phoneValue": "400-000-0000",
+    "emailValue": "hello@jchl.example.com",
+    "rights": "© {year} Jin Ce Hong Lun · All rights reserved",
+    "tagline": "Structured trading courses and member services for disciplined learning."
+  },
+  "home": {
+    "heroTitle": "Systematic trading education",
+    "heroSubtitle": "Risk-aware, framework-first learning from foundations to refinement.",
+    "ctaCourses": "Browse courses",
+    "ctaAbout": "Our philosophy",
+    "pillarsTitle": "Three pillars",
+    "pillar1Title": "Structured curriculum",
+    "pillar1Desc": "From trial to 50-lesson practice, topics, and research reports.",
+    "pillar2Title": "Member growth",
+    "pillar2Desc": "Orders and scholarship settings—unlock the quiz when eligible.",
+    "pillar3Title": "Compliance",
+    "pillar3Desc": "Terms, privacy, and copyright pages for transparency.",
+    "featuredTitle": "Featured courses",
+    "viewAll": "View all courses",
+    "heroEyebrow": "Online trading education",
+    "stat1": "50+",
+    "stat1Label": "Lessons",
+    "stat2": "5",
+    "stat2Label": "Product lines",
+    "stat3": "12",
+    "stat3Label": "Book chapters",
+    "stat4": "180",
+    "stat4Label": "Days to quiz",
+    "pillarsEyebrow": "Learning path",
+    "featuredEyebrow": "Popular",
+    "ctaBandTitle": "Ready to learn systematically?",
+    "ctaBandSubtitle": "Start with the free trial or browse the full curriculum.",
+    "ctaBandPrimary": "Browse courses",
+    "ctaBandSecondary": "Contact us"
+  },
+  "courses": {
+    "title": "Courses",
+    "pageEyebrow": "Catalog",
+    "subtitle": "Browse by product line—playback will connect to your backend later.",
+    "filterAll": "All",
+    "lessons": "{count} lessons",
+    "free": "Free",
+    "buy": "Buy now",
+    "detail": "Details",
+    "notFound": "Course not found",
+    "videoListTitle": "Video list",
+    "videoListLoading": "Loading videos...",
+    "videoListEmpty": "No videos yet",
+    "videoNoIntro": "No description yet",
+    "videoNoCover": "No cover",
+    "videoPlay": "Play video",
+    "videoNowPlaying": "Now playing",
+    "videoClosePlayer": "Close player",
+    "videoNoPlayableUrl": "No playable URL",
+    "videoPrevPage": "Previous",
+    "videoNextPage": "Next",
+    "videoCurrentPage": "Page {page}"
+  },
+  "checkout": {
+    "title": "Checkout",
+    "subtitle": "Demo flow—real payments will integrate here later.",
+    "coupon": "Coupon / referral code",
+    "couponPlaceholder": "Enter code",
+    "verify": "Apply",
+    "couponOk": "Demo discount applied",
+    "payMethod": "Payment method",
+    "payCard": "Card",
+    "payAtm": "ATM",
+    "payAlipay": "Alipay",
+    "payWechat": "WeChat Pay",
+    "secure": "Security",
+    "secureDesc": "Production may use 3D Secure and PSP-hosted flows.",
+    "payerName": "Payer name",
+    "payerPhone": "Phone",
+    "submit": "Place order",
+    "success": "Demo order created—see purchase history in your account."
+  },
+  "auth": {
+    "loginTitle": "Log in",
+    "registerTitle": "Sign up",
+    "forgotTitle": "Forgot password",
+    "changePwdTitle": "Change password",
+    "email": "Email",
+    "password": "Password",
+    "passwordRuleLen": "Use 8 to 15 characters",
+    "passwordRuleCase": "Use both uppercase and lowercase letters",
+    "passwordRuleMix": "Use a combination of numbers and English letters",
+    "name": "Name",
+    "code": "Email code",
+    "sendCode": "Send code",
+    "sendCodeCooldown": "Resend in {sec}s",
+    "country": "Country / region",
+    "countrySearchPlaceholder": "Type to filter…",
+    "countryNoResults": "No matches—try another keyword",
+    "countryRequired": "Please select your country or region",
+    "selectCountry": "Select…",
+    "loadingCountries": "Loading countries…",
+    "registerEmailOnlyHint": "Sign up with email only and choose your country or region.",
+    "errorApi": "Something went wrong. Please try again.",
+    "oauth": "Third-party (demo)",
+    "apple": "Apple",
+    "facebook": "Facebook",
+    "wechat": "WeChat",
+    "loginBtn": "Log in",
+    "registerSuccessTitle": "You're registered",
+    "registerSuccessHint": "Redirecting to sign in. Use your email and password to log in.",
+    "loginAfterRegister": "Registration successful. Sign in with your email and password.",
+    "registerBtn": "Sign up",
+    "resetBtn": "Send reset email (demo)",
+    "changeBtn": "Save new password",
+    "toRegister": "No account? Sign up",
+    "toLogin": "Have an account? Log in",
+    "profileHint": "Complete your profile after sign-up.",
+    "errorInvalidEmail": "Enter a valid email",
+    "errorCredentials": "Invalid email or password (demo: any valid email works)",
+    "errorWeakPassword": "Password does not meet the rules below",
+    "errorCode": "Enter a 6-digit code",
+    "forgotHint": "Demo: no real email is sent.",
+    "changeHint": "Demo: updates local state only."
+  },
+  "account": {
+    "title": "Member center",
+    "welcome": "Welcome, {name}",
+    "logout": "Log out",
+    "orders": "Purchase history",
+    "noOrders": "No orders yet.",
+    "orderAmount": "Amount",
+    "orderDate": "Date",
+    "scholarship": "Scholarship payout",
+    "bankName": "Bank",
+    "accountName": "Account name",
+    "accountNumber": "Card number",
+    "alipayId": "Alipay ID",
+    "saveScholarship": "Save",
+    "quizSection": "Prize quiz",
+    "quizBelow": "After {amount} USD total spend, a {days}-day countdown starts; then the quiz unlocks.",
+    "quizBelowRemain": "{amount} USD to threshold.",
+    "quizCountdown": "Countdown—unlocks at {date}",
+    "quizUnlocked": "Unlocked—open the quiz page.",
+    "goQuiz": "Open quiz",
+    "demoTitle": "Local demo",
+    "demoHint": "Use the shortcut below to test unlock without waiting.",
+    "demoUnlock": "Demo: satisfy rules and skip countdown",
+    "loginRequired": "Log in to view the member center."
+  },
+  "quiz": {
+    "title": "Prize quiz",
+    "locked": "Locked",
+    "lockedDesc": "Check spend rules in the member center.",
+    "intro": "3 questions—pass all to see the scholarship message (demo).",
+    "q1": "Which best matches primary risk management?",
+    "q1a": "Max leverage on one bet",
+    "q1b": "Define max loss and size first",
+    "q1c": "Ignore stops",
+    "q2": "Systematic trading emphasizes?",
+    "q2a": "Random entries",
+    "q2b": "Repeatable rules and review",
+    "q2c": "News only",
+    "q3": "We encourage learners to?",
+    "q3a": "Watch only",
+    "q3b": "Journal and iterate",
+    "q3c": "Change methods without notes",
+    "submit": "Submit",
+    "resultOk": "Completed!",
+    "resultScholarship": "Demo scholarship: {amount} USD",
+    "resultContact": "Screenshot and contact support (demo).",
+    "resultFail": "Some answers are wrong—try again.",
+    "backAccount": "Back to account"
+  },
+  "legal": {
+    "termsTitle": "Terms of service",
+    "privacyTitle": "Privacy policy",
+    "copyrightTitle": "Copyright",
+    "termsBody": "Use the service lawfully. Service may pause for maintenance. Third-party links are not controlled by us.",
+    "privacyBody": "We may collect registration, browsing, and device data to operate and improve the product, and for marketing when allowed. Full policy will expand with backend integration.",
+    "copyrightBody": "Content and layout are protected. No commercial reproduction without permission."
+  },
+  "about": {
+    "title": "About",
+    "ipTitle": "Personal IP",
+    "ipBody": "We focus on systematic trading education and reusable curricula.",
+    "philosophyTitle": "Philosophy",
+    "philosophyBody": "Risk first, framework before flair—journal, review, long-term discipline.",
+    "mediaTitle": "Press",
+    "mediaBody": "Press updates will appear here (placeholder)."
+  },
+  "faq": {
+    "title": "FAQ",
+    "q1": "How do I start?",
+    "a1": "Begin with the free trial, then choose practical or topic courses.",
+    "q2": "Purchase protection?",
+    "a2": "See the guarantee page—demo copy only.",
+    "q3": "How to unlock the quiz?",
+    "a3": "Reach spend threshold, wait for the countdown—see member center."
+  },
+  "guarantee": {
+    "title": "Purchase guarantee",
+    "body": "Refund and exchange rules will be published for production; this site is a demo."
+  },
+  "contact": {
+    "title": "Contact",
+    "body": "Phone and email in the footer; partnerships via email with context."
+  }
+}

+ 254 - 0
messages/zh.json

@@ -0,0 +1,254 @@
+{
+  "metadata": {
+    "title": "金策弘论社 · 系统化交易教育",
+    "description": "金策弘论社:系统化交易课程、专题视频与策略研报,辅以有奖知识问答与会员服务。"
+  },
+  "nav": {
+    "brand": "金策弘论社",
+    "courses": "视频课程",
+    "quiz": "有奖知识问答",
+    "about": "关于我们",
+    "account": "用户中心",
+    "purchase": "购买课程",
+    "login": "登录",
+    "register": "注册",
+    "accountSettings": "修改密码",
+    "userMenu": "用户菜单",
+    "categories": {
+      "trial": "免费试听",
+      "practical": "实盘交易课(50讲)",
+      "book": "新书章节(12讲)",
+      "topic": "专题视频",
+      "strategy": "策略研报"
+    },
+    "quizSub": {
+      "challenge": "知识经验挑战",
+      "festival": "智慧节问答",
+      "journey": "知识探索之旅",
+      "empowerment": "知识赋能计划"
+    },
+    "aboutSub": {
+      "ip": "个人 IP",
+      "philosophy": "教学理念",
+      "media": "媒体报道"
+    }
+  },
+  "footer": {
+    "quick": "快捷入口",
+    "legal": "法律信息",
+    "contact": "联系方式",
+    "faq": "常见问题",
+    "guarantee": "购课保障",
+    "contactUs": "联系我们",
+    "terms": "服务条款",
+    "privacy": "隐私政策",
+    "copyright": "版权声明",
+    "phone": "客服电话",
+    "email": "邮箱",
+    "phoneValue": "400-000-0000",
+    "emailValue": "hello@jchl.example.com",
+    "rights": "© {year} 金策弘论社 · 保留所有权利",
+    "tagline": "系统化交易课程与会员服务,助力理性投资与持续精进。"
+  },
+  "home": {
+    "heroTitle": "系统化交易教育",
+    "heroSubtitle": "以实盘逻辑与风险管理为核心,陪伴你从建立框架到持续精进。",
+    "ctaCourses": "浏览课程",
+    "ctaAbout": "了解教学理念",
+    "pillarsTitle": "三大学习支柱",
+    "pillar1Title": "体系化课程",
+    "pillar1Desc": "从试听、实盘五十讲课程精研,到盘感热点的专题视频,更有定期的市场策略报告,层层递进,干货满满。",
+    "pillar2Title": "会员跟踪成长",
+    "pillar2Desc": "购课记录,开启 6 个月每周一次的答题成长之旅!达标解锁奖学金!",
+    "pillar3Title": "合规透明",
+    "pillar3Desc": "服务条款、隐私与版权声明完整呈现,购课更安心。",
+    "featuredTitle": "精选课程",
+    "viewAll": "查看全部课程",
+    "heroEyebrow": "在线交易教育 · LMS 式学习体验",
+    "stat1": "50+",
+    "stat1Label": "精品课时",
+    "stat2": "5",
+    "stat2Label": "产品线",
+    "stat3": "12",
+    "stat3Label": "新书精讲",
+    "stat4": "180",
+    "stat4Label": "天问答解锁",
+    "pillarsEyebrow": "学习路径",
+    "featuredEyebrow": "热门推荐",
+    "ctaBandTitle": "准备好开启系统化学习?",
+    "ctaBandSubtitle": "从免费试听开始,或直接进入完整课程体系,与金策弘论社一同成长。",
+    "ctaBandPrimary": "立即选课",
+    "ctaBandSecondary": "免费咨询"
+  },
+  "courses": {
+    "title": "课程中心",
+    "pageEyebrow": "课程目录",
+    "subtitle": "按产品线浏览视频内容,后续将对接后台与播放页。",
+    "filterAll": "全部",
+    "lessons": "{count} 讲",
+    "free": "免费",
+    "buy": "立即购买",
+    "detail": "查看详情",
+    "notFound": "未找到该课程",
+    "videoListTitle": "视频列表",
+    "videoListLoading": "视频加载中...",
+    "videoListEmpty": "暂无视频数据",
+    "videoNoIntro": "暂无简介",
+    "videoNoCover": "暂无封面",
+    "videoPlay": "播放视频",
+    "videoNowPlaying": "正在播放",
+    "videoClosePlayer": "关闭播放器",
+    "videoNoPlayableUrl": "暂无可播放地址",
+    "videoPrevPage": "上一页",
+    "videoNextPage": "下一页",
+    "videoCurrentPage": "第 {page} 页"
+  },
+  "checkout": {
+    "title": "选择存款方式",
+    "subtitle": "当前为演示流程,支付与后台对接后可在此完成真实交易。",
+    "coupon": "优惠码 / 推荐码",
+    "couponPlaceholder": "输入优惠码",
+    "verify": "验证",
+    "couponOk": "已应用演示优惠",
+    "payMethod": "支付方式",
+    "payCard": "信用卡",
+    "payAtm": "ATM 转账",
+    "payAlipay": "支付宝",
+    "payWechat": "微信支付",
+    "secure": "交易安全",
+    "secureDesc": "真实环境将支持 3D Secure 等验证方式,保障卡支付安全。",
+    "payerName": "付款人姓名",
+    "payerPhone": "手机号码",
+    "submit": "提交订单",
+    "success": "演示订单已创建,已加入会员中心购买记录。"
+  },
+  "auth": {
+    "loginTitle": "登录",
+    "registerTitle": "注册",
+    "forgotTitle": "忘记密码",
+    "changePwdTitle": "修改密码",
+    "email": "邮箱",
+    "password": "密码",
+    "passwordRuleLen": "使用8到15个字符",
+    "passwordRuleCase": "同时使用大写和小写字母",
+    "passwordRuleMix": "使用数字和英文字母的组合",
+    "name": "姓名",
+    "code": "邮箱验证码",
+    "sendCode": "发送验证码",
+    "sendCodeCooldown": "{sec} 秒后可重发",
+    "country": "国家/地区",
+    "countrySearchPlaceholder": "输入名称筛选…",
+    "countryNoResults": "没有匹配的国家/地区,请换个关键词",
+    "countryRequired": "请选择国家/地区",
+    "selectCountry": "请选择",
+    "loadingCountries": "加载国家列表…",
+    "registerEmailOnlyHint": "请使用邮箱完成注册,并选择所在国家/地区。",
+    "errorApi": "请求失败,请稍后重试",
+    "oauth": "第三方登录(演示)",
+    "apple": "Apple",
+    "facebook": "Facebook",
+    "wechat": "微信",
+    "loginBtn": "登录",
+    "registerSuccessTitle": "注册成功",
+    "registerSuccessHint": "即将跳转到登录页,请使用邮箱与密码登录。",
+    "loginAfterRegister": "注册成功,请使用邮箱与密码登录。",
+    "registerBtn": "注册",
+    "resetBtn": "发送重置邮件(演示)",
+    "changeBtn": "保存新密码",
+    "toRegister": "没有账号?去注册",
+    "toLogin": "已有账号?去登录",
+    "profileHint": "注册后可完善姓名与密码等信息。",
+    "errorInvalidEmail": "请输入有效邮箱",
+    "errorCredentials": "邮箱或密码不正确(演示:任意有效邮箱可登录)",
+    "errorWeakPassword": "密码不符合下方规则",
+    "errorCode": "请输入 6 位数字验证码",
+    "forgotHint": "演示环境不会真实发邮件,点击按钮即可模拟成功。",
+    "changeHint": "演示环境仅更新本地状态,接入后端后将走安全接口。"
+  },
+  "account": {
+    "title": "会员中心",
+    "welcome": "欢迎,{name}",
+    "logout": "退出登录",
+    "walletBalance": "钱包余额",
+    "walletBalanceLoading": "钱包余额加载中...",
+    "walletBalanceError": "钱包余额获取失败",
+    "orders": "购买记录",
+    "noOrders": "暂无订单,去课程中心选购。",
+    "orderAmount": "金额",
+    "orderDate": "日期",
+    "scholarship": "奖学金收款设置",
+    "bankName": "开户银行",
+    "accountName": "开户名",
+    "accountNumber": "银行卡号",
+    "alipayId": "支付宝账号",
+    "saveScholarship": "保存设置",
+    "quizSection": "有奖知识问答",
+    "quizBelow": "累计消费满 {amount} USD 后,将开启 {days} 天倒计时,结束后即可参与有奖问答。",
+    "quizBelowRemain": "距离门槛还差 {amount} USD。",
+    "quizCountdown": "倒计时进行中,解锁时间:{date}",
+    "quizUnlocked": "已解锁!可前往有奖问答页面参与。",
+    "goQuiz": "进入有奖问答",
+    "demoTitle": "本地演示",
+    "demoHint": "后台未就绪时,可用下方按钮快速体验「解锁问答」流程。",
+    "demoUnlock": "一键演示:满足条件并跳过倒计时",
+    "loginRequired": "请先登录以查看会员中心。"
+  },
+  "quiz": {
+    "title": "有奖知识问答",
+    "locked": "尚未解锁",
+    "lockedDesc": "请在会员中心查看累计消费与倒计时规则。",
+    "intro": "共 3 题,全部答对将显示奖学金恭喜提示(演示)。",
+    "q1": "以下哪项更贴近「风险管理」的首要原则?",
+    "q1a": "重仓博取单边行情",
+    "q1b": "先定义亏损上限与仓位",
+    "q1c": "忽略止损依赖直觉",
+    "q2": "系统化交易强调?",
+    "q2a": "完全随机下单",
+    "q2b": "可重复的规则与复盘",
+    "q2c": "仅依赖消息面",
+    "q3": "课程中提倡的学习方式是?",
+    "q3a": "只看不练",
+    "q3b": "持续记录与迭代策略",
+    "q3c": "频繁更换方法不记录",
+    "submit": "提交答案",
+    "resultOk": "恭喜完成!",
+    "resultScholarship": "演示奖学金金额:{amount} USD",
+    "resultContact": "请截图并联系客服完成后续流程(演示文案)。",
+    "resultFail": "有题目未通过,可重新作答。",
+    "backAccount": "返回会员中心"
+  },
+  "legal": {
+    "termsTitle": "服务条款",
+    "privacyTitle": "隐私政策",
+    "copyrightTitle": "版权声明",
+    "termsBody": "用户应合法使用本服务,禁止用于任何违法或侵权用途。服务可能因维护等原因短暂中断;第三方链接由第三方负责,本平台不保证其内容与可用性。",
+    "privacyBody": "我们可能收集注册信息、浏览与设备数据,用于提供服务、改进产品与在征得同意的前提下进行营销分析。您可依法行使查阅与删除等权利,详见完整隐私政策(接入后台后可扩展)。",
+    "copyrightBody": "本站教学内容、标识与版面设计受法律保护。未经许可,禁止以商业目的转载、摘编或镜像。个人学习用途的合理使用请在合法范围内进行。"
+  },
+  "about": {
+    "title": "关于我们",
+    "ipTitle": "个人 IP",
+    "ipBody": "金策弘论社聚焦系统化交易教育,将多年实盘与教研经验沉淀为可复用的课程与工具。",
+    "philosophyTitle": "教学理念",
+    "philosophyBody": "先守风险,再谈收益;先建立框架,再谈天赋。我们强调记录、复盘与长期主义。",
+    "mediaTitle": "媒体报道",
+    "mediaBody": "媒体报道与合作资讯将在此更新(当前为占位内容)。"
+  },
+  "faq": {
+    "title": "常见问题",
+    "q1": "如何开始学习?",
+    "a1": "可从免费试听入手,再按需求选择实盘体系课或专题内容。",
+    "q2": "购课是否有保障?",
+    "a2": "请参阅购课保障页与退款政策说明(演示文案)。",
+    "q3": "有奖问答如何解锁?",
+    "a3": "会员累计消费达标后将开启倒计时,结束后即可参与(详见会员中心)。"
+  },
+  "guarantee": {
+    "title": "购课保障",
+    "body": "我们重视学习体验与合规交付。具体退款与换课规则将在正式运营条款中列明;当前为演示站点,不作为法律承诺。"
+  },
+  "contact": {
+    "title": "联系我们",
+    "body": "客服电话与邮箱请见页脚;商务合作请邮件联系并注明来意。"
+  }
+}

+ 26 - 2
next.config.ts

@@ -1,7 +1,31 @@
 import type { NextConfig } from "next";
+import createNextIntlPlugin from "next-intl/plugin";
+
+/** 开发时代理真实后端,避免浏览器直连局域网 IP 时的 CORS。与 .env.development 中 API_PROXY_TARGET 一致。 */
+const apiProxyTarget =
+  process.env.API_PROXY_TARGET?.replace(/\/$/, "") ?? "";
+const remittanceProxyTarget =
+  process.env.API_PROXY_TARGET_REMITTANCE?.replace(/\/$/, "") ?? "";
 
 const nextConfig: NextConfig = {
-  /* config options here */
+  async rewrites() {
+    const rewrites = [];
+    if (apiProxyTarget && /^https?:\/\//i.test(apiProxyTarget)) {
+      rewrites.push({
+        source: "/api-backend/:path*",
+        destination: `${apiProxyTarget}/:path*`,
+      });
+    }
+    if (remittanceProxyTarget && /^https?:\/\//i.test(remittanceProxyTarget)) {
+      rewrites.push({
+        source: "/api-backend-remittance/:path*",
+        destination: `${remittanceProxyTarget}/:path*`,
+      });
+    }
+    return rewrites;
+  },
 };
 
-export default nextConfig;
+const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
+
+export default withNextIntl(nextConfig);

Diferenças do arquivo suprimidas por serem muito extensas
+ 751 - 75
package-lock.json


+ 19 - 3
package.json

@@ -3,21 +3,37 @@
   "version": "0.1.0",
   "private": true,
   "scripts": {
-    "dev": "next dev",
+    "predev": "node ./scripts/predev-lock-check.mjs",
+    "dev": "next dev --webpack",
+    "dev:clean": "sh -c 'PIDS=$(lsof -ti tcp:3000); if [ -n \"$PIDS\" ]; then echo \"Killing port 3000 process: $PIDS\"; kill $PIDS; fi; next dev --webpack'",
+    "dev:reset": "sh -c 'PIDS=$(lsof -ti tcp:3000 -sTCP:LISTEN); if [ -n \"$PIDS\" ]; then echo \"Killing port 3000 process: $PIDS\"; kill $PIDS; fi; rm -f .next/dev/lock; npm run dev'",
+    "dev:restart": "npm run dev:clean",
     "build": "next build",
+    "build:test": "cross-env NEXT_PUBLIC_APP_ENV=test next build",
     "start": "next start",
-    "lint": "eslint"
+    "lint": "eslint",
+    "pm2:test": "pm2 start ecosystem.config.cjs --only jchl-test",
+    "pm2:prod": "pm2 start ecosystem.config.cjs --only jchl-prod",
+    "pm2:reload:test": "pm2 reload jchl-test",
+    "pm2:reload:prod": "pm2 reload jchl-prod",
+    "release:test": "npm run build:test && pm2 reload jchl-test",
+    "release:prod": "npm run build && pm2 reload jchl-prod"
   },
   "dependencies": {
+    "axios": "^1.15.0",
+    "clsx": "^2.1.1",
     "next": "16.2.3",
+    "next-intl": "^4.9.1",
     "react": "19.2.4",
-    "react-dom": "19.2.4"
+    "react-dom": "19.2.4",
+    "tailwind-merge": "^3.5.0"
   },
   "devDependencies": {
     "@tailwindcss/postcss": "^4",
     "@types/node": "^20",
     "@types/react": "^19",
     "@types/react-dom": "^19",
+    "cross-env": "^10.1.0",
     "eslint": "^9",
     "eslint-config-next": "16.2.3",
     "tailwindcss": "^4",

BIN
public/专题视频-状态--锁定、解锁.png


BIN
public/免费试听--状态--锁定、解锁.png


BIN
public/拆书章节视频--状态--锁定、解锁.png


BIN
public/策略报告--状态--锁定、解锁.png


BIN
public/系统课程--状态--锁定、解锁.png


+ 96 - 0
scripts/predev-lock-check.mjs

@@ -0,0 +1,96 @@
+import { execFileSync } from "node:child_process";
+import { existsSync, readFileSync, rmSync } from "node:fs";
+import { resolve } from "node:path";
+
+const lockPath = ".next/dev/lock";
+const devDirPath = ".next/dev";
+const webpackRuntimePath = ".next/dev/server/webpack-runtime.js";
+const vendorChunkPath = ".next/dev/server/vendor-chunks/next.js";
+const projectRoot = process.cwd();
+
+function run(cmd, args) {
+  try {
+    return execFileSync(cmd, args, { encoding: "utf8" }).trim();
+  } catch {
+    return "";
+  }
+}
+
+function pidExists(pid) {
+  try {
+    process.kill(pid, 0);
+    return true;
+  } catch {
+    return false;
+  }
+}
+
+function getProcessCwd(pid) {
+  const output = run("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"]);
+  const cwdLine = output
+    .split("\n")
+    .find((line) => line.startsWith("n"));
+  return cwdLine ? cwdLine.slice(1) : "";
+}
+
+function getListeningPids(port) {
+  const output = run("lsof", ["-ti", `tcp:${port}`, "-sTCP:LISTEN"]);
+  if (!output) return [];
+  return output
+    .split("\n")
+    .map((line) => Number(line.trim()))
+    .filter((v) => Number.isInteger(v) && v > 0);
+}
+
+function removeLock(reason) {
+  rmSync(lockPath, { force: true });
+  console.log(`Removed stale ${lockPath}: ${reason}`);
+}
+
+function resetDevCache(reason) {
+  rmSync(devDirPath, { force: true, recursive: true });
+  console.log(`Reset ${devDirPath}: ${reason}`);
+}
+
+if (existsSync(webpackRuntimePath) && !existsSync(vendorChunkPath)) {
+  resetDevCache("missing vendor-chunks/next.js");
+  process.exit(0);
+}
+
+if (!existsSync(lockPath)) process.exit(0);
+
+let lock;
+try {
+  lock = JSON.parse(readFileSync(lockPath, "utf8"));
+} catch {
+  removeLock("invalid JSON");
+  process.exit(0);
+}
+
+const pid = Number(lock?.pid ?? 0);
+const port = Number(lock?.port ?? 3000);
+if (!pid) {
+  removeLock("missing pid");
+  process.exit(0);
+}
+
+if (!pidExists(pid)) {
+  removeLock(`pid ${pid} not running`);
+  process.exit(0);
+}
+
+const processCwd = getProcessCwd(pid);
+if (!processCwd) {
+  removeLock(`cannot resolve cwd for pid ${pid}`);
+  process.exit(0);
+}
+
+if (resolve(processCwd) !== resolve(projectRoot)) {
+  removeLock(`pid ${pid} belongs to another directory`);
+  process.exit(0);
+}
+
+const portPids = getListeningPids(port);
+if (portPids.length > 0 && !portPids.includes(pid)) {
+  removeLock(`pid ${pid} is not listening on port ${port}`);
+}

+ 32 - 0
server.js

@@ -0,0 +1,32 @@
+/* eslint-disable @typescript-eslint/no-require-imports */
+const { createServer } = require('http');
+const { parse } = require('url');
+const next = require('next');
+ 
+const port = Number(process.env.PORT || 4000);
+const app = next({ dev: false });
+const handle = app.getRequestHandler();
+ 
+app.prepare().then(() =>
+{
+	const server = createServer((req, res) =>
+	{
+		// Be sure to pass `true` as the second argument to `url.parse`.
+		// This tells it to parse the query portion of the URL.
+		handle(req, res, parse(req.url, true));
+	});
+
+	server.on('error', (error) =>
+	{
+		if (error.code === 'EADDRINUSE')
+		{
+			const fallbackPort = port + 1;
+			console.warn(`Port ${port} is in use, retrying on ${fallbackPort}...`);
+			server.listen(fallbackPort);
+			return;
+		}
+		throw error;
+	});
+
+	server.listen(port);
+});

+ 66 - 0
src/app/[locale]/about/page.tsx

@@ -0,0 +1,66 @@
+import { setRequestLocale } from "next-intl/server";
+
+type Props = { params: Promise<{ locale: string }> };
+
+export default async function AboutPage({ params }: Props) {
+  const { locale } = await params;
+  setRequestLocale(locale);
+
+  return (
+    <div className="page-shell">
+      <section className="relative overflow-hidden rounded-3xl border border-slate-200/70 bg-gradient-to-br from-white via-slate-50 to-blue-50/70 p-8 shadow-[0_20px_50px_rgba(15,23,42,0.08)] md:p-12">
+        <div className="pointer-events-none absolute -right-14 -top-16 h-56 w-56 rounded-full bg-blue-200/30 blur-3xl" />
+        <div className="pointer-events-none absolute -bottom-20 left-1/3 h-48 w-48 rounded-full bg-indigo-200/20 blur-3xl" />
+        <p className="relative inline-flex rounded-full border border-blue-100 bg-white/80 px-4 py-1 text-xs font-semibold tracking-[0.2em] text-slate-500">
+          ABOUT US
+        </p>
+        <h1 className="relative mt-4 font-serif text-4xl font-semibold tracking-tight text-[var(--navy)] md:text-5xl">
+          关于我们
+        </h1>
+        <p className="relative mt-4 max-w-3xl text-sm leading-7 text-slate-600 md:text-base">
+          聚焦系统化交易教育,沉淀实战经验与方法论,帮助投资者建立可复用、可执行的交易体系。
+        </p>
+      </section>
+
+      <div className="mt-8 grid gap-6">
+        <section
+          id="ip"
+          className="scroll-mt-24 rounded-3xl border border-slate-200/70 bg-white p-7 shadow-[0_14px_35px_rgba(15,23,42,0.06)] md:p-9"
+        >
+          <h2 className="font-serif text-2xl font-semibold text-[var(--navy)]">个人 IP 介绍</h2>
+          <div className="mt-5 space-y-4 leading-8 text-[#c62828]">
+            <p>
+              崔宏毅,中级黄金分析师,IPA 国际金融分析师,CFA 行为金融学分析师,中金亚洲综合外汇黄金专家,
+              深耕黄金、白银、外汇、原油等领域多年,兼具实战交易与教研管理双重经验。
+            </p>
+            <p>
+              他对宏观经济与市场动向拥有独有研判体系,深耕斐波那契波数列、行情时间窗等核心技术,
+              秉持“将复杂简化为专一”的投资理念,以实战视角解读市场规律。
+            </p>
+            <div className="rounded-2xl border border-rose-100 bg-rose-50/40 px-5 py-4">
+              <p className="font-medium">从业以来,持续输出专业内容与实战方法论:</p>
+              <ul className="mt-2 list-disc space-y-1 pl-7">
+                <li>2017 年出版《原油投资翻倍篇》</li>
+                <li>2018 年出版《黄金白银投资交易实战》</li>
+                <li>2024 年出版《重新认识黄金》</li>
+              </ul>
+            </div>
+          </div>
+        </section>
+
+        <section
+          id="philosophy"
+          className="scroll-mt-24 rounded-3xl border border-slate-200/70 bg-white p-7 shadow-[0_14px_35px_rgba(15,23,42,0.06)] md:p-9"
+        >
+          <h2 className="font-serif text-2xl font-semibold text-[#c62828]">教学理念</h2>
+          <p className="mt-5 leading-8 text-[#c62828]">
+            金策弘论坛,聚焦系统化实战交易技术,将多年一线实盘经验与教研成果沉淀为可复用、可落地的课程体系与实战工具,
+            助力投资者建立稳定交易框架,实现从认知到盈利的闭环。
+          </p>
+        </section>
+
+        {/* 媒体报道模块按需求先隐藏,后续有内容再恢复 */}
+      </div>
+    </div>
+  );
+}

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

@@ -0,0 +1,104 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import { Link } from "@/i18n/navigation";
+import { useState } from "react";
+import { updateLoginPassword } from "@/lib/auth-api";
+import { isRegisterPasswordValid } from "@/lib/password-rules";
+
+export default function ChangePasswordPage() {
+  const t = useTranslations("auth");
+  const [oldPassword, setOldPassword] = useState("");
+  const [newPassword, setNewPassword] = useState("");
+  const [confirmPassword, setConfirmPassword] = useState("");
+  const [ok, setOk] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+  const [submitting, setSubmitting] = useState(false);
+
+  return (
+    <div className="auth-page-shell">
+      <h1 className="font-serif text-2xl font-semibold text-[var(--navy)]">{t("changePwdTitle")}</h1>
+      <p className="mt-2 text-sm text-[var(--muted)]">请输入原密码,并设置新密码。</p>
+      <form
+        onSubmit={async (e) => {
+          e.preventDefault();
+          setOk(false);
+          setError(null);
+          if (newPassword !== confirmPassword) {
+            setError("两次输入的新密码不一致");
+            return;
+          }
+          if (!isRegisterPasswordValid(newPassword)) {
+            setError(t("errorWeakPassword"));
+            return;
+          }
+          try {
+            setSubmitting(true);
+            await updateLoginPassword({ oldPassword, newPassword });
+            setOk(true);
+            setOldPassword("");
+            setNewPassword("");
+            setConfirmPassword("");
+          } catch (err) {
+            const e2 = err as Error;
+            setError(e2.message || "修改密码失败,请稍后重试。");
+          } finally {
+            setSubmitting(false);
+          }
+        }}
+        className="mt-8 space-y-4"
+      >
+        <div>
+          <label className="text-sm font-medium">原密码</label>
+          <input
+            type="password"
+            required
+            value={oldPassword}
+            onChange={(e) => setOldPassword(e.target.value)}
+            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">新密码</label>
+          <input
+            type="password"
+            required
+            value={newPassword}
+            onChange={(e) => setNewPassword(e.target.value)}
+            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">确认新密码</label>
+          <input
+            type="password"
+            required
+            value={confirmPassword}
+            onChange={(e) => setConfirmPassword(e.target.value)}
+            className="mt-1 w-full rounded-lg border border-[var(--border)] px-3 py-2 text-sm"
+          />
+        </div>
+        <button
+          type="submit"
+          disabled={submitting}
+          className="w-full rounded-full bg-[var(--navy)] py-3 text-sm font-semibold text-white"
+        >
+          {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}
+      <p className="mt-8 text-center text-sm">
+        <Link href="/account" className="text-[var(--accent)] hover:underline">
+          返回会员中心
+        </Link>
+      </p>
+    </div>
+  );
+}

+ 194 - 0
src/app/[locale]/account/orders/page.tsx

@@ -0,0 +1,194 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useTranslations } from "next-intl";
+import { Link } from "@/i18n/navigation";
+import { useAuth } from "@/providers/auth-provider";
+import {
+  cancelOrder,
+  fetchOrderList,
+  getOrderStatusLabel,
+  type OrderRecord,
+} from "@/lib/order-api";
+
+const PAGE_SIZE = 10;
+
+export default function AccountOrdersPage() {
+  const t = useTranslations("account");
+  const { user, isReady } = useAuth();
+  const [orders, setOrders] = useState<OrderRecord[]>([]);
+  const [loading, setLoading] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+  const [page, setPage] = useState(1);
+  const [total, setTotal] = useState(0);
+  const [cancelTarget, setCancelTarget] = useState<OrderRecord | null>(null);
+  const [cancelLoading, setCancelLoading] = useState(false);
+
+  useEffect(() => {
+    if (!user) return;
+    let cancelled = false;
+    async function loadOrders() {
+      setLoading(true);
+      setError(null);
+      try {
+        const res = await fetchOrderList({ current: page, row: PAGE_SIZE });
+        if (cancelled) return;
+        setOrders(res.list);
+        setTotal(res.page.total);
+      } catch (e) {
+        if (cancelled) return;
+        const err = e as Error;
+        setError(err.message || "订单加载失败,请稍后重试。");
+        setOrders([]);
+      } finally {
+        if (!cancelled) setLoading(false);
+      }
+    }
+    void loadOrders();
+    return () => {
+      cancelled = true;
+    };
+  }, [user, page]);
+
+  if (!isReady) {
+    return <div className="page-shell py-16 text-center text-[var(--muted)]">…</div>;
+  }
+
+  if (!user) {
+    return (
+      <div className="page-shell py-16 text-center text-[var(--muted)]">
+        <p>{t("loginRequired")}</p>
+        <Link
+          href="/auth/login"
+          className="mt-4 inline-block rounded-full bg-[var(--navy)] px-6 py-2 text-sm text-white"
+        >
+          登录
+        </Link>
+      </div>
+    );
+  }
+
+  const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
+
+  async function handleConfirmCancel() {
+    if (!cancelTarget) return;
+    try {
+      setCancelLoading(true);
+      await cancelOrder(cancelTarget.id);
+      const res = await fetchOrderList({ current: page, row: PAGE_SIZE });
+      setOrders(res.list);
+      setTotal(res.page.total);
+      setCancelTarget(null);
+    } catch (e) {
+      const err = e as Error;
+      setError(err.message || "取消订单失败,请稍后重试。");
+    } finally {
+      setCancelLoading(false);
+    }
+  }
+
+  return (
+    <div className="page-shell page-shell-wide">
+      <div className="flex items-center justify-between">
+        <h1 className="font-serif text-2xl font-semibold text-[var(--navy)]">全部订单</h1>
+        <Link
+          href="/account"
+          className="rounded-full border border-[var(--border)] px-4 py-1.5 text-xs text-[var(--navy)]"
+        >
+          返回用户中心
+        </Link>
+      </div>
+
+      {loading ? <p className="mt-4 text-sm text-[var(--muted)]">订单加载中…</p> : null}
+      {error ? (
+        <p className="mt-4 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
+          {error}
+        </p>
+      ) : null}
+
+      {!loading && !error && orders.length === 0 ? (
+        <p className="mt-4 text-sm text-[var(--muted)]">{t("noOrders")}</p>
+      ) : null}
+
+      {!loading && !error && orders.length > 0 ? (
+        <ul className="mt-4 divide-y divide-[var(--border)] rounded-xl border border-[var(--border)] bg-[var(--card)]">
+          {orders.map((o) => (
+            <li key={o.serial} className="px-4 py-3 text-sm">
+              <div className="flex flex-wrap items-center justify-between gap-2">
+                <span className="font-medium text-[var(--navy)]">{o.details}</span>
+                <div className="flex items-center gap-2">
+                  <span className="font-semibold text-[var(--navy)]">${o.amount}</span>
+                  {o.status === 1 ? (
+                    <button
+                      type="button"
+                      onClick={() => setCancelTarget(o)}
+                      className="rounded-full border border-rose-200 bg-rose-50 px-2.5 py-0.5 text-xs text-rose-700 hover:bg-rose-100"
+                    >
+                      取消订单
+                    </button>
+                  ) : null}
+                </div>
+              </div>
+              <div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-[var(--muted)]">
+                <span>流水号: {o.serial}</span>
+                <span>状态: {getOrderStatusLabel(o.status)}</span>
+                <span>购买时间: {o.addTime || "-"}</span>
+                <span>支付时间: {o.payTime || "-"}</span>
+              </div>
+            </li>
+          ))}
+        </ul>
+      ) : null}
+
+      <div className="mt-5 flex items-center justify-center gap-3">
+        <button
+          type="button"
+          onClick={() => setPage((p) => Math.max(1, p - 1))}
+          disabled={page <= 1 || loading}
+          className="rounded-lg border border-[var(--border)] px-3 py-1.5 text-sm disabled:opacity-50"
+        >
+          上一页
+        </button>
+        <span className="text-sm text-[var(--muted)]">
+          第 {page} / {totalPages} 页
+        </span>
+        <button
+          type="button"
+          onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
+          disabled={page >= totalPages || loading}
+          className="rounded-lg border border-[var(--border)] px-3 py-1.5 text-sm disabled:opacity-50"
+        >
+          下一页
+        </button>
+      </div>
+
+      {cancelTarget ? (
+        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4">
+          <div className="w-full max-w-sm rounded-2xl border border-[var(--border)] bg-[var(--card)] p-5 shadow-xl">
+            <p className="text-base font-semibold text-[var(--navy)]">确认取消订单?</p>
+            <p className="mt-2 text-sm text-[var(--muted)]">
+              订单号:{cancelTarget.serial}
+            </p>
+            <div className="mt-5 flex justify-end gap-2">
+              <button
+                type="button"
+                onClick={() => setCancelTarget(null)}
+                className="rounded-lg border border-[var(--border)] px-4 py-2 text-sm"
+              >
+                关闭
+              </button>
+              <button
+                type="button"
+                onClick={handleConfirmCancel}
+                disabled={cancelLoading}
+                className="rounded-lg bg-rose-600 px-4 py-2 text-sm text-white disabled:opacity-60"
+              >
+                {cancelLoading ? "处理中..." : "确定取消"}
+              </button>
+            </div>
+          </div>
+        </div>
+      ) : null}
+    </div>
+  );
+}

+ 405 - 0
src/app/[locale]/account/page.tsx

@@ -0,0 +1,405 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import { Link } from "@/i18n/navigation";
+import { useAuth } from "@/providers/auth-provider";
+import { useEffect, useState } from "react";
+import { fetchWalletBalance } from "@/lib/account-api";
+import {
+  cancelOrder,
+  fetchOrderList,
+  getOrderStatusLabel,
+  type OrderRecord,
+} from "@/lib/order-api";
+import {
+  fetchPurchasedCourses,
+  type PurchasedCourse,
+} from "@/lib/goods-order-api";
+import {
+  fetchWithdrawalList,
+  getWithdrawalStatusLabel,
+  type WithdrawalRecord,
+} from "@/lib/withdrawal-api";
+
+export default function AccountPage() {
+  const t = useTranslations("account");
+  const { user, isReady } = useAuth();
+  const [walletBalance, setWalletBalance] = useState<number | null>(null);
+  const [walletLoading, setWalletLoading] = useState(false);
+  const [walletError, setWalletError] = useState<string | null>(null);
+  const [purchasedCourses, setPurchasedCourses] = useState<PurchasedCourse[]>([]);
+  const [purchasedLoading, setPurchasedLoading] = useState(false);
+  const [purchasedError, setPurchasedError] = useState<string | null>(null);
+  const [orders, setOrders] = useState<OrderRecord[]>([]);
+  const [ordersLoading, setOrdersLoading] = useState(false);
+  const [ordersError, setOrdersError] = useState<string | null>(null);
+  const [withdrawals, setWithdrawals] = useState<WithdrawalRecord[]>([]);
+  const [withdrawalsLoading, setWithdrawalsLoading] = useState(false);
+  const [withdrawalsError, setWithdrawalsError] = useState<string | null>(null);
+  const [cancelTarget, setCancelTarget] = useState<OrderRecord | null>(null);
+  const [cancelLoading, setCancelLoading] = useState(false);
+
+  useEffect(() => {
+    if (!user) return;
+    let cancelled = false;
+    async function loadWalletBalance() {
+      setWalletLoading(true);
+      setWalletError(null);
+      try {
+        const balance = await fetchWalletBalance();
+        if (cancelled) return;
+        setWalletBalance(balance);
+      } catch (error) {
+        if (cancelled) return;
+        const e = error as Error;
+        setWalletError(e.message || "钱包余额加载失败,请稍后重试。");
+        setWalletBalance(null);
+      } finally {
+        if (!cancelled) setWalletLoading(false);
+      }
+    }
+    void loadWalletBalance();
+    return () => {
+      cancelled = true;
+    };
+  }, [user]);
+
+  useEffect(() => {
+    if (!user) return;
+    let cancelled = false;
+    async function loadWithdrawals() {
+      setWithdrawalsLoading(true);
+      setWithdrawalsError(null);
+      try {
+        const list = await fetchWithdrawalList();
+        if (cancelled) return;
+        setWithdrawals(list);
+      } catch (error) {
+        if (cancelled) return;
+        const e = error as Error;
+        setWithdrawalsError(e.message || "取款记录加载失败,请稍后重试。");
+        setWithdrawals([]);
+      } finally {
+        if (!cancelled) setWithdrawalsLoading(false);
+      }
+    }
+    void loadWithdrawals();
+    return () => {
+      cancelled = true;
+    };
+  }, [user]);
+
+  useEffect(() => {
+    if (!user) return;
+    let cancelled = false;
+    async function loadOrders() {
+      setOrdersLoading(true);
+      setOrdersError(null);
+      try {
+        const { list } = await fetchOrderList({ current: 1, row: 10 });
+        if (cancelled) return;
+        setOrders(list);
+      } catch (error) {
+        if (cancelled) return;
+        const e = error as Error;
+        setOrdersError(e.message || "订单加载失败,请稍后重试。");
+        setOrders([]);
+      } finally {
+        if (!cancelled) setOrdersLoading(false);
+      }
+    }
+    void loadOrders();
+    return () => {
+      cancelled = true;
+    };
+  }, [user]);
+
+  useEffect(() => {
+    if (!user) return;
+    let cancelled = false;
+    async function loadPurchasedCourses() {
+      setPurchasedLoading(true);
+      setPurchasedError(null);
+      try {
+        const list = await fetchPurchasedCourses();
+        if (cancelled) return;
+        setPurchasedCourses(list);
+      } catch (error) {
+        if (cancelled) return;
+        const e = error as Error;
+        setPurchasedError(e.message || "已购买课程加载失败,请稍后重试。");
+        setPurchasedCourses([]);
+      } finally {
+        if (!cancelled) setPurchasedLoading(false);
+      }
+    }
+    void loadPurchasedCourses();
+    return () => {
+      cancelled = true;
+    };
+  }, [user]);
+
+  async function reloadOrders() {
+    const { list } = await fetchOrderList({ current: 1, row: 10 });
+    setOrders(list);
+  }
+
+  async function handleConfirmCancel() {
+    if (!cancelTarget) return;
+    try {
+      setCancelLoading(true);
+      await cancelOrder(cancelTarget.id);
+      await reloadOrders();
+      setCancelTarget(null);
+    } catch (error) {
+      const e = error as Error;
+      setOrdersError(e.message || "取消订单失败,请稍后重试。");
+    } finally {
+      setCancelLoading(false);
+    }
+  }
+
+  if (!isReady) {
+    return (
+      <div className="page-shell py-16 text-center text-[var(--muted)]">
+        …
+      </div>
+    );
+  }
+
+  if (!user) {
+    return (
+      <div className="page-shell py-16 md:py-20">
+        <div className="mx-auto max-w-md rounded-2xl border border-[var(--border)] bg-[var(--card)] px-8 py-10 text-center shadow-card">
+          <p className="text-[var(--muted)]">{t("loginRequired")}</p>
+          <Link
+            href="/auth/login"
+            className="mt-6 inline-block rounded-full bg-[var(--navy)] px-8 py-3 text-sm font-semibold text-white"
+          >
+            登录
+          </Link>
+        </div>
+      </div>
+    );
+  }
+
+  return (
+    <div className="page-shell">
+      <div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
+        <h1 className="font-serif text-3xl font-semibold text-[var(--navy)]">
+          {t("welcome", { name: user.name })}
+        </h1>
+        <div className="self-start rounded-full border border-[var(--border)] px-4 py-2 text-sm text-[var(--navy)]">
+          {walletLoading
+            ? t("walletBalanceLoading")
+            : walletError
+              ? t("walletBalanceError")
+              : `${t("walletBalance")}: $${(walletBalance ?? 0).toFixed(2)}`}
+        </div>
+      </div>
+
+      <section className="mt-10">
+        <div className="flex items-center justify-between">
+          <h2 className="font-serif text-lg font-semibold text-[var(--navy)]">已购买课程</h2>
+          <Link
+            href="/account/purchased-courses"
+            className="rounded-full border border-[var(--border)] px-4 py-1.5 text-xs font-medium text-[var(--navy)] hover:bg-[var(--card)]"
+          >
+            全部课程
+          </Link>
+        </div>
+        {purchasedLoading ? (
+          <p className="mt-3 text-sm text-[var(--muted)]">课程加载中…</p>
+        ) : null}
+        {purchasedError ? (
+          <p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
+            {purchasedError}
+          </p>
+        ) : null}
+        {!purchasedLoading && !purchasedError && purchasedCourses.length === 0 ? (
+          <p className="mt-3 text-sm text-[var(--muted)]">暂无已购买课程。</p>
+        ) : (
+          !purchasedLoading &&
+          !purchasedError && (
+            <ul className="mt-4 grid gap-3 md:grid-cols-3">
+              {purchasedCourses.slice(0, 3).map((c) => (
+                <li
+                  key={c.id}
+                  className="overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--card)] shadow-sm"
+                >
+                  <div className="h-36 w-full bg-slate-100">
+                    {c.coverUrl ? (
+                      // eslint-disable-next-line @next/next/no-img-element
+                      <img src={c.coverUrl} alt={c.title} className="h-full w-full object-cover" />
+                    ) : null}
+                  </div>
+                  <div className="px-4 py-3 text-center">
+                    <p className="text-base font-semibold text-[var(--navy)]">{c.title}</p>
+                    <p
+                      className="mt-1 text-sm text-[var(--muted)]"
+                      style={{
+                        display: "-webkit-box",
+                        WebkitLineClamp: 2,
+                        WebkitBoxOrient: "vertical",
+                        overflow: "hidden",
+                      }}
+                    >
+                      {c.introduction || "-"}
+                    </p>
+                    <Link
+                      href={`/courses/${c.goodsId}`}
+                      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"
+                    >
+                      查看课程
+                    </Link>
+                  </div>
+                </li>
+              ))}
+            </ul>
+          )
+        )}
+      </section>
+
+      <section className="mt-10">
+        <div className="flex items-center justify-between">
+          <h2 className="font-serif text-lg font-semibold text-[var(--navy)]">{t("orders")}</h2>
+          <Link
+            href="/account/orders"
+            className="rounded-full border border-[var(--border)] px-4 py-1.5 text-xs font-medium text-[var(--navy)] hover:bg-[var(--card)]"
+          >
+            全部订单
+          </Link>
+        </div>
+        {ordersLoading ? (
+          <p className="mt-3 text-sm text-[var(--muted)]">订单加载中…</p>
+        ) : null}
+        {ordersError ? (
+          <p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
+            {ordersError}
+          </p>
+        ) : null}
+        {!ordersLoading && !ordersError && orders.length === 0 ? (
+          <p className="mt-3 text-sm text-[var(--muted)]">{t("noOrders")}</p>
+        ) : (
+          !ordersLoading &&
+          !ordersError && (
+            <ul className="mt-4 space-y-3">
+              {orders.slice(0, 3).map((o) => (
+                <li
+                  key={o.serial}
+                  className="rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 shadow-sm"
+                >
+                  <div className="flex flex-wrap items-center justify-between gap-2">
+                    <span className="font-medium text-[var(--navy)]">{o.details}</span>
+                    <div className="flex items-center gap-2">
+                      <span className="rounded-full bg-slate-100 px-2.5 py-0.5 text-xs text-[var(--navy)]">
+                        {getOrderStatusLabel(o.status)}
+                      </span>
+                      <span className="rounded-full border border-[var(--border)] px-2.5 py-0.5 text-xs font-semibold text-[var(--navy)]">
+                        ${o.amount}
+                      </span>
+                      {o.status === 1 ? (
+                        <button
+                          type="button"
+                          onClick={() => setCancelTarget(o)}
+                          className="rounded-full border border-rose-200 bg-rose-50 px-2.5 py-0.5 text-xs text-rose-700 hover:bg-rose-100"
+                        >
+                          取消订单
+                        </button>
+                      ) : null}
+                    </div>
+                  </div>
+                  <div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-[var(--muted)]">
+                    <span>流水号: {o.serial}</span>
+                    <span>购买时间: {o.addTime || "-"}</span>
+                    <span>支付时间: {o.payTime || "-"}</span>
+                  </div>
+                </li>
+              ))}
+            </ul>
+          )
+        )}
+      </section>
+
+      <section className="mt-10">
+        <div className="flex items-center justify-between">
+          <h2 className="font-serif text-lg font-semibold text-[var(--navy)]">取款记录</h2>
+          <Link
+            href="/account/withdrawals"
+            className="rounded-full border border-[var(--border)] px-4 py-1.5 text-xs font-medium text-[var(--navy)] hover:bg-[var(--card)]"
+          >
+            全部记录
+          </Link>
+        </div>
+        {withdrawalsLoading ? (
+          <p className="mt-3 text-sm text-[var(--muted)]">取款记录加载中…</p>
+        ) : null}
+        {withdrawalsError ? (
+          <p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
+            {withdrawalsError}
+          </p>
+        ) : null}
+        {!withdrawalsLoading && !withdrawalsError && withdrawals.length === 0 ? (
+          <p className="mt-3 text-sm text-[var(--muted)]">暂无取款记录。</p>
+        ) : (
+          !withdrawalsLoading &&
+          !withdrawalsError && (
+            <ul className="mt-4 space-y-3">
+              {withdrawals.slice(0, 3).map((w) => (
+                <li
+                  key={w.serial}
+                  className="rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 shadow-sm"
+                >
+                  <div className="flex flex-wrap items-center justify-between gap-2">
+                    <span className="font-medium text-[var(--navy)]">{w.details}</span>
+                    <div className="flex items-center gap-2">
+                      <span className="rounded-full bg-slate-100 px-2.5 py-0.5 text-xs text-[var(--navy)]">
+                        {getWithdrawalStatusLabel(w.status)}
+                      </span>
+                      <span className="rounded-full border border-[var(--border)] px-2.5 py-0.5 text-xs font-semibold text-[var(--navy)]">
+                        ${w.amount}
+                      </span>
+                    </div>
+                  </div>
+                  <div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-[var(--muted)]">
+                    <span>流水号: {w.serial}</span>
+                    <span>申请时间: {w.addTime || "-"}</span>
+                    <span>到账时间: {w.payTime || "-"}</span>
+                  </div>
+                </li>
+              ))}
+            </ul>
+          )
+        )}
+      </section>
+
+      {cancelTarget ? (
+        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4">
+          <div className="w-full max-w-sm rounded-2xl border border-[var(--border)] bg-[var(--card)] p-5 shadow-xl">
+            <p className="text-base font-semibold text-[var(--navy)]">确认取消订单?</p>
+            <p className="mt-2 text-sm text-[var(--muted)]">
+              订单号:{cancelTarget.serial}
+            </p>
+            <div className="mt-5 flex justify-end gap-2">
+              <button
+                type="button"
+                onClick={() => setCancelTarget(null)}
+                className="rounded-lg border border-[var(--border)] px-4 py-2 text-sm"
+              >
+                关闭
+              </button>
+              <button
+                type="button"
+                onClick={handleConfirmCancel}
+                disabled={cancelLoading}
+                className="rounded-lg bg-rose-600 px-4 py-2 text-sm text-white disabled:opacity-60"
+              >
+                {cancelLoading ? "处理中..." : "确定取消"}
+              </button>
+            </div>
+          </div>
+        </div>
+      ) : null}
+    </div>
+  );
+}

+ 259 - 0
src/app/[locale]/account/purchased-courses/page.tsx

@@ -0,0 +1,259 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { Link } from "@/i18n/navigation";
+import { useAuth } from "@/providers/auth-provider";
+import {
+  fetchPurchasedCourses,
+  type PurchasedCourse,
+} from "@/lib/goods-order-api";
+import {
+  fetchRewardQuestion,
+  submitRewardQuestionAnswer,
+  type RewardQuestion,
+} from "@/lib/reward-api";
+
+export default function PurchasedCoursesPage() {
+  const { user, isReady } = useAuth();
+  const [courses, setCourses] = useState<PurchasedCourse[]>([]);
+  const [loading, setLoading] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+  const [rewardQuestion, setRewardQuestion] = useState<RewardQuestion | null>(null);
+  const [showRewardConfirm, setShowRewardConfirm] = useState(false);
+  const [showRewardAnswer, setShowRewardAnswer] = useState(false);
+  const [showRewardThanks, setShowRewardThanks] = useState(false);
+  const [rewardSubmitting, setRewardSubmitting] = useState(false);
+  const [rewardError, setRewardError] = useState<string | null>(null);
+
+  useEffect(() => {
+    if (!user) return;
+    let cancelled = false;
+    async function loadCourses() {
+      setLoading(true);
+      setError(null);
+      try {
+        const list = await fetchPurchasedCourses({ current: 1, row: 100 });
+        if (cancelled) return;
+        setCourses(list);
+      } catch (e) {
+        if (cancelled) return;
+        const err = e as Error;
+        setError(err.message || "已购买课程加载失败,请稍后重试。");
+        setCourses([]);
+      } finally {
+        if (!cancelled) setLoading(false);
+      }
+    }
+    void loadCourses();
+    return () => {
+      cancelled = true;
+    };
+  }, [user]);
+
+  useEffect(() => {
+    if (!showRewardThanks) return;
+    const timer = window.setTimeout(() => {
+      setShowRewardThanks(false);
+    }, 3000);
+    return () => {
+      window.clearTimeout(timer);
+    };
+  }, [showRewardThanks]);
+
+  useEffect(() => {
+    if (!user) return;
+    let cancelled = false;
+    async function loadRewardQuestion() {
+      try {
+        const question = await fetchRewardQuestion();
+        if (cancelled) return;
+        setRewardQuestion(question);
+        if (question.type === 1 && question.questionId && question.question) {
+          setShowRewardConfirm(true);
+          setRewardError(null);
+        }
+      } catch {
+        if (cancelled) return;
+      }
+    }
+    void loadRewardQuestion();
+    return () => {
+      cancelled = true;
+    };
+  }, [user]);
+
+  async function handleSubmitRewardAnswer(customAnswer: 0 | 1) {
+    if (!rewardQuestion?.questionId) return;
+    try {
+      setRewardSubmitting(true);
+      setRewardError(null);
+      await submitRewardQuestionAnswer({
+        questionId: rewardQuestion.questionId,
+        customAnswer,
+      });
+      setShowRewardAnswer(false);
+      setShowRewardThanks(true);
+    } catch (e) {
+      const err = e as Error;
+      setRewardError(err.message || "答题提交失败,请稍后重试。");
+    } finally {
+      setRewardSubmitting(false);
+    }
+  }
+
+  if (!isReady) {
+    return <div className="page-shell py-16 text-center text-[var(--muted)]">…</div>;
+  }
+
+  if (!user) {
+    return (
+      <div className="page-shell py-16 text-center text-[var(--muted)]">
+        <p>请先登录后查看已购买课程。</p>
+        <Link
+          href="/auth/login"
+          className="mt-4 inline-block rounded-full bg-[var(--navy)] px-6 py-2 text-sm text-white"
+        >
+          登录
+        </Link>
+      </div>
+    );
+  }
+
+  return (
+    <div className="page-shell page-shell-wide">
+      <div className="flex items-center justify-between">
+        <h1 className="font-serif text-2xl font-semibold text-[var(--navy)]">已购买课程</h1>
+        <Link
+          href="/account"
+          className="rounded-full border border-[var(--border)] px-4 py-1.5 text-xs text-[var(--navy)]"
+        >
+          返回用户中心
+        </Link>
+      </div>
+
+      {loading ? <p className="mt-4 text-sm text-[var(--muted)]">课程加载中…</p> : null}
+      {error ? (
+        <p className="mt-4 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
+          {error}
+        </p>
+      ) : null}
+      {!loading && !error && courses.length === 0 ? (
+        <p className="mt-4 text-sm text-[var(--muted)]">暂无已购买课程。</p>
+      ) : null}
+
+      {!loading && !error && courses.length > 0 ? (
+        <ul className="mt-4 grid gap-3 md:grid-cols-3">
+          {courses.map((c) => (
+            <li
+              key={c.id}
+              className="overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--card)] shadow-sm"
+            >
+              <div className="h-36 w-full bg-slate-100">
+                {c.coverUrl ? (
+                  // eslint-disable-next-line @next/next/no-img-element
+                  <img src={c.coverUrl} alt={c.title} className="h-full w-full object-cover" />
+                ) : null}
+              </div>
+              <div className="px-4 py-3 text-center">
+                <p className="text-base font-semibold text-[var(--navy)]">{c.title}</p>
+                <p
+                  className="mt-1 text-sm text-[var(--muted)]"
+                  style={{
+                    display: "-webkit-box",
+                    WebkitLineClamp: 2,
+                    WebkitBoxOrient: "vertical",
+                    overflow: "hidden",
+                  }}
+                >
+                  {c.introduction || "-"}
+                </p>
+                <Link
+                  href={`/courses/${c.goodsId}`}
+                  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"
+                >
+                  查看课程
+                </Link>
+              </div>
+            </li>
+          ))}
+        </ul>
+      ) : null}
+
+      {showRewardConfirm ? (
+        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4">
+          <div className="w-full max-w-sm rounded-2xl border border-[var(--border)] bg-[var(--card)] p-5 shadow-xl">
+            <p className="text-base font-semibold text-[var(--navy)]">有奖问答</p>
+            <p className="mt-2 text-sm text-[var(--muted)]">是否参加有奖问答?</p>
+            <div className="mt-5 flex justify-end gap-2">
+              <button
+                type="button"
+                onClick={() => setShowRewardConfirm(false)}
+                className="rounded-lg border border-[var(--border)] px-4 py-2 text-sm"
+              >
+                否
+              </button>
+              <button
+                type="button"
+                onClick={() => {
+                  setShowRewardConfirm(false);
+                  setShowRewardAnswer(true);
+                }}
+                className="rounded-lg bg-[var(--navy)] px-4 py-2 text-sm text-white"
+              >
+                是
+              </button>
+            </div>
+          </div>
+        </div>
+      ) : null}
+
+      {showRewardAnswer && rewardQuestion ? (
+        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4">
+          <div className="w-full max-w-lg rounded-2xl border border-[var(--border)] bg-[var(--card)] p-5 shadow-xl">
+            <p className="text-base font-semibold text-[var(--navy)]">有奖问答</p>
+            <p className="mt-2 text-sm text-[var(--muted)]">{rewardQuestion.question}</p>
+            {rewardError ? (
+              <p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
+                {rewardError}
+              </p>
+            ) : null}
+            <div className="mt-5 flex flex-wrap justify-end gap-2">
+              <button
+                type="button"
+                onClick={() => setShowRewardAnswer(false)}
+                disabled={rewardSubmitting}
+                className="rounded-lg border border-[var(--border)] px-4 py-2 text-sm disabled:opacity-60"
+              >
+                关闭
+              </button>
+              <button
+                type="button"
+                onClick={() => void handleSubmitRewardAnswer(0)}
+                disabled={rewardSubmitting}
+                className="rounded-lg border border-rose-200 bg-rose-50 px-4 py-2 text-sm text-rose-700 disabled:opacity-60"
+              >
+                选错
+              </button>
+              <button
+                type="button"
+                onClick={() => void handleSubmitRewardAnswer(1)}
+                disabled={rewardSubmitting}
+                className="rounded-lg bg-emerald-600 px-4 py-2 text-sm text-white disabled:opacity-60"
+              >
+                选对
+              </button>
+            </div>
+          </div>
+        </div>
+      ) : null}
+
+      {showRewardThanks ? (
+        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4">
+          <div className="w-full max-w-sm rounded-2xl border border-[var(--border)] bg-[var(--card)] p-5 text-center shadow-xl">
+            <p className="text-base font-semibold text-[var(--navy)]">感谢参与</p>
+          </div>
+        </div>
+      ) : null}
+    </div>
+  );
+}

+ 870 - 0
src/app/[locale]/account/withdraw-apply/page.tsx

@@ -0,0 +1,870 @@
+"use client";
+
+import { Link } from "@/i18n/navigation";
+import { useEffect, useMemo, useState } from "react";
+import {
+  fetchSavedWithdrawAccounts,
+  fetchWithdrawBankOptions,
+  fetchWithdrawChannels,
+  submitWithdrawApply,
+  type SavedWithdrawAccount,
+  type WithdrawBankOption,
+  type WithdrawChannel,
+} from "@/lib/withdrawal-api";
+
+function channelGroupLabel(channel: WithdrawChannel): string {
+  const type = channel.type;
+  const code = (channel.code || "").toUpperCase();
+  const name = `${channel.name || ""} ${channel.enName || ""}`.toUpperCase();
+  const aliHint = code.includes("ALI") || code.includes("ALIPAY") || name.includes("ALIPAY");
+
+  if (type === "BANK_TELEGRAPHIC") return "国际转账";
+  if (type === "BANK") return "网银支付";
+  if (type === "DIGITAL_CURRENCY") return "数字货币";
+  if (type === "CHANNEL_TYPE_WALLET") return "电子钱包";
+  if (type === "CHANNEL_TYPE_CARD") return "信用卡";
+  if (type === "CHANNEL_TYPE_ALI_WALLET" || aliHint) return "支付宝";
+  if (type === "UCARD_WALLET") return "电子卡";
+  return "其他";
+}
+
+function groupOrder(label: string): number {
+  if (label === "数字货币") return 1;
+  if (label === "网银支付") return 2;
+  if (label === "国际转账") return 3;
+  if (label === "电子钱包") return 4;
+  if (label === "电子卡") return 5;
+  if (label === "支付宝") return 6;
+  return 99;
+}
+
+function formatAmountRange(item: WithdrawChannel): string {
+  const min = item.minAmount || 0;
+  const max = item.maxAmount > 0 ? item.maxAmount : "-";
+  return `$${min} - $${max} ${item.currency || "USD"}`;
+}
+
+function formatFee(item: WithdrawChannel): string {
+  if (item.feeType === 1) return `${item.free ?? 0}%`;
+  if (item.feeType === 2) return `$${item.feeAmount ?? 0}`;
+  if (item.free !== null && item.free !== undefined) return `${item.free}%`;
+  return "0%";
+}
+
+function sanitizeHtml(input: string): string {
+  if (!input) return "";
+  return input
+    .replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, "")
+    .replace(/\son\w+="[^"]*"/gi, "")
+    .replace(/\son\w+='[^']*'/gi, "");
+}
+
+function isWalletType(type: string): boolean {
+  return type === "CHANNEL_TYPE_WALLET" || type === "CHANNEL_TYPE_ALI_WALLET";
+}
+
+function isBankType(type: string): boolean {
+  return type === "BANK";
+}
+
+function isCardType(type: string): boolean {
+  return type === "CHANNEL_TYPE_CARD";
+}
+
+function needsSavedAccount(type: string): boolean {
+  return (
+    type === "BANK" ||
+    type === "BANK_TELEGRAPHIC" ||
+    type === "CHANNEL_TYPE_CARD" ||
+    type === "DIGITAL_CURRENCY"
+  );
+}
+
+function savedAccountType(type: string): number | null {
+  if (type === "BANK") return 1;
+  if (type === "BANK_TELEGRAPHIC") return 2;
+  if (type === "CHANNEL_TYPE_CARD") return 3;
+  if (type === "DIGITAL_CURRENCY") return 4;
+  return null;
+}
+
+export default function WithdrawApplyPage() {
+  const [channels, setChannels] = useState<WithdrawChannel[]>([]);
+  const [channelsLoading, setChannelsLoading] = useState(false);
+  const [channelsError, setChannelsError] = useState<string | null>(null);
+  const [savedAccountsError, setSavedAccountsError] = useState<string | null>(null);
+
+  const [savedAccounts, setSavedAccounts] = useState<SavedWithdrawAccount[]>([]);
+  const [bankOptions, setBankOptions] = useState<WithdrawBankOption[]>([]);
+
+  const [selectedChannelId, setSelectedChannelId] = useState("");
+  const [selectedSavedId, setSelectedSavedId] = useState("");
+  const [selectedBankCode, setSelectedBankCode] = useState("");
+  const [address, setAddress] = useState("");
+  const [amount, setAmount] = useState("");
+  const [agree, setAgree] = useState(false);
+  const [agreeExtra, setAgreeExtra] = useState(false);
+  const [agencyNo, setAgencyNo] = useState("");
+  const [cpf, setCpf] = useState("");
+  const [bankUnameInput, setBankUnameInput] = useState("");
+  const [bankCardNumInput, setBankCardNumInput] = useState("");
+  const [bankNameInput, setBankNameInput] = useState("");
+  const [bankBranchNameInput, setBankBranchNameInput] = useState("");
+  const [swiftCodeInput, setSwiftCodeInput] = useState("");
+  const [customBankCodeInput, setCustomBankCodeInput] = useState("");
+  const [bankAddrInput, setBankAddrInput] = useState("");
+  const [telegraphicCurrency, setTelegraphicCurrency] = useState("USD");
+  const [cardUnameInput, setCardUnameInput] = useState("");
+  const [cardNumInput, setCardNumInput] = useState("");
+  const [cardCvvInput, setCardCvvInput] = useState("");
+  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 selectedChannel = useMemo(
+    () => channels.find((item) => item.id === selectedChannelId) ?? null,
+    [channels, selectedChannelId],
+  );
+
+  const selectedSavedAccount = useMemo(
+    () => savedAccounts.find((item) => item.id === selectedSavedId) ?? null,
+    [savedAccounts, selectedSavedId],
+  );
+
+  const filteredSavedAccounts = useMemo(() => {
+    if (!selectedChannel) return [];
+    const type = savedAccountType(selectedChannel.type);
+    if (type === null) return [];
+    return savedAccounts.filter((item) => item.type === type);
+  }, [savedAccounts, selectedChannel]);
+
+  const shouldRequireSavedAccount = Boolean(selectedChannel && needsSavedAccount(selectedChannel.type));
+  const shouldShowSavedAccountSelector = shouldRequireSavedAccount && filteredSavedAccounts.length > 0;
+  const isBankTelegraphic = selectedChannel?.type === "BANK_TELEGRAPHIC";
+  const needCpf = selectedChannel?.code === "PAY_RETAILER_REMIT_PAY_KEY_BRW";
+
+  useEffect(() => {
+    let cancelled = false;
+    async function loadBase() {
+      setChannelsLoading(true);
+      setChannelsError(null);
+      setSavedAccountsError(null);
+      try {
+        const [channelsResult, savedResult] = await Promise.allSettled([
+          fetchWithdrawChannels(),
+          fetchSavedWithdrawAccounts(),
+        ]);
+        if (cancelled) return;
+        if (channelsResult.status === "fulfilled") {
+          setChannels(channelsResult.value);
+        } else {
+          const err = channelsResult.reason as Error;
+          setChannelsError(err?.message || "提款通道加载失败");
+          setChannels([]);
+        }
+
+        if (savedResult.status === "fulfilled") {
+          setSavedAccounts(savedResult.value);
+        } else {
+          const err = savedResult.reason as Error;
+          setSavedAccountsError(err?.message || "收款信息加载失败");
+          setSavedAccounts([]);
+        }
+      } finally {
+        if (!cancelled) {
+          setChannelsLoading(false);
+        }
+      }
+    }
+    void loadBase();
+    return () => {
+      cancelled = true;
+    };
+  }, []);
+
+  useEffect(() => {
+    if (!selectedChannel) {
+      setBankOptions([]);
+      setSelectedBankCode("");
+      return;
+    }
+    const currentChannel = selectedChannel;
+    setSelectedSavedId("");
+    setAddress("");
+    setAgree(false);
+    setAgreeExtra(false);
+    setBankUnameInput("");
+    setBankCardNumInput("");
+    setBankNameInput("");
+    setBankBranchNameInput("");
+    setSwiftCodeInput("");
+    setCustomBankCodeInput("");
+    setBankAddrInput("");
+    setTelegraphicCurrency("USD");
+    setCardUnameInput("");
+    setCardNumInput("");
+    setCardCvvInput("");
+    setCardExpiryInput("");
+
+    if (!currentChannel.bankValid) {
+      setBankOptions([]);
+      setSelectedBankCode("");
+      return;
+    }
+    let cancelled = false;
+    async function loadBankOptions() {
+      try {
+        const list = await fetchWithdrawBankOptions(currentChannel.code);
+        if (cancelled) return;
+        setBankOptions(list);
+        setSelectedBankCode((prev) => prev || list[0]?.code || "");
+      } catch {
+        if (cancelled) return;
+        setBankOptions([]);
+      }
+    }
+    void loadBankOptions();
+    return () => {
+      cancelled = true;
+    };
+  }, [selectedChannel]);
+
+  useEffect(() => {
+    if (!selectedSavedAccount) return;
+    if (!isBankType(selectedChannel?.type || "") && !isBankTelegraphic) return;
+    setBankUnameInput(selectedSavedAccount.bankUname || "");
+    setBankCardNumInput(selectedSavedAccount.bankCardNum || "");
+    setBankNameInput(selectedSavedAccount.bankName || "");
+    setBankBranchNameInput(selectedSavedAccount.bankBranchName || "");
+    setSwiftCodeInput(selectedSavedAccount.swiftCode || "");
+    setCustomBankCodeInput(selectedSavedAccount.customBankCode || "");
+    setBankAddrInput(selectedSavedAccount.bankAddr || "");
+  }, [selectedSavedAccount, selectedChannel?.type, isBankTelegraphic]);
+
+  function validate(): string | null {
+    if (!selectedChannel) return "请选择提款通道";
+    if (!/^[0-9]+([.][0-9]{1,2})?$/.test(amount.trim())) return "请输入正确的提款金额";
+    const amountNum = Number(amount);
+    if (!Number.isFinite(amountNum) || amountNum <= 0) return "提款金额必须大于 0";
+    if (selectedChannel.minAmount > 0 && amountNum < selectedChannel.minAmount) {
+      return `提款金额不能低于 ${selectedChannel.minAmount}`;
+    }
+    if (selectedChannel.maxAmount > 0 && amountNum > selectedChannel.maxAmount) {
+      return `提款金额不能高于 ${selectedChannel.maxAmount}`;
+    }
+    if (isWalletType(selectedChannel.type) && !address.trim()) return "请填写提款地址";
+    if (shouldRequireSavedAccount && filteredSavedAccounts.length === 0) {
+      return "当前通道暂无可用收款信息,请更换通道或先补充收款信息";
+    }
+    if (shouldShowSavedAccountSelector && !selectedSavedId) return "请选择收款信息";
+    if (isBankType(selectedChannel.type)) {
+      if (!bankUnameInput.trim()) return "请输入户名";
+      if (!bankCardNumInput.trim()) return "请输入银行卡号";
+      if (!bankNameInput.trim()) return "请输入银行名称";
+      if (!bankBranchNameInput.trim()) return "请输入支行名称";
+    }
+    if (isBankTelegraphic) {
+      if (!bankUnameInput.trim()) return "请输入户名";
+      if (!bankCardNumInput.trim()) return "请输入银行卡号";
+      if (!bankNameInput.trim()) return "请输入银行名称";
+      if (!swiftCodeInput.trim()) return "请输入Swift Code";
+      if (!customBankCodeInput.trim()) return "请输入银行代码";
+      if (!bankAddrInput.trim()) return "请输入银行地址";
+    }
+    if (isBankTelegraphic && !agencyNo.trim()) return "请填写 Account Agency NO";
+    if (isBankTelegraphic && needCpf && !cpf.trim()) return "请填写 CPF";
+    if (isCardType(selectedChannel.type)) {
+      if (!cardUnameInput.trim()) return "请输入信用卡户名";
+      if (!cardNumInput.trim()) return "请输入信用卡账户";
+      if (!cardCvvInput.trim()) return "请输入CVV";
+      if (!cardExpiryInput.trim()) return "请输入到期年份/月 份";
+    }
+    if (!agree) return "请先勾选并同意提款条款";
+    if (!agreeExtra) return "请勾选第二条提款确认条款";
+    return null;
+  }
+
+  async function doSubmit() {
+    if (!selectedChannel) return;
+    const amountNum = Number(amount);
+    const payload: Record<string, unknown> = {
+      payType: selectedChannel.code,
+      amount: amountNum,
+      currency: selectedChannel.type === "BANK_TELEGRAPHIC" ? "USD" : selectedChannel.currency,
+      agree2: true,
+    };
+    if (selectedBankCode) payload.bankCode = selectedBankCode;
+    if (address.trim()) payload.address = address.trim();
+    if (selectedSavedAccount) {
+      payload.id = selectedSavedAccount.id;
+      payload.bankUname = selectedSavedAccount.bankUname;
+      payload.bankCardNum = selectedSavedAccount.bankCardNum;
+      payload.bankName = selectedSavedAccount.bankName;
+      payload.bankBranchName = selectedSavedAccount.bankBranchName;
+      payload.bankAddr = selectedSavedAccount.bankAddr;
+      payload.swiftCode = selectedSavedAccount.swiftCode;
+      payload.customBankCode = selectedSavedAccount.customBankCode;
+      payload.addressName = selectedSavedAccount.addressName;
+      payload.address = payload.address ?? selectedSavedAccount.address;
+      payload.cvv = selectedSavedAccount.cvv;
+      payload.expiryYearMonth = selectedSavedAccount.expiryYearMonth;
+    }
+    if (isBankType(selectedChannel.type)) {
+      payload.bankUname = bankUnameInput.trim();
+      payload.bankCardNum = bankCardNumInput.trim();
+      payload.bankName = bankNameInput.trim();
+      payload.bankBranchName = bankBranchNameInput.trim();
+    }
+    if (isBankTelegraphic) {
+      payload.bankUname = bankUnameInput.trim();
+      payload.bankCardNum = bankCardNumInput.trim();
+      payload.bankName = bankNameInput.trim();
+      payload.swiftCode = swiftCodeInput.trim();
+      payload.customBankCode = customBankCodeInput.trim();
+      payload.bankAddr = bankAddrInput.trim();
+      payload.currency = telegraphicCurrency || "USD";
+      payload.agencyNo = agencyNo.trim();
+      if (needCpf) payload.cpf = cpf.trim();
+    }
+    if (isCardType(selectedChannel.type)) {
+      payload.bankUname = cardUnameInput.trim();
+      payload.bankCardNum = cardNumInput.trim();
+      payload.cvv = cardCvvInput.trim();
+      payload.expiryYearMonth = cardExpiryInput.trim();
+    }
+    setSubmitting(true);
+    setError(null);
+    setSuccess(null);
+    try {
+      await submitWithdrawApply({
+        requestUrl: selectedChannel.requestUrl,
+        payload,
+      });
+      setSuccess("提款申请提交成功,请等待审核。");
+      setAmount("");
+      setAddress("");
+      setAgree(false);
+      setAgreeExtra(false);
+      setSelectedSavedId("");
+      setAgencyNo("");
+      setCpf("");
+      setBankUnameInput("");
+      setBankCardNumInput("");
+      setBankNameInput("");
+      setBankBranchNameInput("");
+      setSwiftCodeInput("");
+      setCustomBankCodeInput("");
+      setBankAddrInput("");
+      setTelegraphicCurrency("USD");
+      setCardUnameInput("");
+      setCardNumInput("");
+      setCardCvvInput("");
+      setCardExpiryInput("");
+    } catch (e) {
+      const err = e as Error;
+      setError(err.message || "提款申请失败,请稍后重试。");
+    } finally {
+      setSubmitting(false);
+      setConfirmOpen(false);
+    }
+  }
+
+  const channelGroups = useMemo(() => {
+    const groups: Record<string, WithdrawChannel[]> = {};
+    for (const item of channels) {
+      const key = channelGroupLabel(item);
+      if (!groups[key]) groups[key] = [];
+      groups[key].push(item);
+    }
+    return Object.entries(groups).sort((a, b) => groupOrder(a[0]) - groupOrder(b[0]));
+  }, [channels]);
+
+  return (
+    <div className="page-shell page-shell-wide">
+      <h1 className="font-serif text-2xl font-semibold text-[var(--navy)]">提款申请</h1>
+      <p className="mt-2 text-sm text-[var(--muted)]">流程:选择通道 - 填写信息 - 确认提交</p>
+
+      <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}
+        {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>
+        ) : null}
+        <div className="mt-3 space-y-3">
+          {channelGroups.map(([group, items]) => (
+            <div key={group} className="rounded-lg border border-[var(--border)] bg-white">
+              <button
+                type="button"
+                onClick={() => setExpandedGroup((v) => (v === group ? "" : group))}
+                className="flex w-full items-center gap-2 px-3 py-2 text-left text-base font-semibold text-[var(--navy)]"
+              >
+                <span
+                  className={`text-sm transition-transform duration-300 ${
+                    expandedGroup === group ? "rotate-0" : "-rotate-90"
+                  }`}
+                >
+                  ▼
+                </span>
+                <span>{group}</span>
+              </button>
+              <div
+                className={`grid overflow-hidden transition-all duration-300 ease-out ${
+                  expandedGroup === group ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
+                }`}
+              >
+                <div className="min-h-0">
+                  <div className="overflow-x-auto border-t border-[var(--border)]">
+                  <table className="w-full min-w-[900px] text-sm">
+                    <thead className="bg-slate-100/70 text-[var(--navy)]">
+                      <tr>
+                        <th className="px-3 py-2 text-left font-semibold">付款方式</th>
+                        <th className="px-3 py-2 text-left font-semibold">描述</th>
+                        <th className="px-3 py-2 text-left font-semibold">金额范围</th>
+                        <th className="px-3 py-2 text-left font-semibold">处理时间</th>
+                        <th className="px-3 py-2 text-left font-semibold">费用</th>
+                        <th className="px-3 py-2 text-right font-semibold">操作</th>
+                      </tr>
+                    </thead>
+                    <tbody>
+                      {items.map((item) => (
+                        <tr key={item.id} className="border-t border-[var(--border)]">
+                          <td className="px-3 py-2">
+                            <div className="flex items-center gap-2">
+                              {item.icon ? (
+                                // eslint-disable-next-line @next/next/no-img-element
+                                <img
+                                  src={item.icon}
+                                  alt={item.name || item.code}
+                                  className="h-7 w-7 rounded object-contain"
+                                />
+                              ) : (
+                                <span className="inline-block h-7 w-7 rounded bg-slate-100 text-center leading-7">
+                                  -
+                                </span>
+                              )}
+                              <span>{item.name || item.code}</span>
+                            </div>
+                          </td>
+                          <td className="px-3 py-2 text-[var(--muted)]">{item.enName || item.name || "-"}</td>
+                          <td className="px-3 py-2">{formatAmountRange(item)}</td>
+                          <td className="px-3 py-2">{item.fundingTime || "1 hours"}</td>
+                          <td className="px-3 py-2">{formatFee(item)}</td>
+                          <td className="px-3 py-2 text-right">
+                            <button
+                              type="button"
+                              onClick={() => {
+                                setSelectedChannelId(item.id);
+                                setApplyDialogOpen(true);
+                              }}
+                              className={`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"
+                              }`}
+                            >
+                              选择
+                            </button>
+                          </td>
+                        </tr>
+                      ))}
+                    </tbody>
+                  </table>
+                </div>
+                  {items.length === 0 ? <p className="px-3 py-3 text-sm text-[var(--muted)]">暂无通道</p> : null}
+                </div>
+              </div>
+            </div>
+          ))}
+        </div>
+        {selectedChannel ? (
+          <p className="mt-3 text-sm text-emerald-700">
+            已选择通道:{selectedChannel.name || selectedChannel.enName || selectedChannel.code}
+          </p>
+        ) : null}
+      </section>
+
+      <p className="mt-8 text-center text-sm">
+        <Link href="/account" className="text-[var(--accent)] hover:underline">
+          返回会员中心
+        </Link>
+      </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"
+            }`}
+          >
+            <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>
+              <p>
+                提款金额:{amount} {selectedChannel.currency || "USD"}
+              </p>
+            </div>
+            <div className="mt-5 flex justify-end gap-2">
+              <button
+                type="button"
+                onClick={() => setConfirmOpen(false)}
+                className="rounded-lg border border-[var(--border)] px-4 py-2 text-sm"
+              >
+                取消
+              </button>
+              <button
+                type="button"
+                onClick={() => void doSubmit()}
+                className="rounded-lg bg-[var(--navy)] px-4 py-2 text-sm text-white"
+              >
+                确认提交
+              </button>
+            </div>
+          </div>
+        </div>
+      ) : 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"
+          }`}
+        >
+          <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"
+            }`}
+          >
+            <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)]"
+              >
+                关闭
+              </button>
+            </div>
+
+            {selectedChannel.introduce || selectedChannel.enIntroduce ? (
+              <div
+                className="mt-3 rounded-lg border border-[var(--border)] bg-slate-50 p-3 text-sm leading-7 text-[var(--navy)]"
+                dangerouslySetInnerHTML={{
+                  __html: sanitizeHtml(selectedChannel.introduce || selectedChannel.enIntroduce || ""),
+                }}
+              />
+            ) : null}
+
+            {bankOptions.length > 0 ? (
+              <div className="mt-3">
+                <label className="text-sm font-medium text-[var(--navy)]">银行通道</label>
+                <select
+                  value={selectedBankCode}
+                  onChange={(e) => setSelectedBankCode(e.target.value)}
+                  className="mt-1 w-full rounded-lg border border-[var(--border)] bg-white px-3 py-2 text-sm"
+                >
+                  <option value="">请选择银行通道</option>
+                  {bankOptions.map((item) => (
+                    <option key={item.code} value={item.code}>
+                      {item.name || item.enName || item.code}
+                    </option>
+                  ))}
+                </select>
+              </div>
+            ) : null}
+
+            {shouldShowSavedAccountSelector ? (
+              <div className="mt-3">
+                <label className="text-sm font-medium text-[var(--navy)]">收款信息</label>
+                <select
+                  value={selectedSavedId}
+                  onChange={(e) => setSelectedSavedId(e.target.value)}
+                  className="mt-1 w-full rounded-lg border border-[var(--border)] bg-white px-3 py-2 text-sm"
+                >
+                  <option value="">请选择收款信息</option>
+                  {filteredSavedAccounts.map((item) => (
+                    <option key={item.id} value={item.id} disabled={item.type === 4 && item.authStatus === 0}>
+                      {item.type === 4
+                        ? `${item.addressName || "-"} - ${item.address || "-"}`
+                        : `${item.bankName || "-"} - ${item.bankCardNum || "-"}`}
+                    </option>
+                  ))}
+                </select>
+              </div>
+            ) : null}
+
+            {isBankType(selectedChannel.type) ? (
+              <div className="mt-3 grid gap-3 md:grid-cols-2">
+                <div>
+                  <label className="text-sm font-medium text-[var(--navy)]">户名</label>
+                  <input
+                    value={bankUnameInput}
+                    onChange={(e) => setBankUnameInput(e.target.value)}
+                    className="mt-1 w-full rounded-lg border border-[var(--border)] bg-white px-3 py-2 text-sm"
+                  />
+                </div>
+                <div>
+                  <label className="text-sm font-medium text-[var(--navy)]">银行卡号</label>
+                  <input
+                    value={bankCardNumInput}
+                    onChange={(e) => setBankCardNumInput(e.target.value)}
+                    className="mt-1 w-full rounded-lg border border-[var(--border)] bg-white px-3 py-2 text-sm"
+                  />
+                </div>
+                <div>
+                  <label className="text-sm font-medium text-[var(--navy)]">银行名称</label>
+                  <input
+                    value={bankNameInput}
+                    onChange={(e) => setBankNameInput(e.target.value)}
+                    className="mt-1 w-full rounded-lg border border-[var(--border)] bg-white px-3 py-2 text-sm"
+                  />
+                </div>
+                <div>
+                  <label className="text-sm font-medium text-[var(--navy)]">支行名称</label>
+                  <input
+                    value={bankBranchNameInput}
+                    onChange={(e) => setBankBranchNameInput(e.target.value)}
+                    className="mt-1 w-full rounded-lg border border-[var(--border)] bg-white px-3 py-2 text-sm"
+                  />
+                </div>
+              </div>
+            ) : null}
+
+            {selectedChannel.type === "BANK_TELEGRAPHIC" ? (
+              <div className="mt-3 grid gap-3 md:grid-cols-3">
+                <div>
+                  <label className="text-sm font-medium text-[var(--navy)]">户名</label>
+                  <input
+                    value={bankUnameInput}
+                    onChange={(e) => setBankUnameInput(e.target.value)}
+                    className="mt-1 w-full rounded-lg border border-[var(--border)] bg-white px-3 py-2 text-sm"
+                  />
+                </div>
+                <div>
+                  <label className="text-sm font-medium text-[var(--navy)]">银行卡号</label>
+                  <input
+                    value={bankCardNumInput}
+                    onChange={(e) => setBankCardNumInput(e.target.value)}
+                    className="mt-1 w-full rounded-lg border border-[var(--border)] bg-white px-3 py-2 text-sm"
+                  />
+                </div>
+                <div>
+                  <label className="text-sm font-medium text-[var(--navy)]">银行名称</label>
+                  <input
+                    value={bankNameInput}
+                    onChange={(e) => setBankNameInput(e.target.value)}
+                    className="mt-1 w-full rounded-lg border border-[var(--border)] bg-white px-3 py-2 text-sm"
+                  />
+                </div>
+                <div>
+                  <label className="text-sm font-medium text-[var(--navy)]">Swift Code</label>
+                  <input
+                    value={swiftCodeInput}
+                    onChange={(e) => setSwiftCodeInput(e.target.value)}
+                    className="mt-1 w-full rounded-lg border border-[var(--border)] bg-white px-3 py-2 text-sm"
+                  />
+                </div>
+                <div>
+                  <label className="text-sm font-medium text-[var(--navy)]">银行代码</label>
+                  <input
+                    value={customBankCodeInput}
+                    onChange={(e) => setCustomBankCodeInput(e.target.value)}
+                    className="mt-1 w-full rounded-lg border border-[var(--border)] bg-white px-3 py-2 text-sm"
+                  />
+                </div>
+                <div>
+                  <label className="text-sm font-medium text-[var(--navy)]">银行地址</label>
+                  <input
+                    value={bankAddrInput}
+                    onChange={(e) => setBankAddrInput(e.target.value)}
+                    className="mt-1 w-full rounded-lg border border-[var(--border)] bg-white px-3 py-2 text-sm"
+                  />
+                </div>
+              </div>
+            ) : null}
+
+            {isCardType(selectedChannel.type) ? (
+              <div className="mt-3 grid gap-3 md:grid-cols-2">
+                <div>
+                  <label className="text-sm font-medium text-[var(--navy)]">信用卡户名</label>
+                  <input
+                    value={cardUnameInput}
+                    onChange={(e) => setCardUnameInput(e.target.value)}
+                    placeholder="John Doe"
+                    className="mt-1 w-full rounded-lg border border-[var(--border)] bg-white px-3 py-2 text-sm"
+                  />
+                </div>
+                <div>
+                  <label className="text-sm font-medium text-[var(--navy)]">信用卡账户</label>
+                  <input
+                    value={cardNumInput}
+                    onChange={(e) => setCardNumInput(e.target.value)}
+                    placeholder="5188 5136 1855 2975"
+                    className="mt-1 w-full rounded-lg border border-[var(--border)] bg-white px-3 py-2 text-sm"
+                  />
+                </div>
+                <div>
+                  <label className="text-sm font-medium text-[var(--navy)]">CVV</label>
+                  <input
+                    value={cardCvvInput}
+                    onChange={(e) => setCardCvvInput(e.target.value)}
+                    placeholder="123"
+                    className="mt-1 w-full rounded-lg border border-[var(--border)] bg-white px-3 py-2 text-sm"
+                  />
+                </div>
+                <div>
+                  <label className="text-sm font-medium text-[var(--navy)]">到期年份/月 份</label>
+                  <input
+                    value={cardExpiryInput}
+                    onChange={(e) => setCardExpiryInput(e.target.value)}
+                    placeholder="30/09"
+                    className="mt-1 w-full rounded-lg border border-[var(--border)] bg-white px-3 py-2 text-sm"
+                  />
+                </div>
+              </div>
+            ) : null}
+
+            {shouldRequireSavedAccount && !shouldShowSavedAccountSelector ? (
+              <p className="mt-3 text-xs text-[var(--muted)]">
+                当前通道暂无可用收款信息,请更换通道或先补充收款信息。
+              </p>
+            ) : null}
+
+            {savedAccountsError && shouldRequireSavedAccount ? (
+              <p className="mt-1 text-xs text-[var(--muted)]">
+                收款信息加载失败,可先切换其他通道后重试。
+              </p>
+            ) : null}
+
+            {isWalletType(selectedChannel.type) ? (
+              <div className="mt-3">
+                <label className="text-sm font-medium text-[var(--navy)]">提款地址</label>
+                <input
+                  value={address}
+                  onChange={(e) => setAddress(e.target.value)}
+                  className="mt-1 w-full rounded-lg border border-[var(--border)] px-3 py-2 text-sm"
+                />
+              </div>
+            ) : null}
+
+            {isBankTelegraphic ? (
+              <div className="mt-3 grid gap-3 md:grid-cols-2">
+                <div>
+                  <label className="text-sm font-medium text-[var(--navy)]">货币类型</label>
+                  <select
+                    value={telegraphicCurrency}
+                    onChange={(e) => setTelegraphicCurrency(e.target.value)}
+                    className="mt-1 w-full rounded-lg border border-[var(--border)] bg-white px-3 py-2 text-sm"
+                  >
+                    <option value="USD">USD</option>
+                  </select>
+                </div>
+                <div>
+                  <label className="text-sm font-medium text-[var(--navy)]">金额</label>
+                  <input
+                    value={amount}
+                    onChange={(e) => setAmount(e.target.value)}
+                    className="mt-1 w-full rounded-lg border border-[var(--border)] px-3 py-2 text-sm"
+                  />
+                </div>
+              </div>
+            ) : (
+              <div className="mt-3">
+                <label className="text-sm font-medium text-[var(--navy)]">
+                  提款金额({selectedChannel.currency || "USD"})
+                </label>
+                <input
+                  value={amount}
+                  onChange={(e) => setAmount(e.target.value)}
+                  className="mt-1 w-full rounded-lg border border-[var(--border)] px-3 py-2 text-sm"
+                />
+              </div>
+            )}
+
+            {isBankTelegraphic ? (
+              <div className="mt-3 grid gap-3 md:grid-cols-2">
+                <div>
+                  <label className="text-sm font-medium text-[var(--navy)]">Account Agency NO</label>
+                  <input
+                    value={agencyNo}
+                    onChange={(e) => setAgencyNo(e.target.value)}
+                    className="mt-1 w-full rounded-lg border border-[var(--border)] px-3 py-2 text-sm"
+                  />
+                </div>
+                {needCpf ? (
+                  <div>
+                    <label className="text-sm font-medium text-[var(--navy)]">CPF</label>
+                    <input
+                      value={cpf}
+                      onChange={(e) => setCpf(e.target.value)}
+                      className="mt-1 w-full rounded-lg border border-[var(--border)] px-3 py-2 text-sm"
+                    />
+                  </div>
+                ) : null}
+              </div>
+            ) : null}
+
+            {
+              <label className="mt-3 flex items-start gap-2 text-sm text-[var(--navy)]">
+                <input
+                  type="checkbox"
+                  checked={agree}
+                  onChange={(e) => setAgree(e.target.checked)}
+                  className="mt-0.5"
+                />
+                <span>我已阅读并同意提款条款,知悉手续费与到账时间以平台审核为准。</span>
+              </label>
+            }
+
+            {
+              <label className="mt-2 flex items-start gap-2 text-sm text-[var(--navy)]">
+                <input
+                  type="checkbox"
+                  checked={agreeExtra}
+                  onChange={(e) => setAgreeExtra(e.target.checked)}
+                  className="mt-0.5"
+                />
+                <span>* 我确认本次提款信息准确无误,并接受平台审核结果。</span>
+              </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);
+                    return;
+                  }
+                  setConfirmOpen(true);
+                }}
+                className="mt-4 w-full rounded-full bg-[var(--navy)] py-3 text-sm font-semibold text-white disabled:opacity-60"
+              >
+                {submitting ? "提交中..." : "提交提款申请"}
+              </button>
+            }
+          </section>
+        </div>
+      ) : null}
+    </div>
+  );
+}

+ 141 - 0
src/app/[locale]/account/withdrawals/page.tsx

@@ -0,0 +1,141 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useTranslations } from "next-intl";
+import { Link } from "@/i18n/navigation";
+import { useAuth } from "@/providers/auth-provider";
+import {
+  fetchWithdrawalList,
+  getWithdrawalStatusLabel,
+  type WithdrawalRecord,
+} from "@/lib/withdrawal-api";
+
+const PAGE_SIZE = 10;
+
+export default function AccountWithdrawalsPage() {
+  const t = useTranslations("account");
+  const { user, isReady } = useAuth();
+  const [records, setRecords] = useState<WithdrawalRecord[]>([]);
+  const [loading, setLoading] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+  const [page, setPage] = useState(1);
+
+  useEffect(() => {
+    if (!user) return;
+    let cancelled = false;
+    async function loadRecords() {
+      setLoading(true);
+      setError(null);
+      try {
+        const list = await fetchWithdrawalList({ current: page, row: PAGE_SIZE });
+        if (cancelled) return;
+        setRecords(list);
+      } catch (e) {
+        if (cancelled) return;
+        const err = e as Error;
+        setError(err.message || "取款记录加载失败,请稍后重试。");
+        setRecords([]);
+      } finally {
+        if (!cancelled) setLoading(false);
+      }
+    }
+    void loadRecords();
+    return () => {
+      cancelled = true;
+    };
+  }, [user, page]);
+
+  if (!isReady) {
+    return <div className="page-shell py-16 text-center text-[var(--muted)]">…</div>;
+  }
+
+  if (!user) {
+    return (
+      <div className="page-shell py-16 text-center text-[var(--muted)]">
+        <p>{t("loginRequired")}</p>
+        <Link
+          href="/auth/login"
+          className="mt-4 inline-block rounded-full bg-[var(--navy)] px-6 py-2 text-sm text-white"
+        >
+          登录
+        </Link>
+      </div>
+    );
+  }
+
+  const hasPrev = page > 1;
+  const hasNext = records.length >= PAGE_SIZE;
+
+  return (
+    <div className="page-shell page-shell-wide">
+      <div className="flex items-center justify-between">
+        <h1 className="font-serif text-2xl font-semibold text-[var(--navy)]">全部取款记录</h1>
+        <Link
+          href="/account"
+          className="rounded-full border border-[var(--border)] px-4 py-1.5 text-xs text-[var(--navy)]"
+        >
+          返回用户中心
+        </Link>
+      </div>
+
+      {loading ? <p className="mt-4 text-sm text-[var(--muted)]">取款记录加载中…</p> : null}
+      {error ? (
+        <p className="mt-4 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
+          {error}
+        </p>
+      ) : null}
+
+      {!loading && !error && records.length === 0 ? (
+        <p className="mt-4 text-sm text-[var(--muted)]">暂无取款记录。</p>
+      ) : null}
+
+      {!loading && !error && records.length > 0 ? (
+        <ul className="mt-4 space-y-3">
+          {records.map((w) => (
+            <li
+              key={w.serial}
+              className="rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 shadow-sm"
+            >
+              <div className="flex flex-wrap items-center justify-between gap-2">
+                <span className="font-medium text-[var(--navy)]">{w.details}</span>
+                <div className="flex items-center gap-2">
+                  <span className="rounded-full bg-slate-100 px-2.5 py-0.5 text-xs text-[var(--navy)]">
+                    {getWithdrawalStatusLabel(w.status)}
+                  </span>
+                  <span className="rounded-full border border-[var(--border)] px-2.5 py-0.5 text-xs font-semibold text-[var(--navy)]">
+                    ${w.amount}
+                  </span>
+                </div>
+              </div>
+              <div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-[var(--muted)]">
+                <span>流水号: {w.serial}</span>
+                <span>申请时间: {w.addTime || "-"}</span>
+                <span>到账时间: {w.payTime || "-"}</span>
+              </div>
+            </li>
+          ))}
+        </ul>
+      ) : null}
+
+      <div className="mt-5 flex items-center justify-center gap-3">
+        <button
+          type="button"
+          onClick={() => setPage((p) => Math.max(1, p - 1))}
+          disabled={!hasPrev || loading}
+          className="rounded-lg border border-[var(--border)] px-3 py-1.5 text-sm disabled:opacity-50"
+        >
+          上一页
+        </button>
+        <span className="text-sm text-[var(--muted)]">第 {page} 页</span>
+        <button
+          type="button"
+          onClick={() => setPage((p) => p + 1)}
+          disabled={!hasNext || loading}
+          className="rounded-lg border border-[var(--border)] px-3 py-1.5 text-sm disabled:opacity-50"
+        >
+          下一页
+        </button>
+      </div>
+    </div>
+  );
+}

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

@@ -0,0 +1,51 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import { Link } from "@/i18n/navigation";
+import { useState } from "react";
+
+export default function ForgotPasswordPage() {
+  const t = useTranslations("auth");
+  const [email, setEmail] = useState("");
+  const [done, setDone] = useState(false);
+
+  return (
+    <div className="auth-page-shell">
+      <h1 className="font-serif text-2xl font-semibold text-[var(--navy)]">{t("forgotTitle")}</h1>
+      <p className="mt-2 text-sm text-[var(--muted)]">{t("forgotHint")}</p>
+      {!done ? (
+        <form
+          onSubmit={(e) => {
+            e.preventDefault();
+            setDone(true);
+          }}
+          className="mt-8 space-y-4"
+        >
+          <div>
+            <label className="text-sm font-medium">{t("email")}</label>
+            <input
+              type="email"
+              required
+              value={email}
+              onChange={(e) => setEmail(e.target.value)}
+              className="mt-1 w-full rounded-lg border border-[var(--border)] px-3 py-2 text-sm"
+            />
+          </div>
+          <button
+            type="submit"
+            className="w-full rounded-full bg-[var(--navy)] py-3 text-sm font-semibold text-white"
+          >
+            {t("resetBtn")}
+          </button>
+        </form>
+      ) : (
+        <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">
+          {t("toLogin")}
+        </Link>
+      </p>
+    </div>
+  );
+}

+ 148 - 0
src/app/[locale]/auth/login/page.tsx

@@ -0,0 +1,148 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import { useRouter } from "@/i18n/navigation";
+import { Link } from "@/i18n/navigation";
+import { useSearchParams } from "next/navigation";
+import { Suspense, useEffect, useState } from "react";
+import { useAuth } from "@/providers/auth-provider";
+
+const REGISTER_BANNER_MS = 3000;
+
+function safePostLoginPath(raw: string | null): string | null {
+  if (!raw) return null;
+  if (!raw.startsWith("/") || raw.startsWith("//")) return null;
+  if (raw.includes("\\")) return null;
+  return raw;
+}
+
+function LoginForm() {
+  const t = useTranslations("auth");
+  const router = useRouter();
+  const searchParams = useSearchParams();
+  const { login } = useAuth();
+  const fromRegister = searchParams.get("registered") === "1";
+  const [registerBannerDismissed, setRegisterBannerDismissed] = useState(false);
+  const showRegisterBanner = fromRegister && !registerBannerDismissed;
+  const [email, setEmail] = useState("");
+  const [password, setPassword] = useState("");
+  const [err, setErr] = useState<string | null>(null);
+  const [submitting, setSubmitting] = useState(false);
+  const canSubmit = email.trim().length > 0 && password.length > 0 && !submitting;
+
+  useEffect(() => {
+    if (!fromRegister) return;
+    const timer = window.setTimeout(() => {
+      setRegisterBannerDismissed(true);
+      router.replace("/auth/login");
+    }, REGISTER_BANNER_MS);
+    return () => window.clearTimeout(timer);
+  }, [fromRegister, router]);
+
+  const onSubmit = async (e: React.FormEvent) => {
+    e.preventDefault();
+    if (!canSubmit) return;
+    setErr(null);
+    setSubmitting(true);
+    const r = await login(email.trim(), password);
+    if (!r.ok) {
+      if (r.message) setErr(r.message);
+      else if (r.error === "invalid_email") setErr(t("errorInvalidEmail"));
+      else setErr(t("errorCredentials"));
+      setSubmitting(false);
+      return;
+    }
+    const next = safePostLoginPath(searchParams.get("next"));
+    router.push(next ?? "/account");
+  };
+
+  return (
+    <div className="auth-page-shell">
+      <div className="rounded-2xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-card sm:p-8">
+        <h1 className="font-serif text-2xl font-semibold text-[var(--navy)]">{t("loginTitle")}</h1>
+        {showRegisterBanner && (
+          <p
+            className="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2.5 text-sm text-emerald-900"
+            role="status"
+          >
+            {t("loginAfterRegister")}
+          </p>
+        )}
+        <form onSubmit={onSubmit} className="mt-8 space-y-4">
+          <div>
+            <label className="text-sm font-medium">{t("email")}</label>
+            <input
+              type="email"
+              required
+              value={email}
+              onChange={(e) => {
+                setEmail(e.target.value);
+                if (err) setErr(null);
+              }}
+              onBlur={() => setEmail((v) => v.trim())}
+              autoComplete="email"
+              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
+              type="password"
+              required
+              value={password}
+              onChange={(e) => {
+                setPassword(e.target.value);
+                if (err) setErr(null);
+              }}
+              autoComplete="current-password"
+              className="mt-1 w-full rounded-lg border border-[var(--border)] px-3 py-2 text-sm"
+            />
+          </div>
+          {err && (
+            <p className="text-sm text-red-600" role="alert" aria-live="polite">
+              {err}
+            </p>
+          )}
+          <button
+            type="submit"
+            disabled={!canSubmit}
+            className="w-full rounded-full bg-[var(--navy)] py-3 text-sm font-semibold text-white transition-opacity disabled:cursor-not-allowed disabled:opacity-60"
+          >
+            {submitting ? `${t("loginBtn")}...` : t("loginBtn")}
+          </button>
+        </form>
+        <p className="mt-4 text-center text-sm text-[var(--muted)]">
+          <Link href="/auth/forgot-password" className="underline">
+            {t("forgotTitle")}
+          </Link>
+        </p>
+        <p className="mt-6 text-center text-sm">
+          <Link href="/auth/register" className="text-[var(--accent)] hover:underline">
+            {t("toRegister")}
+          </Link>
+        </p>
+      </div>
+    </div>
+  );
+}
+
+function LoginFormWithSearchParamsKey() {
+  const searchParams = useSearchParams();
+  return <LoginForm key={searchParams.toString()} />;
+}
+
+export default function LoginPage() {
+  return (
+    <Suspense
+      fallback={
+        <div className="auth-page-shell">
+          <div className="rounded-2xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-card sm:p-8">
+            <div className="h-8 w-40 animate-pulse rounded bg-[var(--border)]" />
+          </div>
+        </div>
+      }
+    >
+      <LoginFormWithSearchParamsKey />
+    </Suspense>
+  );
+}

+ 265 - 0
src/app/[locale]/auth/register/page.tsx

@@ -0,0 +1,265 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import { useRouter } from "@/i18n/navigation";
+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";
+
+const SEND_CODE_COOLDOWN_SEC = 60;
+
+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);
+  const [sendingCode, setSendingCode] = useState(false);
+  const [submitting, setSubmitting] = useState(false);
+  const [registerSuccess, setRegisterSuccess] = useState(false);
+  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(() => {
+      setSendCooldown((s) => (s <= 1 ? 0 : s - 1));
+    }, 1000);
+    return () => window.clearInterval(id);
+  }, [sendCooldown]);
+
+  useEffect(() => {
+    if (!registerSuccess) return;
+    const t = window.setTimeout(() => {
+      router.push("/auth/login?registered=1");
+    }, 1600);
+    return () => window.clearTimeout(t);
+  }, [registerSuccess, router]);
+
+  const onSendCode = async () => {
+    if (!canSendCode) return;
+    setErr(null);
+    if (!emailValid) {
+      setErr(t("errorInvalidEmail"));
+      return;
+    }
+    setSendingCode(true);
+    try {
+      await sendRegisterVerificationCode(email.trim());
+      setSendCooldown(SEND_CODE_COOLDOWN_SEC);
+    } catch (e) {
+      const msg = e instanceof ApiError ? e.message : t("errorApi");
+      setErr(msg);
+    } finally {
+      setSendingCode(false);
+    }
+  };
+
+  const onSubmit = async (e: React.FormEvent) => {
+    e.preventDefault();
+    if (!canSubmit) return;
+    setErr(null);
+    if (!isRegisterPasswordValid(password)) {
+      setErr(t("errorWeakPassword"));
+      return;
+    }
+    setSubmitting(true);
+    const r = await register({
+      country,
+      email: email.trim(),
+      password,
+      name: name.trim(),
+      code: code.trim(),
+    });
+    if (!r.ok) {
+      if (r.message) setErr(r.message);
+      else if (r.error === "invalid_email") setErr(t("errorInvalidEmail"));
+      else if (r.error === "weak_password") setErr(t("errorWeakPassword"));
+      else if (r.error === "invalid_code") setErr(t("errorCode"));
+      else if (r.error === "country_required") setErr(t("countryRequired"));
+      else setErr(t("errorApi"));
+      setSubmitting(false);
+      return;
+    }
+    logout();
+    setRegisterSuccess(true);
+  };
+
+  if (registerSuccess) {
+    return (
+      <div className="auth-page-shell">
+        <div className="rounded-2xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-card sm:p-8">
+          <h1 className="font-serif text-2xl font-semibold text-[var(--navy)]">{t("registerTitle")}</h1>
+          <div
+            className="mt-8 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-5 text-center"
+            role="status"
+          >
+            <p className="font-medium text-emerald-900">{t("registerSuccessTitle")}</p>
+            <p className="mt-2 text-sm text-emerald-800/90">{t("registerSuccessHint")}</p>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+  return (
+    <div className="auth-page-shell">
+      <div className="rounded-2xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-card sm:p-8">
+        <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
+              type="email"
+              required
+              value={email}
+              onChange={(e) => {
+                setEmail(e.target.value);
+                if (err) setErr(null);
+              }}
+              onBlur={() => setEmail((v) => v.trim())}
+              autoComplete="email"
+              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("code")}</label>
+            <div className="mt-1 flex gap-2">
+              <input
+                required
+                value={code}
+                onChange={(e) => {
+                  const normalized = e.target.value.replace(/\D/g, "").slice(0, 6);
+                  setCode(normalized);
+                  if (err) setErr(null);
+                }}
+                className="flex-1 rounded-lg border border-[var(--border)] px-3 py-2 text-sm"
+                placeholder="000000"
+                inputMode="numeric"
+                autoComplete="one-time-code"
+              />
+              <button
+                type="button"
+                disabled={!canSendCode}
+                onClick={onSendCode}
+                className="shrink-0 rounded-lg border border-[var(--border)] px-3 text-xs disabled:cursor-not-allowed disabled:opacity-50"
+              >
+                {sendingCode
+                  ? `${t("sendCode")}...`
+                  : sendCooldown > 0
+                  ? t("sendCodeCooldown", { sec: sendCooldown })
+                  : t("sendCode")}
+              </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
+              type="password"
+              required
+              autoComplete="new-password"
+              value={password}
+              onChange={(e) => {
+                setPassword(e.target.value);
+                if (err) setErr(null);
+              }}
+              className="mt-1 w-full rounded-lg border border-[var(--border)] px-3 py-2 text-sm"
+            />
+            <ul className="mt-2 list-disc space-y-1 pl-5 text-xs text-[var(--muted)]">
+              <li>{t("passwordRuleLen")}</li>
+              <li>{t("passwordRuleCase")}</li>
+              <li>{t("passwordRuleMix")}</li>
+            </ul>
+          </div>
+          {err && (
+            <p className="text-sm text-red-600" role="alert" aria-live="polite">
+              {err}
+            </p>
+          )}
+          <button
+            type="submit"
+            disabled={!canSubmit}
+            className="w-full rounded-full bg-[var(--navy)] py-3 text-sm font-semibold text-white transition-opacity disabled:cursor-not-allowed disabled:opacity-60"
+          >
+            {submitting ? `${t("registerBtn")}...` : t("registerBtn")}
+          </button>
+        </form>
+        <p className="mt-6 text-center text-sm">
+          <Link href="/auth/login" className="text-[var(--accent)] hover:underline">
+            {t("toLogin")}
+          </Link>
+        </p>
+      </div>
+    </div>
+  );
+}

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

@@ -0,0 +1,497 @@
+"use client";
+
+import { useSearchParams } from "next/navigation";
+import { useTranslations } from "next-intl";
+import { useRouter } from "@/i18n/navigation";
+import { useEffect, useMemo, useState } from "react";
+import { getCourseBySlug } from "@/data/courses";
+import { useAuth } from "@/providers/auth-provider";
+import {
+  fetchBankChannelOptions,
+  fetchRemittanceChannels,
+  submitXfgPayOrder,
+  type BankChannelOption,
+  type RemittanceChannel,
+} from "@/lib/checkout-api";
+
+export function CheckoutForm() {
+  const t = useTranslations("checkout");
+  const router = useRouter();
+  const searchParams = useSearchParams();
+  const slug = searchParams.get("course") ?? "";
+  const goodsIdFromQuery = searchParams.get("id") ?? "";
+  const titleFromQuery = searchParams.get("title") ?? "";
+  const priceFromQuery = Number(searchParams.get("price") ?? "");
+  const course = slug ? getCourseBySlug(slug) : undefined;
+  const displayCourseTitle = course?.title ?? (titleFromQuery || "-");
+  const displayCoursePrice =
+    course?.price ?? (Number.isFinite(priceFromQuery) ? priceFromQuery : 0);
+  const hasCourseInfo = displayCourseTitle !== "-" && displayCoursePrice >= 0;
+  const goodsId = (course?.id ?? goodsIdFromQuery).trim();
+  const { user, addMockOrder } = useAuth();
+
+  const [channels, setChannels] = useState<RemittanceChannel[]>([]);
+  const [channelsLoading, setChannelsLoading] = useState(true);
+  const [channelsError, setChannelsError] = useState<string | null>(null);
+  const [selectedChannelId, setSelectedChannelId] = useState<string>("");
+  const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
+  const [isPaymentModalOpen, setIsPaymentModalOpen] = useState(false);
+  const [bankOptions, setBankOptions] = useState<BankChannelOption[]>([]);
+  const [bankOptionsLoading, setBankOptionsLoading] = useState(false);
+  const [bankOptionsError, setBankOptionsError] = useState<string | null>(null);
+  const [selectedBankCode, setSelectedBankCode] = useState("");
+  const [depositAmount, setDepositAmount] = useState("");
+  const [name, setName] = useState("");
+  const [phone, setPhone] = useState("");
+  const [msg, setMsg] = useState<string | null>(null);
+  const [submitting, setSubmitting] = useState(false);
+  const [submitDialog, setSubmitDialog] = useState<{
+    open: boolean;
+    status: "loading" | "success" | "error";
+    message: string;
+  }>({
+    open: false,
+    status: "loading",
+    message: "",
+  });
+
+  useEffect(() => {
+    let cancelled = false;
+    async function loadChannels() {
+      setChannelsLoading(true);
+      setChannelsError(null);
+      try {
+        const list = await fetchRemittanceChannels();
+        if (cancelled) return;
+        setChannels(list);
+      } catch (error) {
+        if (cancelled) return;
+        const e = error as Error;
+        setChannelsError(e.message || "通道加载失败,请稍后重试。");
+      } finally {
+        if (!cancelled) setChannelsLoading(false);
+      }
+    }
+    void loadChannels();
+    return () => {
+      cancelled = true;
+    };
+  }, []);
+
+  useEffect(() => {
+    if (channels.length === 0) return;
+    setExpandedGroups((prev) => {
+      if (Object.keys(prev).length > 0) return prev;
+      const firstGroupName = channels[0]?.groupName;
+      if (!firstGroupName) return prev;
+      return { [firstGroupName]: true };
+    });
+  }, [channels]);
+
+  const channelGroups = useMemo(() => {
+    const grouped = new Map<string, { order: number; list: RemittanceChannel[] }>();
+    for (const channel of channels) {
+      const key = channel.groupName || "支付通道";
+      const current = grouped.get(key);
+      if (current) current.list.push(channel);
+      else grouped.set(key, { order: channel.groupOrder, list: [channel] });
+    }
+    return Array.from(grouped.entries())
+      .map(([groupName, value]) => ({ groupName, list: value.list, order: value.order }))
+      .sort((a, b) => a.order - b.order);
+  }, [channels]);
+
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault();
+    if (!user) return;
+    const depositAmountNum = Number(depositAmount);
+    const finalAmount =
+      Number.isFinite(depositAmountNum) && depositAmountNum > 0
+        ? depositAmountNum
+        : displayCoursePrice;
+    if (!goodsId) {
+      setMsg("商品ID缺失,无法提交订单。");
+      return;
+    }
+    try {
+      setSubmitting(true);
+      setSubmitDialog({
+        open: true,
+        status: "loading",
+        message: "订单提交中,请稍候...",
+      });
+      const { resultUrl } = await submitXfgPayOrder({
+        requestUrl: selectedChannel?.requestUrl || "/xfgpay/pay",
+        amount: finalAmount,
+        bankCode: selectedBankCode || undefined,
+        goodIds: [goodsId],
+        payName: name.trim(),
+        payPhone: phone.trim(),
+      });
+      if (!resultUrl) {
+        throw new Error("下单成功但未返回支付地址");
+      }
+      addMockOrder({
+        amount: finalAmount,
+        title: displayCourseTitle,
+      });
+      setIsPaymentModalOpen(false);
+      setSubmitDialog({
+        open: true,
+        status: "success",
+        message: "提交订单成功,正在跳转到支付页面...",
+      });
+      setTimeout(() => {
+        window.open(resultUrl, "_blank", "noopener,noreferrer");
+      }, 1000);
+    } catch (error) {
+      const e = error as Error;
+      setSubmitDialog({
+        open: true,
+        status: "error",
+        message: e.message || "提交失败,请稍后重试。",
+      });
+      setMsg(e.message || "提交失败,请稍后重试。");
+    } finally {
+      setSubmitting(false);
+    }
+  };
+
+  const handleClosePaymentModal = () => {
+    setIsPaymentModalOpen(false);
+    setSelectedChannelId("");
+    setSelectedBankCode("");
+    setDepositAmount("");
+  };
+
+  const selectedChannel = channels.find((c) => c.id === selectedChannelId);
+  const selectedBank = bankOptions.find((b) => b.value === selectedBankCode);
+  const shouldShowBankSelector = selectedChannel?.bankValid === 1;
+
+  const toggleGroup = (groupName: string) => {
+    setExpandedGroups((prev) => ({
+      ...prev,
+      [groupName]: !prev[groupName],
+    }));
+  };
+
+  useEffect(() => {
+    let cancelled = false;
+    async function loadBankOptions() {
+      if (!isPaymentModalOpen || !selectedChannelId || !shouldShowBankSelector) {
+        setBankOptions([]);
+        setSelectedBankCode("");
+        return;
+      }
+      setBankOptionsLoading(true);
+      setBankOptionsError(null);
+      setSelectedBankCode("");
+      try {
+        const list = await fetchBankChannelOptions(selectedChannel?.code);
+        if (cancelled) return;
+        setBankOptions(list);
+      } catch (error) {
+        if (cancelled) return;
+        const e = error as Error;
+        setBankOptionsError(e.message || "付款方式加载失败,请稍后重试。");
+        setBankOptions([]);
+      } finally {
+        if (!cancelled) setBankOptionsLoading(false);
+      }
+    }
+    void loadBankOptions();
+    return () => {
+      cancelled = true;
+    };
+  }, [isPaymentModalOpen, selectedChannelId, selectedChannel?.code, shouldShowBankSelector]);
+
+  useEffect(() => {
+    if (!isPaymentModalOpen) return;
+    if (depositAmount) return;
+    if (!(displayCoursePrice > 0)) return;
+    setDepositAmount(String(displayCoursePrice));
+  }, [isPaymentModalOpen, displayCoursePrice, depositAmount]);
+
+  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}
+        {channelsError ? (
+          <p className="mt-4 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
+            {channelsError}
+          </p>
+        ) : null}
+        {!channelsLoading && !channelsError && channelGroups.length === 0 ? (
+          <p className="mt-4 text-sm text-[var(--muted)]">暂无可用支付通道。</p>
+        ) : null}
+        {!channelsLoading && !channelsError ? (
+          <div className="mt-4 space-y-8">
+            {channelGroups.map((group) => (
+              <div key={group.groupName}>
+                <button
+                  type="button"
+                  onClick={() => toggleGroup(group.groupName)}
+                  className="mb-3 flex items-center gap-2 text-base font-semibold text-[var(--navy)]"
+                >
+                  <span
+                    className={`inline-block text-xs transition-transform ${
+                      expandedGroups[group.groupName] ? "rotate-90" : ""
+                    }`}
+                  >
+                    ▶
+                  </span>
+                  {group.groupName}
+                </button>
+                {expandedGroups[group.groupName] ? (
+                  <div className="overflow-x-auto rounded-xl border border-[var(--border)]">
+                    <table className="min-w-full text-sm">
+                      <thead className="bg-[var(--background)]">
+                        <tr className="text-left text-[var(--muted)]">
+                          <th className="px-4 py-3 font-medium">付款方式</th>
+                          <th className="px-4 py-3 font-medium">描述</th>
+                          <th className="px-4 py-3 font-medium">金额范围</th>
+                          <th className="px-4 py-3 font-medium">处理时间</th>
+                          <th className="px-4 py-3 font-medium">费用</th>
+                          <th className="px-4 py-3 font-medium text-right">操作</th>
+                        </tr>
+                      </thead>
+                      <tbody>
+                        {group.list.map((channel) => {
+                          const selected = selectedChannelId === channel.id;
+                          return (
+                            <tr key={channel.id} className="border-t border-[var(--border)]">
+                              <td className="px-4 py-3">
+                                {channel.icon ? (
+                                  // eslint-disable-next-line @next/next/no-img-element
+                                  <img
+                                    src={channel.icon}
+                                    alt={channel.name}
+                                    className="h-7 w-auto rounded object-contain"
+                                  />
+                                ) : (
+                                  <span className="text-[var(--muted)]">-</span>
+                                )}
+                              </td>
+                              <td className="px-4 py-3 text-[var(--muted)]">{channel.description}</td>
+                              <td className="px-4 py-3">{channel.amountRange}</td>
+                              <td className="px-4 py-3">{channel.processingTime}</td>
+                              <td className="px-4 py-3">{channel.fee}</td>
+                              <td className="px-4 py-3 text-right">
+                                <button
+                                  type="button"
+                                  onClick={() => {
+                                    setSelectedChannelId(channel.id);
+                                    setIsPaymentModalOpen(true);
+                                  }}
+                                  className={`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"
+                                  }`}
+                                >
+                                  {selected ? "已选择" : "选择"}
+                                </button>
+                              </td>
+                            </tr>
+                          );
+                        })}
+                      </tbody>
+                    </table>
+                  </div>
+                ) : null}
+              </div>
+            ))}
+          </div>
+        ) : null}
+      </div>
+
+      {!user && (
+        <p className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
+          提交演示订单前请先
+          <button
+            type="button"
+            className="mx-1 font-semibold underline"
+            onClick={() => router.push("/auth/login")}
+          >
+            登录
+          </button>
+          或
+          <button
+            type="button"
+            className="mx-1 font-semibold underline"
+            onClick={() => router.push("/auth/register")}
+          >
+            注册
+          </button>
+          。
+        </p>
+      )}
+
+      {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">
+            <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)]" />
+              ) : submitDialog.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)]">
+                  {submitDialog.status === "loading"
+                    ? "正在处理"
+                    : submitDialog.status === "success"
+                      ? "提交成功"
+                      : "提交失败"}
+                </p>
+                <p className="mt-1 text-sm text-[var(--muted)]">{submitDialog.message}</p>
+              </div>
+            </div>
+            {submitDialog.status === "error" ? (
+              <button
+                type="button"
+                onClick={() => setSubmitDialog((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>
+        </div>
+      ) : 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">
+            <div className="flex items-start justify-between gap-4">
+              <div>
+                <p className="text-base font-semibold text-[var(--navy)]">{t("payMethod")}</p>
+                <p className="mt-1 text-sm text-[var(--muted)]">
+                  已选择: {selectedChannel?.name ?? "-"}
+                </p>
+              </div>
+              <button
+                type="button"
+                onClick={handleClosePaymentModal}
+                className="rounded border border-[var(--border)] px-3 py-1 text-sm text-[var(--muted)] hover:bg-[var(--background)]"
+              >
+                关闭
+              </button>
+            </div>
+
+            <div className="mt-5 rounded-xl border border-dashed border-[var(--border)] bg-[var(--background)] p-4">
+              <div className="flex items-start justify-between gap-4">
+                <div>
+                  <p className="text-sm font-medium text-[var(--navy)]">购买课程</p>
+                <p className="mt-1 text-sm text-[var(--muted)]">{displayCourseTitle}</p>
+                </div>
+                <div className="rounded-full border border-amber-300 bg-amber-50 px-3 py-1 text-sm font-semibold text-amber-900">
+                  ${displayCoursePrice} USD
+                </div>
+              </div>
+            </div>
+
+            {shouldShowBankSelector ? (
+              <div className="mt-5">
+                <label className="text-sm font-medium">选择付款方式</label>
+                <div className="relative mt-1">
+                  <select
+                    value={selectedBankCode}
+                    onChange={(e) => setSelectedBankCode(e.target.value)}
+                    className="h-11 w-full appearance-none rounded-xl border border-slate-300 bg-white px-3 pr-10 text-sm text-slate-700 shadow-sm outline-none transition focus:border-[var(--navy)] focus:ring-2 focus:ring-[var(--navy)]/10 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400"
+                    disabled={bankOptionsLoading}
+                    required
+                  >
+                    <option value="">
+                      {bankOptionsLoading ? "加载中..." : "请选择付款方式"}
+                    </option>
+                    {bankOptions.map((option) => (
+                      <option key={option.value} value={option.value}>
+                        {option.label}
+                      </option>
+                    ))}
+                  </select>
+                  <span className="pointer-events-none absolute inset-y-0 right-3 flex items-center text-slate-400">
+                    <svg viewBox="0 0 20 20" className="h-4 w-4 fill-current" aria-hidden="true">
+                      <path d="M5.7 7.8a1 1 0 0 1 1.4 0L10 10.7l2.9-2.9a1 1 0 1 1 1.4 1.4l-3.6 3.6a1 1 0 0 1-1.4 0L5.7 9.2a1 1 0 0 1 0-1.4Z" />
+                    </svg>
+                  </span>
+                </div>
+                {bankOptionsError ? (
+                  <p className="mt-2 text-sm text-amber-700">{bankOptionsError}</p>
+                ) : null}
+              </div>
+            ) : null}
+
+            <div className="mt-4">
+              <label className="text-sm font-medium">
+                支付金额({selectedBank?.currency ?? "USDT"})
+              </label>
+              <input
+                required
+                type="number"
+                min="0"
+                step="0.01"
+                value={depositAmount}
+                readOnly
+                className="mt-1 w-full rounded-lg border border-[var(--border)] bg-slate-100 px-3 py-2 text-sm text-slate-600"
+                placeholder="支付金额"
+              />
+            </div>
+
+            <div className="mt-5 grid gap-4 md:grid-cols-2">
+              <div>
+                <label className="text-sm font-medium">{t("payerName")}</label>
+                <input
+                  required
+                  value={name}
+                  onChange={(e) => setName(e.target.value)}
+                  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("payerPhone")}</label>
+                <input
+                  required
+                  value={phone}
+                  onChange={(e) => setPhone(e.target.value)}
+                  className="mt-1 w-full rounded-lg border border-[var(--border)] px-3 py-2 text-sm"
+                />
+              </div>
+            </div>
+
+            <div className="mt-6">
+              <button
+                type="submit"
+                disabled={
+                  !user ||
+                  !hasCourseInfo ||
+                  !goodsId ||
+                  submitting ||
+                  !selectedChannelId ||
+                  (shouldShowBankSelector && !selectedBankCode) ||
+                  !name.trim() ||
+                  !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"
+              >
+                {submitting ? "提交中..." : t("submit")}
+              </button>
+            </div>
+          </div>
+        </div>
+      ) : null}
+    </form>
+  );
+}

+ 23 - 0
src/app/[locale]/checkout/page.tsx

@@ -0,0 +1,23 @@
+import { Suspense } from "react";
+import { getTranslations, setRequestLocale } from "next-intl/server";
+import { CheckoutForm } from "./checkout-form";
+
+type Props = { params: Promise<{ locale: string }> };
+
+export default async function CheckoutPage({ params }: Props) {
+  const { locale } = await params;
+  setRequestLocale(locale);
+  const t = await getTranslations("checkout");
+
+  return (
+    <div className="page-shell page-shell-wide">
+      <h1 className="font-serif text-3xl font-semibold text-[var(--navy)]">{t("title")}</h1>
+      {/* <p className="mt-2 text-[var(--muted)]">{t("subtitle")}</p> */}
+      <div className="mt-10">
+        <Suspense fallback={<p className="text-sm text-[var(--muted)]">加载中…</p>}>
+          <CheckoutForm />
+        </Suspense>
+      </div>
+    </div>
+  );
+}

+ 16 - 0
src/app/[locale]/contact/page.tsx

@@ -0,0 +1,16 @@
+import { getTranslations, setRequestLocale } from "next-intl/server";
+
+type Props = { params: Promise<{ locale: string }> };
+
+export default async function ContactPage({ params }: Props) {
+  const { locale } = await params;
+  setRequestLocale(locale);
+  const t = await getTranslations("contact");
+
+  return (
+    <div className="page-shell">
+      <h1 className="font-serif text-3xl font-semibold text-[var(--navy)]">{t("title")}</h1>
+      <p className="mt-8 leading-relaxed text-[var(--muted)]">{t("body")}</p>
+    </div>
+  );
+}

+ 107 - 0
src/app/[locale]/courses/[slug]/page.tsx

@@ -0,0 +1,107 @@
+import { getTranslations, setRequestLocale } from "next-intl/server";
+import { Link } from "@/i18n/navigation";
+import {
+  fetchCourses,
+  getCourseBySlug,
+  shouldShowBuyButton,
+} from "@/data/courses";
+import { CourseVideosListClient } from "@/app/[locale]/courses/[slug]/videos-list-client";
+import { CourseBuyButton } from "@/components/course-buy-button";
+
+type Props = { params: Promise<{ locale: string; slug: string }> };
+
+export default async function CourseDetailPage({ params }: Props) {
+  const { locale, slug } = await params;
+  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 t = await getTranslations("courses");
+  const tn = await getTranslations("nav");
+
+  return (
+    <div>
+      <div className="border-b border-slate-200/90 bg-gradient-to-br from-slate-900 via-[#0c1929] to-slate-800 text-white">
+        <div className="site-container section-block-compact">
+          <Link
+            href="/courses"
+            className="text-sm font-medium text-amber-300/90 transition hover:text-amber-200"
+          >
+            ← {t("title")}
+          </Link>
+          {course ? (
+            <>
+              <p className="mt-4 text-sm font-medium text-blue-200/90">
+                {tn(`categories.${course.category}` as "categories.trial")}
+              </p>
+              <h1 className="font-serif mt-2 text-3xl font-bold md:text-4xl">{course.title}</h1>
+            </>
+          ) : (
+            <h1 className="font-serif mt-4 text-3xl font-bold md:text-4xl">
+              {t("videoListTitle")}
+            </h1>
+          )}
+        </div>
+      </div>
+
+      <div className="site-container section-block-compact">
+        {course ? (
+          <>
+            <div
+              className={`shadow-card h-48 overflow-hidden rounded-3xl bg-gradient-to-br md:h-56 ${
+                course.coverUrl ? "" : course.coverGradient
+              }`}
+              style={
+                course.coverUrl
+                  ? {
+                      backgroundImage: `url(${course.coverUrl})`,
+                      backgroundSize: "cover",
+                      backgroundPosition: "center",
+                    }
+                  : undefined
+              }
+            />
+            <p className="mt-8 text-lg leading-relaxed text-slate-600">{course.subtitle}</p>
+            <div className="mt-6 flex flex-col gap-3 border-t border-slate-200 pt-6 sm:flex-row sm:items-center sm:justify-between sm:gap-6">
+              <p className="text-[11px] font-semibold uppercase tracking-widest text-slate-400">
+                {t("lessons", { count: course.lessonCount })}
+              </p>
+              {course.price > 0 ? (
+                <div className="flex w-fit items-baseline gap-2.5 rounded-2xl bg-gradient-to-br from-amber-50 via-amber-50 to-amber-100/90 px-4 py-2.5 shadow-md shadow-amber-900/5 ring-1 ring-amber-200/90 tabular-nums">
+                  <span className="text-3xl font-bold leading-none tracking-tight text-amber-900 md:text-4xl">
+                    ${course.price}
+                  </span>
+                  <span className="text-xs font-bold uppercase leading-none text-amber-800/75">
+                    {course.currency}
+                  </span>
+                </div>
+              ) : (
+                <span className="inline-flex w-fit items-center rounded-full bg-emerald-50 px-4 py-1.5 text-base font-bold tracking-tight text-emerald-700 ring-1 ring-emerald-200/70">
+                  {t("free")}
+                </span>
+              )}
+            </div>
+          </>
+        ) : null}
+        <CourseVideosListClient
+          goodsId={goodsId}
+          allHref="/courses"
+          allLabel={t("filterAll")}
+        />
+        <div className="mt-10 flex flex-wrap gap-4">
+          {course && shouldShowBuyButton(course) ? (
+            <CourseBuyButton
+              courseSlug={course.slug}
+              courseId={course.id}
+              courseTitle={course.title}
+              coursePrice={course.price}
+              className="inline-flex rounded-full bg-gradient-to-r from-blue-600 to-blue-700 px-8 py-3.5 text-sm font-bold text-white shadow-lg shadow-blue-600/25 transition-all duration-300 hover:from-blue-500 hover:to-blue-600"
+            />
+          ) : null}
+        </div>
+      </div>
+    </div>
+  );
+}

+ 240 - 0
src/app/[locale]/courses/[slug]/videos-list-client.tsx

@@ -0,0 +1,240 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useTranslations } from "next-intl";
+import { Link } from "@/i18n/navigation";
+import { fetchCourseVideos, type CourseVideo } from "@/data/courses";
+
+type Props = {
+  goodsId: string;
+  allHref?: string;
+  allLabel?: string;
+};
+
+export function CourseVideosListClient({ goodsId, allHref, allLabel }: Props) {
+  const pageSize = 10;
+  const [page, setPage] = useState(1);
+  const [videos, setVideos] = useState<CourseVideo[] | null>(null);
+  const [total, setTotal] = useState(0);
+  const [activeVideo, setActiveVideo] = useState<CourseVideo | null>(null);
+  const [isModalVisible, setIsModalVisible] = useState(false);
+  const t = useTranslations("courses");
+
+  useEffect(() => {
+    let cancelled = false;
+    void fetchCourseVideos(goodsId, { current: page, row: pageSize })
+      .then((result) => {
+        if (cancelled) return;
+        setVideos(result.list);
+        setTotal(result.page.total);
+      })
+      .catch(() => {
+        if (cancelled) return;
+        setVideos([]);
+        setTotal(0);
+      });
+
+    return () => {
+      cancelled = true;
+    };
+  }, [goodsId, page]);
+
+  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 modalDurationMs = 220;
+
+  function openPlayer(video: CourseVideo) {
+    setActiveVideo(video);
+    requestAnimationFrame(() => setIsModalVisible(true));
+  }
+
+  function closePlayer() {
+    setIsModalVisible(false);
+    window.setTimeout(() => {
+      setActiveVideo(null);
+    }, modalDurationMs);
+  }
+
+  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("videoListTitle")}
+        </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("videoListLoading")}</p>
+      ) : videos.length > 0 ? (
+        <ul className="mt-6 grid gap-6 md:grid-cols-2 2xl:grid-cols-3">
+          {videos.map((video) => {
+            const canPlay = video.payType === 0 && Boolean(video.playUrl);
+            return (
+            <li
+              key={video.id}
+              className="shadow-card flex h-full min-h-0 flex-col overflow-hidden rounded-2xl border border-slate-200/90 bg-white"
+            >
+              <div className="relative h-44 w-full shrink-0 overflow-hidden bg-slate-100">
+                {video.frontUrl ? (
+                  <img
+                    src={video.frontUrl}
+                    alt={video.videoName}
+                    className="h-full w-full object-cover"
+                  />
+                ) : (
+                  <div className="flex h-full items-center justify-center text-sm text-slate-400">
+                    {t("videoNoCover")}
+                  </div>
+                )}
+                {canPlay ? (
+                  <button
+                    type="button"
+                    onClick={() => openPlayer(video)}
+                    className="group absolute inset-0 flex cursor-pointer items-center justify-center border-0 bg-black/40 p-0 transition-colors hover:bg-black/50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
+                  >
+                    <span className="flex h-14 w-14 items-center justify-center rounded-full bg-white/95 text-blue-700 shadow-lg transition-transform group-hover:scale-105">
+                      <svg
+                        aria-hidden="true"
+                        viewBox="0 0 24 24"
+                        className="ml-0.5 h-8 w-8"
+                        fill="currentColor"
+                      >
+                        <path d="M8 5v14l11-7z" />
+                      </svg>
+                    </span>
+                    <span className="sr-only">{t("videoPlay")}</span>
+                  </button>
+                ) : null}
+                {video.payType === 1 ? (
+                  <>
+                    <div className="absolute inset-0 bg-black/45" />
+                    <span className="absolute bottom-2 right-2 inline-flex items-center gap-1 rounded-full bg-black/70 px-2 py-1 text-xs font-medium text-white">
+                      <svg
+                        aria-hidden="true"
+                        viewBox="0 0 24 24"
+                        className="h-3.5 w-3.5"
+                        fill="none"
+                        stroke="currentColor"
+                        strokeWidth="2"
+                        strokeLinecap="round"
+                        strokeLinejoin="round"
+                      >
+                        <rect x="4" y="11" width="16" height="9" rx="2" />
+                        <path d="M8 11V8a4 4 0 1 1 8 0v3" />
+                      </svg>
+                    </span>
+                  </>
+                ) : null}
+              </div>
+              <div className="flex min-h-0 min-w-0 flex-1 flex-col border-t border-slate-100 px-5 pb-5 pt-4">
+                <h3 className="font-serif text-lg font-bold leading-snug tracking-tight text-[var(--navy)] break-words">
+                  {video.videoName}
+                </h3>
+                <p className="mt-2.5 border-l-[3px] border-blue-600/35 pl-3 text-[13px] font-medium leading-snug text-slate-600 break-words">
+                  {video.title}
+                </p>
+                <p className="mt-3 line-clamp-3 text-sm leading-relaxed text-slate-500 break-words">
+                  {video.introduction || t("videoNoIntro")}
+                </p>
+                <div className="mt-auto shrink-0" aria-hidden />
+              </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("videoListEmpty")}
+        </p>
+      )}
+      <div className="mt-4 flex items-center justify-end gap-3">
+        <button
+          type="button"
+          onClick={() => {
+            if (!canPrev) return;
+            setVideos(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;
+            setVideos(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>
+      {activeVideo ? (
+        <div
+          className={`fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-[2px] transition-opacity duration-200 ${
+            isModalVisible
+              ? "bg-slate-950/70 opacity-100"
+              : "bg-slate-950/0 opacity-0"
+          }`}
+          onClick={closePlayer}
+        >
+          <div
+            className={`w-full max-w-4xl overflow-hidden rounded-2xl border border-slate-200/20 bg-black shadow-2xl transition duration-200 ${
+              isModalVisible
+                ? "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-white/10 bg-slate-900/95 px-4 py-3 text-white">
+              <p className="truncate text-sm font-semibold">
+                {t("videoNowPlaying")}: {activeVideo.videoName}
+              </p>
+              <button
+                type="button"
+                onClick={closePlayer}
+                className="rounded-full border border-white/25 px-3 py-1 text-xs font-medium text-white/90 hover:bg-white/10"
+              >
+                {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]"
+              />
+            )}
+          </div>
+        </div>
+      ) : null}
+    </div>
+  );
+}

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

@@ -0,0 +1,101 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useTranslations } from "next-intl";
+import { Link } from "@/i18n/navigation";
+import { CourseBuyButton } from "@/components/course-buy-button";
+import { fetchCourses, type Course, type CourseCategory } from "@/data/courses";
+
+type Props = {
+  active: CourseCategory | null;
+  initialCourses: Course[];
+};
+
+function shouldShowBuyButton(course: Course): boolean {
+  return !(course.goodsType === 1 || course.customType === 2);
+}
+
+export function CoursesListClient({ active, initialCourses }: Props) {
+  const [courses, setCourses] = useState(initialCourses);
+  const t = useTranslations("courses");
+  const tn = useTranslations("nav");
+
+  useEffect(() => {
+    let cancelled = false;
+    void fetchCourses(active ?? undefined)
+      .then((data) => {
+        if (cancelled) return;
+        setCourses(data);
+      })
+      .catch(() => {
+        // Keep server-rendered fallback data.
+      });
+    return () => {
+      cancelled = true;
+    };
+  }, [active]);
+
+  return (
+    <ul className="mt-6 grid auto-rows-fr gap-6 md:grid-cols-2 md:gap-8">
+      {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">
+            <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
+              }`}
+              style={c.coverUrl ? { backgroundImage: `url(${c.coverUrl})`, backgroundSize: "cover", backgroundPosition: "center" } : undefined}
+            >
+              <span className="absolute bottom-3 left-3 rounded-md bg-black/35 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-white backdrop-blur-sm">
+                {tn(`categories.${c.category}` as "categories.trial")}
+              </span>
+            </div>
+            <div className="flex min-h-0 min-w-0 flex-1 flex-col p-5">
+              <div className="flex items-start justify-between gap-3">
+                <h2 className="line-clamp-2 min-w-0 flex-1 font-serif text-lg font-bold leading-snug tracking-tight text-[var(--navy)] transition group-hover:text-blue-700">
+                  {c.title}
+                </h2>
+                <div className="shrink-0 translate-y-px">
+                  {c.price === 0 ? (
+                    <span className="inline-flex w-fit items-center rounded-full bg-emerald-50 px-3 py-2 text-sm font-bold tracking-tight text-emerald-700 ring-1 ring-emerald-200/70">
+                      {t("free")}
+                    </span>
+                  ) : (
+                    <div className="flex w-fit items-baseline gap-2 rounded-xl bg-gradient-to-br from-amber-50 via-amber-50 to-amber-100/90 px-3.5 py-2 shadow-sm ring-1 ring-amber-200/90 tabular-nums">
+                      <span className="text-2xl font-bold leading-none tracking-tight text-amber-900">
+                        ${c.price}
+                      </span>
+                      <span className="text-[11px] font-bold uppercase leading-none text-amber-800/75">
+                        {c.currency}
+                      </span>
+                    </div>
+                  )}
+                </div>
+              </div>
+              <p className="mt-2 line-clamp-2 min-h-[2.5rem] text-sm leading-relaxed text-slate-600">
+                {c.subtitle}
+              </p>
+              <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"
+                >
+                  {t("detail")}
+                </Link>
+                {shouldShowBuyButton(c) ? (
+                  <CourseBuyButton
+                    courseSlug={c.slug}
+                    courseId={c.id}
+                    courseTitle={c.title}
+                    coursePrice={c.price}
+                    className="inline-flex h-10 min-w-[7.5rem] items-center justify-center rounded-full bg-gradient-to-r from-blue-600 to-blue-700 px-4 text-sm font-semibold text-white shadow-md shadow-blue-600/20"
+                  />
+                ) : null}
+              </div>
+            </div>
+          </article>
+        </li>
+      ))}
+    </ul>
+  );
+}

+ 78 - 0
src/app/[locale]/courses/page.tsx

@@ -0,0 +1,78 @@
+import { getTranslations, setRequestLocale } from "next-intl/server";
+import { Link } from "@/i18n/navigation";
+import {
+  COURSE_CATEGORIES,
+  fetchCourses,
+  type CourseCategory,
+} from "@/data/courses";
+import { cn } from "@/lib/utils";
+import { CoursesListClient } from "./courses-list-client";
+
+type Props = {
+  params: Promise<{ locale: string }>;
+  searchParams: Promise<{ cat?: string }>;
+};
+
+function isCategory(s: string | undefined): s is CourseCategory {
+  return (
+    s !== undefined &&
+    COURSE_CATEGORIES.some((c) => c.id === (s as CourseCategory))
+  );
+}
+
+export default async function CoursesPage({ params, searchParams }: Props) {
+  const { locale } = await params;
+  setRequestLocale(locale);
+  const { cat } = await searchParams;
+  const active = isCategory(cat) ? cat : null;
+  const list = await fetchCourses(active ?? undefined);
+  const t = await getTranslations("courses");
+  const tn = await getTranslations("nav");
+
+  return (
+    <div>
+      <div className="border-b border-slate-200/90 bg-gradient-to-br from-slate-900 via-[#0c1929] to-slate-800 py-12 text-white md:py-14">
+        <div className="site-container">
+          <p className="text-sm font-semibold text-amber-300/90">{t("pageEyebrow")}</p>
+          <h1 className="font-serif mt-2 text-3xl font-bold md:text-4xl">{t("title")}</h1>
+          <p className="mt-3 max-w-2xl text-slate-300">{t("subtitle")}</p>
+        </div>
+      </div>
+
+      <div className="site-container section-block-compact">
+        <div className="flex flex-wrap gap-2">
+          <Link
+            href="/courses"
+            className={cn(
+              "rounded-full px-5 py-2 text-sm font-semibold transition",
+              !active
+                ? "bg-blue-600 text-white shadow-lg shadow-blue-600/25"
+                : "border border-slate-200 bg-white text-slate-600 hover:border-blue-200 hover:text-blue-800",
+            )}
+          >
+            {t("filterAll")}
+          </Link>
+          {COURSE_CATEGORIES.map((c) => (
+            <Link
+              key={c.id}
+              href={`/courses?cat=${c.id}`}
+              className={cn(
+                "rounded-full px-5 py-2 text-sm font-semibold transition",
+                active === c.id
+                  ? "bg-blue-600 text-white shadow-lg shadow-blue-600/25"
+                  : "border border-slate-200 bg-white text-slate-600 hover:border-blue-200 hover:text-blue-800",
+              )}
+            >
+              {tn(`categories.${c.id}` as "categories.trial")}
+            </Link>
+          ))}
+        </div>
+
+        <CoursesListClient
+          active={active}
+          initialCourses={list}
+        />
+      </div>
+    </div>
+  );
+}

+ 29 - 0
src/app/[locale]/faq/page.tsx

@@ -0,0 +1,29 @@
+import { getTranslations, setRequestLocale } from "next-intl/server";
+
+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>
+    </div>
+  );
+}

+ 53 - 0
src/app/[locale]/layout.tsx

@@ -0,0 +1,53 @@
+import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+import { hasLocale, NextIntlClientProvider } from "next-intl";
+import { getMessages, setRequestLocale } from "next-intl/server";
+import { SiteFooter } from "@/components/site-footer";
+import { SiteHeader } from "@/components/site-header";
+import { LocaleHtmlLang } from "@/components/locale-html-lang";
+import { routing } from "@/i18n/routing";
+import { AuthProvider } from "@/providers/auth-provider";
+
+export function generateStaticParams() {
+  return routing.locales.map((locale) => ({ locale }));
+}
+
+export async function generateMetadata({
+  params,
+}: {
+  params: Promise<{ locale: string }>;
+}): Promise<Metadata> {
+  const { locale } = await params;
+  const { getTranslations } = await import("next-intl/server");
+  const t = await getTranslations({ locale, namespace: "metadata" });
+  return {
+    title: t("title"),
+    description: t("description"),
+  };
+}
+
+export default async function LocaleLayout({
+  children,
+  params,
+}: Readonly<{
+  children: React.ReactNode;
+  params: Promise<{ locale: string }>;
+}>) {
+  const { locale } = await params;
+  if (!hasLocale(routing.locales, locale)) {
+    notFound();
+  }
+  setRequestLocale(locale);
+  const messages = await getMessages();
+
+  return (
+    <NextIntlClientProvider locale={locale} messages={messages}>
+      <AuthProvider>
+        <LocaleHtmlLang />
+        <SiteHeader />
+        <main className="flex-1">{children}</main>
+        <SiteFooter />
+      </AuthProvider>
+    </NextIntlClientProvider>
+  );
+}

+ 16 - 0
src/app/[locale]/legal/copyright/page.tsx

@@ -0,0 +1,16 @@
+import { getTranslations, setRequestLocale } from "next-intl/server";
+
+type Props = { params: Promise<{ locale: string }> };
+
+export default async function CopyrightPage({ params }: Props) {
+  const { locale } = await params;
+  setRequestLocale(locale);
+  const t = await getTranslations("legal");
+
+  return (
+    <div className="page-shell">
+      <h1 className="font-serif text-3xl font-semibold text-[var(--navy)]">{t("copyrightTitle")}</h1>
+      <p className="mt-8 leading-relaxed text-[var(--muted)]">{t("copyrightBody")}</p>
+    </div>
+  );
+}

+ 16 - 0
src/app/[locale]/legal/privacy/page.tsx

@@ -0,0 +1,16 @@
+import { getTranslations, setRequestLocale } from "next-intl/server";
+
+type Props = { params: Promise<{ locale: string }> };
+
+export default async function PrivacyPage({ params }: Props) {
+  const { locale } = await params;
+  setRequestLocale(locale);
+  const t = await getTranslations("legal");
+
+  return (
+    <div className="page-shell">
+      <h1 className="font-serif text-3xl font-semibold text-[var(--navy)]">{t("privacyTitle")}</h1>
+      <p className="mt-8 leading-relaxed text-[var(--muted)]">{t("privacyBody")}</p>
+    </div>
+  );
+}

+ 16 - 0
src/app/[locale]/legal/terms/page.tsx

@@ -0,0 +1,16 @@
+import { getTranslations, setRequestLocale } from "next-intl/server";
+
+type Props = { params: Promise<{ locale: string }> };
+
+export default async function TermsPage({ params }: Props) {
+  const { locale } = await params;
+  setRequestLocale(locale);
+  const t = await getTranslations("legal");
+
+  return (
+    <div className="page-shell">
+      <h1 className="font-serif text-3xl font-semibold text-[var(--navy)]">{t("termsTitle")}</h1>
+      <p className="mt-8 leading-relaxed text-[var(--muted)]">{t("termsBody")}</p>
+    </div>
+  );
+}

+ 239 - 0
src/app/[locale]/page.tsx

@@ -0,0 +1,239 @@
+import { getTranslations, setRequestLocale } from "next-intl/server";
+import { Link } from "@/i18n/navigation";
+import { courses, type CourseCategory } from "@/data/courses";
+import { SectionHeading } from "@/components/section-heading";
+import { IconBook, IconChart, IconShield } from "@/components/icons";
+
+type Props = { params: Promise<{ locale: string }> };
+
+export default async function HomePage({ params }: Props) {
+  const { locale } = await params;
+  setRequestLocale(locale);
+  const t = await getTranslations("home");
+  const tc = await getTranslations("courses");
+  const featured = courses.slice(0, 5);
+  const featuredCoverMap: Record<CourseCategory, string> = {
+    trial: "/免费试听--状态--锁定、解锁.png",
+    practical: "/系统课程--状态--锁定、解锁.png",
+    book: "/拆书章节视频--状态--锁定、解锁.png",
+    topic: "/专题视频-状态--锁定、解锁.png",
+    strategy: "/策略报告--状态--锁定、解锁.png",
+  };
+
+  const stats = [
+    { value: t("stat1"), label: t("stat1Label") },
+    { value: t("stat2"), label: t("stat2Label") },
+    { value: t("stat3"), label: t("stat3Label") },
+    { value: t("stat4"), label: t("stat4Label") },
+  ];
+
+  const pillars = [
+    {
+      icon: IconBook,
+      title: t("pillar1Title"),
+      desc: t("pillar1Desc"),
+      accent: "from-blue-500 to-indigo-600",
+    },
+    {
+      icon: IconChart,
+      title: t("pillar2Title"),
+      desc: t("pillar2Desc"),
+      accent: "from-amber-500 to-orange-600",
+    },
+    {
+      icon: IconShield,
+      title: t("pillar3Title"),
+      desc: t("pillar3Desc"),
+      accent: "from-emerald-500 to-teal-600",
+    },
+  ];
+
+  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">
+        <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" />
+
+        <div className="site-container relative grid gap-10 py-14 md:grid-cols-2 md:items-center md:py-24 lg:gap-16">
+          <div>
+            <p className="text-sm font-semibold tracking-wide text-amber-300/95">
+              {t("heroEyebrow")}
+            </p>
+            <h1 className="font-serif mt-5 text-4xl font-bold leading-[1.15] tracking-tight md:text-5xl lg:text-[3.25rem]">
+              {t("heroTitle")}
+            </h1>
+            <p className="mt-6 max-w-lg text-lg leading-relaxed text-slate-300">
+              {t("heroSubtitle")}
+            </p>
+            <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"
+              >
+                {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"
+              >
+                {t("ctaAbout")}
+              </Link>
+            </div>
+          </div>
+
+          <div className="relative hidden md:block">
+            <div className="relative z-10 rounded-3xl border border-white/10 bg-white/5 p-6 shadow-2xl shadow-black/30 backdrop-blur-md">
+              <div className="flex items-center gap-3 border-b border-white/10 pb-4">
+                <div className="h-3 w-3 rounded-full bg-red-400/90" />
+                <div className="h-3 w-3 rounded-full bg-amber-400/90" />
+                <div className="h-3 w-3 rounded-full bg-emerald-400/90" />
+                <span className="ml-2 text-xs text-slate-400">Learning Hub</span>
+              </div>
+              <div className="mt-5 space-y-3">
+                {[1, 2, 3].map((i) => (
+                  <div
+                    key={i}
+                    className="flex items-center gap-3 rounded-xl bg-white/5 p-3 ring-1 ring-white/10"
+                  >
+                    <div className="h-10 w-10 shrink-0 rounded-lg bg-gradient-to-br from-blue-500 to-indigo-600 opacity-90" />
+                    <div className="min-w-0 flex-1">
+                      <div className="h-2.5 w-3/4 max-w-[180px] rounded bg-white/20" />
+                      <div className="mt-2 h-2 w-1/2 max-w-[100px] rounded bg-white/10" />
+                    </div>
+                  </div>
+                ))}
+              </div>
+              <p className="mt-5 text-center text-xs text-slate-500">
+                课程进度 · 会员权益 · 问答解锁(示意)
+              </p>
+            </div>
+            <div className="absolute -bottom-4 -right-4 -z-0 h-40 w-40 rounded-3xl bg-gradient-to-br from-blue-600/40 to-indigo-800/40 blur-2xl" />
+          </div>
+        </div>
+
+        {/* 数据条 */}
+        <div className="border-t border-white/10 bg-black/20">
+          <div className="site-container grid grid-cols-2 gap-6 py-8 md:grid-cols-4">
+            {stats.map((s) => (
+              <div key={s.label} className="text-center md:text-left">
+                <p className="font-serif text-3xl font-bold text-white md:text-4xl">{s.value}</p>
+                <p className="mt-1 text-sm text-slate-400">{s.label}</p>
+              </div>
+            ))}
+          </div>
+        </div>
+      </section>
+
+      {/* 三大支柱 */}
+      <section className="site-container section-block">
+        <SectionHeading
+          eyebrow={t("pillarsEyebrow")}
+          title={t("pillarsTitle")}
+          align="center"
+        />
+        <div className="mt-14 grid gap-8 md:grid-cols-3">
+          {pillars.map((p) => {
+            const Icon = p.icon;
+            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"
+            >
+              <div
+                className={`flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br ${p.accent} text-white shadow-lg`}
+              >
+                <Icon className="h-7 w-7" aria-hidden />
+              </div>
+              <h3 className="font-serif mt-6 text-xl font-bold text-[var(--navy)]">{p.title}</h3>
+              <p className="mt-3 text-sm leading-relaxed text-slate-600">{p.desc}</p>
+            </div>
+            );
+          })}
+        </div>
+      </section>
+
+      {/* 精选课程 */}
+      <section className="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">
+              <SectionHeading
+                eyebrow={t("featuredEyebrow")}
+                title={t("featuredTitle")}
+                align="left"
+              />
+            </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"
+            >
+              {t("viewAll")}
+            </Link>
+          </div>
+
+          <div className="mt-12 grid gap-8 md:grid-cols-2 lg:grid-cols-3">
+            {featured.map((c) => (
+              <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"
+              >
+                <div className="relative h-36 overflow-hidden">
+                  {/* eslint-disable-next-line @next/next/no-img-element */}
+                  <img
+                    src={featuredCoverMap[c.category]}
+                    alt={c.title}
+                    className="absolute inset-0 h-full w-full object-cover transition duration-500 group-hover:scale-105"
+                  />
+                  <span className="absolute right-4 top-4 rounded-full bg-white/95 px-3 py-1 text-xs font-bold text-slate-800 shadow-sm">
+                    {tc("lessons", { count: c.lessonCount })}
+                  </span>
+                </div>
+                <div className="p-6">
+                  <h3 className="font-serif text-lg font-bold text-[var(--navy)] transition group-hover:text-blue-700">
+                    {c.title}
+                  </h3>
+                  <p className="mt-2 line-clamp-2 text-sm leading-relaxed text-slate-600">
+                    {c.subtitle}
+                  </p>
+                  <span className="mt-4 inline-flex items-center text-sm font-semibold text-blue-600">
+                    {tc("detail")}
+                    <span className="ml-1 transition group-hover:translate-x-0.5">→</span>
+                  </span>
+                </div>
+              </Link>
+            ))}
+          </div>
+        </div>
+      </section>
+
+      {/* CTA 带 */}
+      <section className="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" />
+          <h2 className="font-serif relative text-2xl font-bold md:text-3xl">{t("ctaBandTitle")}</h2>
+          <p className="relative mx-auto mt-4 max-w-2xl text-slate-200">
+            {t("ctaBandSubtitle")}
+          </p>
+          <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"
+            >
+              {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"
+            >
+              {t("ctaBandSecondary")}
+            </Link>
+          </div>
+        </div>
+      </section>
+    </div>
+  );
+}

+ 16 - 0
src/app/[locale]/purchase-guarantee/page.tsx

@@ -0,0 +1,16 @@
+import { getTranslations, setRequestLocale } from "next-intl/server";
+
+type Props = { params: Promise<{ locale: string }> };
+
+export default async function GuaranteePage({ params }: Props) {
+  const { locale } = await params;
+  setRequestLocale(locale);
+  const t = await getTranslations("guarantee");
+
+  return (
+    <div className="page-shell">
+      <h1 className="font-serif text-3xl font-semibold text-[var(--navy)]">{t("title")}</h1>
+      <p className="mt-8 leading-relaxed text-[var(--muted)]">{t("body")}</p>
+    </div>
+  );
+}

+ 172 - 0
src/app/[locale]/quiz/page.tsx

@@ -0,0 +1,172 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import { Link } from "@/i18n/navigation";
+import { useMemo, useState } from "react";
+import { useAuth } from "@/providers/auth-provider";
+import { computeQuizAccess } from "@/lib/quiz-rules";
+import { sumOrders } from "@/lib/auth-types";
+import { useNow } from "@/hooks/use-now";
+
+const CORRECT = { q1: "b", q2: "b", q3: "b" } as const;
+
+export default function QuizPage() {
+  const t = useTranslations("quiz");
+  const { user } = useAuth();
+  const now = useNow(Boolean(user));
+  const quizState = useMemo(() => {
+    if (!user) return computeQuizAccess(0, null);
+    return computeQuizAccess(
+      sumOrders(user.orders),
+      user.thresholdReachedAt,
+      now,
+    );
+  }, [user, now]);
+  const [q1, setQ1] = useState("");
+  const [q2, setQ2] = useState("");
+  const [q3, setQ3] = useState("");
+  const [submitted, setSubmitted] = useState(false);
+
+  const unlocked = user && quizState.status === "unlocked";
+  const allCorrect =
+    submitted &&
+    q1 === CORRECT.q1 &&
+    q2 === CORRECT.q2 &&
+    q3 === CORRECT.q3;
+
+  if (!user) {
+    return (
+      <div className="page-shell-sm py-16 text-center md:py-20">
+        <p className="text-[var(--muted)]">请先登录后访问。</p>
+        <Link href="/auth/login" className="mt-6 inline-block text-[var(--accent)] underline">
+          登录
+        </Link>
+      </div>
+    );
+  }
+
+  if (!unlocked) {
+    return (
+      <div className="page-shell-sm py-16 text-center md:py-20">
+        <h1 className="font-serif text-2xl font-semibold text-[var(--navy)]">{t("locked")}</h1>
+        <p className="mt-4 text-[var(--muted)]">{t("lockedDesc")}</p>
+        <Link
+          href="/account"
+          className="mt-8 inline-block rounded-full bg-[var(--navy)] px-8 py-3 text-sm font-semibold text-white"
+        >
+          {t("backAccount")}
+        </Link>
+      </div>
+    );
+  }
+
+  return (
+    <div className="page-shell-md">
+      <h1 className="font-serif text-3xl font-semibold text-[var(--navy)]">{t("title")}</h1>
+      <p className="mt-2 text-sm text-[var(--muted)]">{t("intro")}</p>
+
+      <form
+        className="mt-10 space-y-8"
+        onSubmit={(e) => {
+          e.preventDefault();
+          setSubmitted(true);
+        }}
+      >
+        <fieldset className="space-y-3">
+          <legend className="text-sm font-medium text-[var(--navy)]">{t("q1")}</legend>
+          {(
+            [
+              ["a", t("q1a")],
+              ["b", t("q1b")],
+              ["c", t("q1c")],
+            ] as const
+          ).map(([v, label]) => (
+            <label key={v} className="flex cursor-pointer items-center gap-2 text-sm">
+              <input
+                type="radio"
+                name="q1"
+                value={v}
+                checked={q1 === v}
+                onChange={() => setQ1(v)}
+              />
+              {label}
+            </label>
+          ))}
+        </fieldset>
+
+        <fieldset className="space-y-3">
+          <legend className="text-sm font-medium text-[var(--navy)]">{t("q2")}</legend>
+          {(
+            [
+              ["a", t("q2a")],
+              ["b", t("q2b")],
+              ["c", t("q2c")],
+            ] as const
+          ).map(([v, label]) => (
+            <label key={v} className="flex cursor-pointer items-center gap-2 text-sm">
+              <input
+                type="radio"
+                name="q2"
+                value={v}
+                checked={q2 === v}
+                onChange={() => setQ2(v)}
+              />
+              {label}
+            </label>
+          ))}
+        </fieldset>
+
+        <fieldset className="space-y-3">
+          <legend className="text-sm font-medium text-[var(--navy)]">{t("q3")}</legend>
+          {(
+            [
+              ["a", t("q3a")],
+              ["b", t("q3b")],
+              ["c", t("q3c")],
+            ] as const
+          ).map(([v, label]) => (
+            <label key={v} className="flex cursor-pointer items-center gap-2 text-sm">
+              <input
+                type="radio"
+                name="q3"
+                value={v}
+                checked={q3 === v}
+                onChange={() => setQ3(v)}
+              />
+              {label}
+            </label>
+          ))}
+        </fieldset>
+
+        <button
+          type="submit"
+          className="rounded-full bg-[var(--navy)] px-8 py-3 text-sm font-semibold text-white"
+        >
+          {t("submit")}
+        </button>
+      </form>
+
+      {submitted && (
+        <div className="mt-10 rounded-2xl border border-[var(--border)] bg-[var(--card)] p-6">
+          {allCorrect ? (
+            <>
+              <p className="font-serif text-xl font-semibold text-emerald-800">{t("resultOk")}</p>
+              <p className="mt-2 text-[var(--navy)]">
+                {t("resultScholarship", { amount: "1,000" })}
+              </p>
+              <p className="mt-2 text-sm text-[var(--muted)]">{t("resultContact")}</p>
+            </>
+          ) : (
+            <p className="text-amber-800">{t("resultFail")}</p>
+          )}
+        </div>
+      )}
+
+      <p className="mt-10 text-center">
+        <Link href="/account" className="text-sm text-[var(--accent)] hover:underline">
+          {t("backAccount")}
+        </Link>
+      </p>
+    </div>
+  );
+}

+ 35 - 0
src/app/api/channel/bank/list/route.ts

@@ -0,0 +1,35 @@
+import { NextResponse } from "next/server";
+
+function getRemittanceTarget(): string {
+  const explicit = process.env.API_PROXY_TARGET_REMITTANCE?.trim();
+  if (explicit) return explicit.replace(/\/$/, "");
+  return "http://192.168.0.33:8504";
+}
+
+export async function POST(request: Request) {
+  const target = `${getRemittanceTarget()}/channel/bank/list`;
+  const bodyText = await request.text();
+  const accessToken = request.headers.get("access-token");
+  const authorization = request.headers.get("authorization");
+
+  const upstream = await fetch(target, {
+    method: "POST",
+    headers: {
+      "Content-Type": "application/json",
+      ...(accessToken ? { "access-token": accessToken } : {}),
+      ...(authorization ? { Authorization: authorization } : {}),
+    },
+    body: bodyText || "{}",
+    cache: "no-store",
+  });
+
+  const text = await upstream.text();
+  let payload: unknown = text;
+  try {
+    payload = JSON.parse(text);
+  } catch {
+    // keep raw text payload when upstream doesn't return JSON
+  }
+
+  return NextResponse.json(payload, { status: upstream.status });
+}

+ 14 - 0
src/app/api/courses/route.ts

@@ -0,0 +1,14 @@
+import { NextResponse } from "next/server";
+import { COURSE_CATEGORIES, fetchCourses, type CourseCategory } from "@/data/courses";
+
+function isCategory(s: string | null): s is CourseCategory {
+  return s !== null && COURSE_CATEGORIES.some((c) => c.id === (s as CourseCategory));
+}
+
+export async function GET(request: Request) {
+  const { searchParams } = new URL(request.url);
+  const cat = searchParams.get("cat");
+  const category = isCategory(cat) ? cat : undefined;
+  const data = await fetchCourses(category);
+  return NextResponse.json({ data });
+}

+ 35 - 0
src/app/api/remittance/channel/list/route.ts

@@ -0,0 +1,35 @@
+import { NextResponse } from "next/server";
+
+function getRemittanceTarget(): string {
+  const explicit = process.env.API_PROXY_TARGET_REMITTANCE?.trim();
+  if (explicit) return explicit.replace(/\/$/, "");
+  return "http://192.168.0.33:8504";
+}
+
+export async function POST(request: Request) {
+  const target = `${getRemittanceTarget()}/remittance/channel/list`;
+  const bodyText = await request.text();
+  const accessToken = request.headers.get("access-token");
+  const authorization = request.headers.get("authorization");
+
+  const upstream = await fetch(target, {
+    method: "POST",
+    headers: {
+      "Content-Type": "application/json",
+      ...(accessToken ? { "access-token": accessToken } : {}),
+      ...(authorization ? { Authorization: authorization } : {}),
+    },
+    body: bodyText || "{}",
+    cache: "no-store",
+  });
+
+  const text = await upstream.text();
+  let payload: unknown = text;
+  try {
+    payload = JSON.parse(text);
+  } catch {
+    // keep raw text payload when upstream doesn't return JSON
+  }
+
+  return NextResponse.json(payload, { status: upstream.status });
+}

+ 65 - 0
src/app/api/xfgpay/pay/[...segments]/route.ts

@@ -0,0 +1,65 @@
+import { NextResponse } from "next/server";
+
+function getRemittanceTarget(): string {
+  const explicit = process.env.API_PROXY_TARGET_REMITTANCE?.trim();
+  if (explicit) return explicit.replace(/\/$/, "");
+  return "http://192.168.0.33:8504";
+}
+
+type PayRequestBody = {
+  goodIds?: string[] | string;
+  payName?: string;
+  payPhone?: string;
+};
+
+type Params = {
+  segments: string[];
+};
+
+export async function POST(
+  request: Request,
+  { params }: { params: Promise<Params> },
+) {
+  const { segments } = await params;
+  if (!Array.isArray(segments) || segments.length < 4) {
+    return NextResponse.json({ code: 400, msg: "path invalid" }, { status: 400 });
+  }
+
+  const body = (await request.json()) as PayRequestBody;
+  const goodIds = Array.isArray(body.goodIds)
+    ? body.goodIds.map((v) => String(v)).filter(Boolean)
+    : typeof body.goodIds === "string" && body.goodIds.trim()
+      ? [body.goodIds.trim()]
+      : [];
+
+  const upstreamBody = {
+    goodIds,
+    payName: typeof body.payName === "string" ? body.payName : "",
+    payPhone: typeof body.payPhone === "string" ? body.payPhone : "",
+  };
+
+  const accessToken = request.headers.get("access-token");
+  const authorization = request.headers.get("authorization");
+  const safeSegments = segments.map((s) => encodeURIComponent(decodeURIComponent(s)));
+  const upstreamPath = `/${safeSegments.join("/")}`;
+
+  const upstream = await fetch(`${getRemittanceTarget()}${upstreamPath}`, {
+    method: "POST",
+    headers: {
+      "Content-Type": "application/json",
+      ...(accessToken ? { "access-token": accessToken } : {}),
+      ...(authorization ? { Authorization: authorization } : {}),
+    },
+    body: JSON.stringify(upstreamBody),
+    cache: "no-store",
+  });
+
+  const text = await upstream.text();
+  let payload: unknown = text;
+  try {
+    payload = JSON.parse(text);
+  } catch {
+    // keep raw text
+  }
+  return NextResponse.json(payload, { status: upstream.status });
+}

+ 174 - 11
src/app/globals.css

@@ -1,26 +1,189 @@
 @import "tailwindcss";
 
+/* 参考 Edulan 类 LMS:清爽蓝系主色、大留白、卡片阴影与分区背景;结合本站金融教育调性 */
 :root {
-  --background: #ffffff;
-  --foreground: #171717;
+  --background: #f0f4fa;
+  --foreground: #0f172a;
+  --muted: #64748b;
+  --card: #ffffff;
+  --border: #e2e8f0;
+  --accent: #d4a012;
+  --accent-hover: #e5b82a;
+  --accent-foreground: #0f172a;
+  --navy: #0c1929;
+  --navy-soft: #152a45;
+  --primary: #1d4ed8;
+  --primary-soft: #3b82f6;
+  --surface-muted: #e8eef7;
+  --hero-glow: rgba(59, 130, 246, 0.35);
+  --container-max: 80rem;
+  --container-px: 1rem;
+  --section-py: 4rem;
 }
 
 @theme inline {
   --color-background: var(--background);
   --color-foreground: var(--foreground);
-  --font-sans: var(--font-geist-sans);
-  --font-mono: var(--font-geist-mono);
+  --color-muted: var(--muted);
+  --color-card: var(--card);
+  --color-border: var(--border);
+  --color-accent: var(--accent);
+  --color-accent-hover: var(--accent-hover);
+  --color-navy: var(--navy);
+  --color-navy-soft: var(--navy-soft);
+  --color-primary-500: var(--primary-soft);
+  --color-primary-600: #2563eb;
+  --color-primary-700: #1d4ed8;
+  --font-sans: var(--font-noto-sans);
+  --font-serif: var(--font-noto-serif);
+  --shadow-card: 0 4px 6px -1px rgb(15 23 42 / 0.06), 0 12px 24px -4px rgb(15 23 42 / 0.08);
+  --shadow-card-hover: 0 12px 32px -8px rgb(15 23 42 / 0.12), 0 4px 12px -2px rgb(15 23 42 / 0.06);
+  --radius-card: 1.25rem;
 }
 
-@media (prefers-color-scheme: dark) {
+body {
+  background: var(--background);
+  color: var(--foreground);
+  font-family: var(--font-sans), system-ui, sans-serif;
+}
+
+.site-container {
+  margin-inline: auto;
+  width: 100%;
+  max-width: var(--container-max);
+  padding-inline: var(--container-px);
+}
+
+.section-block {
+  padding-block: var(--section-py);
+}
+
+.section-block-compact {
+  padding-block: clamp(2.25rem, 7vw, 3.5rem);
+}
+
+.mobile-safe-pb {
+  padding-bottom: max(1rem, env(safe-area-inset-bottom));
+}
+
+/*
+  布局约定(新页面请按类型选一种,勿混用 site-container + max-w-*):
+  - .site-container:全站主栏 max 80rem,与顶栏/底栏对齐。
+  - .section-block / .section-block-compact:区块上下留白。
+  - .page-shell*:正文阅读区,横向 padding 用 --container-px,与顶栏一致。
+  - .auth-page-shell:表单窄页 max 28rem,禁止再包 site-container。
+*/
+.page-shell {
+  box-sizing: border-box;
+  margin-inline: auto;
+  width: 100%;
+  max-width: 48rem;
+  padding-inline: var(--container-px);
+  padding-block: clamp(2.5rem, 5vw, 3rem);
+}
+
+@media (min-width: 768px) {
+  .page-shell {
+    padding-block: 3rem;
+  }
+}
+
+.page-shell-wide {
+  max-width: 1200px;
+}
+
+.page-shell-md {
+  max-width: 36rem;
+}
+
+.page-shell-sm {
+  max-width: 32rem;
+}
+
+/* 登录/注册等窄页:不要用 site-container(会与 max-w-md 抢 max-width,导致 PC 上表单过宽) */
+.auth-page-shell {
+  margin-inline: auto;
+  width: 100%;
+  max-width: 28rem;
+  padding-inline: 1rem;
+  padding-block: clamp(2.5rem, 8vw, 4rem);
+}
+
+@media (min-width: 768px) {
+  .auth-page-shell {
+    padding-block: 4rem;
+  }
+}
+
+.font-serif {
+  font-family: var(--font-serif), "Noto Serif SC", serif;
+}
+
+.text-muted-foreground {
+  color: var(--muted);
+}
+
+/* 页面底纹:网格 + 柔光(Edulan 常见层次) */
+.bg-page {
+  background-color: var(--background);
+  background-image:
+    linear-gradient(180deg, rgb(255 255 255 / 0.72) 0%, transparent 32%),
+    radial-gradient(ellipse 80% 50% at 50% -20%, rgb(59 130 246 / 0.12), transparent),
+    linear-gradient(
+      rgb(15 23 42 / 0.03) 1px,
+      transparent 1px
+    ),
+    linear-gradient(
+      90deg,
+      rgb(15 23 42 / 0.03) 1px,
+      transparent 1px
+    );
+  background-size: auto, auto, 48px 48px, 48px 48px;
+}
+
+@media (min-width: 640px) {
   :root {
-    --background: #0a0a0a;
-    --foreground: #ededed;
+    --container-px: 1.25rem;
   }
 }
 
-body {
-  background: var(--background);
-  color: var(--foreground);
-  font-family: Arial, Helvetica, sans-serif;
+@media (min-width: 768px) {
+  :root {
+    --container-px: 2rem;
+    --section-py: 5rem;
+  }
+}
+
+@media (min-width: 1024px) {
+  :root {
+    --container-px: 2rem;
+    --section-py: 5rem;
+  }
+}
+
+@media (max-width: 767px) {
+  .bg-page {
+    background-size: auto, auto, 32px 32px, 32px 32px;
+  }
+}
+
+.shadow-card {
+  box-shadow: var(--shadow-card);
+}
+
+.shadow-card-hover {
+  box-shadow: var(--shadow-card-hover);
+}
+
+/* 英雄区网格纹理 */
+.hero-grid {
+  background-image:
+    linear-gradient(rgb(255 255 255 / 0.04) 1px, transparent 1px),
+    linear-gradient(90deg, rgb(255 255 255 / 0.04) 1px, transparent 1px);
+  background-size: 40px 40px;
+}
+
+/* 底部波浪装饰 */
+.footer-wave {
+  background: linear-gradient(180deg, var(--background) 0%, var(--navy) 100%);
 }

+ 17 - 19
src/app/layout.tsx

@@ -1,33 +1,31 @@
-import type { Metadata } from "next";
-import { Geist, Geist_Mono } from "next/font/google";
+import type { ReactNode } from "react";
+import { Noto_Sans_SC, Noto_Serif_SC } from "next/font/google";
 import "./globals.css";
 
-const geistSans = Geist({
-  variable: "--font-geist-sans",
+const notoSans = Noto_Sans_SC({
   subsets: ["latin"],
+  weight: ["400", "500", "600", "700"],
+  variable: "--font-noto-sans",
+  display: "swap",
 });
 
-const geistMono = Geist_Mono({
-  variable: "--font-geist-mono",
+const notoSerif = Noto_Serif_SC({
   subsets: ["latin"],
+  weight: ["500", "600", "700"],
+  variable: "--font-noto-serif",
+  display: "swap",
 });
 
-export const metadata: Metadata = {
-  title: "Create Next App",
-  description: "Generated by create next app",
-};
-
-export default function RootLayout({
-  children,
-}: Readonly<{
-  children: React.ReactNode;
-}>) {
+export default function RootLayout({ children }: { children: ReactNode }) {
   return (
     <html
-      lang="en"
-      className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
+      lang="zh-CN"
+      className={`${notoSans.variable} ${notoSerif.variable} h-full`}
+      suppressHydrationWarning
     >
-      <body className="min-h-full flex flex-col">{children}</body>
+      <body className="bg-page flex min-h-full flex-col font-sans antialiased">
+        {children}
+      </body>
     </html>
   );
 }

+ 0 - 65
src/app/page.tsx

@@ -1,65 +0,0 @@
-import Image from "next/image";
-
-export default function Home() {
-  return (
-    <div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
-      <main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
-        <Image
-          className="dark:invert"
-          src="/next.svg"
-          alt="Next.js logo"
-          width={100}
-          height={20}
-          priority
-        />
-        <div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
-          <h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
-            To get started, edit the page.tsx file.
-          </h1>
-          <p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
-            Looking for a starting point or more instructions? Head over to{" "}
-            <a
-              href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
-              className="font-medium text-zinc-950 dark:text-zinc-50"
-            >
-              Templates
-            </a>{" "}
-            or the{" "}
-            <a
-              href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
-              className="font-medium text-zinc-950 dark:text-zinc-50"
-            >
-              Learning
-            </a>{" "}
-            center.
-          </p>
-        </div>
-        <div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
-          <a
-            className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
-            href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
-            target="_blank"
-            rel="noopener noreferrer"
-          >
-            <Image
-              className="dark:invert"
-              src="/vercel.svg"
-              alt="Vercel logomark"
-              width={16}
-              height={16}
-            />
-            Deploy Now
-          </a>
-          <a
-            className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
-            href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
-            target="_blank"
-            rel="noopener noreferrer"
-          >
-            Documentation
-          </a>
-        </div>
-      </main>
-    </div>
-  );
-}

+ 192 - 0
src/components/country-combobox.tsx

@@ -0,0 +1,192 @@
+"use client";
+
+import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
+import { cn } from "@/lib/utils";
+import type { CountryOption } from "@/lib/register-api";
+
+type Labels = {
+  placeholder: string;
+  loading: string;
+  searchPlaceholder: string;
+  noResults: string;
+};
+
+type CountryComboboxProps = {
+  countries: CountryOption[];
+  value: string;
+  onValueChange: (value: string) => void;
+  disabled?: boolean;
+  loading?: boolean;
+  labels: Labels;
+  id?: string;
+};
+
+export function CountryCombobox({
+  countries,
+  value,
+  onValueChange,
+  disabled,
+  loading,
+  labels,
+  id: idProp,
+}: CountryComboboxProps) {
+  const reactId = useId();
+  const listboxId = idProp ?? `${reactId}-listbox`;
+  const inputId = `${listboxId}-input`;
+
+  const [open, setOpen] = useState(false);
+  const [query, setQuery] = useState("");
+  const [highlight, setHighlight] = useState(0);
+  const rootRef = useRef<HTMLDivElement>(null);
+  const inputRef = useRef<HTMLInputElement>(null);
+
+  const selected = useMemo(
+    () => countries.find((c) => c.value === value),
+    [countries, value],
+  );
+
+  /** 未输入关键词时展示全部(列表区域滚动);输入后按名称筛选 */
+  const displayed = useMemo(() => {
+    const q = query.trim().toLowerCase();
+    if (!q) return countries;
+    return countries.filter((c) => c.label.toLowerCase().includes(q));
+  }, [countries, query]);
+
+  const showList = open && displayed.length > 0;
+  const noHits = open && query.trim().length > 0 && displayed.length === 0;
+
+  useEffect(() => {
+    if (!open) return;
+    const onDoc = (e: MouseEvent) => {
+      if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
+    };
+    document.addEventListener("mousedown", onDoc);
+    return () => document.removeEventListener("mousedown", onDoc);
+  }, [open]);
+
+  const pick = useCallback(
+    (v: string) => {
+      onValueChange(v);
+      setOpen(false);
+      setQuery("");
+    },
+    [onValueChange],
+  );
+
+  const onKeyDown = (e: React.KeyboardEvent) => {
+    if (!showList) {
+      if (e.key === "Escape") setOpen(false);
+      return;
+    }
+    if (e.key === "ArrowDown") {
+      e.preventDefault();
+      if (displayed.length === 0) return;
+      setHighlight((h) => Math.min(h + 1, displayed.length - 1));
+    } else if (e.key === "ArrowUp") {
+      e.preventDefault();
+      if (displayed.length === 0) return;
+      setHighlight((h) => Math.max(h - 1, 0));
+    } else if (e.key === "Enter") {
+      e.preventDefault();
+      const item = displayed[highlight];
+      if (item) pick(item.value);
+    } else if (e.key === "Escape") {
+      setOpen(false);
+    }
+  };
+
+  const isDisabled = Boolean(disabled || loading || countries.length === 0);
+
+  const onTriggerClick = () => {
+    if (isDisabled) return;
+    if (open) {
+      setOpen(false);
+      return;
+    }
+    setQuery("");
+    setHighlight(0);
+    setOpen(true);
+    requestAnimationFrame(() => inputRef.current?.focus());
+  };
+
+  return (
+    <div ref={rootRef} className="relative">
+      <button
+        type="button"
+        id={inputId}
+        aria-expanded={open}
+        aria-haspopup="listbox"
+        aria-controls={listboxId}
+        disabled={isDisabled}
+        onClick={onTriggerClick}
+        className={cn(
+          "mt-1 flex w-full items-center justify-between gap-2 rounded-lg border border-[var(--border)] bg-white px-3 py-2 text-left text-sm",
+          isDisabled && "cursor-not-allowed opacity-60",
+        )}
+      >
+        <span className={cn("truncate", !selected && "text-[var(--muted)]")}>
+          {loading
+            ? labels.loading
+            : selected
+              ? selected.label
+              : labels.placeholder}
+        </span>
+        <span className="shrink-0 text-[var(--muted)]" aria-hidden>
+          {open ? "▴" : "▾"}
+        </span>
+      </button>
+
+      {open && (
+        <div
+          id={listboxId}
+          role="listbox"
+          className="absolute left-0 right-0 z-50 mt-1 max-h-[min(18rem,calc(100vh-8rem))] overflow-hidden rounded-lg border border-[var(--border)] bg-white py-1 shadow-lg"
+        >
+          <div className="border-b border-[var(--border)] px-2 pb-2 pt-1">
+            <input
+              ref={inputRef}
+              type="text"
+              value={query}
+              onChange={(e) => {
+                setQuery(e.target.value);
+                setHighlight(0);
+              }}
+              onKeyDown={onKeyDown}
+              placeholder={labels.searchPlaceholder}
+              className="w-full rounded-md border border-[var(--border)] px-2 py-1.5 text-sm outline-none focus:ring-1 focus:ring-[var(--navy)]"
+              autoComplete="off"
+              aria-autocomplete="list"
+              aria-controls={listboxId}
+            />
+          </div>
+          <div className="max-h-60 overflow-y-auto px-1 py-1">
+            {noHits && (
+              <p className="px-2 py-3 text-center text-xs text-[var(--muted)]">
+                {labels.noResults}
+              </p>
+            )}
+            {showList &&
+              displayed.map((c, i) => (
+                <button
+                  key={c.value}
+                  type="button"
+                  role="option"
+                  aria-selected={value === c.value}
+                  onMouseDown={(e) => e.preventDefault()}
+                  onClick={() => pick(c.value)}
+                  className={cn(
+                    "flex w-full rounded-md px-2 py-2 text-left text-sm",
+                    i === highlight
+                      ? "bg-[var(--navy)]/10 text-[var(--navy)]"
+                      : "hover:bg-black/[0.04]",
+                  )}
+                >
+                  {c.label}
+                </button>
+              ))}
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}

+ 56 - 0
src/components/course-buy-button.tsx

@@ -0,0 +1,56 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import { useRouter } from "@/i18n/navigation";
+import { cn } from "@/lib/utils";
+import { useAuth } from "@/providers/auth-provider";
+
+type Props = {
+  courseSlug: string;
+  courseId: string;
+  courseTitle: string;
+  coursePrice: number;
+  className?: string;
+};
+
+export function CourseBuyButton({
+  courseSlug,
+  courseId,
+  courseTitle,
+  coursePrice,
+  className,
+}: Props) {
+  const t = useTranslations("courses");
+  const { user, isReady } = useAuth();
+  const router = useRouter();
+  const checkoutPath =
+    `/checkout?course=${encodeURIComponent(courseSlug)}` +
+    `&id=${encodeURIComponent(courseId)}` +
+    `&title=${encodeURIComponent(courseTitle)}` +
+    `&price=${encodeURIComponent(String(coursePrice))}`;
+
+  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>
+  );
+}

+ 38 - 0
src/components/icons.tsx

@@ -0,0 +1,38 @@
+/** 轻量 SVG 图标,避免额外依赖 */
+
+import type { SVGProps } from "react";
+
+export function IconBook(props: SVGProps<SVGSVGElement>) {
+  return (
+    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" {...props}>
+      <path d="M4 19.5A2.5 2.5 0 016.5 17H20" />
+      <path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z" />
+      <path d="M8 7h8M8 11h6" />
+    </svg>
+  );
+}
+
+export function IconChart(props: SVGProps<SVGSVGElement>) {
+  return (
+    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" {...props}>
+      <path d="M3 3v18h18" />
+      <path d="M7 16l4-4 4 4 5-7" />
+    </svg>
+  );
+}
+
+export function IconShield(props: SVGProps<SVGSVGElement>) {
+  return (
+    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" {...props}>
+      <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
+    </svg>
+  );
+}
+
+export function IconSpark(props: SVGProps<SVGSVGElement>) {
+  return (
+    <svg viewBox="0 0 24 24" fill="currentColor" {...props}>
+      <path d="M12 2l1.2 5.2L18 9l-4.8 1.8L12 16l-1.2-5.2L6 9l4.8-1.8L12 2z" />
+    </svg>
+  );
+}

+ 12 - 0
src/components/locale-html-lang.tsx

@@ -0,0 +1,12 @@
+"use client";
+
+import { useLocale } from "next-intl";
+import { useEffect } from "react";
+
+export function LocaleHtmlLang() {
+  const locale = useLocale();
+  useEffect(() => {
+    document.documentElement.lang = locale === "zh" ? "zh-CN" : "en";
+  }, [locale]);
+  return null;
+}

+ 29 - 0
src/components/locale-switcher.tsx

@@ -0,0 +1,29 @@
+"use client";
+
+import { useLocale } from "next-intl";
+import { Link } from "@/i18n/navigation";
+
+export function LocaleSwitcher() {
+  const locale = useLocale();
+
+  return (
+    <div className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-400">
+      <span className="text-slate-500">Language</span>
+      {locale !== "zh" ? (
+        <Link href="/" locale="zh" className="hover:text-white">
+          中文
+        </Link>
+      ) : (
+        <span className="text-white">中文</span>
+      )}
+      <span className="text-slate-600">|</span>
+      {locale !== "en" ? (
+        <Link href="/" locale="en" className="hover:text-white">
+          English
+        </Link>
+      ) : (
+        <span className="text-white">English</span>
+      )}
+    </div>
+  );
+}

+ 38 - 0
src/components/section-heading.tsx

@@ -0,0 +1,38 @@
+type Props = {
+  eyebrow?: string;
+  title: string;
+  subtitle?: string;
+  align?: "left" | "center";
+};
+
+export function SectionHeading({ eyebrow, title, subtitle, align = "center" }: Props) {
+  return (
+    <div
+      className={
+        align === "center"
+          ? "mx-auto max-w-2xl text-center"
+          : "max-w-2xl text-left"
+      }
+    >
+      {eyebrow ? (
+        <p className="font-semibold tracking-wide text-blue-600">{eyebrow}</p>
+      ) : null}
+      <h2 className="font-serif mt-2 text-3xl font-bold tracking-tight text-[var(--navy)] md:text-[2rem]">
+        {title}
+      </h2>
+      {subtitle ? (
+        <p className="text-muted-foreground mt-3 text-base leading-relaxed md:text-lg">
+          {subtitle}
+        </p>
+      ) : null}
+      <div
+        className={
+          align === "center"
+            ? "mx-auto mt-5 h-1 w-14 rounded-full bg-blue-500/90"
+            : "mt-5 h-1 w-14 rounded-full bg-blue-500/90"
+        }
+        aria-hidden
+      />
+    </div>
+  );
+}

+ 101 - 0
src/components/site-footer.tsx

@@ -0,0 +1,101 @@
+import { getTranslations } from "next-intl/server";
+import { Link } from "@/i18n/navigation";
+import { LocaleSwitcher } from "@/components/locale-switcher";
+
+export async function SiteFooter() {
+  const t = await getTranslations("footer");
+  const tc = await getTranslations("courses");
+  const year = new Date().getFullYear();
+
+  return (
+    <footer className="relative mt-auto overflow-hidden rounded-t-[2rem] bg-[var(--navy)] text-slate-300 shadow-[0_-12px_48px_rgba(15,23,42,0.12)]">
+      <div
+        className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-amber-400/40 to-transparent"
+        aria-hidden
+      />
+      <div className="from-background absolute -top-px left-0 right-0 h-8 bg-gradient-to-b to-transparent opacity-90" />
+
+      <div className="site-container section-block-compact relative pb-16 pt-10 sm:pb-10 sm:pt-12">
+        <div className="grid gap-8 lg:grid-cols-4 lg:gap-10">
+          <div className="lg:col-span-1">
+            <p className="font-serif text-xl font-bold text-white">金策弘论社</p>
+            <p className="mt-3 text-sm leading-relaxed text-slate-400">{t("tagline")}</p>
+            <p className="mt-5 text-xs text-slate-500">{t("rights", { year })}</p>
+            <LocaleSwitcher />
+          </div>
+          <div className="grid grid-cols-3 gap-5 border-t border-white/10 pt-6 lg:col-span-3 lg:border-0 lg:pt-0">
+            <div>
+              <p className="text-xs font-bold uppercase tracking-[0.15em] text-amber-400/90">
+                {t("quick")}
+              </p>
+              <ul className="mt-3 space-y-2.5 text-sm">
+                <li>
+                  <Link href="/courses" className="transition hover:text-white">
+                    {tc("title")}
+                  </Link>
+                </li>
+                <li>
+                  <Link href="/faq" className="transition hover:text-white">
+                    {t("faq")}
+                  </Link>
+                </li>
+                <li>
+                  <Link href="/purchase-guarantee" className="transition hover:text-white">
+                    {t("guarantee")}
+                  </Link>
+                </li>
+                <li>
+                  <Link href="/contact" className="transition hover:text-white">
+                    {t("contactUs")}
+                  </Link>
+                </li>
+              </ul>
+            </div>
+            <div>
+              <p className="text-xs font-bold uppercase tracking-[0.15em] text-amber-400/90">
+                {t("legal")}
+              </p>
+              <ul className="mt-3 space-y-2.5 text-sm">
+                <li>
+                  <Link href="/legal/terms" className="transition hover:text-white">
+                    {t("terms")}
+                  </Link>
+                </li>
+                <li>
+                  <Link href="/legal/privacy" className="transition hover:text-white">
+                    {t("privacy")}
+                  </Link>
+                </li>
+                <li>
+                  <Link href="/legal/copyright" className="transition hover:text-white">
+                    {t("copyright")}
+                  </Link>
+                </li>
+              </ul>
+            </div>
+            <div>
+              <p className="text-xs font-bold uppercase tracking-[0.15em] text-amber-400/90">
+                {t("contact")}
+              </p>
+              <p className="mt-3 text-sm leading-relaxed text-slate-400">
+                {t("phone")}
+                <br />
+                <span className="text-slate-200">{t("phoneValue")}</span>
+              </p>
+              <p className="mt-3 text-sm leading-relaxed text-slate-400">
+                {t("email")}
+                <br />
+                <a
+                  href={`mailto:${t("emailValue")}`}
+                  className="break-all text-slate-200 underline-offset-2 transition hover:text-white hover:underline"
+                >
+                  {t("emailValue")}
+                </a>
+              </p>
+            </div>
+          </div>
+        </div>
+      </div>
+    </footer>
+  );
+}

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

@@ -0,0 +1,403 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import { useEffect, useRef, useState } from "react";
+import { Link } from "@/i18n/navigation";
+import { cn } from "@/lib/utils";
+import { COURSE_CATEGORIES } from "@/data/courses";
+import type { UserSession } from "@/lib/auth-types";
+import { useAuth } from "@/providers/auth-provider";
+import { IconSpark } from "@/components/icons";
+
+function avatarInitial(name: string): string {
+  const trimmed = name.trim();
+  if (!trimmed) return "?";
+  const first = Array.from(trimmed)[0] ?? "?";
+  return /[a-z]/i.test(first) ? first.toUpperCase() : first;
+}
+
+function UserAvatarMenu({
+  user,
+  onLogout,
+}: {
+  user: UserSession;
+  onLogout: () => void;
+}) {
+  const t = useTranslations("nav");
+  const ta = useTranslations("account");
+  const [menuOpen, setMenuOpen] = useState(false);
+  const rootRef = useRef<HTMLDivElement>(null);
+
+  useEffect(() => {
+    if (!menuOpen) return;
+    const onDoc = (e: MouseEvent) => {
+      if (!rootRef.current?.contains(e.target as Node)) setMenuOpen(false);
+    };
+    document.addEventListener("mousedown", onDoc);
+    return () => document.removeEventListener("mousedown", onDoc);
+  }, [menuOpen]);
+
+  useEffect(() => {
+    if (!menuOpen) return;
+    const onKey = (e: KeyboardEvent) => {
+      if (e.key === "Escape") setMenuOpen(false);
+    };
+    window.addEventListener("keydown", onKey);
+    return () => window.removeEventListener("keydown", onKey);
+  }, [menuOpen]);
+
+  return (
+    <div className="relative" ref={rootRef}>
+      <button
+        type="button"
+        aria-expanded={menuOpen}
+        aria-haspopup="menu"
+        aria-label={t("userMenu")}
+        className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-violet-600 to-purple-700 text-sm font-semibold text-white shadow-md shadow-purple-600/30 ring-2 ring-white outline-none transition hover:from-violet-500 hover:to-purple-600 hover:shadow-lg focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
+        onClick={() => setMenuOpen((o) => !o)}
+      >
+        {avatarInitial(user.name)}
+      </button>
+      {menuOpen && (
+        <div
+          role="menu"
+          className="absolute right-0 top-full z-[60] mt-2 min-w-[176px] overflow-hidden rounded-xl border border-slate-100/80 bg-white py-1 shadow-lg shadow-slate-900/12 ring-1 ring-slate-900/5"
+        >
+          <Link
+            href="/account/change-password"
+            role="menuitem"
+            className="block px-4 py-2.5 text-sm font-medium text-slate-800 transition hover:bg-slate-50"
+            onClick={() => setMenuOpen(false)}
+          >
+            {t("accountSettings")}
+          </Link>
+          <Link
+            href="/account/withdraw-apply"
+            role="menuitem"
+            className="block px-4 py-2.5 text-sm font-medium text-slate-800 transition hover:bg-slate-50"
+            onClick={() => setMenuOpen(false)}
+          >
+            提款申请
+          </Link>
+          <div className="my-0.5 border-t border-slate-100" aria-hidden />
+          <button
+            type="button"
+            role="menuitem"
+            className="w-full px-4 py-2.5 text-left text-sm font-medium text-slate-800 transition hover:bg-slate-50"
+            onClick={() => {
+              setMenuOpen(false);
+              onLogout();
+            }}
+          >
+            {ta("logout")}
+          </button>
+        </div>
+      )}
+    </div>
+  );
+}
+
+export function SiteHeader() {
+  const t = useTranslations("nav");
+  const tf = useTranslations("footer");
+  const ta = useTranslations("account");
+  const [open, setOpen] = useState(false);
+  const [mega, setMega] = useState<string | null>(null);
+  const { user, logout, isReady } = useAuth();
+
+  useEffect(() => {
+    document.body.style.overflow = open ? "hidden" : "";
+    return () => {
+      document.body.style.overflow = "";
+    };
+  }, [open]);
+
+  return (
+    <header className="sticky top-0 z-50">
+      <div className="hidden border-b border-slate-200/80 bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 text-xs text-slate-300 sm:block">
+        <div className="site-container flex items-center justify-between py-2">
+          <div className="flex flex-wrap items-center gap-x-6 gap-y-1">
+            <span>
+              {tf("phone")}:{tf("phoneValue")}
+            </span>
+            <span>
+              {tf("email")}:{tf("emailValue")}
+            </span>
+          </div>
+          <Link
+            href="/courses"
+            className="font-medium text-amber-300/90 transition hover:text-amber-200"
+          >
+            {t("purchase")} →
+          </Link>
+        </div>
+      </div>
+
+      <div className="border-b border-slate-200/90 bg-white/90 shadow-[0_1px_0_rgb(15_23_42/0.04)] backdrop-blur-md">
+        <div className="site-container flex items-center justify-between gap-3 py-3">
+          <Link href="/" className="group flex items-center gap-3">
+            <span className="flex h-11 w-11 items-center justify-center rounded-2xl bg-gradient-to-br from-amber-400 to-amber-600 text-white shadow-lg shadow-amber-500/25 ring-2 ring-white transition group-hover:scale-[1.02]">
+              <IconSpark className="h-6 w-6" aria-hidden />
+            </span>
+            <span className="flex flex-col leading-tight">
+              <span className="font-serif text-lg font-bold tracking-tight text-[var(--navy)] md:text-xl">
+                {t("brand")}
+              </span>
+              <span className="hidden text-[11px] font-medium uppercase tracking-[0.12em] text-slate-500 sm:block">
+                Trading · Education
+              </span>
+            </span>
+          </Link>
+
+          <nav className="hidden items-center gap-0.5 lg:flex">
+            <div
+              className="relative"
+              onMouseEnter={() => setMega("courses")}
+              onMouseLeave={() => setMega(null)}
+            >
+              <button
+                type="button"
+                className={cn(
+                  "rounded-xl px-4 py-2.5 text-sm font-semibold transition",
+                  mega === "courses"
+                    ? "bg-blue-50 text-blue-800"
+                    : "text-slate-600 hover:bg-slate-50 hover:text-[var(--navy)]",
+                )}
+              >
+                {t("courses")}
+              </button>
+              {mega === "courses" && (
+                <div className="absolute left-0 top-full z-20 pt-2">
+                  <div className="min-w-[240px] rounded-2xl border border-slate-100 bg-white py-2 shadow-xl shadow-slate-900/10 ring-1 ring-slate-900/5">
+                    {COURSE_CATEGORIES.map((c) => (
+                      <Link
+                        key={c.id}
+                        href={`/courses?cat=${c.id}`}
+                        className="block px-4 py-2.5 text-sm font-medium text-slate-700 transition hover:bg-blue-50/80 hover:text-blue-900"
+                      >
+                        {t(`categories.${c.id}` as "categories.trial")}
+                      </Link>
+                    ))}
+                  </div>
+                </div>
+              )}
+            </div>
+
+            <div
+              className="relative"
+              onMouseEnter={() => setMega("about")}
+              onMouseLeave={() => setMega(null)}
+            >
+              <button
+                type="button"
+                className={cn(
+                  "rounded-xl px-4 py-2.5 text-sm font-semibold transition",
+                  mega === "about"
+                    ? "bg-blue-50 text-blue-800"
+                    : "text-slate-600 hover:bg-slate-50 hover:text-[var(--navy)]",
+                )}
+              >
+                {t("about")}
+              </button>
+              {mega === "about" && (
+                <div className="absolute left-0 top-full z-20 pt-2">
+                  <div className="min-w-[200px] rounded-2xl border border-slate-100 bg-white py-2 shadow-xl shadow-slate-900/10 ring-1 ring-slate-900/5">
+                    <Link
+                      href="/about#ip"
+                      className="block px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-blue-50/80"
+                    >
+                      {t("aboutSub.ip")}
+                    </Link>
+                    <Link
+                      href="/about#philosophy"
+                      className="block px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-blue-50/80"
+                    >
+                      {t("aboutSub.philosophy")}
+                    </Link>
+                    <Link
+                      href="/about#media"
+                      className="block px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-blue-50/80"
+                    >
+                      {t("aboutSub.media")}
+                    </Link>
+                  </div>
+                </div>
+              )}
+            </div>
+
+            <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)]"
+            >
+              {t("purchase")}
+            </Link>
+            <Link
+              href="/account"
+              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("account")}
+            </Link>
+          </nav>
+
+          <div className="hidden min-h-[42px] items-center gap-3 sm:flex">
+            {!isReady ? (
+              <span
+                className="h-9 w-28 animate-pulse rounded-full bg-slate-100"
+                aria-hidden
+              />
+            ) : user ? (
+              <UserAvatarMenu user={user} onLogout={logout} />
+            ) : (
+              <>
+                <Link
+                  href="/auth/login"
+                  className="rounded-full border-2 border-slate-200 px-5 py-2 text-sm font-semibold text-[var(--navy)] transition hover:border-blue-200 hover:bg-slate-50"
+                >
+                  {t("login")}
+                </Link>
+                <Link
+                  href="/auth/register"
+                  className="rounded-full bg-gradient-to-r from-blue-600 to-blue-700 px-5 py-2 text-sm font-semibold text-white shadow-lg shadow-blue-600/25 transition hover:from-blue-500 hover:to-blue-600"
+                >
+                  {t("register")}
+                </Link>
+              </>
+            )}
+          </div>
+
+          <button
+            type="button"
+            className="rounded-xl p-2.5 text-[var(--navy)] hover:bg-slate-100 lg:hidden"
+            aria-label="Menu"
+            onClick={() => setOpen(!open)}
+          >
+            <span className="block h-0.5 w-6 bg-current" />
+            <span className="mt-1.5 block h-0.5 w-6 bg-current" />
+            <span className="mt-1.5 block h-0.5 w-6 bg-current" />
+          </button>
+        </div>
+
+      </div>
+      <div
+        className={cn(
+          "fixed inset-0 z-[90] lg:hidden",
+          open ? "pointer-events-auto" : "pointer-events-none",
+        )}
+        role="dialog"
+        aria-modal="true"
+        aria-hidden={!open}
+      >
+          <button
+            type="button"
+            aria-label="Close menu overlay"
+            className={cn(
+              "absolute inset-0 bg-slate-900/45 backdrop-blur-[1px] transition-opacity duration-300 ease-out",
+              open ? "opacity-100" : "opacity-0",
+            )}
+            onClick={() => setOpen(false)}
+          />
+          <aside
+            className={cn(
+              "mobile-safe-pb relative z-10 h-full w-[84%] max-w-[360px] overflow-y-auto rounded-r-3xl bg-white px-5 pb-6 pt-5 shadow-[8px_0_30px_rgba(15,23,42,0.24)] transition-transform duration-300 ease-out",
+              open ? "translate-x-0" : "-translate-x-full",
+            )}
+          >
+            <div className="flex items-center justify-between">
+              <span className="font-serif text-xl font-semibold text-[var(--navy)]">{t("brand")}</span>
+              <button
+                type="button"
+                className="flex h-9 w-9 items-center justify-center rounded-full text-xl leading-none text-slate-600 transition hover:bg-slate-100"
+                aria-label="Close menu"
+                onClick={() => setOpen(false)}
+              >
+                ×
+              </button>
+            </div>
+
+            <div className="mt-6 flex flex-col">
+              <Link
+                href="/courses"
+                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("courses")}
+              </Link>
+              <Link
+                href="/about"
+                className="flex items-center justify-between 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)}
+              >
+                <span>{t("about")}</span>
+                <span className="text-xl text-slate-400">+</span>
+              </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"
+                onClick={() => setOpen(false)}
+              >
+                {t("account")}
+              </Link>
+            </div>
+
+            <div className="mt-6 border-t border-slate-200 pt-4">
+              {!isReady ? (
+                <div className="h-12 animate-pulse rounded-full bg-slate-100" />
+              ) : user ? (
+                <div className="flex flex-col gap-3">
+                  <div className="flex items-center gap-3 rounded-xl bg-slate-50 px-3 py-3">
+                    <div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-violet-600 to-purple-700 text-sm font-semibold text-white shadow-md">
+                      {avatarInitial(user.name)}
+                    </div>
+                    <span className="min-w-0 flex-1 truncate text-sm font-semibold text-slate-800">
+                      {user.name}
+                    </span>
+                  </div>
+                  <Link
+                    href="/account/change-password"
+                    className="rounded-xl border border-slate-200 py-3 text-center text-sm font-semibold text-slate-800 transition hover:bg-slate-50"
+                    onClick={() => setOpen(false)}
+                  >
+                    {t("accountSettings")}
+                  </Link>
+                  <Link
+                    href="/account/withdraw-apply"
+                    className="rounded-xl border border-slate-200 py-3 text-center text-sm font-semibold text-slate-800 transition hover:bg-slate-50"
+                    onClick={() => setOpen(false)}
+                  >
+                    提款申请
+                  </Link>
+                  <button
+                    type="button"
+                    className="rounded-xl bg-slate-800 py-3 text-center text-sm font-semibold text-white transition hover:bg-slate-900"
+                    onClick={() => {
+                      logout();
+                      setOpen(false);
+                    }}
+                  >
+                    {ta("logout")}
+                  </button>
+                </div>
+              ) : (
+                <div className="flex gap-3">
+                  <Link
+                    href="/auth/login"
+                    className="flex-1 rounded-full border-2 border-slate-200 py-3 text-center text-sm font-semibold"
+                    onClick={() => setOpen(false)}
+                  >
+                    {t("login")}
+                  </Link>
+                  <Link
+                    href="/auth/register"
+                    className="flex-1 rounded-full bg-blue-600 py-3 text-center text-sm font-semibold text-white"
+                    onClick={() => setOpen(false)}
+                  >
+                    {t("register")}
+                  </Link>
+                </div>
+              )}
+            </div>
+          </aside>
+      </div>
+    </header>
+  );
+}

+ 426 - 0
src/data/courses.ts

@@ -0,0 +1,426 @@
+import { apiPost } from "@/lib/api";
+
+export type GoodsType = 1 | 2 | 3 | 4 | 5;
+export type CustomType = 0 | 1 | 2;
+export type CourseCategory =
+  | "trial"
+  | "practical"
+  | "book"
+  | "topic"
+  | "strategy";
+
+export type Course = {
+  slug: string;
+  id: string;
+  goodsType: GoodsType;
+  customType: CustomType;
+  category: CourseCategory;
+  title: string;
+  subtitle: string;
+  lessonCount: number;
+  /** 商品原价/标价(接口 `goodsPrice`);未返回时界面可回退用 `price`。 */
+  goodsPrice?: number;
+  price: number;
+  currency: "USD";
+  coverGradient: string;
+  coverUrl?: string;
+};
+
+export type CourseVideo = {
+  id: string;
+  videoName: string;
+  title: string;
+  introduction: string;
+  frontUrl?: string;
+  playUrl?: string;
+  payType: 0 | 1;
+};
+
+export type CourseVideoPageResult = {
+  list: CourseVideo[];
+  page: {
+    current: number;
+    row: number;
+    total: number;
+  };
+};
+
+export const COURSE_CATEGORIES: {
+  id: CourseCategory;
+  labelKey: string;
+}[] = [
+  { id: "trial", labelKey: "categories.trial" },
+  { id: "practical", labelKey: "categories.practical" },
+  { id: "book", labelKey: "categories.book" },
+  { id: "topic", labelKey: "categories.topic" },
+  { id: "strategy", labelKey: "categories.strategy" },
+];
+
+export const courses: Course[] = [
+  {
+    slug: "trial-intro-trends",
+    id: "trial-intro-trends",
+    goodsType: 1,
+    customType: 0,
+    category: "trial",
+    title: "免费试听 · 趋势与仓位入门",
+    subtitle: "零基础快速建立交易框架与风险意识。",
+    lessonCount: 6,
+    price: 0,
+    currency: "USD",
+    coverGradient: "from-slate-700 via-slate-800 to-slate-900",
+  },
+  {
+    slug: "practical-50-core",
+    id: "practical-50-core",
+    goodsType: 2,
+    customType: 1,
+    category: "practical",
+    title: "实盘交易体系课(50 讲)",
+    subtitle: "从行情解读到执行与复盘,系统化实盘训练。",
+    lessonCount: 50,
+    price: 2980,
+    currency: "USD",
+    coverGradient: "from-amber-900/90 via-slate-900 to-slate-950",
+  },
+  {
+    slug: "book-12-chapters",
+    id: "book-12-chapters",
+    goodsType: 3,
+    customType: 1,
+    category: "book",
+    title: "新书章节精讲(12 讲)",
+    subtitle: "与著作同步更新,逐章拆解策略与案例。",
+    lessonCount: 12,
+    price: 1280,
+    currency: "USD",
+    coverGradient: "from-emerald-900/80 via-slate-900 to-slate-950",
+  },
+  {
+    slug: "topic-volatility",
+    id: "topic-volatility",
+    goodsType: 4,
+    customType: 1,
+    category: "topic",
+    title: "专题 · 波动率与事件交易",
+    subtitle: "围绕高波动场景的专题深度研讨。",
+    lessonCount: 8,
+    price: 890,
+    currency: "USD",
+    coverGradient: "from-indigo-900/80 via-slate-900 to-slate-950",
+  },
+  {
+    slug: "strategy-quarterly-q1",
+    id: "strategy-quarterly-q1",
+    goodsType: 5,
+    customType: 1,
+    category: "strategy",
+    title: "策略研报 · 季度策略合集",
+    subtitle: "季度市场展望与策略要点,含图表与执行清单。",
+    lessonCount: 4,
+    price: 560,
+    currency: "USD",
+    coverGradient: "from-rose-900/70 via-slate-900 to-slate-950",
+  },
+];
+
+type GoodsSearchListItem = {
+  id?: string | number;
+  goodsId?: string | number;
+  slug?: string;
+  goodsType?: number;
+  customType?: number;
+  title?: string;
+  goodsName?: string;
+  name?: string;
+  subtitle?: string;
+  subTitle?: string;
+  introduction?: string;
+  intro?: string;
+  lessonCount?: number;
+  lessons?: number;
+  chapterCount?: number;
+  price?: number;
+  goodsPrice?: number | string;
+  amount?: number;
+  currency?: string;
+  frontUrl?: string | null;
+  coverGradient?: string;
+};
+
+type GoodsSearchListResponse = {
+  code?: number | string;
+  data?:
+    | GoodsSearchListItem[]
+    | {
+        list?: GoodsSearchListItem[];
+        records?: GoodsSearchListItem[];
+      };
+  list?: GoodsSearchListItem[];
+  records?: GoodsSearchListItem[];
+};
+
+type GoodsVideoListItem = {
+  id?: string | number;
+  videoId?: string | number;
+  name?: string;
+  videoName?: string;
+  title?: string;
+  videoTitle?: string;
+  introduction?: string;
+  intro?: string;
+  description?: string;
+  frontUrl?: string | null;
+  linkUrl?: string | null;
+  fileUrl?: string | null;
+  playUrl?: string | null;
+  payType?: number;
+};
+
+type GoodsVideoListResponse = {
+  code?: number | string;
+  data?:
+    | GoodsVideoListItem[]
+    | {
+        list?: GoodsVideoListItem[];
+        records?: GoodsVideoListItem[];
+      };
+  list?: GoodsVideoListItem[];
+  records?: GoodsVideoListItem[];
+  page?: {
+    current?: number;
+    row?: number;
+    size?: number;
+    total?: number;
+  };
+};
+
+function toCategory(goodsType: GoodsType): CourseCategory {
+  switch (goodsType) {
+    case 1:
+      return "trial";
+    case 2:
+      return "practical";
+    case 3:
+      return "book";
+    case 4:
+      return "topic";
+    case 5:
+      return "strategy";
+  }
+}
+
+export function categoryToGoodsType(category: CourseCategory): GoodsType {
+  switch (category) {
+    case "trial":
+      return 1;
+    case "practical":
+      return 2;
+    case "book":
+      return 3;
+    case "topic":
+      return 4;
+    case "strategy":
+      return 5;
+  }
+}
+
+function asGoodsType(value: unknown): GoodsType | null {
+  return value === 1 || value === 2 || value === 3 || value === 4 || value === 5
+    ? value
+    : null;
+}
+
+function asCustomType(value: unknown): CustomType {
+  return value === 2 || value === 1 || value === 0 ? value : 1;
+}
+
+function normalizeCourse(item: GoodsSearchListItem): Course | null {
+  const goodsType = asGoodsType(item.goodsType);
+  if (!goodsType) return null;
+  const id = String(item.goodsId ?? item.id ?? "").trim();
+  if (!id) return null;
+  const rawSlug = String(item.slug ?? id).trim();
+  const slug = rawSlug.replace(/\s+/g, "-").toLowerCase();
+  const title = String(item.title ?? item.goodsName ?? item.name ?? "").trim();
+  if (!title) return null;
+
+  const subtitle = String(
+    item.subtitle ?? item.subTitle ?? item.introduction ?? item.intro ?? "",
+  ).trim();
+  const lessonCount = Number(item.lessonCount ?? item.lessons ?? item.chapterCount ?? 0);
+  const price = Number(item.price ?? item.goodsPrice ?? item.amount ?? 0);
+  const currency = item.currency === "USD" ? "USD" : "USD";
+  const rawGoodsPrice = item.goodsPrice;
+  const goodsPriceParsed =
+    rawGoodsPrice === undefined || rawGoodsPrice === null || rawGoodsPrice === ""
+      ? undefined
+      : Number(rawGoodsPrice);
+  const goodsPrice =
+    goodsPriceParsed !== undefined && Number.isFinite(goodsPriceParsed)
+      ? goodsPriceParsed
+      : undefined;
+
+  return {
+    id,
+    slug,
+    goodsType,
+    customType: asCustomType(item.customType),
+    category: toCategory(goodsType),
+    title,
+    subtitle,
+    lessonCount: Number.isFinite(lessonCount) ? lessonCount : 0,
+    goodsPrice,
+    price: Number.isFinite(price) ? price : 0,
+    currency,
+    coverUrl: typeof item.frontUrl === "string" ? item.frontUrl : undefined,
+    coverGradient: item.coverGradient ?? "from-slate-700 via-slate-800 to-slate-900",
+  };
+}
+
+export function getCourseBySlug(slug: string): Course | undefined {
+  return courses.find((c) => c.slug === slug);
+}
+
+export function getCoursesByCategory(category: CourseCategory): Course[] {
+  return courses.filter((c) => c.category === category);
+}
+
+export function shouldShowBuyButton(course: Course): boolean {
+  return !(course.goodsType === 1 || course.customType === 2);
+}
+
+function extractList(payload: GoodsSearchListResponse): GoodsSearchListItem[] {
+  if (Array.isArray(payload?.data)) return payload.data;
+  if (Array.isArray(payload?.data?.list)) return payload.data.list;
+  if (Array.isArray(payload?.data?.records)) return payload.data.records;
+  if (Array.isArray(payload?.list)) return payload.list;
+  if (Array.isArray(payload?.records)) return payload.records;
+  return [];
+}
+
+function extractVideoList(payload: GoodsVideoListResponse): GoodsVideoListItem[] {
+  if (Array.isArray(payload?.data)) return payload.data;
+  if (Array.isArray(payload?.data?.list)) return payload.data.list;
+  if (Array.isArray(payload?.data?.records)) return payload.data.records;
+  if (Array.isArray(payload?.list)) return payload.list;
+  if (Array.isArray(payload?.records)) return payload.records;
+  return [];
+}
+
+function normalizeVideo(item: GoodsVideoListItem): CourseVideo | null {
+  const id = String(item.videoId ?? item.id ?? "").trim();
+  if (!id) return null;
+
+  const videoName = String(item.videoName ?? item.videoTitle ?? item.name ?? "").trim();
+  const title = String(item.title ?? item.name ?? "").trim();
+  const introduction = String(
+    item.introduction ?? item.intro ?? item.description ?? "",
+  ).trim();
+  const frontUrl =
+    typeof item.frontUrl === "string" && item.frontUrl.trim()
+      ? item.frontUrl.trim()
+      : undefined;
+  const linkUrl =
+    typeof item.linkUrl === "string" && item.linkUrl.trim()
+      ? item.linkUrl.trim()
+      : undefined;
+  const fileUrl =
+    typeof item.fileUrl === "string" && item.fileUrl.trim()
+      ? item.fileUrl.trim()
+      : undefined;
+  const playUrl =
+    typeof item.playUrl === "string" && item.playUrl.trim()
+      ? item.playUrl.trim()
+      : undefined;
+  const payType: 0 | 1 = item.payType === 1 ? 1 : 0;
+
+  if (!videoName && !title && !introduction) return null;
+
+  return {
+    id,
+    videoName: videoName || title || "未命名视频",
+    title: title || videoName || "未命名视频",
+    introduction,
+    frontUrl,
+    playUrl: linkUrl ?? fileUrl ?? playUrl,
+    payType,
+  };
+}
+
+function extractVideoPage(payload: GoodsVideoListResponse): {
+  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,
+  };
+}
+
+export async function fetchCourses(category?: CourseCategory): Promise<Course[]> {
+  try {
+    const payload = await apiPost<
+      GoodsSearchListResponse,
+      { goodsType?: GoodsType; page: { current: number; row: number } }
+    >(
+      "/goods/search/list",
+      {
+        ...(category ? { goodsType: categoryToGoodsType(category) } : {}),
+        page: { current: 1, row: 10 },
+      },
+    );
+    const normalized = extractList(payload)
+      .map(normalizeCourse)
+      .filter((c): c is Course => c !== null);
+    return normalized;
+  } catch {
+    // Return empty list when API fails to avoid showing mock items.
+    return [];
+  }
+}
+
+export async function fetchCourseVideos(
+  goodsId: string,
+  page: { current: number; row: number } = { current: 1, row: 10 },
+): Promise<CourseVideoPageResult> {
+  const normalizedGoodsId = goodsId.trim();
+  if (!normalizedGoodsId) {
+    return { list: [], page: { current: 1, row: 10, total: 0 } };
+  }
+  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<
+      GoodsVideoListResponse,
+      { goodsId: string; page: { current: number; row: number } }
+    >(
+      "/goods/video/search/list",
+      {
+        goodsId: normalizedGoodsId,
+        page: { current, row },
+      },
+    );
+    const list = extractVideoList(payload)
+      .map(normalizeVideo)
+      .filter((v): v is CourseVideo => v !== null)
+      .slice(0, 10);
+    const serverPage = extractVideoPage(payload);
+    return {
+      list,
+      page: {
+        current: serverPage.current || current,
+        row: serverPage.row || row,
+        total: serverPage.total || (current - 1) * row + list.length,
+      },
+    };
+  } catch {
+    return { list: [], page: { current, row, total: 0 } };
+  }
+}

+ 16 - 0
src/hooks/use-now.ts

@@ -0,0 +1,16 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+/** 每秒刷新,用于倒计时展示 */
+export function useNow(enabled: boolean) {
+  const [now, setNow] = useState(() => new Date());
+
+  useEffect(() => {
+    if (!enabled) return;
+    const id = window.setInterval(() => setNow(new Date()), 1000);
+    return () => window.clearInterval(id);
+  }, [enabled]);
+
+  return now;
+}

+ 5 - 0
src/i18n/navigation.ts

@@ -0,0 +1,5 @@
+import { createNavigation } from "next-intl/navigation";
+import { routing } from "./routing";
+
+export const { Link, redirect, usePathname, useRouter, getPathname } =
+  createNavigation(routing);

+ 15 - 0
src/i18n/request.ts

@@ -0,0 +1,15 @@
+import { hasLocale } from "next-intl";
+import { getRequestConfig } from "next-intl/server";
+import { routing } from "./routing";
+
+export default getRequestConfig(async ({ requestLocale }) => {
+  const requested = await requestLocale;
+  const locale = hasLocale(routing.locales, requested)
+    ? requested
+    : routing.defaultLocale;
+
+  return {
+    locale,
+    messages: (await import(`../../messages/${locale}.json`)).default,
+  };
+});

+ 14 - 0
src/i18n/routing.ts

@@ -0,0 +1,14 @@
+import { defineRouting } from "next-intl/routing";
+
+export const routing = defineRouting({
+  locales: ["zh", "en"],
+  defaultLocale: "zh",
+  /**
+   * 使用 always:路径始终带 /zh、/en,与 app/[locale] 一致。
+   * as-needed 时默认语言无前缀(如 /auth/login),强依赖中间件重写;
+   * 在部分环境(如 Next 16)下若国际化中间件未生效会整站 404。
+   */
+  localePrefix: "always",
+  /** 关闭浏览器语言与 locale cookie 的自动跳转,避免默认被带到 /en */
+  localeDetection: false,
+});

+ 33 - 0
src/lib/account-api.ts

@@ -0,0 +1,33 @@
+import { apiPost } from "@/lib/api";
+
+function pickBalance(raw: unknown): number {
+  if (!raw || typeof raw !== "object") return 0;
+  const o = raw as Record<string, unknown>;
+  const candidates: unknown[] = [
+    o.data,
+    o.balance,
+    o.amount,
+    o.money,
+    o.walletBalance,
+  ];
+
+  if (o.data && typeof o.data === "object") {
+    const data = o.data as Record<string, unknown>;
+    candidates.push(data.balance, data.amount, data.money, data.walletBalance);
+  }
+
+  for (const value of candidates) {
+    if (typeof value === "number" && Number.isFinite(value)) return value;
+    if (typeof value === "string" && value.trim()) {
+      const parsed = Number(value);
+      if (Number.isFinite(parsed)) return parsed;
+    }
+  }
+
+  return 0;
+}
+
+export async function fetchWalletBalance(): Promise<number> {
+  const raw = await apiPost<unknown>("/custom/get/balance", {});
+  return pickBalance(raw);
+}

+ 140 - 0
src/lib/api.ts

@@ -0,0 +1,140 @@
+import axios, {
+  AxiosError,
+  type AxiosInstance,
+  type AxiosRequestConfig,
+} from "axios";
+
+import { getApiBaseUrl } from "@/lib/env";
+
+/**
+ * 业务后端接口约定:
+ * - 一律使用 POST(含「查询类」路径),新接口请用 {@link apiPost}。
+ * - 响应体若包含 `code` 字段,仅当 `code === 200` 视为成功;否则抛出 {@link ApiError},优先使用 `msg`。
+ * 若接入后端 JWT,可把 token 写入该 key,请求会自动带 Authorization。
+ */
+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",
+  },
+});
+
+api.interceptors.request.use((config) => {
+  if (typeof window !== "undefined") {
+    const token = localStorage.getItem(API_TOKEN_STORAGE_KEY);
+    if (token) {
+      config.headers[API_ACCESS_TOKEN_HEADER] = token;
+      config.headers.Authorization = `Bearer ${token}`;
+    }
+  }
+  return config;
+});
+
+export class ApiError extends Error {
+  constructor(
+    message: string,
+    public status?: number,
+    public payload?: unknown,
+  ) {
+    super(message);
+    this.name = "ApiError";
+  }
+}
+
+function isPlainObject(v: unknown): v is Record<string, unknown> {
+  return typeof v === "object" && v !== null && !Array.isArray(v);
+}
+
+/** 业务层约定:响应体含 `code` 时,仅 `200` 为成功,其余走失败(含 HTTP 200 但 code≠200) */
+function businessMessage(body: Record<string, unknown>): string {
+  const msg = body.msg;
+  if (typeof msg === "string" && msg.trim()) return msg.trim();
+  const message = body.message;
+  if (typeof message === "string" && message.trim()) return message.trim();
+  return "请求失败";
+}
+
+function isBusinessSuccessCode(code: unknown): boolean {
+  return code === 200 || code === "200";
+}
+
+api.interceptors.response.use(
+  (res) => {
+    const body = res.data;
+    if (!isPlainObject(body) || !("code" in body)) return res;
+    if (body.code === 600 || body.code === "600") {
+      setApiAuthToken(null);
+      return Promise.reject(new ApiError("登录状态失效,请重新登录", 600, body));
+    }
+    if (isBusinessSuccessCode(body.code)) return res;
+    const msg = businessMessage(body);
+    const codeNum =
+      typeof body.code === "number"
+        ? body.code
+        : Number(body.code) || res.status || 400;
+    return Promise.reject(new ApiError(msg, codeNum, body));
+  },
+  (error: AxiosError<{ message?: string; error?: string; msg?: string }>) => {
+    const status = error.response?.status;
+    const body = error.response?.data;
+    const msg =
+      (typeof body === "object" && body !== null && "msg" in body
+        ? String((body as { msg?: unknown }).msg)
+        : undefined) ??
+      (typeof body === "object" && body !== null && "message" in body
+        ? body.message
+        : undefined) ??
+      (typeof body === "object" && body !== null && "error" in body
+        ? String((body as { error?: unknown }).error)
+        : undefined) ??
+      (error.message?.trim() || "请求失败");
+    return Promise.reject(new ApiError(String(msg), status, body));
+  },
+);
+
+export function setApiAuthToken(token: string | null) {
+  if (typeof window === "undefined") return;
+  if (token) localStorage.setItem(API_TOKEN_STORAGE_KEY, token);
+  else localStorage.removeItem(API_TOKEN_STORAGE_KEY);
+}
+
+export async function apiPost<T, B = unknown>(
+  url: string,
+  body?: B,
+  config?: AxiosRequestConfig,
+): Promise<T> {
+  const { data } = await api.post<T>(url, body, config);
+  return data;
+}
+
+export async function apiPut<T, B = unknown>(
+  url: string,
+  body?: B,
+  config?: AxiosRequestConfig,
+): Promise<T> {
+  const { data } = await api.put<T>(url, body, config);
+  return data;
+}
+
+export async function apiPatch<T, B = unknown>(
+  url: string,
+  body?: B,
+  config?: AxiosRequestConfig,
+): Promise<T> {
+  const { data } = await api.patch<T>(url, body, config);
+  return data;
+}
+
+export async function apiDelete<T>(
+  url: string,
+  config?: AxiosRequestConfig,
+): Promise<T> {
+  const { data } = await api.delete<T>(url, config);
+  return data;
+}

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

@@ -0,0 +1,54 @@
+import { apiPost } from "@/lib/api";
+
+function pickToken(payload: unknown): string | null {
+  if (!payload || typeof payload !== "object") return null;
+  const o = payload as Record<string, unknown>;
+  if (typeof o.token === "string") return o.token;
+  if (typeof o.accessToken === "string") return o.accessToken;
+  if (typeof o.data === "string" && o.data.trim()) return o.data.trim();
+  if (o.data && typeof o.data === "object" && o.data !== null) {
+    const d = o.data as Record<string, unknown>;
+    if (typeof d.token === "string") return d.token;
+    if (typeof d.accessToken === "string") return d.accessToken;
+  }
+  return null;
+}
+
+function pickDisplayName(payload: unknown, fallbackEmail: string): string {
+  if (!payload || typeof payload !== "object") {
+    return fallbackEmail.split("@")[0] || "学员";
+  }
+  const o = payload as Record<string, unknown>;
+  const data =
+    o.data && typeof o.data === "object" && o.data !== null
+      ? (o.data as Record<string, unknown>)
+      : o;
+  const name =
+    typeof data.name === "string"
+      ? data.name.trim()
+      : typeof data.nickname === "string"
+        ? data.nickname.trim()
+        : "";
+  if (name) return name;
+  return fallbackEmail.split("@")[0] || "学员";
+}
+
+export async function loginWithPassword(
+  loginName: string,
+  password: string,
+): Promise<{ token: string | null; displayName: string }> {
+  const data = await apiPost<unknown>("/custom/login", { loginName, password });
+  const token = pickToken(data);
+  const displayName = pickDisplayName(data, loginName.trim());
+  return { token, displayName };
+}
+
+export async function updateLoginPassword(input: {
+  oldPassword: string;
+  newPassword: string;
+}): Promise<void> {
+  await apiPost("/update/login/password", {
+    oldPassword: input.oldPassword,
+    newPassword: input.newPassword,
+  });
+}

+ 60 - 0
src/lib/auth-types.ts

@@ -0,0 +1,60 @@
+export type MockOrder = {
+  id: string;
+  amount: number;
+  createdAt: string;
+  title: string;
+};
+
+export type ScholarshipPayout = {
+  bankName?: string;
+  accountName?: string;
+  accountNumber?: string;
+  alipayId?: string;
+};
+
+import { SPEND_THRESHOLD_USD } from "./quiz-rules";
+
+export type UserSession = {
+  email: string;
+  name: string;
+  /** ISO:首次累计消费达到门槛的时间(仅本地 Mock) */
+  thresholdReachedAt: string | null;
+  orders: MockOrder[];
+  scholarship: ScholarshipPayout;
+};
+
+const STORAGE_KEY = "jchl_user_v1";
+
+export function loadUser(): UserSession | null {
+  if (typeof window === "undefined") return null;
+  try {
+    const raw = localStorage.getItem(STORAGE_KEY);
+    if (!raw) return null;
+    return JSON.parse(raw) as UserSession;
+  } catch {
+    return null;
+  }
+}
+
+export function saveUser(user: UserSession): void {
+  localStorage.setItem(STORAGE_KEY, JSON.stringify(user));
+}
+
+export function clearUser(): void {
+  localStorage.removeItem(STORAGE_KEY);
+}
+
+export function sumOrders(orders: MockOrder[]): number {
+  return orders.reduce((s, o) => s + o.amount, 0);
+}
+
+/** 在订单变更后更新门槛时间:首次累计 ≥ 5000 时写入 */
+export function ensureThresholdReachedAt(
+  orders: MockOrder[],
+  previous: string | null,
+): string | null {
+  const total = sumOrders(orders);
+  if (total < SPEND_THRESHOLD_USD) return null;
+  if (previous) return previous;
+  return new Date().toISOString();
+}

+ 240 - 0
src/lib/checkout-api.ts

@@ -0,0 +1,240 @@
+import { api } from "@/lib/api";
+
+export type RemittanceChannel = {
+  id: string;
+  code: string;
+  requestUrl: string;
+  icon: string;
+  name: string;
+  description: string;
+  amountRange: string;
+  processingTime: string;
+  fee: string;
+  groupName: string;
+  groupOrder: number;
+  channelType: number | null;
+  bankValid: number;
+};
+
+export type BankChannelOption = {
+  value: string;
+  label: string;
+  currency: string;
+};
+
+function toNumber(v: unknown): number | null {
+  if (typeof v === "number" && Number.isFinite(v)) return v;
+  if (typeof v === "string" && v.trim()) {
+    const n = Number(v);
+    if (Number.isFinite(n)) return n;
+  }
+  return null;
+}
+
+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 pickAmountRange(record: Record<string, unknown>): string {
+  const min = toNumber(
+    record.minAmount ?? record.amountMin ?? record.lowerAmount ?? record.min,
+  );
+  const max = toNumber(
+    record.maxAmount ?? record.amountMax ?? record.upperAmount ?? record.max,
+  );
+  const unit = pickString(record, ["currency", "currencyCode", "coin", "unit"]).toUpperCase();
+  if (min !== null && max !== null) {
+    return `$${min} - $${max}${unit ? ` ${unit}` : ""}`;
+  }
+  const rangeText = pickString(record, ["amountRange", "limitRange", "amountDesc"]);
+  return rangeText || "-";
+}
+
+function pickFee(record: Record<string, unknown>): string {
+  const feeRate = toNumber(record.feeRate ?? record.rate);
+  if (feeRate !== null) {
+    if (feeRate <= 1) return `${(feeRate * 100).toFixed(2).replace(/\.00$/, "")}%`;
+    return `${feeRate.toFixed(2).replace(/\.00$/, "")}%`;
+  }
+  const fee = pickString(record, ["fee", "charge", "feeDesc"]);
+  return fee || "0%";
+}
+
+const CHANNEL_GROUP_NAME_MAP: Record<number, string> = {
+  1: "国际转账支付",
+  2: "中国网银支付",
+  3: "数字货币",
+  4: "电子钱包",
+  5: "CWG 电子卡",
+};
+
+const CHANNEL_GROUP_DISPLAY_ORDER: Record<number, number> = {
+  3: 1, // 数字货币
+  2: 2, // 中国网银支付
+  1: 3, // 国际转账支付
+  4: 4, // 电子钱包
+  5: 5, // CWG 电子卡
+};
+
+function pickGroupId(record: Record<string, unknown>): number | null {
+  const id = toNumber(
+    record.type ??
+      record.channelType ??
+      record.payTypeType ??
+      record.groupId ??
+      record.channelGroupId ??
+      record.channelClassify ??
+      record.channelCategory ??
+      record.categoryId ??
+      record.payTypeGroup ??
+      record.payTypeClassify,
+  );
+  if (id === null) return null;
+  if (id >= 1 && id <= 5) return id;
+  return null;
+}
+
+function normalizeChannelList(raw: unknown): RemittanceChannel[] {
+  if (!raw || typeof raw !== "object") return [];
+  const container = raw as Record<string, unknown>;
+  const inner =
+    container.data ??
+    container.list ??
+    container.rows ??
+    container.records ??
+    container.channels;
+
+  const source = Array.isArray(inner) ? inner : [];
+  const output: RemittanceChannel[] = [];
+
+  for (const item of source) {
+    if (!item || typeof item !== "object") continue;
+    const row = item as Record<string, unknown>;
+    const id = String(
+      row.id ??
+        row.channelId ??
+        row.payTypeId ??
+        row.code ??
+        row.channelCode ??
+        output.length + 1,
+    );
+    const name =
+      pickString(row, ["name", "channelName", "payType", "channelCode", "code"]) || "-";
+    const code = pickString(row, ["code", "channelCode", "payCode", "channelNo"]) || id;
+    const requestUrl = pickString(row, ["requestUrl", "payUrl", "url"]) || "/xfgpay/pay";
+    const icon = pickString(row, ["icon", "logo", "img", "image", "iconUrl"]);
+    const processingTime =
+      pickString(row, ["processingTime", "processTime", "arrivalTime", "timeDesc"]) || "1 hours";
+    const groupId = pickGroupId(row);
+    const fallbackGroupName =
+      pickString(row, [
+        "groupName",
+        "categoryName",
+        "channelGroup",
+        "payScene",
+        "group",
+      ]) || "支付通道";
+    const groupName = groupId ? CHANNEL_GROUP_NAME_MAP[groupId] : fallbackGroupName;
+    const groupOrder = groupId ? (CHANNEL_GROUP_DISPLAY_ORDER[groupId] ?? 999) : 999;
+
+    output.push({
+      id,
+      code,
+      requestUrl,
+      icon,
+      name,
+      description: name || "-",
+      amountRange: pickAmountRange(row),
+      processingTime,
+      fee: pickFee(row),
+      groupName,
+      groupOrder,
+      channelType: toNumber(row.type ?? row.channelType ?? row.payTypeType),
+      bankValid: toNumber(row.bankValid) ?? 0,
+    });
+  }
+  return output;
+}
+
+export async function fetchRemittanceChannels(): Promise<RemittanceChannel[]> {
+  const { data: raw } = await api.post<unknown>(
+    "/api/remittance/channel/list",
+    {},
+    { baseURL: "" },
+  );
+  return normalizeChannelList(raw);
+}
+
+function normalizeBankChannelList(raw: unknown): BankChannelOption[] {
+  if (!raw || typeof raw !== "object") return [];
+  const container = raw as Record<string, unknown>;
+  const inner =
+    container.data ??
+    container.list ??
+    container.rows ??
+    container.records ??
+    container.channels;
+  const source = Array.isArray(inner) ? inner : [];
+  const output: BankChannelOption[] = [];
+  for (const item of source) {
+    if (!item || typeof item !== "object") continue;
+    const row = item as Record<string, unknown>;
+    const value = pickString(row, ["code", "name", "currency", "enName"]);
+    if (!value) continue;
+    const label = pickString(row, ["name", "enName", "currency", "code"]) || value;
+    const currency = pickString(row, ["currency", "code", "name"]) || "USDT";
+    output.push({ value, label, currency });
+  }
+  return output;
+}
+
+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: "" });
+  return normalizeBankChannelList(raw);
+}
+
+export async function submitXfgPayOrder(input: {
+  requestUrl: string;
+  amount: number;
+  bankCode?: string;
+  goodIds: string[];
+  payName: string;
+  payPhone: string;
+}): Promise<{ raw: unknown; resultUrl: string | null }> {
+  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`;
+  const body = {
+    goodIds: input.goodIds,
+    payName: input.payName,
+    payPhone: input.payPhone,
+  };
+  const { data } = await api.post<unknown>(path, body, { baseURL: "" });
+  return { raw: data, resultUrl: pickResultUrl(data) };
+}
+
+function pickResultUrl(raw: unknown): string | null {
+  if (!raw || typeof raw !== "object") return null;
+  const o = raw as Record<string, unknown>;
+  const candidates: unknown[] = [
+    o.result,
+    o.url,
+    o.payUrl,
+    o.redirectUrl,
+  ];
+  if (o.data && typeof o.data === "object" && o.data !== null) {
+    const d = o.data as Record<string, unknown>;
+    candidates.push(d.result, d.url, d.payUrl, d.redirectUrl);
+  }
+  for (const item of candidates) {
+    if (typeof item === "string" && item.trim()) return item.trim();
+  }
+  return null;
+}

+ 50 - 0
src/lib/env.ts

@@ -0,0 +1,50 @@
+/**
+ * 三环境 API 根地址:
+ * - local:本地开发(next dev 读 .env.development)
+ * - test:测试环境(构建时用 build:test,或 CI 注入 NEXT_PUBLIC_APP_ENV=test)
+ * - production:生产(next build 默认读 .env.production)
+ *
+ * 优先使用 NEXT_PUBLIC_API_BASE_URL 覆盖(CI / 临时调试)。
+ */
+
+export type AppEnv = "local" | "test" | "production";
+
+export function resolveAppEnv(): AppEnv {
+  const fromEnv = process.env.NEXT_PUBLIC_APP_ENV;
+  if (fromEnv === "local" || fromEnv === "test" || fromEnv === "production") {
+    return fromEnv;
+  }
+  if (process.env.NODE_ENV === "development") return "local";
+  return "production";
+}
+
+export function getApiBaseUrl(): string {
+  const explicit = process.env.NEXT_PUBLIC_API_BASE_URL?.trim();
+  if (explicit) return explicit.replace(/\/$/, "");
+
+  const env = resolveAppEnv();
+  const map: Record<AppEnv, string | undefined> = {
+    local: process.env.NEXT_PUBLIC_API_BASE_URL_LOCAL,
+    test: process.env.NEXT_PUBLIC_API_BASE_URL_TEST,
+    production: process.env.NEXT_PUBLIC_API_BASE_URL_PRODUCTION,
+  };
+  return (map[env]?.trim() ?? "").replace(/\/$/, "");
+}
+
+export function getRemittanceApiBaseUrl(): string {
+  const explicit = process.env.NEXT_PUBLIC_REMITTANCE_API_BASE_URL?.trim();
+  if (explicit) return explicit.replace(/\/$/, "");
+
+  const base = getApiBaseUrl();
+  if (!base) return "";
+
+  try {
+    const url = new URL(base);
+    url.port = "8504";
+    return url.toString().replace(/\/$/, "");
+  } catch {
+    return base.replace(/\/$/, "");
+  }
+}
+
+export const appEnv = resolveAppEnv();

+ 56 - 0
src/lib/goods-order-api.ts

@@ -0,0 +1,56 @@
+import { apiPost } from "@/lib/api";
+
+export type PurchasedCourse = {
+  id: string;
+  goodsId: string;
+  title: string;
+  introduction: string;
+  coverUrl: string;
+};
+
+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 [];
+}
+
+export async function fetchPurchasedCourses(page: {
+  current: number;
+  row: number;
+} = {
+  current: 1,
+  row: 10,
+}): Promise<PurchasedCourse[]> {
+  const raw = await apiPost<unknown, { page: { current: number; row: number } }>(
+    "/goods/order/list",
+    {
+      page: {
+        current: Number.isFinite(page.current) ? Math.max(1, page.current) : 1,
+        row: Number.isFinite(page.row) ? Math.max(1, page.row) : 10,
+      },
+    },
+  );
+  const list = pickList(raw);
+  const out: PurchasedCourse[] = [];
+  for (const item of list) {
+    if (!item || typeof item !== "object") continue;
+    const o = item as Record<string, unknown>;
+    const id = String(o.id ?? o.orderId ?? o.serial ?? "").trim();
+    const goodsId = String(o.goodsId ?? o.id ?? "").trim();
+    if (!id || !goodsId) continue;
+    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 });
+  }
+  return out;
+}

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

@@ -0,0 +1,156 @@
+import { apiPost } from "@/lib/api";
+
+export type OrderStatus = 1 | 2 | 3 | 4 | 5;
+
+export type OrderRecord = {
+  id: string;
+  serial: string;
+  amount: number;
+  status: OrderStatus;
+  addTime: string;
+  payTime: string;
+  details: string;
+};
+
+export type OrderPage = {
+  current: number;
+  row: number;
+  total: number;
+};
+
+export type OrderListResult = {
+  list: OrderRecord[];
+  page: OrderPage;
+};
+
+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 normalizeDetails(value: unknown): string {
+  if (typeof value === "string" && value.trim()) return value.trim();
+  if (Array.isArray(value)) {
+    return value
+      .map((v) => (typeof v === "string" ? v.trim() : ""))
+      .filter(Boolean)
+      .join("、");
+  }
+  if (value && typeof value === "object") {
+    const o = value as Record<string, unknown>;
+    const name = o.title ?? o.name ?? o.goodsName ?? o.courseName;
+    if (typeof name === "string" && name.trim()) return name.trim();
+  }
+  return "-";
+}
+
+function pickPage(raw: unknown, fallback: { current: number; row: number }): OrderPage {
+  if (!raw || typeof raw !== "object") {
+    return { current: fallback.current, row: fallback.row, total: 0 };
+  }
+  const o = raw as Record<string, unknown>;
+  const fromData =
+    o.data && typeof o.data === "object" && o.data !== null
+      ? (o.data as Record<string, unknown>)
+      : null;
+  const page =
+    (fromData?.page && typeof fromData.page === "object" ? fromData.page : null) ??
+    (o.page && typeof o.page === "object" ? o.page : null);
+
+  const current = Number(
+    (page as Record<string, unknown> | null)?.current ??
+      (page as Record<string, unknown> | null)?.pageNum ??
+      fallback.current,
+  );
+  const row = Number(
+    (page as Record<string, unknown> | null)?.row ??
+      (page as Record<string, unknown> | null)?.size ??
+      fallback.row,
+  );
+  const total = Number((page as Record<string, unknown> | null)?.total ?? 0);
+  return {
+    current: Number.isFinite(current) && current > 0 ? current : fallback.current,
+    row: Number.isFinite(row) && row > 0 ? row : fallback.row,
+    total: Number.isFinite(total) && total >= 0 ? total : 0,
+  };
+}
+
+export function getOrderStatusLabel(status: number): string {
+  const map: Record<number, string> = {
+    1: "未支付",
+    2: "已支付",
+    3: "支付失败",
+    4: "已过期",
+    5: "已取消",
+  };
+  return map[status] ?? `状态${status}`;
+}
+
+export async function fetchOrderList(page: {
+  current: number;
+  row: number;
+} = {
+  current: 1,
+  row: 10,
+}): Promise<OrderListResult> {
+  const current = Number.isFinite(page.current) ? Math.max(1, page.current) : 1;
+  const row = Number.isFinite(page.row) ? Math.max(1, page.row) : 10;
+  const raw = await apiPost<
+    unknown,
+    {
+      page: { current: number; row: number };
+    }
+  >("/order/search/list", {
+    page: {
+      current,
+      row,
+    },
+  });
+  const list = pickList(raw);
+  const out: OrderRecord[] = [];
+
+  for (const item of list) {
+    if (!item || typeof item !== "object") continue;
+    const o = item as Record<string, unknown>;
+    const id = String(o.id ?? o.orderId ?? o.tradeId ?? o.serial ?? "").trim();
+    const serial = String(o.serial ?? o.orderNo ?? o.tradeNo ?? o.id ?? "").trim();
+    if (!serial || !id) continue;
+    const amountRaw = o.amount ?? o.payAmount ?? o.totalAmount ?? 0;
+    const amount = typeof amountRaw === "number" ? amountRaw : Number(amountRaw) || 0;
+    const statusRaw = Number(o.status ?? o.payStatus ?? 1);
+    const status: OrderStatus =
+      statusRaw === 1 || statusRaw === 2 || statusRaw === 3 || statusRaw === 4 || statusRaw === 5
+        ? statusRaw
+        : 1;
+    const addTime = String(o.addTime ?? o.createTime ?? o.createdAt ?? "").trim();
+    const payTime = String(o.payTime ?? o.paidAt ?? "").trim();
+    const details = normalizeDetails(o.details ?? o.detail ?? o.goodsName ?? o.courseName);
+
+    out.push({
+      id,
+      serial,
+      amount,
+      status,
+      addTime,
+      payTime,
+      details,
+    });
+  }
+  return {
+    list: out,
+    page: pickPage(raw, { current, row }),
+  };
+}
+
+export async function cancelOrder(id: string): Promise<void> {
+  await apiPost("/order/cancel", { id });
+}

+ 11 - 0
src/lib/password-rules.ts

@@ -0,0 +1,11 @@
+/**
+ * 注册密码:8–15 位;仅英文字母与数字;须同时含大写、小写、数字。
+ */
+export function isRegisterPasswordValid(password: string): boolean {
+  if (password.length < 8 || password.length > 15) return false;
+  if (!/^[A-Za-z0-9]+$/.test(password)) return false;
+  if (!/[a-z]/.test(password)) return false;
+  if (!/[A-Z]/.test(password)) return false;
+  if (!/[0-9]/.test(password)) return false;
+  return true;
+}

+ 52 - 0
src/lib/quiz-rules.ts

@@ -0,0 +1,52 @@
+/** 累计消费达到该金额(美元)后开启倒计时 */
+export const SPEND_THRESHOLD_USD = 5000;
+
+/** 倒计时天数,结束后解锁有奖知识问答 */
+export const QUIZ_COUNTDOWN_DAYS = 180;
+
+export type QuizAccessState =
+  | { status: "below_threshold"; totalSpent: number; amountToThreshold: number }
+  | {
+      status: "countdown";
+      totalSpent: number;
+      quizUnlockAt: string;
+      msRemaining: number;
+    }
+  | { status: "unlocked"; totalSpent: number; quizUnlockAt: string };
+
+export function computeQuizAccess(
+  totalSpent: number,
+  thresholdReachedAtIso: string | null | undefined,
+  now: Date = new Date(),
+): QuizAccessState {
+  if (totalSpent < SPEND_THRESHOLD_USD) {
+    return {
+      status: "below_threshold",
+      totalSpent,
+      amountToThreshold: SPEND_THRESHOLD_USD - totalSpent,
+    };
+  }
+
+  const thresholdReached = thresholdReachedAtIso
+    ? new Date(thresholdReachedAtIso)
+    : now;
+  const quizUnlockAt = new Date(thresholdReached);
+  quizUnlockAt.setDate(quizUnlockAt.getDate() + QUIZ_COUNTDOWN_DAYS);
+
+  const msRemaining = quizUnlockAt.getTime() - now.getTime();
+
+  if (msRemaining > 0) {
+    return {
+      status: "countdown",
+      totalSpent,
+      quizUnlockAt: quizUnlockAt.toISOString(),
+      msRemaining,
+    };
+  }
+
+  return {
+    status: "unlocked",
+    totalSpent,
+    quizUnlockAt: quizUnlockAt.toISOString(),
+  };
+}

+ 71 - 0
src/lib/register-api.ts

@@ -0,0 +1,71 @@
+import { apiPost } from "@/lib/api";
+
+export type CountryOption = { value: string; label: string };
+
+function normalizeCountryList(raw: unknown): CountryOption[] {
+  let list: unknown[] = [];
+  if (Array.isArray(raw)) list = raw;
+  else if (raw && typeof raw === "object") {
+    const o = raw as Record<string, unknown>;
+    const inner = o.data ?? o.list ?? o.rows ?? o.records ?? o.countries;
+    if (Array.isArray(inner)) list = inner;
+  }
+  const out: CountryOption[] = [];
+  for (const item of list) {
+    if (typeof item === "string") {
+      out.push({ value: item, label: item });
+      continue;
+    }
+    if (item && typeof item === "object") {
+      const o = item as Record<string, unknown>;
+      /** 与后端约定:提交国家用 ISO/地区 `code`(如 AF),不要用数字 `id` */
+      const value =
+        o.code ?? o.countryCode ?? o.isoCode ?? o.value ?? o.countryId ?? o.id;
+      const label = o.name ?? o.countryName ?? o.label ?? o.title ?? String(value ?? "");
+      if (value !== undefined && value !== null && String(value).length > 0) {
+        out.push({ value: String(value), label: String(label) });
+      }
+    }
+  }
+  return out;
+}
+
+export async function fetchRegisterCountries(): Promise<CountryOption[]> {
+  const raw = await apiPost<unknown>("/country/get", {});
+  return normalizeCountryList(raw);
+}
+
+export async function sendRegisterVerificationCode(email: string): Promise<void> {
+  await apiPost("/custom/register/send/code", { email });
+}
+
+function pickToken(payload: unknown): string | null {
+  if (!payload || typeof payload !== "object") return null;
+  const o = payload as Record<string, unknown>;
+  if (typeof o.token === "string") return o.token;
+  if (typeof o.accessToken === "string") return o.accessToken;
+  if (typeof o.data === "string" && o.data.trim()) return o.data.trim();
+  if (o.data && typeof o.data === "object" && o.data !== null) {
+    const d = o.data as Record<string, unknown>;
+    if (typeof d.token === "string") return d.token;
+    if (typeof d.accessToken === "string") return d.accessToken;
+  }
+  return null;
+}
+
+export async function registerWithEmail(input: {
+  country: string;
+  name: string;
+  email: string;
+  emailCode: string;
+  password: string;
+}): Promise<{ token: string | null }> {
+  const data = await apiPost<unknown>("/custom/register", {
+    country: input.country,
+    name: input.name,
+    email: input.email,
+    emailCode: input.emailCode,
+    password: input.password,
+  });
+  return { token: pickToken(data) };
+}

+ 51 - 0
src/lib/reward-api.ts

@@ -0,0 +1,51 @@
+import { apiPost } from "@/lib/api";
+
+export type RewardQuestion = {
+  type: 0 | 1;
+  questionId: string;
+  question: string;
+};
+
+function toBinaryType(value: unknown): 0 | 1 {
+  const n = typeof value === "number" ? value : Number(value);
+  return n === 1 ? 1 : 0;
+}
+
+function toQuestion(raw: unknown): RewardQuestion {
+  if (!raw || typeof raw !== "object") {
+    return { type: 0, questionId: "", question: "" };
+  }
+  const o = raw as Record<string, unknown>;
+  const data = o.data && typeof o.data === "object" ? (o.data as Record<string, unknown>) : null;
+  const fromRoot = {
+    type: o.type,
+    questionId: o.questionId ?? o.id,
+    question: o.question,
+  };
+  const fromData = {
+    type: data?.type,
+    questionId: data?.questionId ?? data?.id,
+    question: data?.question,
+  };
+
+  return {
+    type: toBinaryType(fromData.type ?? fromRoot.type),
+    questionId: String(fromData.questionId ?? fromRoot.questionId ?? "").trim(),
+    question: String(fromData.question ?? fromRoot.question ?? "").trim(),
+  };
+}
+
+export async function fetchRewardQuestion(): Promise<RewardQuestion> {
+  const raw = await apiPost<unknown>("/reward/question/get", {});
+  return toQuestion(raw);
+}
+
+export async function submitRewardQuestionAnswer(input: {
+  questionId: string;
+  customAnswer: 0 | 1;
+}): Promise<void> {
+  await apiPost("/reward/question/custom/add", {
+    questionId: input.questionId,
+    customAnswer: input.customAnswer,
+  });
+}

+ 6 - 0
src/lib/utils.ts

@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+  return twMerge(clsx(inputs));
+}

+ 288 - 0
src/lib/withdrawal-api.ts

@@ -0,0 +1,288 @@
+import { api } from "@/lib/api";
+import { getRemittanceApiBaseUrl } from "@/lib/env";
+
+export type WithdrawalRecord = {
+  id: string;
+  serial: string;
+  amount: number;
+  status: string;
+  addTime: string;
+  payTime: string;
+  details: string;
+};
+
+export function getWithdrawalStatusLabel(status: string): string {
+  const normalized = String(status).trim();
+  const map: Record<string, string> = {
+    "0": "待审核",
+    "1": "处理中",
+    "2": "已完成",
+    "3": "已拒绝",
+    "4": "已取消",
+    pending: "待审核",
+    processing: "处理中",
+    success: "已完成",
+    completed: "已完成",
+    failed: "已拒绝",
+    rejected: "已拒绝",
+    cancelled: "已取消",
+  };
+  return map[normalized.toLowerCase()] ?? (normalized || "处理中");
+}
+
+export async function fetchWithdrawalList(page: { current: number; row: number } = {
+  current: 1,
+  row: 10,
+}): Promise<WithdrawalRecord[]> {
+  const current = Number.isFinite(page.current) ? Math.max(1, page.current) : 1;
+  const row = Number.isFinite(page.row) ? Math.max(1, page.row) : 10;
+  const { data: raw } = await api.post<unknown>("/finance/withdraw/searcher/list", {
+    page: {
+      current,
+      row,
+    },
+  });
+  const list = pickList(raw);
+  const out: WithdrawalRecord[] = [];
+  for (const item of list) {
+    if (!item || typeof item !== "object") continue;
+    const o = item as Record<string, unknown>;
+    const id = String(o.id ?? o.withdrawId ?? o.serial ?? out.length + 1);
+    const serial = String(o.serial ?? o.orderNo ?? o.tradeNo ?? id);
+    const amount = toNum(o.amount ?? o.withdrawAmount ?? o.money) ?? 0;
+    const statusRaw = o.status ?? o.state ?? o.withdrawStatus;
+    const status =
+      typeof statusRaw === "number"
+        ? String(statusRaw)
+        : String(statusRaw ?? "处理中").trim() || "处理中";
+    const addTime = String(o.addTime ?? o.createTime ?? o.applyTime ?? "");
+    const payTime = String(o.payTime ?? o.finishTime ?? o.arrivalTime ?? "");
+    const details = String(o.details ?? o.channelName ?? o.remark ?? "提款申请");
+    out.push({
+      id,
+      serial,
+      amount,
+      status,
+      addTime,
+      payTime,
+      details,
+    });
+  }
+  return out;
+}
+
+export type WithdrawAccount = {
+  login: string;
+  type: string;
+  currency: string;
+  balance: number;
+  closeFunctions: string[];
+};
+
+export type WithdrawChannel = {
+  id: string;
+  code: string;
+  name: string;
+  enName: string;
+  type: string;
+  icon: string;
+  currency: string;
+  minAmount: number;
+  maxAmount: number;
+  feeType: number | null;
+  free: number | null;
+  feeAmount: number | null;
+  fundingTime: string;
+  requestUrl: string;
+  bankValid: number;
+  introduce: string;
+  enIntroduce: string;
+};
+
+export type WithdrawBankOption = {
+  code: string;
+  name: string;
+  enName: string;
+  minAmount: number | null;
+  maxAmount: number | null;
+  feeType: number | null;
+  free: number | null;
+  feeAmount: number | null;
+  currency: string;
+};
+
+export type SavedWithdrawAccount = {
+  id: string;
+  type: number;
+  bankUname: string;
+  bankCardNum: string;
+  bankName: string;
+  bankBranchName: string;
+  bankAddr: string;
+  swiftCode: string;
+  customBankCode: string;
+  addressName: string;
+  address: string;
+  addressProve: string;
+  cvv: string;
+  expiryYearMonth: string;
+  authStatus: number;
+  defaultBank: boolean;
+};
+
+function toNum(value: unknown): number | null {
+  if (typeof value === "number" && Number.isFinite(value)) return value;
+  if (typeof value === "string" && value.trim()) {
+    const n = Number(value);
+    if (Number.isFinite(n)) return n;
+  }
+  return null;
+}
+
+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 [];
+}
+
+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);
+  const out: WithdrawAccount[] = [];
+  for (const item of list) {
+    if (!item || typeof item !== "object") continue;
+    const o = item as Record<string, unknown>;
+    const login = String(o.login ?? o.account ?? "").trim();
+    if (!login) continue;
+    out.push({
+      login,
+      type: String(o.type ?? "").trim(),
+      currency: String(o.currency ?? "USD").trim() || "USD",
+      balance: toNum(o.balance) ?? 0,
+      closeFunctions: Array.isArray(o.closeFunctions)
+        ? o.closeFunctions.map((x) => String(x))
+        : String(o.closeFunctions ?? "")
+            .split(",")
+            .map((x) => x.trim())
+            .filter(Boolean),
+    });
+  }
+  return out;
+}
+
+export async function fetchWithdrawChannels(): Promise<WithdrawChannel[]> {
+  let raw: unknown;
+  try {
+    raw = await postRemittance("/remit/channel/list", {});
+  } catch {
+    // 兼容部分环境仍使用 remittance 路径
+    raw = await postRemittance("/remittance/channel/list", {});
+  }
+  const list = pickList(raw);
+  const out: WithdrawChannel[] = [];
+  for (const item of list) {
+    if (!item || typeof item !== "object") continue;
+    const o = item as Record<string, unknown>;
+    out.push({
+      id: String(o.id ?? o.code ?? out.length + 1),
+      code: String(o.code ?? ""),
+      name: String(o.name ?? ""),
+      enName: String(o.enName ?? ""),
+      type: String(o.type ?? ""),
+      icon: String(o.icon ?? ""),
+      currency: String(o.currency ?? "USD"),
+      minAmount: toNum(o.minAmount) ?? 0,
+      maxAmount: toNum(o.maxAmount) ?? 0,
+      feeType: toNum(o.feeType),
+      free: toNum(o.free),
+      feeAmount: toNum(o.feeAmount),
+      fundingTime: String(o.fundingTime ?? o.processingTime ?? "1 hours"),
+      requestUrl: String(o.requestUrl ?? "").trim(),
+      bankValid: toNum(o.bankValid) ?? 0,
+      introduce: String(o.introduce ?? ""),
+      enIntroduce: String(o.enIntroduce ?? ""),
+    });
+  }
+  return out;
+}
+
+export async function fetchWithdrawBankOptions(channelCode: string): Promise<WithdrawBankOption[]> {
+  const raw = await postRemittance("/channel/bank/list", { channelCode });
+  const list = pickList(raw);
+  return list
+    .filter((item) => item && typeof item === "object")
+    .map((item) => {
+      const o = item as Record<string, unknown>;
+      return {
+        code: String(o.code ?? ""),
+        name: String(o.name ?? ""),
+        enName: String(o.enName ?? ""),
+        minAmount: toNum(o.minAmount),
+        maxAmount: toNum(o.maxAmount),
+        feeType: toNum(o.feeType),
+        free: toNum(o.free),
+        feeAmount: toNum(o.feeAmount),
+        currency: String(o.currency ?? "USD"),
+      };
+    });
+}
+
+export async function fetchSavedWithdrawAccounts(): Promise<SavedWithdrawAccount[]> {
+  const raw = await postRemittance("/custom/bank/list", {});
+  const list = pickList(raw);
+  const out: SavedWithdrawAccount[] = [];
+  for (const item of list) {
+    if (!item || typeof item !== "object") continue;
+    const o = item as Record<string, unknown>;
+    out.push({
+      id: String(o.id ?? ""),
+      type: toNum(o.type) ?? 0,
+      bankUname: String(o.bankUname ?? ""),
+      bankCardNum: String(o.bankCardNum ?? ""),
+      bankName: String(o.bankName ?? ""),
+      bankBranchName: String(o.bankBranchName ?? ""),
+      bankAddr: String(o.bankAddr ?? ""),
+      swiftCode: String(o.swiftCode ?? ""),
+      customBankCode: String(o.customBankCode ?? o.bankCode ?? ""),
+      addressName: String(o.addressName ?? ""),
+      address: String(o.address ?? ""),
+      addressProve: String(o.addressProve ?? ""),
+      cvv: String(o.cvv ?? ""),
+      expiryYearMonth:
+        String(o.expiryYearMonth ?? "").trim() ||
+        `${String(o.expiryYear ?? "")}/${String(o.expiryMonth ?? "")}`,
+      authStatus: toNum(o.authStatus) ?? 1,
+      defaultBank: Boolean(o.defaultBank),
+    });
+  }
+  return out;
+}
+
+function normalizeRequestUrl(path: string): string {
+  const p = path.trim();
+  if (!p) return "/withdraw/apply";
+  return p.startsWith("/") ? p : `/${p}`;
+}
+
+export async function submitWithdrawApply(input: {
+  requestUrl: string;
+  payload: Record<string, unknown>;
+}): Promise<void> {
+  const path = normalizeRequestUrl(input.requestUrl);
+  await postRemittance(path, input.payload);
+}

+ 234 - 0
src/providers/auth-provider.tsx

@@ -0,0 +1,234 @@
+"use client";
+
+import {
+  createContext,
+  useCallback,
+  useContext,
+  useEffect,
+  useMemo,
+  useState,
+  type ReactNode,
+} from "react";
+import {
+  clearUser,
+  ensureThresholdReachedAt,
+  loadUser,
+  saveUser,
+  sumOrders,
+  type MockOrder,
+  type UserSession,
+} from "@/lib/auth-types";
+import { SPEND_THRESHOLD_USD } from "@/lib/quiz-rules";
+import { ApiError, setApiAuthToken } from "@/lib/api";
+import { loginWithPassword } from "@/lib/auth-api";
+import { isRegisterPasswordValid } from "@/lib/password-rules";
+import { registerWithEmail } from "@/lib/register-api";
+
+type AuthContextValue = {
+  user: UserSession | null;
+  isReady: boolean;
+  login: (
+    email: string,
+    password: string,
+  ) => Promise<{ ok: boolean; error?: string; message?: string }>;
+  register: (input: {
+    country: string;
+    email: string;
+    password: string;
+    name: string;
+    code: string;
+  }) => Promise<{ ok: boolean; error?: string; message?: string }>;
+  logout: () => void;
+  updateProfile: (patch: Partial<Pick<UserSession, "name" | "scholarship">>) => void;
+  addMockOrder: (order: Omit<MockOrder, "id" | "createdAt">) => void;
+  /** 本地演示:将门槛达成时间设为 181 天前,用于验证问答解锁 */
+  demoUnlockQuizCountdown: () => void;
+};
+
+const AuthContext = createContext<AuthContextValue | null>(null);
+
+function newUserSession(
+  email: string,
+  name: string,
+  seedOrders: MockOrder[] = [],
+): UserSession {
+  const thresholdReachedAt = ensureThresholdReachedAt(seedOrders, null);
+  return {
+    email,
+    name,
+    thresholdReachedAt,
+    orders: seedOrders,
+    scholarship: {},
+  };
+}
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+  const [user, setUser] = useState<UserSession | null>(null);
+  const [isReady, setIsReady] = useState(false);
+  useEffect(() => {
+    /* eslint-disable react-hooks/set-state-in-effect -- hydrate once from localStorage after mount */
+    setUser(loadUser());
+    setIsReady(true);
+    /* eslint-enable react-hooks/set-state-in-effect */
+  }, []);
+
+  const persist = useCallback((next: UserSession | null) => {
+    setUser(next);
+    if (next) saveUser(next);
+    else clearUser();
+  }, []);
+
+  const login = useCallback(
+    async (email: string, password: string) => {
+      if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
+        return { ok: false, error: "invalid_email" };
+      }
+      try {
+        const { token, displayName } = await loginWithPassword(
+          email.trim(),
+          password,
+        );
+        if (token) setApiAuthToken(token);
+        persist(newUserSession(email.trim(), displayName));
+        return { ok: true };
+      } catch (e) {
+        const message = e instanceof ApiError ? e.message : "登录失败";
+        return { ok: false, error: "api", message };
+      }
+    },
+    [persist],
+  );
+
+  const register = useCallback(
+    async (input: {
+      country: string;
+      email: string;
+      password: string;
+      name: string;
+      code: string;
+    }) => {
+      if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.email)) {
+        return { ok: false, error: "invalid_email" };
+      }
+      if (!isRegisterPasswordValid(input.password)) {
+        return { ok: false, error: "weak_password" };
+      }
+      if (!input.code.trim()) {
+        return { ok: false, error: "invalid_code" };
+      }
+      if (!input.country.trim()) {
+        return { ok: false, error: "country_required" };
+      }
+      try {
+        const { token } = await registerWithEmail({
+          country: input.country.trim(),
+          name: input.name.trim() || "学员",
+          email: input.email.trim(),
+          emailCode: input.code.trim(),
+          password: input.password,
+        });
+        if (token) setApiAuthToken(token);
+        persist(newUserSession(input.email, input.name.trim() || "学员"));
+        return { ok: true };
+      } catch (e) {
+        const message = e instanceof ApiError ? e.message : "注册失败";
+        return { ok: false, error: "api", message };
+      }
+    },
+    [persist],
+  );
+
+  const logout = useCallback(() => {
+    setApiAuthToken(null);
+    persist(null);
+  }, [persist]);
+
+  const updateProfile = useCallback(
+    (patch: Partial<Pick<UserSession, "name" | "scholarship">>) => {
+      if (!user) return;
+      persist({
+        ...user,
+        ...patch,
+        name: patch.name ?? user.name,
+        scholarship: { ...user.scholarship, ...patch.scholarship },
+      });
+    },
+    [user, persist],
+  );
+
+  const addMockOrder = useCallback(
+    (order: Omit<MockOrder, "id" | "createdAt">) => {
+      if (!user) return;
+      const nextOrder: MockOrder = {
+        ...order,
+        id: `ord_${Date.now()}`,
+        createdAt: new Date().toISOString(),
+      };
+      const orders = [...user.orders, nextOrder];
+      const thresholdReachedAt = ensureThresholdReachedAt(
+        orders,
+        user.thresholdReachedAt,
+      );
+      persist({
+        ...user,
+        orders,
+        thresholdReachedAt,
+      });
+    },
+    [user, persist],
+  );
+
+  const demoUnlockQuizCountdown = useCallback(() => {
+    if (!user) return;
+    let orders = user.orders;
+    if (sumOrders(orders) < SPEND_THRESHOLD_USD) {
+      orders = [
+        ...orders,
+        {
+          id: `ord_demo_${Date.now()}`,
+          amount: SPEND_THRESHOLD_USD,
+          createdAt: new Date().toISOString(),
+          title: "演示订单(验证问答解锁)",
+        },
+      ];
+    }
+    const past = new Date();
+    past.setDate(past.getDate() - 181);
+    persist({
+      ...user,
+      orders,
+      thresholdReachedAt: past.toISOString(),
+    });
+  }, [user, persist]);
+
+  const value = useMemo<AuthContextValue>(
+    () => ({
+      user,
+      isReady,
+      login,
+      register,
+      logout,
+      updateProfile,
+      addMockOrder,
+      demoUnlockQuizCountdown,
+    }),
+    [
+      user,
+      isReady,
+      login,
+      register,
+      logout,
+      updateProfile,
+      addMockOrder,
+      demoUnlockQuizCountdown,
+    ],
+  );
+
+  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+}
+
+export function useAuth() {
+  const ctx = useContext(AuthContext);
+  if (!ctx) throw new Error("useAuth must be used within AuthProvider");
+  return ctx;
+}

+ 16 - 0
src/proxy.ts

@@ -0,0 +1,16 @@
+import createMiddleware from "next-intl/middleware";
+import type { NextRequest } from "next/server";
+import { routing } from "./i18n/routing";
+
+const intlMiddleware = createMiddleware(routing);
+
+/** Next.js 16+:原 middleware.ts 已迁移为 proxy(nodejs 运行时) */
+export function proxy(request: NextRequest) {
+  return intlMiddleware(request);
+}
+
+export const config = {
+  matcher: [
+    "/((?!api|api-backend|_next|_vercel|.*\\..*).*)",
+  ],
+};

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff