first commit
This commit is contained in:
4
.env.example
Normal file
4
.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
# MONGODB数据库
|
||||
MONGODB_URI=
|
||||
# JWT配置,用于生成token
|
||||
JWT_SECRET=
|
||||
4
.env.local
Normal file
4
.env.local
Normal file
@@ -0,0 +1,4 @@
|
||||
# MONGODB数据库
|
||||
MONGODB_URI=mongodb://18152827937:aiwoQwo520@192.168.10.2:27017/aoun?authSource=admin
|
||||
# JWT配置,用于生成token
|
||||
JWT_SECRET=123456
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -31,7 +31,7 @@ yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
# .env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
51
Dockerfile
Normal file
51
Dockerfile
Normal file
@@ -0,0 +1,51 @@
|
||||
# 🚀 优化版 Dockerfile for Next.js with pnpm (目标: <200MB)
|
||||
# 第一阶段:安装依赖
|
||||
FROM node:22-alpine AS deps
|
||||
WORKDIR /app
|
||||
# 查看 https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine 以了解为什么可能需要 libc6-compat
|
||||
RUN apk add --no-cache libc6-compat
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN npm install -g pnpm && pnpm i --frozen-lockfile
|
||||
|
||||
# 第二阶段:构建应用
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
# 在构建期间禁用遥测
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN npm install -g pnpm && pnpm run build
|
||||
|
||||
# 第三阶段:生产镜像,复制所有文件并运行 next
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
# 在运行时禁用遥测
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# 复制 public 文件夹
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# 自动利用输出追踪来减小镜像大小
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
# 设置主机名为 0.0.0.0 以便外部访问
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]PORT=3000 \
|
||||
HOSTNAME="0.0.0.0" \
|
||||
NODE_ENV=production
|
||||
|
||||
# 启动应用 (standalone模式直接运行server.js)
|
||||
CMD ["node", "server.js"]
|
||||
101
README.md
101
README.md
@@ -1,40 +1,91 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/pages/api-reference/create-next-app).
|
||||
# AounApp - 个人资源付费系统
|
||||
|
||||
## Getting Started
|
||||
这是一个基于 Next.js 全栈开发的个人资源付费与会员系统。支持文章/资源发布、会员订阅、积分充值、以及支付宝/微信支付集成。
|
||||
|
||||
First, run the development server:
|
||||
## 🛠 技术栈 (Tech Stack)
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
### 核心框架
|
||||
- **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/))
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
### 前端 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 富文本编辑器)
|
||||
|
||||
You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
|
||||
### 后端 & 工具
|
||||
- **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`
|
||||
|
||||
[API routes](https://nextjs.org/docs/pages/building-your-application/routing/api-routes) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
|
||||
## ✨ 核心功能 (Key Features)
|
||||
|
||||
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/pages/building-your-application/routing/api-routes) instead of React pages.
|
||||
1. **用户系统**
|
||||
- 注册/登录 (JWT)
|
||||
- 个人中心 (Dashboard):查看订单、会员状态、积分
|
||||
- 钱包系统:积分充值与消费
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/pages/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
2. **内容管理 (CMS)**
|
||||
- **文章/资源发布**:支持 Markdown 编辑 (Tiptap),支持设置价格(积分/现金)、隐藏内容(下载链接/提取码)。
|
||||
- **分类与标签**:灵活的内容组织。
|
||||
- **评论系统**:支持多级评论。
|
||||
|
||||
## Learn More
|
||||
3. **会员与支付**
|
||||
- **会员订阅**:不同等级会员享受不同折扣和每日下载限制。
|
||||
- **支付集成**:
|
||||
- **支付宝**:原生集成,支持当面付/电脑网站支付(根据配置)。
|
||||
- **积分支付**:站内虚拟货币支付。
|
||||
- **订单系统**:完整的订单创建、支付回调、状态流转。
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
4. **管理后台 (Admin)**
|
||||
- 仪表盘:数据概览。
|
||||
- 用户管理、文章管理、订单管理、会员套餐管理。
|
||||
- 系统设置:全局 SEO、支付参数配置。
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn-pages-router) - an interactive Next.js tutorial.
|
||||
## ⚠️ 关键架构与注意事项 (Architecture & Notes)
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
### 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` 直接渲染。
|
||||
|
||||
## Deploy on Vercel
|
||||
### 2. 编辑器 (Tiptap)
|
||||
* **SSR 兼容性**:Tiptap 在 Next.js 中使用时,必须在 `useEditor` 中设置 `immediatelyRender: false`,以避免服务端渲染与客户端水合不匹配 (Hydration Mismatch) 导致的运行时错误。
|
||||
* **Markdown 支持**:集成了 `tiptap-markdown` 扩展,确保编辑器内容可以作为 Markdown 格式保存到数据库,保持数据格式的通用性。
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
### 3. 支付系统
|
||||
* **去 SDK 化**:为了避免 `alipay-sdk` 等库的潜在依赖问题和体积,我们使用 Node.js 原生 `crypto` 模块实现了支付宝的签名和验签逻辑 (`src/lib/alipay.ts`)。
|
||||
* **安全性**:支付回调 (`notify`) 必须严格验证签名,防止伪造请求。
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/pages/building-your-application/deploying) for more details.
|
||||
### 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`
|
||||
|
||||
34
implementation_plan.md
Normal file
34
implementation_plan.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Admin Order Management Implementation Plan
|
||||
|
||||
## Goal
|
||||
Implement a backend API and a frontend page for administrators to view and manage orders.
|
||||
|
||||
## User Review Required
|
||||
None.
|
||||
|
||||
## Proposed Changes
|
||||
|
||||
### Backend - APIs
|
||||
#### [NEW] [index.ts](file:///d:/AI/aounapp/src/pages/api/admin/orders/index.ts)
|
||||
- `GET /api/admin/orders`
|
||||
- Protected route (admin only).
|
||||
- Query params: `page`, `limit`, `status`, `search` (order no or username).
|
||||
- Logic: Fetch orders with pagination and filtering, populate user details.
|
||||
|
||||
### Frontend - Pages
|
||||
#### [NEW] [index.tsx](file:///d:/AI/aounapp/src/pages/admin/orders/index.tsx)
|
||||
- Display a table of orders.
|
||||
- Columns: Order No, User, Type, Amount, Status, Payment Method, Date.
|
||||
- Filters: Status filter, Search input.
|
||||
- Pagination.
|
||||
|
||||
### Frontend - Components
|
||||
#### [MODIFY] [AdminLayout.tsx](file:///d:/AI/aounapp/src/components/admin/AdminLayout.tsx)
|
||||
- Ensure the "Order Management" link is active and points to `/admin/orders`. (Already present, just verifying).
|
||||
|
||||
## Verification Plan
|
||||
### Manual Verification
|
||||
1. **Access**: Log in as admin and navigate to `/admin/orders`.
|
||||
2. **List**: Verify orders are listed correctly.
|
||||
3. **Filter**: Test filtering by status and searching by order number.
|
||||
4. **Pagination**: Test pagination if enough orders exist.
|
||||
BIN
lint_errors.log
Normal file
BIN
lint_errors.log
Normal file
Binary file not shown.
822
lint_errors_utf8.log
Normal file
822
lint_errors_utf8.log
Normal file
@@ -0,0 +1,822 @@
|
||||
|
||||
> aounapp@0.1.0 lint D:\aounapp
|
||||
> eslint
|
||||
|
||||
|
||||
D:\aounapp\src\components\admin\AIPanel.tsx
|
||||
64:42 warning 'e' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
D:\aounapp\src\components\admin\AdminLayout.tsx
|
||||
140:29 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
|
||||
D:\aounapp\src\components\admin\ArticleEditor.tsx
|
||||
37:50 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
78:8 warning React Hook useEffect has a missing dependency: 'fetchArticle'. Either include it or remove the dependency array react-hooks/exhaustive-deps
|
||||
91:60 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
112:50 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
128:35 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
D:\aounapp\src\components\admin\TiptapEditor.tsx
|
||||
55:51 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
73:89 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
93:14 error Error: Cannot create components during render
|
||||
|
||||
Components created during render will reset their state each time they are created. Declare components outside of render.
|
||||
|
||||
D:\aounapp\src\components\admin\TiptapEditor.tsx:93:14
|
||||
91 | </Button>
|
||||
92 | <div className="w-px h-6 bg-gray-200 mx-1 self-center" />
|
||||
> 93 | <ToggleButton isActive={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}><Bold className="h-4 w-4" /></ToggleButton>
|
||||
| ^^^^^^^^^^^^ This component is created during render
|
||||
94 | <ToggleButton isActive={editor.isActive('italic')} onClick={() => editor.chain().focus().toggleItalic().run()}><Italic className="h-4 w-4" /></ToggleButton>
|
||||
95 | <ToggleButton isActive={editor.isActive('strike')} onClick={() => editor.chain().focus().toggleStrike().run()}><Strikethrough className="h-4 w-4" /></ToggleButton>
|
||||
96 | <div className="w-px h-6 bg-gray-200 mx-1 self-center" />
|
||||
|
||||
D:\aounapp\src\components\admin\TiptapEditor.tsx:73:26
|
||||
71 | };
|
||||
72 |
|
||||
> 73 | const ToggleButton = ({ isActive, onClick, children, disabled = false, className }: any) => (
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
> 74 | <Button
|
||||
| ^^^^^^^^^^^^^^^
|
||||
> 75 | type="button"
|
||||
鈥? | ^^^^^^^^^^^^^^^
|
||||
> 83 | </Button>
|
||||
| ^^^^^^^^^^^^^^^
|
||||
> 84 | );
|
||||
| ^^^^^^ The component is created during render here
|
||||
85 |
|
||||
86 | return (
|
||||
87 | <div className="border-b bg-muted/50/50 p-2 flex flex-wrap gap-1 sticky top-0 z-10 items-center"> react-hooks/static-components
|
||||
94:14 error Error: Cannot create components during render
|
||||
|
||||
Components created during render will reset their state each time they are created. Declare components outside of render.
|
||||
|
||||
D:\aounapp\src\components\admin\TiptapEditor.tsx:94:14
|
||||
92 | <div className="w-px h-6 bg-gray-200 mx-1 self-center" />
|
||||
93 | <ToggleButton isActive={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}><Bold className="h-4 w-4" /></ToggleButton>
|
||||
> 94 | <ToggleButton isActive={editor.isActive('italic')} onClick={() => editor.chain().focus().toggleItalic().run()}><Italic className="h-4 w-4" /></ToggleButton>
|
||||
| ^^^^^^^^^^^^ This component is created during render
|
||||
95 | <ToggleButton isActive={editor.isActive('strike')} onClick={() => editor.chain().focus().toggleStrike().run()}><Strikethrough className="h-4 w-4" /></ToggleButton>
|
||||
96 | <div className="w-px h-6 bg-gray-200 mx-1 self-center" />
|
||||
97 | <ToggleButton isActive={editor.isActive('heading', { level: 1 })} onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}><Heading1 className="h-4 w-4" /></ToggleButton>
|
||||
|
||||
D:\aounapp\src\components\admin\TiptapEditor.tsx:73:26
|
||||
71 | };
|
||||
72 |
|
||||
> 73 | const ToggleButton = ({ isActive, onClick, children, disabled = false, className }: any) => (
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
> 74 | <Button
|
||||
| ^^^^^^^^^^^^^^^
|
||||
> 75 | type="button"
|
||||
鈥? | ^^^^^^^^^^^^^^^
|
||||
> 83 | </Button>
|
||||
| ^^^^^^^^^^^^^^^
|
||||
> 84 | );
|
||||
| ^^^^^^ The component is created during render here
|
||||
85 |
|
||||
86 | return (
|
||||
87 | <div className="border-b bg-muted/50/50 p-2 flex flex-wrap gap-1 sticky top-0 z-10 items-center"> react-hooks/static-components
|
||||
95:14 error Error: Cannot create components during render
|
||||
|
||||
Components created during render will reset their state each time they are created. Declare components outside of render.
|
||||
|
||||
D:\aounapp\src\components\admin\TiptapEditor.tsx:95:14
|
||||
93 | <ToggleButton isActive={editor.isActive('bold')} onClick={() => editor.chain().focus().toggleBold().run()}><Bold className="h-4 w-4" /></ToggleButton>
|
||||
94 | <ToggleButton isActive={editor.isActive('italic')} onClick={() => editor.chain().focus().toggleItalic().run()}><Italic className="h-4 w-4" /></ToggleButton>
|
||||
> 95 | <ToggleButton isActive={editor.isActive('strike')} onClick={() => editor.chain().focus().toggleStrike().run()}><Strikethrough className="h-4 w-4" /></ToggleButton>
|
||||
| ^^^^^^^^^^^^ This component is created during render
|
||||
96 | <div className="w-px h-6 bg-gray-200 mx-1 self-center" />
|
||||
97 | <ToggleButton isActive={editor.isActive('heading', { level: 1 })} onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}><Heading1 className="h-4 w-4" /></ToggleButton>
|
||||
98 | <ToggleButton isActive={editor.isActive('heading', { level: 2 })} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}><Heading2 className="h-4 w-4" /></ToggleButton>
|
||||
|
||||
D:\aounapp\src\components\admin\TiptapEditor.tsx:73:26
|
||||
71 | };
|
||||
72 |
|
||||
> 73 | const ToggleButton = ({ isActive, onClick, children, disabled = false, className }: any) => (
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
> 74 | <Button
|
||||
| ^^^^^^^^^^^^^^^
|
||||
> 75 | type="button"
|
||||
鈥? | ^^^^^^^^^^^^^^^
|
||||
> 83 | </Button>
|
||||
| ^^^^^^^^^^^^^^^
|
||||
> 84 | );
|
||||
| ^^^^^^ The component is created during render here
|
||||
85 |
|
||||
86 | return (
|
||||
87 | <div className="border-b bg-muted/50/50 p-2 flex flex-wrap gap-1 sticky top-0 z-10 items-center"> react-hooks/static-components
|
||||
97:14 error Error: Cannot create components during render
|
||||
|
||||
Components created during render will reset their state each time they are created. Declare components outside of render.
|
||||
|
||||
D:\aounapp\src\components\admin\TiptapEditor.tsx:97:14
|
||||
95 | <ToggleButton isActive={editor.isActive('strike')} onClick={() => editor.chain().focus().toggleStrike().run()}><Strikethrough className="h-4 w-4" /></ToggleButton>
|
||||
96 | <div className="w-px h-6 bg-gray-200 mx-1 self-center" />
|
||||
> 97 | <ToggleButton isActive={editor.isActive('heading', { level: 1 })} onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}><Heading1 className="h-4 w-4" /></ToggleButton>
|
||||
| ^^^^^^^^^^^^ This component is created during render
|
||||
98 | <ToggleButton isActive={editor.isActive('heading', { level: 2 })} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}><Heading2 className="h-4 w-4" /></ToggleButton>
|
||||
99 | <ToggleButton isActive={editor.isActive('heading', { level: 3 })} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}><Heading3 className="h-4 w-4" /></ToggleButton>
|
||||
100 | <div className="w-px h-6 bg-gray-200 mx-1 self-center" />
|
||||
|
||||
D:\aounapp\src\components\admin\TiptapEditor.tsx:73:26
|
||||
71 | };
|
||||
72 |
|
||||
> 73 | const ToggleButton = ({ isActive, onClick, children, disabled = false, className }: any) => (
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
> 74 | <Button
|
||||
| ^^^^^^^^^^^^^^^
|
||||
> 75 | type="button"
|
||||
鈥? | ^^^^^^^^^^^^^^^
|
||||
> 83 | </Button>
|
||||
| ^^^^^^^^^^^^^^^
|
||||
> 84 | );
|
||||
| ^^^^^^ The component is created during render here
|
||||
85 |
|
||||
86 | return (
|
||||
87 | <div className="border-b bg-muted/50/50 p-2 flex flex-wrap gap-1 sticky top-0 z-10 items-center"> react-hooks/static-components
|
||||
98:14 error Error: Cannot create components during render
|
||||
|
||||
Components created during render will reset their state each time they are created. Declare components outside of render.
|
||||
|
||||
D:\aounapp\src\components\admin\TiptapEditor.tsx:98:14
|
||||
96 | <div className="w-px h-6 bg-gray-200 mx-1 self-center" />
|
||||
97 | <ToggleButton isActive={editor.isActive('heading', { level: 1 })} onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}><Heading1 className="h-4 w-4" /></ToggleButton>
|
||||
> 98 | <ToggleButton isActive={editor.isActive('heading', { level: 2 })} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}><Heading2 className="h-4 w-4" /></ToggleButton>
|
||||
| ^^^^^^^^^^^^ This component is created during render
|
||||
99 | <ToggleButton isActive={editor.isActive('heading', { level: 3 })} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}><Heading3 className="h-4 w-4" /></ToggleButton>
|
||||
100 | <div className="w-px h-6 bg-gray-200 mx-1 self-center" />
|
||||
101 | <ToggleButton isActive={editor.isActive('bulletList')} onClick={() => editor.chain().focus().toggleBulletList().run()}><List className="h-4 w-4" /></ToggleButton>
|
||||
|
||||
D:\aounapp\src\components\admin\TiptapEditor.tsx:73:26
|
||||
71 | };
|
||||
72 |
|
||||
> 73 | const ToggleButton = ({ isActive, onClick, children, disabled = false, className }: any) => (
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
> 74 | <Button
|
||||
| ^^^^^^^^^^^^^^^
|
||||
> 75 | type="button"
|
||||
鈥? | ^^^^^^^^^^^^^^^
|
||||
> 83 | </Button>
|
||||
| ^^^^^^^^^^^^^^^
|
||||
> 84 | );
|
||||
| ^^^^^^ The component is created during render here
|
||||
85 |
|
||||
86 | return (
|
||||
87 | <div className="border-b bg-muted/50/50 p-2 flex flex-wrap gap-1 sticky top-0 z-10 items-center"> react-hooks/static-components
|
||||
99:14 error Error: Cannot create components during render
|
||||
|
||||
Components created during render will reset their state each time they are created. Declare components outside of render.
|
||||
|
||||
D:\aounapp\src\components\admin\TiptapEditor.tsx:99:14
|
||||
97 | <ToggleButton isActive={editor.isActive('heading', { level: 1 })} onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}><Heading1 className="h-4 w-4" /></ToggleButton>
|
||||
98 | <ToggleButton isActive={editor.isActive('heading', { level: 2 })} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}><Heading2 className="h-4 w-4" /></ToggleButton>
|
||||
> 99 | <ToggleButton isActive={editor.isActive('heading', { level: 3 })} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}><Heading3 className="h-4 w-4" /></ToggleButton>
|
||||
| ^^^^^^^^^^^^ This component is created during render
|
||||
100 | <div className="w-px h-6 bg-gray-200 mx-1 self-center" />
|
||||
101 | <ToggleButton isActive={editor.isActive('bulletList')} onClick={() => editor.chain().focus().toggleBulletList().run()}><List className="h-4 w-4" /></ToggleButton>
|
||||
102 | <ToggleButton isActive={editor.isActive('orderedList')} onClick={() => editor.chain().focus().toggleOrderedList().run()}><ListOrdered className="h-4 w-4" /></ToggleButton>
|
||||
|
||||
D:\aounapp\src\components\admin\TiptapEditor.tsx:73:26
|
||||
71 | };
|
||||
72 |
|
||||
> 73 | const ToggleButton = ({ isActive, onClick, children, disabled = false, className }: any) => (
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
> 74 | <Button
|
||||
| ^^^^^^^^^^^^^^^
|
||||
> 75 | type="button"
|
||||
鈥? | ^^^^^^^^^^^^^^^
|
||||
> 83 | </Button>
|
||||
| ^^^^^^^^^^^^^^^
|
||||
> 84 | );
|
||||
| ^^^^^^ The component is created during render here
|
||||
85 |
|
||||
86 | return (
|
||||
87 | <div className="border-b bg-muted/50/50 p-2 flex flex-wrap gap-1 sticky top-0 z-10 items-center"> react-hooks/static-components
|
||||
101:14 error Error: Cannot create components during render
|
||||
|
||||
Components created during render will reset their state each time they are created. Declare components outside of render.
|
||||
|
||||
D:\aounapp\src\components\admin\TiptapEditor.tsx:101:14
|
||||
99 | <ToggleButton isActive={editor.isActive('heading', { level: 3 })} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}><Heading3 className="h-4 w-4" /></ToggleButton>
|
||||
100 | <div className="w-px h-6 bg-gray-200 mx-1 self-center" />
|
||||
> 101 | <ToggleButton isActive={editor.isActive('bulletList')} onClick={() => editor.chain().focus().toggleBulletList().run()}><List className="h-4 w-4" /></ToggleButton>
|
||||
| ^^^^^^^^^^^^ This component is created during render
|
||||
102 | <ToggleButton isActive={editor.isActive('orderedList')} onClick={() => editor.chain().focus().toggleOrderedList().run()}><ListOrdered className="h-4 w-4" /></ToggleButton>
|
||||
103 | <div className="w-px h-6 bg-gray-200 mx-1 self-center" />
|
||||
104 | <ToggleButton isActive={editor.isActive('blockquote')} onClick={() => editor.chain().focus().toggleBlockquote().run()}><Quote className="h-4 w-4" /></ToggleButton>
|
||||
|
||||
D:\aounapp\src\components\admin\TiptapEditor.tsx:73:26
|
||||
71 | };
|
||||
72 |
|
||||
> 73 | const ToggleButton = ({ isActive, onClick, children, disabled = false, className }: any) => (
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
> 74 | <Button
|
||||
| ^^^^^^^^^^^^^^^
|
||||
> 75 | type="button"
|
||||
鈥? | ^^^^^^^^^^^^^^^
|
||||
> 83 | </Button>
|
||||
| ^^^^^^^^^^^^^^^
|
||||
> 84 | );
|
||||
| ^^^^^^ The component is created during render here
|
||||
85 |
|
||||
86 | return (
|
||||
87 | <div className="border-b bg-muted/50/50 p-2 flex flex-wrap gap-1 sticky top-0 z-10 items-center"> react-hooks/static-components
|
||||
102:14 error Error: Cannot create components during render
|
||||
|
||||
Components created during render will reset their state each time they are created. Declare components outside of render.
|
||||
|
||||
D:\aounapp\src\components\admin\TiptapEditor.tsx:102:14
|
||||
100 | <div className="w-px h-6 bg-gray-200 mx-1 self-center" />
|
||||
101 | <ToggleButton isActive={editor.isActive('bulletList')} onClick={() => editor.chain().focus().toggleBulletList().run()}><List className="h-4 w-4" /></ToggleButton>
|
||||
> 102 | <ToggleButton isActive={editor.isActive('orderedList')} onClick={() => editor.chain().focus().toggleOrderedList().run()}><ListOrdered className="h-4 w-4" /></ToggleButton>
|
||||
| ^^^^^^^^^^^^ This component is created during render
|
||||
103 | <div className="w-px h-6 bg-gray-200 mx-1 self-center" />
|
||||
104 | <ToggleButton isActive={editor.isActive('blockquote')} onClick={() => editor.chain().focus().toggleBlockquote().run()}><Quote className="h-4 w-4" /></ToggleButton>
|
||||
105 | <ToggleButton isActive={editor.isActive('codeBlock')} onClick={() => editor.chain().focus().toggleCodeBlock().run()}><Code className="h-4 w-4" /></ToggleButton>
|
||||
|
||||
D:\aounapp\src\components\admin\TiptapEditor.tsx:73:26
|
||||
71 | };
|
||||
72 |
|
||||
> 73 | const ToggleButton = ({ isActive, onClick, children, disabled = false, className }: any) => (
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
> 74 | <Button
|
||||
| ^^^^^^^^^^^^^^^
|
||||
> 75 | type="button"
|
||||
鈥? | ^^^^^^^^^^^^^^^
|
||||
> 83 | </Button>
|
||||
| ^^^^^^^^^^^^^^^
|
||||
> 84 | );
|
||||
| ^^^^^^ The component is created during render here
|
||||
85 |
|
||||
86 | return (
|
||||
87 | <div className="border-b bg-muted/50/50 p-2 flex flex-wrap gap-1 sticky top-0 z-10 items-center"> react-hooks/static-components
|
||||
104:14 error Error: Cannot create components during render
|
||||
|
||||
Components created during render will reset their state each time they are created. Declare components outside of render.
|
||||
|
||||
D:\aounapp\src\components\admin\TiptapEditor.tsx:104:14
|
||||
102 | <ToggleButton isActive={editor.isActive('orderedList')} onClick={() => editor.chain().focus().toggleOrderedList().run()}><ListOrdered className="h-4 w-4" /></ToggleButton>
|
||||
103 | <div className="w-px h-6 bg-gray-200 mx-1 self-center" />
|
||||
> 104 | <ToggleButton isActive={editor.isActive('blockquote')} onClick={() => editor.chain().focus().toggleBlockquote().run()}><Quote className="h-4 w-4" /></ToggleButton>
|
||||
| ^^^^^^^^^^^^ This component is created during render
|
||||
105 | <ToggleButton isActive={editor.isActive('codeBlock')} onClick={() => editor.chain().focus().toggleCodeBlock().run()}><Code className="h-4 w-4" /></ToggleButton>
|
||||
106 | <div className="w-px h-6 bg-gray-200 mx-1 self-center" />
|
||||
107 | <ToggleButton onClick={addLink} isActive={editor.isActive('link')}><LinkIcon className="h-4 w-4" /></ToggleButton>
|
||||
|
||||
D:\aounapp\src\components\admin\TiptapEditor.tsx:73:26
|
||||
71 | };
|
||||
72 |
|
||||
> 73 | const ToggleButton = ({ isActive, onClick, children, disabled = false, className }: any) => (
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
> 74 | <Button
|
||||
| ^^^^^^^^^^^^^^^
|
||||
> 75 | type="button"
|
||||
鈥? | ^^^^^^^^^^^^^^^
|
||||
> 83 | </Button>
|
||||
| ^^^^^^^^^^^^^^^
|
||||
> 84 | );
|
||||
| ^^^^^^ The component is created during render here
|
||||
85 |
|
||||
86 | return (
|
||||
87 | <div className="border-b bg-muted/50/50 p-2 flex flex-wrap gap-1 sticky top-0 z-10 items-center"> react-hooks/static-components
|
||||
105:14 error Error: Cannot create components during render
|
||||
|
||||
Components created during render will reset their state each time they are created. Declare components outside of render.
|
||||
|
||||
D:\aounapp\src\components\admin\TiptapEditor.tsx:105:14
|
||||
103 | <div className="w-px h-6 bg-gray-200 mx-1 self-center" />
|
||||
104 | <ToggleButton isActive={editor.isActive('blockquote')} onClick={() => editor.chain().focus().toggleBlockquote().run()}><Quote className="h-4 w-4" /></ToggleButton>
|
||||
> 105 | <ToggleButton isActive={editor.isActive('codeBlock')} onClick={() => editor.chain().focus().toggleCodeBlock().run()}><Code className="h-4 w-4" /></ToggleButton>
|
||||
| ^^^^^^^^^^^^ This component is created during render
|
||||
106 | <div className="w-px h-6 bg-gray-200 mx-1 self-center" />
|
||||
107 | <ToggleButton onClick={addLink} isActive={editor.isActive('link')}><LinkIcon className="h-4 w-4" /></ToggleButton>
|
||||
108 | <ToggleButton onClick={addImage}><ImageIcon className="h-4 w-4" /></ToggleButton>
|
||||
|
||||
D:\aounapp\src\components\admin\TiptapEditor.tsx:73:26
|
||||
71 | };
|
||||
72 |
|
||||
> 73 | const ToggleButton = ({ isActive, onClick, children, disabled = false, className }: any) => (
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
> 74 | <Button
|
||||
| ^^^^^^^^^^^^^^^
|
||||
> 75 | type="button"
|
||||
鈥? | ^^^^^^^^^^^^^^^
|
||||
> 83 | </Button>
|
||||
| ^^^^^^^^^^^^^^^
|
||||
> 84 | );
|
||||
| ^^^^^^ The component is created during render here
|
||||
85 |
|
||||
86 | return (
|
||||
87 | <div className="border-b bg-muted/50/50 p-2 flex flex-wrap gap-1 sticky top-0 z-10 items-center"> react-hooks/static-components
|
||||
107:14 error Error: Cannot create components during render
|
||||
|
||||
Components created during render will reset their state each time they are created. Declare components outside of render.
|
||||
|
||||
D:\aounapp\src\components\admin\TiptapEditor.tsx:107:14
|
||||
105 | <ToggleButton isActive={editor.isActive('codeBlock')} onClick={() => editor.chain().focus().toggleCodeBlock().run()}><Code className="h-4 w-4" /></ToggleButton>
|
||||
106 | <div className="w-px h-6 bg-gray-200 mx-1 self-center" />
|
||||
> 107 | <ToggleButton onClick={addLink} isActive={editor.isActive('link')}><LinkIcon className="h-4 w-4" /></ToggleButton>
|
||||
| ^^^^^^^^^^^^ This component is created during render
|
||||
108 | <ToggleButton onClick={addImage}><ImageIcon className="h-4 w-4" /></ToggleButton>
|
||||
109 | <div className="w-px h-6 bg-gray-200 mx-1 self-center" />
|
||||
110 | <ToggleButton onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()}><Undo className="h-4 w-4" /></ToggleButton>
|
||||
|
||||
D:\aounapp\src\components\admin\TiptapEditor.tsx:73:26
|
||||
71 | };
|
||||
72 |
|
||||
> 73 | const ToggleButton = ({ isActive, onClick, children, disabled = false, className }: any) => (
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
> 74 | <Button
|
||||
| ^^^^^^^^^^^^^^^
|
||||
> 75 | type="button"
|
||||
鈥? | ^^^^^^^^^^^^^^^
|
||||
> 83 | </Button>
|
||||
| ^^^^^^^^^^^^^^^
|
||||
> 84 | );
|
||||
| ^^^^^^ The component is created during render here
|
||||
85 |
|
||||
86 | return (
|
||||
87 | <div className="border-b bg-muted/50/50 p-2 flex flex-wrap gap-1 sticky top-0 z-10 items-center"> react-hooks/static-components
|
||||
108:14 error Error: Cannot create components during render
|
||||
|
||||
Components created during render will reset their state each time they are created. Declare components outside of render.
|
||||
|
||||
D:\aounapp\src\components\admin\TiptapEditor.tsx:108:14
|
||||
106 | <div className="w-px h-6 bg-gray-200 mx-1 self-center" />
|
||||
107 | <ToggleButton onClick={addLink} isActive={editor.isActive('link')}><LinkIcon className="h-4 w-4" /></ToggleButton>
|
||||
> 108 | <ToggleButton onClick={addImage}><ImageIcon className="h-4 w-4" /></ToggleButton>
|
||||
| ^^^^^^^^^^^^ This component is created during render
|
||||
109 | <div className="w-px h-6 bg-gray-200 mx-1 self-center" />
|
||||
110 | <ToggleButton onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()}><Undo className="h-4 w-4" /></ToggleButton>
|
||||
111 | <ToggleButton onClick={() => editor.chain().focus().redo().run()} disabled={!editor.can().redo()}><Redo className="h-4 w-4" /></ToggleButton>
|
||||
|
||||
D:\aounapp\src\components\admin\TiptapEditor.tsx:73:26
|
||||
71 | };
|
||||
72 |
|
||||
> 73 | const ToggleButton = ({ isActive, onClick, children, disabled = false, className }: any) => (
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
> 74 | <Button
|
||||
| ^^^^^^^^^^^^^^^
|
||||
> 75 | type="button"
|
||||
鈥? | ^^^^^^^^^^^^^^^
|
||||
> 83 | </Button>
|
||||
| ^^^^^^^^^^^^^^^
|
||||
> 84 | );
|
||||
| ^^^^^^ The component is created during render here
|
||||
85 |
|
||||
86 | return (
|
||||
87 | <div className="border-b bg-muted/50/50 p-2 flex flex-wrap gap-1 sticky top-0 z-10 items-center"> react-hooks/static-components
|
||||
110:14 error Error: Cannot create components during render
|
||||
|
||||
Components created during render will reset their state each time they are created. Declare components outside of render.
|
||||
|
||||
D:\aounapp\src\components\admin\TiptapEditor.tsx:110:14
|
||||
108 | <ToggleButton onClick={addImage}><ImageIcon className="h-4 w-4" /></ToggleButton>
|
||||
109 | <div className="w-px h-6 bg-gray-200 mx-1 self-center" />
|
||||
> 110 | <ToggleButton onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()}><Undo className="h-4 w-4" /></ToggleButton>
|
||||
| ^^^^^^^^^^^^ This component is created during render
|
||||
111 | <ToggleButton onClick={() => editor.chain().focus().redo().run()} disabled={!editor.can().redo()}><Redo className="h-4 w-4" /></ToggleButton>
|
||||
112 | </div>
|
||||
113 | );
|
||||
|
||||
D:\aounapp\src\components\admin\TiptapEditor.tsx:73:26
|
||||
71 | };
|
||||
72 |
|
||||
> 73 | const ToggleButton = ({ isActive, onClick, children, disabled = false, className }: any) => (
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
> 74 | <Button
|
||||
| ^^^^^^^^^^^^^^^
|
||||
> 75 | type="button"
|
||||
鈥? | ^^^^^^^^^^^^^^^
|
||||
> 83 | </Button>
|
||||
| ^^^^^^^^^^^^^^^
|
||||
> 84 | );
|
||||
| ^^^^^^ The component is created during render here
|
||||
85 |
|
||||
86 | return (
|
||||
87 | <div className="border-b bg-muted/50/50 p-2 flex flex-wrap gap-1 sticky top-0 z-10 items-center"> react-hooks/static-components
|
||||
111:14 error Error: Cannot create components during render
|
||||
|
||||
Components created during render will reset their state each time they are created. Declare components outside of render.
|
||||
|
||||
D:\aounapp\src\components\admin\TiptapEditor.tsx:111:14
|
||||
109 | <div className="w-px h-6 bg-gray-200 mx-1 self-center" />
|
||||
110 | <ToggleButton onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()}><Undo className="h-4 w-4" /></ToggleButton>
|
||||
> 111 | <ToggleButton onClick={() => editor.chain().focus().redo().run()} disabled={!editor.can().redo()}><Redo className="h-4 w-4" /></ToggleButton>
|
||||
| ^^^^^^^^^^^^ This component is created during render
|
||||
112 | </div>
|
||||
113 | );
|
||||
114 | };
|
||||
|
||||
D:\aounapp\src\components\admin\TiptapEditor.tsx:73:26
|
||||
71 | };
|
||||
72 |
|
||||
> 73 | const ToggleButton = ({ isActive, onClick, children, disabled = false, className }: any) => (
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
> 74 | <Button
|
||||
| ^^^^^^^^^^^^^^^
|
||||
> 75 | type="button"
|
||||
鈥? | ^^^^^^^^^^^^^^^
|
||||
> 83 | </Button>
|
||||
| ^^^^^^^^^^^^^^^
|
||||
> 84 | );
|
||||
| ^^^^^^ The component is created during render here
|
||||
85 |
|
||||
86 | return (
|
||||
87 | <div className="border-b bg-muted/50/50 p-2 flex flex-wrap gap-1 sticky top-0 z-10 items-center"> react-hooks/static-components
|
||||
127:73 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
140:49 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
155:37 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
181:39 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
202:51 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
290:42 warning 'e' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
D:\aounapp\src\components\article\CommentSection.tsx
|
||||
35:8 warning React Hook useEffect has a missing dependency: 'fetchComments'. Either include it or remove the dependency array react-hooks/exhaustive-deps
|
||||
75:18 warning 'error' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
D:\aounapp\src\components\dashboard\AIConfigSettings.tsx
|
||||
67:18 warning 'error' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
D:\aounapp\src\components\home\HeroBanner.tsx
|
||||
31:9 error Error: Calling setState synchronously within an effect can trigger cascading renders
|
||||
|
||||
Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:
|
||||
* Update external systems with the latest state from React.
|
||||
* Subscribe for updates from some external system, calling setState in a callback function when external state changes.
|
||||
|
||||
Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).
|
||||
|
||||
D:\aounapp\src\components\home\HeroBanner.tsx:31:9
|
||||
29 | }
|
||||
30 |
|
||||
> 31 | setCount(api.scrollSnapList().length);
|
||||
| ^^^^^^^^ Avoid calling setState() directly within an effect
|
||||
32 | setCurrent(api.selectedScrollSnap());
|
||||
33 |
|
||||
34 | api.on("select", () => { react-hooks/set-state-in-effect
|
||||
|
||||
D:\aounapp\src\components\home\Sidebar.tsx
|
||||
5:11 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
D:\aounapp\src\components\layouts\AdminLayout.tsx
|
||||
100:29 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
|
||||
D:\aounapp\src\components\layouts\MainLayout.tsx
|
||||
138:29 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
210:45 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
232:45 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
240:49 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
|
||||
D:\aounapp\src\components\tiptap-node\image-upload-node\image-upload-node-extension.ts
|
||||
38:34 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
D:\aounapp\src\components\tiptap-templates\simple\simple-editor.tsx
|
||||
71:3 warning 'onHighlighterClick' is defined but never used @typescript-eslint/no-unused-vars
|
||||
72:3 warning 'onLinkClick' is defined but never used @typescript-eslint/no-unused-vars
|
||||
73:3 warning 'isMobile' is defined but never used @typescript-eslint/no-unused-vars
|
||||
174:26 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
D:\aounapp\src\components\tiptap-ui-primitive\tooltip\tooltip.tsx
|
||||
182:35 error Error: Cannot access refs during render
|
||||
|
||||
React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the `current` property) during render can cause your component not to update as expected (https://react.dev/reference/react/useRef).
|
||||
|
||||
D:\aounapp\src\components\tiptap-ui-primitive\tooltip\tooltip.tsx:182:35
|
||||
180 | return cloneElement(
|
||||
181 | children,
|
||||
> 182 | context.getReferenceProps({
|
||||
| ^
|
||||
> 183 | ref,
|
||||
| ^^^^^^^^^^^^^^
|
||||
> 184 | ...props,
|
||||
| ^^^^^^^^^^^^^^
|
||||
> 185 | ...(typeof children.props === "object" ? children.props : {}),
|
||||
| ^^^^^^^^^^^^^^
|
||||
> 186 | ...dataAttributes,
|
||||
| ^^^^^^^^^^^^^^
|
||||
> 187 | })
|
||||
| ^^^^^^^^^^ Passing a ref to a function may read its value during render
|
||||
188 | )
|
||||
189 | }
|
||||
190 | react-hooks/refs
|
||||
|
||||
D:\aounapp\src\components\ui\textarea.tsx
|
||||
5:18 error An interface declaring no members is equivalent to its supertype @typescript-eslint/no-empty-object-type
|
||||
|
||||
D:\aounapp\src\hooks\use-cursor-visibility.ts
|
||||
9:47 warning 'overlayHeight' is defined but never used @typescript-eslint/no-unused-vars
|
||||
18:16 warning 'e' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
D:\aounapp\src\hooks\use-tiptap-editor.ts
|
||||
1:10 warning 'Editor' is defined but never used @typescript-eslint/no-unused-vars
|
||||
3:33 warning 'deps' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
3:39 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
D:\aounapp\src\lib\alipay.ts
|
||||
98:23 warning 'sign_type' is assigned a value but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
D:\aounapp\src\lib\api-handler.ts
|
||||
29:25 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
D:\aounapp\src\lib\auth.ts
|
||||
21:14 warning 'error' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
D:\aounapp\src\lib\tiptap-utils.ts
|
||||
14:41 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
19:31 warning 'pos' is defined but never used @typescript-eslint/no-unused-vars
|
||||
20:33 warning 'pos' is defined but never used @typescript-eslint/no-unused-vars
|
||||
20:39 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
D:\aounapp\src\pages\admin\articles\index.tsx
|
||||
48:8 warning React Hook useEffect has a missing dependency: 'fetchArticles'. Either include it or remove the dependency array react-hooks/exhaustive-deps
|
||||
|
||||
D:\aounapp\src\pages\admin\banners\index.tsx
|
||||
40:8 warning React Hook useEffect has a missing dependency: 'fetchSettings'. Either include it or remove the dependency array react-hooks/exhaustive-deps
|
||||
166:53 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
|
||||
D:\aounapp\src\pages\admin\index.tsx
|
||||
20:42 error Error: Cannot call impure function during render
|
||||
|
||||
`Math.random` is an impure function. Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).
|
||||
|
||||
D:\aounapp\src\pages\admin\index.tsx:20:42
|
||||
18 | // 妯℃嫙鍥捐〃鏁版嵁 (瀹為檯搴斾粠 API 鑾峰彇)
|
||||
19 | const data = [
|
||||
> 20 | { name: 'Jan', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
| ^^^^^^^^^^^^^ Cannot call impure function
|
||||
21 | { name: 'Feb', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
22 | { name: 'Mar', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
23 | { name: 'Apr', total: Math.floor(Math.random() * 5000) + 1000 }, react-hooks/purity
|
||||
21:42 error Error: Cannot call impure function during render
|
||||
|
||||
`Math.random` is an impure function. Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).
|
||||
|
||||
D:\aounapp\src\pages\admin\index.tsx:21:42
|
||||
19 | const data = [
|
||||
20 | { name: 'Jan', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
> 21 | { name: 'Feb', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
| ^^^^^^^^^^^^^ Cannot call impure function
|
||||
22 | { name: 'Mar', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
23 | { name: 'Apr', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
24 | { name: 'May', total: Math.floor(Math.random() * 5000) + 1000 }, react-hooks/purity
|
||||
22:42 error Error: Cannot call impure function during render
|
||||
|
||||
`Math.random` is an impure function. Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).
|
||||
|
||||
D:\aounapp\src\pages\admin\index.tsx:22:42
|
||||
20 | { name: 'Jan', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
21 | { name: 'Feb', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
> 22 | { name: 'Mar', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
| ^^^^^^^^^^^^^ Cannot call impure function
|
||||
23 | { name: 'Apr', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
24 | { name: 'May', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
25 | { name: 'Jun', total: Math.floor(Math.random() * 5000) + 1000 }, react-hooks/purity
|
||||
23:42 error Error: Cannot call impure function during render
|
||||
|
||||
`Math.random` is an impure function. Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).
|
||||
|
||||
D:\aounapp\src\pages\admin\index.tsx:23:42
|
||||
21 | { name: 'Feb', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
22 | { name: 'Mar', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
> 23 | { name: 'Apr', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
| ^^^^^^^^^^^^^ Cannot call impure function
|
||||
24 | { name: 'May', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
25 | { name: 'Jun', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
26 | { name: 'Jul', total: Math.floor(Math.random() * 5000) + 1000 }, react-hooks/purity
|
||||
24:42 error Error: Cannot call impure function during render
|
||||
|
||||
`Math.random` is an impure function. Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).
|
||||
|
||||
D:\aounapp\src\pages\admin\index.tsx:24:42
|
||||
22 | { name: 'Mar', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
23 | { name: 'Apr', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
> 24 | { name: 'May', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
| ^^^^^^^^^^^^^ Cannot call impure function
|
||||
25 | { name: 'Jun', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
26 | { name: 'Jul', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
27 | { name: 'Aug', total: Math.floor(Math.random() * 5000) + 1000 }, react-hooks/purity
|
||||
25:42 error Error: Cannot call impure function during render
|
||||
|
||||
`Math.random` is an impure function. Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).
|
||||
|
||||
D:\aounapp\src\pages\admin\index.tsx:25:42
|
||||
23 | { name: 'Apr', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
24 | { name: 'May', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
> 25 | { name: 'Jun', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
| ^^^^^^^^^^^^^ Cannot call impure function
|
||||
26 | { name: 'Jul', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
27 | { name: 'Aug', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
28 | { name: 'Sep', total: Math.floor(Math.random() * 5000) + 1000 }, react-hooks/purity
|
||||
26:42 error Error: Cannot call impure function during render
|
||||
|
||||
`Math.random` is an impure function. Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).
|
||||
|
||||
D:\aounapp\src\pages\admin\index.tsx:26:42
|
||||
24 | { name: 'May', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
25 | { name: 'Jun', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
> 26 | { name: 'Jul', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
| ^^^^^^^^^^^^^ Cannot call impure function
|
||||
27 | { name: 'Aug', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
28 | { name: 'Sep', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
29 | { name: 'Oct', total: Math.floor(Math.random() * 5000) + 1000 }, react-hooks/purity
|
||||
27:42 error Error: Cannot call impure function during render
|
||||
|
||||
`Math.random` is an impure function. Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).
|
||||
|
||||
D:\aounapp\src\pages\admin\index.tsx:27:42
|
||||
25 | { name: 'Jun', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
26 | { name: 'Jul', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
> 27 | { name: 'Aug', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
| ^^^^^^^^^^^^^ Cannot call impure function
|
||||
28 | { name: 'Sep', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
29 | { name: 'Oct', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
30 | { name: 'Nov', total: Math.floor(Math.random() * 5000) + 1000 }, react-hooks/purity
|
||||
28:42 error Error: Cannot call impure function during render
|
||||
|
||||
`Math.random` is an impure function. Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).
|
||||
|
||||
D:\aounapp\src\pages\admin\index.tsx:28:42
|
||||
26 | { name: 'Jul', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
27 | { name: 'Aug', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
> 28 | { name: 'Sep', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
| ^^^^^^^^^^^^^ Cannot call impure function
|
||||
29 | { name: 'Oct', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
30 | { name: 'Nov', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
31 | { name: 'Dec', total: Math.floor(Math.random() * 5000) + 1000 }, react-hooks/purity
|
||||
29:42 error Error: Cannot call impure function during render
|
||||
|
||||
`Math.random` is an impure function. Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).
|
||||
|
||||
D:\aounapp\src\pages\admin\index.tsx:29:42
|
||||
27 | { name: 'Aug', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
28 | { name: 'Sep', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
> 29 | { name: 'Oct', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
| ^^^^^^^^^^^^^ Cannot call impure function
|
||||
30 | { name: 'Nov', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
31 | { name: 'Dec', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
32 | ]; react-hooks/purity
|
||||
30:42 error Error: Cannot call impure function during render
|
||||
|
||||
`Math.random` is an impure function. Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).
|
||||
|
||||
D:\aounapp\src\pages\admin\index.tsx:30:42
|
||||
28 | { name: 'Sep', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
29 | { name: 'Oct', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
> 30 | { name: 'Nov', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
| ^^^^^^^^^^^^^ Cannot call impure function
|
||||
31 | { name: 'Dec', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
32 | ];
|
||||
33 | react-hooks/purity
|
||||
31:42 error Error: Cannot call impure function during render
|
||||
|
||||
`Math.random` is an impure function. Calling an impure function can produce unstable results that update unpredictably when the component happens to re-render. (https://react.dev/reference/rules/components-and-hooks-must-be-pure#components-and-hooks-must-be-idempotent).
|
||||
|
||||
D:\aounapp\src\pages\admin\index.tsx:31:42
|
||||
29 | { name: 'Oct', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
30 | { name: 'Nov', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
> 31 | { name: 'Dec', total: Math.floor(Math.random() * 5000) + 1000 },
|
||||
| ^^^^^^^^^^^^^ Cannot call impure function
|
||||
32 | ];
|
||||
33 |
|
||||
34 | return ( react-hooks/purity
|
||||
201:22 error A `require()` style import is forbidden @typescript-eslint/no-require-imports
|
||||
212:61 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
D:\aounapp\src\pages\admin\orders\index.tsx
|
||||
26:42 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
41:8 warning React Hook useEffect has a missing dependency: 'fetchOrders'. Either include it or remove the dependency array react-hooks/exhaustive-deps
|
||||
166:53 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
|
||||
D:\aounapp\src\pages\admin\plans\edit\[id].tsx
|
||||
38:8 warning React Hook useEffect has missing dependencies: 'fetchPlan' and 'isEditMode'. Either include them or remove the dependency array react-hooks/exhaustive-deps
|
||||
55:35 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
D:\aounapp\src\pages\admin\plans\index.tsx
|
||||
17:40 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
D:\aounapp\src\pages\admin\settings\index.tsx
|
||||
83:8 warning React Hook useEffect has a missing dependency: 'fetchSettings'. Either include it or remove the dependency array react-hooks/exhaustive-deps
|
||||
175:61 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
203:57 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
|
||||
D:\aounapp\src\pages\admin\users\index.tsx
|
||||
69:8 warning React Hook useEffect has a missing dependency: 'fetchUsers'. Either include it or remove the dependency array react-hooks/exhaustive-deps
|
||||
|
||||
D:\aounapp\src\pages\aitools\chat\index.tsx
|
||||
39:61 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
102:25 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
126:42 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
D:\aounapp\src\pages\api\admin\ai\models.ts
|
||||
6:67 warning '_user' is defined but never used @typescript-eslint/no-unused-vars
|
||||
6:74 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
24:58 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
61:54 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
D:\aounapp\src\pages\api\admin\articles\[id].ts
|
||||
20:18 warning 'error' is defined but never used @typescript-eslint/no-unused-vars
|
||||
41:18 warning 'error' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
D:\aounapp\src\pages\api\admin\articles\index.ts
|
||||
6:73 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
14:26 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
D:\aounapp\src\pages\api\admin\orders\index.ts
|
||||
17:22 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
59:21 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
D:\aounapp\src\pages\api\admin\plans\[id].ts
|
||||
13:18 warning 'error' is defined but never used @typescript-eslint/no-unused-vars
|
||||
21:18 warning 'error' is defined but never used @typescript-eslint/no-unused-vars
|
||||
29:18 warning 'error' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
D:\aounapp\src\pages\api\admin\plans\index.ts
|
||||
10:18 warning 'error' is defined but never used @typescript-eslint/no-unused-vars
|
||||
17:18 warning 'error' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
D:\aounapp\src\pages\api\admin\settings.ts
|
||||
6:67 warning '_user' is defined but never used @typescript-eslint/no-unused-vars
|
||||
6:74 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
37:67 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
42:74 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
D:\aounapp\src\pages\api\admin\users\[id].ts
|
||||
6:78 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
D:\aounapp\src\pages\api\admin\users\index.ts
|
||||
6:67 warning '_user' is defined but never used @typescript-eslint/no-unused-vars
|
||||
6:74 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
16:18 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
D:\aounapp\src\pages\api\ai\chat.ts
|
||||
38:60 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
53:63 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
97:21 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
D:\aounapp\src\pages\api\ai\generate.ts
|
||||
20:67 warning '_user' is defined but never used @typescript-eslint/no-unused-vars
|
||||
20:74 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
44:48 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
46:48 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
D:\aounapp\src\pages\api\articles\[id].ts
|
||||
65:22 warning 'e' is defined but never used @typescript-eslint/no-unused-vars
|
||||
133:21 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
D:\aounapp\src\pages\api\articles\index.ts
|
||||
13:26 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
D:\aounapp\src\pages\api\comments\index.ts
|
||||
22:18 warning 'error' is defined but never used @typescript-eslint/no-unused-vars
|
||||
53:18 warning 'error' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
D:\aounapp\src\pages\api\cron\expire-orders.ts
|
||||
39:21 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
D:\aounapp\src\pages\api\init.ts
|
||||
16:21 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
D:\aounapp\src\pages\api\orders\create.ts
|
||||
23:26 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
81:56 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
117:21 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
D:\aounapp\src\pages\api\payment\return.ts
|
||||
90:21 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
D:\aounapp\src\pages\api\plans\index.ts
|
||||
10:18 warning 'error' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
D:\aounapp\src\pages\api\public\config.ts
|
||||
22:60 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
D:\aounapp\src\pages\api\user\profile.ts
|
||||
27:27 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
D:\aounapp\src\pages\article\[id].tsx
|
||||
68:8 warning React Hook useEffect has missing dependencies: 'fetchArticle' and 'fetchRecentArticles'. Either include them or remove the dependency array react-hooks/exhaustive-deps
|
||||
134:18 warning 'error' is defined but never used @typescript-eslint/no-unused-vars
|
||||
418:67 warning '_index' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
D:\aounapp\src\pages\auth\login.tsx
|
||||
64:23 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
D:\aounapp\src\pages\auth\register.tsx
|
||||
70:23 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
D:\aounapp\src\pages\dashboard\index.tsx
|
||||
93:18 warning 'error' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
D:\aounapp\src\pages\membership.tsx
|
||||
9:40 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
51:25 error Unexpected any. Specify a different type @typescript-eslint/no-explicit-any
|
||||
|
||||
D:\aounapp\src\pages\test1.tsx
|
||||
27:48 warning 'originalName' is defined but never used @typescript-eslint/no-unused-vars
|
||||
51:48 warning 'originalName' is defined but never used @typescript-eslint/no-unused-vars
|
||||
|
||||
鉁?142 problems (88 errors, 54 warnings)
|
||||
|
||||
鈥塃LIFECYCLE鈥?Command failed with exit code 1.
|
||||
@@ -2,7 +2,28 @@ import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: 'standalone',
|
||||
reactStrictMode: true,
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'picsum.photos',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'api.dicebear.com',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'images.unsplash.com',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'unsplash.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
51
package.json
51
package.json
@@ -9,18 +9,65 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.27.19",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@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-radio-group": "^1.3.8",
|
||||
"@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",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@uiw/react-markdown-preview": "^5.1.5",
|
||||
"@uiw/react-md-editor": "^4.0.8",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cron": "^4.4.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"formidable": "^3.5.4",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.554.0",
|
||||
"mongoose": "^9.0.0",
|
||||
"next": "16.0.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"openai": "^6.27.0",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0"
|
||||
"react-dom": "19.2.0",
|
||||
"react-hook-form": "^7.66.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^3.8.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",
|
||||
"unified": "^11.0.5",
|
||||
"uuid": "^13.0.0",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/formidable": "^3.5.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"baseline-browser-mapping": "^2.10.0",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.3",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
}
|
||||
3488
pnpm-lock.yaml
generated
3488
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
1
public/Gemini.svg
Normal file
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
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 |
Binary file not shown.
|
After Width: | Height: | Size: 630 KiB |
BIN
public/uploads/69ab99a97d6530829d334dd8_admin/shumox.png
Normal file
BIN
public/uploads/69ab99a97d6530829d334dd8_admin/shumox.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 163 KiB |
BIN
public/uploads/69ab99a97d6530829d334dd8_admin/画板 21.png
Normal file
BIN
public/uploads/69ab99a97d6530829d334dd8_admin/画板 21.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
478
scripts/seed.ts
Normal file
478
scripts/seed.ts
Normal file
@@ -0,0 +1,478 @@
|
||||
/**
|
||||
* 作者: 阿瑞
|
||||
* 功能: 数据库填充脚本 (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)];
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 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-1643116774075-acc00caa9a7b?q=80&w=1740&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
|
||||
按钮文本: '立即学习',
|
||||
按钮链接: '/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();
|
||||
166
src/components/admin/AIPanel.tsx
Normal file
166
src/components/admin/AIPanel.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Sparkles, Wand2, RefreshCw, Minimize2, Maximize2, Check, Send, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
interface AIPanelProps {
|
||||
assistantId: string;
|
||||
onStream?: (text: string) => void;
|
||||
contextText?: string;
|
||||
}
|
||||
|
||||
export default function AIPanel({ assistantId, onStream, contextText = '' }: AIPanelProps) {
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
|
||||
const runAiAction = async (customPrompt?: string) => {
|
||||
if (!assistantId) {
|
||||
toast.error('请先选择 AI 助手');
|
||||
return;
|
||||
}
|
||||
|
||||
const finalPrompt = customPrompt || prompt;
|
||||
if (!finalPrompt.trim()) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/ai/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
prompt: finalPrompt,
|
||||
assistantId: assistantId,
|
||||
context: contextText
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Generation failed');
|
||||
|
||||
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 (onStream) {
|
||||
onStream(content);
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const data = await res.json();
|
||||
if (onStream) {
|
||||
onStream(data.text);
|
||||
}
|
||||
}
|
||||
setPrompt(''); // Clear prompt after success
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('AI 生成失败');
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const quickActions = [
|
||||
{ label: '润色优化', icon: RefreshCw, action: () => runAiAction(`请润色以下文本,使其更加流畅优美:\n${contextText}`) },
|
||||
{ label: '内容总结', icon: Minimize2, action: () => runAiAction(`请总结以下文本的核心内容:\n${contextText}`) },
|
||||
{ label: '扩写内容', icon: Maximize2, action: () => runAiAction(`请扩写以下文本,补充更多细节:\n${contextText}`) },
|
||||
{ label: '纠错检查', icon: Check, action: () => runAiAction(`请检查并纠正以下文本中的错别字和语法错误:\n${contextText}`) },
|
||||
{ label: '续写', icon: Wand2, action: () => runAiAction(`请根据以下上下文继续写作:\n${contextText}`) },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-background w-full">
|
||||
<div className="p-4 border-b border-border flex items-center gap-2 bg-muted/40">
|
||||
<Sparkles className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||
<span className="font-semibold text-sm text-foreground">AI 助手</span>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<div className="space-y-6">
|
||||
{/* Quick Actions */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">快捷指令</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{quickActions.map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="justify-start h-8 text-xs bg-background hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
onClick={action.action}
|
||||
disabled={isGenerating || !contextText}
|
||||
>
|
||||
{isGenerating && action.label === '生成中...' ? (
|
||||
<Loader2 className="w-3 h-3 mr-2 animate-spin" />
|
||||
) : (
|
||||
<action.icon className="w-3 h-3 mr-2" />
|
||||
)}
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{!contextText && (
|
||||
<p className="text-[10px] text-muted-foreground text-center py-2">
|
||||
选中编辑器中的文本以使用快捷指令
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Custom Input */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">自定义指令</h4>
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
placeholder="输入你的指令..."
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
className="min-h-[100px] text-sm resize-none pr-10 bg-muted/50 border-border focus:bg-background transition-colors"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
runAiAction();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
className="absolute bottom-2 right-2 h-7 w-7 rounded-full bg-purple-600 hover:bg-purple-700 text-white shadow-sm disabled:opacity-50"
|
||||
onClick={() => runAiAction()}
|
||||
disabled={isGenerating || !prompt.trim()}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
160
src/components/admin/AdminLayout.tsx
Normal file
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-muted/40 flex overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={cn(
|
||||
"bg-background border-r border-border 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-border 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-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Operations Section */}
|
||||
<div className="mt-4 pt-4 border-t border-border">
|
||||
<h3 className="px-4 text-xs font-semibold text-muted-foreground 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-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<CreditCard className="w-5 h-5" />
|
||||
套餐管理
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
<div className="p-4 border-t border-border shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
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-background border-b border-border 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-muted-foreground">
|
||||
欢迎回来,<span className="font-semibold text-foreground">管理员</span>
|
||||
</div>
|
||||
<div className="w-8 h-8 rounded-full bg-muted 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>
|
||||
);
|
||||
}
|
||||
449
src/components/admin/ArticleEditor.tsx
Normal file
449
src/components/admin/ArticleEditor.tsx
Normal file
@@ -0,0 +1,449 @@
|
||||
import { useEffect, useState, useCallback } 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 { MarkdownEditor } from '@/components/markdown/MarkdownEditor';
|
||||
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
import { Loader2, Save, ArrowLeft, Globe, Lock, Image as ImageIcon, DollarSign, Settings } from 'lucide-react';
|
||||
import { useForm, Controller, useFieldArray } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import AIPanel from './AIPanel';
|
||||
|
||||
interface Category {
|
||||
_id: string;
|
||||
分类名称: string;
|
||||
}
|
||||
|
||||
interface AssistantItem {
|
||||
_id?: string;
|
||||
id?: string;
|
||||
名称?: string;
|
||||
是否启用?: boolean;
|
||||
}
|
||||
|
||||
interface ArticleEditorProps {
|
||||
mode: 'create' | 'edit';
|
||||
articleId?: string;
|
||||
}
|
||||
|
||||
interface ArticleFormData {
|
||||
文章标题: string;
|
||||
URL别名: string;
|
||||
封面图: string;
|
||||
摘要: string;
|
||||
正文内容: string;
|
||||
SEO关键词: string;
|
||||
SEO描述: string;
|
||||
分类ID: string;
|
||||
标签ID列表: string[];
|
||||
价格: number | string;
|
||||
支付方式: string;
|
||||
资源属性: {
|
||||
下载链接: string;
|
||||
提取码: string;
|
||||
解压密码: string;
|
||||
隐藏内容: string;
|
||||
版本号: string;
|
||||
文件大小: string;
|
||||
扩展属性: { 属性名: string; 属性值: string }[];
|
||||
};
|
||||
发布状态: 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 [assistants, setAssistants] = useState<AssistantItem[]>([]);
|
||||
const [selectedAssistantId, setSelectedAssistantId] = useState<string>('');
|
||||
|
||||
const { register, handleSubmit, control, reset, setValue, watch, getValues } = useForm<ArticleFormData>({
|
||||
defaultValues: {
|
||||
文章标题: '',
|
||||
URL别名: '',
|
||||
封面图: '',
|
||||
摘要: '',
|
||||
正文内容: '',
|
||||
SEO关键词: '',
|
||||
SEO描述: '',
|
||||
分类ID: '',
|
||||
标签ID列表: [] as string[],
|
||||
价格: 0,
|
||||
支付方式: 'points',
|
||||
资源属性: {
|
||||
下载链接: '',
|
||||
提取码: '',
|
||||
解压密码: '',
|
||||
隐藏内容: '',
|
||||
版本号: '',
|
||||
文件大小: '',
|
||||
扩展属性: [] as { 属性名: string; 属性值: string }[]
|
||||
},
|
||||
发布状态: 'published'
|
||||
}
|
||||
});
|
||||
|
||||
useFieldArray({
|
||||
control,
|
||||
name: "资源属性.扩展属性"
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const fetchDependencies = useCallback(async () => {
|
||||
try {
|
||||
const [catRes, settingsRes] = await Promise.all([
|
||||
fetch('/api/admin/categories'),
|
||||
fetch('/api/admin/settings')
|
||||
]);
|
||||
|
||||
if (catRes.ok) setCategories(await catRes.json());
|
||||
|
||||
if (settingsRes.ok) {
|
||||
const settings = await settingsRes.json();
|
||||
const aiList = settings.AI配置列表?.filter((a: AssistantItem) => a.是否启用) || [];
|
||||
setAssistants(aiList);
|
||||
if (aiList.length > 0) {
|
||||
setSelectedAssistantId(aiList[0]._id || aiList[0].id);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch dependencies', error);
|
||||
toast.error('加载依赖数据失败');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchArticle = useCallback(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: Record<string, unknown>) => 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);
|
||||
}
|
||||
}, [articleId, reset]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDependencies();
|
||||
if (mode === 'edit' && articleId) {
|
||||
fetchArticle();
|
||||
}
|
||||
}, [mode, articleId, fetchDependencies, fetchArticle]);
|
||||
|
||||
const onSubmit = async (data: ArticleFormData) => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const [selectedContext, setSelectedContext] = useState('');
|
||||
|
||||
const handleEditorChange = (content: string) => {
|
||||
setValue('正文内容', content);
|
||||
setSelectedContext(content);
|
||||
};
|
||||
|
||||
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-screen flex flex-col bg-muted/40 dark:bg-muted/10">
|
||||
{/* Top Bar */}
|
||||
<div className="flex items-center justify-between px-6 py-3 border-b bg-background shadow-sm shrink-0 z-10">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()} className="hover:bg-accent rounded-full">
|
||||
<ArrowLeft className="h-5 w-5 text-muted-foreground" />
|
||||
</Button>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{mode === 'edit' ? '编辑文章' : '撰写新文章'}
|
||||
</span>
|
||||
{watch('发布状态') === 'published' && (
|
||||
<span className="flex items-center text-[10px] text-green-600 font-medium">
|
||||
<Globe className="w-3 h-3 mr-1" /> 已发布
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button onClick={handleSubmit(onSubmit)} disabled={saving} className="rounded-full px-6 shadow-sm hover:shadow-md transition-all bg-primary hover:bg-primary/90 text-primary-foreground">
|
||||
{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 Layout Area: 3 Columns */}
|
||||
<div className="flex-1 grid grid-cols-[1fr_300px_350px] overflow-hidden">
|
||||
|
||||
{/* 1. Content Editor (Left) */}
|
||||
<div className="flex flex-col min-w-0 overflow-hidden bg-background border-r border-border">
|
||||
<div className="flex-1 flex flex-col h-full">
|
||||
<div className="px-6 py-4 border-b border-border shrink-0">
|
||||
{/* 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-muted-foreground h-auto py-2 leading-tight"
|
||||
placeholder="请输入文章标题..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
{/* Markdown Editor */}
|
||||
<Controller
|
||||
name="正文内容"
|
||||
control={control}
|
||||
rules={{ required: true }}
|
||||
render={({ field }) => (
|
||||
<MarkdownEditor
|
||||
value={field.value}
|
||||
onChange={(val: string) => {
|
||||
field.onChange(val);
|
||||
handleEditorChange(val);
|
||||
}}
|
||||
className="h-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. AI Assistant (Middle) */}
|
||||
<div className="flex flex-col border-r border-border bg-background overflow-hidden">
|
||||
<div className="p-3 border-b border-border bg-muted/20">
|
||||
<div className="flex items-center gap-2 bg-background px-3 py-1.5 rounded-lg border border-border">
|
||||
<span className="text-xs font-medium text-muted-foreground shrink-0">模型:</span>
|
||||
<select
|
||||
className="bg-transparent text-xs font-medium text-foreground focus:outline-none cursor-pointer w-full"
|
||||
value={selectedAssistantId}
|
||||
onChange={(e) => setSelectedAssistantId(e.target.value)}
|
||||
>
|
||||
{assistants.length > 0 ? (
|
||||
assistants.map(a => (
|
||||
<option key={a._id || a.id} value={a._id || a.id}>{a.名称}</option>
|
||||
))
|
||||
) : (
|
||||
<option value="">无可用助手</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<AIPanel
|
||||
assistantId={selectedAssistantId}
|
||||
contextText={selectedContext}
|
||||
onStream={async (text: string) => {
|
||||
// Append raw markdown text to the editor content
|
||||
const currentContent = getValues('正文内容') || '';
|
||||
const newContent = currentContent + text;
|
||||
setValue('正文内容', newContent);
|
||||
// Update context for AI awareness (optional, might be too frequent)
|
||||
// setSelectedContext(newContent);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 3. Settings (Right) */}
|
||||
<div className="flex flex-col overflow-y-auto bg-muted/10">
|
||||
<div className="p-4 space-y-5">
|
||||
<div className="flex items-center gap-2 pb-2 border-b border-border">
|
||||
<Settings className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="font-semibold text-sm text-foreground">文章设置</span>
|
||||
</div>
|
||||
|
||||
{/* Publishing Status */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-medium text-muted-foreground">发布状态</Label>
|
||||
<div className="space-y-3">
|
||||
<Controller
|
||||
name="发布状态"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<SelectTrigger className="bg-background border-border h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="published">立即发布</SelectItem>
|
||||
<SelectItem value="draft">草稿</SelectItem>
|
||||
<SelectItem value="offline">下架</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-muted-foreground">URL 别名</Label>
|
||||
<Input {...register('URL别名')} className="h-8 text-xs bg-background border-border" placeholder="slug-url" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Classification */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-medium text-muted-foreground">分类与封面</Label>
|
||||
<div className="space-y-3">
|
||||
<Controller
|
||||
name="分类ID"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<SelectTrigger className="bg-background border-border h-8 text-xs">
|
||||
<SelectValue placeholder="选择分类" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map(cat => (
|
||||
<SelectItem key={cat._id} value={cat._id}>{cat.分类名称}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Input {...register('封面图')} className="h-8 text-xs bg-background border-border" placeholder="封面图 URL..." />
|
||||
<Button size="icon" variant="outline" className="h-8 w-8 shrink-0 bg-background border-border">
|
||||
<ImageIcon className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SEO */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-medium text-muted-foreground">SEO 优化</Label>
|
||||
<div className="space-y-3">
|
||||
<Textarea {...register('摘要')} className="text-xs min-h-[60px] bg-background border-border resize-none" placeholder="文章摘要..." />
|
||||
<Input {...register('SEO关键词')} className="h-8 text-xs bg-background border-border" placeholder="关键词 (逗号分隔)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resource Delivery */}
|
||||
<div className="space-y-3 pt-2 border-t border-border">
|
||||
<Label className="text-xs font-medium text-blue-600 dark:text-blue-400 flex items-center gap-1.5">
|
||||
<Lock className="w-3.5 h-3.5" /> 资源交付
|
||||
</Label>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input {...register('资源属性.版本号')} className="h-8 text-xs bg-background border-border" placeholder="版本号" />
|
||||
<Input {...register('资源属性.文件大小')} className="h-8 text-xs bg-background border-border" placeholder="大小" />
|
||||
</div>
|
||||
<Input {...register('资源属性.下载链接')} className="h-8 text-xs bg-background border-border" placeholder="下载链接" />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input {...register('资源属性.提取码')} className="h-8 text-xs bg-background border-border" placeholder="提取码" />
|
||||
<Input {...register('资源属性.解压密码')} className="h-8 text-xs bg-background border-border" placeholder="解压密码" />
|
||||
</div>
|
||||
<Textarea {...register('资源属性.隐藏内容')} className="text-xs bg-background min-h-[50px] border-border resize-none" placeholder="付费可见内容..." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sales Strategy */}
|
||||
<div className="space-y-3 pt-2 border-t border-border">
|
||||
<Label className="text-xs font-medium text-green-600 dark:text-green-400 flex items-center gap-1.5">
|
||||
<DollarSign className="w-3.5 h-3.5" /> 售卖策略
|
||||
</Label>
|
||||
<div className="space-y-3">
|
||||
<Controller
|
||||
name="支付方式"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<SelectTrigger className="h-8 text-xs bg-background border-border">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="points">积分支付</SelectItem>
|
||||
<SelectItem value="cash">现金支付 (CNY)</SelectItem>
|
||||
<SelectItem value="membership_free">会员免费</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs text-muted-foreground shrink-0">
|
||||
价格 ({watch('支付方式') === 'points' ? '积分' : '元'})
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
{...register('价格')}
|
||||
className="h-8 text-xs bg-background border-border"
|
||||
min="0"
|
||||
step={watch('支付方式') === 'cash' ? "0.01" : "1"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
src/components/aitools/AIToolsLayout.tsx
Normal file
38
src/components/aitools/AIToolsLayout.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
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="flex h-[calc(100vh-64px)] overflow-hidden bg-gray-50">
|
||||
{/* Left Sidebar - Fixed & Scrollable */}
|
||||
<div className="w-80 shrink-0 border-r border-gray-200 bg-white overflow-y-auto hidden lg:block">
|
||||
<div className="p-6">
|
||||
<ToolsSidebar />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Content - Scrollable */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="container mx-auto p-6 md:p-8 max-w-5xl">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
58
src/components/aitools/ToolsSidebar.tsx
Normal file
58
src/components/aitools/ToolsSidebar.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { AI_TOOLS } from '@/constants/aiTools';
|
||||
|
||||
const tools = AI_TOOLS;
|
||||
|
||||
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-foreground">工具列表</h2>
|
||||
<p className="text-xs text-muted-foreground">选择一个工具开始使用</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-card border-primary/50 shadow-md ring-1 ring-primary/20"
|
||||
: "bg-card border-border hover:border-border"
|
||||
)}
|
||||
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-foreground")}>
|
||||
{tool.name}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{tool.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isActive && (
|
||||
<ChevronRight className="w-4 h-4 text-primary shrink-0" />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
163
src/components/article/CommentSection.tsx
Normal file
163
src/components/article/CommentSection.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import React, { useState, useEffect, useCallback } 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);
|
||||
|
||||
|
||||
|
||||
const fetchComments = useCallback(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);
|
||||
}
|
||||
}, [articleId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchComments();
|
||||
}, [fetchComments]);
|
||||
|
||||
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 {
|
||||
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>
|
||||
);
|
||||
}
|
||||
260
src/components/dashboard/AIConfigSettings.tsx
Normal file
260
src/components/dashboard/AIConfigSettings.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Loader2, Plus, Trash2, Save, Bot, Key, Settings2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
interface Assistant {
|
||||
_id?: string;
|
||||
名称: string;
|
||||
功能类型: 'chat' | 'text' | 'image' | 'video';
|
||||
API_Endpoint?: string;
|
||||
API_Key?: string;
|
||||
Model?: string;
|
||||
系统提示词?: string;
|
||||
}
|
||||
|
||||
interface AIConfig {
|
||||
使用模式: 'system' | 'custom';
|
||||
自定义助手列表: Assistant[];
|
||||
当前助手ID?: string;
|
||||
}
|
||||
|
||||
export default function AIConfigSettings() {
|
||||
const { user, refreshUser } = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [config, setConfig] = useState<AIConfig>({
|
||||
使用模式: 'system',
|
||||
自定义助手列表: [],
|
||||
当前助手ID: ''
|
||||
});
|
||||
|
||||
// Load initial config from user
|
||||
useEffect(() => {
|
||||
if (user?.AI配置) {
|
||||
setConfig({
|
||||
使用模式: user.AI配置.使用模式 || 'system',
|
||||
自定义助手列表: user.AI配置.自定义助手列表 || [],
|
||||
当前助手ID: user.AI配置.当前助手ID || ''
|
||||
});
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/user/profile', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
aiConfig: config
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
toast.success('AI 配置已保存');
|
||||
refreshUser();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
toast.error(data.message || '保存失败');
|
||||
}
|
||||
} catch {
|
||||
toast.error('请求出错');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addAssistant = () => {
|
||||
setConfig(prev => ({
|
||||
...prev,
|
||||
自定义助手列表: [
|
||||
...prev.自定义助手列表,
|
||||
{
|
||||
名称: '新助手',
|
||||
功能类型: 'chat',
|
||||
API_Endpoint: 'https://api.openai.com/v1',
|
||||
Model: 'gpt-3.5-turbo',
|
||||
系统提示词: '你是一个有用的助手。'
|
||||
}
|
||||
]
|
||||
}));
|
||||
};
|
||||
|
||||
const removeAssistant = (index: number) => {
|
||||
setConfig(prev => {
|
||||
const newList = [...prev.自定义助手列表];
|
||||
newList.splice(index, 1);
|
||||
return { ...prev, 自定义助手列表: newList };
|
||||
});
|
||||
};
|
||||
|
||||
const updateAssistant = (index: number, field: keyof Assistant, value: string) => {
|
||||
setConfig(prev => {
|
||||
const newList = [...prev.自定义助手列表];
|
||||
newList[index] = { ...newList[index], [field]: value };
|
||||
return { ...prev, 自定义助手列表: newList };
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Settings2 className="w-5 h-5" />
|
||||
AI 模式设置
|
||||
</CardTitle>
|
||||
<CardDescription>选择您希望如何使用 AI 功能</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<RadioGroup
|
||||
value={config.使用模式}
|
||||
onValueChange={(val: 'system' | 'custom') => setConfig({ ...config, 使用模式: val })}
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-4"
|
||||
>
|
||||
<div className={`flex items-start space-x-3 space-y-0 rounded-md border p-4 ${config.使用模式 === 'system' ? 'border-primary bg-primary/5' : ''}`}>
|
||||
<RadioGroupItem value="system" id="mode-system" className="mt-1" />
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="mode-system" className="font-medium cursor-pointer">系统托管模式</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
使用平台提供的 AI 服务,无需配置 Key。
|
||||
<br />
|
||||
<span className="text-xs text-orange-600">需消耗积分或购买会员</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`flex items-start space-x-3 space-y-0 rounded-md border p-4 ${config.使用模式 === 'custom' ? 'border-primary bg-primary/5' : ''}`}>
|
||||
<RadioGroupItem value="custom" id="mode-custom" className="mt-1" />
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="mode-custom" className="font-medium cursor-pointer">自定义模式 (BYOK)</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
使用您自己的 API Key (OpenAI, Anthropic 等)。
|
||||
<br />
|
||||
<span className="text-xs text-green-600">免费使用,不消耗平台积分</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{config.使用模式 === 'custom' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bot className="w-5 h-5" />
|
||||
自定义助手列表
|
||||
</CardTitle>
|
||||
<CardDescription>配置您的私人 AI 助手</CardDescription>
|
||||
</div>
|
||||
<Button onClick={addAssistant} size="sm" variant="outline">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
添加助手
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{config.自定义助手列表.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground border-2 border-dashed rounded-lg">
|
||||
暂无自定义助手,请点击右上角添加
|
||||
</div>
|
||||
) : (
|
||||
config.自定义助手列表.map((assistant, index) => (
|
||||
<div key={index} className="border rounded-lg p-4 space-y-4 relative bg-muted/20">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeAssistant(index)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>助手名称</Label>
|
||||
<Input
|
||||
value={assistant.名称}
|
||||
onChange={(e) => updateAssistant(index, '名称', e.target.value)}
|
||||
placeholder="例如:我的翻译官"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>模型 (Model)</Label>
|
||||
<Input
|
||||
value={assistant.Model}
|
||||
onChange={(e) => updateAssistant(index, 'Model', e.target.value)}
|
||||
placeholder="gpt-3.5-turbo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>API Endpoint</Label>
|
||||
<Input
|
||||
value={assistant.API_Endpoint}
|
||||
onChange={(e) => updateAssistant(index, 'API_Endpoint', e.target.value)}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>API Key</Label>
|
||||
<div className="relative">
|
||||
<Key className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="password"
|
||||
value={assistant.API_Key || ''}
|
||||
onChange={(e) => updateAssistant(index, 'API_Key', e.target.value)}
|
||||
className="pl-9"
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>系统提示词 (System Prompt)</Label>
|
||||
<Textarea
|
||||
value={assistant.系统提示词}
|
||||
onChange={(e) => updateAssistant(index, '系统提示词', e.target.value)}
|
||||
placeholder="你是一个..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={config.当前助手ID === (assistant._id || index.toString())} // Temporary ID logic for new items
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) setConfig({ ...config, 当前助手ID: assistant._id || index.toString() });
|
||||
}}
|
||||
disabled={!assistant._id} // Only saved assistants can be selected ideally, but for UI flow we might need better handling
|
||||
/>
|
||||
<Label className="text-sm text-muted-foreground">设为默认助手 (需保存后生效)</Label>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSave} disabled={loading} className="w-full md:w-auto">
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
保存配置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
src/components/home/ArticleCard.tsx
Normal file
76
src/components/home/ArticleCard.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
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-card rounded-xl overflow-hidden border border-border hover:shadow-lg transition-all duration-300 h-full">
|
||||
{/* Image */}
|
||||
<Link href={`/article/${article._id}`} className="relative aspect-video overflow-hidden bg-muted">
|
||||
{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-muted-foreground">No Image</div>
|
||||
)}
|
||||
<div className="absolute top-3 left-3">
|
||||
<Badge variant="secondary" className="bg-card text-foreground font-medium shadow-sm border border-border">
|
||||
{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-muted-foreground 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-foreground line-clamp-2 group-hover:text-primary transition-colors">
|
||||
{article.文章标题}
|
||||
</h3>
|
||||
</Link>
|
||||
|
||||
<p className="text-muted-foreground 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-border">
|
||||
{isFree ? (
|
||||
<span className="text-xs font-bold text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-500/10 px-2 py-1 rounded">FREE</span>
|
||||
) : (
|
||||
<span className="text-xs font-bold text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-500/10 px-2 py-1 rounded">¥{article.价格}</span>
|
||||
)}
|
||||
|
||||
<Link href={`/article/${article._id}`} className="text-sm font-medium text-foreground flex items-center gap-1 group-hover:gap-2 transition-all">
|
||||
阅读全文 <ArrowRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
169
src/components/home/HeroBanner.tsx
Normal file
169
src/components/home/HeroBanner.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
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;
|
||||
}
|
||||
|
||||
const onInit = () => {
|
||||
setCount(api.scrollSnapList().length);
|
||||
setCurrent(api.selectedScrollSnap());
|
||||
};
|
||||
const onSelect = () => {
|
||||
setCurrent(api.selectedScrollSnap());
|
||||
};
|
||||
|
||||
api.on("init", onInit);
|
||||
api.on("select", onSelect);
|
||||
|
||||
// Bootstrap manually because effect can attach late
|
||||
onInit();
|
||||
|
||||
return () => {
|
||||
api.off("init", onInit);
|
||||
api.off("select", onSelect);
|
||||
};
|
||||
}, [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">
|
||||
<Image
|
||||
src={banner.图片地址}
|
||||
alt={banner.标题}
|
||||
fill
|
||||
className="object-cover transition-transform duration-700 hover:scale-105"
|
||||
priority={index === 0}
|
||||
/>
|
||||
<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-primary-foreground text-primary 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-card 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>
|
||||
);
|
||||
}
|
||||
55
src/components/home/Sidebar.tsx
Normal file
55
src/components/home/Sidebar.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface SidebarProps {
|
||||
tags: { _id: string; 标签名称: string }[];
|
||||
}
|
||||
|
||||
export default function Sidebar({ tags }: SidebarProps) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Hot Tags */}
|
||||
<div>
|
||||
<h3 className="font-bold text-foreground 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-secondary/80 cursor-pointer font-normal px-3 py-1">
|
||||
{tag.标签名称}
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recommended (Static for now) */}
|
||||
<div>
|
||||
<h3 className="font-bold text-foreground 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-foreground' :
|
||||
i === 2 ? 'text-muted-foreground' :
|
||||
'text-muted-foreground/40'
|
||||
}`}>0{i}</span>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-foreground group-hover:text-primary transition-colors line-clamp-2">
|
||||
2025年前端架构演进:从 Micro-Frontends 到 Islands Architecture
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground mt-1">12 min read</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
src/components/layouts/AdminLayout.tsx
Normal file
121
src/components/layouts/AdminLayout.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
FileText,
|
||||
Settings,
|
||||
LogOut,
|
||||
Menu,
|
||||
ShoppingBag
|
||||
} 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: ShoppingBag, label: '订单管理', href: '/admin/orders' },
|
||||
{ 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');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex">
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={cn(
|
||||
"bg-white border-r border-gray-200 fixed inset-y-0 left-0 z-50 w-64 transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0",
|
||||
!isSidebarOpen && "-translate-x-full lg:hidden"
|
||||
)}
|
||||
>
|
||||
<div className="h-16 flex items-center justify-center border-b border-gray-200">
|
||||
<h1 className="text-xl font-bold text-primary">AounApp Admin</h1>
|
||||
</div>
|
||||
<nav className="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>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="absolute bottom-0 w-full p-4 border-t border-gray-200">
|
||||
<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 */}
|
||||
<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">
|
||||
<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">
|
||||
<Image src="https://api.dicebear.com/7.x/avataaars/svg?seed=admin" alt="Avatar" width={32} height={32} unoptimized />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
318
src/components/layouts/MainLayout.tsx
Normal file
318
src/components/layouts/MainLayout.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import Head from 'next/head';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Search, 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";
|
||||
import { ModeToggle } from "@/components/mode-toggle";
|
||||
|
||||
interface SEOProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
keywords?: string;
|
||||
}
|
||||
|
||||
interface MainLayoutProps {
|
||||
children: React.ReactNode;
|
||||
transparentHeader?: boolean;
|
||||
seo?: SEOProps;
|
||||
showFooter?: boolean;
|
||||
}
|
||||
|
||||
function AuthButtons({ isTransparent }: { isTransparent?: boolean }) {
|
||||
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 ${isTransparent ? 'hover:bg-white/20' : ''}`}>
|
||||
<Avatar className={`h-10 w-10 border ${isTransparent ? 'border-white/20' : '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={isTransparent ? "text-white hover:bg-white/20 hover:text-white" : "text-foreground hover:bg-accent hover:text-accent-foreground"}>登录</Button>
|
||||
</Link>
|
||||
<Link href="/auth/register">
|
||||
<Button size="sm" className={isTransparent ? "bg-white text-black hover:bg-white/90 shadow-sm" : "bg-primary text-primary-foreground hover:bg-primary/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-background">
|
||||
<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-background/80 backdrop-blur-md border-b border-border 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">
|
||||
<Image src={config?.Logo地址 || "/LOGO.png"} alt={config?.网站标题 || "AOUN"} width={80} height={32} unoptimized className="h-8 w-auto" />
|
||||
<span className={`font-bold text-xl tracking-tight ${isTransparent ? 'text-white' : 'text-foreground'}`}>
|
||||
{config?.网站标题 || 'AOUN'}
|
||||
</span>
|
||||
</Link>
|
||||
<nav className={`hidden md:flex items-center gap-8 text-sm font-medium ${isTransparent ? 'text-white/90' : 'text-muted-foreground'}`}>
|
||||
{[
|
||||
//{ 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-muted-foreground'}`} />
|
||||
<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-muted/50 border-border text-foreground focus:ring-primary/20'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ModeToggle className={isTransparent ? 'text-white hover:bg-white/20 hover:text-white' : 'text-foreground hover:bg-accent hover:text-accent-foreground'} />
|
||||
|
||||
<AuthButtons isTransparent={isTransparent} />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className={`flex-1 ${!transparentHeader ? 'pt-16' : ''}`}>
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
{showFooter && (
|
||||
<footer className="bg-background border-t border-border 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-muted/50 to-transparent border border-border flex flex-col">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-background p-0.5 shadow-sm border border-border overflow-hidden">
|
||||
<Image src="https://api.dicebear.com/7.x/avataaars/svg?seed=admin" alt="Admin" width={48} height={48} unoptimized className="w-full h-full object-cover rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg text-foreground 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-muted-foreground 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-background hover:bg-muted border-border">Bilibili</Button>
|
||||
<Button variant="outline" size="sm" className="h-8 text-xs flex-1 bg-background hover:bg-muted border-border">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-500/10 to-transparent border border-blue-500/20 flex flex-col">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-background flex items-center justify-center shadow-sm border border-blue-500/20">
|
||||
<Image src="/Gemini.svg" alt="Gemini" width={28} height={28} unoptimized className="w-7 h-7" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-lg text-foreground leading-tight">
|
||||
Gemini
|
||||
</h3>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Powered by</span>
|
||||
<Image src="/Google.svg" alt="Google" width={60} height={14} unoptimized className="h-3.5 opacity-60 w-auto dark:invert dark:opacity-80" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground 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 dark:text-blue-400 bg-blue-500/10 px-3 py-2 rounded-lg border border-blue-500/20 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-muted/50 to-transparent border border-border flex flex-col">
|
||||
<div className="grid grid-cols-2 gap-4 h-full">
|
||||
<div className="flex flex-col">
|
||||
<h4 className="font-bold text-foreground 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-muted-foreground hover:text-primary hover:bg-background 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-muted-foreground hover:text-primary hover:bg-background 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-muted-foreground hover:text-primary hover:bg-background 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-foreground 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-muted-foreground hover:text-primary hover:bg-background 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-muted-foreground hover:text-primary hover:bg-background 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-muted-foreground hover:text-primary hover:bg-background 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-muted/50 to-transparent border border-border flex flex-col">
|
||||
<h4 className="font-bold text-lg text-foreground mb-2">订阅周刊</h4>
|
||||
<p className="text-xs text-muted-foreground 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-border bg-background 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-primary-foreground shadow-sm shadow-primary/20">
|
||||
立即订阅
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-border mt-12 pt-8 flex flex-col md:flex-row items-center justify-between text-sm text-muted-foreground">
|
||||
<p>{config?.底部版权信息 || `© ${new Date().getFullYear()} ${config?.网站标题 || 'AounApp'}. All rights reserved.`}</p>
|
||||
<p>{config?.备案号}</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)}
|
||||
<Toaster />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
src/components/markdown/MarkdownEditor.tsx
Normal file
37
src/components/markdown/MarkdownEditor.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import dynamic from 'next/dynamic';
|
||||
import { cn } from '@/lib/utils';
|
||||
import '@uiw/react-md-editor/markdown-editor.css';
|
||||
import '@uiw/react-markdown-preview/markdown.css';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
const MDEditor = dynamic(
|
||||
() => import("@uiw/react-md-editor"),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
interface MarkdownEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function MarkdownEditor({ value, onChange, className, placeholder }: MarkdownEditorProps) {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", className)} data-color-mode={theme === 'dark' ? 'dark' : 'light'}>
|
||||
<MDEditor
|
||||
value={value}
|
||||
onChange={(val) => onChange(val || '')}
|
||||
height="100%"
|
||||
preview="live"
|
||||
visibleDragbar={false}
|
||||
className="bg-background! border-border! text-foreground!"
|
||||
textareaProps={{
|
||||
placeholder: placeholder || "在此输入 Markdown 内容..."
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
src/components/mode-toggle.tsx
Normal file
23
src/components/mode-toggle.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client"
|
||||
|
||||
import { Moon, Sun } from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
export function ModeToggle({ className }: { className?: string }) {
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={className}
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
>
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
11
src/components/theme-provider.tsx
Normal file
11
src/components/theme-provider.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||
}
|
||||
48
src/components/ui/avatar.tsx
Normal file
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
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 }
|
||||
115
src/components/ui/breadcrumb.tsx
Normal file
115
src/components/ui/breadcrumb.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import * as React from "react"
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Breadcrumb = React.forwardRef<
|
||||
HTMLElement,
|
||||
React.ComponentPropsWithoutRef<"nav"> & {
|
||||
separator?: React.ReactNode
|
||||
}
|
||||
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
|
||||
Breadcrumb.displayName = "Breadcrumb"
|
||||
|
||||
const BreadcrumbList = React.forwardRef<
|
||||
HTMLOListElement,
|
||||
React.ComponentPropsWithoutRef<"ol">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ol
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-1.5 wrap-break-word text-sm text-muted-foreground sm:gap-2.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
BreadcrumbList.displayName = "BreadcrumbList"
|
||||
|
||||
const BreadcrumbItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentPropsWithoutRef<"li">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li
|
||||
ref={ref}
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
BreadcrumbItem.displayName = "BreadcrumbItem"
|
||||
|
||||
const BreadcrumbLink = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentPropsWithoutRef<"a"> & {
|
||||
asChild?: boolean
|
||||
}
|
||||
>(({ asChild, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn("transition-colors hover:text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
BreadcrumbLink.displayName = "BreadcrumbLink"
|
||||
|
||||
const BreadcrumbPage = React.forwardRef<
|
||||
HTMLSpanElement,
|
||||
React.ComponentPropsWithoutRef<"span">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("font-normal text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
BreadcrumbPage.displayName = "BreadcrumbPage"
|
||||
|
||||
const BreadcrumbSeparator = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) => (
|
||||
<li
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
)
|
||||
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
|
||||
|
||||
const BreadcrumbEllipsis = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) => (
|
||||
<span
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
||||
57
src/components/ui/button.tsx
Normal file
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
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
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,
|
||||
}
|
||||
30
src/components/ui/checkbox.tsx
Normal file
30
src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("flex items-center justify-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
120
src/components/ui/dialog.tsx
Normal file
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
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
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
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
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
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 }
|
||||
42
src/components/ui/radio-group.tsx
Normal file
42
src/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
)
|
||||
})
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
18
src/components/ui/scroll-area.tsx
Normal file
18
src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("relative overflow-auto", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
))
|
||||
ScrollArea.displayName = "ScrollArea"
|
||||
|
||||
export { ScrollArea }
|
||||
157
src/components/ui/select.tsx
Normal file
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
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
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
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
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
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 }
|
||||
23
src/components/ui/textarea.tsx
Normal file
23
src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type TextareaProps = 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 }
|
||||
218
src/components/ui/uploader.tsx
Normal file
218
src/components/ui/uploader.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { UploadCloud, X, CheckCircle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface UploaderProps {
|
||||
type?: 'public' | 'private';
|
||||
accept?: string;
|
||||
maxSize?: number; // in MB
|
||||
variant?: 'default' | 'compact';
|
||||
onUploadSuccess: (url: string, originalName: string) => void;
|
||||
onUploadError?: (error: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Uploader({
|
||||
type = 'public',
|
||||
accept,
|
||||
maxSize = 100, // Default 100MB
|
||||
variant = 'default',
|
||||
onUploadSuccess,
|
||||
onUploadError,
|
||||
className
|
||||
}: UploaderProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [uploadedFile, setUploadedFile] = useState<{ name: string; url: string } | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = async (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files && files.length > 0) {
|
||||
await handleFileUpload(files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
await handleFileUpload(files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = async (file: globalThis.File) => {
|
||||
// Validate Max Size
|
||||
if (file.size > maxSize * 1024 * 1024) {
|
||||
const errorMsg = `文件大小超过限制 (${maxSize}MB)`;
|
||||
toast.error(errorMsg);
|
||||
onUploadError?.(errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
setProgress(0);
|
||||
setUploadedFile(null);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('type', type);
|
||||
|
||||
try {
|
||||
// Use XMLHttpRequest to track upload progress
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.upload.addEventListener('progress', (event) => {
|
||||
if (event.lengthComputable) {
|
||||
const percentCompleted = Math.round((event.loaded * 100) / event.total);
|
||||
setProgress(percentCompleted);
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
setIsUploading(false);
|
||||
if (xhr.status === 200) {
|
||||
const response = JSON.parse(xhr.responseText);
|
||||
toast.success('上传成功');
|
||||
setUploadedFile({ name: response.originalName, url: response.url });
|
||||
onUploadSuccess(response.url, response.originalName);
|
||||
} else {
|
||||
const errorResponse = JSON.parse(xhr.responseText);
|
||||
const errorMsg = errorResponse.message || '上传失败';
|
||||
toast.error(errorMsg);
|
||||
onUploadError?.(errorMsg);
|
||||
setProgress(0);
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
setIsUploading(false);
|
||||
setProgress(0);
|
||||
toast.error('网络错误导致上传失败');
|
||||
onUploadError?.('网络错误导致上传失败');
|
||||
});
|
||||
|
||||
xhr.open('POST', '/api/upload', true);
|
||||
xhr.send(formData);
|
||||
} catch (error: unknown) {
|
||||
console.error('Upload Error:', error);
|
||||
setIsUploading(false);
|
||||
setProgress(0);
|
||||
toast.error('上传过程发生异常');
|
||||
onUploadError?.(error instanceof Error ? error.message : 'Unknown error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('relative w-full', className)}>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileSelect}
|
||||
accept={accept}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{!uploadedFile && !isUploading && (
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center w-full border-2 border-dashed rounded-lg cursor-pointer transition-colors backdrop-blur-sm',
|
||||
variant === 'compact' ? 'h-24 px-4 py-4' : 'h-40 px-4 py-8',
|
||||
isDragging
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-muted-foreground/25 hover:border-primary/50 hover:bg-accent/50'
|
||||
)}
|
||||
>
|
||||
<UploadCloud className={cn(
|
||||
'mb-2 text-muted-foreground transition-colors',
|
||||
variant === 'compact' ? 'w-6 h-6' : 'w-10 h-10 mb-3',
|
||||
isDragging && 'text-primary'
|
||||
)} />
|
||||
<p className={cn(
|
||||
"font-medium text-foreground",
|
||||
variant === 'compact' ? 'text-xs mb-0.5' : 'text-sm mb-1',
|
||||
)}>
|
||||
<span className="font-semibold text-primary">点击选择文件</span> 或拖拽到此处
|
||||
</p>
|
||||
{variant !== 'compact' && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
支持的文件最大为 {maxSize}MB ({type === 'private' ? '私密资源' : '公开资源'})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isUploading && (
|
||||
<div className={cn(
|
||||
"flex flex-col items-center justify-center w-full border rounded-lg bg-card/50",
|
||||
variant === 'compact' ? 'h-24 p-4' : 'h-40 p-6'
|
||||
)}>
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
<div className="w-6 h-6 rounded-full border-b-2 border-primary animate-spin" />
|
||||
<span className={cn(
|
||||
"font-medium",
|
||||
variant === 'compact' ? 'text-xs' : 'text-sm'
|
||||
)}>正在上传... {progress}%</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300 ease-out"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadedFile && !isUploading && (
|
||||
<div className={cn(
|
||||
"flex items-center justify-between w-full border rounded-lg bg-green-500/10 border-green-500/20",
|
||||
variant === 'compact' ? 'p-2' : 'p-4'
|
||||
)}>
|
||||
<div className="flex items-center space-x-2 overflow-hidden">
|
||||
<CheckCircle className={cn(
|
||||
"text-green-500 shrink-0",
|
||||
variant === 'compact' ? 'w-4 h-4' : 'w-6 h-6'
|
||||
)} />
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
<span className={cn(
|
||||
"font-medium text-foreground truncate",
|
||||
variant === 'compact' ? 'text-xs' : 'text-sm'
|
||||
)}>{uploadedFile.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setUploadedFile(null);
|
||||
}}
|
||||
className="p-1 rounded-full hover:bg-background/80 transition-colors text-muted-foreground hover:text-foreground shrink-0 ml-2"
|
||||
type="button"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
src/constants/aiTools.ts
Normal file
46
src/constants/aiTools.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Sparkles, Languages, Wand2, MessageSquare } from 'lucide-react';
|
||||
|
||||
export const AI_TOOLS = [
|
||||
{
|
||||
id: 'chat',
|
||||
name: 'AI 智能对话',
|
||||
description: '多模型智能问答助手,支持 GPT-4、Claude 等多种模型,提供流式响应。',
|
||||
icon: MessageSquare,
|
||||
href: '/aitools/chat',
|
||||
color: 'text-green-600',
|
||||
bgColor: 'bg-green-100',
|
||||
badge: '热门',
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
id: 'prompt-optimizer',
|
||||
name: '提示词优化师',
|
||||
description: '智能优化您的 Prompt 指令,让 AI 更懂你的意图,提升生成质量。',
|
||||
icon: Sparkles,
|
||||
href: '/aitools/prompt-optimizer',
|
||||
color: 'text-purple-600',
|
||||
bgColor: 'bg-purple-100',
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
id: 'translator',
|
||||
name: '智能翻译助手',
|
||||
description: '基于 AI 的多语言精准互译工具,支持上下文理解和专业术语优化。',
|
||||
icon: Languages,
|
||||
href: '/aitools/translator',
|
||||
color: 'text-blue-600',
|
||||
bgColor: 'bg-blue-100',
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
id: 'more',
|
||||
name: '更多工具',
|
||||
description: '更多实用 AI 工具正在开发中,敬请期待...',
|
||||
icon: Wand2,
|
||||
href: '#',
|
||||
color: 'text-gray-400',
|
||||
bgColor: 'bg-gray-100',
|
||||
disabled: true,
|
||||
status: 'coming_soon'
|
||||
}
|
||||
];
|
||||
58
src/contexts/ConfigContext.tsx
Normal file
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;
|
||||
}
|
||||
13
src/hooks/use-composed-ref.ts
Normal file
13
src/hooks/use-composed-ref.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
export function useComposedRef<T>(...refs: (React.Ref<T> | undefined)[]) {
|
||||
return useCallback((value: T | null) => {
|
||||
refs.forEach((ref) => {
|
||||
if (typeof ref === "function") {
|
||||
ref(value);
|
||||
} else if (ref != null) {
|
||||
(ref as React.MutableRefObject<T | null>).current = value;
|
||||
}
|
||||
});
|
||||
}, [refs]);
|
||||
}
|
||||
14
src/hooks/use-is-breakpoint.ts
Normal file
14
src/hooks/use-is-breakpoint.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export function useIsBreakpoint() {
|
||||
const [isBreakpoint, setIsBreakpoint] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => setIsBreakpoint(window.innerWidth < 768);
|
||||
handleResize();
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
return isBreakpoint;
|
||||
}
|
||||
14
src/hooks/use-window-size.ts
Normal file
14
src/hooks/use-window-size.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export function useWindowSize() {
|
||||
const [size, setSize] = useState({ width: 0, height: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => setSize({ width: window.innerWidth, height: window.innerHeight });
|
||||
handleResize();
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
return size;
|
||||
}
|
||||
96
src/hooks/useAuth.tsx
Normal file
96
src/hooks/useAuth.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
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;
|
||||
};
|
||||
AI配置?: {
|
||||
使用模式: 'system' | 'custom';
|
||||
自定义助手列表: {
|
||||
名称: string;
|
||||
功能类型: 'chat' | 'text' | 'image' | 'video';
|
||||
API_Endpoint?: string;
|
||||
API_Key?: string;
|
||||
Model?: string;
|
||||
系统提示词?: string;
|
||||
}[];
|
||||
当前助手ID?: string;
|
||||
系统用量?: {
|
||||
今日调用次数: number;
|
||||
剩余免费额度: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
141
src/lib/alipay.ts
Normal file
141
src/lib/alipay.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
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 rest = { ...params };
|
||||
const sign = rest.sign;
|
||||
delete rest.sign;
|
||||
delete rest.sign_type;
|
||||
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
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: unknown) {
|
||||
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 as Error).message || 'Internal Server Error' });
|
||||
}
|
||||
};
|
||||
}
|
||||
54
src/lib/auth.ts
Normal file
54
src/lib/auth.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET;
|
||||
|
||||
export interface DecodedToken {
|
||||
userId: string;
|
||||
email: string;
|
||||
role: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
export function verifyToken(token: string): DecodedToken | null {
|
||||
if (!JWT_SECRET) {
|
||||
console.error('JWT_SECRET is not defined');
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return jwt.verify(token, JWT_SECRET) as unknown as DecodedToken;
|
||||
} catch {
|
||||
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
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;
|
||||
16
src/lib/markdown.ts
Normal file
16
src/lib/markdown.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
|
||||
import { unified } from 'unified';
|
||||
import remarkParse from 'remark-parse';
|
||||
import remarkRehype from 'remark-rehype';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import rehypeStringify from 'rehype-stringify';
|
||||
|
||||
export async function markdownToHtml(markdown: string): Promise<string> {
|
||||
const file = await unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkRehype, { allowDangerousHtml: true })
|
||||
.use(rehypeRaw)
|
||||
.use(rehypeStringify)
|
||||
.process(markdown);
|
||||
return String(file);
|
||||
}
|
||||
51
src/lib/orderCron.ts
Normal file
51
src/lib/orderCron.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { CronJob } from 'cron';
|
||||
import dbConnect from './dbConnect';
|
||||
import { Order } from '@/models';
|
||||
|
||||
let cronJobStarted = false;
|
||||
|
||||
/**
|
||||
* Start background cron job to expire pending orders
|
||||
* This runs every 10 minutes
|
||||
*/
|
||||
export function startOrderExpirationCron() {
|
||||
if (cronJobStarted) {
|
||||
console.log('Order expiration cron job already running');
|
||||
return;
|
||||
}
|
||||
|
||||
// Run every 10 minutes
|
||||
const job = new CronJob('*/10 * * * *', async () => {
|
||||
try {
|
||||
await dbConnect();
|
||||
|
||||
const fifteenMinutesAgo = new Date(Date.now() - 15 * 60 * 1000);
|
||||
|
||||
const result = await Order.updateMany(
|
||||
{
|
||||
订单状态: 'pending',
|
||||
createdAt: { $lt: fifteenMinutesAgo }
|
||||
},
|
||||
{
|
||||
$set: { 订单状态: 'expired' }
|
||||
}
|
||||
);
|
||||
|
||||
if (result.modifiedCount > 0) {
|
||||
console.log(`[Cron] Expired ${result.modifiedCount} orders`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Cron] Error expiring orders:', error);
|
||||
}
|
||||
});
|
||||
|
||||
job.start();
|
||||
|
||||
cronJobStarted = true;
|
||||
console.log('Order expiration cron job started (every 10 minutes)');
|
||||
}
|
||||
|
||||
// Auto-start when module is loaded (only on server side)
|
||||
if (typeof window === 'undefined') {
|
||||
startOrderExpirationCron();
|
||||
}
|
||||
6
src/lib/utils.ts
Normal file
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
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;
|
||||
329
src/models/index.ts
Normal file
329
src/models/index.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
import mongoose, { Schema } 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 }
|
||||
},
|
||||
|
||||
// 【新增】AI 偏好与配置 (支持混合模式)
|
||||
AI配置: {
|
||||
// 模式选择:'system' (使用系统Key,扣积分) | 'custom' (使用自己的Key,免费)
|
||||
使用模式: { type: String, enum: ['system', 'custom'], default: 'system' },
|
||||
|
||||
// 【升级】用户自定义助手列表 (BYOK模式,支持多助手/Agent)
|
||||
自定义助手列表: [{
|
||||
名称: { type: String, required: true }, // AI_Name (e.g. "我的私人翻译官")
|
||||
功能类型: { type: String, enum: ['chat', 'text', 'image', 'video'], default: 'chat' }, // Type
|
||||
|
||||
// BYOK 配置
|
||||
API_Endpoint: { type: String },
|
||||
API_Key: { type: String, select: false },
|
||||
Model: { type: String },
|
||||
系统提示词: { type: String } // System_Prompt
|
||||
}],
|
||||
|
||||
// 选中的助手ID (可选,方便前端记住用户上次用的助手)
|
||||
当前助手ID: { type: Schema.Types.ObjectId },
|
||||
|
||||
// 系统 Key 模式下的用量统计 (用于每日限额/防刷)
|
||||
系统用量: {
|
||||
今日调用次数: { type: Number, default: 0 },
|
||||
上次调用时间: { type: Date }, // 用于跨天重置
|
||||
剩余免费额度: { type: Number, default: 5 } // 比如每日赠送5次
|
||||
}
|
||||
},
|
||||
|
||||
// 账号风控
|
||||
是否被封禁: { type: Boolean, default: false },
|
||||
|
||||
}, { timestamps: true });
|
||||
|
||||
|
||||
/**
|
||||
* ==================================================================
|
||||
* 2. Article (文章/资源模型)
|
||||
* ==================================================================
|
||||
* 核心更新:
|
||||
* - 支付方式增加了 'membership_free' (会员免费)。
|
||||
* - 资源属性增加了 版本、大小、扩展属性。
|
||||
* - 统计数据增加了 销量、最近售出时间。
|
||||
*/
|
||||
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 },
|
||||
// 【升级】新增 membership_free:会员免费,普通用户需购买
|
||||
支付方式: { type: String, enum: ['points', 'cash', 'membership_free'], default: 'points' },
|
||||
|
||||
// --- 资源交付 (付费后可见) ---
|
||||
资源属性: {
|
||||
下载链接: { type: String },
|
||||
提取码: { type: String },
|
||||
解压密码: { type: String },
|
||||
|
||||
// 【升级】标准资源字段
|
||||
版本号: { type: String }, // e.g., "v2.0.1"
|
||||
文件大小: { type: String }, // e.g., "1.5GB"
|
||||
|
||||
// 【升级】扩展属性:支持自定义键值对,如 [{ 属性名: "运行环境", 属性值: "Win11" }]
|
||||
扩展属性: [{
|
||||
属性名: { 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: Number, default: 0 },
|
||||
最近售出时间: { type: Date }
|
||||
},
|
||||
|
||||
// --- 状态控制 ---
|
||||
发布状态: { type: String, enum: ['draft', 'published', 'offline'], default: 'published' }
|
||||
}, { timestamps: true });
|
||||
|
||||
// 复合索引优化
|
||||
ArticleSchema.index({ createdAt: -1 });
|
||||
ArticleSchema.index({ 分类ID: 1, 发布状态: 1 });
|
||||
ArticleSchema.index({ '统计数据.销量': -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 }, //助手的类型,例如文本生成/图片生成/语音合成/多模态等
|
||||
名称: { 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 },
|
||||
//定价,按token计算
|
||||
token价格: { type: Number, default: 0 },
|
||||
}],
|
||||
|
||||
}, { 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
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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,23 @@
|
||||
import "@/styles/globals.css";
|
||||
import type { AppProps } from "next/app";
|
||||
import '@/styles/globals.css';
|
||||
|
||||
import type { AppProps } from 'next/app';
|
||||
import { AuthProvider } from '@/hooks/useAuth';
|
||||
import { ConfigProvider } from '@/contexts/ConfigContext';
|
||||
|
||||
import { ThemeProvider } from "@/components/theme-provider"
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps) {
|
||||
return <Component {...pageProps} />;
|
||||
return (
|
||||
<AuthProvider>
|
||||
<ConfigProvider>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
>
|
||||
<Component {...pageProps} />
|
||||
</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Html, Head, Main, NextScript } from "next/document";
|
||||
|
||||
export default function Document() {
|
||||
return (
|
||||
<Html lang="en">
|
||||
<Html lang="zh">
|
||||
<Head />
|
||||
<body className="antialiased">
|
||||
<Main />
|
||||
|
||||
314
src/pages/admin/ai/index.tsx
Normal file
314
src/pages/admin/ai/index.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
import { 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 } from "@/components/ui/dialog";
|
||||
import { Loader2, Plus, Bot, Trash2, Edit, 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 [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
|
||||
const { register, control, handleSubmit, reset } = 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 {
|
||||
const newAssistants = [...assistants];
|
||||
|
||||
if (editingIndex !== null) {
|
||||
// Update existing by index
|
||||
const originalItem = newAssistants[editingIndex];
|
||||
newAssistants[editingIndex] = {
|
||||
...data,
|
||||
_id: originalItem._id // Preserve original ID if it exists
|
||||
};
|
||||
} 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.
|
||||
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(editingIndex !== null ? 'AI 助手已更新' : 'AI 助手已创建');
|
||||
setIsDialogOpen(false);
|
||||
setEditingAssistant(null);
|
||||
setEditingIndex(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);
|
||||
setEditingIndex(null);
|
||||
reset({
|
||||
名称: 'New Assistant',
|
||||
接口地址: 'https://generativelanguage.googleapis.com/v1beta/openai',
|
||||
API密钥: '',
|
||||
模型: 'gemini-1.5-flash',
|
||||
系统提示词: '你是一个智能助手。',
|
||||
流式传输: true,
|
||||
是否启用: true
|
||||
});
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEditDialog = (assistant: AIConfig, index: number) => {
|
||||
setEditingAssistant(assistant);
|
||||
setEditingIndex(index);
|
||||
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, index)}>
|
||||
<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
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" />;
|
||||
}
|
||||
13
src/pages/admin/articles/edit/[id].tsx
Normal file
13
src/pages/admin/articles/edit/[id].tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import ArticleEditor from '@/components/admin/ArticleEditor';
|
||||
|
||||
export default function EditArticlePage() {
|
||||
const router = useRouter();
|
||||
const { id } = router.query;
|
||||
|
||||
if (!id) return null;
|
||||
|
||||
return (
|
||||
<ArticleEditor mode="edit" articleId={id as string} />
|
||||
);
|
||||
}
|
||||
236
src/pages/admin/articles/index.tsx
Normal file
236
src/pages/admin/articles/index.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
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, 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 [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);
|
||||
|
||||
const fetchArticles = useCallback(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);
|
||||
}
|
||||
}, [page, searchTerm]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchArticles();
|
||||
}, [fetchArticles]);
|
||||
|
||||
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-card">
|
||||
<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-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
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>
|
||||
);
|
||||
}
|
||||
248
src/pages/admin/banners/index.tsx
Normal file
248
src/pages/admin/banners/index.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import Image from 'next/image';
|
||||
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, 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';
|
||||
import { Uploader } from '@/components/ui/uploader';
|
||||
|
||||
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, setValue, watch } = useForm<BannerConfig>({
|
||||
defaultValues: {
|
||||
Banner配置: []
|
||||
}
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: "Banner配置"
|
||||
});
|
||||
|
||||
const fetchSettings = useCallback(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);
|
||||
}
|
||||
}, [reset]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, [fetchSettings]);
|
||||
|
||||
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 flex-col gap-3">
|
||||
<div className="flex gap-4 items-start">
|
||||
<Input {...register(`Banner配置.${index}.图片地址`)} placeholder="https://..." className="flex-1" />
|
||||
<div className="w-32 h-16 bg-muted rounded overflow-hidden shrink-0 border relative">
|
||||
{watch(`Banner配置.${index}.图片地址`) && (
|
||||
<Image
|
||||
src={watch(`Banner配置.${index}.图片地址`)}
|
||||
alt="Preview"
|
||||
fill
|
||||
unoptimized
|
||||
className="object-cover"
|
||||
onError={(e) => (e.currentTarget.style.display = 'none')}
|
||||
onLoad={(e) => (e.currentTarget.style.display = 'block')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full pb-2">
|
||||
<Uploader
|
||||
type="public"
|
||||
accept="image/*"
|
||||
maxSize={10}
|
||||
variant="compact"
|
||||
onUploadSuccess={(url) => {
|
||||
setValue(`Banner配置.${index}.图片地址`, url, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
223
src/pages/admin/index.tsx
Normal file
223
src/pages/admin/index.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import { GetServerSideProps } from 'next';
|
||||
|
||||
import mongoose from 'mongoose';
|
||||
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';
|
||||
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer } from 'recharts';
|
||||
|
||||
// 定义统计数据接口
|
||||
interface DashboardStats {
|
||||
totalUsers: number;
|
||||
totalArticles: number;
|
||||
totalOrders: number;
|
||||
totalRevenue: number;
|
||||
}
|
||||
|
||||
export default function AdminDashboard({ stats }: { stats: DashboardStats }) {
|
||||
const data = [
|
||||
{ name: 'Jan', total: 1000 },
|
||||
{ name: 'Feb', total: 1500 },
|
||||
{ name: 'Mar', total: 2000 },
|
||||
{ name: 'Apr', total: 2500 },
|
||||
{ name: 'May', total: 3000 },
|
||||
{ name: 'Jun', total: 3500 },
|
||||
{ name: 'Jul', total: 4000 },
|
||||
{ name: 'Aug', total: 4500 },
|
||||
{ name: 'Sep', total: 5000 },
|
||||
{ name: 'Oct', total: 5500 },
|
||||
{ name: 'Nov', total: 6000 },
|
||||
{ name: 'Dec', total: 6500 },
|
||||
];
|
||||
|
||||
return (
|
||||
<AdminLayout>
|
||||
<div className="space-y-6">
|
||||
{/* ... (existing header and stats cards) ... */}
|
||||
<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">
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<BarChart data={data}>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `¥${value}`}
|
||||
/>
|
||||
<Bar dataKey="total" fill="#000000" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</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 中直接操作数据库需要确保连接。
|
||||
if (mongoose.connection.readyState === 0) {
|
||||
await mongoose.connect(process.env.MONGODB_URI as string);
|
||||
}
|
||||
|
||||
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: { 支付金额?: number }) => acc + (order.支付金额 || 0), 0);
|
||||
|
||||
return {
|
||||
props: {
|
||||
stats: {
|
||||
totalUsers,
|
||||
totalArticles,
|
||||
totalOrders,
|
||||
totalRevenue
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
253
src/pages/admin/orders/index.tsx
Normal file
253
src/pages/admin/orders/index.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import Image from 'next/image';
|
||||
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';
|
||||
|
||||
interface Order {
|
||||
_id: string;
|
||||
订单号: string;
|
||||
用户ID: {
|
||||
用户名: string;
|
||||
邮箱: string;
|
||||
头像?: string;
|
||||
} | null;
|
||||
订单类型: string;
|
||||
商品快照?: {
|
||||
标题: string;
|
||||
};
|
||||
支付金额: number;
|
||||
订单状态: string;
|
||||
支付方式?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function OrderList() {
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pagination, setPagination] = useState({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
total: 0,
|
||||
pages: 0
|
||||
});
|
||||
const [filters, setFilters] = useState({
|
||||
status: 'all',
|
||||
search: ''
|
||||
});
|
||||
|
||||
const fetchOrders = useCallback(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);
|
||||
}
|
||||
}, [pagination.page, pagination.limit, filters.status, filters.search]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrders();
|
||||
}, [fetchOrders]);
|
||||
|
||||
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 relative">
|
||||
<Image
|
||||
src={order.用户ID?.头像 || '/images/default_avatar.png'}
|
||||
alt="Avatar"
|
||||
fill
|
||||
unoptimized
|
||||
className="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>
|
||||
);
|
||||
}
|
||||
179
src/pages/admin/plans/edit/[id].tsx
Normal file
179
src/pages/admin/plans/edit/[id].tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { useEffect, useState, useCallback } 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';
|
||||
|
||||
interface PlanFormData {
|
||||
套餐名称: string;
|
||||
有效天数: number;
|
||||
价格: number;
|
||||
描述: string;
|
||||
特权配置: {
|
||||
每日下载限制: number;
|
||||
购买折扣: number;
|
||||
};
|
||||
是否上架: boolean;
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
});
|
||||
|
||||
const fetchPlan = useCallback(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);
|
||||
}
|
||||
}, [id, reset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditMode) {
|
||||
fetchPlan();
|
||||
}
|
||||
}, [isEditMode, fetchPlan]);
|
||||
|
||||
const onSubmit = async (data: PlanFormData) => {
|
||||
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>
|
||||
);
|
||||
}
|
||||
137
src/pages/admin/plans/index.tsx
Normal file
137
src/pages/admin/plans/index.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { 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 interface Plan {
|
||||
_id: string;
|
||||
套餐名称: string;
|
||||
价格: number;
|
||||
有效天数: number;
|
||||
特权配置?: {
|
||||
每日下载限制?: number;
|
||||
购买折扣?: number;
|
||||
};
|
||||
是否上架: boolean;
|
||||
}
|
||||
|
||||
export default function PlansIndex() {
|
||||
const [plans, setPlans] = useState<Plan[]>([]);
|
||||
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-card">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
430
src/pages/admin/settings/index.tsx
Normal file
430
src/pages/admin/settings/index.tsx
Normal file
@@ -0,0 +1,430 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import Image from 'next/image';
|
||||
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 { Loader2, Save } from 'lucide-react';
|
||||
import { useForm, useFieldArray } from 'react-hook-form';
|
||||
import { Uploader } from '@/components/ui/uploader';
|
||||
|
||||
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, setValue, watch } = useForm<SystemSettingsForm>({
|
||||
defaultValues: {
|
||||
站点设置: {},
|
||||
支付宝设置: {},
|
||||
微信支付设置: {},
|
||||
阿里云短信设置: {},
|
||||
邮箱设置: {},
|
||||
AI配置列表: []
|
||||
}
|
||||
});
|
||||
|
||||
useFieldArray({
|
||||
control,
|
||||
name: "AI配置列表"
|
||||
});
|
||||
|
||||
const fetchSettings = useCallback(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);
|
||||
}
|
||||
}, [reset]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, [fetchSettings]);
|
||||
|
||||
const onSubmit = async (data: SystemSettingsForm) => {
|
||||
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>
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-4 items-start">
|
||||
<Input id="siteLogo" {...register('站点设置.Logo地址')} placeholder="外部链接或直接下方上传" className="flex-1" />
|
||||
<div className="w-10 h-10 bg-gray-100 rounded overflow-hidden shrink-0 border relative">
|
||||
{watch('站点设置.Logo地址') && (
|
||||
<Image
|
||||
src={watch('站点设置.Logo地址') || ''}
|
||||
alt="Logo"
|
||||
fill
|
||||
unoptimized
|
||||
className="object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Uploader
|
||||
type="public"
|
||||
accept="image/*"
|
||||
maxSize={5}
|
||||
variant="compact"
|
||||
onUploadSuccess={(url) => {
|
||||
setValue('站点设置.Logo地址', url, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="siteFavicon">Favicon</Label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-4 items-start">
|
||||
<Input id="siteFavicon" {...register('站点设置.Favicon')} placeholder="外部链接或直接下方上传" className="flex-1" />
|
||||
<div className="w-10 h-10 bg-gray-100 rounded overflow-hidden shrink-0 border relative">
|
||||
{watch('站点设置.Favicon') && (
|
||||
<Image
|
||||
src={watch('站点设置.Favicon') || ''}
|
||||
alt="Favicon"
|
||||
fill
|
||||
unoptimized
|
||||
className="object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Uploader
|
||||
type="public"
|
||||
accept="image/*"
|
||||
maxSize={2}
|
||||
variant="compact"
|
||||
onUploadSuccess={(url) => {
|
||||
setValue('站点设置.Favicon', url, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
313
src/pages/admin/users/index.tsx
Normal file
313
src/pages/admin/users/index.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
import { useState, useEffect, useCallback } 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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} 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 [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 });
|
||||
|
||||
const fetchUsers = useCallback(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);
|
||||
}
|
||||
}, [page, search]);
|
||||
|
||||
// 搜索防抖
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
fetchUsers();
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [fetchUsers]);
|
||||
|
||||
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-card">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
266
src/pages/aitools/chat/index.tsx
Normal file
266
src/pages/aitools/chat/index.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import AIToolsLayout from '@/components/aitools/AIToolsLayout';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Bot, Send, Loader2, Eraser } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
|
||||
interface Message {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
|
||||
export default function AIChat() {
|
||||
const { user } = useAuth();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedAssistant, setSelectedAssistant] = useState<string>('system');
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll to bottom
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
// Load initial system message based on selected assistant
|
||||
useEffect(() => {
|
||||
if (selectedAssistant === 'system') {
|
||||
// System default
|
||||
} else {
|
||||
// Find custom assistant
|
||||
const assistant = user?.AI配置?.自定义助手列表?.find((a: any) => a.名称 === selectedAssistant);
|
||||
if (assistant && assistant.系统提示词) {
|
||||
// Optionally add a system message to context (not visible)
|
||||
}
|
||||
}
|
||||
}, [selectedAssistant, user]);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() || isLoading) return;
|
||||
|
||||
const userMessage: Message = {
|
||||
role: 'user',
|
||||
content: input,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
setInput('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/ai/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
messages: [...messages, userMessage].map(m => ({ role: m.role, content: m.content })),
|
||||
assistantId: selectedAssistant === 'system' ? 'system' : selectedAssistant,
|
||||
mode: selectedAssistant === 'system' ? 'system' : 'custom',
|
||||
// Pass custom config if needed, but backend should fetch from user profile for security
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || 'Failed to send message');
|
||||
}
|
||||
|
||||
// Handle streaming response
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const assistantMessage: Message = {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, assistantMessage]);
|
||||
|
||||
if (reader) {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const chunk = decoder.decode(value);
|
||||
assistantMessage.content += chunk;
|
||||
|
||||
setMessages(prev => {
|
||||
const newMessages = [...prev];
|
||||
newMessages[newMessages.length - 1] = { ...assistantMessage };
|
||||
return newMessages;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || '发送失败,请重试');
|
||||
// Remove the failed user message or show error state?
|
||||
// For now just show toast
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const clearHistory = () => {
|
||||
setMessages([]);
|
||||
toast.success('对话历史已清空');
|
||||
};
|
||||
|
||||
// Get available assistants
|
||||
const assistants = [
|
||||
{ value: 'system', label: '系统默认助手 (System)' },
|
||||
...(user?.AI配置?.自定义助手列表?.map((a: any) => ({
|
||||
value: a.名称,
|
||||
label: `${a.名称} (Custom)`
|
||||
})) || [])
|
||||
];
|
||||
|
||||
return (
|
||||
<AIToolsLayout title="AI 智能对话" description="多模型智能问答助手,支持自定义模型配置">
|
||||
<div className="h-[calc(100vh-140px)] flex flex-col bg-card rounded-2xl border border-border overflow-hidden shadow-sm">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-border flex items-center justify-between bg-muted/50/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-green-100 flex items-center justify-center text-green-600">
|
||||
<Bot className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-bold text-foreground">AI 智能对话</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
当前模式: {selectedAssistant === 'system' ? '系统托管 (消耗积分)' : '自定义 (免费)'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={selectedAssistant} onValueChange={setSelectedAssistant}>
|
||||
<SelectTrigger className="w-[200px] bg-background">
|
||||
<SelectValue placeholder="选择助手" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{assistants.map(a => (
|
||||
<SelectItem key={a.value} value={a.value}>{a.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button variant="ghost" size="icon" onClick={clearHistory} title="清空对话">
|
||||
<Eraser className="w-4 h-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat Area */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6 bg-muted/50/30">
|
||||
{messages.length === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-muted-foreground space-y-4">
|
||||
<Bot className="w-16 h-16 opacity-20" />
|
||||
<p>开始一个新的对话吧...</p>
|
||||
<div className="grid grid-cols-2 gap-2 max-w-md w-full">
|
||||
{['帮我写一篇关于AI的文章', '解释一下量子纠缠', '如何优化React性能', '翻译这段代码'].map((q, i) => (
|
||||
<Button
|
||||
key={i}
|
||||
variant="outline"
|
||||
className="text-xs justify-start h-auto py-3 px-4 bg-background hover:bg-green-50 hover:text-green-600 hover:border-green-200"
|
||||
onClick={() => setInput(q)}
|
||||
>
|
||||
{q}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((msg, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex gap-3 max-w-3xl",
|
||||
msg.role === 'user' ? "ml-auto flex-row-reverse" : "mr-auto"
|
||||
)}
|
||||
>
|
||||
<Avatar className={cn("w-8 h-8", msg.role === 'user' ? "bg-primary" : "bg-green-600")}>
|
||||
{msg.role === 'user' ? (
|
||||
<AvatarImage src={user?.头像} />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-green-100 text-green-600">
|
||||
<Bot className="w-5 h-5" />
|
||||
</div>
|
||||
)}
|
||||
<AvatarFallback>{msg.role === 'user' ? 'U' : 'AI'}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className={cn(
|
||||
"rounded-2xl px-4 py-3 text-sm shadow-sm",
|
||||
msg.role === 'user'
|
||||
? "bg-primary text-white rounded-tr-none"
|
||||
: "bg-card border border-border text-foreground rounded-tl-none"
|
||||
)}>
|
||||
{msg.role === 'assistant' ? (
|
||||
<div className="prose prose-sm max-w-none dark:prose-invert">
|
||||
<ReactMarkdown>{msg.content}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<div className="whitespace-pre-wrap">{msg.content}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{isLoading && (
|
||||
<div className="flex gap-3 mr-auto max-w-3xl">
|
||||
<div className="w-8 h-8 rounded-full bg-green-100 flex items-center justify-center text-green-600">
|
||||
<Bot className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-2xl rounded-tl-none px-4 py-3 flex items-center">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground ml-2">正在思考...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="p-4 bg-card border-t border-border">
|
||||
<div className="relative max-w-4xl mx-auto">
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="输入您的问题... (Shift+Enter 换行)"
|
||||
className="pr-12 py-6 rounded-xl bg-muted/50 border-border focus:bg-background transition-colors"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 h-8 w-8 rounded-lg"
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || isLoading}
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-center mt-2">
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
AI 生成的内容可能不准确,请核实重要信息。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AIToolsLayout>
|
||||
);
|
||||
}
|
||||
19
src/pages/aitools/index.tsx
Normal file
19
src/pages/aitools/index.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
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-card rounded-2xl border border-border 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-foreground mb-2">欢迎使用 AI 工具箱</h1>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
请在左侧选择一个工具开始使用。<br />
|
||||
我们将持续更新更多实用的 AI 辅助工具,敬请期待。
|
||||
</p>
|
||||
</div>
|
||||
</AIToolsLayout>
|
||||
);
|
||||
}
|
||||
164
src/pages/aitools/prompt-optimizer/index.tsx
Normal file
164
src/pages/aitools/prompt-optimizer/index.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { 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-card rounded-2xl border border-border 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-foreground">原始提示词</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
placeholder="例如:帮我写一篇关于咖啡的文章..."
|
||||
className="flex-1 resize-none border-border 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-card rounded-2xl border border-border 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-muted flex items-center justify-center text-muted-foreground">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-foreground">优化结果</h2>
|
||||
</div>
|
||||
</div>
|
||||
{output && (
|
||||
<Button variant="outline" size="sm" onClick={copyToClipboard} className="text-muted-foreground hover:text-purple-600 border-border h-8">
|
||||
<Copy className="w-3.5 h-3.5 mr-1.5" />
|
||||
复制
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 bg-muted/50 rounded-xl border border-border p-4 overflow-y-auto relative z-10">
|
||||
{output ? (
|
||||
<div className="prose prose-sm max-w-none text-foreground whitespace-pre-wrap font-mono text-sm">
|
||||
{output}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-muted-foreground">
|
||||
<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
196
src/pages/aitools/translator/index.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { 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-card rounded-2xl border border-border 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-foreground">原文</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
placeholder="请输入需要翻译的内容..."
|
||||
className="flex-1 resize-none border-border 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-card rounded-2xl border border-border 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-foreground">目标语言:</span>
|
||||
<Select value={targetLang} onValueChange={setTargetLang}>
|
||||
<SelectTrigger className="w-[140px] h-8 text-xs border-border 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-muted-foreground hover:text-blue-600 border-border h-8">
|
||||
<Copy className="w-3.5 h-3.5 mr-1.5" />
|
||||
复制
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 bg-muted/50 rounded-xl border border-border p-4 overflow-y-auto relative z-10">
|
||||
{output ? (
|
||||
<div className="prose prose-sm max-w-none text-foreground whitespace-pre-wrap text-base leading-relaxed">
|
||||
{output}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-muted-foreground">
|
||||
<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
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) {
|
||||
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: { _id: { toString: () => string }; API密钥?: string }) => 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: { id: string }) => 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
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
61
src/pages/api/admin/articles/index.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { Article } from '@/models';
|
||||
import withDatabase from '@/lib/withDatabase';
|
||||
import { requireAdmin, DecodedToken } from '@/lib/auth';
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse, user: DecodedToken) {
|
||||
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: Record<string, unknown> = {};
|
||||
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
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
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: Record<string, unknown> = {};
|
||||
|
||||
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: unknown) {
|
||||
console.error('Fetch Orders Admin Error:', error);
|
||||
res.status(500).json({ message: (error as Error).message || 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
||||
export default requireAdmin(handler);
|
||||
37
src/pages/api/admin/plans/[id].ts
Normal file
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 {
|
||||
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 {
|
||||
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 {
|
||||
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
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 {
|
||||
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 {
|
||||
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
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) {
|
||||
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: { _id?: string; API密钥?: string; [key: string]: unknown }) => {
|
||||
// 如果是新项目(没有_id),直接返回
|
||||
if (!newItem._id) return newItem;
|
||||
|
||||
// 查找旧项目
|
||||
const oldItem = currentConfig.AI配置列表.find((item: { _id?: { toString: () => string }; API密钥?: string; [key: string]: unknown }) =>
|
||||
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
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
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, DecodedToken } from '@/lib/auth';
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse, adminUser: DecodedToken) {
|
||||
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
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) {
|
||||
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: Record<string, unknown> = {};
|
||||
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));
|
||||
103
src/pages/api/ai/chat.ts
Normal file
103
src/pages/api/ai/chat.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { verifyToken } from '@/lib/auth';
|
||||
import dbConnect from '@/lib/dbConnect';
|
||||
import { User, SystemConfig } from '@/models';
|
||||
import OpenAI from 'openai';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ message: '方法不被允许' });
|
||||
}
|
||||
|
||||
try {
|
||||
const token = req.cookies.token;
|
||||
if (!token) {
|
||||
return res.status(401).json({ message: '您还没有登录,请先登录' });
|
||||
}
|
||||
|
||||
const decoded = verifyToken(token);
|
||||
if (!decoded) {
|
||||
return res.status(401).json({ message: '无效的token' });
|
||||
}
|
||||
|
||||
const { messages, assistantId, mode } = req.body;
|
||||
|
||||
await dbConnect();
|
||||
const user = await User.findById(decoded.userId).select('+AI配置.自定义助手列表.API_Key');
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: '用户未找到' });
|
||||
}
|
||||
|
||||
let apiKey = process.env.OPENAI_API_KEY;
|
||||
let baseURL = process.env.OPENAI_API_BASE_URL || 'https://api.openai.com/v1';
|
||||
let model = 'gpt-3.5-turbo';
|
||||
let systemPrompt = 'You are a helpful assistant.';
|
||||
|
||||
if (mode === 'custom') {
|
||||
const assistant = user.AI配置?.自定义助手列表?.find((a: { 名称: string; API_Key?: string; API_Endpoint?: string; Model?: string; 系统提示词?: string }) => a.名称 === assistantId);
|
||||
if (!assistant) {
|
||||
return res.status(404).json({ message: '未找到自定义助手' });
|
||||
}
|
||||
if (!assistant.API_Key) {
|
||||
return res.status(400).json({ message: '自定义助手 API Key 为空' });
|
||||
}
|
||||
apiKey = assistant.API_Key;
|
||||
baseURL = assistant.API_Endpoint || baseURL;
|
||||
model = assistant.Model || model;
|
||||
systemPrompt = assistant.系统提示词 || systemPrompt;
|
||||
} else {
|
||||
// System mode: Fetch from SystemConfig
|
||||
const systemConfig = await SystemConfig.findOne({ 配置标识: 'default' }).select('+AI配置列表.API密钥');
|
||||
// Use the first enabled AI config or a specific one if we had a selection mechanism
|
||||
const defaultAI = systemConfig?.AI配置列表?.find((ai: { 是否启用: boolean; API密钥?: string; 接口地址?: string; 模型?: string; 系统提示词?: string }) => ai.是否启用);
|
||||
|
||||
if (defaultAI) {
|
||||
apiKey = defaultAI.API密钥;
|
||||
baseURL = defaultAI.接口地址;
|
||||
model = defaultAI.模型;
|
||||
systemPrompt = defaultAI.系统提示词 || systemPrompt;
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
return res.status(500).json({ message: '未配置系统AI (缺少凭证)' });
|
||||
}
|
||||
}
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: apiKey,
|
||||
baseURL: baseURL,
|
||||
});
|
||||
|
||||
const stream = await openai.chat.completions.create({
|
||||
model: model,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
...messages
|
||||
],
|
||||
stream: true,
|
||||
});
|
||||
|
||||
// Set headers for SSE
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
'Connection': 'keep-alive',
|
||||
});
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const content = chunk.choices[0]?.delta?.content || '';
|
||||
if (content) {
|
||||
res.write(content);
|
||||
}
|
||||
}
|
||||
|
||||
res.end();
|
||||
|
||||
} catch (error: unknown) {
|
||||
console.error('Chat API Error:', error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ message: (error as Error).message || '服务器错误' });
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user