'init'
This commit is contained in:
38
.cursor/rules/project-foundation.mdc
Normal file
38
.cursor/rules/project-foundation.mdc
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
description: 项目基础规范 - 响应式、主题、组件化
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# LMS 项目基础规范
|
||||
|
||||
以下 3 项为本项目**兜底要求**,任何改动都需遵守。
|
||||
|
||||
## 1. 响应式设计
|
||||
|
||||
必须支持 **PC / PAD / H5** 三种视口:
|
||||
|
||||
| 设备 | 断点 | Tailwind 前缀 |
|
||||
|------|------|---------------|
|
||||
| H5 手机 | < 640px | 默认 / `sm:` |
|
||||
| PAD 平板 | 640px ~ 1024px | `sm:` ~ `lg:` |
|
||||
| PC 桌面 | ≥ 1024px | `lg:` ~ `xl:` |
|
||||
|
||||
- 使用 `sm:`, `md:`, `lg:`, `xl:` 做断点适配
|
||||
- 布局:`grid-cols-1 sm:grid-cols-2 lg:grid-cols-4`
|
||||
- 字号:`text-base sm:text-lg md:text-xl`
|
||||
- 内边距:`px-4 sm:px-6`、`py-12 md:py-16 lg:py-20`
|
||||
|
||||
## 2. 日间/夜间主题
|
||||
|
||||
- 使用 CSS 变量 `var(--background)`、`var(--foreground)` 等,勿硬编码颜色
|
||||
- 主题变量定义在 `app/globals.css` 的 `:root/.light` 和 `.dark`
|
||||
- 主题切换通过 `ThemeProvider` + `ThemeToggle` 实现
|
||||
- 新增样式需同时适配 light/dark 两种模式
|
||||
|
||||
## 3. 功能组件化 / 模块化 / 配置化
|
||||
|
||||
- **组件化**:功能拆成独立组件,放在 `app/components/`
|
||||
- **模块化**:区块组件放在 `app/components/sections/`,通用组件放 `app/components/`
|
||||
- **配置化**:展示数据集中在 `config/course.ts`,组件只负责渲染
|
||||
|
||||
新增内容时:先加配置 → 再写/改组件 → 保持 page 简洁。
|
||||
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
196
README.md
196
README.md
@@ -1,92 +1,174 @@
|
||||
# nomadlms
|
||||
# 🌍 LMS - 轻量级课程落地页与学习系统
|
||||
|
||||
一个基于 Next.js 的课程落地页与视频学习系统,支持配置化课程、学习进度、支付对接、PWA 等。开箱即用,适合知识付费、在线课程、培训等场景。
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## Getting started
|
||||
## ✨ 功能特性
|
||||
|
||||
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
|
||||
### 课程展示
|
||||
- **落地页**:Hero、受众、收益、课程大纲、导师、FAQ、CTA
|
||||
- **课程大纲**:模块折叠、全文搜索、收藏标记、完成状态
|
||||
- **试看机制**:前 2 节免费试看,可配置
|
||||
|
||||
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
|
||||
### 学习体验
|
||||
- **继续学习**:记住上次观看章节,一键续播
|
||||
- **学习进度**:完成度展示、进度条
|
||||
- **视频播放**:续播提示、倍速、全屏、观看进度自动保存
|
||||
- **收藏/书签**:章节收藏,便于回看
|
||||
- **课程证书**:完成全部课程后可查看证书
|
||||
|
||||
## Add your files
|
||||
### 互动与分享
|
||||
- **分享海报**:生成课程海报、二维码,支持下载分享
|
||||
- **社群入口**:报名后解锁社群二维码弹窗
|
||||
- **课程评价**:星级 + 文字评价(可配置隐藏)
|
||||
|
||||
- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files
|
||||
- [ ] [Add files using the command line](https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line) or push an existing Git repository with the following command:
|
||||
### 支付与接入
|
||||
- **支付对接**:支持第三方支付(如 ZPay 等),可扩展
|
||||
- **支付状态**:本地模拟 / 服务端校验,可配置
|
||||
|
||||
### 其他
|
||||
- **PWA**:manifest.json,支持添加到主屏
|
||||
- **深色模式**:跟随系统主题切换
|
||||
- **响应式**:适配桌面与移动端
|
||||
|
||||
---
|
||||
|
||||
## 🛠 技术栈
|
||||
|
||||
| 类别 | 技术 |
|
||||
|------|------|
|
||||
| 框架 | Next.js 16 (App Router)、React 19 |
|
||||
| 语言 | TypeScript 5 |
|
||||
| 样式 | Tailwind CSS 4、animate.css |
|
||||
| 动画 | Framer Motion、tsParticles |
|
||||
| 文档 | react-markdown、remark-gfm、rehype-highlight |
|
||||
| 其他 | highlight.js、github-markdown-css |
|
||||
|
||||
### 核心依赖
|
||||
|
||||
- **framer-motion**:滚动入场、组件动画
|
||||
- **@tsparticles/react**:粒子背景效果
|
||||
- **animate.css**:Hero 入场动画
|
||||
- **react-markdown**:课程配套文档 Markdown 渲染
|
||||
|
||||
---
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
cd existing_repo
|
||||
git remote add origin http://gitlab.yidooplanet.com/gitlab-instance-0a899031/nomadlms.git
|
||||
git branch -M main
|
||||
git push -uf origin main
|
||||
lms/
|
||||
├── app/
|
||||
│ ├── api/ # API 路由
|
||||
│ │ ├── pay/ # 支付相关
|
||||
│ │ └── pay/status/ # 支付状态
|
||||
│ ├── certificate/ # 证书页
|
||||
│ ├── course/[module]/[lesson]/ # 课程详情页
|
||||
│ ├── pay/ # 支付页
|
||||
│ ├── components/ # 组件
|
||||
│ │ ├── custom/ # 自实现封装(无依赖可选)
|
||||
│ │ └── sections/ # 区块组件
|
||||
│ ├── lib/ # 业务逻辑
|
||||
│ │ ├── learning.ts # 学习进度、收藏、笔记
|
||||
│ │ └── payment.ts # 支付状态
|
||||
│ └── providers/ # 主题等 Provider
|
||||
├── config/
|
||||
│ ├── course.ts # 课程落地页配置
|
||||
│ └── lessons.ts # 章节详情
|
||||
├── public/
|
||||
│ └── manifest.json # PWA Manifest
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Integrate with your tools
|
||||
---
|
||||
|
||||
- [ ] [Set up project integrations](http://gitlab.yidooplanet.com/gitlab-instance-0a899031/nomadlms/-/settings/integrations)
|
||||
## 🚀 快速开始
|
||||
|
||||
## Collaborate with your team
|
||||
### 环境要求
|
||||
|
||||
- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/)
|
||||
- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
|
||||
- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
|
||||
- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
|
||||
- [ ] [Automatically merge when pipeline succeeds](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html)
|
||||
- Node.js 18+
|
||||
- pnpm / npm / yarn
|
||||
|
||||
## Test and Deploy
|
||||
### 安装
|
||||
|
||||
Use the built-in continuous integration in GitLab.
|
||||
```bash
|
||||
# 使用 pnpm(推荐)
|
||||
pnpm install
|
||||
|
||||
- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/index.html)
|
||||
- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing(SAST)](https://docs.gitlab.com/ee/user/application_security/sast/)
|
||||
- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
|
||||
- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
|
||||
- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
|
||||
# 或 npm
|
||||
npm install
|
||||
|
||||
***
|
||||
# 或 yarn
|
||||
yarn install
|
||||
```
|
||||
|
||||
# Editing this README
|
||||
### 开发
|
||||
|
||||
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thank you to [makeareadme.com](https://www.makeareadme.com/) for this template.
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## Suggestions for a good README
|
||||
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
|
||||
访问 [http://localhost:3000](http://localhost:3000)
|
||||
|
||||
## Name
|
||||
Choose a self-explaining name for your project.
|
||||
### 构建与预览
|
||||
|
||||
## Description
|
||||
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
|
||||
```bash
|
||||
# 构建
|
||||
pnpm build
|
||||
|
||||
## Badges
|
||||
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
|
||||
# 启动生产服务
|
||||
pnpm start
|
||||
```
|
||||
|
||||
## Visuals
|
||||
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
|
||||
---
|
||||
|
||||
## Installation
|
||||
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
|
||||
## ⚙️ 配置说明
|
||||
|
||||
## Usage
|
||||
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
|
||||
### 课程配置 `config/course.ts`
|
||||
|
||||
## Support
|
||||
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
|
||||
| 配置项 | 说明 |
|
||||
|--------|------|
|
||||
| `SITE_CONFIG` | 站点名称、Footer 等 |
|
||||
| `HERO_CONFIG` | 标题、副标题、数据卡片 |
|
||||
| `CURRICULUM_CONFIG` | 课程模块与章节 |
|
||||
| `AUDIENCE_CONFIG` | 受众说明 |
|
||||
| `BENEFITS_CONFIG` | 学完收益 |
|
||||
| `INSTRUCTORS_CONFIG` | 导师信息 |
|
||||
| `FAQ_CONFIG` | 常见问题 |
|
||||
| `COMMUNITY_CONFIG` | 社群入口、二维码 URL |
|
||||
| `DOWNLOAD_CONFIG` | 网盘下载模块 |
|
||||
|
||||
## Roadmap
|
||||
If you have ideas for releases in the future, it is a good idea to list them in the README.
|
||||
### 章节配置 `config/lessons.ts`
|
||||
|
||||
## Contributing
|
||||
State if you are open to contributions and what your requirements are for accepting them.
|
||||
- 视频 URL、Markdown 文档
|
||||
- 按 `moduleIndex`、`lessonIndex` 映射
|
||||
|
||||
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
|
||||
### 支付与测试
|
||||
|
||||
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
|
||||
- 支付逻辑在 `app/lib/payment.ts`,通过 `localStorage` 的 `pay` 键判断是否已支付
|
||||
- 测试时可手动设置:`localStorage.setItem('pay', 'ok')` 模拟已报名
|
||||
|
||||
## Authors and acknowledgment
|
||||
Show your appreciation to those who have contributed to the project.
|
||||
---
|
||||
|
||||
## License
|
||||
For open source projects, say how it is licensed.
|
||||
## 🎨 特色
|
||||
|
||||
## Project status
|
||||
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
|
||||
1. **配置驱动**:课程内容、落地页、FAQ、社群等均通过配置修改,无需改组件
|
||||
2. **轻量学习逻辑**:进度、收藏、笔记、证书基于 localStorage,无后端即可运行
|
||||
3. **动画体验**:使用 Framer Motion、tsParticles、animate.css 等库,提供流畅动效
|
||||
4. **自实现可选**:`app/components/custom/` 提供无依赖的粒子与滚动动画实现,可替换第三方库
|
||||
5. **PWA 支持**:可安装到主屏,支持离线访问
|
||||
|
||||
---
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
MIT
|
||||
|
||||
---
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request。
|
||||
|
||||
227
app/api/pay/route.ts
Normal file
227
app/api/pay/route.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getZPayConfig } from "@/lib/sdk";
|
||||
import { SITE_URL } from "@/app/lib/env";
|
||||
|
||||
const ZPAY_REQUIRED = [
|
||||
"pid",
|
||||
"type",
|
||||
"out_trade_no",
|
||||
"notify_url",
|
||||
"return_url",
|
||||
"name",
|
||||
"money",
|
||||
"sign",
|
||||
"sign_type",
|
||||
];
|
||||
|
||||
function escapeAttr(s: string): string {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function buildPayHtml(payUrl: string, params: Record<string, unknown>): string {
|
||||
const flat: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v == null) continue;
|
||||
const s = String(v).trim();
|
||||
if (s === "") continue;
|
||||
flat[k] = s;
|
||||
}
|
||||
const inputs = Object.entries(flat)
|
||||
.map(([k, v]) => `<input type="hidden" name="${escapeAttr(k)}" value="${escapeAttr(v)}" />`)
|
||||
.join("\n");
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>跳转支付...</title>
|
||||
<style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f0f4f8;}p{color:#64748b;}</style>
|
||||
</head>
|
||||
<body>
|
||||
<p>正在跳转到支付页面...</p>
|
||||
<form id="zpayForm" action="${escapeAttr(payUrl)}" method="POST" enctype="application/x-www-form-urlencoded">
|
||||
${inputs}
|
||||
</form>
|
||||
<script>document.getElementById("zpayForm").submit();</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function detectDevice(request: NextRequest): "pc" | "h5" | "wechat" {
|
||||
const ua = request.headers.get("user-agent") || "";
|
||||
if (/MicroMessenger/i.test(ua)) return "wechat";
|
||||
if (/Mobile|Android|iPhone|iPad/i.test(ua)) return "h5";
|
||||
return "pc";
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
let body: Record<string, string | number>;
|
||||
const contentType = request.headers.get("content-type") || "";
|
||||
if (contentType.includes("application/json")) {
|
||||
body = await request.json();
|
||||
} else {
|
||||
const form = await request.formData();
|
||||
body = Object.fromEntries(form.entries()) as Record<string, string>;
|
||||
}
|
||||
|
||||
const payConfig = getZPayConfig();
|
||||
const user_id = String(body?.user_id || `user_${Date.now()}`).trim();
|
||||
const return_url = String(body?.return_url || "").trim();
|
||||
const total_fee = Number(body?.total_fee) || payConfig.defaultAmount;
|
||||
const type = String(body?.type || payConfig.defaultType);
|
||||
const channel = String(body?.channel || "alipay");
|
||||
const device = (body?.device as string) || detectDevice(request);
|
||||
|
||||
const origin =
|
||||
request.headers.get("x-forwarded-host")
|
||||
? `${request.headers.get("x-forwarded-proto") || "https"}://${request.headers.get("x-forwarded-host")}`
|
||||
: request.headers.get("origin") || SITE_URL;
|
||||
const finalReturnUrl = return_url || `${origin}/pay/paid`;
|
||||
|
||||
const payload = {
|
||||
user_id,
|
||||
total_fee,
|
||||
type,
|
||||
channel: device === "wechat" ? "wxpay" : channel,
|
||||
return_url: finalReturnUrl,
|
||||
device,
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 15000);
|
||||
|
||||
const redirectRes = await fetch(`${payConfig.apiUrl}/payh5/redirect`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
client_ip: request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||
request.headers.get("x-real-ip") ||
|
||||
"",
|
||||
}),
|
||||
redirect: "manual",
|
||||
signal: controller.signal,
|
||||
}).finally(() => clearTimeout(timeout));
|
||||
|
||||
const orderId = redirectRes.headers.get("x-pay-order-id")?.trim();
|
||||
|
||||
if (redirectRes.status >= 301 && redirectRes.status <= 308) {
|
||||
const location = redirectRes.headers.get("location");
|
||||
if (location) {
|
||||
const res = NextResponse.redirect(location);
|
||||
if (orderId) {
|
||||
res.cookies.set("pay_order", `${orderId}|${Date.now()}`, { path: "/", maxAge: 900 });
|
||||
}
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
const resData = await redirectRes.json().catch(() => ({}));
|
||||
|
||||
if (redirectRes.status === 200 && resData?.use_form) {
|
||||
const { createPayOrder } = await import("@/lib/sdk");
|
||||
const { params } = await createPayOrder({
|
||||
user_id,
|
||||
return_url: finalReturnUrl,
|
||||
total_fee,
|
||||
type,
|
||||
channel: "wxpay",
|
||||
});
|
||||
const html = buildPayHtml(payConfig.zpaySubmitUrl, params);
|
||||
const res = new NextResponse(html, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
if (orderId) {
|
||||
res.cookies.set("pay_order", `${orderId}|${Date.now()}`, { path: "/", maxAge: 900 });
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
if (redirectRes.status === 200 && resData?.qrcode_mode && resData?.img) {
|
||||
const returnUrl = String(resData.return_url || finalReturnUrl).replace(/[&<>"']/g, (c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c
|
||||
);
|
||||
const imgSrc = String(resData.img).replace(/[&<>"']/g, (c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c
|
||||
);
|
||||
const ordId = String(resData.order_id || "").replace(/[&<>"']/g, (c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c
|
||||
);
|
||||
const ch = String(resData.channel || "alipay").toLowerCase();
|
||||
const isWx = ch === "wxpay" || ch === "wx" || ch === "wechat";
|
||||
const title = isWx ? "微信扫码支付" : "支付宝扫码支付";
|
||||
const hint = isWx ? "请使用微信扫描下方二维码完成支付" : "请使用支付宝扫描下方二维码完成支付";
|
||||
const esc = (s: string) =>
|
||||
s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] || c);
|
||||
const html = `<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>${esc(title)}</title>
|
||||
<style>body{font-family:system-ui;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#f0f4f8;}h2{color:#333;margin-bottom:8px;}p{color:#64748b;font-size:14px;}img{width:220px;height:220px;margin:20px 0;border:1px solid #e2e8f0;border-radius:8px;background:#fff;}a{color:#0ea5e9;margin-top:16px;}.tip{font-size:12px;color:#94a3b8;margin-top:8px;}</style></head>
|
||||
<body>
|
||||
<h2>${esc(title)}</h2>
|
||||
<p>${esc(hint)}</p>
|
||||
<img src="${imgSrc}" alt="支付二维码" />
|
||||
<p class="tip" id="tip">支付成功后将自动跳转...</p>
|
||||
<script>
|
||||
(function(){
|
||||
var orderId="${ordId}";
|
||||
var returnUrl="${returnUrl}";
|
||||
if(!orderId){return;}
|
||||
var tip=document.getElementById("tip");
|
||||
var t=0;
|
||||
var maxT=300;
|
||||
function check(){
|
||||
t++;
|
||||
if(t>maxT){tip.textContent="轮询超时";return;}
|
||||
fetch("/api/pay/status?order_id="+encodeURIComponent(orderId))
|
||||
.then(function(r){return r.json();})
|
||||
.then(function(d){
|
||||
if(d.ok&&d.paid){window.location.href=returnUrl;return;}
|
||||
setTimeout(check,2000);
|
||||
})
|
||||
.catch(function(){setTimeout(check,2000);});
|
||||
}
|
||||
setTimeout(check,2000);
|
||||
})();
|
||||
</script>
|
||||
</body></html>`;
|
||||
return new NextResponse(html, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
|
||||
if (redirectRes.status === 400 || redirectRes.status === 500) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: resData?.detail || resData?.msg || "支付跳转失败" },
|
||||
{ status: redirectRes.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "支付服务暂不可用,请稍后重试" },
|
||||
{ status: 500 }
|
||||
);
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error(String(e));
|
||||
const msg = err.message || "";
|
||||
const hint =
|
||||
msg.includes("fetch") || msg.includes("ECONNREFUSED")
|
||||
? "请确认 payjsapi 已启动:cd C:\\project\\payjsapi && python run.py"
|
||||
: (err as Error).name === "AbortError"
|
||||
? "请求超时,请检查 payjsapi 是否正常运行"
|
||||
: msg;
|
||||
console.error("Pay API error:", err);
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: `支付请求失败: ${hint}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
15
app/api/pay/status/route.ts
Normal file
15
app/api/pay/status/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getOrderStatus } from "@/lib/sdk";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const orderId = request.nextUrl.searchParams.get("order_id");
|
||||
if (!orderId) {
|
||||
return NextResponse.json({ ok: false, error: "缺少 order_id" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const { paid } = await getOrderStatus(orderId);
|
||||
return NextResponse.json({ ok: true, paid });
|
||||
} catch (e) {
|
||||
return NextResponse.json({ ok: false, paid: false, error: String(e) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
82
app/certificate/page.tsx
Normal file
82
app/certificate/page.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Header } from "@/app/components";
|
||||
import { getProgress } from "@/app/lib/learning";
|
||||
import { CURRICULUM_CONFIG } from "@/config/course";
|
||||
import { isPaid } from "@/app/lib/payment";
|
||||
|
||||
const TOTAL = CURRICULUM_CONFIG.modules.reduce((s, m) => s + m.lessons.length, 0);
|
||||
|
||||
export default function CertificatePage() {
|
||||
const [progress, setProgress] = useState({ completed: 0, percent: 0 });
|
||||
const [paid, setPaid] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setProgress(getProgress(TOTAL));
|
||||
setPaid(isPaid());
|
||||
}, []);
|
||||
|
||||
const completed = progress.percent >= 100;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)]">
|
||||
<Header />
|
||||
<main className="pt-20 pb-16">
|
||||
<div className="mx-auto max-w-2xl px-4 text-center">
|
||||
{!paid ? (
|
||||
<div className="rounded-2xl border border-[var(--border)] bg-[var(--card)] p-8">
|
||||
<h1 className="mb-4 text-2xl font-bold">报名后可查看证书</h1>
|
||||
<p className="mb-6 text-[var(--muted-foreground)]">
|
||||
完成全部 {TOTAL} 节课程后可申请结业证书
|
||||
</p>
|
||||
<Link
|
||||
href="/pay"
|
||||
className="inline-flex rounded-full bg-[var(--accent)] px-6 py-3 font-medium text-[var(--accent-foreground)] hover:opacity-90"
|
||||
>
|
||||
立即报名
|
||||
</Link>
|
||||
</div>
|
||||
) : completed ? (
|
||||
<div className="rounded-2xl border-2 border-[var(--accent)] bg-[var(--card)] p-8">
|
||||
<div className="mb-6 text-5xl">🎓</div>
|
||||
<h1 className="mb-2 text-2xl font-bold">结业证书</h1>
|
||||
<p className="mb-6 text-[var(--muted-foreground)]">
|
||||
恭喜你完成《数字游民实战课》全部 {TOTAL} 节课程
|
||||
</p>
|
||||
<p className="mb-8 text-sm text-[var(--muted-foreground)]">
|
||||
异度世界、大师兄、小美
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-[var(--accent)] underline hover:no-underline"
|
||||
>
|
||||
返回课程
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-[var(--border)] bg-[var(--card)] p-8">
|
||||
<h1 className="mb-4 text-2xl font-bold">继续加油</h1>
|
||||
<p className="mb-6 text-[var(--muted-foreground)]">
|
||||
已完成 {progress.completed}/{TOTAL} 节,完成全部可解锁证书
|
||||
</p>
|
||||
<div className="mb-6 h-3 overflow-hidden rounded-full bg-[var(--muted)]">
|
||||
<div
|
||||
className="h-full rounded-full bg-[var(--accent)] transition-all"
|
||||
style={{ width: `${progress.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<Link
|
||||
href="/course/0/0"
|
||||
className="inline-flex rounded-full bg-[var(--accent)] px-6 py-3 font-medium text-[var(--accent-foreground)] hover:opacity-90"
|
||||
>
|
||||
继续学习
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
app/components/AnimateOnScroll.tsx
Normal file
22
app/components/AnimateOnScroll.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { motion, type HTMLMotionProps } from "framer-motion";
|
||||
|
||||
type Props = HTMLMotionProps<"div"> & {
|
||||
delay?: number;
|
||||
};
|
||||
|
||||
export function AnimateOnScroll({ children, delay = 0, className = "", ...rest }: Props) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, amount: 0.08, margin: "0px 0px -40px 0px" }}
|
||||
transition={{ duration: 0.6, ease: "easeOut", delay: delay / 1000 }}
|
||||
className={className}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
43
app/components/Button.tsx
Normal file
43
app/components/Button.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import Link from "next/link";
|
||||
|
||||
type ButtonProps = {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
variant?: "primary" | "secondary";
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "px-4 py-2 text-sm",
|
||||
md: "px-5 py-2.5 text-base",
|
||||
lg: "px-8 py-4 text-lg sm:px-10",
|
||||
};
|
||||
|
||||
export function Button({
|
||||
href,
|
||||
children,
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
className = "",
|
||||
}: ButtonProps) {
|
||||
const base =
|
||||
"inline-flex items-center justify-center gap-2 rounded-full font-medium transition";
|
||||
const variants = {
|
||||
primary:
|
||||
"bg-[var(--accent)] text-[var(--accent-foreground)] hover:opacity-90",
|
||||
secondary:
|
||||
"border border-[var(--border)] bg-[var(--card)] hover:bg-[var(--muted)]",
|
||||
};
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`${base} ${variants[variant]} ${sizeClasses[size]} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
135
app/components/CommunityButton.tsx
Normal file
135
app/components/CommunityButton.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { COMMUNITY_CONFIG } from "@/config/course";
|
||||
import { isPaid } from "@/app/lib/payment";
|
||||
|
||||
export function CommunityButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [paid, setPaid] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setPaid(isPaid());
|
||||
const onPay = () => setPaid(isPaid());
|
||||
window.addEventListener("pay:updated", onPay);
|
||||
return () => window.removeEventListener("pay:updated", onPay);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
if (open) {
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!COMMUNITY_CONFIG.enabled || !COMMUNITY_CONFIG.wechat) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="text-sm text-[var(--muted-foreground)] transition hover:text-[var(--foreground)]"
|
||||
>
|
||||
{COMMUNITY_CONFIG.wechat}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="relative w-full max-w-[420px] overflow-hidden rounded-2xl border border-[var(--border)] bg-[var(--background)] shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-[var(--accent)] via-cyan-400 to-[var(--accent)]" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="absolute right-3 top-3 rounded-full p-2 text-[var(--muted-foreground)] transition hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{paid ? (
|
||||
<div className="p-8 pt-10">
|
||||
<div className="mb-6 text-center">
|
||||
<div className="mb-3 inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-[var(--accent)]/30 to-cyan-500/20 text-3xl shadow-lg">
|
||||
💬
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold">加入数字游民社群</h3>
|
||||
<p className="mt-2 text-sm text-[var(--muted-foreground)]">
|
||||
与导师、同学一起交流,获取持续支持与灵感
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<div className="relative flex h-56 w-56 items-center justify-center rounded-2xl bg-gradient-to-br from-[var(--card)] to-[var(--muted)]/30 p-5 shadow-inner ring-2 ring-[var(--accent)]/20">
|
||||
<img
|
||||
src={COMMUNITY_CONFIG.qrCodeUrl}
|
||||
alt="社群二维码"
|
||||
className="h-full w-full object-contain"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = "none";
|
||||
const fallback = target.parentElement?.querySelector(".qr-fallback");
|
||||
if (fallback) (fallback as HTMLElement).classList.remove("hidden");
|
||||
}}
|
||||
/>
|
||||
<div className="qr-fallback hidden absolute inset-0 flex flex-col items-center justify-center gap-2 rounded-2xl bg-[var(--muted)]/40 p-4 text-center text-sm text-[var(--muted-foreground)]">
|
||||
<span className="text-4xl">📱</span>
|
||||
<span>请配置社群二维码</span>
|
||||
<span className="text-xs">config/course.ts → qrCodeUrl</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-5 text-center text-sm text-[var(--muted-foreground)]">
|
||||
长按识别二维码加入
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-8 pt-10">
|
||||
<div className="mb-6 text-center">
|
||||
<div className="mb-3 inline-flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-amber-500/20 to-orange-500/10 text-4xl shadow-lg">
|
||||
🔒
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold">报名后解锁社群</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-[var(--muted-foreground)]">
|
||||
完成课程报名后,即可加入专属社群,与导师、同学交流答疑
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Link
|
||||
href="/pay"
|
||||
className="rounded-xl bg-[var(--accent)] px-6 py-4 text-center font-medium text-[var(--accent-foreground)] transition hover:opacity-90"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
立即报名
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-xl border border-[var(--border)] px-6 py-3 text-sm transition hover:bg-[var(--muted)]"
|
||||
>
|
||||
稍后再说
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
25
app/components/Footer.tsx
Normal file
25
app/components/Footer.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { SITE_CONFIG } from "@/config/course";
|
||||
import { ShareButton } from "./ShareButton";
|
||||
import { CommunityButton } from "./CommunityButton";
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="border-t border-[var(--border)] py-6 sm:py-8">
|
||||
<div className="mx-auto max-w-6xl px-4 sm:px-6">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-center gap-4">
|
||||
<Link href="/certificate" className="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)]">
|
||||
我的证书
|
||||
</Link>
|
||||
<CommunityButton />
|
||||
<ShareButton />
|
||||
</div>
|
||||
<div className="text-center text-sm text-[var(--muted-foreground)]">
|
||||
{SITE_CONFIG.footer}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
45
app/components/Header.tsx
Normal file
45
app/components/Header.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
import { SITE_CONFIG } from "@/config/course";
|
||||
import { isPaid } from "@/app/lib/payment";
|
||||
|
||||
export function Header() {
|
||||
const [paid, setPaid] = useState(false);
|
||||
useEffect(() => {
|
||||
setPaid(isPaid());
|
||||
const onPayUpdate = () => setPaid(isPaid());
|
||||
window.addEventListener("pay:updated", onPayUpdate);
|
||||
return () => window.removeEventListener("pay:updated", onPayUpdate);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header className="fixed left-0 right-0 top-0 z-50 border-b border-[var(--border)] bg-[var(--background)]/90 backdrop-blur-xl">
|
||||
<div className="mx-auto flex h-12 max-w-6xl items-center justify-between gap-2 px-3 sm:h-14 sm:gap-4 sm:px-4 md:h-16 md:px-6">
|
||||
<Link href="/" className="min-w-0 truncate text-sm font-medium sm:text-base md:text-xl">
|
||||
{SITE_CONFIG.brand}
|
||||
</Link>
|
||||
<div className="flex shrink-0 items-center gap-1.5 sm:gap-3">
|
||||
<ThemeToggle />
|
||||
<Link
|
||||
href={paid ? "/course/0/0" : "/pay"}
|
||||
className="hidden rounded-full bg-[var(--accent)] px-3 py-1.5 text-xs font-medium text-[var(--accent-foreground)] transition hover:opacity-90 sm:inline-flex sm:px-4 sm:py-2 sm:text-sm"
|
||||
>
|
||||
{paid ? "进入课程 →" : "立即报名 →"}
|
||||
</Link>
|
||||
<Link
|
||||
href={paid ? "/course/0/0" : "/pay"}
|
||||
className="inline-flex rounded-full bg-[var(--accent)] p-2 text-[var(--accent-foreground)] sm:hidden"
|
||||
aria-label={paid ? "进入课程" : "立即报名"}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
111
app/components/Hero.tsx
Normal file
111
app/components/Hero.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { HERO_CONFIG, CURRICULUM_CONFIG } from "@/config/course";
|
||||
import { isPaid } from "@/app/lib/payment";
|
||||
import { getLastWatched, getProgress } from "@/app/lib/learning";
|
||||
import { ParticleBackground } from "./ParticleBackground";
|
||||
|
||||
const TOTAL_LESSONS = CURRICULUM_CONFIG.modules.reduce((s, m) => s + m.lessons.length, 0);
|
||||
|
||||
export function Hero() {
|
||||
const [paid, setPaid] = useState(false);
|
||||
const [last, setLast] = useState<ReturnType<typeof getLastWatched>>(null);
|
||||
const [progress, setProgress] = useState({ completed: 0, percent: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
setPaid(isPaid());
|
||||
setLast(getLastWatched());
|
||||
setProgress(getProgress(TOTAL_LESSONS));
|
||||
const onPay = () => setPaid(isPaid());
|
||||
const onProgress = () => {
|
||||
setLast(getLastWatched());
|
||||
setProgress(getProgress(TOTAL_LESSONS));
|
||||
};
|
||||
window.addEventListener("pay:updated", onPay);
|
||||
window.addEventListener("learning:progress", onProgress);
|
||||
return () => {
|
||||
window.removeEventListener("pay:updated", onPay);
|
||||
window.removeEventListener("learning:progress", onProgress);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden pt-24 pb-16 sm:pt-28 sm:pb-20 md:pt-32 md:pb-24">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,var(--gradient-accent),transparent)] animate-gradient" />
|
||||
<ParticleBackground />
|
||||
<div className="relative mx-auto max-w-4xl px-4 text-center sm:px-6">
|
||||
<h1
|
||||
className="mb-4 text-3xl font-bold tracking-tight animate__animated animate__fadeInDown sm:text-4xl md:text-5xl lg:text-6xl"
|
||||
style={{ animationDelay: "0.1s", animationFillMode: "both" }}
|
||||
>
|
||||
{HERO_CONFIG.title}
|
||||
</h1>
|
||||
<p
|
||||
className="mb-10 text-lg text-[var(--muted-foreground)] animate__animated animate__fadeInDown sm:mb-12 sm:text-xl md:text-2xl"
|
||||
style={{ animationDelay: "0.25s", animationFillMode: "both" }}
|
||||
>
|
||||
{HERO_CONFIG.subtitle}
|
||||
</p>
|
||||
<div
|
||||
className="mb-10 flex flex-wrap justify-center gap-6 animate__animated animate__fadeInDown sm:mb-12 sm:gap-8"
|
||||
style={{ animationDelay: "0.4s", animationFillMode: "both" }}
|
||||
>
|
||||
{HERO_CONFIG.stats.map((s) => (
|
||||
<div key={s.label} className="text-center transition-transform duration-300 hover:scale-105">
|
||||
<div className="text-2xl font-bold text-[var(--accent)] sm:text-3xl md:text-4xl">
|
||||
{s.value}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-[var(--muted-foreground)] sm:text-sm">
|
||||
{s.label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="flex flex-wrap justify-center gap-3 animate__animated animate__fadeInDown sm:gap-4"
|
||||
style={{ animationDelay: "0.6s", animationFillMode: "both" }}
|
||||
>
|
||||
{paid && last ? (
|
||||
<Link
|
||||
href={`/course/${last.moduleIndex}/${last.lessonIndex}`}
|
||||
className="inline-flex shrink-0 items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] transition hover:opacity-90 sm:px-10"
|
||||
>
|
||||
继续学习 →
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
href={paid ? "/course/0/0" : "/pay"}
|
||||
className="inline-flex shrink-0 items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] transition hover:opacity-90 sm:px-10"
|
||||
>
|
||||
{paid ? "进入课程 →" : "立即报名 →"}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{paid && progress.completed > 0 && (
|
||||
<div
|
||||
className="mt-6 w-full max-w-xs animate__animated animate__fadeInDown"
|
||||
style={{ animationDelay: "0.7s", animationFillMode: "both" }}
|
||||
>
|
||||
<div className="mb-1 flex justify-between text-xs text-[var(--muted-foreground)]">
|
||||
<span>学习进度</span>
|
||||
<span>{progress.completed}/{TOTAL_LESSONS} 节</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-[var(--muted)]">
|
||||
<div
|
||||
className="h-full rounded-full bg-[var(--accent)] transition-all"
|
||||
style={{ width: `${progress.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
{progress.percent >= 100 && (
|
||||
<Link href="/certificate" className="mt-2 inline-block text-sm font-medium text-[var(--accent)] hover:underline">
|
||||
查看证书 →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
40
app/components/LessonNotes.tsx
Normal file
40
app/components/LessonNotes.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { getNote, setNote } from "@/app/lib/learning";
|
||||
|
||||
type Props = { moduleIndex: number; lessonIndex: number };
|
||||
|
||||
export function LessonNotes({ moduleIndex, lessonIndex }: Props) {
|
||||
const [text, setText] = useState("");
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setText(getNote(moduleIndex, lessonIndex));
|
||||
}, [moduleIndex, lessonIndex]);
|
||||
|
||||
const handleSave = () => {
|
||||
setNote(moduleIndex, lessonIndex, text);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 sm:p-6">
|
||||
<h3 className="mb-3 flex items-center gap-2 font-semibold">
|
||||
<span>📝</span> 学习笔记
|
||||
</h3>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onBlur={handleSave}
|
||||
placeholder="记录你的学习心得..."
|
||||
className="mb-3 w-full min-h-[100px] rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm resize-y focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
|
||||
rows={4}
|
||||
/>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
{saved ? "✓ 已保存" : "失焦自动保存"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
app/components/MarkdownContent.tsx
Normal file
89
app/components/MarkdownContent.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
type MarkdownContentProps = {
|
||||
content: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function MarkdownContent({ content, className = "" }: MarkdownContentProps) {
|
||||
return (
|
||||
<article
|
||||
className={`markdown-content text-[var(--foreground)] ${className}`}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => (
|
||||
<h1 className="mb-4 mt-8 border-b border-[var(--border)] pb-2 text-2xl font-bold first:mt-0 sm:text-3xl">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="mb-3 mt-6 text-xl font-semibold sm:text-2xl">{children}</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="mb-2 mt-4 text-lg font-semibold sm:text-xl">{children}</h3>
|
||||
),
|
||||
p: ({ children }) => <p className="mb-4 leading-7">{children}</p>,
|
||||
ul: ({ children }) => (
|
||||
<ul className="mb-4 list-disc space-y-1 pl-6">{children}</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="mb-4 list-decimal space-y-1 pl-6">{children}</ol>
|
||||
),
|
||||
li: ({ children }) => <li className="leading-7">{children}</li>,
|
||||
code: ({ className: codeClassName, children }) => {
|
||||
const isBlock = codeClassName?.includes("language-");
|
||||
if (isBlock) {
|
||||
return (
|
||||
<pre className="mb-4 overflow-x-auto rounded-lg border border-[var(--border)] bg-[var(--muted)] p-4 text-sm">
|
||||
<code className={codeClassName}>{children}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className="rounded bg-[var(--muted)] px-1.5 py-0.5 font-mono text-sm">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-[var(--accent)] pl-4 italic text-[var(--muted-foreground)]">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[var(--accent)] underline hover:opacity-80"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="mb-4 overflow-x-auto">
|
||||
<table className="min-w-full border-collapse border border-[var(--border)]">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="border border-[var(--border)] bg-[var(--muted)] px-4 py-2 text-left font-semibold">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="border border-[var(--border)] px-4 py-2">{children}</td>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
55
app/components/ParticleBackground.tsx
Normal file
55
app/components/ParticleBackground.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Particles, { initParticlesEngine } from "@tsparticles/react";
|
||||
import { loadSlim } from "@tsparticles/slim";
|
||||
import type { ISourceOptions } from "@tsparticles/engine";
|
||||
|
||||
export function ParticleBackground() {
|
||||
const [init, setInit] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
initParticlesEngine(async (engine) => {
|
||||
await loadSlim(engine);
|
||||
}).then(() => setInit(true));
|
||||
}, []);
|
||||
|
||||
const options: ISourceOptions = {
|
||||
background: { color: { value: "transparent" } },
|
||||
fpsLimit: 60,
|
||||
interactivity: {
|
||||
events: { onHover: { enable: true, mode: "grab" } },
|
||||
modes: { grab: { distance: 140, links: { opacity: 0.3 } } },
|
||||
},
|
||||
particles: {
|
||||
color: { value: "#f59e0b" },
|
||||
links: {
|
||||
color: "#f59e0b",
|
||||
distance: 120,
|
||||
enable: true,
|
||||
opacity: 0.15,
|
||||
width: 0.5,
|
||||
},
|
||||
move: {
|
||||
enable: true,
|
||||
outModes: { default: "bounce" },
|
||||
speed: 0.3,
|
||||
},
|
||||
number: { density: { enable: true, area: 800 }, value: 50 },
|
||||
opacity: { value: { min: 0.1, max: 0.4 } },
|
||||
shape: { type: "circle" },
|
||||
size: { value: { min: 0.5, max: 2 } },
|
||||
},
|
||||
detectRetina: true,
|
||||
};
|
||||
|
||||
if (!init) return null;
|
||||
|
||||
return (
|
||||
<Particles
|
||||
id="hero-particles"
|
||||
className="pointer-events-none absolute inset-0"
|
||||
options={options}
|
||||
/>
|
||||
);
|
||||
}
|
||||
59
app/components/PayButton.tsx
Normal file
59
app/components/PayButton.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
type PayButtonProps = {
|
||||
children: React.ReactNode;
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
channel?: "alipay" | "wxpay";
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "px-3 py-1.5 text-xs sm:px-4 sm:py-2 sm:text-sm",
|
||||
md: "px-5 py-2.5 text-base",
|
||||
lg: "px-8 py-4 text-lg sm:px-10",
|
||||
};
|
||||
|
||||
export function PayButton({ children, size = "md", className = "", channel = "alipay" }: PayButtonProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handlePay = () => {
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
const form = document.createElement("form");
|
||||
form.method = "POST";
|
||||
form.action = "/api/pay";
|
||||
form.style.display = "none";
|
||||
|
||||
const fields = {
|
||||
user_id: `user_${Date.now()}`,
|
||||
return_url: typeof window !== "undefined" ? `${window.location.origin}/pay/paid` : "",
|
||||
total_fee: 80,
|
||||
type: "meetup",
|
||||
channel,
|
||||
html: "1",
|
||||
};
|
||||
|
||||
for (const [k, v] of Object.entries(fields)) {
|
||||
const input = document.createElement("input");
|
||||
input.name = k;
|
||||
input.value = String(v);
|
||||
form.appendChild(input);
|
||||
}
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePay}
|
||||
disabled={loading}
|
||||
className={`inline-flex items-center justify-center gap-2 rounded-full bg-[var(--accent)] font-medium text-[var(--accent-foreground)] transition hover:opacity-90 disabled:opacity-70 ${sizeClasses[size]} ${className}`}
|
||||
>
|
||||
{loading ? "跳转中..." : children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
17
app/components/Section.tsx
Normal file
17
app/components/Section.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
type SectionProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/** 通用区块容器 - 统一内边距与最大宽度,响应式 */
|
||||
export function Section({ children, className = "" }: SectionProps) {
|
||||
return (
|
||||
<section
|
||||
className={`border-t border-[var(--border)] py-12 md:py-16 lg:py-20 ${className}`}
|
||||
>
|
||||
<div className="mx-auto max-w-6xl px-4 sm:px-6">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
379
app/components/ShareButton.tsx
Normal file
379
app/components/ShareButton.tsx
Normal file
@@ -0,0 +1,379 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import {
|
||||
HERO_CONFIG,
|
||||
BENEFITS_CONFIG,
|
||||
CURRICULUM_CONFIG,
|
||||
CTA_CONFIG,
|
||||
INSTRUCTORS_CONFIG,
|
||||
} from "@/config/course";
|
||||
|
||||
type Props = { title?: string; url?: string; className?: string };
|
||||
|
||||
const POSTER_WIDTH = 750;
|
||||
const POSTER_HEIGHT = 1334;
|
||||
|
||||
/** 使用公共 API 生成二维码图片,无需 npm 包 */
|
||||
async function getQRImage(url: string, size: number): Promise<HTMLImageElement | null> {
|
||||
const encoded = encodeURIComponent(url);
|
||||
const apiUrl = `https://api.qrserver.com/v1/create-qr-code/?size=${size}x${size}&data=${encoded}&format=png&bgcolor=0f172a&color=22d3ee`;
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => resolve(null);
|
||||
img.src = apiUrl;
|
||||
});
|
||||
}
|
||||
|
||||
function wrapText(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
x: number,
|
||||
y: number,
|
||||
maxWidth: number,
|
||||
lineHeight: number
|
||||
): number {
|
||||
const words = text.split("");
|
||||
let line = "";
|
||||
let lineCount = 0;
|
||||
for (const char of words) {
|
||||
const test = line + char;
|
||||
const m = ctx.measureText(test);
|
||||
if (m.width > maxWidth && line) {
|
||||
ctx.fillText(line, x, y + lineCount * lineHeight);
|
||||
lineCount++;
|
||||
line = char;
|
||||
} else {
|
||||
line = test;
|
||||
}
|
||||
}
|
||||
if (line) {
|
||||
ctx.fillText(line, x, y + lineCount * lineHeight);
|
||||
lineCount++;
|
||||
}
|
||||
return lineCount * lineHeight;
|
||||
}
|
||||
|
||||
export function ShareButton({ title = HERO_CONFIG.title, url, className = "" }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const getShareUrl = () => url || (typeof window !== "undefined" ? window.location.origin : "");
|
||||
|
||||
const drawPoster = async () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || typeof window === "undefined") return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const shareUrl = getShareUrl();
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
canvas.width = POSTER_WIDTH * dpr;
|
||||
canvas.height = POSTER_HEIGHT * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const w = POSTER_WIDTH;
|
||||
const h = POSTER_HEIGHT;
|
||||
const cyan = "#22d3ee";
|
||||
const cyanDim = "rgba(34,211,238,0.6)";
|
||||
const white = "#f8fafc";
|
||||
const muted = "rgba(248,250,252,0.7)";
|
||||
const dark = "#0f172a";
|
||||
const cardBg = "rgba(15,23,42,0.8)";
|
||||
|
||||
// ========== 背景:深色科技感 ==========
|
||||
const bgGrad = ctx.createLinearGradient(0, 0, w, h);
|
||||
bgGrad.addColorStop(0, "#020617");
|
||||
bgGrad.addColorStop(0.4, "#0f172a");
|
||||
bgGrad.addColorStop(0.8, "#0c4a6e");
|
||||
bgGrad.addColorStop(1, "#0e7490");
|
||||
ctx.fillStyle = bgGrad;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// 网格线
|
||||
ctx.strokeStyle = "rgba(34,211,238,0.06)";
|
||||
ctx.lineWidth = 1;
|
||||
const gridStep = 40;
|
||||
for (let i = 0; i <= w; i += gridStep) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(i, 0);
|
||||
ctx.lineTo(i, h);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let i = 0; i <= h; i += gridStep) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, i);
|
||||
ctx.lineTo(w, i);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 顶部光晕
|
||||
const glow = ctx.createRadialGradient(w / 2, 0, 0, w / 2, 0, 400);
|
||||
glow.addColorStop(0, "rgba(34,211,238,0.15)");
|
||||
glow.addColorStop(0.5, "rgba(34,211,238,0.04)");
|
||||
glow.addColorStop(1, "transparent");
|
||||
ctx.fillStyle = glow;
|
||||
ctx.fillRect(0, 0, w, 350);
|
||||
|
||||
// 底部光晕
|
||||
const glowBot = ctx.createRadialGradient(w / 2, h, 0, w / 2, h, 350);
|
||||
glowBot.addColorStop(0, "rgba(34,211,238,0.12)");
|
||||
glowBot.addColorStop(1, "transparent");
|
||||
ctx.fillStyle = glowBot;
|
||||
ctx.fillRect(0, h - 400, w, 400);
|
||||
|
||||
// ========== 顶部:标题区 ==========
|
||||
ctx.fillStyle = cyan;
|
||||
ctx.font = "bold 38px sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText("🌍 " + title, w / 2, 88);
|
||||
ctx.font = "20px sans-serif";
|
||||
ctx.fillStyle = muted;
|
||||
ctx.textAlign = "left";
|
||||
wrapText(ctx, HERO_CONFIG.subtitle, 48, 128, 654, 26);
|
||||
ctx.textAlign = "center";
|
||||
|
||||
// 分隔线
|
||||
ctx.strokeStyle = "rgba(34,211,238,0.3)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(60, 168);
|
||||
ctx.lineTo(w - 60, 168);
|
||||
ctx.stroke();
|
||||
|
||||
// ========== 数据卡片 ==========
|
||||
const cardY = 192;
|
||||
const cardW = 210;
|
||||
const cardH = 68;
|
||||
const cardGap = (w - cardW * 3) / 4;
|
||||
HERO_CONFIG.stats.forEach((s, i) => {
|
||||
const x = cardGap + i * (cardW + cardGap);
|
||||
ctx.fillStyle = cardBg;
|
||||
ctx.strokeStyle = "rgba(34,211,238,0.25)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, cardY, cardW, cardH, 10);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = cyan;
|
||||
ctx.font = "bold 26px sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.fillText(s.value, x + cardW / 2, cardY + 36);
|
||||
ctx.font = "13px sans-serif";
|
||||
ctx.fillStyle = muted;
|
||||
ctx.fillText(s.label, x + cardW / 2, cardY + 56);
|
||||
});
|
||||
ctx.textAlign = "center";
|
||||
|
||||
// ========== 学完你将获得 ==========
|
||||
let y = 290;
|
||||
ctx.font = "bold 18px sans-serif";
|
||||
ctx.fillStyle = white;
|
||||
ctx.fillText(BENEFITS_CONFIG.title, w / 2, y);
|
||||
y += 28;
|
||||
ctx.font = "13px sans-serif";
|
||||
ctx.fillStyle = muted;
|
||||
ctx.fillText(BENEFITS_CONFIG.subtitle, w / 2, y);
|
||||
y += 32;
|
||||
|
||||
BENEFITS_CONFIG.items.forEach((item) => {
|
||||
ctx.fillStyle = cardBg;
|
||||
ctx.strokeStyle = "rgba(34,211,238,0.15)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(48, y, w - 96, 40, 8);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = cyanDim;
|
||||
ctx.font = "15px sans-serif";
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillText(`${item.num}. ${item.title}`, 64, y + 26);
|
||||
ctx.textAlign = "center";
|
||||
y += 48;
|
||||
});
|
||||
|
||||
// ========== 课程模块 ==========
|
||||
y += 16;
|
||||
ctx.font = "bold 18px sans-serif";
|
||||
ctx.fillStyle = white;
|
||||
ctx.fillText(CURRICULUM_CONFIG.title, w / 2, y);
|
||||
y += 26;
|
||||
ctx.font = "13px sans-serif";
|
||||
ctx.fillStyle = muted;
|
||||
ctx.fillText(CURRICULUM_CONFIG.subtitle, w / 2, y);
|
||||
y += 32;
|
||||
|
||||
const cols = 2;
|
||||
const modW = (w - 96 - 12) / cols;
|
||||
CURRICULUM_CONFIG.modules.forEach((m, i) => {
|
||||
const col = i % cols;
|
||||
const row = Math.floor(i / cols);
|
||||
const mx = 48 + col * (modW + 12);
|
||||
const my = y + row * 44;
|
||||
ctx.fillStyle = cardBg;
|
||||
ctx.strokeStyle = "rgba(34,211,238,0.12)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(mx, my, modW, 38, 6);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = white;
|
||||
ctx.font = "14px sans-serif";
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillText(`${m.emoji} ${m.title}`, mx + 12, my + 24);
|
||||
ctx.textAlign = "center";
|
||||
});
|
||||
y += Math.ceil(CURRICULUM_CONFIG.modules.length / cols) * 44 + 20;
|
||||
|
||||
// ========== 徽章 ==========
|
||||
ctx.font = "15px sans-serif";
|
||||
ctx.fillStyle = cyanDim;
|
||||
ctx.fillText("✓ " + CTA_CONFIG.badges.join(" · "), w / 2, y);
|
||||
y += 44;
|
||||
|
||||
// ========== 导师 ==========
|
||||
ctx.font = "13px sans-serif";
|
||||
ctx.fillStyle = muted;
|
||||
ctx.fillText(INSTRUCTORS_CONFIG.subtitle, w / 2, y);
|
||||
y += 32;
|
||||
|
||||
// ========== 二维码区 ==========
|
||||
const qrSize = 200;
|
||||
const qrX = (w - qrSize) / 2;
|
||||
const qrY = y;
|
||||
const qrImg = await getQRImage(shareUrl, qrSize);
|
||||
if (qrImg) {
|
||||
ctx.fillStyle = cardBg;
|
||||
ctx.strokeStyle = "rgba(34,211,238,0.4)";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(qrX - 10, qrY - 10, qrSize + 20, qrSize + 20, 12);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
ctx.drawImage(qrImg, qrX, qrY, qrSize, qrSize);
|
||||
} else {
|
||||
ctx.fillStyle = cardBg;
|
||||
ctx.fillRect(qrX, qrY, qrSize, qrSize);
|
||||
ctx.fillStyle = cyan;
|
||||
ctx.font = "13px sans-serif";
|
||||
ctx.fillText(shareUrl, w / 2, qrY + qrSize / 2 + 5);
|
||||
}
|
||||
|
||||
y += qrSize + 24;
|
||||
ctx.font = "bold 18px sans-serif";
|
||||
ctx.fillStyle = cyan;
|
||||
ctx.fillText("扫码访问课程", w / 2, y);
|
||||
y += 24;
|
||||
ctx.font = "14px sans-serif";
|
||||
ctx.fillStyle = muted;
|
||||
ctx.fillText("长按识别二维码", w / 2, y);
|
||||
y += 28;
|
||||
ctx.font = "12px sans-serif";
|
||||
ctx.fillStyle = "rgba(248,250,252,0.5)";
|
||||
ctx.fillText("自由工作 · 自在生活", w / 2, y);
|
||||
|
||||
setReady(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open && canvasRef.current) {
|
||||
setReady(false);
|
||||
drawPoster();
|
||||
}
|
||||
}, [open, url]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
if (open) {
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const handleDownload = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !ready) return;
|
||||
const link = document.createElement("a");
|
||||
link.download = `分享-${title}.png`;
|
||||
link.href = canvas.toDataURL("image/png");
|
||||
link.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className={`inline-flex items-center gap-2 rounded-lg border border-[var(--border)] px-4 py-2 text-sm transition hover:bg-[var(--muted)] ${className}`}
|
||||
>
|
||||
分享
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="relative max-h-[90vh] overflow-auto rounded-2xl bg-[var(--background)] p-4 shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="absolute right-2 top-2 rounded-full p-2 text-[var(--muted-foreground)] hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<h3 className="mb-4 text-center font-semibold">分享海报</h3>
|
||||
<div className="flex justify-center rounded-xl bg-[var(--muted)]/30 p-2">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={POSTER_WIDTH}
|
||||
height={POSTER_HEIGHT}
|
||||
className="max-w-full rounded-lg"
|
||||
style={{ width: POSTER_WIDTH, height: POSTER_HEIGHT, maxWidth: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-3 text-center text-sm text-[var(--muted-foreground)]">
|
||||
下载海报后,可通过微信、朋友圈等渠道分享
|
||||
</p>
|
||||
<div className="mt-4 flex justify-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-lg border border-[var(--border)] px-6 py-2 text-sm transition hover:bg-[var(--muted)]"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
disabled={!ready}
|
||||
className="rounded-lg bg-[var(--accent)] px-6 py-2 text-sm font-medium text-[var(--accent-foreground)] transition hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{ready ? "下载海报" : "生成中..."}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
36
app/components/ThemeToggle.tsx
Normal file
36
app/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "../providers/ThemeProvider";
|
||||
|
||||
const SunIcon = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const MoonIcon = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
|
||||
const toggle = () => {
|
||||
setTheme(resolvedTheme === "dark" ? "light" : "dark");
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
className="flex items-center justify-center rounded-full p-2 text-[var(--muted-foreground)] transition hover:bg-[var(--muted)] hover:text-[var(--foreground)]"
|
||||
aria-label={resolvedTheme === "dark" ? "切换至日间模式" : "切换至夜间模式"}
|
||||
title={resolvedTheme === "dark" ? "日间" : "夜间"}
|
||||
>
|
||||
{resolvedTheme === "dark" ? <SunIcon /> : <MoonIcon />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
369
app/components/VideoPlayer.tsx
Normal file
369
app/components/VideoPlayer.tsx
Normal file
@@ -0,0 +1,369 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
const STORAGE_PREFIX = "lms-video-";
|
||||
const SAVE_INTERVAL = 5000; // 每 5 秒保存一次
|
||||
const MIN_PROGRESS_TO_SHOW = 10; // 至少观看 10 秒才提示续播
|
||||
|
||||
function getStoredProgress(videoId: string): number {
|
||||
if (typeof window === "undefined") return 0;
|
||||
try {
|
||||
const raw = localStorage.getItem(`${STORAGE_PREFIX}${videoId}`);
|
||||
if (!raw) return 0;
|
||||
const data = JSON.parse(raw);
|
||||
return typeof data?.time === "number" ? data.time : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function saveProgress(videoId: string, time: number): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
localStorage.setItem(`${STORAGE_PREFIX}${videoId}`, JSON.stringify({ time }));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
type VideoPlayerProps = {
|
||||
src: string;
|
||||
poster?: string;
|
||||
title?: string;
|
||||
className?: string;
|
||||
/** 用于存储观看进度的唯一 ID,如 course-0-1 */
|
||||
videoId?: string;
|
||||
/** 播完时回调,用于标记完成 */
|
||||
onComplete?: () => void;
|
||||
};
|
||||
|
||||
export function VideoPlayer({ src, poster, title, className = "", videoId, onComplete }: VideoPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [speed, setSpeed] = useState(1);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [showResumePrompt, setShowResumePrompt] = useState(false);
|
||||
const [savedTime, setSavedTime] = useState(0);
|
||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastSaveRef = useRef(0);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
if (v.paused) {
|
||||
v.play();
|
||||
setPlaying(true);
|
||||
} else {
|
||||
v.pause();
|
||||
setPlaying(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTimeUpdate = useCallback(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
const time = v.currentTime;
|
||||
const dur = v.duration;
|
||||
setCurrentTime(time);
|
||||
setProgress(Number.isFinite(dur) && dur > 0 ? (time / dur) * 100 : 0);
|
||||
|
||||
// 定期保存观看进度
|
||||
if (videoId && v.currentTime > 0) {
|
||||
const now = Date.now();
|
||||
if (now - lastSaveRef.current > SAVE_INTERVAL) {
|
||||
lastSaveRef.current = now;
|
||||
saveProgress(videoId, v.currentTime);
|
||||
}
|
||||
}
|
||||
}, [videoId]);
|
||||
|
||||
const handleLoadedMetadata = useCallback(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
const dur = v.duration;
|
||||
if (!Number.isFinite(dur) || dur <= 0) return;
|
||||
setDuration(dur);
|
||||
|
||||
// 检查是否有上次观看进度
|
||||
if (videoId) {
|
||||
const stored = getStoredProgress(videoId);
|
||||
if (Number.isFinite(stored) && stored >= MIN_PROGRESS_TO_SHOW && stored < dur - 5) {
|
||||
setSavedTime(stored);
|
||||
setShowResumePrompt(true);
|
||||
}
|
||||
}
|
||||
}, [videoId]);
|
||||
|
||||
const handleEnded = useCallback(() => {
|
||||
setPlaying(false);
|
||||
setProgress(0);
|
||||
setCurrentTime(0);
|
||||
if (videoId) saveProgress(videoId, 0); // 播完清除进度
|
||||
onComplete?.();
|
||||
}, [videoId, onComplete]);
|
||||
|
||||
const handlePause = useCallback(() => {
|
||||
const v = videoRef.current;
|
||||
if (videoId && v && v.currentTime > 0) {
|
||||
saveProgress(videoId, v.currentTime);
|
||||
}
|
||||
}, [videoId]);
|
||||
|
||||
const handleResume = useCallback(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v || savedTime <= 0) return;
|
||||
const dur = v.duration;
|
||||
if (!Number.isFinite(dur) || dur <= 0) return;
|
||||
const time = Math.min(savedTime, dur - 0.1);
|
||||
v.currentTime = time;
|
||||
setCurrentTime(time);
|
||||
setProgress((time / dur) * 100);
|
||||
v.play();
|
||||
setPlaying(true);
|
||||
setShowResumePrompt(false);
|
||||
}, [savedTime]);
|
||||
|
||||
const handleRestart = useCallback(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
if (videoId) saveProgress(videoId, 0);
|
||||
v.currentTime = 0;
|
||||
setCurrentTime(0);
|
||||
setProgress(0);
|
||||
setShowResumePrompt(false);
|
||||
}, [videoId]);
|
||||
|
||||
const handleSeek = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
const dur = v.duration;
|
||||
if (!Number.isFinite(dur) || dur <= 0) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
if (rect.width <= 0) return;
|
||||
const p = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
const time = p * dur;
|
||||
v.currentTime = time;
|
||||
setCurrentTime(time);
|
||||
setProgress(p * 100);
|
||||
}, []);
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
v.muted = !v.muted;
|
||||
setMuted(v.muted);
|
||||
}, []);
|
||||
|
||||
const handleVolumeChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
const val = parseFloat(e.target.value);
|
||||
v.volume = val;
|
||||
setVolume(val);
|
||||
setMuted(val === 0);
|
||||
}, []);
|
||||
|
||||
const cycleSpeed = useCallback(() => {
|
||||
const speeds = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
|
||||
const i = speeds.indexOf(speed);
|
||||
const next = speeds[(i + 1) % speeds.length];
|
||||
const v = videoRef.current;
|
||||
if (v) v.playbackRate = next;
|
||||
setSpeed(next);
|
||||
}, [speed]);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
const container = videoRef.current?.parentElement;
|
||||
if (!container) return;
|
||||
if (!document.fullscreenElement) {
|
||||
container.requestFullscreen();
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const showControlsTemporarily = useCallback(() => {
|
||||
setShowControls(true);
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
||||
hideTimerRef.current = setTimeout(() => setShowControls(false), 3000);
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => { if (hideTimerRef.current) clearTimeout(hideTimerRef.current); }, []);
|
||||
|
||||
const formatTime = (s: number) => {
|
||||
if (!Number.isFinite(s) || s < 0) return "0:00";
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${m}:${sec.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const formatResumeTime = (s: number) => {
|
||||
if (!Number.isFinite(s) || s < 0) return "0秒";
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
if (m > 0) return `${m}分${sec}秒`;
|
||||
return `${sec}秒`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative overflow-hidden rounded-xl bg-black md:rounded-2xl ${className}`}
|
||||
onMouseMove={showControlsTemporarily}
|
||||
onMouseLeave={() => setShowControls(false)}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={src}
|
||||
poster={poster}
|
||||
title={title}
|
||||
className="aspect-video w-full object-contain"
|
||||
onClick={togglePlay}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onEnded={handleEnded}
|
||||
onPause={handlePause}
|
||||
playsInline
|
||||
/>
|
||||
|
||||
{/* 续播提示 - 位于视频左下角上方 */}
|
||||
{showResumePrompt && (
|
||||
<div className="absolute bottom-20 left-3 z-10 max-w-[280px] rounded-lg bg-black/85 px-4 py-3 shadow-xl backdrop-blur sm:left-4 sm:bottom-24">
|
||||
<p className="mb-3 text-sm text-white/90">
|
||||
上次看到 <span className="font-semibold text-white">{formatResumeTime(savedTime)}</span>
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResume}
|
||||
className="flex-1 rounded-md bg-[var(--accent)] px-3 py-2 text-sm font-medium text-black transition hover:opacity-90"
|
||||
>
|
||||
从上次继续
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRestart}
|
||||
className="rounded-md border border-white/30 bg-white/10 px-3 py-2 text-sm text-white transition hover:bg-white/20"
|
||||
>
|
||||
重新观看
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 中央播放按钮 */}
|
||||
{!playing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePlay}
|
||||
className="absolute inset-0 flex items-center justify-center bg-black/20 transition hover:bg-black/30"
|
||||
aria-label="播放"
|
||||
>
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-white/90 shadow-lg transition hover:scale-110 sm:h-20 sm:w-20">
|
||||
<svg
|
||||
className="ml-1 h-8 w-8 text-black sm:h-10 sm:w-10"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 底部控制栏 */}
|
||||
<div
|
||||
className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 to-transparent p-3 transition-opacity duration-300 ${
|
||||
showControls ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
{/* 进度条 */}
|
||||
<div
|
||||
className="mb-3 h-1 cursor-pointer rounded-full bg-white/30"
|
||||
onClick={handleSeek}
|
||||
role="slider"
|
||||
aria-valuenow={progress}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-[var(--accent)] transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 sm:gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePlay}
|
||||
className="rounded p-1 text-white transition hover:bg-white/20"
|
||||
aria-label={playing ? "暂停" : "播放"}
|
||||
>
|
||||
{playing ? (
|
||||
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<span className="min-w-[4rem] text-xs text-white/90 sm:text-sm">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMute}
|
||||
className="rounded p-1 text-white transition hover:bg-white/20"
|
||||
aria-label={muted ? "取消静音" : "静音"}
|
||||
>
|
||||
{muted || volume === 0 ? (
|
||||
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={muted ? 0 : volume}
|
||||
onChange={handleVolumeChange}
|
||||
className="h-1 w-16 cursor-pointer accent-[var(--accent)] sm:w-20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={cycleSpeed}
|
||||
className="rounded px-2 py-1 text-xs font-medium text-white/90 transition hover:bg-white/20 sm:text-sm"
|
||||
>
|
||||
{speed}x
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleFullscreen}
|
||||
className="ml-auto rounded p-1 text-white transition hover:bg-white/20"
|
||||
aria-label="全屏"
|
||||
>
|
||||
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
app/components/custom/CustomAnimateOnScroll.tsx
Normal file
43
app/components/custom/CustomAnimateOnScroll.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 自实现滚动入场动画 - 封装待用
|
||||
* 基于 IntersectionObserver,无第三方依赖
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
};
|
||||
|
||||
export function CustomAnimateOnScroll({ children, className = "", delay = 0 }: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) setVisible(true);
|
||||
},
|
||||
{ threshold: 0.08, rootMargin: "0px 0px -40px 0px" }
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`animate-on-scroll ${visible ? "animate-on-scroll-visible" : ""} ${className}`}
|
||||
style={{ transitionDelay: `${delay}ms` }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
117
app/components/custom/CustomParticleBackground.tsx
Normal file
117
app/components/custom/CustomParticleBackground.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 自实现 Canvas 粒子背景 - 封装待用
|
||||
* 无第三方依赖,轻量级替代方案
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const PARTICLE_COUNT = 50;
|
||||
const CONNECT_DISTANCE = 120;
|
||||
|
||||
type Particle = {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
r: number;
|
||||
opacity: number;
|
||||
};
|
||||
|
||||
export function CustomParticleBackground() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
let animationId: number;
|
||||
let particles: Particle[] = [];
|
||||
|
||||
const resize = () => {
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const w = canvas.offsetWidth;
|
||||
const h = canvas.offsetHeight;
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
canvas.style.width = `${w}px`;
|
||||
canvas.style.height = `${h}px`;
|
||||
|
||||
particles = Array.from({ length: PARTICLE_COUNT }, () => ({
|
||||
x: Math.random() * w,
|
||||
y: Math.random() * h,
|
||||
vx: (Math.random() - 0.5) * 0.3,
|
||||
vy: (Math.random() - 0.5) * 0.3,
|
||||
r: Math.random() * 2 + 0.5,
|
||||
opacity: Math.random() * 0.4 + 0.1,
|
||||
}));
|
||||
};
|
||||
|
||||
const getAccentRgb = () => {
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const accent = style.getPropertyValue("--accent").trim() || "#f59e0b";
|
||||
const m = accent.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
|
||||
if (m) return `${parseInt(m[1], 16)}, ${parseInt(m[2], 16)}, ${parseInt(m[3], 16)}`;
|
||||
return "245, 158, 11";
|
||||
};
|
||||
|
||||
const draw = () => {
|
||||
const w = canvas.offsetWidth;
|
||||
const h = canvas.offsetHeight;
|
||||
const rgb = getAccentRgb();
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
particles.forEach((p, i) => {
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
if (p.x < 0 || p.x > w) p.vx *= -1;
|
||||
if (p.y < 0 || p.y > h) p.vy *= -1;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = `rgba(${rgb}, ${p.opacity})`;
|
||||
ctx.fill();
|
||||
|
||||
for (let j = i + 1; j < particles.length; j++) {
|
||||
const q = particles[j];
|
||||
const dx = p.x - q.x;
|
||||
const dy = p.y - q.y;
|
||||
const dist = Math.hypot(dx, dy);
|
||||
if (dist < CONNECT_DISTANCE) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p.x, p.y);
|
||||
ctx.lineTo(q.x, q.y);
|
||||
ctx.strokeStyle = `rgba(${rgb}, ${0.08 * (1 - dist / CONNECT_DISTANCE)})`;
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
animationId = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
resize();
|
||||
window.addEventListener("resize", resize);
|
||||
draw();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", resize);
|
||||
cancelAnimationFrame(animationId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="pointer-events-none absolute inset-0"
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
2
app/components/custom/index.ts
Normal file
2
app/components/custom/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { CustomParticleBackground } from "./CustomParticleBackground";
|
||||
export { CustomAnimateOnScroll } from "./CustomAnimateOnScroll";
|
||||
15
app/components/index.ts
Normal file
15
app/components/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export { Header } from "./Header";
|
||||
export { Hero } from "./Hero";
|
||||
export { Footer } from "./Footer";
|
||||
export { Section } from "./Section";
|
||||
export { AnimateOnScroll } from "./AnimateOnScroll";
|
||||
export { CustomParticleBackground, CustomAnimateOnScroll } from "./custom";
|
||||
export { Button } from "./Button";
|
||||
export { ThemeToggle } from "./ThemeToggle";
|
||||
export { AudienceSection } from "./sections/AudienceSection";
|
||||
export { BenefitsSection } from "./sections/BenefitsSection";
|
||||
export { CurriculumSection } from "./sections/CurriculumSection";
|
||||
export { InstructorsSection } from "./sections/InstructorsSection";
|
||||
export { FAQSection } from "./sections/FAQSection";
|
||||
export { RatingSection } from "./sections/RatingSection";
|
||||
export { CTASection } from "./sections/CTASection";
|
||||
27
app/components/sections/AudienceSection.tsx
Normal file
27
app/components/sections/AudienceSection.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Section } from "../Section";
|
||||
import { AUDIENCE_CONFIG } from "@/config/course";
|
||||
|
||||
export function AudienceSection() {
|
||||
return (
|
||||
<Section>
|
||||
<h2 className="mb-4 text-center text-xl font-bold sm:text-2xl md:text-3xl">
|
||||
{AUDIENCE_CONFIG.title}
|
||||
</h2>
|
||||
<p className="mb-10 text-center text-[var(--muted-foreground)] sm:mb-12 md:mb-16">
|
||||
{AUDIENCE_CONFIG.subtitle}
|
||||
</p>
|
||||
<div className="grid gap-4 sm:grid-cols-2 sm:gap-6 lg:grid-cols-4">
|
||||
{AUDIENCE_CONFIG.items.map((a) => (
|
||||
<div
|
||||
key={a.title}
|
||||
className="hover-shimmer rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 transition-all duration-300 hover:border-[var(--accent)]/30 hover:bg-[var(--muted)] hover:-translate-y-0.5 sm:rounded-2xl sm:p-6"
|
||||
>
|
||||
<div className="mb-3 text-2xl sm:mb-4 sm:text-3xl">{a.emoji}</div>
|
||||
<h3 className="mb-2 font-semibold">{a.title}</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">{a.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
31
app/components/sections/BenefitsSection.tsx
Normal file
31
app/components/sections/BenefitsSection.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Section } from "../Section";
|
||||
import { BENEFITS_CONFIG } from "@/config/course";
|
||||
|
||||
export function BenefitsSection() {
|
||||
return (
|
||||
<Section>
|
||||
<h2 className="mb-4 text-center text-xl font-bold sm:text-2xl md:text-3xl">
|
||||
{BENEFITS_CONFIG.title}
|
||||
</h2>
|
||||
<p className="mb-10 text-center text-[var(--muted-foreground)] sm:mb-12 md:mb-16">
|
||||
{BENEFITS_CONFIG.subtitle}
|
||||
</p>
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{BENEFITS_CONFIG.items.map((b) => (
|
||||
<div
|
||||
key={b.num}
|
||||
className="hover-shimmer flex flex-col gap-4 rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 transition-all duration-300 hover:border-[var(--accent)]/30 hover:-translate-y-0.5 sm:flex-row sm:gap-6 sm:rounded-2xl sm:p-6"
|
||||
>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[var(--accent-muted)] text-base font-bold text-[var(--accent)] sm:h-12 sm:w-12 sm:text-lg">
|
||||
{b.num}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="mb-1 font-semibold">{b.title}</h3>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">{b.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
46
app/components/sections/CTASection.tsx
Normal file
46
app/components/sections/CTASection.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { CTA_CONFIG } from "@/config/course";
|
||||
import { isPaid } from "@/app/lib/payment";
|
||||
|
||||
export function CTASection() {
|
||||
const [paid, setPaid] = useState(false);
|
||||
useEffect(() => {
|
||||
setPaid(isPaid());
|
||||
const onPayUpdate = () => setPaid(isPaid());
|
||||
window.addEventListener("pay:updated", onPayUpdate);
|
||||
return () => window.removeEventListener("pay:updated", onPayUpdate);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="border-t border-[var(--border)] py-16 sm:py-20 md:py-24">
|
||||
<div className="mx-auto max-w-3xl px-4 text-center sm:px-6">
|
||||
<h2 className="mb-4 text-xl font-bold sm:text-2xl md:text-3xl">
|
||||
{CTA_CONFIG.title}
|
||||
</h2>
|
||||
<p className="mb-6 text-[var(--muted-foreground)] sm:mb-8">
|
||||
{CTA_CONFIG.subtitle}
|
||||
</p>
|
||||
<div className="mb-8 flex flex-wrap justify-center gap-4 text-sm text-[var(--muted-foreground)] sm:mb-10 sm:gap-6">
|
||||
{CTA_CONFIG.badges.map((b, i) => (
|
||||
<span
|
||||
key={b}
|
||||
className="animate-float-badge rounded-full border border-[var(--border)] bg-[var(--card)] px-4 py-2"
|
||||
style={{ animationDelay: `${i * 0.2}s` }}
|
||||
>
|
||||
{b}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<Link
|
||||
href={paid ? "/course/0/0" : "/pay"}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] shadow-lg shadow-[var(--accent)]/25 transition-all duration-300 hover:scale-105 hover:opacity-95 hover:shadow-xl hover:shadow-[var(--accent)]/30 sm:px-10"
|
||||
>
|
||||
{paid ? "开始学习 →" : "立即加入 →"}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
159
app/components/sections/CurriculumSection.tsx
Normal file
159
app/components/sections/CurriculumSection.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { Section } from "../Section";
|
||||
import { CURRICULUM_CONFIG } from "@/config/course";
|
||||
import { canWatchLesson } from "@/app/lib/payment";
|
||||
import { isBookmarked, toggleBookmark, getBookmarks, getCompletedLessons } from "@/app/lib/learning";
|
||||
|
||||
export function CurriculumSection() {
|
||||
const [expanded, setExpanded] = useState<Record<number, boolean>>(() =>
|
||||
Object.fromEntries(CURRICULUM_CONFIG.modules.map((_, i) => [i, i === 0]))
|
||||
);
|
||||
const [search, setSearch] = useState("");
|
||||
const [bookmarks, setBookmarks] = useState<Set<string>>(new Set());
|
||||
const [completed, setCompleted] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
setBookmarks(getBookmarks());
|
||||
setCompleted(getCompletedLessons());
|
||||
const onB = () => setBookmarks(getBookmarks());
|
||||
const onP = () => setCompleted(getCompletedLessons());
|
||||
window.addEventListener("learning:bookmarks", onB);
|
||||
window.addEventListener("learning:progress", onP);
|
||||
return () => {
|
||||
window.removeEventListener("learning:bookmarks", onB);
|
||||
window.removeEventListener("learning:progress", onP);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggle = (i: number) => {
|
||||
setExpanded((prev) => ({ ...prev, [i]: !prev[i] }));
|
||||
};
|
||||
|
||||
const allLessons = CURRICULUM_CONFIG.modules.flatMap((m, mi) =>
|
||||
m.lessons.map((l, li) => ({ moduleIndex: mi, lessonIndex: li, ...l, moduleTitle: m.title }))
|
||||
);
|
||||
const filtered = search.trim()
|
||||
? allLessons.filter(
|
||||
(l) =>
|
||||
l.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
l.moduleTitle.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
: allLessons;
|
||||
|
||||
const filteredByModule = CURRICULUM_CONFIG.modules
|
||||
.map((m, mi) => ({
|
||||
...m,
|
||||
lessons: filtered.filter((l) => l.moduleIndex === mi),
|
||||
}))
|
||||
.filter((m) => m.lessons.length > 0);
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<h2 className="mb-4 text-center text-xl font-bold sm:text-2xl md:text-3xl">
|
||||
{CURRICULUM_CONFIG.title}
|
||||
</h2>
|
||||
<p className="mb-6 text-center text-[var(--muted-foreground)] sm:mb-8 md:mb-10">
|
||||
{CURRICULUM_CONFIG.subtitle}
|
||||
</p>
|
||||
<div className="mx-auto mb-8 max-w-md">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="搜索课程..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full rounded-full border border-[var(--border)] bg-[var(--background)] px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 md:space-y-3">
|
||||
{filteredByModule.map((c) => {
|
||||
const moduleIndex = CURRICULUM_CONFIG.modules.findIndex((m) => m.title === c.title);
|
||||
const isOpen = expanded[moduleIndex] ?? true;
|
||||
return (
|
||||
<div
|
||||
key={c.title}
|
||||
className="overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--card)] md:rounded-2xl"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(moduleIndex)}
|
||||
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition hover:bg-[var(--muted)] sm:px-5 sm:py-4"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-base font-semibold sm:text-lg">
|
||||
<span
|
||||
className={`transition-transform duration-200 ${isOpen ? "rotate-90" : ""}`}
|
||||
>
|
||||
▶
|
||||
</span>
|
||||
<span>{c.emoji}</span>
|
||||
{c.title}
|
||||
</span>
|
||||
<span className="text-sm text-[var(--muted-foreground)]">
|
||||
{c.lessons.length} 节
|
||||
</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<ul className="border-t border-[var(--border)]">
|
||||
{c.lessons.map((l) => {
|
||||
const free = l.moduleIndex === 0 && l.lessonIndex < 2;
|
||||
const unlocked = canWatchLesson(l.moduleIndex, l.lessonIndex);
|
||||
const key = `${l.moduleIndex}-${l.lessonIndex}`;
|
||||
const bookmarked = isBookmarked(l.moduleIndex, l.lessonIndex);
|
||||
const isCompleted = completed.has(key);
|
||||
return (
|
||||
<li key={key}>
|
||||
<Link
|
||||
href={`/course/${l.moduleIndex}/${l.lessonIndex}`}
|
||||
className="flex flex-col gap-1 px-4 py-3 transition hover:bg-[var(--muted)] sm:flex-row sm:items-center sm:justify-between sm:px-5 sm:py-3"
|
||||
>
|
||||
<span className="flex items-center gap-2 truncate sm:gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleBookmark(l.moduleIndex, l.lessonIndex);
|
||||
}}
|
||||
className="shrink-0 text-xs"
|
||||
aria-label={bookmarked ? "取消收藏" : "收藏"}
|
||||
>
|
||||
{bookmarked ? "⭐" : "☆"}
|
||||
</button>
|
||||
<span className={unlocked ? "text-[var(--foreground)]" : "text-[var(--muted-foreground)]"}>
|
||||
{l.name}
|
||||
</span>
|
||||
{free && (
|
||||
<span className="shrink-0 rounded bg-[var(--accent)]/20 px-1.5 py-0.5 text-xs text-[var(--accent)]">
|
||||
试看
|
||||
</span>
|
||||
)}
|
||||
{isCompleted && (
|
||||
<span className="shrink-0 rounded bg-green-500/20 px-1.5 py-0.5 text-xs text-green-600 dark:text-green-400">
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
{!unlocked && (
|
||||
<span className="shrink-0 text-xs text-[var(--muted-foreground)]">
|
||||
🔒
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-2 text-xs text-[var(--muted-foreground)] sm:text-sm">
|
||||
{l.duration}
|
||||
<span className="text-[var(--accent)]">→</span>
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
43
app/components/sections/FAQSection.tsx
Normal file
43
app/components/sections/FAQSection.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Section } from "../Section";
|
||||
import { FAQ_CONFIG } from "@/config/course";
|
||||
|
||||
export function FAQSection() {
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<h2 className="mb-10 text-center text-xl font-bold sm:text-2xl md:text-3xl">
|
||||
{FAQ_CONFIG.title}
|
||||
</h2>
|
||||
<div className="mx-auto max-w-2xl space-y-2">
|
||||
{FAQ_CONFIG.items.map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--card)] transition"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenIndex(openIndex === i ? null : i)}
|
||||
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition hover:bg-[var(--muted)] sm:px-5"
|
||||
>
|
||||
<span className="font-medium">{item.q}</span>
|
||||
<span
|
||||
className={`shrink-0 text-[var(--muted-foreground)] transition-transform ${openIndex === i ? "rotate-180" : ""}`}
|
||||
>
|
||||
▼
|
||||
</span>
|
||||
</button>
|
||||
{openIndex === i && (
|
||||
<div className="border-t border-[var(--border)] px-4 py-3 text-sm text-[var(--muted-foreground)] sm:px-5">
|
||||
{item.a}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
37
app/components/sections/InstructorsSection.tsx
Normal file
37
app/components/sections/InstructorsSection.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Section } from "../Section";
|
||||
import { INSTRUCTORS_CONFIG } from "@/config/course";
|
||||
|
||||
export function InstructorsSection() {
|
||||
return (
|
||||
<Section>
|
||||
<h2 className="mb-4 text-center text-xl font-bold sm:text-2xl md:text-3xl">
|
||||
{INSTRUCTORS_CONFIG.title}
|
||||
</h2>
|
||||
<p className="mb-10 text-center text-[var(--muted-foreground)] sm:mb-12 md:mb-16">
|
||||
{INSTRUCTORS_CONFIG.subtitle}
|
||||
</p>
|
||||
<div className="grid gap-6 sm:grid-cols-2 md:gap-8 lg:grid-cols-3">
|
||||
{INSTRUCTORS_CONFIG.items.map((i) => (
|
||||
<div
|
||||
key={i.name}
|
||||
className="hover-shimmer rounded-xl border border-[var(--border)] bg-[var(--card)] p-5 text-center transition-all duration-300 hover:-translate-y-0.5 hover:border-[var(--accent)]/30 sm:rounded-2xl sm:p-6"
|
||||
>
|
||||
<div className="mb-3 flex justify-center sm:mb-4">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-[var(--accent-muted)] text-xl font-bold text-[var(--accent)] sm:h-20 sm:w-20 sm:text-2xl">
|
||||
{i.name.slice(0, 1)}
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="mb-1 font-semibold">{i.name}</h3>
|
||||
<p className="mb-1 text-sm text-[var(--accent)]">{i.role}</p>
|
||||
<p className="mb-2 text-sm text-[var(--muted-foreground)]">
|
||||
{i.desc}
|
||||
</p>
|
||||
<span className="inline-block rounded-full bg-[var(--accent-muted)] px-3 py-1 text-xs text-[var(--accent)]">
|
||||
{i.tag}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
67
app/components/sections/RatingSection.tsx
Normal file
67
app/components/sections/RatingSection.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { getRating, setRating } from "@/app/lib/learning";
|
||||
|
||||
export function RatingSection() {
|
||||
const [stars, setStars] = useState(0);
|
||||
const [comment, setComment] = useState("");
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const r = getRating();
|
||||
if (r) {
|
||||
setStars(r.stars);
|
||||
setComment(r.comment);
|
||||
setSubmitted(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSubmit = () => {
|
||||
setRating(stars, comment);
|
||||
setSubmitted(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="border-t border-[var(--border)] py-12 sm:py-16">
|
||||
<div className="mx-auto max-w-2xl px-4 text-center sm:px-6">
|
||||
<h2 className="mb-2 text-xl font-bold sm:text-2xl">课程评价</h2>
|
||||
<p className="mb-6 text-sm text-[var(--muted-foreground)]">
|
||||
你的反馈对我们很重要
|
||||
</p>
|
||||
{submitted ? (
|
||||
<p className="text-[var(--muted-foreground)]">感谢你的评价 ✓</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4 flex justify-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
onClick={() => setStars(n)}
|
||||
className="text-2xl transition hover:scale-110"
|
||||
>
|
||||
{n <= stars ? "⭐" : "☆"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="选填:一句话评价"
|
||||
className="mb-4 w-full rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
|
||||
rows={2}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
className="rounded-full bg-[var(--accent)] px-6 py-2 text-sm font-medium text-[var(--accent-foreground)] hover:opacity-90"
|
||||
>
|
||||
提交评价
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
195
app/course/[moduleIndex]/[lessonIndex]/LessonMarkdown.tsx
Normal file
195
app/course/[moduleIndex]/[lessonIndex]/LessonMarkdown.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeHighlight from "rehype-highlight";
|
||||
import "highlight.js/styles/atom-one-dark.min.css";
|
||||
import styles from "./lesson-markdown.module.css";
|
||||
|
||||
type TocItem = { level: number; text: string; id: string };
|
||||
|
||||
function extractToc(content: string): TocItem[] {
|
||||
const items: TocItem[] = [];
|
||||
const lines = content.split("\n");
|
||||
let idCounter = 0;
|
||||
for (const line of lines) {
|
||||
const h1 = line.match(/^# (.+)$/);
|
||||
const h2 = line.match(/^## (.+)$/);
|
||||
if (h1) {
|
||||
items.push({ level: 1, text: h1[1].replace(/#{1,6}\s/g, "").trim(), id: `h-${idCounter++}` });
|
||||
} else if (h2) {
|
||||
items.push({ level: 2, text: h2[1].replace(/#{1,6}\s/g, "").trim(), id: `h-${idCounter++}` });
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
.replace(/[^\p{L}\p{N}\s-]/gu, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function LessonMarkdown({ content }: { content: string }) {
|
||||
const [readProgress, setReadProgress] = useState(0);
|
||||
const [showBackTop, setShowBackTop] = useState(false);
|
||||
const [showToc, setShowToc] = useState(false);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const toc = extractToc(content);
|
||||
|
||||
useEffect(() => {
|
||||
let ticking = false;
|
||||
const onScroll = () => {
|
||||
if (ticking) return;
|
||||
ticking = true;
|
||||
requestAnimationFrame(() => {
|
||||
const scrollTop = window.scrollY || document.documentElement.scrollTop;
|
||||
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
|
||||
setReadProgress(docHeight > 0 ? Math.min(100, (scrollTop / docHeight) * 100) : 0);
|
||||
setShowBackTop(scrollTop > 400);
|
||||
ticking = false;
|
||||
});
|
||||
};
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
const handleTocJump = useCallback((index: number) => {
|
||||
setShowToc(false);
|
||||
const root = contentRef.current;
|
||||
if (!root) return;
|
||||
const headings = root.querySelectorAll("h1, h2");
|
||||
const el = headings[index] as HTMLElement;
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.readingProgress} style={{ width: `${readProgress}%` }} />
|
||||
|
||||
<div ref={contentRef} className={`${styles.markdownBody} markdown-body`}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[[rehypeHighlight, { detect: true }]]}
|
||||
components={{
|
||||
h1: ({ children }) => {
|
||||
const text = String(children);
|
||||
const id = slugify(text);
|
||||
return (
|
||||
<h1 id={id} className="scroll-mt-24">
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
},
|
||||
h2: ({ children }) => {
|
||||
const text = String(children);
|
||||
const id = slugify(text);
|
||||
return (
|
||||
<h2 id={id} className="scroll-mt-24">
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
},
|
||||
h3: ({ children }) => <h3 className="scroll-mt-20">{children}</h3>,
|
||||
h4: ({ children }) => <h4>{children}</h4>,
|
||||
h5: ({ children }) => <h5>{children}</h5>,
|
||||
h6: ({ children }) => <h6>{children}</h6>,
|
||||
p: ({ children }) => <p>{children}</p>,
|
||||
ul: ({ children }) => <ul>{children}</ul>,
|
||||
ol: ({ children }) => <ol>{children}</ol>,
|
||||
li: ({ children, className }) => (
|
||||
<li className={className?.includes("task-list-item") ? "flex items-start gap-2" : ""}>
|
||||
{children}
|
||||
</li>
|
||||
),
|
||||
pre: ({ children }) => (
|
||||
<pre className={styles.codeBlock}>{children}</pre>
|
||||
),
|
||||
code: ({ className, children }) => (
|
||||
<code className={className}>{children}</code>
|
||||
),
|
||||
blockquote: ({ children }) => <blockquote>{children}</blockquote>,
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
img: ({ src, alt }) => (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={src || ""} alt={alt || ""} loading="lazy" decoding="async" />
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="overflow-x-auto">
|
||||
<table>{children}</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }) => <th>{children}</th>,
|
||||
td: ({ children }) => <td>{children}</td>,
|
||||
hr: () => <hr />,
|
||||
strong: ({ children }) => <strong>{children}</strong>,
|
||||
em: ({ children }) => <em>{children}</em>,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
{/* 目录抽屉 */}
|
||||
<div
|
||||
className={`${styles.drawerMask} ${showToc ? styles.drawerMaskOpen : ""}`}
|
||||
onClick={() => setShowToc(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<aside className={`${styles.drawer} ${showToc ? styles.drawerOpen : ""}`}>
|
||||
<div className={styles.drawerTitle}>目录</div>
|
||||
{toc.map((item, i) => (
|
||||
<div
|
||||
key={item.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={item.level === 2 ? `${styles.drawerItem} ${styles.drawerItemH2}` : styles.drawerItem}
|
||||
onClick={() => handleTocJump(i)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") handleTocJump(i);
|
||||
}}
|
||||
>
|
||||
{item.text}
|
||||
</div>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
{showBackTop && (
|
||||
<>
|
||||
{toc.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.tocButton}
|
||||
onClick={() => setShowToc(true)}
|
||||
aria-label="打开目录"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.backToTop}
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
||||
aria-label="回到顶部"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M18 15l-6-6-6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
/* 参考 digital ebook 样式,适配日间/夜间主题 */
|
||||
|
||||
.readingProgress {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--accent) 0%, #fbbf24 100%);
|
||||
z-index: 100;
|
||||
transition: width 0.15s linear;
|
||||
border-radius: 0 3px 3px 0;
|
||||
}
|
||||
|
||||
.backToTop {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
cursor: pointer;
|
||||
z-index: 50;
|
||||
color: var(--accent);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.backToTop:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.tocButton {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 84px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
cursor: pointer;
|
||||
z-index: 50;
|
||||
color: var(--accent);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.tocButton:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.drawerMask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 140;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.drawerMaskOpen {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 280px;
|
||||
max-width: 85vw;
|
||||
background: var(--background);
|
||||
border-left: 1px solid var(--border);
|
||||
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15);
|
||||
z-index: 150;
|
||||
padding: 80px 0 24px;
|
||||
overflow-y: auto;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.drawerOpen {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.drawerTitle {
|
||||
padding: 0 20px 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--muted-foreground);
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.drawerItem {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
color: var(--foreground);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.drawerItem:hover {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.drawerItemH2 {
|
||||
padding-left: 28px;
|
||||
font-size: 13px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* Markdown 正文样式 */
|
||||
.markdownBody {
|
||||
font-size: 16px;
|
||||
line-height: 1.85;
|
||||
color: var(--foreground);
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.markdownBody h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
color: var(--foreground);
|
||||
margin: 28px 0 12px;
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.markdownBody h1:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.markdownBody h1::after {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 40px;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--accent), #fbbf24);
|
||||
border-radius: 3px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.markdownBody h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--foreground);
|
||||
margin: 24px 0 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.markdownBody h3 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
margin: 20px 0 8px;
|
||||
}
|
||||
|
||||
.markdownBody h4,
|
||||
.markdownBody h5,
|
||||
.markdownBody h6 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
margin: 16px 0 6px;
|
||||
}
|
||||
|
||||
.markdownBody p {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 14px;
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
.markdownBody blockquote {
|
||||
position: relative;
|
||||
margin: 24px 0;
|
||||
padding: 16px 20px 16px 24px;
|
||||
border-left: none;
|
||||
background: var(--muted);
|
||||
border-radius: 0 12px 12px 0;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.markdownBody blockquote::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background: var(--accent);
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
/* 代码块 - highlight.js 主题已通过 atom-one-dark 提供语法高亮 */
|
||||
.markdownBody pre.codeBlock {
|
||||
margin: 24px 0;
|
||||
padding: 0;
|
||||
overflow-x: auto;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.markdownBody pre.codeBlock code {
|
||||
display: block;
|
||||
padding: 16px;
|
||||
font-size: 0.875em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 行内代码 - 与代码块区分 */
|
||||
.markdownBody p code,
|
||||
.markdownBody li code {
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 0.9em;
|
||||
padding: 0.2em 0.4em;
|
||||
background: var(--muted);
|
||||
color: var(--foreground);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.light .markdownBody p code,
|
||||
.light .markdownBody li code {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
color: #c41e3a;
|
||||
}
|
||||
|
||||
.markdownBody a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.markdownBody a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.markdownBody img {
|
||||
display: block;
|
||||
margin: 28px auto;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.markdownBody img:hover {
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.markdownBody ul,
|
||||
.markdownBody ol {
|
||||
margin: 16px 0;
|
||||
padding-left: 28px;
|
||||
}
|
||||
|
||||
.markdownBody li {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.markdownBody li > ul,
|
||||
.markdownBody li > ol {
|
||||
margin-top: 6px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.markdownBody table {
|
||||
width: 100%;
|
||||
margin: 24px 0;
|
||||
border-collapse: collapse;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.markdownBody th {
|
||||
background: var(--muted);
|
||||
font-weight: 600;
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.markdownBody td {
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.markdownBody tr:nth-child(even) {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.markdownBody hr {
|
||||
border: none;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 28px 0;
|
||||
}
|
||||
|
||||
.markdownBody strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.markdownBody input[type="checkbox"] {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (min-width: 768px) {
|
||||
.markdownBody {
|
||||
font-size: 17px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.markdownBody h1 {
|
||||
font-size: 1.75rem;
|
||||
margin: 32px 0 14px;
|
||||
}
|
||||
|
||||
.markdownBody h1::after {
|
||||
width: 50px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.markdownBody h2 {
|
||||
font-size: 1.35rem;
|
||||
margin: 28px 0 12px;
|
||||
}
|
||||
|
||||
.markdownBody h3 {
|
||||
font-size: 1.2rem;
|
||||
margin: 24px 0 10px;
|
||||
}
|
||||
|
||||
.markdownBody p {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.backToTop,
|
||||
.tocButton {
|
||||
bottom: 32px;
|
||||
right: 32px;
|
||||
}
|
||||
|
||||
.tocButton {
|
||||
right: 92px;
|
||||
}
|
||||
}
|
||||
154
app/course/[moduleIndex]/[lessonIndex]/page.tsx
Normal file
154
app/course/[moduleIndex]/[lessonIndex]/page.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Header } from "@/app/components";
|
||||
import { VideoPlayer } from "@/app/components/VideoPlayer";
|
||||
import { LessonMarkdown } from "./LessonMarkdown";
|
||||
import { getLesson } from "@/config/lessons";
|
||||
import { canWatchLesson } from "@/app/lib/payment";
|
||||
import { setLastWatched, markCompleted } from "@/app/lib/learning";
|
||||
import { DOWNLOAD_CONFIG } from "@/config/course";
|
||||
|
||||
export default function LessonPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const moduleIndex = Number(params.moduleIndex);
|
||||
const lessonIndex = Number(params.lessonIndex);
|
||||
|
||||
const lesson = getLesson(moduleIndex, lessonIndex);
|
||||
const unlocked = canWatchLesson(moduleIndex, lessonIndex);
|
||||
const isFreeTrial = moduleIndex === 0 && lessonIndex < 2;
|
||||
|
||||
useEffect(() => {
|
||||
if (lesson && unlocked) setLastWatched(moduleIndex, lessonIndex, lesson.name);
|
||||
}, [moduleIndex, lessonIndex, lesson?.name, unlocked]);
|
||||
|
||||
if (!lesson || isNaN(moduleIndex) || isNaN(lessonIndex)) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--background)]">
|
||||
<Header />
|
||||
<main className="pt-20 pb-16">
|
||||
<div className="mx-auto max-w-4xl px-4 py-16 text-center">
|
||||
<h1 className="mb-4 text-2xl font-bold">章节不存在</h1>
|
||||
<p className="mb-6 text-[var(--muted-foreground)]">
|
||||
请检查链接或返回课程首页
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 rounded-full bg-[var(--accent)] px-6 py-3 font-medium text-[var(--accent-foreground)] transition hover:opacity-90"
|
||||
>
|
||||
← 返回首页
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)]">
|
||||
<Header />
|
||||
<main className="pt-14 pb-16 md:pt-16">
|
||||
<div className="mx-auto max-w-4xl px-4 sm:px-6">
|
||||
{/* 返回导航 */}
|
||||
<nav className="mb-6 flex items-center gap-2 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="flex items-center gap-1 text-[var(--muted-foreground)] transition hover:text-[var(--foreground)]"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
返回
|
||||
</button>
|
||||
<span className="text-[var(--muted-foreground)]">/</span>
|
||||
<Link href="/" className="text-[var(--muted-foreground)] transition hover:text-[var(--foreground)]">
|
||||
课程首页
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* 标题 */}
|
||||
<h1 className="mb-2 flex items-center gap-2 text-xl font-bold sm:text-2xl md:text-3xl">
|
||||
{lesson.name}
|
||||
{isFreeTrial && (
|
||||
<span className="rounded bg-[var(--accent)]/20 px-2 py-0.5 text-sm font-normal text-[var(--accent)]">
|
||||
试看
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
<p className="mb-8 text-sm text-[var(--muted-foreground)]">
|
||||
时长 {lesson.duration}
|
||||
</p>
|
||||
|
||||
{/* 视频播放器 / 锁定 overlay */}
|
||||
<div className="relative mb-12">
|
||||
{unlocked ? (
|
||||
<VideoPlayer
|
||||
src={lesson.videoUrl}
|
||||
title={lesson.name}
|
||||
videoId={`course-${moduleIndex}-${lessonIndex}`}
|
||||
className="shadow-xl"
|
||||
onComplete={() => markCompleted(moduleIndex, lessonIndex)}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative overflow-hidden rounded-xl bg-black md:rounded-2xl">
|
||||
<div className="aspect-video flex flex-col items-center justify-center gap-4 bg-zinc-900">
|
||||
<span className="text-6xl">🔒</span>
|
||||
<p className="text-lg text-white/90">报名解锁本章节</p>
|
||||
<Link
|
||||
href="/pay"
|
||||
className="rounded-full bg-[var(--accent)] px-6 py-3 font-medium text-[var(--accent-foreground)] transition hover:opacity-90"
|
||||
>
|
||||
立即报名
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 配套文档 - 仅解锁后显示 */}
|
||||
{unlocked && (
|
||||
<div className="relative rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm md:rounded-2xl md:p-8">
|
||||
<h2 className="mb-6 flex items-center gap-2 text-lg font-semibold">
|
||||
<svg className="h-5 w-5 text-[var(--accent)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
配套文档
|
||||
</h2>
|
||||
<LessonMarkdown content={lesson.markdown} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 网盘下载 - 可配置开关 */}
|
||||
{DOWNLOAD_CONFIG.enabled && unlocked && (
|
||||
<div className="mt-8 rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm md:rounded-2xl md:p-8">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold">
|
||||
<svg className="h-5 w-5 text-[var(--accent)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
{DOWNLOAD_CONFIG.title}
|
||||
</h2>
|
||||
<p className="mb-4 text-sm text-[var(--muted-foreground)]">
|
||||
课程配套资料已上传至网盘,报名后可前往下载
|
||||
</p>
|
||||
<Link
|
||||
href={DOWNLOAD_CONFIG.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-full bg-[var(--accent)] px-6 py-3 font-medium text-[var(--accent-foreground)] transition hover:opacity-90"
|
||||
>
|
||||
{DOWNLOAD_CONFIG.buttonText}
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
111
app/globals.css
Normal file
111
app/globals.css
Normal file
@@ -0,0 +1,111 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
@import "animate.css";
|
||||
|
||||
/* 主题变量 - 日间/夜间模式 */
|
||||
:root,
|
||||
.light {
|
||||
--background: #fafafa;
|
||||
--foreground: #171717;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #171717;
|
||||
--muted: #f4f4f5;
|
||||
--muted-foreground: #71717a;
|
||||
--border: #e4e4e7;
|
||||
--accent: #f59e0b;
|
||||
--accent-foreground: #1c1917;
|
||||
--accent-muted: rgba(245, 158, 11, 0.2);
|
||||
--gradient-accent: rgba(251, 191, 36, 0.15);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: #0c0c0f;
|
||||
--foreground: #fafafa;
|
||||
--card: rgba(255, 255, 255, 0.05);
|
||||
--card-foreground: #fafafa;
|
||||
--muted: rgba(255, 255, 255, 0.05);
|
||||
--muted-foreground: #a1a1aa;
|
||||
--border: rgba(255, 255, 255, 0.08);
|
||||
--accent: #f59e0b;
|
||||
--accent-foreground: #1c1917;
|
||||
--accent-muted: rgba(245, 158, 11, 0.2);
|
||||
--gradient-accent: rgba(251, 191, 36, 0.15);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-noto-sans-sc), var(--font-geist-sans), system-ui,
|
||||
sans-serif;
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-sans);
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
/* 滚动入场动画 */
|
||||
.animate-on-scroll {
|
||||
opacity: 0;
|
||||
transform: translateY(24px);
|
||||
transition: opacity 0.6s ease-out, transform 0.6s ease-out;
|
||||
}
|
||||
|
||||
.animate-on-scroll-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* 渐变背景动画 */
|
||||
@keyframes gradient-shift {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.85; }
|
||||
}
|
||||
|
||||
.animate-gradient {
|
||||
animation: gradient-shift 8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* 卡片悬停光效 */
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
.hover-shimmer {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hover-shimmer::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.05),
|
||||
transparent
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.hover-shimmer:hover::after {
|
||||
opacity: 1;
|
||||
animation: shimmer 1.5s ease-in-out;
|
||||
}
|
||||
|
||||
/* CTA 徽章轻微浮动 */
|
||||
@keyframes float-badge {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-2px); }
|
||||
}
|
||||
|
||||
.animate-float-badge {
|
||||
animation: float-badge 3s ease-in-out infinite;
|
||||
}
|
||||
47
app/layout.tsx
Normal file
47
app/layout.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono, Noto_Sans_SC } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "./providers/ThemeProvider";
|
||||
import { themeInitScript } from "./theme-init";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const notoSansSC = Noto_Sans_SC({
|
||||
variable: "--font-noto-sans-sc",
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600", "700"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "数字游民实战课 | 从朝九晚五到边旅行边赚钱",
|
||||
description:
|
||||
"数字游民实战课,21节课程带你掌握远程变现、被动收入、全球旅居,异度世界、大师兄、小美联合授课",
|
||||
manifest: "/manifest.json",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="zh-CN" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} ${notoSansSC.variable} antialiased font-sans`}
|
||||
>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{ __html: themeInitScript }}
|
||||
/>
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
5
app/lib/env.ts
Normal file
5
app/lib/env.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
export const SITE_URL =
|
||||
process.env.NEXT_PUBLIC_SITE_URL ||
|
||||
(isDev ? "http://localhost:3000" : "https://example.com");
|
||||
141
app/lib/learning.ts
Normal file
141
app/lib/learning.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* 学习进度、继续学习、收藏、笔记 - localStorage
|
||||
*/
|
||||
|
||||
const PREFIX = "lms-";
|
||||
const KEY_LAST = `${PREFIX}last`;
|
||||
const KEY_COMPLETED = `${PREFIX}completed`;
|
||||
const KEY_BOOKMARKS = `${PREFIX}bookmarks`;
|
||||
const KEY_NOTES = `${PREFIX}notes`;
|
||||
const KEY_RATING = `${PREFIX}rating`;
|
||||
|
||||
export type LastWatched = { moduleIndex: number; lessonIndex: number; name: string };
|
||||
|
||||
export function getLastWatched(): LastWatched | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY_LAST);
|
||||
if (!raw) return null;
|
||||
const d = JSON.parse(raw);
|
||||
if (typeof d?.moduleIndex !== "number" || typeof d?.lessonIndex !== "number") return null;
|
||||
return { moduleIndex: d.moduleIndex, lessonIndex: d.lessonIndex, name: d.name || "" };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setLastWatched(m: number, l: number, name: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
localStorage.setItem(KEY_LAST, JSON.stringify({ moduleIndex: m, lessonIndex: l, name }));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function getCompletedLessons(): Set<string> {
|
||||
if (typeof window === "undefined") return new Set();
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY_COMPLETED);
|
||||
if (!raw) return new Set();
|
||||
const arr = JSON.parse(raw);
|
||||
return new Set(Array.isArray(arr) ? arr : []);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
export function markCompleted(moduleIndex: number, lessonIndex: number): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const set = getCompletedLessons();
|
||||
set.add(`${moduleIndex}-${lessonIndex}`);
|
||||
localStorage.setItem(KEY_COMPLETED, JSON.stringify([...set]));
|
||||
window.dispatchEvent(new CustomEvent("learning:progress"));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function getProgress(totalLessons: number): { completed: number; percent: number } {
|
||||
const set = getCompletedLessons();
|
||||
const completed = set.size;
|
||||
return { completed, percent: totalLessons > 0 ? Math.round((completed / totalLessons) * 100) : 0 };
|
||||
}
|
||||
|
||||
export function getBookmarks(): Set<string> {
|
||||
if (typeof window === "undefined") return new Set();
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY_BOOKMARKS);
|
||||
if (!raw) return new Set();
|
||||
const arr = JSON.parse(raw);
|
||||
return new Set(Array.isArray(arr) ? arr : []);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleBookmark(moduleIndex: number, lessonIndex: number): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
const set = getBookmarks();
|
||||
const key = `${moduleIndex}-${lessonIndex}`;
|
||||
if (set.has(key)) set.delete(key);
|
||||
else set.add(key);
|
||||
localStorage.setItem(KEY_BOOKMARKS, JSON.stringify([...set]));
|
||||
window.dispatchEvent(new CustomEvent("learning:bookmarks"));
|
||||
return set.has(key);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isBookmarked(moduleIndex: number, lessonIndex: number): boolean {
|
||||
return getBookmarks().has(`${moduleIndex}-${lessonIndex}`);
|
||||
}
|
||||
|
||||
export function getNote(moduleIndex: number, lessonIndex: number): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY_NOTES);
|
||||
if (!raw) return "";
|
||||
const map = JSON.parse(raw);
|
||||
return map[`${moduleIndex}-${lessonIndex}`] ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function setNote(moduleIndex: number, lessonIndex: number, text: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY_NOTES);
|
||||
const map = raw ? JSON.parse(raw) : {};
|
||||
map[`${moduleIndex}-${lessonIndex}`] = text;
|
||||
localStorage.setItem(KEY_NOTES, JSON.stringify(map));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function getRating(): { stars: number; comment: string } | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY_RATING);
|
||||
if (!raw) return null;
|
||||
const d = JSON.parse(raw);
|
||||
return { stars: d?.stars ?? 0, comment: d?.comment ?? "" };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setRating(stars: number, comment: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
localStorage.setItem(KEY_RATING, JSON.stringify({ stars, comment }));
|
||||
window.dispatchEvent(new CustomEvent("learning:rating"));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
20
app/lib/payment.ts
Normal file
20
app/lib/payment.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/** 支付状态 localStorage key */
|
||||
export const PAY_STORAGE_KEY = "pay";
|
||||
|
||||
/** 是否已支付(含测试标记 pay=ok) */
|
||||
export function isPaid(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return localStorage.getItem(PAY_STORAGE_KEY) === "ok";
|
||||
}
|
||||
|
||||
/** 前 2 章为试看,无需支付 */
|
||||
export function isFreeTrial(moduleIndex: number, lessonIndex: number): boolean {
|
||||
if (moduleIndex === 0 && lessonIndex < 2) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 章节是否可观看 */
|
||||
export function canWatchLesson(moduleIndex: number, lessonIndex: number): boolean {
|
||||
if (isFreeTrial(moduleIndex, lessonIndex)) return true;
|
||||
return isPaid();
|
||||
}
|
||||
44
app/page.tsx
Normal file
44
app/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Header,
|
||||
Hero,
|
||||
Footer,
|
||||
AudienceSection,
|
||||
BenefitsSection,
|
||||
CurriculumSection,
|
||||
InstructorsSection,
|
||||
FAQSection,
|
||||
CTASection,
|
||||
AnimateOnScroll,
|
||||
} from "./components";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)]">
|
||||
<Header />
|
||||
<main>
|
||||
<Hero />
|
||||
<AnimateOnScroll delay={0}>
|
||||
<AudienceSection />
|
||||
</AnimateOnScroll>
|
||||
<AnimateOnScroll delay={100}>
|
||||
<BenefitsSection />
|
||||
</AnimateOnScroll>
|
||||
<AnimateOnScroll delay={0}>
|
||||
<CurriculumSection />
|
||||
</AnimateOnScroll>
|
||||
<AnimateOnScroll delay={100}>
|
||||
<InstructorsSection />
|
||||
</AnimateOnScroll>
|
||||
<AnimateOnScroll delay={0}>
|
||||
<FAQSection />
|
||||
</AnimateOnScroll>
|
||||
<AnimateOnScroll delay={100}>
|
||||
<CTASection />
|
||||
</AnimateOnScroll>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
app/pay/page.tsx
Normal file
60
app/pay/page.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Header } from "@/app/components";
|
||||
import { PayButton } from "@/app/components/PayButton";
|
||||
|
||||
function PayContent() {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined" && searchParams.get("test") === "ok") {
|
||||
localStorage.setItem("pay", "ok");
|
||||
window.location.href = "/pay/paid";
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
if (searchParams.get("test") === "ok") {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<p className="text-[var(--muted-foreground)]">正在跳转...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)]">
|
||||
<Header />
|
||||
<main className="flex min-h-[80vh] flex-col items-center justify-center px-4">
|
||||
<h1 className="mb-2 text-2xl font-bold">报名数字游民实战课</h1>
|
||||
<p className="mb-6 text-[var(--muted-foreground)]">
|
||||
¥0.80 加入课程,解锁全部 21 节视频
|
||||
</p>
|
||||
<div className="mb-8 flex flex-col gap-3 sm:flex-row sm:gap-4">
|
||||
<PayButton size="lg" channel="alipay">支付宝支付</PayButton>
|
||||
<PayButton size="lg" channel="wxpay">微信支付</PayButton>
|
||||
</div>
|
||||
<p className="mb-4 text-xs text-[var(--muted-foreground)]">
|
||||
测试:<Link href="/pay?test=ok" className="text-[var(--accent)] underline">点击模拟支付成功</Link>
|
||||
</p>
|
||||
<Link href="/" className="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)]">
|
||||
返回首页
|
||||
</Link>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PayPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="flex min-h-screen items-center justify-center bg-[var(--background)]">
|
||||
<p className="text-[var(--muted-foreground)]">加载中...</p>
|
||||
</div>
|
||||
}>
|
||||
<PayContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
37
app/pay/paid/page.tsx
Normal file
37
app/pay/paid/page.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { Header } from "@/app/components";
|
||||
|
||||
const PAY_STORAGE_KEY = "pay";
|
||||
|
||||
export default function PayPaidPage() {
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(PAY_STORAGE_KEY, "ok");
|
||||
window.dispatchEvent(new CustomEvent("pay:updated"));
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--background)] text-[var(--foreground)]">
|
||||
<Header />
|
||||
<main className="flex min-h-[80vh] flex-col items-center justify-center px-4">
|
||||
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-green-500/20 text-4xl">
|
||||
✓
|
||||
</div>
|
||||
<h1 className="mb-2 text-2xl font-bold">支付成功</h1>
|
||||
<p className="mb-8 text-[var(--muted-foreground)]">
|
||||
已解锁全部课程视频,开始学习吧
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-full bg-[var(--accent)] px-8 py-3 font-medium text-[var(--accent-foreground)] transition hover:opacity-90"
|
||||
>
|
||||
返回课程首页
|
||||
</Link>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
app/providers/ThemeProvider.tsx
Normal file
84
app/providers/ThemeProvider.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
export type ThemeMode = "light" | "dark" | "system";
|
||||
|
||||
type ThemeContextValue = {
|
||||
theme: ThemeMode;
|
||||
resolvedTheme: "light" | "dark";
|
||||
setTheme: (theme: ThemeMode) => void;
|
||||
};
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
const STORAGE_KEY = "lms-theme";
|
||||
|
||||
function getSystemTheme(): "light" | "dark" {
|
||||
if (typeof window === "undefined") return "dark";
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
|
||||
function getStoredTheme(): ThemeMode {
|
||||
if (typeof window === "undefined") return "system";
|
||||
return (localStorage.getItem(STORAGE_KEY) as ThemeMode) || "system";
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setThemeState] = useState<ThemeMode>("system");
|
||||
const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">("dark");
|
||||
|
||||
const applyTheme = useCallback((mode: "light" | "dark") => {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(mode);
|
||||
setResolvedTheme(mode);
|
||||
}, []);
|
||||
|
||||
const setTheme = useCallback(
|
||||
(newTheme: ThemeMode) => {
|
||||
setThemeState(newTheme);
|
||||
localStorage.setItem(STORAGE_KEY, newTheme);
|
||||
const resolved =
|
||||
newTheme === "system" ? getSystemTheme() : newTheme;
|
||||
applyTheme(resolved);
|
||||
},
|
||||
[applyTheme]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = getStoredTheme();
|
||||
setThemeState(stored);
|
||||
const resolved = stored === "system" ? getSystemTheme() : stored;
|
||||
applyTheme(resolved);
|
||||
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handler = () => {
|
||||
if (getStoredTheme() === "system") {
|
||||
applyTheme(media.matches ? "dark" : "light");
|
||||
}
|
||||
};
|
||||
media.addEventListener("change", handler);
|
||||
return () => media.removeEventListener("change", handler);
|
||||
}, [applyTheme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
|
||||
return ctx;
|
||||
}
|
||||
11
app/theme-init.ts
Normal file
11
app/theme-init.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 主题初始化脚本 - 在 React 水合前执行,避免主题闪烁
|
||||
* 通过 suppressHydrationWarning 在 html 上避免服务端/客户端 class 不一致警告
|
||||
*/
|
||||
export const themeInitScript = `
|
||||
(function() {
|
||||
var theme = localStorage.getItem('lms-theme');
|
||||
var resolved = theme === 'light' ? 'light' : (theme === 'dark' ? 'dark' : (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'));
|
||||
document.documentElement.classList.add(resolved);
|
||||
})();
|
||||
`;
|
||||
215
config/course.ts
Normal file
215
config/course.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* 课程落地页配置 - 数字游民主题
|
||||
*/
|
||||
|
||||
export const SITE_CONFIG = {
|
||||
brand: "🌍 数字游民",
|
||||
ctaUrl: "#",
|
||||
footer: "🌍 数字游民 © 2024 | 自由工作,自在生活",
|
||||
} as const;
|
||||
|
||||
export const HERO_CONFIG = {
|
||||
title: "数字游民实战课",
|
||||
subtitle: "从朝九晚五到边旅行边赚钱,开启你的自由人生",
|
||||
stats: [
|
||||
{ value: "21", label: "节实战课程" },
|
||||
{ value: "170+", label: "分钟视频" },
|
||||
{ value: "3", label: "位资深导师" },
|
||||
],
|
||||
} as const;
|
||||
|
||||
export const AUDIENCE_CONFIG = {
|
||||
title: "这门课适合谁?",
|
||||
subtitle: "向往自由,想用技能换时间与地点自由的人",
|
||||
items: [
|
||||
{
|
||||
emoji: "💻",
|
||||
title: "远程打工人",
|
||||
desc: "厌倦通勤,想在家或任何地方高效工作",
|
||||
},
|
||||
{
|
||||
emoji: "✈️",
|
||||
title: "旅行爱好者",
|
||||
desc: "想边旅行边赚钱,不再为假期发愁",
|
||||
},
|
||||
{
|
||||
emoji: "📱",
|
||||
title: "自由职业者",
|
||||
desc: "想建立被动收入,减少接单焦虑",
|
||||
},
|
||||
{
|
||||
emoji: "🌴",
|
||||
title: "生活方式探索者",
|
||||
desc: "想摆脱固定地点,体验不同城市与国家",
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
export const BENEFITS_CONFIG = {
|
||||
title: "学完你将获得",
|
||||
subtitle: "5大核心能力,助你成为真正的数字游民",
|
||||
items: [
|
||||
{
|
||||
num: "1",
|
||||
title: "远程工作技能体系",
|
||||
desc: "从技能选择到接单渠道,系统掌握远程变现能力",
|
||||
},
|
||||
{
|
||||
num: "2",
|
||||
title: "被动收入搭建方法",
|
||||
desc: "产品化你的技能,让收入不依赖时间",
|
||||
},
|
||||
{
|
||||
num: "3",
|
||||
title: "全球旅居实操指南",
|
||||
desc: "签证、住宿、网络、保险,一站式搞定",
|
||||
},
|
||||
{
|
||||
num: "4",
|
||||
title: "一人公司运营心法",
|
||||
desc: "从个人到团队,可持续的自由商业模式",
|
||||
},
|
||||
{
|
||||
num: "5",
|
||||
title: "数字游民社群与资源",
|
||||
desc: "加入同频社群,获取持续支持与灵感",
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
export const CURRICULUM_CONFIG = {
|
||||
title: "21节实战课程",
|
||||
subtitle: "从入门到进阶,完整成为数字游民",
|
||||
modules: [
|
||||
{
|
||||
emoji: "🚀",
|
||||
title: "基础篇 · 数字游民入门",
|
||||
lessons: [
|
||||
{ name: "01 什么是数字游民?你适合吗?", duration: "11:58" },
|
||||
{ name: "02 从朝九晚五到边旅行边赚钱的路径", duration: "14:32" },
|
||||
],
|
||||
},
|
||||
{
|
||||
emoji: "💼",
|
||||
title: "技能篇 · 远程变现能力",
|
||||
lessons: [
|
||||
{ name: "3.1 最适合远程的5大技能方向", duration: "06:25" },
|
||||
{ name: "3.2 如何打造你的技能组合与作品集", duration: "07:34" },
|
||||
],
|
||||
},
|
||||
{
|
||||
emoji: "💰",
|
||||
title: "收入篇 · 被动收入与副业",
|
||||
lessons: [
|
||||
{ name: "04 从接单到产品的思维转变", duration: "04:16" },
|
||||
{ name: "05 打造你的第一个数字产品", duration: "06:18" },
|
||||
{ name: "06 海外平台接单与变现", duration: "02:30" },
|
||||
{ name: "07 被动收入组合:课程+会员+咨询", duration: "06:25" },
|
||||
{ name: "08 如何定价才能既赚又自由", duration: "03:09" },
|
||||
],
|
||||
},
|
||||
{
|
||||
emoji: "🧠",
|
||||
title: "心态篇 · 自由与自律",
|
||||
lessons: [
|
||||
{ name: "09 数字游民的时间管理心法", duration: "07:49" },
|
||||
{ name: "09-1 独处与社交的平衡", duration: "01:24" },
|
||||
{ name: "10 如何应对家人的不理解", duration: "04:07" },
|
||||
],
|
||||
},
|
||||
{
|
||||
emoji: "⚡",
|
||||
title: "落地篇 · 旅居实操",
|
||||
lessons: [
|
||||
{ name: "11 签证与长期居留规划", duration: "04:12" },
|
||||
{ name: "12 住宿选择:短租、民宿、Co-living", duration: "05:19" },
|
||||
{ name: "13 网络、保险与紧急预案", duration: "04:11" },
|
||||
],
|
||||
},
|
||||
{
|
||||
emoji: "🎯",
|
||||
title: "实战篇 · 真实案例",
|
||||
lessons: [
|
||||
{ name: "14 巴厘岛数字游民社区探访", duration: "12:02" },
|
||||
{ name: "15 曼谷/清迈生活成本与体验", duration: "09:39" },
|
||||
{ name: "16 欧洲数字游民签证攻略", duration: "09:04" },
|
||||
{ name: "17 一人公司如何合法合规运营", duration: "05:12" },
|
||||
],
|
||||
},
|
||||
{
|
||||
emoji: "🏆",
|
||||
title: "进阶篇 · 高阶玩法",
|
||||
lessons: [
|
||||
{ name: "18 从一人到小团队的进化", duration: "11:25" },
|
||||
{ name: "19 建立你的数字游民品牌", duration: "25:57" },
|
||||
{ name: "20 年度旅居计划与预算规划", duration: "12:34" },
|
||||
{ name: "21 长期数字游民的可持续之道", duration: "08:53" },
|
||||
],
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
export const INSTRUCTORS_CONFIG = {
|
||||
title: "三位资深导师联合授课",
|
||||
subtitle: "异度世界、大师兄、小美带你开启数字游民之旅",
|
||||
items: [
|
||||
{
|
||||
name: "异度世界",
|
||||
role: "数字游民先行者",
|
||||
desc: "5年旅居经验,走过20+国家",
|
||||
tag: "课程主理",
|
||||
},
|
||||
{
|
||||
name: "大师兄",
|
||||
role: "一人公司实践者",
|
||||
desc: "远程自由职业转型教练",
|
||||
tag: "变现专家",
|
||||
},
|
||||
{
|
||||
name: "小美",
|
||||
role: "生活方式博主",
|
||||
desc: "边旅行边创作的斜杠青年",
|
||||
tag: "旅居达人",
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
export const CTA_CONFIG = {
|
||||
title: "准备好开启你的数字游民之旅了吗?",
|
||||
subtitle: "加入我们,跟着异度世界、大师兄、小美一起,从零开始实现自由人生",
|
||||
badges: ["21节实战视频", "永久回看", "社群答疑"],
|
||||
} as const;
|
||||
|
||||
/** FAQ 常见问题 */
|
||||
export const FAQ_CONFIG = {
|
||||
title: "常见问题",
|
||||
items: [
|
||||
{ q: "课程可以永久回看吗?", a: "可以,报名后永久有效,随时回看。" },
|
||||
{ q: "适合零基础吗?", a: "适合。课程从入门到进阶,前 2 节可免费试看。" },
|
||||
{ q: "有社群答疑吗?", a: "有,报名后加入专属社群,导师和同学一起交流。" },
|
||||
{ q: "支持退款吗?", a: "7 天内不满意可申请全额退款。" },
|
||||
],
|
||||
} as const;
|
||||
|
||||
/** 社群入口 - 开发者可配置 */
|
||||
export const COMMUNITY_CONFIG = {
|
||||
enabled: true,
|
||||
/** 按钮/入口文案 */
|
||||
wechat: "扫码加入数字游民社群",
|
||||
/** 社群二维码图片 URL(报名后弹窗展示) */
|
||||
qrCodeUrl: "/qrcode-community.png",
|
||||
qq: "",
|
||||
link: "",
|
||||
} as const;
|
||||
|
||||
/** 章节底部网盘下载模块 - 开发者可配置开关与跳转地址 */
|
||||
export const DOWNLOAD_CONFIG = {
|
||||
/** 是否显示网盘下载模块 */
|
||||
enabled: true,
|
||||
/** 网盘跳转地址(如百度网盘、阿里云盘等) */
|
||||
url: "https://pan.baidu.com/s/example",
|
||||
/** 模块标题 */
|
||||
title: "课程资料下载",
|
||||
/** 按钮文案 */
|
||||
buttonText: "前往网盘下载",
|
||||
} as const;
|
||||
112
config/lessons.ts
Normal file
112
config/lessons.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 课程章节详情配置 - 数字游民主题
|
||||
*/
|
||||
|
||||
const SAMPLE_VIDEO = "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4";
|
||||
|
||||
const SAMPLE_MARKDOWN = `# 课程概述
|
||||
|
||||
本小节将带你理解数字游民的核心概念,掌握关键技能。
|
||||
|
||||
## 学习目标
|
||||
|
||||
- 理解数字游民的本质与生活方式
|
||||
- 掌握远程变现的基础路径
|
||||
- 完成个人定位与规划
|
||||
|
||||
## 核心要点
|
||||
|
||||
1. **定义**:数字游民 = 远程工作 + 自由旅行 + 地点独立
|
||||
2. **技能**:选择可远程交付的技能方向
|
||||
3. **心态**:自律与自由是相辅相成的
|
||||
|
||||
## 行动清单
|
||||
|
||||
- [ ] 评估自己的技能是否可远程交付
|
||||
- [ ] 列出3个你想旅居的城市
|
||||
- [ ] 设定6个月内的转型目标
|
||||
|
||||
### 代码示例
|
||||
|
||||
\`\`\`bash
|
||||
# 安装依赖
|
||||
pnpm install
|
||||
|
||||
# 启动开发
|
||||
pnpm dev
|
||||
\`\`\`
|
||||
|
||||
行内代码示例:\`npm run build\` 可生成生产版本。
|
||||
|
||||
## 注意事项
|
||||
|
||||
> 数字游民不是逃避,而是主动选择。建议先有稳定收入再考虑长期旅居。
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 收入 | 建议月收入稳定在 1 万以上 |
|
||||
| 技能 | 至少 1 项可远程交付 |
|
||||
| 心态 | 自律与自由平衡 |
|
||||
`;
|
||||
|
||||
export type LessonDetail = {
|
||||
name: string;
|
||||
duration: string;
|
||||
videoUrl: string;
|
||||
markdown: string;
|
||||
};
|
||||
|
||||
/** 扁平化课程数据:moduleIndex-lessonIndex 作为 key */
|
||||
export const LESSONS_MAP: Record<string, LessonDetail> = {
|
||||
"0-0": {
|
||||
name: "01 什么是数字游民?你适合吗?",
|
||||
duration: "11:58",
|
||||
videoUrl: SAMPLE_VIDEO,
|
||||
markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "数字游民的定义与自我评估"),
|
||||
},
|
||||
"0-1": {
|
||||
name: "02 从朝九晚五到边旅行边赚钱的路径",
|
||||
duration: "14:32",
|
||||
videoUrl: SAMPLE_VIDEO,
|
||||
markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "从上班族到数字游民的转型路径"),
|
||||
},
|
||||
"1-0": {
|
||||
name: "3.1 最适合远程的5大技能方向",
|
||||
duration: "06:25",
|
||||
videoUrl: SAMPLE_VIDEO,
|
||||
markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "编程、设计、写作、营销、咨询等远程技能"),
|
||||
},
|
||||
"1-1": {
|
||||
name: "3.2 如何打造你的技能组合与作品集",
|
||||
duration: "07:34",
|
||||
videoUrl: SAMPLE_VIDEO,
|
||||
markdown: SAMPLE_MARKDOWN.replace("本小节", "本节").replace("核心概念", "作品集搭建与个人品牌展示"),
|
||||
},
|
||||
"2-0": { name: "04 从接单到产品的思维转变", duration: "04:16", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
|
||||
"2-1": { name: "05 打造你的第一个数字产品", duration: "06:18", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
|
||||
"2-2": { name: "06 海外平台接单与变现", duration: "02:30", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
|
||||
"2-3": { name: "07 被动收入组合:课程+会员+咨询", duration: "06:25", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
|
||||
"2-4": { name: "08 如何定价才能既赚又自由", duration: "03:09", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
|
||||
"3-0": { name: "09 数字游民的时间管理心法", duration: "07:49", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
|
||||
"3-1": { name: "09-1 独处与社交的平衡", duration: "01:24", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
|
||||
"3-2": { name: "10 如何应对家人的不理解", duration: "04:07", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
|
||||
"4-0": { name: "11 签证与长期居留规划", duration: "04:12", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
|
||||
"4-1": { name: "12 住宿选择:短租、民宿、Co-living", duration: "05:19", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
|
||||
"4-2": { name: "13 网络、保险与紧急预案", duration: "04:11", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
|
||||
"5-0": { name: "14 巴厘岛数字游民社区探访", duration: "12:02", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
|
||||
"5-1": { name: "15 曼谷/清迈生活成本与体验", duration: "09:39", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
|
||||
"5-2": { name: "16 欧洲数字游民签证攻略", duration: "09:04", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
|
||||
"5-3": { name: "17 一人公司如何合法合规运营", duration: "05:12", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
|
||||
"6-0": { name: "18 从一人到小团队的进化", duration: "11:25", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
|
||||
"6-1": { name: "19 建立你的数字游民品牌", duration: "25:57", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
|
||||
"6-2": { name: "20 年度旅居计划与预算规划", duration: "12:34", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
|
||||
"6-3": { name: "21 长期数字游民的可持续之道", duration: "08:53", videoUrl: SAMPLE_VIDEO, markdown: SAMPLE_MARKDOWN },
|
||||
};
|
||||
|
||||
export function getLessonKey(moduleIndex: number, lessonIndex: number): string {
|
||||
return `${moduleIndex}-${lessonIndex}`;
|
||||
}
|
||||
|
||||
export function getLesson(moduleIndex: number, lessonIndex: number): LessonDetail | null {
|
||||
return LESSONS_MAP[getLessonKey(moduleIndex, lessonIndex)] ?? null;
|
||||
}
|
||||
18
eslint.config.mjs
Normal file
18
eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
101
lib/sdk/README.md
Normal file
101
lib/sdk/README.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# SDK 使用说明
|
||||
|
||||
ZPay 支付网关、PocketBase 数据库、MinIO 对象存储,配置成 SDK 随时调用。复制自 digital 项目。
|
||||
|
||||
## 环境变量
|
||||
|
||||
在 `.env.local` 中配置:
|
||||
|
||||
```bash
|
||||
# PocketBase
|
||||
NEXT_PUBLIC_POCKETBASE_URL=https://pocketbase.hackrobot.cn
|
||||
POCKETBASE_JOIN_COLLECTION=solan
|
||||
POCKETBASE_EMAIL=admin@example.com # Admin 登录(可选,用于 createRecord 403 时重试)
|
||||
POCKETBASE_PASSWORD=xxx
|
||||
|
||||
# ZPay 支付
|
||||
PAYMENT_API_URL=http://127.0.0.1:8700 # 开发;生产用 https://api.hackrobot.cn
|
||||
PAYMENT_PROVIDER=zpay
|
||||
PAYMENT_DEFAULT_AMOUNT=80
|
||||
PAYMENT_DEFAULT_TYPE=meetup
|
||||
ZPAY_SUBMIT_URL=https://zpayz.cn/submit.php
|
||||
|
||||
# MinIO
|
||||
MINIO_ENDPOINT=minioweb.hackrobot.cn
|
||||
MINIO_PORT=9035
|
||||
MINIO_USE_SSL=false
|
||||
MINIO_BUCKET=hackrobot
|
||||
MINIO_ACCESS_KEY=xxx
|
||||
MINIO_SECRET_KEY=xxx
|
||||
MINIO_UPLOAD_PREFIX=joins
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
### PocketBase
|
||||
|
||||
```ts
|
||||
import {
|
||||
pbLogin,
|
||||
pbRegister,
|
||||
pbSaveAuth,
|
||||
pbLogout,
|
||||
getStoredUserEmail,
|
||||
createRecord,
|
||||
} from "@/lib/sdk";
|
||||
|
||||
// 客户端:登录
|
||||
const { token, record } = await pbLogin("user@example.com", "password");
|
||||
pbSaveAuth(token, record);
|
||||
|
||||
// 客户端:注册
|
||||
const result = await pbRegister("user@example.com", "pass", "pass");
|
||||
pbSaveAuth(result.token, result.record);
|
||||
|
||||
// 客户端:登出
|
||||
pbLogout();
|
||||
|
||||
// 客户端:获取当前用户
|
||||
const email = getStoredUserEmail();
|
||||
|
||||
// 服务端:创建记录
|
||||
await createRecord("solan", { nickname: "张三", reason: "想加入" });
|
||||
```
|
||||
|
||||
### ZPay
|
||||
|
||||
```ts
|
||||
import { createPayOrder, getOrderStatus, buildPayHtml } from "@/lib/sdk";
|
||||
|
||||
// 创建订单
|
||||
const { params, payUrl } = await createPayOrder({
|
||||
user_id: "user123",
|
||||
return_url: "https://yoursite.com/paid",
|
||||
total_fee: 80, // 分
|
||||
type: "meetup",
|
||||
channel: "alipay",
|
||||
});
|
||||
|
||||
// 查询支付状态
|
||||
const { paid } = await getOrderStatus("out_trade_no_xxx");
|
||||
|
||||
// 构建跳转 HTML
|
||||
const html = buildPayHtml(payUrl, params);
|
||||
```
|
||||
|
||||
### MinIO
|
||||
|
||||
```ts
|
||||
import { uploadBuffer, generateObjectKey } from "@/lib/sdk";
|
||||
|
||||
// 生成对象键
|
||||
const objectKey = generateObjectKey("jpg");
|
||||
|
||||
// 上传
|
||||
const { url } = await uploadBuffer(
|
||||
Buffer.from(fileArrayBuffer),
|
||||
objectKey,
|
||||
"image/jpeg"
|
||||
);
|
||||
// url 即公开访问地址
|
||||
```
|
||||
85
lib/sdk/config.ts
Normal file
85
lib/sdk/config.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* SDK 统一配置 - 从环境变量读取,可随时调用
|
||||
* 复制自 digital 项目,去除主题依赖
|
||||
*/
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
export interface PocketBaseConfig {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
usersCollection: string;
|
||||
joinCollection: string;
|
||||
}
|
||||
|
||||
export interface ZPayConfig {
|
||||
enabled: boolean;
|
||||
apiUrl: string;
|
||||
provider: "zpay" | "xorpay";
|
||||
defaultAmount: number;
|
||||
defaultType: string;
|
||||
zpaySubmitUrl: string;
|
||||
}
|
||||
|
||||
export interface MinioConfig {
|
||||
enabled: boolean;
|
||||
endPoint: string;
|
||||
port: number;
|
||||
useSSL: boolean;
|
||||
bucket: string;
|
||||
accessKey: string;
|
||||
secretKey: string;
|
||||
region: string;
|
||||
publicUrl: string;
|
||||
uploadPrefix: string;
|
||||
}
|
||||
|
||||
/** PocketBase 配置 */
|
||||
export function getPocketBaseConfig(overrides?: { joinCollection?: string }): PocketBaseConfig {
|
||||
return {
|
||||
enabled: true,
|
||||
url: process.env.NEXT_PUBLIC_POCKETBASE_URL || process.env.POCKETBASE_URL || "https://pocketbase.hackrobot.cn",
|
||||
usersCollection: "users",
|
||||
joinCollection: overrides?.joinCollection || process.env.POCKETBASE_JOIN_COLLECTION || "solan",
|
||||
};
|
||||
}
|
||||
|
||||
/** ZPay 支付配置 */
|
||||
export function getZPayConfig(overrides?: { amount?: number; type?: string }): ZPayConfig {
|
||||
const base = {
|
||||
enabled: true,
|
||||
apiUrl: process.env.PAYMENT_API_URL || (isDev ? "http://127.0.0.1:8700" : "https://api.hackrobot.cn"),
|
||||
provider: (process.env.PAYMENT_PROVIDER as "zpay" | "xorpay") || "zpay",
|
||||
defaultAmount: Number(process.env.PAYMENT_DEFAULT_AMOUNT) || 80,
|
||||
defaultType: process.env.PAYMENT_DEFAULT_TYPE || "meetup",
|
||||
zpaySubmitUrl: process.env.ZPAY_SUBMIT_URL || "https://zpayz.cn/submit.php",
|
||||
};
|
||||
if (overrides) {
|
||||
if (overrides.amount != null) base.defaultAmount = overrides.amount;
|
||||
if (overrides.type) base.defaultType = overrides.type;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
/** MinIO 配置 */
|
||||
export function getMinioConfig(overrides?: { bucket?: string; uploadPrefix?: string }): MinioConfig {
|
||||
const endPoint = process.env.MINIO_ENDPOINT || "minioweb.hackrobot.cn";
|
||||
const port = parseInt(process.env.MINIO_PORT || "9035", 10);
|
||||
const useSSL = process.env.MINIO_USE_SSL === "true";
|
||||
const bucket = overrides?.bucket || process.env.MINIO_BUCKET || "hackrobot";
|
||||
const publicUrl = process.env.MINIO_PUBLIC_URL || `https://${endPoint}/${bucket}`;
|
||||
const uploadPrefix = overrides?.uploadPrefix || process.env.MINIO_UPLOAD_PREFIX || "joins";
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
endPoint,
|
||||
port,
|
||||
useSSL,
|
||||
bucket,
|
||||
accessKey: process.env.MINIO_ACCESS_KEY || "6i56HHfg4zPfYItCZtnp",
|
||||
secretKey: process.env.MINIO_SECRET_KEY || "vDJCqEit3ejH5UmWKAZnvqhziNfbVsoOlBW12G8Q",
|
||||
region: process.env.MINIO_REGION || "china",
|
||||
publicUrl: publicUrl.replace(/\/$/, ""),
|
||||
uploadPrefix,
|
||||
};
|
||||
}
|
||||
39
lib/sdk/index.ts
Normal file
39
lib/sdk/index.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* SDK 统一入口 - ZPay / PocketBase / MinIO 随时调用
|
||||
* 配置通过环境变量,复制自 digital 项目
|
||||
*/
|
||||
|
||||
// 配置
|
||||
export {
|
||||
getPocketBaseConfig,
|
||||
getZPayConfig,
|
||||
getMinioConfig,
|
||||
} from "./config";
|
||||
export type { PocketBaseConfig, ZPayConfig, MinioConfig } from "./config";
|
||||
|
||||
// PocketBase
|
||||
export {
|
||||
pbLogin,
|
||||
pbRegister,
|
||||
pbSaveAuth,
|
||||
pbLogout,
|
||||
getStoredUserEmail,
|
||||
getStoredToken,
|
||||
createRecord,
|
||||
getAdminToken,
|
||||
PB_STORAGE_KEYS,
|
||||
} from "./pocketbase";
|
||||
export type { AuthUser, AuthResult } from "./pocketbase";
|
||||
|
||||
// ZPay
|
||||
export {
|
||||
createPayOrder,
|
||||
getOrderStatus,
|
||||
buildPayHtml,
|
||||
ZPAY_REQUIRED,
|
||||
} from "./zpay";
|
||||
export type { CreateOrderOptions, CreateOrderResult } from "./zpay";
|
||||
|
||||
// MinIO
|
||||
export { getMinioClient, uploadBuffer, generateObjectKey } from "./minio";
|
||||
export type { UploadResult } from "./minio";
|
||||
60
lib/sdk/minio/index.ts
Normal file
60
lib/sdk/minio/index.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* MinIO SDK - 上传、下载,随时可调用
|
||||
* 复制自 digital 项目
|
||||
*/
|
||||
|
||||
import * as Minio from "minio";
|
||||
import { getMinioConfig } from "../config";
|
||||
|
||||
export interface UploadResult {
|
||||
url: string;
|
||||
objectKey: string;
|
||||
}
|
||||
|
||||
/** 获取 MinIO 客户端实例 */
|
||||
export function getMinioClient(overrides?: { bucket?: string; uploadPrefix?: string }): Minio.Client {
|
||||
const cfg = getMinioConfig(overrides);
|
||||
return new Minio.Client({
|
||||
endPoint: cfg.endPoint,
|
||||
port: cfg.port,
|
||||
useSSL: cfg.useSSL,
|
||||
accessKey: cfg.accessKey,
|
||||
secretKey: cfg.secretKey,
|
||||
pathStyle: true,
|
||||
region: cfg.region,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件到 MinIO
|
||||
* @param buffer 文件内容
|
||||
* @param objectKey 对象键(含路径)
|
||||
* @param contentType MIME 类型
|
||||
*/
|
||||
export async function uploadBuffer(
|
||||
buffer: Buffer,
|
||||
objectKey: string,
|
||||
contentType: string = "application/octet-stream",
|
||||
overrides?: { bucket?: string; uploadPrefix?: string }
|
||||
): Promise<UploadResult> {
|
||||
const cfg = getMinioConfig(overrides);
|
||||
if (!cfg.enabled) throw new Error("MinIO 存储未启用");
|
||||
|
||||
const client = getMinioClient(overrides);
|
||||
await client.putObject(cfg.bucket, objectKey, buffer, buffer.length, {
|
||||
"Content-Type": contentType,
|
||||
});
|
||||
|
||||
const url = `${cfg.publicUrl}/${objectKey}`;
|
||||
return { url, objectKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成上传用的对象键
|
||||
* @param ext 文件扩展名
|
||||
*/
|
||||
export function generateObjectKey(ext: string, overrides?: { uploadPrefix?: string }): string {
|
||||
const cfg = getMinioConfig(overrides);
|
||||
const prefix = cfg.uploadPrefix.replace(/\/$/, "");
|
||||
return `${prefix}/${Date.now()}_${Math.random().toString(36).slice(2)}.${ext}`;
|
||||
}
|
||||
91
lib/sdk/pocketbase/auth.ts
Normal file
91
lib/sdk/pocketbase/auth.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* PocketBase 认证 - 登录、注册、登出(客户端可用)
|
||||
*/
|
||||
|
||||
import { getPocketBaseConfig } from "../config";
|
||||
import { PB_STORAGE_KEYS } from "./constants";
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AuthResult {
|
||||
token: string;
|
||||
record: AuthUser;
|
||||
}
|
||||
|
||||
function getPBUrl(): string {
|
||||
return getPocketBaseConfig().url;
|
||||
}
|
||||
|
||||
/** 登录 */
|
||||
export async function pbLogin(identity: string, password: string): Promise<AuthResult> {
|
||||
const url = getPBUrl();
|
||||
const res = await fetch(`${url}/api/collections/users/auth-with-password`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ identity, password }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err?.message || "登录失败");
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!data?.token) throw new Error("登录响应异常");
|
||||
return { token: data.token, record: data.record };
|
||||
}
|
||||
|
||||
/** 注册 */
|
||||
export async function pbRegister(
|
||||
email: string,
|
||||
password: string,
|
||||
passwordConfirm: string
|
||||
): Promise<AuthResult> {
|
||||
const url = getPBUrl();
|
||||
const config = getPocketBaseConfig();
|
||||
const res = await fetch(`${url}/api/collections/${config.usersCollection}/records`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password, passwordConfirm }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err?.message || "注册失败");
|
||||
}
|
||||
return pbLogin(email, password);
|
||||
}
|
||||
|
||||
/** 保存认证到 localStorage */
|
||||
export function pbSaveAuth(token: string, record: AuthUser): void {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.setItem(PB_STORAGE_KEYS.token, token);
|
||||
localStorage.setItem(PB_STORAGE_KEYS.user, JSON.stringify(record));
|
||||
}
|
||||
|
||||
/** 登出 */
|
||||
export function pbLogout(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.removeItem(PB_STORAGE_KEYS.token);
|
||||
localStorage.removeItem(PB_STORAGE_KEYS.user);
|
||||
}
|
||||
|
||||
/** 获取存储的用户邮箱 */
|
||||
export function getStoredUserEmail(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(PB_STORAGE_KEYS.user);
|
||||
if (!raw) return null;
|
||||
const user = JSON.parse(raw);
|
||||
return user?.email ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取存储的 token */
|
||||
export function getStoredToken(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
return localStorage.getItem(PB_STORAGE_KEYS.token);
|
||||
}
|
||||
5
lib/sdk/pocketbase/constants.ts
Normal file
5
lib/sdk/pocketbase/constants.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/** localStorage 存储 key */
|
||||
export const PB_STORAGE_KEYS = {
|
||||
token: "pb_token",
|
||||
user: "pb_user",
|
||||
} as const;
|
||||
12
lib/sdk/pocketbase/index.ts
Normal file
12
lib/sdk/pocketbase/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* PocketBase SDK - 认证与数据操作,随时可调用
|
||||
* 复制自 digital 项目
|
||||
*/
|
||||
|
||||
import { getPocketBaseConfig } from "../config";
|
||||
import { PB_STORAGE_KEYS } from "./constants";
|
||||
|
||||
export { PB_STORAGE_KEYS } from "./constants";
|
||||
export type { AuthUser, AuthResult } from "./auth";
|
||||
export { pbLogin, pbRegister, pbSaveAuth, pbLogout, getStoredUserEmail, getStoredToken } from "./auth";
|
||||
export { createRecord, getAdminToken } from "./records";
|
||||
52
lib/sdk/pocketbase/records.ts
Normal file
52
lib/sdk/pocketbase/records.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* PocketBase 记录操作 - 创建、查询(服务端可用)
|
||||
*/
|
||||
|
||||
import { getPocketBaseConfig } from "../config";
|
||||
|
||||
/** 获取 Admin Token(需配置 POCKETBASE_EMAIL + POCKETBASE_PASSWORD) */
|
||||
export async function getAdminToken(): Promise<string | null> {
|
||||
const email = process.env.POCKETBASE_EMAIL;
|
||||
const password = process.env.POCKETBASE_PASSWORD;
|
||||
if (!email || !password) return null;
|
||||
|
||||
const pb = getPocketBaseConfig();
|
||||
const res = await fetch(`${pb.url}/api/admins/auth-with-password`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ identity: email, password }),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return data?.token ?? null;
|
||||
}
|
||||
|
||||
/** 创建记录 */
|
||||
export async function createRecord(
|
||||
collection: string,
|
||||
data: Record<string, unknown>,
|
||||
authToken?: string
|
||||
): Promise<{ id: string; [key: string]: unknown }> {
|
||||
const pb = getPocketBaseConfig();
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (authToken) headers["Authorization"] = authToken;
|
||||
|
||||
const res = await fetch(`${pb.url}/api/collections/${collection}/records`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text();
|
||||
let errMsg = "创建失败";
|
||||
try {
|
||||
const errJson = JSON.parse(errText);
|
||||
if (errJson?.message) errMsg = errJson.message;
|
||||
} catch {
|
||||
if (errText.length < 200) errMsg = errText;
|
||||
}
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
130
lib/sdk/zpay/index.ts
Normal file
130
lib/sdk/zpay/index.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* ZPay 支付网关 SDK - 创建订单、查询状态,随时可调用
|
||||
* 复制自 digital 项目 pay API
|
||||
*/
|
||||
|
||||
import { getZPayConfig } from "../config";
|
||||
|
||||
const ZPAY_REQUIRED = [
|
||||
"pid",
|
||||
"type",
|
||||
"out_trade_no",
|
||||
"notify_url",
|
||||
"return_url",
|
||||
"name",
|
||||
"money",
|
||||
"sign",
|
||||
"sign_type",
|
||||
];
|
||||
|
||||
function escapeAttr(s: string): string {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
export interface CreateOrderOptions {
|
||||
user_id: string;
|
||||
return_url: string;
|
||||
total_fee?: number;
|
||||
type?: string;
|
||||
channel?: string;
|
||||
}
|
||||
|
||||
export interface CreateOrderResult {
|
||||
params: Record<string, string>;
|
||||
payUrl: string;
|
||||
}
|
||||
|
||||
/** 创建支付订单 */
|
||||
export async function createPayOrder(options: CreateOrderOptions): Promise<CreateOrderResult> {
|
||||
const config = getZPayConfig();
|
||||
if (!config.apiUrl) throw new Error("未配置 PAYMENT_API_URL");
|
||||
|
||||
const { user_id, return_url, total_fee, type, channel = "alipay" } = options;
|
||||
const fee = total_fee ?? config.defaultAmount;
|
||||
const orderType = type ?? config.defaultType;
|
||||
|
||||
const payload = {
|
||||
user_id,
|
||||
total_fee: Number(fee) || config.defaultAmount,
|
||||
type: orderType,
|
||||
channel,
|
||||
return_url,
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
const res = await fetch(`${config.apiUrl}/payh5`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
}).finally(() => clearTimeout(timeout));
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data?.status !== "ok") {
|
||||
throw new Error(data?.detail || data?.msg || data?.message || "支付创建失败");
|
||||
}
|
||||
|
||||
let params = data.params || data.xorpay_params || {};
|
||||
const payUrl = data.pay_url || data.xorpay_url || config.zpaySubmitUrl;
|
||||
if (!params || typeof params !== "object") {
|
||||
throw new Error("payjsapi 未返回支付参数");
|
||||
}
|
||||
params = JSON.parse(JSON.stringify(params));
|
||||
if (data.provider === "xorpay") {
|
||||
throw new Error("当前 payjsapi 使用 xorpay,请设置 PAYMENT_PROVIDER=zpay");
|
||||
}
|
||||
return { params, payUrl };
|
||||
}
|
||||
|
||||
/** 查询订单支付状态 */
|
||||
export async function getOrderStatus(orderId: string): Promise<{ paid: boolean }> {
|
||||
const config = getZPayConfig();
|
||||
if (!config.apiUrl) throw new Error("未配置 PAYMENT_API_URL");
|
||||
|
||||
const res = await fetch(
|
||||
`${config.apiUrl}/zpay_order_status?out_trade_no=${encodeURIComponent(orderId)}`,
|
||||
{ cache: "no-store" }
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return { paid: !!data?.paid };
|
||||
}
|
||||
|
||||
/** 构建 ZPay 表单跳转 HTML */
|
||||
export function buildPayHtml(payUrl: string, params: Record<string, unknown>): string {
|
||||
const flat: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v == null) continue;
|
||||
const s = String(v).trim();
|
||||
if (s === "") continue;
|
||||
flat[k] = s;
|
||||
}
|
||||
const inputs = Object.entries(flat)
|
||||
.map(([k, v]) => `<input type="hidden" name="${escapeAttr(k)}" value="${escapeAttr(v)}" />`)
|
||||
.join("\n");
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>跳转支付...</title>
|
||||
<style>body{font-family:system-ui;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#f0f4f8;}p{color:#64748b;}</style>
|
||||
</head>
|
||||
<body>
|
||||
<p>正在跳转到支付页面...</p>
|
||||
<form id="zpayForm" action="${escapeAttr(payUrl)}" method="POST" enctype="application/x-www-form-urlencoded">
|
||||
${inputs}
|
||||
</form>
|
||||
<script>document.getElementById("zpayForm").submit();</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/** 获取 ZPay 必填参数列表(用于校验) */
|
||||
export { ZPAY_REQUIRED };
|
||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
43
package.json
Normal file
43
package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "lms",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"github-markdown-css": "^5.9.0",
|
||||
"minio": "^8.0.7",
|
||||
"next": "16.1.6",
|
||||
"pocketbase": "^0.26.8",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"@tsparticles/engine": "^3.0.2",
|
||||
"@tsparticles/react": "^3.0.0",
|
||||
"@tsparticles/slim": "^3.9.1",
|
||||
"animate.css": "^4.1.1",
|
||||
"d3": "^7.9.0",
|
||||
"framer-motion": "^11.15.0",
|
||||
"echarts": "^5.5.0",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
"highlight.js": "^11.10.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
6154
pnpm-lock.yaml
generated
Normal file
6154
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
ignoredBuiltDependencies:
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
7
postcss.config.mjs
Normal file
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
public/file.svg
Normal file
1
public/file.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
1
public/globe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
18
public/manifest.json
Normal file
18
public/manifest.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "数字游民实战课",
|
||||
"short_name": "数字游民",
|
||||
"description": "从朝九晚五到边旅行边赚钱,开启你的自由人生",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#0ea5e9",
|
||||
"lang": "zh-CN",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon.ico",
|
||||
"sizes": "48x48",
|
||||
"type": "image/x-icon",
|
||||
"purpose": "any"
|
||||
}
|
||||
]
|
||||
}
|
||||
1
public/next.svg
Normal file
1
public/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/vercel.svg
Normal file
1
public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
public/window.svg
Normal file
1
public/window.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
34
tsconfig.json
Normal file
34
tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user