2025.11.27.17.50
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
|
||||||
91
README.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# AounApp - 个人资源付费系统
|
||||||
|
|
||||||
|
这是一个基于 Next.js 全栈开发的个人资源付费与会员系统。支持文章/资源发布、会员订阅、积分充值、以及支付宝/微信支付集成。
|
||||||
|
|
||||||
|
## 🛠 技术栈 (Tech Stack)
|
||||||
|
|
||||||
|
### 核心框架
|
||||||
|
- **Framework**: [Next.js 16](https://nextjs.org/) (Pages Router)
|
||||||
|
- **Language**: [TypeScript](https://www.typescriptlang.org/)
|
||||||
|
- **Database**: [MongoDB](https://www.mongodb.com/) (via [Mongoose](https://mongoosejs.com/))
|
||||||
|
|
||||||
|
### 前端 UI & 交互
|
||||||
|
- **UI Library**: [Shadcn UI](https://ui.shadcn.com/) (基于 Radix UI)
|
||||||
|
- **Styling**: [Tailwind CSS v4](https://tailwindcss.com/)
|
||||||
|
- **Icons**: [Lucide React](https://lucide.dev/)
|
||||||
|
- **Forms**: [React Hook Form](https://react-hook-form.com/) + [Zod](https://zod.dev/) (验证)
|
||||||
|
- **Notifications**: [Sonner](https://sonner.emilkowal.ski/)
|
||||||
|
- **Editor**: [Tiptap](https://tiptap.dev/) (Headless 富文本编辑器)
|
||||||
|
|
||||||
|
### 后端 & 工具
|
||||||
|
- **Authentication**: JWT (JSON Web Tokens) - 自定义实现,存储于 HttpOnly Cookie
|
||||||
|
- **Payment**: Native Alipay Integration (Node.js `crypto` 实现,无第三方 SDK 依赖)
|
||||||
|
- **Markdown Processing**:
|
||||||
|
- `unified`, `remark`, `rehype` (服务端处理)
|
||||||
|
- `shiki` (代码高亮)
|
||||||
|
- **Date Handling**: `date-fns`
|
||||||
|
|
||||||
|
## ✨ 核心功能 (Key Features)
|
||||||
|
|
||||||
|
1. **用户系统**
|
||||||
|
- 注册/登录 (JWT)
|
||||||
|
- 个人中心 (Dashboard):查看订单、会员状态、积分
|
||||||
|
- 钱包系统:积分充值与消费
|
||||||
|
|
||||||
|
2. **内容管理 (CMS)**
|
||||||
|
- **文章/资源发布**:支持 Markdown 编辑 (Tiptap),支持设置价格(积分/现金)、隐藏内容(下载链接/提取码)。
|
||||||
|
- **分类与标签**:灵活的内容组织。
|
||||||
|
- **评论系统**:支持多级评论。
|
||||||
|
|
||||||
|
3. **会员与支付**
|
||||||
|
- **会员订阅**:不同等级会员享受不同折扣和每日下载限制。
|
||||||
|
- **支付集成**:
|
||||||
|
- **支付宝**:原生集成,支持当面付/电脑网站支付(根据配置)。
|
||||||
|
- **积分支付**:站内虚拟货币支付。
|
||||||
|
- **订单系统**:完整的订单创建、支付回调、状态流转。
|
||||||
|
|
||||||
|
4. **管理后台 (Admin)**
|
||||||
|
- 仪表盘:数据概览。
|
||||||
|
- 用户管理、文章管理、订单管理、会员套餐管理。
|
||||||
|
- 系统设置:全局 SEO、支付参数配置。
|
||||||
|
|
||||||
|
## ⚠️ 关键架构与注意事项 (Architecture & Notes)
|
||||||
|
|
||||||
|
### 1. Markdown 渲染架构
|
||||||
|
* **服务端渲染 (SSR)**:为了解决 `rehype-pretty-code` 在客户端运行时的兼容性问题,并将繁重的 Markdown 解析逻辑移出前端 bundle,我们采用了**服务端预处理**方案。
|
||||||
|
* **流程**:
|
||||||
|
1. API (`/api/articles/[id]`) 接收请求。
|
||||||
|
2. 后端读取 Markdown 内容,使用 `unified` + `remark` + `rehype` + `shiki` 转换为 HTML。
|
||||||
|
3. API 返回预渲染好的 HTML 字符串。
|
||||||
|
4. 前端 (`pages/article/[id].tsx`) 使用 `dangerouslySetInnerHTML` 直接渲染。
|
||||||
|
|
||||||
|
### 2. 编辑器 (Tiptap)
|
||||||
|
* **SSR 兼容性**:Tiptap 在 Next.js 中使用时,必须在 `useEditor` 中设置 `immediatelyRender: false`,以避免服务端渲染与客户端水合不匹配 (Hydration Mismatch) 导致的运行时错误。
|
||||||
|
* **Markdown 支持**:集成了 `tiptap-markdown` 扩展,确保编辑器内容可以作为 Markdown 格式保存到数据库,保持数据格式的通用性。
|
||||||
|
|
||||||
|
### 3. 支付系统
|
||||||
|
* **去 SDK 化**:为了避免 `alipay-sdk` 等库的潜在依赖问题和体积,我们使用 Node.js 原生 `crypto` 模块实现了支付宝的签名和验签逻辑 (`src/lib/alipay.ts`)。
|
||||||
|
* **安全性**:支付回调 (`notify`) 必须严格验证签名,防止伪造请求。
|
||||||
|
|
||||||
|
### 4. 样式系统
|
||||||
|
* **Tailwind v4**:项目配置了 Tailwind CSS v4。
|
||||||
|
* **Typography**:使用了 `@tailwindcss/typography` 插件来美化文章详情页的 HTML 内容渲染 (`prose` 类)。
|
||||||
|
|
||||||
|
## 🚀 快速开始 (Getting Started)
|
||||||
|
|
||||||
|
1. **安装依赖**
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **配置环境变量**
|
||||||
|
复制 `.env.example` 到 `.env.local` 并填入 MongoDB URI 和 JWT Secret 等信息。
|
||||||
|
|
||||||
|
3. **运行开发服务器**
|
||||||
|
```bash
|
||||||
|
pnpm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **访问**
|
||||||
|
- 前台: `http://localhost:3000`
|
||||||
|
- 后台: `http://localhost:3000/admin`
|
||||||
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;
|
||||||
20
next.config.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
reactStrictMode: true,
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'picsum.photos',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: 'api.dicebear.com',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
70
package.json
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"name": "aounapp",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"@tiptap/extension-bubble-menu": "^3.11.0",
|
||||||
|
"@tiptap/extension-floating-menu": "^3.11.0",
|
||||||
|
"@tiptap/extension-image": "^3.11.0",
|
||||||
|
"@tiptap/extension-link": "^3.11.0",
|
||||||
|
"@tiptap/extension-placeholder": "^3.11.0",
|
||||||
|
"@tiptap/react": "^3.11.0",
|
||||||
|
"@tiptap/starter-kit": "^3.11.0",
|
||||||
|
"@uiw/react-md-editor": "^4.0.8",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"eventsource-parser": "^3.0.6",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"lucide-react": "^0.554.0",
|
||||||
|
"mongoose": "^9.0.0",
|
||||||
|
"next": "16.0.3",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"react": "19.2.0",
|
||||||
|
"react-dom": "19.2.0",
|
||||||
|
"react-hook-form": "^7.66.1",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"rehype-pretty-code": "^0.14.1",
|
||||||
|
"rehype-raw": "^7.0.0",
|
||||||
|
"rehype-sanitize": "^6.0.0",
|
||||||
|
"rehype-stringify": "^10.0.1",
|
||||||
|
"remark-parse": "^11.0.0",
|
||||||
|
"remark-rehype": "^11.1.2",
|
||||||
|
"shiki": "^3.15.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"tiptap-markdown": "^0.9.0",
|
||||||
|
"unified": "^11.0.5",
|
||||||
|
"zod": "^4.1.12"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.0.3",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
7620
pnpm-lock.yaml
generated
Normal file
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
1
public/Gemini.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1763895169483" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8921" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M1024 513.024c-274.944 16.896-494.08 236.032-510.976 510.976h-2.048A545.3312 545.3312 0 0 0 0 513.024v-2.048A545.3312 545.3312 0 0 0 510.976 0h2.048c16.896 274.944 236.032 494.08 510.976 510.976v2.048z" fill="#459FFF" p-id="8922"></path></svg>
|
||||||
|
After Width: | Height: | Size: 576 B |
1
public/Google.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1763894582265" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6990" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M214.101 512c0-32.512 5.547-63.701 15.36-92.928L57.173 290.219A491.861 491.861 0 0 0 4.693 512c0 79.701 18.859 154.88 52.395 221.61l172.203-129.066A290.56 290.56 0 0 1 214.1 512" fill="#FBBC05" p-id="6991"></path><path d="M516.693 216.192c72.107 0 137.259 25.003 188.459 65.963l148.95-145.622C763.348 59.18 646.996 11.392 516.692 11.392c-202.325 0-376.234 113.28-459.52 278.827l172.374 128.853c39.68-118.016 152.832-202.88 287.146-202.88" fill="#EA4335" p-id="6992"></path><path d="M516.693 807.808c-134.357 0-247.509-84.864-287.232-202.88L57.173 733.781c83.243 165.547 257.152 278.827 459.52 278.827 124.843 0 244.054-43.392 333.568-124.757L686.677 764.032c-46.122 28.459-104.234 43.776-170.026 43.776" fill="#34A853" p-id="6993"></path><path d="M1005.397 512c0-29.568-4.693-61.44-11.648-91.008H516.651V614.4h274.602c-13.696 65.963-51.072 116.65-104.533 149.632l163.541 123.819C944.256 802.432 1005.397 675.2 1005.397 512" fill="#4285F4" p-id="6994"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
BIN
public/LOGO.png
Normal file
|
After Width: | Height: | Size: 134 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
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
@@ -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 |
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
@@ -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
@@ -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 |
482
scripts/seed.ts
Normal file
@@ -0,0 +1,482 @@
|
|||||||
|
/**
|
||||||
|
* 作者: 阿瑞
|
||||||
|
* 功能: 数据库填充脚本 (Seeding Script)
|
||||||
|
* 版本: 2.0.0
|
||||||
|
*
|
||||||
|
* 运行方式: npx tsx scripts/seed.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import mongoose from 'mongoose';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import {
|
||||||
|
User,
|
||||||
|
Article,
|
||||||
|
SystemConfig,
|
||||||
|
Comment,
|
||||||
|
Order,
|
||||||
|
MembershipPlan,
|
||||||
|
Category,
|
||||||
|
Tag
|
||||||
|
} from '../src/models/index';
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// 1. 环境配置加载 (手动读取 .env.local,避免依赖 dotenv)
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
const loadEnv = () => {
|
||||||
|
const envPath = path.resolve(process.cwd(), '.env.local');
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
console.log('📄 正在加载 .env.local 配置...');
|
||||||
|
const envConfig = fs.readFileSync(envPath, 'utf8');
|
||||||
|
envConfig.split('\n').forEach((line) => {
|
||||||
|
const parts = line.split('=');
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
const key = parts[0].trim();
|
||||||
|
const value = parts.slice(1).join('=').trim();
|
||||||
|
if (key && value && !key.startsWith('#')) {
|
||||||
|
process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn('⚠️ 未找到 .env.local 文件,尝试使用系统环境变量...');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadEnv();
|
||||||
|
|
||||||
|
const MONGODB_URI = process.env.MONGODB_URI;
|
||||||
|
|
||||||
|
if (!MONGODB_URI) {
|
||||||
|
console.error('❌ 错误: MONGODB_URI 未定义,请检查 .env.local 文件');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// 2. 辅助函数
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
const getRandomInt = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
|
const getRandomItem = <T>(arr: T[]): T => arr[Math.floor(Math.random() * arr.length)];
|
||||||
|
const getRandomItems = <T>(arr: T[], count: number): T[] => {
|
||||||
|
const shuffled = [...arr].sort(() => 0.5 - Math.random());
|
||||||
|
return shuffled.slice(0, count);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// 3. 主逻辑
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
const seed = async () => {
|
||||||
|
try {
|
||||||
|
console.log('🔌 正在连接数据库...');
|
||||||
|
await mongoose.connect(MONGODB_URI);
|
||||||
|
console.log('✅ 数据库连接成功');
|
||||||
|
|
||||||
|
console.log('🧹 正在清理旧数据...');
|
||||||
|
await Promise.all([
|
||||||
|
User.deleteMany({}),
|
||||||
|
Article.deleteMany({}),
|
||||||
|
SystemConfig.deleteMany({}),
|
||||||
|
Comment.deleteMany({}),
|
||||||
|
Order.deleteMany({}),
|
||||||
|
MembershipPlan.deleteMany({}),
|
||||||
|
Category.deleteMany({}),
|
||||||
|
Tag.deleteMany({})
|
||||||
|
]);
|
||||||
|
console.log('✅ 旧数据清理完成');
|
||||||
|
|
||||||
|
// --------------------------------------------------------------
|
||||||
|
// 4. 创建基础数据
|
||||||
|
// --------------------------------------------------------------
|
||||||
|
|
||||||
|
// --- 4.1 系统配置 ---
|
||||||
|
console.log('⚙️ 创建系统配置...');
|
||||||
|
await SystemConfig.create({
|
||||||
|
配置标识: 'default',
|
||||||
|
站点设置: {
|
||||||
|
网站标题: 'AOUN - 独立开发者资源站',
|
||||||
|
网站副标题: '构建你的数字资产',
|
||||||
|
Logo地址: '/LOGO.png',
|
||||||
|
全局SEO关键词: 'React, Next.js, AI, 独立开发, 源码下载',
|
||||||
|
全局SEO描述: 'AOUN 是一个专注于分享高质量编程资源、AI 实战教程和独立开发经验的平台。',
|
||||||
|
底部版权信息: '© 2025 AounApp. All Rights Reserved.'
|
||||||
|
},
|
||||||
|
Banner配置: [
|
||||||
|
{
|
||||||
|
标题: 'Next.js 15 全栈实战',
|
||||||
|
描述: '深入探索 Next.js 15 的最新特性,从 App Router 到 Server Actions,构建高性能全栈应用。',
|
||||||
|
图片地址: 'https://images.unsplash.com/photo-1618477247222-ac59124545da?q=80&w=2070&auto=format&fit=crop',
|
||||||
|
按钮文本: '立即学习',
|
||||||
|
按钮链接: '/article/nextjs-15-fullstack',
|
||||||
|
状态: 'visible'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
标题: 'AI Agent 开发指南',
|
||||||
|
描述: '掌握 LLM 应用开发核心,使用 LangChain 和 OpenAI 构建智能体。',
|
||||||
|
图片地址: 'https://images.unsplash.com/photo-1677442136019-21780ecad995?q=80&w=2070&auto=format&fit=crop',
|
||||||
|
按钮文本: '查看教程',
|
||||||
|
按钮链接: '/article/ai-agent-guide',
|
||||||
|
状态: 'visible'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
标题: '独立开发变现之路',
|
||||||
|
描述: '分享从 0 到 1 的产品构建、推广与商业化经验。',
|
||||||
|
图片地址: 'https://images.unsplash.com/photo-1553729459-efe14ef6055d?q=80&w=2070&auto=format&fit=crop',
|
||||||
|
按钮文本: '阅读专栏',
|
||||||
|
按钮链接: '/category/indie-hacker',
|
||||||
|
状态: 'visible'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
AI配置列表: [
|
||||||
|
{
|
||||||
|
名称: '写作助手-Gemini',
|
||||||
|
接口地址: 'https://generativelanguage.googleapis.com/v1beta/openai',
|
||||||
|
API密钥: process.env.GEMINI_API_KEY || 'dummy-key',
|
||||||
|
模型: 'gemini-1.5-flash',
|
||||||
|
系统提示词: '你是一个专业的技术写作助手,擅长撰写清晰、有深度的技术文章。',
|
||||||
|
是否启用: true,
|
||||||
|
流式传输: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 4.2 会员套餐 ---
|
||||||
|
console.log('💎 创建会员套餐...');
|
||||||
|
const plans = await MembershipPlan.create([
|
||||||
|
{
|
||||||
|
套餐名称: '月度会员',
|
||||||
|
有效天数: 30,
|
||||||
|
价格: 29.9,
|
||||||
|
描述: '适合短期突击学习,畅享一个月无限下载',
|
||||||
|
特权配置: { 每日下载限制: 20, 购买折扣: 0.9 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
套餐名称: '年度会员',
|
||||||
|
有效天数: 365,
|
||||||
|
价格: 299,
|
||||||
|
描述: '超值推荐,全年无忧学习,低至 0.8 元/天',
|
||||||
|
特权配置: { 每日下载限制: 100, 购买折扣: 0.7 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
套餐名称: '永久会员',
|
||||||
|
有效天数: 36500,
|
||||||
|
价格: 999,
|
||||||
|
描述: '一次付费,终身受益,尊享所有未来更新',
|
||||||
|
特权配置: { 每日下载限制: 9999, 购买折扣: 0.5 }
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
// --- 4.3 分类与标签 ---
|
||||||
|
console.log('🏷️ 创建分类与标签...');
|
||||||
|
const categories = await Category.create([
|
||||||
|
{ 分类名称: '全栈开发', 别名: 'fullstack', 排序权重: 10 },
|
||||||
|
{ 分类名称: 'AI 工程化', 别名: 'ai-engineering', 排序权重: 9 },
|
||||||
|
{ 分类名称: '系统设计', 别名: 'system-design', 排序权重: 8 },
|
||||||
|
{ 分类名称: '独立开发', 别名: 'indie-hacker', 排序权重: 7 },
|
||||||
|
{ 分类名称: '源码资源', 别名: 'resources', 排序权重: 6 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const tags = await Tag.create([
|
||||||
|
{ 标签名称: 'Next.js' }, { 标签名称: 'React' }, { 标签名称: 'TypeScript' },
|
||||||
|
{ 标签名称: 'TailwindCSS' }, { 标签名称: 'Node.js' }, { 标签名称: 'PostgreSQL' },
|
||||||
|
{ 标签名称: 'MongoDB' }, { 标签名称: 'Docker' }, { 标签名称: 'LangChain' },
|
||||||
|
{ 标签名称: 'OpenAI' }, { 标签名称: 'RAG' }, { 标签名称: 'SaaS' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// --- 4.4 用户 ---
|
||||||
|
console.log('👥 创建用户...');
|
||||||
|
|
||||||
|
// 加密密码
|
||||||
|
const adminPassword = await bcrypt.hash('admin@aoun.ltd', 10);
|
||||||
|
const userPassword = await bcrypt.hash('123456', 10);
|
||||||
|
|
||||||
|
const adminUser = await User.create({
|
||||||
|
用户名: 'Admin',
|
||||||
|
邮箱: 'admin@aoun.ltd',
|
||||||
|
密码: adminPassword,
|
||||||
|
角色: 'admin',
|
||||||
|
头像: 'https://api.dicebear.com/7.x/avataaars/svg?seed=admin',
|
||||||
|
钱包: { 当前积分: 99999, 历史总消费: 0 }
|
||||||
|
});
|
||||||
|
|
||||||
|
const demoUsers = await User.create([
|
||||||
|
{
|
||||||
|
用户名: 'Alex',
|
||||||
|
邮箱: 'alex@example.com',
|
||||||
|
密码: userPassword,
|
||||||
|
角色: 'user',
|
||||||
|
头像: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Alex',
|
||||||
|
钱包: { 当前积分: 50, 历史总消费: 100 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
用户名: 'Sarah',
|
||||||
|
邮箱: 'sarah@example.com',
|
||||||
|
密码: userPassword,
|
||||||
|
角色: 'user',
|
||||||
|
头像: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Sarah',
|
||||||
|
会员信息: {
|
||||||
|
当前等级ID: plans[1]._id, // 年度会员
|
||||||
|
过期时间: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
用户名: 'Developer',
|
||||||
|
邮箱: 'dev@example.com',
|
||||||
|
密码: userPassword,
|
||||||
|
角色: 'editor',
|
||||||
|
头像: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Dev'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const allUsers = [adminUser, ...demoUsers];
|
||||||
|
|
||||||
|
// --- 4.5 文章/资源 ---
|
||||||
|
console.log('📚 创建文章与资源...');
|
||||||
|
const articlesData = [
|
||||||
|
{
|
||||||
|
title: 'Next.js 15 App Router 深度解析与最佳实践',
|
||||||
|
summary: '全面解析 Next.js 15 的 App Router 架构,探讨 Server Components、Server Actions 以及流式渲染的最佳实践。',
|
||||||
|
content: `
|
||||||
|
## 引言
|
||||||
|
|
||||||
|
Next.js 15 带来了革命性的变化,App Router 彻底改变了我们构建 React 应用的方式。本文将带你深入了解其核心概念。
|
||||||
|
|
||||||
|
## Server Components
|
||||||
|
|
||||||
|
React Server Components (RSC) 允许我们在服务器端渲染组件,减少发送到客户端的 JavaScript 体积。
|
||||||
|
|
||||||
|
\`\`\`tsx
|
||||||
|
async function GetData() {
|
||||||
|
const res = await fetch('https://api.example.com/data');
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Page() {
|
||||||
|
const data = await GetData();
|
||||||
|
return <main>{data.title}</main>;
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## Server Actions
|
||||||
|
|
||||||
|
不再需要单独的 API 路由,直接在组件中定义服务器端逻辑。
|
||||||
|
|
||||||
|
## 总结
|
||||||
|
|
||||||
|
掌握 App Router 是成为现代 React 全栈开发者的关键。
|
||||||
|
`,
|
||||||
|
category: '全栈开发',
|
||||||
|
tags: ['Next.js', 'React', 'TypeScript'],
|
||||||
|
isPaid: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '构建企业级 RAG 应用:从原理到实战',
|
||||||
|
summary: '基于 LangChain 和 Vector Database,手把手教你搭建一个能够理解私有数据的 AI 知识库问答系统。',
|
||||||
|
content: `
|
||||||
|
## 什么是 RAG?
|
||||||
|
|
||||||
|
检索增强生成 (Retrieval-Augmented Generation) 是解决 LLM 幻觉和知识过时的有效方案。
|
||||||
|
|
||||||
|
## 架构设计
|
||||||
|
|
||||||
|
1. **文档加载与切分**:使用 LangChain Loader。
|
||||||
|
2. **向量化 (Embedding)**:使用 OpenAI Embedding 模型。
|
||||||
|
3. **向量数据库**:使用 Pinecone 或 Milvus。
|
||||||
|
4. **检索与生成**:构建 Prompt,结合检索结果和 LLM。
|
||||||
|
|
||||||
|
## 代码实现
|
||||||
|
|
||||||
|
\`\`\`python
|
||||||
|
from langchain.chains import RetrievalQA
|
||||||
|
from langchain.llms import OpenAI
|
||||||
|
|
||||||
|
qa = RetrievalQA.from_chain_type(
|
||||||
|
llm=OpenAI(),
|
||||||
|
chain_type="stuff",
|
||||||
|
retriever=docsearch.as_retriever()
|
||||||
|
)
|
||||||
|
\`\`\`
|
||||||
|
`,
|
||||||
|
category: 'AI 工程化',
|
||||||
|
tags: ['LangChain', 'RAG', 'OpenAI'],
|
||||||
|
isPaid: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'SaaS 产品设计与系统架构指南',
|
||||||
|
summary: '如何设计一个高可用、多租户的 SaaS 系统?本文涵盖数据库设计、权限管理及计费系统实现。',
|
||||||
|
content: `
|
||||||
|
## 多租户架构
|
||||||
|
|
||||||
|
选择合适的隔离策略:
|
||||||
|
- **Database per Tenant**:最高隔离性,成本高。
|
||||||
|
- **Schema per Tenant**:平衡方案。
|
||||||
|
- **Discriminator Column**:共享表,成本最低,开发复杂度高。
|
||||||
|
|
||||||
|
## 权限系统 (RBAC)
|
||||||
|
|
||||||
|
设计灵活的角色和权限模型是 SaaS 的核心。
|
||||||
|
|
||||||
|
## 计费集成
|
||||||
|
|
||||||
|
集成 Stripe 或 Lemon Squeezy 实现订阅支付。
|
||||||
|
`,
|
||||||
|
category: '系统设计',
|
||||||
|
tags: ['SaaS', 'System Design', 'PostgreSQL'],
|
||||||
|
isPaid: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '独立开发者如何寻找 Profitable Niche',
|
||||||
|
summary: '避开红海竞争,发现细分市场的机会。分享 5 个验证产品构想的低成本方法。',
|
||||||
|
content: `
|
||||||
|
## 寻找痛点
|
||||||
|
|
||||||
|
最好的产品往往源于开发者自己的痛点。
|
||||||
|
|
||||||
|
## 验证想法
|
||||||
|
|
||||||
|
1. **Landing Page MVP**:先做落地页,收集邮箱。
|
||||||
|
2. **Pre-sale**:尝试预售。
|
||||||
|
3. **社区反馈**:在 Reddit 或 Product Hunt 发布。
|
||||||
|
|
||||||
|
## 案例分析
|
||||||
|
|
||||||
|
分析几个成功的独立开发产品案例...
|
||||||
|
`,
|
||||||
|
category: '独立开发',
|
||||||
|
tags: ['Indie Hacker', 'SaaS'],
|
||||||
|
isPaid: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'React 19 Hook 使用指南',
|
||||||
|
summary: 'React 19 带来了 use()、useOptimistic() 等新 Hook,本文详细介绍它们的用法和场景。',
|
||||||
|
content: `
|
||||||
|
## use() Hook
|
||||||
|
|
||||||
|
\`use()\` 是一个新的 API,用于在组件中读取 Promise 或 Context 的值。
|
||||||
|
|
||||||
|
\`\`\`tsx
|
||||||
|
import { use } from 'react';
|
||||||
|
|
||||||
|
function Message({ messagePromise }) {
|
||||||
|
const message = use(messagePromise);
|
||||||
|
return <p>{message}</p>;
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## useOptimistic
|
||||||
|
|
||||||
|
用于处理乐观 UI 更新,提升用户体验。
|
||||||
|
`,
|
||||||
|
category: '全栈开发',
|
||||||
|
tags: ['React', 'Next.js'],
|
||||||
|
isPaid: false
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const articles = [];
|
||||||
|
for (let i = 0; i < articlesData.length; i++) {
|
||||||
|
const data = articlesData[i];
|
||||||
|
const cat = categories.find(c => c.分类名称 === data.category) || categories[0];
|
||||||
|
const articleTags = tags.filter(t => data.tags.includes(t.标签名称));
|
||||||
|
const author = adminUser; // Admin 发布文章
|
||||||
|
|
||||||
|
articles.push({
|
||||||
|
文章标题: data.title,
|
||||||
|
URL别名: `article-${i + 1}`,
|
||||||
|
封面图: `https://picsum.photos/seed/${i + 100}/800/450`,
|
||||||
|
摘要: data.summary,
|
||||||
|
正文内容: data.content,
|
||||||
|
SEO关键词: [data.category, ...data.tags],
|
||||||
|
SEO描述: data.summary,
|
||||||
|
作者ID: author._id,
|
||||||
|
分类ID: cat._id,
|
||||||
|
标签ID列表: articleTags.map(t => t._id),
|
||||||
|
价格: data.isPaid ? 29.9 : 0,
|
||||||
|
支付方式: 'points',
|
||||||
|
资源属性: data.isPaid ? {
|
||||||
|
下载链接: 'https://pan.baidu.com/s/demo-link',
|
||||||
|
提取码: '8888',
|
||||||
|
解压密码: 'aounapp.com'
|
||||||
|
} : {},
|
||||||
|
统计数据: {
|
||||||
|
阅读数: getRandomInt(100, 5000),
|
||||||
|
点赞数: getRandomInt(10, 500),
|
||||||
|
评论数: 0,
|
||||||
|
收藏数: getRandomInt(5, 200)
|
||||||
|
},
|
||||||
|
发布状态: 'published'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdArticles = await Article.insertMany(articles);
|
||||||
|
|
||||||
|
// --- 4.6 评论 ---
|
||||||
|
console.log('💬 创建评论...');
|
||||||
|
const comments = [];
|
||||||
|
for (const article of createdArticles) {
|
||||||
|
const commentCount = getRandomInt(0, 3);
|
||||||
|
for (let j = 0; j < commentCount; j++) {
|
||||||
|
const user = getRandomItem(demoUsers);
|
||||||
|
comments.push({
|
||||||
|
文章ID: article._id,
|
||||||
|
用户ID: user._id,
|
||||||
|
评论内容: `这篇文章写得很好,对我帮助很大!期待更多关于 ${article.SEO关键词[0]} 的内容。`,
|
||||||
|
点赞数: getRandomInt(0, 10),
|
||||||
|
状态: 'visible'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Comment.insertMany(comments);
|
||||||
|
|
||||||
|
// 更新文章评论数
|
||||||
|
for (const article of createdArticles) {
|
||||||
|
const count = comments.filter(c => c.文章ID === article._id).length;
|
||||||
|
await Article.findByIdAndUpdate(article._id, { '统计数据.评论数': count });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4.7 订单 ---
|
||||||
|
console.log('🧾 创建订单...');
|
||||||
|
const orders = [];
|
||||||
|
for (let k = 0; k < 3; k++) {
|
||||||
|
const user = getRandomItem(demoUsers);
|
||||||
|
const plan = getRandomItem(plans);
|
||||||
|
orders.push({
|
||||||
|
订单号: `ORDER${Date.now()}${k}`,
|
||||||
|
用户ID: user._id,
|
||||||
|
订单类型: 'buy_membership',
|
||||||
|
商品ID: plan._id,
|
||||||
|
商品快照: {
|
||||||
|
标题: plan.套餐名称,
|
||||||
|
封面: '/images/membership.png'
|
||||||
|
},
|
||||||
|
支付金额: plan.价格,
|
||||||
|
支付方式: 'alipay',
|
||||||
|
订单状态: 'paid',
|
||||||
|
支付时间: new Date()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await Order.insertMany(orders);
|
||||||
|
|
||||||
|
console.log('✨ 数据库填充完成!');
|
||||||
|
console.log(`
|
||||||
|
📊 数据统计:
|
||||||
|
- 用户: ${allUsers.length} (管理员: admin@aoun.ltd / admin@aoun.ltd)
|
||||||
|
- 文章: ${createdArticles.length}
|
||||||
|
- 评论: ${comments.length}
|
||||||
|
- 订单: ${orders.length}
|
||||||
|
- 分类: ${categories.length}
|
||||||
|
- 标签: ${tags.length}
|
||||||
|
- 会员套餐: ${plans.length}
|
||||||
|
`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ 数据库填充失败:', error);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
await mongoose.disconnect();
|
||||||
|
console.log('👋 数据库连接已断开');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
seed();
|
||||||
160
src/components/admin/AdminLayout.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Users,
|
||||||
|
FileText,
|
||||||
|
Settings,
|
||||||
|
LogOut,
|
||||||
|
Menu,
|
||||||
|
ShoppingBag,
|
||||||
|
MessageSquare,
|
||||||
|
CreditCard,
|
||||||
|
Image,
|
||||||
|
Bot
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface AdminLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidebarItems = [
|
||||||
|
{ icon: LayoutDashboard, label: '仪表盘', href: '/admin' },
|
||||||
|
{ icon: Users, label: '用户管理', href: '/admin/users' },
|
||||||
|
{ icon: FileText, label: '资源管理', href: '/admin/articles' },
|
||||||
|
{ icon: MessageSquare, label: '评论管理', href: '/admin/comments' },
|
||||||
|
{ icon: ShoppingBag, label: '订单管理', href: '/admin/orders' },
|
||||||
|
{ icon: Image, label: 'Banner 管理', href: '/admin/banners' },
|
||||||
|
{ icon: Bot, label: 'AI 助手', href: '/admin/ai' },
|
||||||
|
{ icon: Settings, label: '系统设置', href: '/admin/settings' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AdminLayout({ children }: AdminLayoutProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isSidebarOpen, setIsSidebarOpen] = React.useState(true);
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
// 简单的清除 Cookie 逻辑,实际可能需要调用 API
|
||||||
|
document.cookie = 'token=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
|
||||||
|
router.push('/auth/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close sidebar on route change (mobile only)
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleRouteChange = () => {
|
||||||
|
if (window.innerWidth < 1024) {
|
||||||
|
setIsSidebarOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
router.events.on('routeChangeStart', handleRouteChange);
|
||||||
|
return () => {
|
||||||
|
router.events.off('routeChangeStart', handleRouteChange);
|
||||||
|
};
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-screen bg-gray-100 flex overflow-hidden">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
"bg-white border-r border-gray-200 shrink-0 w-64 flex flex-col transition-transform duration-300 ease-in-out absolute inset-y-0 left-0 z-50 lg:static lg:translate-x-0",
|
||||||
|
!isSidebarOpen && "-translate-x-full lg:hidden"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="h-16 flex items-center justify-center border-b border-gray-200 shrink-0">
|
||||||
|
<h1 className="text-xl font-bold text-primary">Aoun Admin</h1>
|
||||||
|
</div>
|
||||||
|
<nav className="flex-1 overflow-y-auto p-4 space-y-2">
|
||||||
|
{sidebarItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive = router.pathname === item.href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Operations Section */}
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||||
|
<h3 className="px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
|
||||||
|
运营管理
|
||||||
|
</h3>
|
||||||
|
<Link
|
||||||
|
href="/admin/plans"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors",
|
||||||
|
router.pathname.startsWith('/admin/plans')
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CreditCard className="w-5 h-5" />
|
||||||
|
套餐管理
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<div className="p-4 border-t border-gray-200 shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full justify-start text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||||
|
onClick={handleLogout}
|
||||||
|
>
|
||||||
|
<LogOut className="w-5 h-5 mr-2" />
|
||||||
|
退出登录
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main Content Wrapper */}
|
||||||
|
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="bg-white border-b border-gray-200 h-16 flex items-center justify-between px-6 lg:px-8 shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="lg:hidden"
|
||||||
|
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||||
|
>
|
||||||
|
<Menu className="w-6 h-6" />
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-4 ml-auto">
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
欢迎回来,<span className="font-semibold text-gray-900">管理员</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-8 h-8 rounded-full bg-gray-200 overflow-hidden">
|
||||||
|
<img src="https://api.dicebear.com/7.x/avataaars/svg?seed=admin" alt="Avatar" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Scrollable Page Content */}
|
||||||
|
<main className="flex-1 overflow-y-auto p-6 lg:p-8">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overlay for mobile sidebar */}
|
||||||
|
{isSidebarOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
||||||
|
onClick={() => setIsSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
377
src/components/admin/ArticleEditor.tsx
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Loader2, Save, ArrowLeft, Globe, Lock, Image as ImageIcon, FileText, DollarSign, Settings } from 'lucide-react';
|
||||||
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
|
import TiptapEditor from './TiptapEditor';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface Category {
|
||||||
|
_id: string;
|
||||||
|
分类名称: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Tag {
|
||||||
|
_id: string;
|
||||||
|
标签名称: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArticleEditorProps {
|
||||||
|
mode: 'create' | 'edit';
|
||||||
|
articleId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ArticleEditor({ mode, articleId }: ArticleEditorProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [loading, setLoading] = useState(mode === 'edit');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
const [tags, setTags] = useState<Tag[]>([]);
|
||||||
|
|
||||||
|
|
||||||
|
const { register, handleSubmit, control, reset, setValue, watch } = useForm({
|
||||||
|
defaultValues: {
|
||||||
|
文章标题: '',
|
||||||
|
URL别名: '',
|
||||||
|
封面图: '',
|
||||||
|
摘要: '',
|
||||||
|
正文内容: '',
|
||||||
|
SEO关键词: '',
|
||||||
|
SEO描述: '',
|
||||||
|
分类ID: '',
|
||||||
|
标签ID列表: [] as string[],
|
||||||
|
价格: 0,
|
||||||
|
支付方式: 'points',
|
||||||
|
资源属性: {
|
||||||
|
下载链接: '',
|
||||||
|
提取码: '',
|
||||||
|
解压密码: '',
|
||||||
|
隐藏内容: ''
|
||||||
|
},
|
||||||
|
发布状态: 'published'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDependencies();
|
||||||
|
if (mode === 'edit' && articleId) {
|
||||||
|
fetchArticle();
|
||||||
|
}
|
||||||
|
}, [mode, articleId]);
|
||||||
|
|
||||||
|
const fetchDependencies = async () => {
|
||||||
|
try {
|
||||||
|
const [catRes, tagRes] = await Promise.all([
|
||||||
|
fetch('/api/admin/categories'),
|
||||||
|
fetch('/api/admin/tags')
|
||||||
|
]);
|
||||||
|
if (catRes.ok) setCategories(await catRes.json());
|
||||||
|
if (tagRes.ok) setTags(await tagRes.json());
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch dependencies', error);
|
||||||
|
toast.error('加载分类标签失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchArticle = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/articles/${articleId}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
const formData = {
|
||||||
|
...data,
|
||||||
|
分类ID: data.分类ID?._id || '',
|
||||||
|
标签ID列表: data.标签ID列表?.map((t: any) => t._id) || [],
|
||||||
|
SEO关键词: data.SEO关键词?.join(',') || ''
|
||||||
|
};
|
||||||
|
reset(formData);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
toast.error('加载文章失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch article', error);
|
||||||
|
toast.error('加载文章出错');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (data: any) => {
|
||||||
|
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
...data,
|
||||||
|
SEO关键词: data.SEO关键词.split(/[,,]/).map((k: string) => k.trim()).filter(Boolean),
|
||||||
|
价格: Number(data.价格)
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = mode === 'edit' ? `/api/admin/articles/${articleId}` : '/api/admin/articles';
|
||||||
|
const method = mode === 'edit' ? 'PUT' : 'POST';
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success('保存成功');
|
||||||
|
router.push('/admin/articles');
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
toast.error(`保存失败: ${err.message || '未知错误'}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save article', error);
|
||||||
|
toast.error('保存出错');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full min-h-[500px]">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-[calc(100vh-4rem)] flex flex-col bg-background">
|
||||||
|
{/* Top Bar */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-3 border-b bg-background shrink-0 z-10">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{mode === 'edit' ? '编辑文章' : '撰写新文章'}
|
||||||
|
</span>
|
||||||
|
{watch('发布状态') === 'published' && (
|
||||||
|
<span className="flex items-center text-xs text-green-600 bg-green-50 px-2 py-0.5 rounded-full border border-green-100">
|
||||||
|
<Globe className="w-3 h-3 mr-1" /> 已发布
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button onClick={handleSubmit(onSubmit)} disabled={saving}>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
保存中...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
{mode === 'edit' ? '更新' : '发布'}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Editor Area */}
|
||||||
|
<div className="flex-1 flex overflow-hidden">
|
||||||
|
{/* Left: Content Editor */}
|
||||||
|
<div className="flex-1 overflow-y-auto bg-gray-50/50">
|
||||||
|
<div className="max-w-6xl mx-auto py-8 px-12 min-h-full bg-white shadow-sm border-x border-gray-100">
|
||||||
|
{/* Title Input */}
|
||||||
|
<Input
|
||||||
|
{...register('文章标题', { required: true })}
|
||||||
|
className="text-4xl font-bold border-none shadow-none bg-transparent px-0 focus-visible:ring-0 placeholder:text-gray-300 h-auto py-4 mb-4"
|
||||||
|
placeholder="请输入文章标题..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Tiptap Editor */}
|
||||||
|
<Controller
|
||||||
|
name="正文内容"
|
||||||
|
control={control}
|
||||||
|
rules={{ required: true }}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TiptapEditor
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Sidebar Settings */}
|
||||||
|
<div className="w-[400px] border-l bg-white overflow-y-auto p-6 space-y-8 shrink-0">
|
||||||
|
{/* Publishing */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="font-semibold text-sm text-gray-900 flex items-center gap-2">
|
||||||
|
<Settings className="w-4 h-4" /> 发布设置
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">状态</Label>
|
||||||
|
<Controller
|
||||||
|
name="发布状态"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="published">立即发布</SelectItem>
|
||||||
|
<SelectItem value="draft">草稿</SelectItem>
|
||||||
|
<SelectItem value="offline">下架</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">URL 别名</Label>
|
||||||
|
<Input {...register('URL别名')} className="h-8 text-sm" placeholder="slug-url" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-px bg-gray-100" />
|
||||||
|
|
||||||
|
{/* Organization */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="font-semibold text-sm text-gray-900 flex items-center gap-2">
|
||||||
|
<FileText className="w-4 h-4" /> 分类与标签
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">分类</Label>
|
||||||
|
<Controller
|
||||||
|
name="分类ID"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="选择分类" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{categories.map(cat => (
|
||||||
|
<SelectItem key={cat._id} value={cat._id}>{cat.分类名称}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">封面图 URL</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input {...register('封面图')} className="h-8 text-sm" placeholder="https://..." />
|
||||||
|
<Button size="icon" variant="outline" className="h-8 w-8 shrink-0">
|
||||||
|
<ImageIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-px bg-gray-100" />
|
||||||
|
|
||||||
|
{/* Resource Delivery */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="font-semibold text-sm flex items-center gap-2 text-gray-900">
|
||||||
|
<Lock className="w-4 h-4" /> 资源交付
|
||||||
|
</h3>
|
||||||
|
<div className="p-4 bg-gray-50 rounded-lg space-y-4 border border-gray-100">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">下载链接</Label>
|
||||||
|
<Input {...register('资源属性.下载链接')} className="h-8 text-sm bg-white" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">提取码</Label>
|
||||||
|
<Input {...register('资源属性.提取码')} className="h-8 text-sm bg-white" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">解压密码</Label>
|
||||||
|
<Input {...register('资源属性.解压密码')} className="h-8 text-sm bg-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-px bg-gray-100" />
|
||||||
|
|
||||||
|
{/* Sales */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="font-semibold text-sm text-gray-900 flex items-center gap-2">
|
||||||
|
<DollarSign className="w-4 h-4" /> 售卖策略
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">支付方式</Label>
|
||||||
|
<Controller
|
||||||
|
name="支付方式"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select onValueChange={field.onChange} value={field.value}>
|
||||||
|
<SelectTrigger className="h-8 text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="points">积分支付</SelectItem>
|
||||||
|
<SelectItem value="cash">现金支付 (CNY)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">
|
||||||
|
价格 ({watch('支付方式') === 'points' ? '积分' : '元'})
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
{...register('价格')}
|
||||||
|
className="h-8 text-sm"
|
||||||
|
min="0"
|
||||||
|
step={watch('支付方式') === 'cash' ? "0.01" : "1"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-px bg-gray-100" />
|
||||||
|
|
||||||
|
{/* SEO */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="font-semibold text-sm text-gray-900">SEO 设置</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">摘要</Label>
|
||||||
|
<Textarea {...register('摘要')} className="text-sm min-h-[80px]" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">关键词</Label>
|
||||||
|
<Input {...register('SEO关键词')} className="h-8 text-sm" placeholder="逗号分隔" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
539
src/components/admin/TiptapEditor.tsx
Normal file
@@ -0,0 +1,539 @@
|
|||||||
|
import { useEditor, EditorContent } from '@tiptap/react';
|
||||||
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
|
import { Markdown as TiptapMarkdown } from 'tiptap-markdown';
|
||||||
|
import Link from '@tiptap/extension-link';
|
||||||
|
import Image from '@tiptap/extension-image';
|
||||||
|
import Placeholder from '@tiptap/extension-placeholder';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Bold,
|
||||||
|
Italic,
|
||||||
|
Strikethrough,
|
||||||
|
List,
|
||||||
|
ListOrdered,
|
||||||
|
Heading1,
|
||||||
|
Heading2,
|
||||||
|
Heading3,
|
||||||
|
Quote,
|
||||||
|
Code,
|
||||||
|
Link as LinkIcon,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Undo,
|
||||||
|
Redo,
|
||||||
|
Sparkles,
|
||||||
|
Loader2,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
RefreshCw,
|
||||||
|
Wand2,
|
||||||
|
FileText,
|
||||||
|
Minimize2,
|
||||||
|
Maximize2,
|
||||||
|
Languages,
|
||||||
|
MessageSquare,
|
||||||
|
PenLine,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useEffect, useState, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { unified } from 'unified';
|
||||||
|
import remarkParse from 'remark-parse';
|
||||||
|
import remarkRehype from 'remark-rehype';
|
||||||
|
import rehypeStringify from 'rehype-stringify';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface TiptapEditorProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Toolbar = ({ editor, onAiClick }: { editor: any, onAiClick: () => void }) => {
|
||||||
|
// Removed unused state and handleAiGenerate logic
|
||||||
|
if (!editor) return null;
|
||||||
|
|
||||||
|
const addLink = () => {
|
||||||
|
const url = window.prompt('URL');
|
||||||
|
if (url) {
|
||||||
|
editor.chain().focus().setLink({ href: url }).run();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addImage = () => {
|
||||||
|
const url = window.prompt('Image URL');
|
||||||
|
if (url) {
|
||||||
|
editor.chain().focus().setImage({ src: url }).run();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ToggleButton = ({ isActive, onClick, children, disabled = false, className }: any) => (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn("h-8 w-8 p-0", isActive ? "bg-muted text-primary" : "text-muted-foreground hover:text-primary", className)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b bg-gray-50/50 p-2 flex flex-wrap gap-1 sticky top-0 z-10 items-center">
|
||||||
|
<Button variant="outline" size="sm" className="mr-2 h-8 gap-1 text-purple-600 border-purple-200 hover:bg-purple-50 hover:text-purple-700" onClick={onAiClick}>
|
||||||
|
<Sparkles className="w-3.5 h-3.5" />
|
||||||
|
<span className="text-xs font-medium">AI 写作</span>
|
||||||
|
</Button>
|
||||||
|
<div className="w-px h-6 bg-gray-200 mx-1 self-center" />
|
||||||
|
<ToggleButton isActive={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}><Bold className="h-4 w-4" /></ToggleButton>
|
||||||
|
<ToggleButton isActive={editor.isActive('italic')} onClick={() => editor.chain().focus().toggleItalic().run()}><Italic className="h-4 w-4" /></ToggleButton>
|
||||||
|
<ToggleButton isActive={editor.isActive('strike')} onClick={() => editor.chain().focus().toggleStrike().run()}><Strikethrough className="h-4 w-4" /></ToggleButton>
|
||||||
|
<div className="w-px h-6 bg-gray-200 mx-1 self-center" />
|
||||||
|
<ToggleButton isActive={editor.isActive('heading', { level: 1 })} onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}><Heading1 className="h-4 w-4" /></ToggleButton>
|
||||||
|
<ToggleButton isActive={editor.isActive('heading', { level: 2 })} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}><Heading2 className="h-4 w-4" /></ToggleButton>
|
||||||
|
<ToggleButton isActive={editor.isActive('heading', { level: 3 })} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}><Heading3 className="h-4 w-4" /></ToggleButton>
|
||||||
|
<div className="w-px h-6 bg-gray-200 mx-1 self-center" />
|
||||||
|
<ToggleButton isActive={editor.isActive('bulletList')} onClick={() => editor.chain().focus().toggleBulletList().run()}><List className="h-4 w-4" /></ToggleButton>
|
||||||
|
<ToggleButton isActive={editor.isActive('orderedList')} onClick={() => editor.chain().focus().toggleOrderedList().run()}><ListOrdered className="h-4 w-4" /></ToggleButton>
|
||||||
|
<div className="w-px h-6 bg-gray-200 mx-1 self-center" />
|
||||||
|
<ToggleButton isActive={editor.isActive('blockquote')} onClick={() => editor.chain().focus().toggleBlockquote().run()}><Quote className="h-4 w-4" /></ToggleButton>
|
||||||
|
<ToggleButton isActive={editor.isActive('codeBlock')} onClick={() => editor.chain().focus().toggleCodeBlock().run()}><Code className="h-4 w-4" /></ToggleButton>
|
||||||
|
<div className="w-px h-6 bg-gray-200 mx-1 self-center" />
|
||||||
|
<ToggleButton onClick={addLink} isActive={editor.isActive('link')}><LinkIcon className="h-4 w-4" /></ToggleButton>
|
||||||
|
<ToggleButton onClick={addImage}><ImageIcon className="h-4 w-4" /></ToggleButton>
|
||||||
|
<div className="w-px h-6 bg-gray-200 mx-1 self-center" />
|
||||||
|
<ToggleButton onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()}><Undo className="h-4 w-4" /></ToggleButton>
|
||||||
|
<ToggleButton onClick={() => editor.chain().focus().redo().run()} disabled={!editor.can().redo()}><Redo className="h-4 w-4" /></ToggleButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TiptapEditor({ value, onChange, className }: TiptapEditorProps) {
|
||||||
|
const [isAiGenerating, setIsAiGenerating] = useState(false);
|
||||||
|
const [assistants, setAssistants] = useState<any[]>([]);
|
||||||
|
const [aiInputOpen, setAiInputOpen] = useState(false);
|
||||||
|
const [aiInputPos, setAiInputPos] = useState({ top: 0, left: 0 });
|
||||||
|
const [sideToolPos, setSideToolPos] = useState({ top: 0, left: 0, visible: false });
|
||||||
|
const [bubbleMenuPos, setBubbleMenuPos] = useState({ top: 0, left: 0, visible: false });
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/admin/settings').then(res => res.json()).then(data => {
|
||||||
|
if (data.AI配置列表) {
|
||||||
|
setAssistants(data.AI配置列表.filter((a: any) => a.是否启用));
|
||||||
|
}
|
||||||
|
}).catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
immediatelyRender: false,
|
||||||
|
extensions: [
|
||||||
|
StarterKit.configure({ heading: { levels: [1, 2, 3] } }) as any,
|
||||||
|
TiptapMarkdown,
|
||||||
|
Link.configure({ openOnClick: false, HTMLAttributes: { class: 'text-primary underline' } }),
|
||||||
|
Image.configure({ HTMLAttributes: { class: 'rounded-lg max-w-full' } }),
|
||||||
|
Placeholder.configure({ placeholder: '开始撰写精彩内容...' }),
|
||||||
|
],
|
||||||
|
content: value,
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
class: 'prose prose-lg max-w-none focus:outline-none min-h-[500px] p-8 pl-16', // Added padding-left for side tool
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onUpdate: ({ editor }) => {
|
||||||
|
const markdown = (editor.storage as any).markdown.getMarkdown();
|
||||||
|
onChange(markdown);
|
||||||
|
updateSideTool(editor);
|
||||||
|
updateBubbleMenu(editor);
|
||||||
|
},
|
||||||
|
onSelectionUpdate: ({ editor }) => {
|
||||||
|
updateSideTool(editor);
|
||||||
|
updateBubbleMenu(editor);
|
||||||
|
},
|
||||||
|
onTransaction: ({ editor }) => {
|
||||||
|
updateSideTool(editor);
|
||||||
|
updateBubbleMenu(editor);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateSideTool = (editor: any) => {
|
||||||
|
if (!editor || !wrapperRef.current) return;
|
||||||
|
const { selection } = editor.state;
|
||||||
|
const { $anchor } = selection;
|
||||||
|
const dom = editor.view.domAtPos($anchor.pos).node as HTMLElement;
|
||||||
|
|
||||||
|
// Find the block element
|
||||||
|
let block = dom;
|
||||||
|
if (block.nodeType === 3) { // Text node
|
||||||
|
block = block.parentElement as HTMLElement;
|
||||||
|
}
|
||||||
|
// Traverse up to find the direct child of prose
|
||||||
|
while (block && block.parentElement && !block.parentElement.classList.contains('ProseMirror')) {
|
||||||
|
block = block.parentElement as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block && block.getBoundingClientRect) {
|
||||||
|
const wrapperRect = wrapperRef.current.getBoundingClientRect();
|
||||||
|
const blockRect = block.getBoundingClientRect();
|
||||||
|
const top = blockRect.top - wrapperRect.top;
|
||||||
|
setSideToolPos({ top, left: 10, visible: true });
|
||||||
|
} else {
|
||||||
|
setSideToolPos(prev => ({ ...prev, visible: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateBubbleMenu = (editor: any) => {
|
||||||
|
if (!editor || !wrapperRef.current) return;
|
||||||
|
const { selection } = editor.state;
|
||||||
|
|
||||||
|
if (selection.empty) {
|
||||||
|
setBubbleMenuPos(prev => ({ ...prev, visible: false }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { from, to } = selection;
|
||||||
|
const startCoords = editor.view.coordsAtPos(from);
|
||||||
|
const endCoords = editor.view.coordsAtPos(to);
|
||||||
|
const wrapperRect = wrapperRef.current.getBoundingClientRect();
|
||||||
|
|
||||||
|
const left = (startCoords.left + endCoords.right) / 2 - wrapperRect.left;
|
||||||
|
const top = startCoords.top - wrapperRect.top - 10; // 10px offset above
|
||||||
|
|
||||||
|
setBubbleMenuPos({ top, left, visible: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editor && value && (editor.storage as any).markdown.getMarkdown() !== value) {
|
||||||
|
if (editor.getText() === '' && value) {
|
||||||
|
editor.commands.setContent(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [value, editor]);
|
||||||
|
|
||||||
|
const runAiAction = async (action: string, contextText: string, replace: boolean = false) => {
|
||||||
|
if (!assistants.length) {
|
||||||
|
toast.error('请先配置 AI 助手');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close input immediately to prevent blocking
|
||||||
|
setAiInputOpen(false);
|
||||||
|
setIsAiGenerating(true);
|
||||||
|
|
||||||
|
let prompt = '';
|
||||||
|
// Updated base prompt for Chinese output
|
||||||
|
const basePrompt = (task: string, context: string) => `你是一个专业的 AI 写作助手。任务:${task}。上下文:${context}。重要:请直接输出结果,不要包含任何解释性文字。如果上下文太短或无意义,请礼貌地拒绝。请使用中文(简体)回复。`;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'rewrite': prompt = basePrompt('请重写以下文本,使其更加流畅优美', contextText); break;
|
||||||
|
case 'blogify': prompt = basePrompt('将以下文本改写为吸引人的博客风格', contextText); break;
|
||||||
|
case 'summarize': prompt = basePrompt('请总结以下文本的核心内容', contextText); break;
|
||||||
|
case 'expand': prompt = basePrompt('请扩写以下文本,补充更多细节', contextText); break;
|
||||||
|
case 'shorten': prompt = basePrompt('请精简以下文本,保留核心信息', contextText); break;
|
||||||
|
case 'fix': prompt = basePrompt('请纠正以下文本中的错别字和语法错误', contextText); break;
|
||||||
|
case 'continue': prompt = basePrompt('请根据以下上下文继续写作', contextText); break;
|
||||||
|
case 'custom': prompt = basePrompt('请按照用户指令执行', contextText); break;
|
||||||
|
default: prompt = contextText;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.startsWith('tone:')) {
|
||||||
|
const tone = action.split(':')[1];
|
||||||
|
prompt = basePrompt(`请用${tone}的语气重写以下文本`, contextText);
|
||||||
|
}
|
||||||
|
if (action.startsWith('translate:')) {
|
||||||
|
const lang = action.split(':')[1];
|
||||||
|
prompt = basePrompt(`请将以下文本翻译成${lang}`, contextText);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (replace) {
|
||||||
|
editor?.chain().focus().deleteSelection().run();
|
||||||
|
} else if (action === 'continue') {
|
||||||
|
editor?.chain().focus().run();
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch('/api/ai/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt,
|
||||||
|
assistantId: assistants[0]._id,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error('Generation failed');
|
||||||
|
|
||||||
|
const startPos = editor?.state.selection.from || 0;
|
||||||
|
let fullText = '';
|
||||||
|
|
||||||
|
const contentType = res.headers.get('content-type');
|
||||||
|
if (contentType && contentType.includes('text/event-stream')) {
|
||||||
|
const reader = res.body?.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
if (reader) {
|
||||||
|
let done = false;
|
||||||
|
while (!done) {
|
||||||
|
const { value, done: doneReading } = await reader.read();
|
||||||
|
done = doneReading;
|
||||||
|
const chunkValue = decoder.decode(value, { stream: !done });
|
||||||
|
const lines = chunkValue.split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
const dataStr = line.slice(6);
|
||||||
|
if (dataStr === '[DONE]') continue;
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(dataStr);
|
||||||
|
const content = data.choices?.[0]?.delta?.content || '';
|
||||||
|
if (content && editor) {
|
||||||
|
editor.commands.insertContent(content);
|
||||||
|
fullText += content;
|
||||||
|
// Auto-scroll to keep cursor in view
|
||||||
|
editor.commands.scrollIntoView();
|
||||||
|
}
|
||||||
|
} catch (e) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
if (editor) {
|
||||||
|
editor.commands.insertContent(data.text);
|
||||||
|
fullText = data.text;
|
||||||
|
editor.commands.scrollIntoView();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse Markdown to HTML and replace the raw text
|
||||||
|
if (fullText && editor) {
|
||||||
|
try {
|
||||||
|
const file = await unified()
|
||||||
|
.use(remarkParse)
|
||||||
|
.use(remarkRehype)
|
||||||
|
.use(rehypeStringify)
|
||||||
|
.process(fullText);
|
||||||
|
const htmlContent = String(file);
|
||||||
|
|
||||||
|
const endPos = editor.state.selection.to;
|
||||||
|
// Ensure we are replacing the correct range
|
||||||
|
if (endPos > startPos) {
|
||||||
|
editor.chain()
|
||||||
|
.setTextSelection({ from: startPos, to: endPos })
|
||||||
|
.insertContent(htmlContent)
|
||||||
|
.setTextSelection(startPos + htmlContent.length) // Move cursor to end
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Markdown parsing failed', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error('AI 生成失败');
|
||||||
|
} finally {
|
||||||
|
setIsAiGenerating(false);
|
||||||
|
if (editor) {
|
||||||
|
editor.setEditable(true);
|
||||||
|
}
|
||||||
|
// Input is already closed at the start
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAiInput = () => {
|
||||||
|
if (!editor || !wrapperRef.current) return;
|
||||||
|
const { selection } = editor.state;
|
||||||
|
const { $anchor } = selection;
|
||||||
|
const coords = editor.view.coordsAtPos($anchor.pos);
|
||||||
|
const wrapperRect = wrapperRef.current.getBoundingClientRect();
|
||||||
|
|
||||||
|
setAiInputPos({
|
||||||
|
top: coords.bottom - wrapperRect.top + 10,
|
||||||
|
left: coords.left - wrapperRect.left,
|
||||||
|
});
|
||||||
|
setAiInputOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!editor) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={wrapperRef} className={cn("border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm relative", className)}>
|
||||||
|
<Toolbar editor={editor} onAiClick={openAiInput} />
|
||||||
|
|
||||||
|
{/* Side Tool */}
|
||||||
|
{sideToolPos.visible && (
|
||||||
|
<div
|
||||||
|
className="absolute z-10 transition-all duration-100 ease-out"
|
||||||
|
style={{ top: sideToolPos.top, left: sideToolPos.left }}
|
||||||
|
>
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-4 p-0 text-gray-300 hover:text-gray-600 cursor-grab active:cursor-grabbing"
|
||||||
|
>
|
||||||
|
<span className="text-lg leading-none">⋮⋮</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" side="bottom" className="w-48">
|
||||||
|
<DropdownMenuItem onClick={openAiInput}>
|
||||||
|
<Sparkles className="w-4 h-4 mr-2 text-purple-500" />
|
||||||
|
<span>AI 写作...</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => {
|
||||||
|
const { from } = editor.state.selection;
|
||||||
|
const context = editor.state.doc.textBetween(Math.max(0, from - 500), from, '\n');
|
||||||
|
runAiAction('continue', context, false);
|
||||||
|
}}>
|
||||||
|
<Wand2 className="w-4 h-4 mr-2 text-blue-500" />
|
||||||
|
<span>AI 续写</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Inline AI Input */}
|
||||||
|
{aiInputOpen && (
|
||||||
|
<div
|
||||||
|
className="absolute z-20 w-[600px] max-w-full"
|
||||||
|
style={{ top: aiInputPos.top, left: Math.max(20, aiInputPos.left) }}
|
||||||
|
>
|
||||||
|
<div className="bg-white rounded-xl border border-purple-200 shadow-xl p-2 animate-in fade-in zoom-in-95 duration-200">
|
||||||
|
<div className="flex items-center gap-2 mb-2 px-2 pt-1">
|
||||||
|
<Sparkles className="w-4 h-4 text-purple-500" />
|
||||||
|
<span className="text-sm font-medium text-gray-700">AI 助手</span>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
autoFocus
|
||||||
|
placeholder="告诉 AI 你想要写什么..."
|
||||||
|
className="min-h-[60px] border-0 focus-visible:ring-0 resize-none bg-gray-50/50 text-base"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
const target = e.target as HTMLTextAreaElement;
|
||||||
|
if (target.value.trim()) {
|
||||||
|
runAiAction('custom', target.value.trim(), false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between items-center mt-2 px-1">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-xs text-gray-500">
|
||||||
|
<MessageSquare className="w-3 h-3 mr-1" /> 语气
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem onClick={() => runAiAction('tone:专业', '', false)}>专业</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => runAiAction('tone:幽默', '', false)}>幽默</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setAiInputOpen(false)}>取消</Button>
|
||||||
|
<Button size="sm" className="bg-purple-600 hover:bg-purple-700 text-white" onClick={(e) => {
|
||||||
|
const textarea = e.currentTarget.parentElement?.parentElement?.previousElementSibling as HTMLTextAreaElement;
|
||||||
|
if (textarea && textarea.value.trim()) {
|
||||||
|
runAiAction('custom', textarea.value.trim(), false);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<Sparkles className="w-3 h-3 mr-1" /> 生成
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Custom Bubble Menu */}
|
||||||
|
{bubbleMenuPos.visible && (
|
||||||
|
<div
|
||||||
|
className="absolute z-10 flex overflow-hidden rounded-md border bg-white shadow-md p-1 gap-0.5 animate-in fade-in zoom-in-95 duration-200"
|
||||||
|
style={{
|
||||||
|
top: bubbleMenuPos.top,
|
||||||
|
left: bubbleMenuPos.left,
|
||||||
|
transform: 'translate(-50%, -100%)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 px-2 text-xs gap-1 text-purple-600 hover:text-purple-700 hover:bg-purple-50">
|
||||||
|
<Sparkles className="w-3.5 h-3.5" /> AI 工具
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="w-48">
|
||||||
|
<DropdownMenuItem onClick={() => runAiAction('rewrite', editor.state.selection.content().content.textBetween(0, editor.state.selection.content().size, '\n'), true)}>
|
||||||
|
<RefreshCw className="w-3.5 h-3.5 mr-2" /> 改写优化
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => runAiAction('blogify', editor.state.selection.content().content.textBetween(0, editor.state.selection.content().size, '\n'), true)}>
|
||||||
|
<PenLine className="w-3.5 h-3.5 mr-2" /> 博客风格
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => runAiAction('summarize', editor.state.selection.content().content.textBetween(0, editor.state.selection.content().size, '\n'), false)}>
|
||||||
|
<Minimize2 className="w-3.5 h-3.5 mr-2" /> 内容总结
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => runAiAction('expand', editor.state.selection.content().content.textBetween(0, editor.state.selection.content().size, '\n'), true)}>
|
||||||
|
<Maximize2 className="w-3.5 h-3.5 mr-2" /> 内容扩写
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => runAiAction('fix', editor.state.selection.content().content.textBetween(0, editor.state.selection.content().size, '\n'), true)}>
|
||||||
|
<Check className="w-3.5 h-3.5 mr-2" /> 纠错润色
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<div className="w-px h-4 bg-gray-200 mx-1 self-center" />
|
||||||
|
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 px-2 text-xs">
|
||||||
|
<MessageSquare className="w-3 h-3 mr-1" /> 语气
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
<DropdownMenuItem onClick={() => runAiAction('tone:专业', editor.state.selection.content().content.textBetween(0, editor.state.selection.content().size, '\n'), true)}>专业</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => runAiAction('tone:幽默', editor.state.selection.content().content.textBetween(0, editor.state.selection.content().size, '\n'), true)}>幽默</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => runAiAction('tone:亲切', editor.state.selection.content().content.textBetween(0, editor.state.selection.content().size, '\n'), true)}>亲切</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 px-2 text-xs">
|
||||||
|
<Languages className="w-3 h-3 mr-1" /> 翻译
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start">
|
||||||
|
<DropdownMenuItem onClick={() => runAiAction('translate:英语', editor.state.selection.content().content.textBetween(0, editor.state.selection.content().size, '\n'), true)}>英语</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => runAiAction('translate:中文', editor.state.selection.content().content.textBetween(0, editor.state.selection.content().size, '\n'), true)}>中文</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => runAiAction('translate:日语', editor.state.selection.content().content.textBetween(0, editor.state.selection.content().size, '\n'), true)}>日语</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<EditorContent editor={editor} className={cn(isAiGenerating && "opacity-50 pointer-events-none")} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
src/components/aitools/AIToolsLayout.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import MainLayout from '@/components/layouts/MainLayout';
|
||||||
|
import ToolsSidebar from './ToolsSidebar';
|
||||||
|
|
||||||
|
interface AIToolsLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AIToolsLayout({ children, title, description }: AIToolsLayoutProps) {
|
||||||
|
return (
|
||||||
|
<MainLayout
|
||||||
|
seo={{
|
||||||
|
title: title ? `${title} - AI 工具箱` : 'AI 工具箱',
|
||||||
|
description: description || '提供多种实用的 AI 辅助工具,提升您的工作效率。',
|
||||||
|
keywords: 'AI工具, 提示词优化, 智能翻译, 效率工具'
|
||||||
|
}}
|
||||||
|
showFooter={false}
|
||||||
|
>
|
||||||
|
<div className="bg-gray-50 min-h-screen pt-8 pb-8">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||||
|
{/* Left Sidebar */}
|
||||||
|
<div className="lg:col-span-3">
|
||||||
|
<div className="sticky top-24">
|
||||||
|
<ToolsSidebar />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Content */}
|
||||||
|
<div className="lg:col-span-9">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MainLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
src/components/aitools/ToolsSidebar.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { Sparkles, Languages, Wand2, ChevronRight } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const tools = [
|
||||||
|
{
|
||||||
|
id: 'prompt-optimizer',
|
||||||
|
name: '提示词优化师',
|
||||||
|
description: '优化指令,提升生成质量',
|
||||||
|
icon: Sparkles,
|
||||||
|
href: '/aitools/prompt-optimizer',
|
||||||
|
color: 'text-purple-600',
|
||||||
|
bgColor: 'bg-purple-100',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'translator',
|
||||||
|
name: '智能翻译助手',
|
||||||
|
description: '多语言精准互译',
|
||||||
|
icon: Languages,
|
||||||
|
href: '/aitools/translator',
|
||||||
|
color: 'text-blue-600',
|
||||||
|
bgColor: 'bg-blue-100',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'more',
|
||||||
|
name: '更多工具',
|
||||||
|
description: '敬请期待...',
|
||||||
|
icon: Wand2,
|
||||||
|
href: '#',
|
||||||
|
color: 'text-gray-400',
|
||||||
|
bgColor: 'bg-gray-100',
|
||||||
|
disabled: true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ToolsSidebar() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="mb-6 px-2">
|
||||||
|
<h2 className="text-lg font-bold text-gray-900">工具列表</h2>
|
||||||
|
<p className="text-xs text-gray-500">选择一个工具开始使用</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{tools.map((tool) => {
|
||||||
|
const isActive = router.pathname === tool.href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={tool.id}
|
||||||
|
href={tool.href}
|
||||||
|
className={cn(
|
||||||
|
"group flex items-center gap-4 p-4 rounded-xl border transition-all duration-200",
|
||||||
|
tool.disabled ? "opacity-60 cursor-not-allowed" : "hover:shadow-md cursor-pointer",
|
||||||
|
isActive
|
||||||
|
? "bg-white border-primary/50 shadow-md ring-1 ring-primary/20"
|
||||||
|
: "bg-white border-gray-100 hover:border-gray-200"
|
||||||
|
)}
|
||||||
|
onClick={(e) => tool.disabled && e.preventDefault()}
|
||||||
|
>
|
||||||
|
<div className={cn("w-10 h-10 rounded-lg flex items-center justify-center shrink-0", tool.bgColor, tool.color)}>
|
||||||
|
<tool.icon className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className={cn("font-medium truncate", isActive ? "text-primary" : "text-gray-900")}>
|
||||||
|
{tool.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500 truncate">
|
||||||
|
{tool.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isActive && (
|
||||||
|
<ChevronRight className="w-4 h-4 text-primary shrink-0" />
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
161
src/components/article/CommentSection.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import { zhCN } from 'date-fns/locale';
|
||||||
|
import { Loader2, MessageSquare, Send } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface Comment {
|
||||||
|
_id: string;
|
||||||
|
用户ID: {
|
||||||
|
_id: string;
|
||||||
|
用户名: string;
|
||||||
|
头像: string;
|
||||||
|
};
|
||||||
|
评论内容: string;
|
||||||
|
createdAt: string;
|
||||||
|
父评论ID?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommentSectionProps {
|
||||||
|
articleId: string;
|
||||||
|
isLoggedIn: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CommentSection({ articleId, isLoggedIn }: CommentSectionProps) {
|
||||||
|
const [comments, setComments] = useState<Comment[]>([]);
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchComments();
|
||||||
|
}, [articleId]);
|
||||||
|
|
||||||
|
const fetchComments = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/comments?articleId=${articleId}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setComments(data.comments);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch comments', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!content.trim()) return;
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
toast.error('请先登录后再评论');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/comments', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ articleId, content }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success('评论发表成功');
|
||||||
|
setContent('');
|
||||||
|
fetchComments(); // Refresh comments
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
toast.error(data.message || '评论发表失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('网络错误,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
|
||||||
|
<h3 className="text-lg font-bold flex items-center gap-2 mb-6">
|
||||||
|
<MessageSquare className="w-5 h-5 text-primary" />
|
||||||
|
评论 ({comments.length})
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Comment Form */}
|
||||||
|
<div className="mb-8">
|
||||||
|
{isLoggedIn ? (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<Textarea
|
||||||
|
placeholder="写下你的评论..."
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
className="min-h-[100px]"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="submit" disabled={submitting || !content.trim()}>
|
||||||
|
{submitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
发布中...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send className="w-4 h-4 mr-2" />
|
||||||
|
发布评论
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="bg-gray-50 rounded-lg p-6 text-center">
|
||||||
|
<p className="text-gray-600 mb-4">登录后参与评论讨论</p>
|
||||||
|
<Button variant="outline" onClick={() => window.location.href = '/auth/login'}>
|
||||||
|
立即登录
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comments List */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
|
||||||
|
</div>
|
||||||
|
) : comments.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
暂无评论,快来抢沙发吧!
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
comments.map((comment) => (
|
||||||
|
<div key={comment._id} className="flex gap-4">
|
||||||
|
<Avatar className="w-10 h-10">
|
||||||
|
<AvatarImage src={comment.用户ID?.头像} />
|
||||||
|
<AvatarFallback>{comment.用户ID?.用户名?.charAt(0)}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="font-semibold text-gray-900">
|
||||||
|
{comment.用户ID?.用户名 || '未知用户'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true, locale: zhCN })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-700 text-sm leading-relaxed">
|
||||||
|
{comment.评论内容}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
src/components/home/ArticleCard.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { Clock, ArrowRight } from 'lucide-react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
|
interface ArticleCardProps {
|
||||||
|
article: {
|
||||||
|
_id: string;
|
||||||
|
文章标题: string;
|
||||||
|
摘要: string;
|
||||||
|
封面图: string;
|
||||||
|
分类ID: { 分类名称: string };
|
||||||
|
createdAt: string;
|
||||||
|
价格: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ArticleCard({ article }: ArticleCardProps) {
|
||||||
|
const isFree = article.价格 === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group flex flex-col bg-white rounded-xl overflow-hidden border border-gray-100 hover:shadow-lg transition-all duration-300 h-full">
|
||||||
|
{/* Image */}
|
||||||
|
<Link href={`/article/${article._id}`} className="relative aspect-video overflow-hidden bg-gray-100">
|
||||||
|
{article.封面图 ? (
|
||||||
|
<Image
|
||||||
|
src={article.封面图}
|
||||||
|
alt={article.文章标题}
|
||||||
|
fill
|
||||||
|
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-gray-300">No Image</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute top-3 left-3">
|
||||||
|
<Badge variant="secondary" className="bg-white/90 backdrop-blur-sm text-black font-medium shadow-sm hover:bg-white">
|
||||||
|
{article.分类ID?.分类名称 || '未分类'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 p-5 flex flex-col">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-400 mb-3">
|
||||||
|
<span>{new Date(article.createdAt).toLocaleDateString()}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" /> 12 min read
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link href={`/article/${article._id}`} className="block mb-3">
|
||||||
|
<h3 className="text-xl font-bold text-gray-900 line-clamp-2 group-hover:text-primary transition-colors">
|
||||||
|
{article.文章标题}
|
||||||
|
</h3>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<p className="text-gray-500 text-sm line-clamp-3 mb-6 flex-1">
|
||||||
|
{article.摘要}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mt-auto pt-4 border-t border-gray-50">
|
||||||
|
{isFree ? (
|
||||||
|
<span className="text-xs font-bold text-green-600 bg-green-50 px-2 py-1 rounded">FREE</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs font-bold text-blue-600 bg-blue-50 px-2 py-1 rounded">¥{article.价格}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link href={`/article/${article._id}`} className="text-sm font-medium text-gray-900 flex items-center gap-1 group-hover:gap-2 transition-all">
|
||||||
|
阅读全文 <ArrowRight className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
src/components/home/HeroBanner.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious, type CarouselApi } from "@/components/ui/carousel";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface Banner {
|
||||||
|
标题: string;
|
||||||
|
描述: string;
|
||||||
|
图片地址: string;
|
||||||
|
按钮文本: string;
|
||||||
|
按钮链接: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HeroBannerProps {
|
||||||
|
banners: Banner[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HeroBanner({ banners }: HeroBannerProps) {
|
||||||
|
const [api, setApi] = useState<CarouselApi>();
|
||||||
|
const [current, setCurrent] = useState(0);
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!api) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCount(api.scrollSnapList().length);
|
||||||
|
setCurrent(api.selectedScrollSnap());
|
||||||
|
|
||||||
|
api.on("select", () => {
|
||||||
|
setCurrent(api.selectedScrollSnap());
|
||||||
|
});
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
// Handle auto-play via progress bar animation end
|
||||||
|
const handleAnimationEnd = useCallback(() => {
|
||||||
|
if (api) {
|
||||||
|
api.scrollNext();
|
||||||
|
}
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
if (!banners || banners.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="relative -mt-16 h-[500px] w-full bg-gray-900 animate-pulse flex items-center justify-center">
|
||||||
|
<div className="text-white/20 text-xl font-medium">Loading Banner...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative -mt-16 group"
|
||||||
|
onMouseEnter={() => setIsPaused(true)}
|
||||||
|
onMouseLeave={() => setIsPaused(false)}
|
||||||
|
>
|
||||||
|
<Carousel setApi={setApi} className="w-full" opts={{ loop: true }}>
|
||||||
|
<CarouselContent>
|
||||||
|
{banners.map((banner, index) => (
|
||||||
|
<CarouselItem key={index}>
|
||||||
|
<div className="relative h-[500px] w-full overflow-hidden">
|
||||||
|
{/* Background Image */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-cover bg-center transition-transform duration-700 hover:scale-105"
|
||||||
|
style={{ backgroundImage: `url(${banner.图片地址})` }}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-black/40" /> {/* Overlay */}
|
||||||
|
<div className="absolute inset-0 bg-linear-to-t from-black/60 via-transparent to-transparent" /> {/* Gradient Overlay */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="container mx-auto px-4 h-full flex items-center relative z-10 pt-20">
|
||||||
|
<div className="max-w-3xl space-y-6 text-white animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||||
|
<h1 className="text-4xl md:text-6xl lg:text-7xl font-bold tracking-tight leading-tight drop-shadow-lg">
|
||||||
|
{banner.标题}
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-100 text-lg md:text-xl leading-relaxed max-w-2xl drop-shadow-md opacity-90">
|
||||||
|
{banner.描述}
|
||||||
|
</p>
|
||||||
|
<div className="pt-6 flex gap-4">
|
||||||
|
<Link href={banner.按钮链接 || '#'}>
|
||||||
|
<Button className="bg-white text-black hover:bg-white/90 font-semibold px-8 h-12 rounded-full text-base border-none shadow-lg hover:shadow-xl transition-all hover:-translate-y-0.5">
|
||||||
|
{banner.按钮文本 || '查看详情'}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CarouselItem>
|
||||||
|
))}
|
||||||
|
</CarouselContent>
|
||||||
|
|
||||||
|
{banners.length > 1 && (
|
||||||
|
<>
|
||||||
|
<CarouselPrevious className="left-8 bg-white/10 hover:bg-white/20 text-white border-white/20 h-12 w-12 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||||
|
<CarouselNext className="right-8 bg-white/10 hover:bg-white/20 text-white border-white/20 h-12 w-12 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Carousel>
|
||||||
|
|
||||||
|
{/* Premium Progress Indicators */}
|
||||||
|
{banners.length > 1 && (
|
||||||
|
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-20">
|
||||||
|
<div className="flex items-center gap-1.5 p-1.5 rounded-full bg-black/20 backdrop-blur-md border border-white/10 shadow-lg">
|
||||||
|
{Array.from({ length: count }).map((_, index) => {
|
||||||
|
const isActive = current === index;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => api?.scrollTo(index)}
|
||||||
|
aria-label={`Go to slide ${index + 1}`}
|
||||||
|
className={cn(
|
||||||
|
"relative h-1.5 rounded-full overflow-hidden transition-all duration-500 ease-out focus:outline-none",
|
||||||
|
isActive
|
||||||
|
? "w-10 bg-white/20"
|
||||||
|
: "w-2 bg-white/40 hover:bg-white/60 hover:w-4"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isActive && (
|
||||||
|
<div
|
||||||
|
className="absolute top-0 left-0 h-full w-full bg-white rounded-full shadow-[0_0_10px_rgba(255,255,255,0.5)] origin-left"
|
||||||
|
style={{
|
||||||
|
animation: `progress 5s linear forwards`,
|
||||||
|
animationPlayState: isPaused ? 'paused' : 'running',
|
||||||
|
willChange: 'transform'
|
||||||
|
}}
|
||||||
|
onAnimationEnd={handleAnimationEnd}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style jsx global>{`
|
||||||
|
@keyframes progress {
|
||||||
|
from {
|
||||||
|
transform: scaleX(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
src/components/home/Sidebar.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
tags: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Sidebar({ tags }: SidebarProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Hot Tags */}
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-gray-900 mb-4 flex items-center gap-2">
|
||||||
|
<span className="w-1 h-4 bg-primary rounded-full"></span>
|
||||||
|
热门标签
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<Link key={tag._id} href={`/?tag=${tag._id}`}>
|
||||||
|
<Badge variant="secondary" className="hover:bg-gray-200 cursor-pointer font-normal px-3 py-1">
|
||||||
|
{tag.标签名称}
|
||||||
|
</Badge>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recommended (Static for now) */}
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-gray-900 mb-4 flex items-center gap-2">
|
||||||
|
<span className="w-1 h-4 bg-primary rounded-full"></span>
|
||||||
|
推荐阅读
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className="group cursor-pointer">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className={`text-2xl font-bold transition-colors ${i === 1 ? 'text-gray-900' :
|
||||||
|
i === 2 ? 'text-gray-500' :
|
||||||
|
'text-gray-300'
|
||||||
|
}`}>0{i}</span>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 group-hover:text-primary transition-colors line-clamp-2">
|
||||||
|
2025年前端架构演进:从 Micro-Frontends 到 Islands Architecture
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">12 min read</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
314
src/components/layouts/MainLayout.tsx
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import Head from 'next/head';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Search, User, LogOut, LayoutDashboard, Settings } from 'lucide-react';
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
|
||||||
|
interface SEOProps {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
keywords?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MainLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
transparentHeader?: boolean;
|
||||||
|
seo?: SEOProps;
|
||||||
|
showFooter?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthButtons() {
|
||||||
|
const { user, loading, logout } = useAuth();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="w-8 h-8 rounded-full bg-gray-100 animate-pulse" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
return (
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
|
||||||
|
<Avatar className="h-10 w-10 border border-gray-200">
|
||||||
|
<AvatarImage src={user.头像} alt={user.用户名} />
|
||||||
|
<AvatarFallback>{user.用户名?.charAt(0).toUpperCase()}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="w-56" align="end" forceMount>
|
||||||
|
<DropdownMenuLabel className="font-normal">
|
||||||
|
<div className="flex flex-col space-y-1">
|
||||||
|
<p className="text-sm font-medium leading-none">{user.用户名}</p>
|
||||||
|
<p className="text-xs leading-none text-muted-foreground">
|
||||||
|
{user.邮箱}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/dashboard" className="cursor-pointer">
|
||||||
|
<LayoutDashboard className="mr-2 h-4 w-4" />
|
||||||
|
<span>个人中心</span>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{user.角色 === 'admin' && (
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/admin" className="cursor-pointer">
|
||||||
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
|
<span>管理后台</span>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={logout} className="text-red-600 cursor-pointer">
|
||||||
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
|
<span>退出登录</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link href="/auth/login">
|
||||||
|
<Button variant="ghost" size="sm" className="text-white hover:text-white hover:bg-white/20">登录</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/auth/register">
|
||||||
|
<Button size="sm" className="bg-white text-black hover:bg-white/90">注册账户</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MainLayout({ children, transparentHeader = false, seo, showFooter = true }: MainLayoutProps) {
|
||||||
|
const { config } = useConfig();
|
||||||
|
const router = useRouter();
|
||||||
|
const [isScrolled, setIsScrolled] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
setIsScrolled(window.scrollY > 50);
|
||||||
|
};
|
||||||
|
window.addEventListener('scroll', handleScroll);
|
||||||
|
return () => window.removeEventListener('scroll', handleScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isTransparent = transparentHeader && !isScrolled;
|
||||||
|
|
||||||
|
// Construct title: "Page Title - Site Title" or just "Site Title"
|
||||||
|
const siteTitle = config?.网站标题 || 'AounApp';
|
||||||
|
const pageTitle = seo?.title ? `${seo.title} - ${siteTitle}` : siteTitle;
|
||||||
|
const pageDescription = seo?.description || config?.全局SEO描述 || config?.网站副标题 || 'AounApp - 个人资源站';
|
||||||
|
const pageKeywords = seo?.keywords || config?.全局SEO关键词 || '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col bg-gray-50">
|
||||||
|
<Head>
|
||||||
|
<title>{pageTitle}</title>
|
||||||
|
<meta name="description" content={pageDescription} />
|
||||||
|
<meta name="keywords" content={pageKeywords} />
|
||||||
|
<link rel="icon" href={config?.Favicon || "/favicon.ico"} />
|
||||||
|
</Head>
|
||||||
|
{/* Header */}
|
||||||
|
<header
|
||||||
|
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${isTransparent
|
||||||
|
? 'bg-transparent border-transparent py-4'
|
||||||
|
: 'bg-white/80 backdrop-blur-md border-b py-0 shadow-sm'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="container mx-auto px-4 h-16 flex items-center justify-between">
|
||||||
|
{/* Logo & Nav */}
|
||||||
|
<div className="flex items-center gap-8">
|
||||||
|
<Link href="/" className="flex items-center gap-2">
|
||||||
|
<img src={config?.Logo地址 || "/LOGO.png"} alt={config?.网站标题 || "AOUN"} className="h-8 w-auto" />
|
||||||
|
<span className={`font-bold text-xl tracking-tight ${isTransparent ? 'text-white' : 'text-gray-900'}`}>
|
||||||
|
{config?.网站标题 || 'AOUN'}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<nav className={`hidden md:flex items-center gap-8 text-sm font-medium ${isTransparent ? 'text-white/90' : 'text-gray-600'}`}>
|
||||||
|
{[
|
||||||
|
{ href: '/aitools', label: 'AI工具' },
|
||||||
|
{ href: '/', label: '专栏文章' },
|
||||||
|
{ href: '/membership', label: '会员计划' }
|
||||||
|
].map((link) => {
|
||||||
|
const isActive = link.href === '/'
|
||||||
|
? router.pathname === '/'
|
||||||
|
: router.pathname.startsWith(link.href);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
className="relative group py-2"
|
||||||
|
>
|
||||||
|
<span className={`transition-colors ${isActive
|
||||||
|
? (isTransparent ? 'text-white' : 'text-primary')
|
||||||
|
: (isTransparent ? 'group-hover:text-white' : 'group-hover:text-primary')
|
||||||
|
}`}>
|
||||||
|
{link.label}
|
||||||
|
</span>
|
||||||
|
<span className={`absolute bottom-0 left-0 h-0.5 transition-all duration-300 ${isActive ? 'w-full' : 'w-0 group-hover:w-full'
|
||||||
|
} ${isTransparent ? 'bg-white' : 'bg-primary'
|
||||||
|
}`} />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Actions */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative hidden md:block">
|
||||||
|
<Search className={`absolute left-2.5 top-2.5 h-4 w-4 ${isTransparent ? 'text-white/60' : 'text-gray-400'}`} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索..."
|
||||||
|
className={`h-9 w-64 rounded-full border pl-9 pr-4 text-sm focus:outline-none focus:ring-2 transition-all ${isTransparent
|
||||||
|
? 'bg-white/10 border-white/20 text-white placeholder:text-white/60 focus:ring-white/30'
|
||||||
|
: 'bg-gray-50 border-gray-200 text-gray-900 focus:ring-primary/20'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AuthButtons />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className={`flex-1 ${!transparentHeader ? 'pt-16' : ''}`}>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{showFooter && (
|
||||||
|
<footer className="bg-white border-t py-12 mt-12">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||||
|
{/* Author Profile */}
|
||||||
|
<div className="lg:col-span-3">
|
||||||
|
<div className="h-full p-5 rounded-2xl bg-linear-to-b from-gray-50/80 to-transparent border border-gray-100/60 flex flex-col">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-white p-0.5 shadow-sm border border-gray-100 overflow-hidden">
|
||||||
|
<img src="https://api.dicebear.com/7.x/avataaars/svg?seed=admin" alt="Admin" className="w-full h-full object-cover rounded-lg" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-lg text-gray-900 leading-tight">阿瑞 (RUI)</h3>
|
||||||
|
<p className="text-[10px] font-bold text-primary uppercase tracking-wider mt-1">AOUN FOUNDER</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 leading-relaxed mb-6 font-medium flex-1">
|
||||||
|
专注前端架构、React 生态与独立开发变现。分享最硬核的技术实战经验。
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 mt-auto">
|
||||||
|
<Button variant="outline" size="sm" className="h-8 text-xs flex-1 bg-white hover:bg-gray-50 border-gray-200">Bilibili</Button>
|
||||||
|
<Button variant="outline" size="sm" className="h-8 text-xs flex-1 bg-white hover:bg-gray-50 border-gray-200">WeChat</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gemini Credit */}
|
||||||
|
<div className="lg:col-span-3">
|
||||||
|
<div className="h-full p-5 rounded-2xl bg-linear-to-b from-blue-50/80 to-transparent border border-blue-100/60 flex flex-col">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-white flex items-center justify-center shadow-sm border border-blue-50">
|
||||||
|
<img src="/Gemini.svg" alt="Gemini" className="w-7 h-7" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-lg text-gray-900 leading-tight">
|
||||||
|
Gemini
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-1.5 mt-1">
|
||||||
|
<span className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider">Powered by</span>
|
||||||
|
<img src="/Google.svg" alt="Google" className="h-3.5 opacity-60" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 leading-relaxed mb-6 font-medium flex-1">
|
||||||
|
本站由 Google DeepMind 的 Gemini 模型全栈构建。<br />探索人机协作开发的无限可能。
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 text-[10px] font-bold text-blue-600 bg-white px-3 py-2 rounded-lg border border-blue-100 shadow-sm w-fit mt-auto">
|
||||||
|
<span className="relative flex h-2 w-2">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500"></span>
|
||||||
|
</span>
|
||||||
|
100% AI Generated Code
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resources & About */}
|
||||||
|
<div className="lg:col-span-3">
|
||||||
|
<div className="h-full p-5 rounded-2xl bg-linear-to-b from-gray-50/80 to-transparent border border-gray-100/60 flex flex-col">
|
||||||
|
<div className="grid grid-cols-2 gap-4 h-full">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<h4 className="font-bold text-gray-900 mb-4 flex items-center gap-2">
|
||||||
|
<span className="w-1 h-4 bg-primary rounded-full"></span>
|
||||||
|
资源
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-1 flex-1">
|
||||||
|
<li><Link href="#" className="flex items-center gap-2 px-3 py-2 text-xs text-gray-500 hover:text-primary hover:bg-white hover:shadow-sm rounded-lg transition-all group"><span className="w-1 h-1 rounded-full bg-gray-300 group-hover:bg-primary transition-colors"></span>最新文章</Link></li>
|
||||||
|
<li><Link href="#" className="flex items-center gap-2 px-3 py-2 text-xs text-gray-500 hover:text-primary hover:bg-white hover:shadow-sm rounded-lg transition-all group"><span className="w-1 h-1 rounded-full bg-gray-300 group-hover:bg-primary transition-colors"></span>热门排行</Link></li>
|
||||||
|
<li><Link href="#" className="flex items-center gap-2 px-3 py-2 text-xs text-gray-500 hover:text-primary hover:bg-white hover:shadow-sm rounded-lg transition-all group"><span className="w-1 h-1 rounded-full bg-gray-300 group-hover:bg-primary transition-colors"></span>会员专享</Link></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<h4 className="font-bold text-gray-900 mb-4 flex items-center gap-2">
|
||||||
|
<span className="w-1 h-4 bg-purple-500 rounded-full"></span>
|
||||||
|
关于
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-1 flex-1">
|
||||||
|
<li><Link href="#" className="flex items-center gap-2 px-3 py-2 text-xs text-gray-500 hover:text-primary hover:bg-white hover:shadow-sm rounded-lg transition-all group"><span className="w-1 h-1 rounded-full bg-gray-300 group-hover:bg-primary transition-colors"></span>关于我们</Link></li>
|
||||||
|
<li><Link href="#" className="flex items-center gap-2 px-3 py-2 text-xs text-gray-500 hover:text-primary hover:bg-white hover:shadow-sm rounded-lg transition-all group"><span className="w-1 h-1 rounded-full bg-gray-300 group-hover:bg-primary transition-colors"></span>联系方式</Link></li>
|
||||||
|
<li><Link href="#" className="flex items-center gap-2 px-3 py-2 text-xs text-gray-500 hover:text-primary hover:bg-white hover:shadow-sm rounded-lg transition-all group"><span className="w-1 h-1 rounded-full bg-gray-300 group-hover:bg-primary transition-colors"></span>隐私政策</Link></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subscribe */}
|
||||||
|
<div className="lg:col-span-3">
|
||||||
|
<div className="h-full p-5 rounded-2xl bg-linear-to-b from-primary/5 to-transparent border border-primary/10 flex flex-col">
|
||||||
|
<h4 className="font-bold text-lg text-gray-900 mb-2">订阅周刊</h4>
|
||||||
|
<p className="text-xs text-gray-500 mb-6 leading-relaxed flex-1">
|
||||||
|
每周一发布,分享最新的技术动态、设计灵感和独立开发经验。
|
||||||
|
</p>
|
||||||
|
<div className="mt-auto space-y-2">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="请输入您的邮箱"
|
||||||
|
className="w-full h-9 px-3 rounded-lg border border-gray-200 bg-white text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all"
|
||||||
|
/>
|
||||||
|
<Button size="sm" className="w-full h-9 bg-primary hover:bg-primary/90 text-white shadow-sm shadow-primary/20">
|
||||||
|
立即订阅
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-t mt-12 pt-8 flex flex-col md:flex-row items-center justify-between text-sm text-gray-400">
|
||||||
|
<p>{config?.底部版权信息 || `© ${new Date().getFullYear()} ${config?.网站标题 || 'AounApp'}. All rights reserved.`}</p>
|
||||||
|
<p>{config?.备案号}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
)}
|
||||||
|
<Toaster />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Avatar = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const AvatarImage = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
ref={ref}
|
||||||
|
className={cn("aspect-square h-full w-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||||
|
|
||||||
|
const AvatarFallback = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback }
|
||||||
36
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||||
|
outline: "text-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> { }
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
57
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2",
|
||||||
|
sm: "h-8 rounded-md px-3 text-xs",
|
||||||
|
lg: "h-10 rounded-md px-8",
|
||||||
|
icon: "h-9 w-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
76
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-xl border bg-card text-card-foreground shadow",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Card.displayName = "Card"
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
263
src/components/ui/carousel.tsx
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import useEmblaCarousel, {
|
||||||
|
type UseEmblaCarouselType,
|
||||||
|
} from "embla-carousel-react"
|
||||||
|
import { ArrowLeft, ArrowRight } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
type CarouselApi = UseEmblaCarouselType[1]
|
||||||
|
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
||||||
|
type CarouselOptions = UseCarouselParameters[0]
|
||||||
|
type CarouselPlugin = UseCarouselParameters[1]
|
||||||
|
|
||||||
|
type CarouselProps = {
|
||||||
|
opts?: CarouselOptions
|
||||||
|
plugins?: CarouselPlugin
|
||||||
|
orientation?: "horizontal" | "vertical"
|
||||||
|
setApi?: (api: CarouselApi) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type CarouselContextProps = {
|
||||||
|
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
||||||
|
api: CarouselApi
|
||||||
|
scrollPrev: () => void
|
||||||
|
scrollNext: () => void
|
||||||
|
canScrollPrev: boolean
|
||||||
|
canScrollNext: boolean
|
||||||
|
} & CarouselProps
|
||||||
|
|
||||||
|
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
||||||
|
|
||||||
|
function useCarousel() {
|
||||||
|
const context = React.useContext(CarouselContext)
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useCarousel must be used within a <Carousel />")
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
const Carousel = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & CarouselProps
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
orientation = "horizontal",
|
||||||
|
opts,
|
||||||
|
setApi,
|
||||||
|
plugins,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const [carouselRef, api] = useEmblaCarousel(
|
||||||
|
{
|
||||||
|
...opts,
|
||||||
|
axis: orientation === "horizontal" ? "x" : "y",
|
||||||
|
},
|
||||||
|
plugins
|
||||||
|
)
|
||||||
|
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
||||||
|
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
||||||
|
|
||||||
|
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||||
|
if (!api) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setCanScrollPrev(api.canScrollPrev())
|
||||||
|
setCanScrollNext(api.canScrollNext())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const scrollPrev = React.useCallback(() => {
|
||||||
|
api?.scrollPrev()
|
||||||
|
}, [api])
|
||||||
|
|
||||||
|
const scrollNext = React.useCallback(() => {
|
||||||
|
api?.scrollNext()
|
||||||
|
}, [api])
|
||||||
|
|
||||||
|
const handleKeyDown = React.useCallback(
|
||||||
|
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (event.key === "ArrowLeft") {
|
||||||
|
event.preventDefault()
|
||||||
|
scrollPrev()
|
||||||
|
} else if (event.key === "ArrowRight") {
|
||||||
|
event.preventDefault()
|
||||||
|
scrollNext()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[scrollPrev, scrollNext]
|
||||||
|
)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!api || !setApi) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setApi(api)
|
||||||
|
}, [api, setApi])
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!api) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelect(api)
|
||||||
|
api.on("reInit", onSelect)
|
||||||
|
api.on("select", onSelect)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
api.off("reInit", onSelect)
|
||||||
|
api.off("select", onSelect)
|
||||||
|
}
|
||||||
|
}, [api, onSelect])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CarouselContext.Provider
|
||||||
|
value={{
|
||||||
|
carouselRef,
|
||||||
|
api: api,
|
||||||
|
opts,
|
||||||
|
orientation:
|
||||||
|
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||||
|
scrollPrev,
|
||||||
|
scrollNext,
|
||||||
|
canScrollPrev,
|
||||||
|
canScrollNext,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className={cn("relative", className)}
|
||||||
|
role="region"
|
||||||
|
aria-roledescription="carousel"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</CarouselContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Carousel.displayName = "Carousel"
|
||||||
|
|
||||||
|
const CarouselContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { carouselRef, orientation } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={carouselRef} className="overflow-hidden">
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex",
|
||||||
|
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
CarouselContent.displayName = "CarouselContent"
|
||||||
|
|
||||||
|
const CarouselItem = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { orientation } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="group"
|
||||||
|
aria-roledescription="slide"
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 shrink-0 grow-0 basis-full",
|
||||||
|
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
CarouselItem.displayName = "CarouselItem"
|
||||||
|
|
||||||
|
const CarouselPrevious = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
React.ComponentProps<typeof Button>
|
||||||
|
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||||
|
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={cn(
|
||||||
|
"absolute h-8 w-8 rounded-full",
|
||||||
|
orientation === "horizontal"
|
||||||
|
? "-left-12 top-1/2 -translate-y-1/2"
|
||||||
|
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
disabled={!canScrollPrev}
|
||||||
|
onClick={scrollPrev}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Previous slide</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
CarouselPrevious.displayName = "CarouselPrevious"
|
||||||
|
|
||||||
|
const CarouselNext = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
React.ComponentProps<typeof Button>
|
||||||
|
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||||
|
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
variant={variant}
|
||||||
|
size={size}
|
||||||
|
className={cn(
|
||||||
|
"absolute h-8 w-8 rounded-full",
|
||||||
|
orientation === "horizontal"
|
||||||
|
? "-right-12 top-1/2 -translate-y-1/2"
|
||||||
|
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
disabled={!canScrollNext}
|
||||||
|
onClick={scrollNext}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Next slide</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
CarouselNext.displayName = "CarouselNext"
|
||||||
|
|
||||||
|
export {
|
||||||
|
type CarouselApi,
|
||||||
|
Carousel,
|
||||||
|
CarouselContent,
|
||||||
|
CarouselItem,
|
||||||
|
CarouselPrevious,
|
||||||
|
CarouselNext,
|
||||||
|
}
|
||||||
120
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root
|
||||||
|
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger
|
||||||
|
|
||||||
|
const DialogPortal = DialogPrimitive.Portal
|
||||||
|
|
||||||
|
const DialogClose = DialogPrimitive.Close
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
))
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogHeader.displayName = "DialogHeader"
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogFooter.displayName = "DialogFooter"
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogClose,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
}
|
||||||
198
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||||
|
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||||
|
|
||||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||||
|
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||||
|
|
||||||
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||||
|
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||||
|
|
||||||
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||||
|
|
||||||
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
))
|
||||||
|
DropdownMenuSubTrigger.displayName =
|
||||||
|
DropdownMenuPrimitive.SubTrigger.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSubContent.displayName =
|
||||||
|
DropdownMenuPrimitive.SubContent.displayName
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
))
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
))
|
||||||
|
DropdownMenuCheckboxItem.displayName =
|
||||||
|
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
))
|
||||||
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||||
|
inset?: boolean
|
||||||
|
}
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-sm font-semibold",
|
||||||
|
inset && "pl-8",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
const DropdownMenuShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
}
|
||||||
176
src/components/ui/form.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
ControllerProps,
|
||||||
|
FieldPath,
|
||||||
|
FieldValues,
|
||||||
|
FormProvider,
|
||||||
|
useFormContext,
|
||||||
|
} from "react-hook-form"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
||||||
|
const Form = FormProvider
|
||||||
|
|
||||||
|
type FormFieldContextValue<
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||||
|
> = {
|
||||||
|
name: TName
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||||
|
{} as FormFieldContextValue
|
||||||
|
)
|
||||||
|
|
||||||
|
const FormField = <
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||||
|
>({
|
||||||
|
...props
|
||||||
|
}: ControllerProps<TFieldValues, TName>) => {
|
||||||
|
return (
|
||||||
|
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||||
|
<Controller {...props} />
|
||||||
|
</FormFieldContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const useFormField = () => {
|
||||||
|
const fieldContext = React.useContext(FormFieldContext)
|
||||||
|
const itemContext = React.useContext(FormItemContext)
|
||||||
|
const { getFieldState, formState } = useFormContext()
|
||||||
|
|
||||||
|
const fieldState = getFieldState(fieldContext.name, formState)
|
||||||
|
|
||||||
|
if (!fieldContext) {
|
||||||
|
throw new Error("useFormField should be used within <FormField>")
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = itemContext
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: fieldContext.name,
|
||||||
|
formItemId: `${id}-form-item`,
|
||||||
|
formDescriptionId: `${id}-form-item-description`,
|
||||||
|
formMessageId: `${id}-form-item-message`,
|
||||||
|
...fieldState,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormItemContextValue = {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||||
|
{} as FormItemContextValue
|
||||||
|
)
|
||||||
|
|
||||||
|
const FormItem = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const id = React.useId()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItemContext.Provider value={{ id }}>
|
||||||
|
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||||
|
</FormItemContext.Provider>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormItem.displayName = "FormItem"
|
||||||
|
|
||||||
|
const FormLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { error, formItemId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(error && "text-destructive", className)}
|
||||||
|
htmlFor={formItemId}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormLabel.displayName = "FormLabel"
|
||||||
|
|
||||||
|
const FormControl = React.forwardRef<
|
||||||
|
React.ElementRef<typeof Slot>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof Slot>
|
||||||
|
>(({ ...props }, ref) => {
|
||||||
|
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slot
|
||||||
|
ref={ref}
|
||||||
|
id={formItemId}
|
||||||
|
aria-describedby={
|
||||||
|
!error
|
||||||
|
? `${formDescriptionId}`
|
||||||
|
: `${formDescriptionId} ${formMessageId}`
|
||||||
|
}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormControl.displayName = "FormControl"
|
||||||
|
|
||||||
|
const FormDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { formDescriptionId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={formDescriptionId}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormDescription.displayName = "FormDescription"
|
||||||
|
|
||||||
|
const FormMessage = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, children, ...props }, ref) => {
|
||||||
|
const { error, formMessageId } = useFormField()
|
||||||
|
const body = error ? String(error?.message) : children
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={formMessageId}
|
||||||
|
className={cn("text-sm font-medium text-destructive", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormMessage.displayName = "FormMessage"
|
||||||
|
|
||||||
|
export {
|
||||||
|
useFormField,
|
||||||
|
Form,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormMessage,
|
||||||
|
FormField,
|
||||||
|
}
|
||||||
22
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
|
export { Input }
|
||||||
24
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
||||||
29
src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Popover = PopoverPrimitive.Root
|
||||||
|
|
||||||
|
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||||
|
|
||||||
|
const PopoverContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
|
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
))
|
||||||
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent }
|
||||||
157
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||||
|
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group
|
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
))
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
))
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||||
|
|
||||||
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
))
|
||||||
|
SelectScrollDownButton.displayName =
|
||||||
|
SelectPrimitive.ScrollDownButton.displayName
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width)"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
))
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
))
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
}
|
||||||
15
src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Skeleton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
||||||
31
src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import { Toaster as Sonner } from "sonner"
|
||||||
|
|
||||||
|
type ToasterProps = React.ComponentProps<typeof Sonner>
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const { theme = "system" } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={theme as ToasterProps["theme"]}
|
||||||
|
className="toaster group"
|
||||||
|
toastOptions={{
|
||||||
|
classNames: {
|
||||||
|
toast:
|
||||||
|
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||||
|
description: "group-[.toast]:text-muted-foreground",
|
||||||
|
actionButton:
|
||||||
|
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||||
|
cancelButton:
|
||||||
|
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toaster }
|
||||||
29
src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Switch = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitives.Root
|
||||||
|
className={cn(
|
||||||
|
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<SwitchPrimitives.Thumb
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitives.Root>
|
||||||
|
))
|
||||||
|
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
117
src/components/ui/table.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Table = React.forwardRef<
|
||||||
|
HTMLTableElement,
|
||||||
|
React.HTMLAttributes<HTMLTableElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="relative w-full overflow-auto">
|
||||||
|
<table
|
||||||
|
ref={ref}
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
Table.displayName = "Table"
|
||||||
|
|
||||||
|
const TableHeader = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||||
|
))
|
||||||
|
TableHeader.displayName = "TableHeader"
|
||||||
|
|
||||||
|
const TableBody = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tbody
|
||||||
|
ref={ref}
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableBody.displayName = "TableBody"
|
||||||
|
|
||||||
|
const TableFooter = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tfoot
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableFooter.displayName = "TableFooter"
|
||||||
|
|
||||||
|
const TableRow = React.forwardRef<
|
||||||
|
HTMLTableRowElement,
|
||||||
|
React.HTMLAttributes<HTMLTableRowElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableRow.displayName = "TableRow"
|
||||||
|
|
||||||
|
const TableHead = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableHead.displayName = "TableHead"
|
||||||
|
|
||||||
|
const TableCell = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<td
|
||||||
|
ref={ref}
|
||||||
|
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableCell.displayName = "TableCell"
|
||||||
|
|
||||||
|
const TableCaption = React.forwardRef<
|
||||||
|
HTMLTableCaptionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<caption
|
||||||
|
ref={ref}
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableCaption.displayName = "TableCaption"
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
53
src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||||
24
src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export interface TextareaProps
|
||||||
|
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> { }
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Textarea.displayName = "Textarea"
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
58
src/contexts/ConfigContext.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface SiteConfig {
|
||||||
|
网站标题?: string;
|
||||||
|
网站副标题?: string;
|
||||||
|
Logo地址?: string;
|
||||||
|
Favicon?: string;
|
||||||
|
备案号?: string;
|
||||||
|
全局SEO关键词?: string;
|
||||||
|
全局SEO描述?: string;
|
||||||
|
底部版权信息?: string;
|
||||||
|
第三方统计代码?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfigContextType {
|
||||||
|
config: SiteConfig | null;
|
||||||
|
loading: boolean;
|
||||||
|
refreshConfig: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function ConfigProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [config, setConfig] = useState<SiteConfig | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const fetchConfig = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/public/config');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setConfig(data.site);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch site config:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfig();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfigContext.Provider value={{ config, loading, refreshConfig: fetchConfig }}>
|
||||||
|
{children}
|
||||||
|
</ConfigContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConfig() {
|
||||||
|
const context = useContext(ConfigContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useConfig must be used within a ConfigProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
80
src/hooks/useAuth.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { useState, useEffect, createContext, useContext } from 'react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
_id: string;
|
||||||
|
用户名: string;
|
||||||
|
邮箱: string;
|
||||||
|
头像: string;
|
||||||
|
角色: string;
|
||||||
|
钱包?: {
|
||||||
|
当前积分: number;
|
||||||
|
历史总消费: number;
|
||||||
|
};
|
||||||
|
会员信息?: {
|
||||||
|
当前等级ID?: {
|
||||||
|
_id: string;
|
||||||
|
套餐名称: string;
|
||||||
|
};
|
||||||
|
过期时间?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: User | null;
|
||||||
|
loading: boolean;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
refreshUser: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType>({
|
||||||
|
user: null,
|
||||||
|
loading: true,
|
||||||
|
logout: async () => { },
|
||||||
|
refreshUser: async () => { },
|
||||||
|
});
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const fetchUser = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/me');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setUser(data.user);
|
||||||
|
} else {
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch user', error);
|
||||||
|
setUser(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUser();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/auth/logout', { method: 'POST' });
|
||||||
|
setUser(null);
|
||||||
|
router.push('/auth/login');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout failed', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, loading, logout, refreshUser: fetchUser }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuth = () => useContext(AuthContext);
|
||||||
138
src/lib/alipay.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
import { SystemConfig } from '@/models';
|
||||||
|
import dbConnect from './dbConnect';
|
||||||
|
|
||||||
|
interface AlipayConfig {
|
||||||
|
appId: string;
|
||||||
|
privateKey: string;
|
||||||
|
alipayPublicKey: string;
|
||||||
|
gateway: string;
|
||||||
|
notifyUrl: string;
|
||||||
|
returnUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AlipayService {
|
||||||
|
private config: AlipayConfig | null = null;
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
if (this.config) return;
|
||||||
|
|
||||||
|
await dbConnect();
|
||||||
|
const systemConfig = await SystemConfig.findOne({ 配置标识: 'default' }).select('+支付宝设置.AppID +支付宝设置.应用私钥 +支付宝设置.公钥 +支付宝设置.网关地址 +支付宝设置.回调URL');
|
||||||
|
|
||||||
|
if (!systemConfig || !systemConfig.支付宝设置) {
|
||||||
|
throw new Error('Alipay configuration not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { AppID, 应用私钥, 公钥, 网关地址, 回调URL } = systemConfig.支付宝设置;
|
||||||
|
|
||||||
|
this.config = {
|
||||||
|
appId: AppID,
|
||||||
|
privateKey: this.convertBase64ToPem(应用私钥, 'PRIVATE KEY'),
|
||||||
|
alipayPublicKey: this.convertBase64ToPem(公钥, 'PUBLIC KEY'),
|
||||||
|
gateway: 网关地址 || 'https://openapi.alipay.com/gateway.do',
|
||||||
|
notifyUrl: 回调URL
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Base64 编码的密钥转换为 PEM 格式
|
||||||
|
*/
|
||||||
|
private convertBase64ToPem(base64Key: string, keyType: 'PRIVATE KEY' | 'PUBLIC KEY'): string {
|
||||||
|
// 如果已经是 PEM 格式(包含 BEGIN),直接返回
|
||||||
|
if (base64Key.includes('-----BEGIN')) {
|
||||||
|
return base64Key;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除所有空白字符
|
||||||
|
const cleanKey = base64Key.replace(/\s/g, '');
|
||||||
|
|
||||||
|
// 每 64 个字符插入一个换行符
|
||||||
|
const formattedKey = cleanKey.match(/.{1,64}/g)?.join('\n') || cleanKey;
|
||||||
|
|
||||||
|
return `-----BEGIN ${keyType}-----\n${formattedKey}\n-----END ${keyType}-----`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate Alipay Page Pay URL
|
||||||
|
*/
|
||||||
|
async generatePagePayUrl(order: { outTradeNo: string; totalAmount: string; subject: string; body?: string; returnUrl?: string }) {
|
||||||
|
await this.init();
|
||||||
|
if (!this.config) throw new Error('Alipay not initialized');
|
||||||
|
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
app_id: this.config.appId,
|
||||||
|
method: 'alipay.trade.page.pay',
|
||||||
|
format: 'JSON',
|
||||||
|
charset: 'utf-8',
|
||||||
|
sign_type: 'RSA2',
|
||||||
|
timestamp: new Date().toISOString().replace('T', ' ').split('.')[0],
|
||||||
|
version: '1.0',
|
||||||
|
notify_url: `${this.config.notifyUrl}/api/payment/notify`,
|
||||||
|
return_url: order.returnUrl || `${this.config.notifyUrl}/api/payment/return`, // 优先使用传入的跳转地址
|
||||||
|
biz_content: JSON.stringify({
|
||||||
|
out_trade_no: order.outTradeNo,
|
||||||
|
product_code: 'FAST_INSTANT_TRADE_PAY',
|
||||||
|
total_amount: order.totalAmount,
|
||||||
|
subject: order.subject,
|
||||||
|
body: order.body,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
params.sign = this.sign(params);
|
||||||
|
|
||||||
|
const query = Object.keys(params)
|
||||||
|
.map((key) => `${key}=${encodeURIComponent(params[key])}`)
|
||||||
|
.join('&');
|
||||||
|
|
||||||
|
return `${this.config.gateway}?${query}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify Alipay Signature
|
||||||
|
*/
|
||||||
|
async verifySignature(params: Record<string, string>): Promise<boolean> {
|
||||||
|
await this.init();
|
||||||
|
if (!this.config) throw new Error('Alipay not initialized');
|
||||||
|
|
||||||
|
const { sign, sign_type, ...rest } = params;
|
||||||
|
if (!sign) return false;
|
||||||
|
|
||||||
|
// Sort and stringify params
|
||||||
|
const content = Object.keys(rest)
|
||||||
|
.sort()
|
||||||
|
.map((key) => {
|
||||||
|
const value = rest[key];
|
||||||
|
return value !== '' && value !== undefined && value !== null ? `${key}=${value}` : null;
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('&');
|
||||||
|
|
||||||
|
const verify = crypto.createVerify('RSA-SHA256');
|
||||||
|
verify.update(content);
|
||||||
|
|
||||||
|
return verify.verify(this.config.alipayPublicKey, sign, 'base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal Sign Method
|
||||||
|
*/
|
||||||
|
private sign(params: Record<string, string>): string {
|
||||||
|
if (!this.config) throw new Error('Alipay not initialized');
|
||||||
|
|
||||||
|
const content = Object.keys(params)
|
||||||
|
.sort()
|
||||||
|
.map((key) => {
|
||||||
|
const value = params[key];
|
||||||
|
return value !== '' && value !== undefined && value !== null ? `${key}=${value}` : null;
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('&');
|
||||||
|
|
||||||
|
const sign = crypto.createSign('RSA-SHA256');
|
||||||
|
sign.update(content);
|
||||||
|
return sign.sign(this.config.privateKey, 'base64');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const alipayService = new AlipayService();
|
||||||
43
src/lib/api-handler.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import { ZodError } from 'zod';
|
||||||
|
|
||||||
|
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||||
|
|
||||||
|
interface ApiHandlerConfig {
|
||||||
|
[key: string]: (req: NextApiRequest, res: NextApiResponse) => Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AppError extends Error {
|
||||||
|
statusCode: number;
|
||||||
|
constructor(message: string, statusCode = 500) {
|
||||||
|
super(message);
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createApiHandler(handlers: ApiHandlerConfig) {
|
||||||
|
return async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
|
const method = req.method as HttpMethod;
|
||||||
|
const handler = handlers[method];
|
||||||
|
|
||||||
|
if (!handler) {
|
||||||
|
return res.status(405).json({ message: `Method ${method} Not Allowed` });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handler(req, res);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`API Error [${method} ${req.url}]:`, error);
|
||||||
|
|
||||||
|
if (error instanceof AppError) {
|
||||||
|
return res.status(error.statusCode).json({ message: error.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
return res.status(400).json({ message: 'Validation Error', errors: error.issues });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(500).json({ message: error.message || 'Internal Server Error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
53
src/lib/auth.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET;
|
||||||
|
if (!JWT_SECRET) {
|
||||||
|
throw new Error('Please define the JWT_SECRET environment variable inside .env.local');
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DecodedToken {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
iat: number;
|
||||||
|
exp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyToken(token: string): DecodedToken | null {
|
||||||
|
try {
|
||||||
|
return jwt.verify(token, JWT_SECRET as string) as unknown as DecodedToken;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserFromCookie(req: NextApiRequest): DecodedToken | null {
|
||||||
|
const token = req.cookies.token;
|
||||||
|
if (!token) return null;
|
||||||
|
return verifyToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const requireAuth = (handler: (req: NextApiRequest, res: NextApiResponse, user: DecodedToken) => Promise<void> | void) => {
|
||||||
|
return async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
|
const user = getUserFromCookie(req);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({ message: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler(req, res, user);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const requireAdmin = (handler: (req: NextApiRequest, res: NextApiResponse, user: DecodedToken) => Promise<void> | void) => {
|
||||||
|
return async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
|
const user = getUserFromCookie(req);
|
||||||
|
|
||||||
|
if (!user || user.role !== 'admin') {
|
||||||
|
return res.status(403).json({ message: 'Forbidden: Admin access required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler(req, res, user);
|
||||||
|
};
|
||||||
|
};
|
||||||
130
src/lib/dbConnect.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import mongoose from 'mongoose';
|
||||||
|
|
||||||
|
// 获取环境变量
|
||||||
|
const MONGODB_URI = process.env.MONGODB_URI;
|
||||||
|
|
||||||
|
if (!MONGODB_URI) {
|
||||||
|
throw new Error(
|
||||||
|
'请在 .env.local 文件中定义 MONGODB_URI 环境变量'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局类型定义,防止 TypeScript 报错
|
||||||
|
* 使用 var 声明以挂载到 globalThis 上
|
||||||
|
*/
|
||||||
|
interface MongooseCache {
|
||||||
|
conn: mongoose.Connection | null;
|
||||||
|
promise: Promise<mongoose.Connection> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
var mongoose: MongooseCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化全局缓存,防止热重载导致连接数爆炸
|
||||||
|
let cached = global.mongoose;
|
||||||
|
|
||||||
|
if (!cached) {
|
||||||
|
cached = global.mongoose = { conn: null, promise: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据库连接配置
|
||||||
|
* 针对生产环境和开发环境进行了微调
|
||||||
|
*/
|
||||||
|
const connectionOptions: mongoose.ConnectOptions = {
|
||||||
|
bufferCommands: false, // 禁用缓冲,确保立即失败而不是等待
|
||||||
|
maxPoolSize: process.env.NODE_ENV === 'production' ? 10 : 5, // 生产环境连接池稍大
|
||||||
|
minPoolSize: 2, // 保持少量活跃连接
|
||||||
|
serverSelectionTimeoutMS: 10000, // 寻找服务器超时 (10秒)
|
||||||
|
socketTimeoutMS: 45000, // Socket 操作超时 (45秒)
|
||||||
|
connectTimeoutMS: 10000, // 初始连接超时 (10秒)
|
||||||
|
family: 4, // 强制使用 IPv4,解决某些环境下 localhost 解析慢的问题
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 建立或获取缓存的 MongoDB 连接
|
||||||
|
* 这是核心函数,既可以在普通函数中调用,也可以被 HOF 使用
|
||||||
|
*/
|
||||||
|
async function dbConnect(): Promise<mongoose.Connection> {
|
||||||
|
// 1. 检查是否有活跃的连接 (readyState 1 表示 connected)
|
||||||
|
if (cached.conn && cached.conn.readyState === 1) {
|
||||||
|
return cached.conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 如果正在连接中,复用当前的 Promise,防止并发请求导致多次连接
|
||||||
|
if (cached.promise) {
|
||||||
|
try {
|
||||||
|
cached.conn = await cached.promise;
|
||||||
|
// 再次检查连接状态,确保 Promise 解析后的连接是可用的
|
||||||
|
if (cached.conn.readyState === 1) {
|
||||||
|
return cached.conn;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
cached.promise = null; // 出错则清除 Promise,允许重试
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 建立新连接
|
||||||
|
cached.promise = mongoose.connect(MONGODB_URI!, connectionOptions)
|
||||||
|
.then((mongooseInstance) => {
|
||||||
|
const connection = mongooseInstance.connection;
|
||||||
|
|
||||||
|
console.log('✅ [MongoDB] 新连接已建立');
|
||||||
|
|
||||||
|
// 绑定一次性监听器,防止内存泄漏
|
||||||
|
// 注意:Serverless 环境下,这些监听器可能不会长期存在,仅用于当前实例生命周期
|
||||||
|
connection.on('error', (err) => {
|
||||||
|
console.error('🔴 [MongoDB] 连接发生错误:', err);
|
||||||
|
cached.conn = null;
|
||||||
|
cached.promise = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.on('disconnected', () => {
|
||||||
|
console.warn('🟡 [MongoDB] 连接断开');
|
||||||
|
cached.conn = null;
|
||||||
|
cached.promise = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return connection;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('❌ [MongoDB] 连接建立失败:', error);
|
||||||
|
cached.promise = null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
cached.conn = await cached.promise;
|
||||||
|
} catch (e) {
|
||||||
|
cached.promise = null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cached.conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 辅助函数:获取当前连接状态(用于健康检查 API)
|
||||||
|
*/
|
||||||
|
export function getConnectionStatus() {
|
||||||
|
const connection = mongoose.connection;
|
||||||
|
const states = {
|
||||||
|
0: 'disconnected',
|
||||||
|
1: 'connected',
|
||||||
|
2: 'connecting',
|
||||||
|
3: 'disconnecting',
|
||||||
|
99: 'uninitialized',
|
||||||
|
};
|
||||||
|
const readyState = connection ? connection.readyState : 99;
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: states[readyState as keyof typeof states] || 'unknown',
|
||||||
|
readyState,
|
||||||
|
dbName: connection && connection.name ? connection.name : 'unknown'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default dbConnect;
|
||||||
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
30
src/lib/withDatabase.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { NextApiRequest, NextApiResponse, NextApiHandler } from 'next';
|
||||||
|
import dbConnect from './dbConnect';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 高阶函数 (HOF):自动处理数据库连接
|
||||||
|
* 用法: export default withDatabase(async (req, res) => { ... });
|
||||||
|
*/
|
||||||
|
const withDatabase = (handler: NextApiHandler) => {
|
||||||
|
return async (req: NextApiRequest, res: NextApiResponse) => {
|
||||||
|
try {
|
||||||
|
// 在执行 handler 之前确保数据库已连接
|
||||||
|
await dbConnect();
|
||||||
|
|
||||||
|
// 数据库连接成功后,执行原本的 API 逻辑
|
||||||
|
return await handler(req, res);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [API Error] 数据库连接失败或处理程序出错:', error);
|
||||||
|
|
||||||
|
// 统一错误响应
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Internal Server Error - Database Connection Failed',
|
||||||
|
// 在开发环境下返回详细错误信息
|
||||||
|
error: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withDatabase;
|
||||||
276
src/models/index.ts
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
import mongoose, { Schema, Document, Model } from 'mongoose';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ==================================================================
|
||||||
|
* 1. User (用户模型)
|
||||||
|
* ==================================================================
|
||||||
|
* 核心逻辑:
|
||||||
|
* - 用户是系统的基石,主要存储身份信息和资产信息。
|
||||||
|
*/
|
||||||
|
const UserSchema = new Schema({
|
||||||
|
用户名: { type: String, required: true, unique: true },
|
||||||
|
邮箱: { type: String, required: true, unique: true },
|
||||||
|
电话: { type: String },
|
||||||
|
密码: { type: String, required: true, select: false }, // select: false 保护密码
|
||||||
|
头像: { type: String, default: '/images/default_avatar.png' },
|
||||||
|
|
||||||
|
// 角色权限
|
||||||
|
角色: {
|
||||||
|
type: String,
|
||||||
|
enum: ['user', 'admin', 'editor'],
|
||||||
|
default: 'user'
|
||||||
|
},
|
||||||
|
|
||||||
|
// 钱包/资产
|
||||||
|
钱包: {
|
||||||
|
当前积分: { type: Number, default: 0 },
|
||||||
|
历史总消费: { type: Number, default: 0 }
|
||||||
|
},
|
||||||
|
|
||||||
|
// 会员逻辑
|
||||||
|
会员信息: {
|
||||||
|
当前等级ID: { type: Schema.Types.ObjectId, ref: 'MembershipPlan' },
|
||||||
|
过期时间: { type: Date }
|
||||||
|
},
|
||||||
|
|
||||||
|
// 账号风控
|
||||||
|
是否被封禁: { type: Boolean, default: false },
|
||||||
|
|
||||||
|
}, { timestamps: true });
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ==================================================================
|
||||||
|
* 2. Article (文章/资源模型)
|
||||||
|
* ==================================================================
|
||||||
|
*/
|
||||||
|
const ArticleSchema = new Schema({
|
||||||
|
// --- 基础信息 ---
|
||||||
|
文章标题: { type: String, required: true },
|
||||||
|
URL别名: { type: String, unique: true, index: true },
|
||||||
|
封面图: { type: String },
|
||||||
|
|
||||||
|
// --- 内容部分 ---
|
||||||
|
摘要: { type: String },
|
||||||
|
正文内容: { type: String, required: true },
|
||||||
|
|
||||||
|
// --- SEO 专用优化 ---
|
||||||
|
SEO关键词: { type: [String], default: [] },
|
||||||
|
SEO描述: { type: String },
|
||||||
|
|
||||||
|
// --- 关联信息 ---
|
||||||
|
作者ID: { type: Schema.Types.ObjectId, ref: 'User' },
|
||||||
|
分类ID: { type: Schema.Types.ObjectId, ref: 'Category' },
|
||||||
|
标签ID列表: [{ type: Schema.Types.ObjectId, ref: 'Tag' }],
|
||||||
|
|
||||||
|
// --- 售卖策略 ---
|
||||||
|
价格: { type: Number, default: 0 },
|
||||||
|
支付方式: { type: String, enum: ['points', 'cash'], default: 'points' },
|
||||||
|
|
||||||
|
// --- 资源交付 (付费后可见) ---
|
||||||
|
资源属性: {
|
||||||
|
下载链接: { type: String },
|
||||||
|
提取码: { type: String },
|
||||||
|
解压密码: { type: String },
|
||||||
|
隐藏内容: { type: String }
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- 数据统计 ---
|
||||||
|
统计数据: {
|
||||||
|
阅读数: { type: Number, default: 0 },
|
||||||
|
点赞数: { type: Number, default: 0 },
|
||||||
|
评论数: { type: Number, default: 0 },
|
||||||
|
收藏数: { type: Number, default: 0 },
|
||||||
|
分享数: { type: Number, default: 0 }
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- 状态控制 ---
|
||||||
|
发布状态: { type: String, enum: ['draft', 'published', 'offline'], default: 'published' }
|
||||||
|
}, { timestamps: true });
|
||||||
|
|
||||||
|
// 复合索引优化
|
||||||
|
ArticleSchema.index({ createdAt: -1 });
|
||||||
|
ArticleSchema.index({ 分类ID: 1, 发布状态: 1 });
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ==================================================================
|
||||||
|
* 3. SystemConfig (系统全局配置)
|
||||||
|
* ==================================================================
|
||||||
|
* 作用:存储网站配置、支付秘钥等。
|
||||||
|
*/
|
||||||
|
const SystemConfigSchema = new Schema({
|
||||||
|
// 标识符,确保只取这一条配置
|
||||||
|
配置标识: { type: String, default: 'default', unique: true },
|
||||||
|
|
||||||
|
// --- 站点基础信息 ---
|
||||||
|
站点设置: {
|
||||||
|
网站标题: { type: String, default: '我的个人资源站' },
|
||||||
|
网站副标题: { type: String },
|
||||||
|
Logo地址: { type: String },
|
||||||
|
Favicon: { type: String },
|
||||||
|
备案号: { type: String },
|
||||||
|
全局SEO关键词: { type: String },
|
||||||
|
全局SEO描述: { type: String },
|
||||||
|
底部版权信息: { type: String },
|
||||||
|
第三方统计代码: { type: String }
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---Banner配置(支持多个)---
|
||||||
|
Banner配置: [{
|
||||||
|
标题: { type: String },
|
||||||
|
描述: { type: String },
|
||||||
|
图片地址: { type: String },
|
||||||
|
按钮文本: { type: String },
|
||||||
|
按钮链接: { type: String },
|
||||||
|
状态: { type: String, enum: ['visible', 'hidden'], default: 'visible' }
|
||||||
|
}],
|
||||||
|
|
||||||
|
// --- 支付宝配置 (敏感字段 hidden) ---
|
||||||
|
支付宝设置: {
|
||||||
|
AppID: { type: String, select: false },
|
||||||
|
公钥: { type: String, select: false },
|
||||||
|
应用公钥: { type: String, select: false },
|
||||||
|
应用私钥: { type: String, select: false }, // 核心机密
|
||||||
|
回调URL: { type: String },
|
||||||
|
网关地址: { type: String }
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- 微信支付配置 (敏感字段 hidden) ---
|
||||||
|
微信支付设置: {
|
||||||
|
WX_APPID: { type: String, select: false },
|
||||||
|
WX_MCHID: { type: String, select: false },
|
||||||
|
WX_PRIVATE_KEY: { type: String, select: false }, // 核心机密
|
||||||
|
WX_API_V3_KEY: { type: String, select: false }, // 核心机密
|
||||||
|
WX_SERIAL_NO: { type: String }, // 证书序列号通常不需要隐藏
|
||||||
|
WX_NOTIFY_URL: { type: String },
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- 阿里云短信配置 (敏感字段 hidden) ---
|
||||||
|
阿里云短信设置: {
|
||||||
|
AccessKeyID: { type: String, select: false },
|
||||||
|
AccessKeySecret: { type: String, select: false },
|
||||||
|
aliSignName: { type: String },
|
||||||
|
aliTemplateCode: { type: String },
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- 邮箱配置 (敏感字段 hidden) ---
|
||||||
|
邮箱设置: {
|
||||||
|
MY_MAIL: { type: String },
|
||||||
|
MY_MAIL_PASS: { type: String, select: false }, // 【修复】授权码必须隐藏!
|
||||||
|
},
|
||||||
|
// --- AI助手配置 (数组形式,支持多个) ---
|
||||||
|
AI配置列表: [{
|
||||||
|
名称: { type: String, required: true }, // AI-Name (如: "写作助手-Gemini")
|
||||||
|
接口地址: { type: String, required: true }, // API-Endpoint
|
||||||
|
API密钥: { type: String, select: false, required: true }, // API-Key (敏感字段,默认不查询)
|
||||||
|
模型: { type: String, required: true }, // AI-Model (如: "gemini-1.5-pro", "gpt-4o")
|
||||||
|
系统提示词: { type: String }, // AI-System-Prompt
|
||||||
|
流式传输: { type: Boolean, default: false }, // stream: false (默认关闭流式)
|
||||||
|
是否启用: { type: Boolean, default: true }
|
||||||
|
}],
|
||||||
|
|
||||||
|
}, { timestamps: true });
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ==================================================================
|
||||||
|
* 4. Comment (评论模型)
|
||||||
|
* ==================================================================
|
||||||
|
*/
|
||||||
|
const CommentSchema = new Schema({
|
||||||
|
文章ID: { type: Schema.Types.ObjectId, ref: 'Article', required: true, index: true },
|
||||||
|
用户ID: { type: Schema.Types.ObjectId, ref: 'User', required: true },
|
||||||
|
评论内容: { type: String, required: true },
|
||||||
|
|
||||||
|
父评论ID: { type: Schema.Types.ObjectId, ref: 'Comment', default: null },
|
||||||
|
被回复用户ID: { type: Schema.Types.ObjectId, ref: 'User' },
|
||||||
|
|
||||||
|
点赞数: { type: Number, default: 0 },
|
||||||
|
是否置顶: { type: Boolean, default: false },
|
||||||
|
状态: { type: String, enum: ['visible', 'audit', 'deleted'], default: 'visible' }
|
||||||
|
}, { timestamps: true });
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ==================================================================
|
||||||
|
* 5. Order (订单模型)
|
||||||
|
* ==================================================================
|
||||||
|
* 核心更新:增加了“商品快照”,防止商品删除后订单显示异常。
|
||||||
|
*/
|
||||||
|
const OrderSchema = new Schema({
|
||||||
|
订单号: { type: String, required: true, unique: true },
|
||||||
|
用户ID: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true },
|
||||||
|
|
||||||
|
订单类型: {
|
||||||
|
type: String,
|
||||||
|
enum: ['buy_resource', 'buy_membership', 'recharge_points'],
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
|
||||||
|
// 关联ID (用于逻辑跳转)
|
||||||
|
商品ID: { type: Schema.Types.ObjectId },
|
||||||
|
|
||||||
|
// 【新增】商品快照:下单时的商品信息备份
|
||||||
|
商品快照: {
|
||||||
|
标题: { type: String }, // 如下单时文章叫"React V1",现在叫"React V2",订单里依然显示"V1"
|
||||||
|
封面: { type: String }
|
||||||
|
},
|
||||||
|
|
||||||
|
支付金额: { type: Number, required: true },
|
||||||
|
支付方式: { type: String, enum: ['alipay', 'wechat', 'points', 'balance'] },
|
||||||
|
|
||||||
|
订单状态: {
|
||||||
|
type: String,
|
||||||
|
enum: ['pending', 'paid'],
|
||||||
|
default: 'pending'
|
||||||
|
},
|
||||||
|
支付时间: { type: Date }
|
||||||
|
}, { timestamps: true });
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ==================================================================
|
||||||
|
* 6. MembershipPlan (会员套餐)
|
||||||
|
* ==================================================================
|
||||||
|
*/
|
||||||
|
const MembershipPlanSchema = new Schema({
|
||||||
|
套餐名称: { type: String, required: true },
|
||||||
|
有效天数: { type: Number, required: true },
|
||||||
|
价格: { type: Number, required: true },
|
||||||
|
描述: { type: String },
|
||||||
|
|
||||||
|
特权配置: {
|
||||||
|
每日下载限制: { type: Number, default: 10 },
|
||||||
|
购买折扣: { type: Number, default: 0.8 }
|
||||||
|
},
|
||||||
|
是否上架: { type: Boolean, default: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ==================================================================
|
||||||
|
* 7. Category & Tag (分类/标签)
|
||||||
|
* ==================================================================
|
||||||
|
*/
|
||||||
|
const CategorySchema = new Schema({
|
||||||
|
分类名称: { type: String, required: true },
|
||||||
|
别名: { type: String, required: true, unique: true },
|
||||||
|
排序权重: { type: Number, default: 0 }
|
||||||
|
});
|
||||||
|
|
||||||
|
const TagSchema = new Schema({
|
||||||
|
标签名称: { type: String, required: true, unique: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ------------------------- 模型导出 -------------------------
|
||||||
|
|
||||||
|
export const User = mongoose.models.User || mongoose.model('User', UserSchema);
|
||||||
|
export const SystemConfig = mongoose.models.SystemConfig || mongoose.model('SystemConfig', SystemConfigSchema);
|
||||||
|
export const Article = mongoose.models.Article || mongoose.model('Article', ArticleSchema);
|
||||||
|
export const Comment = mongoose.models.Comment || mongoose.model('Comment', CommentSchema);
|
||||||
|
export const Order = mongoose.models.Order || mongoose.model('Order', OrderSchema);
|
||||||
|
export const MembershipPlan = mongoose.models.MembershipPlan || mongoose.model('MembershipPlan', MembershipPlanSchema);
|
||||||
|
export const Category = mongoose.models.Category || mongoose.model('Category', CategorySchema);
|
||||||
|
export const Tag = mongoose.models.Tag || mongoose.model('Tag', TagSchema);
|
||||||
34
src/pages/404.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Home, MoveLeft } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function Custom404() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-50 px-4 text-center">
|
||||||
|
<div className="space-y-6 max-w-md">
|
||||||
|
{/* Illustration placeholder or large text */}
|
||||||
|
<h1 className="text-9xl font-extrabold text-gray-200">404</h1>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="text-3xl font-bold text-gray-900">页面未找到</h2>
|
||||||
|
<p className="text-gray-500">
|
||||||
|
抱歉,您访问的页面不存在或已被移除。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center gap-4 pt-4">
|
||||||
|
<Button variant="outline" onClick={() => window.history.back()}>
|
||||||
|
<MoveLeft className="mr-2 h-4 w-4" />
|
||||||
|
返回上一页
|
||||||
|
</Button>
|
||||||
|
<Link href="/">
|
||||||
|
<Button>
|
||||||
|
<Home className="mr-2 h-4 w-4" />
|
||||||
|
回到首页
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
src/pages/_app.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import '@/styles/globals.css';
|
||||||
|
import type { AppProps } from 'next/app';
|
||||||
|
import { AuthProvider } from '@/hooks/useAuth';
|
||||||
|
import { ConfigProvider } from '@/contexts/ConfigContext';
|
||||||
|
|
||||||
|
export default function App({ Component, pageProps }: AppProps) {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<ConfigProvider>
|
||||||
|
<Component {...pageProps} />
|
||||||
|
</ConfigProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
src/pages/_document.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Html, Head, Main, NextScript } from "next/document";
|
||||||
|
|
||||||
|
export default function Document() {
|
||||||
|
return (
|
||||||
|
<Html lang="zh">
|
||||||
|
<Head />
|
||||||
|
<body className="antialiased">
|
||||||
|
<Main />
|
||||||
|
<NextScript />
|
||||||
|
</body>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
}
|
||||||
315
src/pages/admin/ai/index.tsx
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import AdminLayout from '@/components/admin/AdminLayout';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
|
import { Loader2, Plus, Bot, Trash2, Edit, Save, MoreVertical, Sparkles } from 'lucide-react';
|
||||||
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface AIConfig {
|
||||||
|
_id?: string;
|
||||||
|
名称: string;
|
||||||
|
接口地址: string;
|
||||||
|
API密钥?: string;
|
||||||
|
模型: string;
|
||||||
|
系统提示词?: string;
|
||||||
|
流式传输?: boolean;
|
||||||
|
是否启用: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AIAdminPage() {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [assistants, setAssistants] = useState<AIConfig[]>([]);
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
|
const [editingAssistant, setEditingAssistant] = useState<AIConfig | null>(null);
|
||||||
|
|
||||||
|
const { register, control, handleSubmit, reset, setValue, watch } = useForm<AIConfig>({
|
||||||
|
defaultValues: {
|
||||||
|
名称: '',
|
||||||
|
接口地址: 'https://generativelanguage.googleapis.com/v1beta/openai',
|
||||||
|
API密钥: '',
|
||||||
|
模型: 'gemini-1.5-flash',
|
||||||
|
系统提示词: '',
|
||||||
|
流式传输: true,
|
||||||
|
是否启用: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAssistants();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchAssistants = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/settings');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setAssistants(data.AI配置列表 || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch AI settings', error);
|
||||||
|
toast.error('获取 AI 配置失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (data: AIConfig) => {
|
||||||
|
try {
|
||||||
|
let newAssistants = [...assistants];
|
||||||
|
|
||||||
|
if (editingAssistant) {
|
||||||
|
// Update existing
|
||||||
|
newAssistants = newAssistants.map(a =>
|
||||||
|
(a._id === editingAssistant._id || (a as any).id === (editingAssistant as any).id) ? { ...data, _id: a._id } : a
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Add new
|
||||||
|
newAssistants.push(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// We need to save the entire SystemConfig, but we only have the AI list here.
|
||||||
|
// So we first fetch the current config to get other settings, then update.
|
||||||
|
// Optimization: The backend API should ideally support patching just one field,
|
||||||
|
// but reusing the existing /api/admin/settings endpoint which expects the full object structure
|
||||||
|
// or we can send a partial update if the API supports it.
|
||||||
|
// Let's assume we need to fetch first to be safe, or just send the partial structure if the backend handles it.
|
||||||
|
// Looking at the previous code, the backend likely does a merge or replacement.
|
||||||
|
// Let's fetch current settings first to be safe.
|
||||||
|
|
||||||
|
const resFetch = await fetch('/api/admin/settings');
|
||||||
|
const currentSettings = await resFetch.json();
|
||||||
|
|
||||||
|
const updatedSettings = {
|
||||||
|
...currentSettings,
|
||||||
|
AI配置列表: newAssistants
|
||||||
|
};
|
||||||
|
|
||||||
|
const resSave = await fetch('/api/admin/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(updatedSettings),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resSave.ok) {
|
||||||
|
toast.success(editingAssistant ? 'AI 助手已更新' : 'AI 助手已创建');
|
||||||
|
setIsDialogOpen(false);
|
||||||
|
setEditingAssistant(null);
|
||||||
|
reset();
|
||||||
|
fetchAssistants();
|
||||||
|
} else {
|
||||||
|
toast.error('保存失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save AI settings', error);
|
||||||
|
toast.error('保存出错');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (index: number) => {
|
||||||
|
if (!confirm('确定要删除这个 AI 助手吗?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newAssistants = [...assistants];
|
||||||
|
newAssistants.splice(index, 1);
|
||||||
|
|
||||||
|
const resFetch = await fetch('/api/admin/settings');
|
||||||
|
const currentSettings = await resFetch.json();
|
||||||
|
|
||||||
|
const updatedSettings = {
|
||||||
|
...currentSettings,
|
||||||
|
AI配置列表: newAssistants
|
||||||
|
};
|
||||||
|
|
||||||
|
const resSave = await fetch('/api/admin/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(updatedSettings),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resSave.ok) {
|
||||||
|
toast.success('AI 助手已删除');
|
||||||
|
fetchAssistants();
|
||||||
|
} else {
|
||||||
|
toast.error('删除失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete assistant', error);
|
||||||
|
toast.error('删除出错');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAddDialog = () => {
|
||||||
|
setEditingAssistant(null);
|
||||||
|
reset({
|
||||||
|
名称: 'New Assistant',
|
||||||
|
接口地址: 'https://generativelanguage.googleapis.com/v1beta/openai',
|
||||||
|
API密钥: '',
|
||||||
|
模型: 'gemini-1.5-flash',
|
||||||
|
系统提示词: '你是一个智能助手。',
|
||||||
|
流式传输: true,
|
||||||
|
是否启用: true
|
||||||
|
});
|
||||||
|
setIsDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditDialog = (assistant: AIConfig) => {
|
||||||
|
setEditingAssistant(assistant);
|
||||||
|
reset(assistant);
|
||||||
|
setIsDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<AdminLayout>
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout>
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">AI 助手管理</h2>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
配置和管理您的 AI 智能体,支持多模型接入。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={openAddDialog}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" /> 添加助手
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{assistants.map((assistant, index) => (
|
||||||
|
<Card key={index} className="relative group hover:shadow-lg transition-all duration-300 border-t-4 border-t-primary/20 hover:border-t-primary">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
|
||||||
|
<Bot className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg">{assistant.名称}</CardTitle>
|
||||||
|
<CardDescription className="mt-1 flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="text-xs font-normal">
|
||||||
|
{assistant.模型}
|
||||||
|
</Badge>
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`w-2.5 h-2.5 rounded-full ${assistant.是否启用 ? 'bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.5)]' : 'bg-gray-300'}`} title={assistant.是否启用 ? "已启用" : "已禁用"} />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pb-4">
|
||||||
|
<div className="space-y-3 text-sm text-gray-500">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="w-4 h-4 text-purple-500" />
|
||||||
|
<span className="truncate max-w-[200px]">{assistant.接口地址}</span>
|
||||||
|
</div>
|
||||||
|
<p className="line-clamp-2 min-h-[40px] bg-gray-50 p-2 rounded-md text-xs">
|
||||||
|
{assistant.系统提示词 || "无系统提示词"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="pt-2 flex justify-end gap-2 border-t bg-gray-50/50 rounded-b-xl">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => openEditDialog(assistant)}>
|
||||||
|
<Edit className="w-4 h-4 mr-1" /> 编辑
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="text-red-500 hover:text-red-600 hover:bg-red-50" onClick={() => handleDelete(index)}>
|
||||||
|
<Trash2 className="w-4 h-4 mr-1" /> 删除
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Add New Card Button */}
|
||||||
|
<button
|
||||||
|
onClick={openAddDialog}
|
||||||
|
className="flex flex-col items-center justify-center h-full min-h-[240px] border-2 border-dashed border-gray-200 rounded-xl hover:border-primary/50 hover:bg-primary/5 transition-all group cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="w-12 h-12 rounded-full bg-gray-100 group-hover:bg-primary/20 flex items-center justify-center mb-3 transition-colors">
|
||||||
|
<Plus className="w-6 h-6 text-gray-400 group-hover:text-primary" />
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-gray-500 group-hover:text-primary">创建新助手</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[600px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingAssistant ? '编辑 AI 助手' : '添加 AI 助手'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
配置 AI 模型的连接参数和行为设定。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit(handleSave)} className="space-y-6 py-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>助手名称</Label>
|
||||||
|
<Input {...register('名称', { required: true })} placeholder="例如: 写作助手" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>模型 (Model)</Label>
|
||||||
|
<Input {...register('模型', { required: true })} placeholder="例如: gemini-1.5-pro" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 col-span-2">
|
||||||
|
<Label>接口地址 (Endpoint)</Label>
|
||||||
|
<Input {...register('接口地址', { required: true })} placeholder="https://..." />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 col-span-2">
|
||||||
|
<Label>API 密钥</Label>
|
||||||
|
<Input type="password" {...register('API密钥')} placeholder={editingAssistant ? "留空则不修改" : "请输入 API Key"} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 col-span-2">
|
||||||
|
<Label>系统提示词 (System Prompt)</Label>
|
||||||
|
<Textarea {...register('系统提示词')} rows={4} placeholder="设定 AI 的角色和行为..." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="是否启用"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Label>启用助手</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="流式传输"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Label>流式传输</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setIsDialogOpen(false)}>取消</Button>
|
||||||
|
<Button type="submit">保存配置</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/pages/admin/articles/create.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import ArticleEditor from '@/components/admin/ArticleEditor';
|
||||||
|
|
||||||
|
export default function CreateArticlePage() {
|
||||||
|
return <ArticleEditor mode="create" />;
|
||||||
|
}
|
||||||
17
src/pages/admin/articles/edit/[id].tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import ArticleEditor from '@/components/admin/ArticleEditor';
|
||||||
|
import AdminLayout from '@/components/admin/AdminLayout';
|
||||||
|
|
||||||
|
export default function EditArticlePage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { id } = router.query;
|
||||||
|
|
||||||
|
if (!id) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout>
|
||||||
|
<ArticleEditor mode="edit" articleId={id as string} />
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
238
src/pages/admin/articles/index.tsx
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import AdminLayout from '@/components/admin/AdminLayout';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Plus, Search, Edit, Trash2, Eye, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Article {
|
||||||
|
_id: string;
|
||||||
|
文章标题: string;
|
||||||
|
分类ID: { _id: string; 分类名称: string } | null;
|
||||||
|
作者ID: { _id: string; username: string } | null;
|
||||||
|
发布状态: 'draft' | 'published' | 'offline';
|
||||||
|
统计数据: {
|
||||||
|
阅读数: number;
|
||||||
|
点赞数: number;
|
||||||
|
};
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ArticlesPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [articles, setArticles] = useState<Article[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchArticles();
|
||||||
|
}, [page, searchTerm]);
|
||||||
|
|
||||||
|
const fetchArticles = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/articles?page=${page}&limit=10&search=${searchTerm}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
setArticles(data.articles);
|
||||||
|
setTotalPages(data.pagination.pages);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch articles', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteId) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/articles/${deleteId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
fetchArticles();
|
||||||
|
setDeleteId(null);
|
||||||
|
} else {
|
||||||
|
alert('删除失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete article', error);
|
||||||
|
alert('删除出错');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'published':
|
||||||
|
return <Badge className="bg-green-500">已发布</Badge>;
|
||||||
|
case 'draft':
|
||||||
|
return <Badge variant="secondary">草稿</Badge>;
|
||||||
|
case 'offline':
|
||||||
|
return <Badge variant="destructive">已下架</Badge>;
|
||||||
|
default:
|
||||||
|
return <Badge variant="outline">{status}</Badge>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">资源管理</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
管理网站的所有文章和资源内容。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/admin/articles/create">
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
发布新资源
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="relative flex-1 max-w-sm">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="搜索文章标题..."
|
||||||
|
className="pl-8"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchTerm(e.target.value);
|
||||||
|
setPage(1); // 重置到第一页
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border bg-white">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>标题</TableHead>
|
||||||
|
<TableHead>分类</TableHead>
|
||||||
|
<TableHead>作者</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>数据 (阅/赞)</TableHead>
|
||||||
|
<TableHead>发布时间</TableHead>
|
||||||
|
<TableHead className="text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="h-24 text-center">
|
||||||
|
<Loader2 className="mx-auto h-6 w-6 animate-spin" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : articles.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="h-24 text-center">
|
||||||
|
暂无数据
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
articles.map((article) => (
|
||||||
|
<TableRow key={article._id}>
|
||||||
|
<TableCell className="font-medium max-w-[200px] truncate" title={article.文章标题}>
|
||||||
|
{article.文章标题}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{article.分类ID?.分类名称 || '-'}</TableCell>
|
||||||
|
<TableCell>{article.作者ID?.username || 'Unknown'}</TableCell>
|
||||||
|
<TableCell>{getStatusBadge(article.发布状态)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{article.统计数据.阅读数} / {article.统计数据.点赞数}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{new Date(article.createdAt).toLocaleDateString()}</TableCell>
|
||||||
|
<TableCell className="text-right space-x-2">
|
||||||
|
<Link href={`/admin/articles/edit/${article._id}`}>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||||
|
onClick={() => setDeleteId(article._id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="flex items-center justify-end space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
第 {page} 页 / 共 {totalPages} 页
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<Dialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>确认删除</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
您确定要删除这篇文章吗?此操作无法撤销。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteId(null)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDelete}>
|
||||||
|
确认删除
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
230
src/pages/admin/banners/index.tsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import AdminLayout from '@/components/admin/AdminLayout';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Loader2, Save, Trash2, Plus, Image as ImageIcon } from 'lucide-react';
|
||||||
|
import { useForm, useFieldArray, Controller } from 'react-hook-form';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface BannerConfig {
|
||||||
|
Banner配置: {
|
||||||
|
标题: string;
|
||||||
|
描述: string;
|
||||||
|
图片地址: string;
|
||||||
|
按钮文本: string;
|
||||||
|
按钮链接: string;
|
||||||
|
状态: 'visible' | 'hidden';
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BannerSettings() {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const { register, control, handleSubmit, reset } = useForm<BannerConfig>({
|
||||||
|
defaultValues: {
|
||||||
|
Banner配置: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { fields, append, remove } = useFieldArray({
|
||||||
|
control,
|
||||||
|
name: "Banner配置"
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchSettings = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/settings');
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
// Only reset if Banner配置 exists, otherwise default to empty array
|
||||||
|
reset({ Banner配置: data.Banner配置 || [] });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch settings', error);
|
||||||
|
toast.error('加载配置失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (data: BannerConfig) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
// We need to merge with existing settings, so we fetch first or just send a partial update if API supports it.
|
||||||
|
// Our current API replaces the whole object usually, but let's check.
|
||||||
|
// Actually, the /api/admin/settings endpoint likely does a merge or we should send everything.
|
||||||
|
// To be safe and since we don't have the full state here, we should probably fetch current settings, merge, and save.
|
||||||
|
// OR, we can update the API to handle partial updates.
|
||||||
|
// Let's assume we need to fetch current settings first to avoid overwriting other fields.
|
||||||
|
|
||||||
|
const currentSettingsRes = await fetch('/api/admin/settings');
|
||||||
|
const currentSettings = await currentSettingsRes.json();
|
||||||
|
|
||||||
|
const newSettings = {
|
||||||
|
...currentSettings,
|
||||||
|
Banner配置: data.Banner配置
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch('/api/admin/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(newSettings),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success('Banner 配置已保存');
|
||||||
|
fetchSettings();
|
||||||
|
} else {
|
||||||
|
toast.error('保存失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save settings', error);
|
||||||
|
toast.error('保存出错');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<AdminLayout>
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">Banner 管理</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
配置首页轮播图或横幅内容。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => append({
|
||||||
|
标题: '新 Banner',
|
||||||
|
描述: '这是一个新的 Banner 描述',
|
||||||
|
图片地址: 'https://images.unsplash.com/photo-1579546929518-9e396f3cc809',
|
||||||
|
按钮文本: '查看详情',
|
||||||
|
按钮链接: '/',
|
||||||
|
状态: 'visible'
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" /> 添加 Banner
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<Card key={field.id}>
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ImageIcon className="w-5 h-5 text-gray-500" />
|
||||||
|
<CardTitle className="text-base">Banner #{index + 1}</CardTitle>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||||
|
onClick={() => remove(index)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>标题</Label>
|
||||||
|
<Input {...register(`Banner配置.${index}.标题`)} placeholder="Banner 标题" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>描述</Label>
|
||||||
|
<Input {...register(`Banner配置.${index}.描述`)} placeholder="简短描述" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<Label>图片地址</Label>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Input {...register(`Banner配置.${index}.图片地址`)} placeholder="https://..." className="flex-1" />
|
||||||
|
<div className="w-24 h-10 bg-gray-100 rounded overflow-hidden shrink-0 border">
|
||||||
|
{/* Preview logic could go here, but simple img tag relies on valid url */}
|
||||||
|
<img
|
||||||
|
src={control._formValues.Banner配置?.[index]?.图片地址 || ''}
|
||||||
|
alt="Preview"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
onError={(e) => (e.currentTarget.style.display = 'none')}
|
||||||
|
onLoad={(e) => (e.currentTarget.style.display = 'block')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>按钮文本</Label>
|
||||||
|
<Input {...register(`Banner配置.${index}.按钮文本`)} placeholder="例如: 立即查看" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>按钮链接</Label>
|
||||||
|
<Input {...register(`Banner配置.${index}.按钮链接`)} placeholder="/article/123" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={`Banner配置.${index}.状态`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Switch
|
||||||
|
checked={field.value === 'visible'}
|
||||||
|
onCheckedChange={(checked) => field.onChange(checked ? 'visible' : 'hidden')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Label className="text-sm text-muted-foreground">
|
||||||
|
{control._formValues.Banner配置?.[index]?.状态 === 'visible' ? '显示' : '隐藏'}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{fields.length === 0 && (
|
||||||
|
<div className="text-center py-12 text-muted-foreground border-2 border-dashed rounded-lg">
|
||||||
|
暂无 Banner 配置,点击上方按钮添加。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex justify-end sticky bottom-6">
|
||||||
|
<Button type="submit" disabled={saving} size="lg" className="shadow-lg">
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
保存中...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
保存配置
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
191
src/pages/admin/index.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { GetServerSideProps } from 'next';
|
||||||
|
import AdminLayout from '@/components/admin/AdminLayout';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Users, FileText, ShoppingBag, DollarSign } from 'lucide-react';
|
||||||
|
import { verifyToken } from '@/lib/auth';
|
||||||
|
import { User, Article, Order } from '@/models';
|
||||||
|
|
||||||
|
// 定义统计数据接口
|
||||||
|
interface DashboardStats {
|
||||||
|
totalUsers: number;
|
||||||
|
totalArticles: number;
|
||||||
|
totalOrders: number;
|
||||||
|
totalRevenue: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminDashboard({ stats }: { stats: DashboardStats }) {
|
||||||
|
return (
|
||||||
|
<AdminLayout>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">仪表盘</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
查看系统概览和关键指标。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">总用户数</CardTitle>
|
||||||
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats.totalUsers}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
+20.1% 较上月
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">文章总数</CardTitle>
|
||||||
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats.totalArticles}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
+15 篇 本周新增
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">总订单数</CardTitle>
|
||||||
|
<ShoppingBag className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stats.totalOrders}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
+19% 较上月
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">总收入</CardTitle>
|
||||||
|
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">¥{stats.totalRevenue.toFixed(2)}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
+10.5% 较上月
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||||
|
<Card className="col-span-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>近期概览</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pl-2">
|
||||||
|
<div className="h-[200px] flex items-center justify-center text-muted-foreground">
|
||||||
|
图表组件待集成 (Recharts)
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="col-span-3">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>最新动态</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* 模拟动态数据 */}
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="relative flex h-2 w-2 mr-2">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-sky-400 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-sky-500"></span>
|
||||||
|
</span>
|
||||||
|
<div className="ml-4 space-y-1">
|
||||||
|
<p className="text-sm font-medium leading-none">新用户注册</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
zhangsan@example.com
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto font-medium text-xs text-muted-foreground">刚刚</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="relative flex h-2 w-2 mr-2">
|
||||||
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
|
||||||
|
</span>
|
||||||
|
<div className="ml-4 space-y-1">
|
||||||
|
<p className="text-sm font-medium leading-none">新订单支付成功</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
¥299.00 - 年度会员
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto font-medium text-xs text-muted-foreground">5分钟前</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||||
|
const { req } = context;
|
||||||
|
const token = req.cookies.token;
|
||||||
|
|
||||||
|
// 1. 验证 Token
|
||||||
|
if (!token) {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
destination: '/auth/login?redirect=/admin',
|
||||||
|
permanent: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = verifyToken(token);
|
||||||
|
if (!user || user.role !== 'admin') {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
destination: '/', // 非管理员跳转首页
|
||||||
|
permanent: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 获取统计数据 (SSR)
|
||||||
|
// 使用 withDatabase 确保数据库连接
|
||||||
|
// 注意:getServerSideProps 中不能直接使用 withDatabase 高阶函数包裹,需要手动调用连接逻辑或确保全局连接
|
||||||
|
// 这里我们假设 mongoose 已经在 api 路由或其他地方连接过,或者我们在 _app.tsx 中处理了连接
|
||||||
|
// 为了稳妥,我们在 lib/dbConnect.ts 中应该有一个缓存连接的逻辑,这里简单起见,我们直接查询
|
||||||
|
// 更好的做法是把数据获取逻辑封装成 service
|
||||||
|
|
||||||
|
// 由于 withDatabase 是 API 路由的高阶函数,这里我们直接使用 mongoose
|
||||||
|
// 但为了避免连接问题,我们最好在 models/index.ts 导出时确保连接,或者在这里手动 connect
|
||||||
|
// 鉴于项目结构,我们假设 models 已经可用。
|
||||||
|
|
||||||
|
// 修正:在 getServerSideProps 中直接操作数据库需要确保连接。
|
||||||
|
// 我们临时引入 mongoose 并连接。
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
if (mongoose.connection.readyState === 0) {
|
||||||
|
await mongoose.connect(process.env.MONGODB_URI);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalUsers = await User.countDocuments();
|
||||||
|
const totalArticles = await Article.countDocuments();
|
||||||
|
const totalOrders = await Order.countDocuments();
|
||||||
|
|
||||||
|
// 计算总收入
|
||||||
|
const orders = await Order.find({ 订单状态: 'paid' }, '支付金额');
|
||||||
|
const totalRevenue = orders.reduce((acc: number, order: any) => acc + (order.支付金额 || 0), 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
stats: {
|
||||||
|
totalUsers,
|
||||||
|
totalArticles,
|
||||||
|
totalOrders,
|
||||||
|
totalRevenue
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
232
src/pages/admin/orders/index.tsx
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import AdminLayout from '@/components/admin/AdminLayout';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Loader2, Search, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { zhCN } from 'date-fns/locale';
|
||||||
|
|
||||||
|
export default function OrderList() {
|
||||||
|
const [orders, setOrders] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [pagination, setPagination] = useState({
|
||||||
|
page: 1,
|
||||||
|
limit: 10,
|
||||||
|
total: 0,
|
||||||
|
pages: 0
|
||||||
|
});
|
||||||
|
const [filters, setFilters] = useState({
|
||||||
|
status: 'all',
|
||||||
|
search: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchOrders();
|
||||||
|
}, [pagination.page, filters]);
|
||||||
|
|
||||||
|
const fetchOrders = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
page: pagination.page.toString(),
|
||||||
|
limit: pagination.limit.toString(),
|
||||||
|
status: filters.status,
|
||||||
|
search: filters.search
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(`/api/admin/orders?${query}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setOrders(data.orders);
|
||||||
|
setPagination(data.pagination);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch orders', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setPagination(prev => ({ ...prev, page: 1 }));
|
||||||
|
fetchOrders(); // Trigger fetch immediately or let useEffect handle it if search state changed
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'paid':
|
||||||
|
return <Badge className="bg-green-500">已支付</Badge>;
|
||||||
|
case 'pending':
|
||||||
|
return <Badge variant="outline" className="text-yellow-600 border-yellow-600">待支付</Badge>;
|
||||||
|
default:
|
||||||
|
return <Badge variant="outline">{status}</Badge>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOrderTypeLabel = (type: string) => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
'buy_membership': '购买会员',
|
||||||
|
'buy_resource': '购买资源',
|
||||||
|
'recharge_points': '充值积分'
|
||||||
|
};
|
||||||
|
return map[type] || type;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">订单管理</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 bg-white p-4 rounded-lg border border-gray-100 shadow-sm">
|
||||||
|
<div className="w-full sm:w-48">
|
||||||
|
<Select
|
||||||
|
value={filters.status}
|
||||||
|
onValueChange={(value) => setFilters(prev => ({ ...prev, status: value, page: 1 }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="订单状态" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部状态</SelectItem>
|
||||||
|
<SelectItem value="paid">已支付</SelectItem>
|
||||||
|
<SelectItem value="pending">待支付</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSearch} className="flex-1 flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500" />
|
||||||
|
<Input
|
||||||
|
placeholder="搜索订单号、用户名或邮箱..."
|
||||||
|
className="pl-9"
|
||||||
|
value={filters.search}
|
||||||
|
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit">搜索</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="bg-white rounded-lg border border-gray-100 shadow-sm overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>订单号</TableHead>
|
||||||
|
<TableHead>用户</TableHead>
|
||||||
|
<TableHead>商品信息</TableHead>
|
||||||
|
<TableHead>金额</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>支付方式</TableHead>
|
||||||
|
<TableHead>创建时间</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="h-24 text-center">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : orders.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="h-24 text-center text-gray-500">
|
||||||
|
暂无订单数据
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
orders.map((order) => (
|
||||||
|
<TableRow key={order._id}>
|
||||||
|
<TableCell className="font-mono text-xs">{order.订单号}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-gray-100 overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={order.用户ID?.头像 || '/images/default_avatar.png'}
|
||||||
|
alt="Avatar"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium">{order.用户ID?.用户名 || '未知用户'}</span>
|
||||||
|
<span className="text-xs text-gray-500">{order.用户ID?.邮箱}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{getOrderTypeLabel(order.订单类型)}</span>
|
||||||
|
<span className="text-xs text-gray-500">{order.商品快照?.标题}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-bold text-gray-900">
|
||||||
|
¥{order.支付金额.toFixed(2)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{getStatusBadge(order.订单状态)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{order.支付方式 === 'alipay' && <span className="text-blue-500 text-sm">支付宝</span>}
|
||||||
|
{order.支付方式 === 'wechat' && <span className="text-green-500 text-sm">微信支付</span>}
|
||||||
|
{!order.支付方式 && <span className="text-gray-400 text-sm">-</span>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-gray-500 text-sm">
|
||||||
|
{format(new Date(order.createdAt), 'yyyy-MM-dd HH:mm', { locale: zhCN })}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
共 {pagination.total} 条记录,当前第 {pagination.page} / {pagination.pages} 页
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPagination(prev => ({ ...prev, page: prev.page - 1 }))}
|
||||||
|
disabled={pagination.page <= 1}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPagination(prev => ({ ...prev, page: prev.page + 1 }))}
|
||||||
|
disabled={pagination.page >= pagination.pages}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
167
src/pages/admin/plans/edit/[id].tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import AdminLayout from '@/components/admin/AdminLayout';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Loader2, Save, ArrowLeft } from 'lucide-react';
|
||||||
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
|
|
||||||
|
export default function PlanEditor() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { id } = router.query;
|
||||||
|
const isEditMode = id && id !== 'create';
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(isEditMode ? true : false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const { register, handleSubmit, control, reset } = useForm({
|
||||||
|
defaultValues: {
|
||||||
|
套餐名称: '',
|
||||||
|
有效天数: 30,
|
||||||
|
价格: 0,
|
||||||
|
描述: '',
|
||||||
|
特权配置: {
|
||||||
|
每日下载限制: 10,
|
||||||
|
购买折扣: 0.8
|
||||||
|
},
|
||||||
|
是否上架: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditMode) {
|
||||||
|
fetchPlan();
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const fetchPlan = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/plans/${id}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
reset(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch plan', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (data: any) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const url = isEditMode ? `/api/admin/plans/${id}` : '/api/admin/plans';
|
||||||
|
const method = isEditMode ? 'PUT' : 'POST';
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
router.push('/admin/plans');
|
||||||
|
} else {
|
||||||
|
alert('保存失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save plan', error);
|
||||||
|
alert('保存出错');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<AdminLayout>
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout>
|
||||||
|
<div className="max-w-2xl mx-auto py-6">
|
||||||
|
<div className="flex items-center gap-4 mb-8">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">
|
||||||
|
{isEditMode ? '编辑套餐' : '新建套餐'}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg border p-6 space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>套餐名称</Label>
|
||||||
|
<Input {...register('套餐名称', { required: true })} placeholder="例如:月度会员" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>价格 (元)</Label>
|
||||||
|
<Input type="number" {...register('价格', { required: true, min: 0 })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>有效天数</Label>
|
||||||
|
<Input type="number" {...register('有效天数', { required: true, min: 1 })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>描述</Label>
|
||||||
|
<Textarea {...register('描述')} placeholder="套餐描述..." />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 border-t pt-4">
|
||||||
|
<h3 className="font-medium">特权配置</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>每日下载限制 (次)</Label>
|
||||||
|
<Input type="number" {...register('特权配置.每日下载限制')} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>购买折扣 (0.1 - 1.0)</Label>
|
||||||
|
<Input type="number" step="0.1" max="1" min="0.1" {...register('特权配置.购买折扣')} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between border-t pt-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label>上架状态</Label>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
是否在前端展示此套餐
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Controller
|
||||||
|
name="是否上架"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Switch
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4">
|
||||||
|
<Button onClick={handleSubmit(onSubmit)} disabled={saving} className="w-full">
|
||||||
|
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
|
||||||
|
保存套餐
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
src/pages/admin/plans/index.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import AdminLayout from '@/components/admin/AdminLayout';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Plus, Pencil, Trash2, Loader2 } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
|
export default function PlansIndex() {
|
||||||
|
const [plans, setPlans] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPlans();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchPlans = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/plans');
|
||||||
|
if (res.ok) {
|
||||||
|
setPlans(await res.json());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch plans', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm('确定要删除这个套餐吗?')) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/plans/${id}`, { method: 'DELETE' });
|
||||||
|
if (res.ok) {
|
||||||
|
fetchPlans();
|
||||||
|
} else {
|
||||||
|
alert('删除失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete error', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">套餐管理</h1>
|
||||||
|
<Link href="/admin/plans/edit/create">
|
||||||
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" /> 新建套餐
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border bg-white">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>套餐名称</TableHead>
|
||||||
|
<TableHead>价格</TableHead>
|
||||||
|
<TableHead>有效天数</TableHead>
|
||||||
|
<TableHead>每日下载</TableHead>
|
||||||
|
<TableHead>折扣</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead className="text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="h-24 text-center">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : plans.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="h-24 text-center text-muted-foreground">
|
||||||
|
暂无套餐
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
plans.map((plan) => (
|
||||||
|
<TableRow key={plan._id}>
|
||||||
|
<TableCell className="font-medium">{plan.套餐名称}</TableCell>
|
||||||
|
<TableCell>¥{plan.价格}</TableCell>
|
||||||
|
<TableCell>{plan.有效天数} 天</TableCell>
|
||||||
|
<TableCell>{plan.特权配置?.每日下载限制 || 0}</TableCell>
|
||||||
|
<TableCell>{(plan.特权配置?.购买折扣 || 1) * 10} 折</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{plan.是否上架 ? (
|
||||||
|
<Badge className="bg-green-500">已上架</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">已下架</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Link href={`/admin/plans/edit/${plan._id}`}>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => handleDelete(plan._id)}>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
377
src/pages/admin/settings/index.tsx
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import AdminLayout from '@/components/admin/AdminLayout';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Loader2, Save, Trash2, Plus, Bot } from 'lucide-react';
|
||||||
|
import { useForm, useFieldArray, Controller } from 'react-hook-form';
|
||||||
|
|
||||||
|
interface SystemSettingsForm {
|
||||||
|
站点设置: {
|
||||||
|
网站标题?: string;
|
||||||
|
网站副标题?: string;
|
||||||
|
Logo地址?: string;
|
||||||
|
Favicon?: string;
|
||||||
|
备案号?: string;
|
||||||
|
全局SEO关键词?: string;
|
||||||
|
全局SEO描述?: string;
|
||||||
|
底部版权信息?: string;
|
||||||
|
第三方统计代码?: string;
|
||||||
|
};
|
||||||
|
支付宝设置: {
|
||||||
|
AppID?: string;
|
||||||
|
回调URL?: string;
|
||||||
|
网关地址?: string;
|
||||||
|
公钥?: string;
|
||||||
|
应用公钥?: string;
|
||||||
|
应用私钥?: string;
|
||||||
|
};
|
||||||
|
微信支付设置: {
|
||||||
|
WX_APPID?: string;
|
||||||
|
WX_MCHID?: string;
|
||||||
|
WX_SERIAL_NO?: string;
|
||||||
|
WX_NOTIFY_URL?: string;
|
||||||
|
WX_API_V3_KEY?: string;
|
||||||
|
WX_PRIVATE_KEY?: string;
|
||||||
|
};
|
||||||
|
阿里云短信设置: {
|
||||||
|
AccessKeyID?: string;
|
||||||
|
AccessKeySecret?: string;
|
||||||
|
aliSignName?: string;
|
||||||
|
aliTemplateCode?: string;
|
||||||
|
};
|
||||||
|
邮箱设置: {
|
||||||
|
MY_MAIL?: string;
|
||||||
|
MY_MAIL_PASS?: string;
|
||||||
|
};
|
||||||
|
AI配置列表: {
|
||||||
|
名称: string;
|
||||||
|
接口地址: string;
|
||||||
|
API密钥?: string;
|
||||||
|
模型: string;
|
||||||
|
系统提示词?: string;
|
||||||
|
流式传输?: boolean;
|
||||||
|
是否启用: boolean;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SystemSettings() {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const { register, control, handleSubmit, reset } = useForm<SystemSettingsForm>({
|
||||||
|
defaultValues: {
|
||||||
|
站点设置: {},
|
||||||
|
支付宝设置: {},
|
||||||
|
微信支付设置: {},
|
||||||
|
阿里云短信设置: {},
|
||||||
|
邮箱设置: {},
|
||||||
|
AI配置列表: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { fields, append, remove } = useFieldArray({
|
||||||
|
control,
|
||||||
|
name: "AI配置列表"
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchSettings = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/settings');
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
// 重置表单数据,注意处理嵌套对象
|
||||||
|
reset(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch settings', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (data: any) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
alert('设置已保存');
|
||||||
|
fetchSettings();
|
||||||
|
} else {
|
||||||
|
alert('保存失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save settings', error);
|
||||||
|
alert('保存出错');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<AdminLayout>
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">系统设置</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
配置网站的基本信息、支付接口和第三方服务。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<Tabs defaultValue="basic" className="space-y-6">
|
||||||
|
<TabsList className="grid w-full grid-cols-4 lg:w-[500px]">
|
||||||
|
<TabsTrigger value="basic">基础设置</TabsTrigger>
|
||||||
|
<TabsTrigger value="payment">支付设置</TabsTrigger>
|
||||||
|
<TabsTrigger value="service">服务设置</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="basic">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>网站基础信息</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
设置网站的标题、描述和联系方式。
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="siteName">网站标题</Label>
|
||||||
|
<Input id="siteName" {...register('站点设置.网站标题')} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="siteSubTitle">网站副标题</Label>
|
||||||
|
<Input id="siteSubTitle" {...register('站点设置.网站副标题')} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="siteLogo">Logo地址</Label>
|
||||||
|
<Input id="siteLogo" {...register('站点设置.Logo地址')} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="siteFavicon">Favicon</Label>
|
||||||
|
<Input id="siteFavicon" {...register('站点设置.Favicon')} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="icp">备案号</Label>
|
||||||
|
<Input id="icp" {...register('站点设置.备案号')} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="footerCopyright">底部版权信息</Label>
|
||||||
|
<Input id="footerCopyright" {...register('站点设置.底部版权信息')} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="seoKeywords">全局SEO关键词</Label>
|
||||||
|
<Input id="seoKeywords" {...register('站点设置.全局SEO关键词')} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="seoDesc">全局SEO描述</Label>
|
||||||
|
<Textarea id="seoDesc" rows={3} {...register('站点设置.全局SEO描述')} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="analytics">第三方统计代码</Label>
|
||||||
|
<Textarea id="analytics" rows={4} className="font-mono text-xs" {...register('站点设置.第三方统计代码')} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="payment">
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>支付宝配置</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
配置支付宝支付接口参数。
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="alipayAppId">AppID</Label>
|
||||||
|
<Input id="alipayAppId" {...register('支付宝设置.AppID')} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="alipayNotifyUrl">回调URL</Label>
|
||||||
|
<Input id="alipayNotifyUrl" {...register('支付宝设置.回调URL')} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<Label htmlFor="alipayGateway">网关地址</Label>
|
||||||
|
<Input id="alipayGateway" {...register('支付宝设置.网关地址')} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<Label htmlFor="alipayPublicKey">支付宝公钥</Label>
|
||||||
|
<Textarea id="alipayPublicKey" className="font-mono text-xs" rows={3} {...register('支付宝设置.公钥')} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<Label htmlFor="alipayAppPublicKey">应用公钥 (ALIPAY_APP_PUBLIC_KEY)</Label>
|
||||||
|
<Textarea id="alipayAppPublicKey" className="font-mono text-xs" rows={3} {...register('支付宝设置.应用公钥')} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<Label htmlFor="alipayPrivateKey">应用私钥 (核心机密)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="alipayPrivateKey"
|
||||||
|
className="font-mono text-xs"
|
||||||
|
rows={5}
|
||||||
|
{...register('支付宝设置.应用私钥')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>微信支付配置</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
配置微信支付接口参数。
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="wechatAppId">AppID (WX_APPID)</Label>
|
||||||
|
<Input id="wechatAppId" {...register('微信支付设置.WX_APPID')} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="wechatMchId">商户号 (WX_MCHID)</Label>
|
||||||
|
<Input id="wechatMchId" {...register('微信支付设置.WX_MCHID')} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="wechatSerialNo">证书序列号 (WX_SERIAL_NO)</Label>
|
||||||
|
<Input id="wechatSerialNo" {...register('微信支付设置.WX_SERIAL_NO')} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="wechatNotifyUrl">回调地址 (WX_NOTIFY_URL)</Label>
|
||||||
|
<Input id="wechatNotifyUrl" {...register('微信支付设置.WX_NOTIFY_URL')} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<Label htmlFor="wechatKey">API V3 密钥 (WX_API_V3_KEY)</Label>
|
||||||
|
<Input
|
||||||
|
id="wechatKey"
|
||||||
|
className="font-mono"
|
||||||
|
{...register('微信支付设置.WX_API_V3_KEY')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<Label htmlFor="wechatPrivateKey">商户私钥 (WX_PRIVATE_KEY)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="wechatPrivateKey"
|
||||||
|
className="font-mono text-xs"
|
||||||
|
rows={5}
|
||||||
|
{...register('微信支付设置.WX_PRIVATE_KEY')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="service">
|
||||||
|
<div className="grid gap-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>邮件服务 (SMTP)</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
配置系统邮件发送服务。
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="smtpUser">邮箱地址 (MY_MAIL)</Label>
|
||||||
|
<Input id="smtpUser" {...register('邮箱设置.MY_MAIL')} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="smtpPass">授权码 (MY_MAIL_PASS)</Label>
|
||||||
|
<Input
|
||||||
|
id="smtpPass"
|
||||||
|
type="password"
|
||||||
|
{...register('邮箱设置.MY_MAIL_PASS')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>阿里云短信</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
配置阿里云短信服务。
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="aliyunKey">AccessKey ID</Label>
|
||||||
|
<Input id="aliyunKey" {...register('阿里云短信设置.AccessKeyID')} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="aliyunSecret">AccessKey Secret</Label>
|
||||||
|
<Input
|
||||||
|
id="aliyunSecret"
|
||||||
|
type="password"
|
||||||
|
{...register('阿里云短信设置.AccessKeySecret')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="aliyunSign">签名名称</Label>
|
||||||
|
<Input id="aliyunSign" {...register('阿里云短信设置.aliSignName')} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="aliyunTemplate">模板代码</Label>
|
||||||
|
<Input id="aliyunTemplate" {...register('阿里云短信设置.aliTemplateCode')} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<div className="mt-6 flex justify-end">
|
||||||
|
<Button type="submit" disabled={saving}>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
保存中...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
保存设置
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
316
src/pages/admin/users/index.tsx
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import AdminLayout from '@/components/admin/AdminLayout';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { Search, Edit, Trash2, MoreHorizontal, Loader2 } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
_id: string;
|
||||||
|
用户名: string;
|
||||||
|
邮箱: string;
|
||||||
|
角色: string;
|
||||||
|
是否被封禁: boolean;
|
||||||
|
头像: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserManagement() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [totalUsers, setTotalUsers] = useState(0);
|
||||||
|
|
||||||
|
// 编辑状态
|
||||||
|
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||||
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||||
|
const [editForm, setEditForm] = useState({ role: '', isBanned: false });
|
||||||
|
|
||||||
|
// 搜索防抖
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
fetchUsers();
|
||||||
|
}, 500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [search, page]);
|
||||||
|
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/users?page=${page}&limit=10&search=${search}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
setUsers(data.users);
|
||||||
|
setTotalPages(data.totalPages);
|
||||||
|
setTotalUsers(data.total);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch users', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditClick = (user: User) => {
|
||||||
|
setEditingUser(user);
|
||||||
|
setEditForm({ role: user.角色, isBanned: user.是否被封禁 });
|
||||||
|
setIsEditDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateUser = async () => {
|
||||||
|
if (!editingUser) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/users/${editingUser._id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(editForm),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setIsEditDialogOpen(false);
|
||||||
|
fetchUsers(); // 刷新列表
|
||||||
|
} else {
|
||||||
|
alert('更新失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update failed', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteUser = async (id: string) => {
|
||||||
|
if (!confirm('确定要删除该用户吗?此操作不可恢复。')) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/users/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
fetchUsers();
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
alert(data.message || '删除失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete failed', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-3xl font-bold tracking-tight">用户管理</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
管理系统用户,查看详情、修改角色或封禁账号。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/* <Button>添加用户</Button> */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="relative w-full max-w-sm">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder="搜索用户名或邮箱..."
|
||||||
|
className="pl-8"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border bg-white">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[80px]">头像</TableHead>
|
||||||
|
<TableHead>用户名</TableHead>
|
||||||
|
<TableHead>邮箱</TableHead>
|
||||||
|
<TableHead>角色</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead>注册时间</TableHead>
|
||||||
|
<TableHead className="text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="h-24 text-center">
|
||||||
|
<Loader2 className="mx-auto h-6 w-6 animate-spin" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="h-24 text-center">
|
||||||
|
暂无用户
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
users.map((user) => (
|
||||||
|
<TableRow key={user._id}>
|
||||||
|
<TableCell>
|
||||||
|
<Avatar>
|
||||||
|
<AvatarImage src={user.头像} alt={user.用户名} />
|
||||||
|
<AvatarFallback>{user.用户名.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium">{user.用户名}</TableCell>
|
||||||
|
<TableCell>{user.邮箱}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={user.角色 === 'admin' ? 'default' : 'secondary'}>
|
||||||
|
{user.角色}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{user.是否被封禁 ? (
|
||||||
|
<Badge variant="destructive">已封禁</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-green-600 border-green-200 bg-green-50">正常</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{new Date(user.createdAt).toLocaleDateString()}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
|
<span className="sr-only">打开菜单</span>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>操作</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem onClick={() => handleEditClick(user)}>
|
||||||
|
<Edit className="mr-2 h-4 w-4" /> 编辑用户
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-red-600"
|
||||||
|
onClick={() => handleDeleteUser(user._id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" /> 删除用户
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分页 */}
|
||||||
|
<div className="flex items-center justify-end space-x-2 py-4">
|
||||||
|
<div className="flex-1 text-sm text-muted-foreground">
|
||||||
|
共 {totalUsers} 条记录,当前第 {page} / {totalPages} 页
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1 || loading}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page === totalPages || loading}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 编辑弹窗 */}
|
||||||
|
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>编辑用户</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
修改用户 {editingUser?.用户名} 的信息。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<label htmlFor="role" className="text-right text-sm font-medium">
|
||||||
|
角色
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={editForm.role}
|
||||||
|
onValueChange={(val) => setEditForm({ ...editForm, role: val })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="col-span-3">
|
||||||
|
<SelectValue placeholder="选择角色" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="user">User (普通用户)</SelectItem>
|
||||||
|
<SelectItem value="editor">Editor (编辑)</SelectItem>
|
||||||
|
<SelectItem value="admin">Admin (管理员)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<label htmlFor="status" className="text-right text-sm font-medium">
|
||||||
|
状态
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={editForm.isBanned ? 'banned' : 'active'}
|
||||||
|
onValueChange={(val) => setEditForm({ ...editForm, isBanned: val === 'banned' })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="col-span-3">
|
||||||
|
<SelectValue placeholder="选择状态" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="active">正常</SelectItem>
|
||||||
|
<SelectItem value="banned">封禁</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" onClick={handleUpdateUser}>保存更改</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
src/pages/aitools/index.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import AIToolsLayout from '@/components/aitools/AIToolsLayout';
|
||||||
|
import { Sparkles } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function AIToolsIndex() {
|
||||||
|
return (
|
||||||
|
<AIToolsLayout>
|
||||||
|
<div className="h-full min-h-[500px] bg-white rounded-2xl border border-gray-100 p-8 flex flex-col items-center justify-center text-center">
|
||||||
|
<div className="w-20 h-20 bg-primary/10 rounded-full flex items-center justify-center mb-6">
|
||||||
|
<Sparkles className="w-10 h-10 text-primary" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">欢迎使用 AI 工具箱</h1>
|
||||||
|
<p className="text-gray-500 max-w-md">
|
||||||
|
请在左侧选择一个工具开始使用。<br />
|
||||||
|
我们将持续更新更多实用的 AI 辅助工具,敬请期待。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</AIToolsLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
164
src/pages/aitools/prompt-optimizer/index.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import AIToolsLayout from '@/components/aitools/AIToolsLayout';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Sparkles, Copy, RefreshCw } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
export default function PromptOptimizer() {
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [output, setOutput] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleOptimize = async () => {
|
||||||
|
if (!input.trim()) {
|
||||||
|
toast.error('请输入需要优化的提示词');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/ai/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: `作为一名专业的提示词工程师(Prompt Engineer),请优化以下提示词。
|
||||||
|
|
||||||
|
原始提示词:
|
||||||
|
"${input}"
|
||||||
|
|
||||||
|
要求:
|
||||||
|
1. 使用结构化的格式(如 Role, Context, Task, Constraints)。
|
||||||
|
2. 语言精炼,指向明确。
|
||||||
|
3. 如果原始提示词是中文,请保持中文;如果是英文,请保持英文。
|
||||||
|
4. 直接输出优化后的提示词,不要包含解释或其他废话。`,
|
||||||
|
stream: true
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Optimization failed');
|
||||||
|
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
if (reader) {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
const chunk = decoder.decode(value);
|
||||||
|
|
||||||
|
const lines = chunk.split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
try {
|
||||||
|
const data = line.slice(6);
|
||||||
|
if (data === '[DONE]') continue;
|
||||||
|
const json = JSON.parse(data);
|
||||||
|
if (json.content) {
|
||||||
|
setOutput(prev => prev + json.content);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing SSE', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error('优化失败,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = () => {
|
||||||
|
navigator.clipboard.writeText(output);
|
||||||
|
toast.success('已复制到剪贴板');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AIToolsLayout
|
||||||
|
title="提示词优化师"
|
||||||
|
description="将简单的指令转化为结构化、高质量的 AI 提示词。"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-[calc(100vh-180px)] min-h-[600px]">
|
||||||
|
{/* Input Section */}
|
||||||
|
<div className="bg-white rounded-2xl border border-gray-100 p-6 shadow-xs flex flex-col h-full">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-purple-100 flex items-center justify-center text-purple-600">
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold text-gray-900">原始提示词</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
placeholder="例如:帮我写一篇关于咖啡的文章..."
|
||||||
|
className="flex-1 resize-none border-gray-200 focus:border-purple-500 focus:ring-purple-500/20 p-4 text-base leading-relaxed"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button
|
||||||
|
onClick={handleOptimize}
|
||||||
|
disabled={loading || !input.trim()}
|
||||||
|
className="w-full bg-purple-600 hover:bg-purple-700 text-white h-10 text-sm font-medium shadow-lg shadow-purple-500/20"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
正在优化中...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Sparkles className="w-4 h-4 mr-2" />
|
||||||
|
立即优化
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Output Section */}
|
||||||
|
<div className="bg-white rounded-2xl border border-gray-100 p-6 shadow-xs flex flex-col h-full relative overflow-hidden">
|
||||||
|
{/* Decorative Background */}
|
||||||
|
<div className="absolute top-0 right-0 w-64 h-64 bg-linear-to-br from-purple-500/5 to-transparent rounded-bl-full pointer-events-none" />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mb-4 relative z-10">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center text-gray-600">
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold text-gray-900">优化结果</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{output && (
|
||||||
|
<Button variant="outline" size="sm" onClick={copyToClipboard} className="text-gray-600 hover:text-purple-600 border-gray-200 h-8">
|
||||||
|
<Copy className="w-3.5 h-3.5 mr-1.5" />
|
||||||
|
复制
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 bg-gray-50 rounded-xl border border-gray-100 p-4 overflow-y-auto relative z-10">
|
||||||
|
{output ? (
|
||||||
|
<div className="prose prose-sm max-w-none text-gray-700 whitespace-pre-wrap font-mono text-sm">
|
||||||
|
{output}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center text-gray-400">
|
||||||
|
<Sparkles className="w-10 h-10 mb-3 opacity-20" />
|
||||||
|
<p className="text-xs">优化后的内容将显示在这里</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AIToolsLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
196
src/pages/aitools/translator/index.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import AIToolsLayout from '@/components/aitools/AIToolsLayout';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Languages, Copy, RefreshCw, ArrowRightLeft } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
const languages = [
|
||||||
|
{ value: 'en', label: '英语 (English)' },
|
||||||
|
{ value: 'zh', label: '中文 (Chinese)' },
|
||||||
|
{ value: 'ja', label: '日语 (Japanese)' },
|
||||||
|
{ value: 'ko', label: '韩语 (Korean)' },
|
||||||
|
{ value: 'fr', label: '法语 (French)' },
|
||||||
|
{ value: 'de', label: '德语 (German)' },
|
||||||
|
{ value: 'es', label: '西班牙语 (Spanish)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Translator() {
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [output, setOutput] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [targetLang, setTargetLang] = useState('en');
|
||||||
|
|
||||||
|
const handleTranslate = async () => {
|
||||||
|
if (!input.trim()) {
|
||||||
|
toast.error('请输入需要翻译的内容');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setOutput(''); // Clear previous output
|
||||||
|
try {
|
||||||
|
const targetLangLabel = languages.find(l => l.value === targetLang)?.label;
|
||||||
|
|
||||||
|
const response = await fetch('/api/ai/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt: `作为一名精通多国语言的专业翻译官,请将以下内容翻译成${targetLangLabel}。
|
||||||
|
|
||||||
|
待翻译内容:
|
||||||
|
"${input}"
|
||||||
|
|
||||||
|
要求:
|
||||||
|
1. 翻译准确、信达雅。
|
||||||
|
2. 保持原文的语气和风格。
|
||||||
|
3. 只输出翻译后的结果,不要包含任何解释或额外说明。`,
|
||||||
|
stream: true
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('Translation failed');
|
||||||
|
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
if (reader) {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
const chunk = decoder.decode(value);
|
||||||
|
|
||||||
|
const lines = chunk.split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data: ')) {
|
||||||
|
try {
|
||||||
|
const data = line.slice(6);
|
||||||
|
if (data === '[DONE]') continue;
|
||||||
|
const json = JSON.parse(data);
|
||||||
|
if (json.content) {
|
||||||
|
setOutput(prev => prev + json.content);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing SSE', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error('翻译失败,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = () => {
|
||||||
|
navigator.clipboard.writeText(output);
|
||||||
|
toast.success('已复制到剪贴板');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AIToolsLayout
|
||||||
|
title="智能翻译助手"
|
||||||
|
description="精准、地道的多语言互译工具。"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-[calc(100vh-180px)] min-h-[600px]">
|
||||||
|
{/* Input Section */}
|
||||||
|
<div className="bg-white rounded-2xl border border-gray-100 p-6 shadow-xs flex flex-col h-full">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-blue-100 flex items-center justify-center text-blue-600">
|
||||||
|
<Languages className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold text-gray-900">原文</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
placeholder="请输入需要翻译的内容..."
|
||||||
|
className="flex-1 resize-none border-gray-200 focus:border-blue-500 focus:ring-blue-500/20 p-4 text-base leading-relaxed"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button
|
||||||
|
onClick={handleTranslate}
|
||||||
|
disabled={loading || !input.trim()}
|
||||||
|
className="w-full bg-blue-600 hover:bg-blue-700 text-white h-10 text-sm font-medium shadow-lg shadow-blue-500/20"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
正在翻译...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ArrowRightLeft className="w-4 h-4 mr-2" />
|
||||||
|
开始翻译
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Output Section */}
|
||||||
|
<div className="bg-white rounded-2xl border border-gray-100 p-6 shadow-xs flex flex-col h-full relative overflow-hidden">
|
||||||
|
{/* Decorative Background */}
|
||||||
|
<div className="absolute top-0 right-0 w-64 h-64 bg-linear-to-br from-blue-500/5 to-transparent rounded-bl-full pointer-events-none" />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mb-4 relative z-10">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-green-100 flex items-center justify-center text-green-600">
|
||||||
|
<ArrowRightLeft className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-bold text-gray-900">目标语言:</span>
|
||||||
|
<Select value={targetLang} onValueChange={setTargetLang}>
|
||||||
|
<SelectTrigger className="w-[140px] h-8 text-xs border-gray-200 bg-white focus:ring-0 shadow-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{languages.map(lang => (
|
||||||
|
<SelectItem key={lang.value} value={lang.value}>{lang.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{output && (
|
||||||
|
<Button variant="outline" size="sm" onClick={copyToClipboard} className="text-gray-600 hover:text-blue-600 border-gray-200 h-8">
|
||||||
|
<Copy className="w-3.5 h-3.5 mr-1.5" />
|
||||||
|
复制
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 bg-gray-50 rounded-xl border border-gray-100 p-4 overflow-y-auto relative z-10">
|
||||||
|
{output ? (
|
||||||
|
<div className="prose prose-sm max-w-none text-gray-700 whitespace-pre-wrap text-base leading-relaxed">
|
||||||
|
{output}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center text-gray-400">
|
||||||
|
<Languages className="w-10 h-10 mb-3 opacity-20" />
|
||||||
|
<p className="text-xs">翻译结果将显示在这里</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AIToolsLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
src/pages/api/admin/ai/models.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import { SystemConfig } from '@/models';
|
||||||
|
import withDatabase from '@/lib/withDatabase';
|
||||||
|
import { requireAdmin } from '@/lib/auth';
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse, user: any) {
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
return res.status(405).json({ message: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { endpoint, apiKey, assistantId } = req.body;
|
||||||
|
|
||||||
|
if (!endpoint) {
|
||||||
|
return res.status(400).json({ message: 'Endpoint is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let finalApiKey = apiKey;
|
||||||
|
|
||||||
|
// 如果没有提供 Key 但提供了 ID,尝试从数据库获取
|
||||||
|
if (!finalApiKey && assistantId) {
|
||||||
|
const config = await SystemConfig.findOne().select('+AI配置列表.API密钥').lean();
|
||||||
|
if (config && config.AI配置列表) {
|
||||||
|
const assistant = config.AI配置列表.find((a: any) => a._id.toString() === assistantId);
|
||||||
|
if (assistant) {
|
||||||
|
finalApiKey = assistant.API密钥;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!finalApiKey) {
|
||||||
|
return res.status(400).json({ message: 'API Key is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 Endpoint,确保指向 /models
|
||||||
|
// 假设用户填写的 endpoint 是 base url (e.g. https://api.openai.com/v1)
|
||||||
|
// 或者完整的 chat url (e.g. https://api.openai.com/v1/chat/completions)
|
||||||
|
let baseUrl = endpoint.replace(/\/+$/, '');
|
||||||
|
if (baseUrl.endsWith('/chat/completions')) {
|
||||||
|
baseUrl = baseUrl.replace('/chat/completions', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${baseUrl}/models`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${finalApiKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error('Fetch Models Error:', errorText);
|
||||||
|
return res.status(response.status).json({ message: `Provider Error: ${response.statusText}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
// OpenAI 格式返回 { data: [{ id: 'model-name', ... }] }
|
||||||
|
const models = data.data ? data.data.map((m: any) => m.id) : [];
|
||||||
|
|
||||||
|
return res.status(200).json({ models });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch Models Internal Error:', error);
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withDatabase(requireAdmin(handler));
|
||||||
49
src/pages/api/admin/articles/[id].ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import { Article } from '@/models';
|
||||||
|
import withDatabase from '@/lib/withDatabase';
|
||||||
|
import { requireAdmin } from '@/lib/auth';
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const { id } = req.query;
|
||||||
|
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
try {
|
||||||
|
const article = await Article.findById(id)
|
||||||
|
.populate('分类ID')
|
||||||
|
.populate('标签ID列表')
|
||||||
|
.lean();
|
||||||
|
|
||||||
|
if (!article) {
|
||||||
|
return res.status(404).json({ message: '文章不存在' });
|
||||||
|
}
|
||||||
|
return res.status(200).json(article);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).json({ message: '获取文章详情失败' });
|
||||||
|
}
|
||||||
|
} else if (req.method === 'PUT') {
|
||||||
|
try {
|
||||||
|
const article = await Article.findByIdAndUpdate(id, req.body, { new: true, runValidators: true });
|
||||||
|
if (!article) {
|
||||||
|
return res.status(404).json({ message: '文章不存在' });
|
||||||
|
}
|
||||||
|
return res.status(200).json(article);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update article error:', error);
|
||||||
|
return res.status(500).json({ message: '更新文章失败' });
|
||||||
|
}
|
||||||
|
} else if (req.method === 'DELETE') {
|
||||||
|
try {
|
||||||
|
const article = await Article.findByIdAndDelete(id);
|
||||||
|
if (!article) {
|
||||||
|
return res.status(404).json({ message: '文章不存在' });
|
||||||
|
}
|
||||||
|
return res.status(200).json({ message: '文章已删除' });
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).json({ message: '删除文章失败' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return res.status(405).json({ message: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withDatabase(requireAdmin(handler));
|
||||||
61
src/pages/api/admin/articles/index.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import { Article, User } from '@/models';
|
||||||
|
import withDatabase from '@/lib/withDatabase';
|
||||||
|
import { requireAdmin } from '@/lib/auth';
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse, user: any) {
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
try {
|
||||||
|
const { page = 1, limit = 10, search = '' } = req.query;
|
||||||
|
const pageNum = parseInt(page as string);
|
||||||
|
const limitNum = parseInt(limit as string);
|
||||||
|
const skip = (pageNum - 1) * limitNum;
|
||||||
|
|
||||||
|
const query: any = {};
|
||||||
|
if (search) {
|
||||||
|
query.文章标题 = { $regex: search, $options: 'i' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [articles, total] = await Promise.all([
|
||||||
|
Article.find(query)
|
||||||
|
.populate('作者ID', 'username email')
|
||||||
|
.populate('分类ID', '分类名称')
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.skip(skip)
|
||||||
|
.limit(limitNum)
|
||||||
|
.lean(),
|
||||||
|
Article.countDocuments(query)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
articles,
|
||||||
|
pagination: {
|
||||||
|
total,
|
||||||
|
page: pageNum,
|
||||||
|
limit: limitNum,
|
||||||
|
pages: Math.ceil(total / limitNum)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch articles error:', error);
|
||||||
|
return res.status(500).json({ message: '获取文章列表失败' });
|
||||||
|
}
|
||||||
|
} else if (req.method === 'POST') {
|
||||||
|
try {
|
||||||
|
const data = req.body;
|
||||||
|
|
||||||
|
// 自动设置作者为当前管理员
|
||||||
|
data.作者ID = user.userId;
|
||||||
|
|
||||||
|
const article = await Article.create(data);
|
||||||
|
return res.status(201).json(article);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Create article error:', error);
|
||||||
|
return res.status(500).json({ message: '创建文章失败', error: (error as Error).message });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return res.status(405).json({ message: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withDatabase(requireAdmin(handler));
|
||||||
20
src/pages/api/admin/categories/index.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import { Category } from '@/models';
|
||||||
|
import withDatabase from '@/lib/withDatabase';
|
||||||
|
import { requireAdmin } from '@/lib/auth';
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
try {
|
||||||
|
const categories = await Category.find().sort({ 排序权重: -1, createdAt: -1 }).lean();
|
||||||
|
return res.status(200).json(categories);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch categories error:', error);
|
||||||
|
return res.status(500).json({ message: '获取分类失败' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return res.status(405).json({ message: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withDatabase(requireAdmin(handler));
|
||||||
65
src/pages/api/admin/orders/index.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import dbConnect from '@/lib/dbConnect';
|
||||||
|
import { Order, User } from '@/models';
|
||||||
|
import { requireAdmin } from '@/lib/auth';
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method !== 'GET') {
|
||||||
|
return res.status(405).json({ message: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await dbConnect();
|
||||||
|
|
||||||
|
const { page = 1, limit = 10, status, search } = req.query;
|
||||||
|
const skip = (Number(page) - 1) * Number(limit);
|
||||||
|
|
||||||
|
const query: any = {};
|
||||||
|
|
||||||
|
if (status && status !== 'all') {
|
||||||
|
query.订单状态 = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
// Find users matching the search term first
|
||||||
|
const users = await User.find({
|
||||||
|
$or: [
|
||||||
|
{ 用户名: { $regex: search, $options: 'i' } },
|
||||||
|
{ 邮箱: { $regex: search, $options: 'i' } }
|
||||||
|
]
|
||||||
|
}).select('_id');
|
||||||
|
|
||||||
|
const userIds = users.map(u => u._id);
|
||||||
|
|
||||||
|
query.$or = [
|
||||||
|
{ 订单号: { $regex: search, $options: 'i' } },
|
||||||
|
{ 用户ID: { $in: userIds } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [orders, total] = await Promise.all([
|
||||||
|
Order.find(query)
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.skip(skip)
|
||||||
|
.limit(Number(limit))
|
||||||
|
.populate('用户ID', '用户名 邮箱 头像'),
|
||||||
|
Order.countDocuments(query)
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
orders,
|
||||||
|
pagination: {
|
||||||
|
total,
|
||||||
|
page: Number(page),
|
||||||
|
limit: Number(limit),
|
||||||
|
pages: Math.ceil(total / Number(limit))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Fetch orders error:', error);
|
||||||
|
res.status(500).json({ message: error.message || 'Internal server error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default requireAdmin(handler);
|
||||||
37
src/pages/api/admin/plans/[id].ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import { MembershipPlan } from '@/models';
|
||||||
|
import withDatabase from '@/lib/withDatabase';
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const { id } = req.query;
|
||||||
|
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
try {
|
||||||
|
const plan = await MembershipPlan.findById(id);
|
||||||
|
if (!plan) return res.status(404).json({ message: 'Plan not found' });
|
||||||
|
return res.status(200).json(plan);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).json({ message: 'Failed to fetch plan' });
|
||||||
|
}
|
||||||
|
} else if (req.method === 'PUT') {
|
||||||
|
try {
|
||||||
|
const plan = await MembershipPlan.findByIdAndUpdate(id, req.body, { new: true });
|
||||||
|
if (!plan) return res.status(404).json({ message: 'Plan not found' });
|
||||||
|
return res.status(200).json(plan);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).json({ message: 'Failed to update plan' });
|
||||||
|
}
|
||||||
|
} else if (req.method === 'DELETE') {
|
||||||
|
try {
|
||||||
|
const plan = await MembershipPlan.findByIdAndDelete(id);
|
||||||
|
if (!plan) return res.status(404).json({ message: 'Plan not found' });
|
||||||
|
return res.status(200).json({ message: 'Plan deleted' });
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).json({ message: 'Failed to delete plan' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return res.status(405).json({ message: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withDatabase(handler);
|
||||||
25
src/pages/api/admin/plans/index.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import { MembershipPlan } from '@/models';
|
||||||
|
import withDatabase from '@/lib/withDatabase';
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
try {
|
||||||
|
const plans = await MembershipPlan.find().sort({ 价格: 1 });
|
||||||
|
return res.status(200).json(plans);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).json({ message: 'Failed to fetch plans' });
|
||||||
|
}
|
||||||
|
} else if (req.method === 'POST') {
|
||||||
|
try {
|
||||||
|
const plan = await MembershipPlan.create(req.body);
|
||||||
|
return res.status(201).json(plan);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).json({ message: 'Failed to create plan' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return res.status(405).json({ message: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withDatabase(handler);
|
||||||
72
src/pages/api/admin/settings.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import { SystemConfig } from '@/models';
|
||||||
|
import withDatabase from '@/lib/withDatabase';
|
||||||
|
import { requireAdmin } from '@/lib/auth';
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse, user: any) {
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
try {
|
||||||
|
// 显式选择所有被隐藏的敏感字段
|
||||||
|
const config = await SystemConfig.findOne().select(
|
||||||
|
'+支付宝设置.AppID +支付宝设置.公钥 +支付宝设置.应用公钥 +支付宝设置.应用私钥 ' +
|
||||||
|
'+微信支付设置.WX_APPID +微信支付设置.WX_MCHID +微信支付设置.WX_PRIVATE_KEY +微信支付设置.WX_API_V3_KEY ' +
|
||||||
|
'+阿里云短信设置.AccessKeyID +阿里云短信设置.AccessKeySecret ' +
|
||||||
|
'+邮箱设置.MY_MAIL_PASS'
|
||||||
|
// 注意:AI配置列表.API密钥 默认不查询,保持安全
|
||||||
|
).lean();
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return res.status(200).json({});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json(config);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch settings error:', error);
|
||||||
|
return res.status(500).json({ message: '获取系统配置失败' });
|
||||||
|
}
|
||||||
|
} else if (req.method === 'PUT') {
|
||||||
|
try {
|
||||||
|
const updates = req.body;
|
||||||
|
|
||||||
|
// 特殊处理 AI配置列表 的 API密钥
|
||||||
|
if (updates.AI配置列表 && Array.isArray(updates.AI配置列表)) {
|
||||||
|
// 获取当前配置(包含敏感字段)
|
||||||
|
const currentConfig = await SystemConfig.findOne().select('+AI配置列表.API密钥').lean();
|
||||||
|
|
||||||
|
if (currentConfig && currentConfig.AI配置列表) {
|
||||||
|
updates.AI配置列表 = updates.AI配置列表.map((newItem: any) => {
|
||||||
|
// 如果是新项目(没有_id),直接返回
|
||||||
|
if (!newItem._id) return newItem;
|
||||||
|
|
||||||
|
// 查找旧项目
|
||||||
|
const oldItem = currentConfig.AI配置列表.find((item: any) =>
|
||||||
|
item._id.toString() === newItem._id
|
||||||
|
);
|
||||||
|
|
||||||
|
// 如果找到了旧项目,且新项目的密钥为空,则保留旧密钥
|
||||||
|
if (oldItem && !newItem.API密钥) {
|
||||||
|
return { ...newItem, API密钥: oldItem.API密钥 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return newItem;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await SystemConfig.findOneAndUpdate(
|
||||||
|
{},
|
||||||
|
{ $set: updates },
|
||||||
|
{ new: true, upsert: true, setDefaultsOnInsert: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.status(200).json({ message: '配置已更新', config });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update settings error:', error);
|
||||||
|
return res.status(500).json({ message: '更新系统配置失败' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return res.status(405).json({ message: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withDatabase(requireAdmin(handler));
|
||||||
20
src/pages/api/admin/tags/index.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import { Tag } from '@/models';
|
||||||
|
import withDatabase from '@/lib/withDatabase';
|
||||||
|
import { requireAdmin } from '@/lib/auth';
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
try {
|
||||||
|
const tags = await Tag.find().sort({ createdAt: -1 }).lean();
|
||||||
|
return res.status(200).json(tags);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch tags error:', error);
|
||||||
|
return res.status(500).json({ message: '获取标签失败' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return res.status(405).json({ message: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withDatabase(requireAdmin(handler));
|
||||||
56
src/pages/api/admin/users/[id].ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import { User } from '@/models';
|
||||||
|
import withDatabase from '@/lib/withDatabase';
|
||||||
|
import { requireAdmin } from '@/lib/auth';
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse, adminUser: any) {
|
||||||
|
const { id } = req.query;
|
||||||
|
|
||||||
|
if (req.method === 'PUT') {
|
||||||
|
// 更新用户
|
||||||
|
const { role, isBanned } = req.body;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updatedUser = await User.findByIdAndUpdate(
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
角色: role,
|
||||||
|
是否被封禁: isBanned
|
||||||
|
},
|
||||||
|
{ new: true }
|
||||||
|
).select('-密码');
|
||||||
|
|
||||||
|
if (!updatedUser) {
|
||||||
|
return res.status(404).json({ message: '用户不存在' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json({ message: '用户更新成功', user: updatedUser });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update user error:', error);
|
||||||
|
return res.status(500).json({ message: '更新用户失败' });
|
||||||
|
}
|
||||||
|
} else if (req.method === 'DELETE') {
|
||||||
|
// 删除用户
|
||||||
|
try {
|
||||||
|
// 防止自杀
|
||||||
|
if (id === adminUser.userId) {
|
||||||
|
return res.status(400).json({ message: '无法删除自己' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedUser = await User.findByIdAndDelete(id);
|
||||||
|
|
||||||
|
if (!deletedUser) {
|
||||||
|
return res.status(404).json({ message: '用户不存在' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json({ message: '用户删除成功' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete user error:', error);
|
||||||
|
return res.status(500).json({ message: '删除用户失败' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return res.status(405).json({ message: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withDatabase(requireAdmin(handler));
|
||||||
46
src/pages/api/admin/users/index.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import { User } from '@/models';
|
||||||
|
import withDatabase from '@/lib/withDatabase';
|
||||||
|
import { requireAdmin } from '@/lib/auth';
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse, user: any) {
|
||||||
|
if (req.method !== 'GET') {
|
||||||
|
return res.status(405).json({ message: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { page = 1, limit = 10, search = '' } = req.query;
|
||||||
|
const pageNum = parseInt(page as string, 10);
|
||||||
|
const limitNum = parseInt(limit as string, 10);
|
||||||
|
const skip = (pageNum - 1) * limitNum;
|
||||||
|
|
||||||
|
const query: any = {};
|
||||||
|
if (search) {
|
||||||
|
query.$or = [
|
||||||
|
{ 用户名: { $regex: search, $options: 'i' } },
|
||||||
|
{ 邮箱: { $regex: search, $options: 'i' } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [users, total] = await Promise.all([
|
||||||
|
User.find(query)
|
||||||
|
.select('-密码') // 不返回密码
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.skip(skip)
|
||||||
|
.limit(limitNum),
|
||||||
|
User.countDocuments(query),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
users,
|
||||||
|
total,
|
||||||
|
page: pageNum,
|
||||||
|
totalPages: Math.ceil(total / limitNum),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch users error:', error);
|
||||||
|
return res.status(500).json({ message: '获取用户列表失败' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withDatabase(requireAdmin(handler));
|
||||||
135
src/pages/api/ai/generate.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import { SystemConfig } from '@/models';
|
||||||
|
import withDatabase from '@/lib/withDatabase';
|
||||||
|
import { requireAdmin } from '@/lib/auth';
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
api: {
|
||||||
|
bodyParser: false, // Disable body parser for streaming
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function readBody(req: NextApiRequest) {
|
||||||
|
const chunks = [];
|
||||||
|
for await (const chunk of req) {
|
||||||
|
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks).toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse, user: any) {
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
return res.status(405).json({ message: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Manually parse body since bodyParser is disabled
|
||||||
|
const bodyStr = await readBody(req);
|
||||||
|
const { prompt, assistantId, systemPrompt } = JSON.parse(bodyStr);
|
||||||
|
|
||||||
|
if (!prompt) {
|
||||||
|
return res.status(400).json({ message: 'Prompt is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 获取系统配置
|
||||||
|
const config = await SystemConfig.findOne().select('+AI配置列表.API密钥 +AI配置列表.流式传输').lean();
|
||||||
|
|
||||||
|
if (!config || !config.AI配置列表 || config.AI配置列表.length === 0) {
|
||||||
|
return res.status(404).json({ message: 'No AI assistants configured' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 选择 AI 助手
|
||||||
|
let assistant;
|
||||||
|
if (assistantId) {
|
||||||
|
assistant = config.AI配置列表.find((a: any) => a._id.toString() === assistantId && a.是否启用);
|
||||||
|
} else {
|
||||||
|
assistant = config.AI配置列表.find((a: any) => a.是否启用);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!assistant) {
|
||||||
|
return res.status(404).json({ message: 'Selected AI assistant not found or disabled' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 构建请求
|
||||||
|
const messages = [];
|
||||||
|
const sysPrompt = systemPrompt || assistant.系统提示词;
|
||||||
|
if (sysPrompt) {
|
||||||
|
messages.push({ role: 'system', content: sysPrompt });
|
||||||
|
}
|
||||||
|
messages.push({ role: 'user', content: prompt });
|
||||||
|
|
||||||
|
const apiEndpoint = assistant.接口地址.replace(/\/+$/, '');
|
||||||
|
const url = apiEndpoint.endsWith('/chat/completions')
|
||||||
|
? apiEndpoint
|
||||||
|
: `${apiEndpoint}/chat/completions`;
|
||||||
|
|
||||||
|
const isStream = assistant.流式传输 === true;
|
||||||
|
|
||||||
|
// 4. 发起请求
|
||||||
|
const aiRes = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${assistant.API密钥}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: assistant.模型,
|
||||||
|
messages: messages,
|
||||||
|
temperature: 0.7,
|
||||||
|
stream: isStream, // Use the stream setting
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!aiRes.ok) {
|
||||||
|
const errorText = await aiRes.text();
|
||||||
|
console.error('AI API Error:', errorText);
|
||||||
|
return res.status(aiRes.status).json({ message: `AI Provider Error: ${aiRes.statusText}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isStream) {
|
||||||
|
// Handle Streaming Response
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache, no-transform',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!aiRes.body) {
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = aiRes.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
const chunk = decoder.decode(value);
|
||||||
|
// Just forward the raw chunk from OpenAI to the client
|
||||||
|
// The client will handle parsing the "data: {...}" format
|
||||||
|
res.write(chunk);
|
||||||
|
}
|
||||||
|
} catch (streamError) {
|
||||||
|
console.error('Stream Error:', streamError);
|
||||||
|
} finally {
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Handle Normal Response
|
||||||
|
const data = await aiRes.json();
|
||||||
|
const generatedText = data.choices?.[0]?.message?.content || '';
|
||||||
|
return res.status(200).json({ text: generatedText });
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('AI Generate Error:', error);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withDatabase(requireAdmin(handler));
|
||||||
122
src/pages/api/articles/[id].ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import dbConnect from '@/lib/dbConnect';
|
||||||
|
import { Article, User, Order } from '@/models';
|
||||||
|
import { verifyToken } from '@/lib/auth';
|
||||||
|
import { unified } from 'unified';
|
||||||
|
import remarkParse from 'remark-parse';
|
||||||
|
import remarkRehype from 'remark-rehype';
|
||||||
|
import rehypePrettyCode from 'rehype-pretty-code';
|
||||||
|
import rehypeStringify from 'rehype-stringify';
|
||||||
|
|
||||||
|
async function processMarkdown(content: string) {
|
||||||
|
if (!content) return '';
|
||||||
|
const file = await unified()
|
||||||
|
.use(remarkParse)
|
||||||
|
.use(remarkRehype)
|
||||||
|
.use(rehypePrettyCode, {
|
||||||
|
theme: 'github-dark',
|
||||||
|
keepBackground: true,
|
||||||
|
})
|
||||||
|
.use(rehypeStringify)
|
||||||
|
.process(content);
|
||||||
|
return String(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method !== 'GET') {
|
||||||
|
return res.status(405).json({ message: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await dbConnect();
|
||||||
|
const { id } = req.query;
|
||||||
|
|
||||||
|
// 1. Fetch Article
|
||||||
|
const article = await Article.findById(id)
|
||||||
|
.populate('作者ID', '用户名 头像')
|
||||||
|
.populate('分类ID', '分类名称 别名')
|
||||||
|
.populate('标签ID列表', '标签名称');
|
||||||
|
|
||||||
|
if (!article) {
|
||||||
|
return res.status(404).json({ message: 'Article not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment view count
|
||||||
|
article.统计数据.阅读数 += 1;
|
||||||
|
await article.save({ validateBeforeSave: false });
|
||||||
|
|
||||||
|
// 2. Check Permissions
|
||||||
|
let hasAccess = false;
|
||||||
|
let userId = null;
|
||||||
|
|
||||||
|
// Get user from token if exists
|
||||||
|
const token = req.cookies.token;
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
const decoded = verifyToken(token);
|
||||||
|
if (decoded) {
|
||||||
|
userId = decoded.userId;
|
||||||
|
|
||||||
|
// Check if admin or author
|
||||||
|
if (decoded.role === 'admin' || decoded.userId === article.作者ID?._id?.toString()) {
|
||||||
|
hasAccess = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Invalid token, treat as guest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If article is free, everyone has access
|
||||||
|
if (article.价格 === 0) {
|
||||||
|
hasAccess = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not yet accessible and user is logged in, check purchase/membership
|
||||||
|
if (!hasAccess && userId) {
|
||||||
|
const user = await User.findById(userId);
|
||||||
|
|
||||||
|
// Check Membership
|
||||||
|
if (user?.会员信息?.过期时间 && new Date(user.会员信息.过期时间) > new Date()) {
|
||||||
|
hasAccess = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if purchased
|
||||||
|
if (!hasAccess) {
|
||||||
|
const order = await Order.findOne({
|
||||||
|
用户ID: userId,
|
||||||
|
商品ID: article._id.toString(),
|
||||||
|
订单状态: 'paid',
|
||||||
|
订单类型: 'buy_resource'
|
||||||
|
});
|
||||||
|
if (order) {
|
||||||
|
hasAccess = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Prepare Response
|
||||||
|
const articleData = article.toObject();
|
||||||
|
|
||||||
|
// Process Markdown to HTML
|
||||||
|
const htmlContent = await processMarkdown(articleData.正文内容);
|
||||||
|
articleData.正文内容 = htmlContent; // Replace content with HTML
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
// Hide sensitive content
|
||||||
|
delete articleData.资源属性;
|
||||||
|
// Optional: Truncate content for preview
|
||||||
|
articleData.正文内容 = await processMarkdown(article.正文内容.substring(0, 300) + '...');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
article: articleData,
|
||||||
|
hasAccess,
|
||||||
|
isLoggedIn: !!userId
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Fetch article error:', error);
|
||||||
|
res.status(500).json({ message: error.message || 'Internal server error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/pages/api/articles/index.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import { Article, Category } from '@/models';
|
||||||
|
import withDatabase from '@/lib/withDatabase';
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
try {
|
||||||
|
const { page = 1, limit = 9, category, tag, search } = req.query;
|
||||||
|
const pageNum = parseInt(page as string);
|
||||||
|
const limitNum = parseInt(limit as string);
|
||||||
|
const skip = (pageNum - 1) * limitNum;
|
||||||
|
|
||||||
|
const query: any = { 发布状态: 'published' };
|
||||||
|
|
||||||
|
if (category) {
|
||||||
|
// 如果传入的是分类ID,直接查询;如果是别名,先查分类ID
|
||||||
|
if (category.length === 24) {
|
||||||
|
query.分类ID = category;
|
||||||
|
} else {
|
||||||
|
const catDoc = await Category.findOne({ 别名: category });
|
||||||
|
if (catDoc) query.分类ID = catDoc._id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tag) {
|
||||||
|
// 暂不支持按标签别名查,假设传入的是ID或暂不处理复杂逻辑
|
||||||
|
// 实际项目中通常需要关联查询 Tag 表
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
query.文章标题 = { $regex: search, $options: 'i' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [articles, total] = await Promise.all([
|
||||||
|
Article.find(query)
|
||||||
|
.populate('作者ID', 'username avatar') // 假设 User 模型有 avatar 字段,如果没有需确认
|
||||||
|
.populate('分类ID', '分类名称 别名')
|
||||||
|
.populate('标签ID列表', '标签名称')
|
||||||
|
.select('-正文内容 -资源属性 -SEO关键词 -SEO描述') // 列表页不需要这些大字段
|
||||||
|
.sort({ createdAt: -1 }) // 最新发布在前
|
||||||
|
.skip(skip)
|
||||||
|
.limit(limitNum)
|
||||||
|
.lean(),
|
||||||
|
Article.countDocuments(query)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
articles,
|
||||||
|
pagination: {
|
||||||
|
total,
|
||||||
|
page: pageNum,
|
||||||
|
limit: limitNum,
|
||||||
|
pages: Math.ceil(total / limitNum)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch public articles error:', error);
|
||||||
|
return res.status(500).json({ message: '获取文章列表失败' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return res.status(405).json({ message: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withDatabase(handler);
|
||||||
64
src/pages/api/auth/login.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { User } from '@/models';
|
||||||
|
import withDatabase from '@/lib/withDatabase';
|
||||||
|
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this';
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
return res.status(405).json({ message: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { email, password } = req.body;
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
return res.status(400).json({ message: '请输入邮箱和密码' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 查找用户,显式选择密码字段
|
||||||
|
const user = await User.findOne({ 邮箱: email }).select('+密码');
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({ message: '邮箱或密码错误' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证密码
|
||||||
|
const isPasswordValid = await bcrypt.compare(password, user.密码);
|
||||||
|
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
return res.status(401).json({ message: '邮箱或密码错误' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成 JWT
|
||||||
|
const token = jwt.sign(
|
||||||
|
{ userId: user._id, email: user.邮箱, role: user.角色 },
|
||||||
|
JWT_SECRET,
|
||||||
|
{ expiresIn: '7d' }
|
||||||
|
);
|
||||||
|
|
||||||
|
// 设置 Cookie
|
||||||
|
res.setHeader(
|
||||||
|
'Set-Cookie',
|
||||||
|
`token=${token}; Path=/; HttpOnly; Max-Age=${60 * 60 * 24 * 7}; SameSite=Strict; ${process.env.NODE_ENV === 'production' ? 'Secure' : ''}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
message: '登录成功',
|
||||||
|
user: {
|
||||||
|
id: user._id,
|
||||||
|
username: user.用户名,
|
||||||
|
email: user.邮箱,
|
||||||
|
role: user.角色,
|
||||||
|
avatar: user.头像
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error);
|
||||||
|
return res.status(500).json({ message: '登录失败,请稍后重试' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withDatabase(handler);
|
||||||
12
src/pages/api/auth/logout.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
|
||||||
|
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
return res.status(405).json({ message: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the cookie by setting it to expire in the past
|
||||||
|
res.setHeader('Set-Cookie', 'token=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT; HttpOnly; SameSite=Strict');
|
||||||
|
|
||||||
|
res.status(200).json({ message: 'Logged out successfully' });
|
||||||
|
}
|
||||||
36
src/pages/api/auth/me.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import { verifyToken } from '@/lib/auth';
|
||||||
|
import dbConnect from '@/lib/dbConnect';
|
||||||
|
import { User } from '@/models';
|
||||||
|
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method !== 'GET') {
|
||||||
|
return res.status(405).json({ message: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = req.cookies.token;
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({ message: 'Not authenticated' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = verifyToken(token);
|
||||||
|
if (!decoded) {
|
||||||
|
return res.status(401).json({ message: 'Invalid token' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbConnect();
|
||||||
|
const user = await User.findById(decoded.userId)
|
||||||
|
.select('-密码') // Exclude password
|
||||||
|
.populate('会员信息.当前等级ID', '套餐名称');
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ message: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({ user });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auth check error:', error);
|
||||||
|
res.status(500).json({ message: 'Internal server error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/pages/api/auth/register.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { User } from '@/models';
|
||||||
|
import withDatabase from '@/lib/withDatabase';
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
return res.status(405).json({ message: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { email, password, username } = req.body;
|
||||||
|
|
||||||
|
if (!email || !password || !username) {
|
||||||
|
return res.status(400).json({ message: '请填写所有必填项' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 检查邮箱是否已存在
|
||||||
|
const existingUser = await User.findOne({ 邮箱: email });
|
||||||
|
if (existingUser) {
|
||||||
|
return res.status(409).json({ message: '该邮箱已被注册' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户名是否已存在
|
||||||
|
const existingUsername = await User.findOne({ 用户名: username });
|
||||||
|
if (existingUsername) {
|
||||||
|
return res.status(409).json({ message: '该用户名已被使用' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 哈希密码
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
|
// 创建新用户
|
||||||
|
const newUser = await User.create({
|
||||||
|
用户名: username,
|
||||||
|
邮箱: email,
|
||||||
|
密码: hashedPassword,
|
||||||
|
角色: 'user', // 默认为普通用户
|
||||||
|
头像: `https://api.dicebear.com/7.x/avataaars/svg?seed=${username}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).json({ message: '注册成功', userId: newUser._id });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Registration error:', error);
|
||||||
|
return res.status(500).json({ message: '注册失败,请稍后重试' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withDatabase(handler);
|
||||||
19
src/pages/api/categories/index.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import { Category } from '@/models';
|
||||||
|
import withDatabase from '@/lib/withDatabase';
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
try {
|
||||||
|
const categories = await Category.find().sort({ 排序权重: -1, createdAt: -1 }).lean();
|
||||||
|
return res.status(200).json(categories);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch public categories error:', error);
|
||||||
|
return res.status(500).json({ message: '获取分类失败' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return res.status(405).json({ message: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withDatabase(handler);
|
||||||
59
src/pages/api/comments/index.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import dbConnect from '@/lib/dbConnect';
|
||||||
|
import { Comment } from '@/models';
|
||||||
|
import { verifyToken } from '@/lib/auth';
|
||||||
|
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
await dbConnect();
|
||||||
|
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
try {
|
||||||
|
const { articleId } = req.query;
|
||||||
|
if (!articleId) {
|
||||||
|
return res.status(400).json({ message: 'Article ID required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const comments = await Comment.find({ 文章ID: articleId, 状态: 'visible' })
|
||||||
|
.populate('用户ID', '用户名 头像')
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.limit(50);
|
||||||
|
|
||||||
|
res.status(200).json({ comments });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ message: 'Failed to fetch comments' });
|
||||||
|
}
|
||||||
|
} else if (req.method === 'POST') {
|
||||||
|
try {
|
||||||
|
const token = req.cookies.token;
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({ message: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = verifyToken(token);
|
||||||
|
if (!decoded) {
|
||||||
|
return res.status(401).json({ message: 'Invalid token' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { articleId, content } = req.body;
|
||||||
|
|
||||||
|
if (!articleId || !content) {
|
||||||
|
return res.status(400).json({ message: 'Missing fields' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const comment = await Comment.create({
|
||||||
|
文章ID: articleId,
|
||||||
|
用户ID: decoded.userId,
|
||||||
|
评论内容: content,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Populate user info for immediate display
|
||||||
|
await comment.populate('用户ID', '用户名 头像');
|
||||||
|
|
||||||
|
res.status(201).json({ comment });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ message: 'Failed to post comment' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
res.status(405).json({ message: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/pages/api/hello.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
|
||||||
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
type Data = {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function handler(
|
||||||
|
req: NextApiRequest,
|
||||||
|
res: NextApiResponse<Data>,
|
||||||
|
) {
|
||||||
|
res.status(200).json({ name: "John Doe" });
|
||||||
|
}
|
||||||
121
src/pages/api/orders/create.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import dbConnect from '@/lib/dbConnect';
|
||||||
|
import { Order, MembershipPlan, User } from '@/models';
|
||||||
|
import { alipayService } from '@/lib/alipay';
|
||||||
|
import { getUserFromCookie } from '@/lib/auth';
|
||||||
|
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
return res.status(405).json({ message: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Auth Check
|
||||||
|
const user = getUserFromCookie(req);
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({ message: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { type, planId, itemId } = req.body;
|
||||||
|
|
||||||
|
await dbConnect();
|
||||||
|
|
||||||
|
let orderData: any = {
|
||||||
|
用户ID: user.userId,
|
||||||
|
支付方式: 'alipay',
|
||||||
|
订单状态: 'pending'
|
||||||
|
};
|
||||||
|
let subject = '';
|
||||||
|
let body = '';
|
||||||
|
let amount = 0;
|
||||||
|
|
||||||
|
if (type === 'buy_membership') {
|
||||||
|
if (!planId) {
|
||||||
|
return res.status(400).json({ message: 'Plan ID is required' });
|
||||||
|
}
|
||||||
|
const plan = await MembershipPlan.findById(planId);
|
||||||
|
if (!plan) {
|
||||||
|
return res.status(404).json({ message: 'Plan not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
orderData.订单类型 = 'buy_membership';
|
||||||
|
orderData.商品ID = plan._id;
|
||||||
|
orderData.商品快照 = {
|
||||||
|
标题: plan.套餐名称,
|
||||||
|
封面: '/images/membership-cover.png'
|
||||||
|
};
|
||||||
|
amount = plan.价格;
|
||||||
|
subject = `购买会员 - ${plan.套餐名称}`;
|
||||||
|
body = plan.描述;
|
||||||
|
|
||||||
|
} else if (type === 'buy_resource') {
|
||||||
|
if (!itemId) {
|
||||||
|
return res.status(400).json({ message: 'Item ID is required' });
|
||||||
|
}
|
||||||
|
// Import Article model dynamically or ensure it's imported at top
|
||||||
|
const { Article } = await import('@/models');
|
||||||
|
const article = await Article.findById(itemId);
|
||||||
|
if (!article) {
|
||||||
|
return res.status(404).json({ message: 'Article not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
orderData.订单类型 = 'buy_resource';
|
||||||
|
orderData.商品ID = article._id;
|
||||||
|
orderData.商品快照 = {
|
||||||
|
标题: article.文章标题,
|
||||||
|
封面: article.封面图 || '/images/article-cover.png'
|
||||||
|
};
|
||||||
|
amount = article.价格;
|
||||||
|
subject = `购买资源 - ${article.文章标题}`;
|
||||||
|
body = article.摘要 || '付费资源';
|
||||||
|
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({ message: 'Invalid order type' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Create Order
|
||||||
|
const outTradeNo = `ORDER_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
||||||
|
orderData.订单号 = outTradeNo;
|
||||||
|
orderData.支付金额 = amount;
|
||||||
|
|
||||||
|
const order = await Order.create(orderData) as any;
|
||||||
|
|
||||||
|
// 4. Generate Alipay URL
|
||||||
|
// Determine return URL based on order type
|
||||||
|
let returnUrl = '';
|
||||||
|
if (orderData.订单类型 === 'buy_resource') {
|
||||||
|
if (req.body.returnUrl) {
|
||||||
|
// Point to backend return handler, passing the frontend URL as 'target'
|
||||||
|
// We need the base URL of the API. Assuming it's in config or we can derive it.
|
||||||
|
// Since we don't have easy access to base URL here without config, let's use the notifyUrl base if available
|
||||||
|
// or just assume relative path if alipay supports it (it doesn't).
|
||||||
|
// We'll use the alipayService config which has notifyUrl (usually the domain)
|
||||||
|
// We need to access alipayService.config, but it's private.
|
||||||
|
// Let's re-init it to be sure or just use a known env var if possible.
|
||||||
|
// Actually, alipayService.generatePagePayUrl uses config.notifyUrl to build default return_url.
|
||||||
|
// We can just pass the FULL URL to generatePagePayUrl.
|
||||||
|
|
||||||
|
// Hack: We need the domain. Let's assume req.headers.host
|
||||||
|
const protocol = req.headers['x-forwarded-proto'] || 'http';
|
||||||
|
const host = req.headers.host;
|
||||||
|
const baseUrl = `${protocol}://${host}`;
|
||||||
|
|
||||||
|
returnUrl = `${baseUrl}/api/payment/return?target=${encodeURIComponent(req.body.returnUrl)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payUrl = await alipayService.generatePagePayUrl({
|
||||||
|
outTradeNo: order.订单号,
|
||||||
|
totalAmount: order.支付金额.toFixed(2),
|
||||||
|
subject: subject.substring(0, 256), // Alipay subject limit
|
||||||
|
body: body?.substring(0, 128), // Alipay body limit
|
||||||
|
returnUrl: returnUrl || undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({ success: true, payUrl });
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Order creation error:', error);
|
||||||
|
res.status(500).json({ message: error.message || 'Internal server error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/pages/api/payment/notify.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import dbConnect from '@/lib/dbConnect';
|
||||||
|
import { Order, User, MembershipPlan } from '@/models';
|
||||||
|
import { alipayService } from '@/lib/alipay';
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
api: {
|
||||||
|
bodyParser: false, // Alipay sends x-www-form-urlencoded
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getRawBody(req: NextApiRequest): Promise<string> {
|
||||||
|
const chunks = [];
|
||||||
|
for await (const chunk of req) {
|
||||||
|
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
|
||||||
|
}
|
||||||
|
return Buffer.concat(chunks).toString('utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method !== 'POST') {
|
||||||
|
return res.status(405).send('Method Not Allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawBody = await getRawBody(req);
|
||||||
|
const params = new URLSearchParams(rawBody);
|
||||||
|
const data: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const [key, value] of params.entries()) {
|
||||||
|
data[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Verify Signature
|
||||||
|
console.log('Received Alipay Notify:', data);
|
||||||
|
const isValid = await alipayService.verifySignature(data);
|
||||||
|
console.log('Signature Verification Result:', isValid);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
console.error('Alipay signature verification failed');
|
||||||
|
return res.send('fail');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check Trade Status
|
||||||
|
const tradeStatus = data.trade_status;
|
||||||
|
|
||||||
|
// Only process successful payments
|
||||||
|
if (tradeStatus !== 'TRADE_SUCCESS' && tradeStatus !== 'TRADE_FINISHED') {
|
||||||
|
console.log(`Order ${data.out_trade_no} - ignoring status: ${tradeStatus}`);
|
||||||
|
return res.send('success');
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbConnect();
|
||||||
|
|
||||||
|
// Find Order
|
||||||
|
const outTradeNo = data.out_trade_no;
|
||||||
|
const order = await Order.findOne({ 订单号: outTradeNo });
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
console.error(`Order not found: ${outTradeNo}`);
|
||||||
|
return res.send('fail');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.订单状态 === 'paid') {
|
||||||
|
return res.send('success'); // Already processed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Order to paid
|
||||||
|
order.订单状态 = 'paid';
|
||||||
|
order.支付时间 = new Date();
|
||||||
|
order.支付方式 = 'alipay';
|
||||||
|
await order.save();
|
||||||
|
|
||||||
|
// Update User Membership
|
||||||
|
if (order.订单类型 === 'buy_membership') {
|
||||||
|
const plan = await MembershipPlan.findById(order.商品ID);
|
||||||
|
if (plan) {
|
||||||
|
const user = await User.findById(order.用户ID);
|
||||||
|
if (user) {
|
||||||
|
const currentExpiry = user.会员信息?.过期时间 ? new Date(user.会员信息.过期时间) : new Date();
|
||||||
|
const now = new Date();
|
||||||
|
const startTime = currentExpiry > now ? currentExpiry : now;
|
||||||
|
const newExpiry = new Date(startTime.getTime() + plan.有效天数 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
user.会员信息 = {
|
||||||
|
当前等级ID: plan._id,
|
||||||
|
过期时间: newExpiry
|
||||||
|
};
|
||||||
|
await user.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Order ${outTradeNo} marked as paid`);
|
||||||
|
return res.send('success');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Alipay notify error:', error);
|
||||||
|
res.send('fail');
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/pages/api/payment/return.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import dbConnect from '@/lib/dbConnect';
|
||||||
|
import { Order, User, MembershipPlan } from '@/models';
|
||||||
|
import { alipayService } from '@/lib/alipay';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle synchronous return from Alipay (return_url)
|
||||||
|
* This is called when user is redirected back after payment
|
||||||
|
*/
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method !== 'GET') {
|
||||||
|
return res.status(405).json({ message: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = req.query as Record<string, string>;
|
||||||
|
|
||||||
|
console.log('Received Alipay Return:', params);
|
||||||
|
|
||||||
|
// 1. Verify Signature
|
||||||
|
const isValid = await alipayService.verifySignature(params);
|
||||||
|
console.log('Return Signature Verification:', isValid);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
console.error('Alipay return signature verification failed');
|
||||||
|
return res.redirect('/payment/failure?error=signature');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check Trade Status
|
||||||
|
const tradeStatus = params.trade_status;
|
||||||
|
const outTradeNo = params.out_trade_no;
|
||||||
|
|
||||||
|
await dbConnect();
|
||||||
|
|
||||||
|
// 3. Find Order
|
||||||
|
const order = await Order.findOne({ 订单号: outTradeNo });
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
console.error(`Order not found: ${outTradeNo}`);
|
||||||
|
return res.redirect('/payment/failure?error=order_not_found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Handle payment success
|
||||||
|
if (tradeStatus === 'TRADE_SUCCESS' || tradeStatus === 'TRADE_FINISHED') {
|
||||||
|
// Payment successful - update to paid
|
||||||
|
if (order.订单状态 !== 'paid') {
|
||||||
|
order.订单状态 = 'paid';
|
||||||
|
order.支付时间 = new Date();
|
||||||
|
order.支付方式 = 'alipay';
|
||||||
|
await order.save();
|
||||||
|
|
||||||
|
// Update User Membership
|
||||||
|
if (order.订单类型 === 'buy_membership') {
|
||||||
|
const plan = await MembershipPlan.findById(order.商品ID);
|
||||||
|
if (plan) {
|
||||||
|
const user = await User.findById(order.用户ID);
|
||||||
|
if (user) {
|
||||||
|
const currentExpiry = user.会员信息?.过期时间 ? new Date(user.会员信息.过期时间) : new Date();
|
||||||
|
const now = new Date();
|
||||||
|
const startTime = currentExpiry > now ? currentExpiry : now;
|
||||||
|
const newExpiry = new Date(startTime.getTime() + plan.有效天数 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
user.会员信息 = {
|
||||||
|
当前等级ID: plan._id,
|
||||||
|
过期时间: newExpiry
|
||||||
|
};
|
||||||
|
await user.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Order ${outTradeNo} status updated to paid via return_url`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to target if present, otherwise success page
|
||||||
|
const target = params.target as string;
|
||||||
|
if (target) {
|
||||||
|
return res.redirect(target);
|
||||||
|
}
|
||||||
|
return res.redirect('/payment/success');
|
||||||
|
}
|
||||||
|
|
||||||
|
// For any other status
|
||||||
|
const target = params.target as string;
|
||||||
|
if (target) {
|
||||||
|
return res.redirect(target);
|
||||||
|
}
|
||||||
|
return res.redirect('/membership');
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Alipay return handler error:', error);
|
||||||
|
res.redirect('/payment/failure?error=server');
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/pages/api/plans/index.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import { MembershipPlan } from '@/models';
|
||||||
|
import withDatabase from '@/lib/withDatabase';
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
try {
|
||||||
|
const plans = await MembershipPlan.find({ 是否上架: true }).sort({ 价格: 1 });
|
||||||
|
return res.status(200).json(plans);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(500).json({ message: 'Failed to fetch plans' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return res.status(405).json({ message: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withDatabase(handler);
|
||||||
32
src/pages/api/public/config.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import dbConnect from '@/lib/dbConnect';
|
||||||
|
import { SystemConfig } from '@/models';
|
||||||
|
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method !== 'GET') {
|
||||||
|
return res.status(405).json({ message: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await dbConnect();
|
||||||
|
|
||||||
|
const config = await SystemConfig.findOne({ 配置标识: 'default' })
|
||||||
|
.select('Banner配置 站点设置')
|
||||||
|
.lean();
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return res.status(404).json({ message: 'Config not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter visible banners
|
||||||
|
const visibleBanners = config.Banner配置?.filter((b: any) => b.状态 === 'visible') || [];
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
banners: visibleBanners,
|
||||||
|
site: config.站点设置
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch public config error:', error);
|
||||||
|
res.status(500).json({ message: 'Internal server error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/pages/api/tags/index.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import { Tag } from '@/models';
|
||||||
|
import withDatabase from '@/lib/withDatabase';
|
||||||
|
|
||||||
|
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
try {
|
||||||
|
// 简单返回所有标签,实际可优化为返回热门标签
|
||||||
|
const tags = await Tag.find().limit(20).sort({ createdAt: -1 }).lean();
|
||||||
|
return res.status(200).json(tags);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch public tags error:', error);
|
||||||
|
return res.status(500).json({ message: '获取标签失败' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return res.status(405).json({ message: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withDatabase(handler);
|
||||||
33
src/pages/api/user/orders.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import dbConnect from '@/lib/dbConnect';
|
||||||
|
import { Order } from '@/models';
|
||||||
|
import { verifyToken } from '@/lib/auth';
|
||||||
|
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method !== 'GET') {
|
||||||
|
return res.status(405).json({ message: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = req.cookies.token;
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({ message: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = verifyToken(token);
|
||||||
|
if (!decoded) {
|
||||||
|
return res.status(401).json({ message: 'Invalid token' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbConnect();
|
||||||
|
|
||||||
|
const orders = await Order.find({ 用户ID: decoded.userId })
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.limit(20);
|
||||||
|
|
||||||
|
res.status(200).json({ orders });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch user orders error:', error);
|
||||||
|
res.status(500).json({ message: 'Internal server error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/pages/api/user/orders/index.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
import { verifyToken } from '@/lib/auth';
|
||||||
|
import dbConnect from '@/lib/dbConnect';
|
||||||
|
import { Order } from '@/models';
|
||||||
|
|
||||||
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
if (req.method !== 'GET') {
|
||||||
|
return res.status(405).json({ message: 'Method not allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = req.cookies.token;
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({ message: 'Not authenticated' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = verifyToken(token);
|
||||||
|
if (!decoded) {
|
||||||
|
return res.status(401).json({ message: 'Invalid token' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbConnect();
|
||||||
|
|
||||||
|
const orders = await Order.find({ 用户ID: decoded.userId })
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.lean();
|
||||||
|
|
||||||
|
res.status(200).json({ orders });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch orders error:', error);
|
||||||
|
res.status(500).json({ message: 'Internal server error' });
|
||||||
|
}
|
||||||
|
}
|
||||||