Compare commits
1 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
2074482f44 |
|
|
@ -0,0 +1,261 @@
|
|||
---
|
||||
name: 后端密码访问功能开发
|
||||
overview: 为 Vue 3 前端项目添加后端密码访问控制功能,使用 Express + SQLite + JWT 实现用户密码验证和管理员密码管理功能
|
||||
design:
|
||||
architecture:
|
||||
framework: vue
|
||||
component: tdesign
|
||||
styleKeywords:
|
||||
- Glassmorphism
|
||||
- Dark Mode
|
||||
- Minimalist
|
||||
- Gradient Accent
|
||||
fontSystem:
|
||||
fontFamily: PingFang SC
|
||||
heading:
|
||||
size: 28px
|
||||
weight: 600
|
||||
subheading:
|
||||
size: 18px
|
||||
weight: 500
|
||||
body:
|
||||
size: 16px
|
||||
weight: 400
|
||||
colorSystem:
|
||||
primary:
|
||||
- "#6366F1"
|
||||
- "#8B5CF6"
|
||||
- "#A855F7"
|
||||
background:
|
||||
- "#0F172A"
|
||||
- "#1E293B"
|
||||
- "#334155"
|
||||
text:
|
||||
- "#F8FAFC"
|
||||
- "#94A3B8"
|
||||
- "#64748B"
|
||||
functional:
|
||||
- "#10B981"
|
||||
- "#EF4444"
|
||||
- "#F59E0B"
|
||||
todos:
|
||||
- id: setup-backend
|
||||
content: 创建后端项目结构,安装依赖,初始化 Express 服务和 SQLite 数据库
|
||||
status: completed
|
||||
- id: implement-auth-api
|
||||
content: 实现用户密码验证 API 和管理员登录 API,包含 JWT 生成和 bcrypt 加密
|
||||
status: completed
|
||||
dependencies:
|
||||
- setup-backend
|
||||
- id: implement-admin-api
|
||||
content: 实现管理员密码管理 API(增删查),添加 JWT 认证中间件
|
||||
status: completed
|
||||
dependencies:
|
||||
- setup-backend
|
||||
- id: create-frontend-auth
|
||||
content: 创建前端认证工具模块,实现 token 管理和 API 请求封装
|
||||
status: completed
|
||||
- id: create-login-pages
|
||||
content: 创建用户密码输入页和管理员登录页组件,实现登录验证逻辑
|
||||
status: completed
|
||||
dependencies:
|
||||
- create-frontend-auth
|
||||
- id: create-admin-panel
|
||||
content: 创建管理员密码管理面板,实现密码列表展示、添加和删除功能
|
||||
status: completed
|
||||
dependencies:
|
||||
- create-frontend-auth
|
||||
- id: add-route-guard
|
||||
content: 修改路由配置,添加全局路由守卫和管理员路由,配置 Vite 代理
|
||||
status: completed
|
||||
dependencies:
|
||||
- create-login-pages
|
||||
---
|
||||
|
||||
## 产品概述
|
||||
|
||||
为现有的 AI 英语学习平台添加后端密码访问控制功能,实现全站密码保护和管理员密码管理能力。
|
||||
|
||||
## 核心功能
|
||||
|
||||
- **全站访问控制**:所有页面必须验证密码才能访问,验证后保持登录状态
|
||||
- **用户密码验证**:用户输入访问密码,验证成功后通过 JWT Token 保持登录状态
|
||||
- **管理员登录**:独立的管理员登录入口,使用用户名+密码验证
|
||||
- **密码管理面板**:管理员可添加新的访问密码(名称+密码)、删除现有密码
|
||||
- **数据安全**:SQLite 数据库存储,密码使用 bcrypt 加密,SQL 参数化查询防止注入
|
||||
|
||||
## 用户确认的配置
|
||||
|
||||
- 管理员凭据存储在 SQLite 数据库中(可修改)
|
||||
- 所有密码都可访问全部页面(仅用于区分不同访问者)
|
||||
- 使用 JWT Token 保持登录状态(无状态)
|
||||
- 首次访问显示密码输入页,验证后保持登录状态
|
||||
|
||||
## 技术栈
|
||||
|
||||
### 后端(新建)
|
||||
|
||||
- **运行环境**:Node.js
|
||||
- **Web 框架**:Express.js
|
||||
- **数据库**:SQLite3(better-sqlite3 同步 API)
|
||||
- **认证**:JWT(jsonwebtoken)
|
||||
- **密码加密**:bcryptjs
|
||||
- **安全**:cors、helmet、express-rate-limit
|
||||
|
||||
### 前端(已有)
|
||||
|
||||
- **框架**:Vue 3 + Vite
|
||||
- **路由**:Vue Router 5
|
||||
- **HTTP 客户端**:axios(已安装)
|
||||
|
||||
## 技术架构
|
||||
|
||||
### 系统架构
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph 前端 Vue3
|
||||
A[路由守卫] --> B{Token 有效?}
|
||||
B -->|是| C[页面组件]
|
||||
B -->|否| D[密码输入页]
|
||||
E[管理员入口] --> F[管理员登录页]
|
||||
F --> G[密码管理面板]
|
||||
end
|
||||
|
||||
subgraph 后端 Express
|
||||
H[API 路由]
|
||||
H --> I[认证中间件]
|
||||
I --> J[用户密码验证]
|
||||
I --> K[管理员认证]
|
||||
H --> L[密码 CRUD]
|
||||
end
|
||||
|
||||
subgraph 数据层
|
||||
M[(SQLite)]
|
||||
M --> N[access_passwords 表]
|
||||
M --> O[admin_users 表]
|
||||
end
|
||||
|
||||
D -->|POST /api/auth/verify| J
|
||||
F -->|POST /api/admin/login| K
|
||||
G -->|GET/POST/DELETE /api/admin/passwords| L
|
||||
J -->|返回 JWT| D
|
||||
K -->|返回 JWT| F
|
||||
L --> M
|
||||
```
|
||||
|
||||
### 数据库设计
|
||||
|
||||
```sql
|
||||
-- 访问密码表
|
||||
CREATE TABLE access_passwords (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE, -- 密码名称标识
|
||||
password_hash TEXT NOT NULL, -- bcrypt 加密后的密码
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 管理员用户表
|
||||
CREATE TABLE admin_users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
### API 设计
|
||||
|
||||
| 端点 | 方法 | 描述 | 认证 |
|
||||
| --- | --- | --- | --- |
|
||||
| `/api/auth/verify` | POST | 用户密码验证 | 无 |
|
||||
| `/api/auth/verify-token` | GET | 验证 Token 有效性 | User JWT |
|
||||
| `/api/admin/login` | POST | 管理员登录 | 无 |
|
||||
| `/api/admin/passwords` | GET | 获取密码列表 | Admin JWT |
|
||||
| `/api/admin/passwords` | POST | 添加新密码 | Admin JWT |
|
||||
| `/api/admin/passwords/:id` | DELETE | 删除密码 | Admin JWT |
|
||||
|
||||
|
||||
## 实现要点
|
||||
|
||||
### 安全措施
|
||||
|
||||
1. **密码存储**:使用 bcryptjs 加密,cost factor = 10
|
||||
2. **JWT 配置**:HS256 签名,access token 有效期 7 天
|
||||
3. **SQL 注入防护**:使用参数化查询
|
||||
4. **速率限制**:登录接口限制 5 次/分钟/IP
|
||||
5. **CORS 配置**:仅允许同源请求
|
||||
|
||||
### 前端路由守卫逻辑
|
||||
|
||||
```
|
||||
用户访问任意页面 → 检查 localStorage 中的 token
|
||||
→ 无 token 或无效 → 重定向到密码输入页
|
||||
→ token 有效 → 正常访问
|
||||
```
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
c:/code/work/AI_Demo/
|
||||
├── server/ # [NEW] 后端服务目录
|
||||
│ ├── index.js # [NEW] Express 入口,配置中间件和路由
|
||||
│ ├── database.js # [NEW] SQLite 数据库初始化和操作
|
||||
│ ├── routes/
|
||||
│ │ ├── auth.js # [NEW] 用户认证路由(密码验证)
|
||||
│ │ └── admin.js # [NEW] 管理员路由(登录、密码管理)
|
||||
│ ├── middleware/
|
||||
│ │ ├── auth.js # [NEW] JWT 验证中间件
|
||||
│ │ └── rateLimit.js # [NEW] 速率限制中间件
|
||||
│ └── utils/
|
||||
│ └── jwt.js # [NEW] JWT 生成和验证工具
|
||||
├── src/
|
||||
│ ├── router/
|
||||
│ │ └── index.js # [MODIFY] 添加路由守卫和管理员路由
|
||||
│ ├── views/
|
||||
│ │ ├── LoginPage.vue # [NEW] 用户密码输入页面
|
||||
│ │ ├── AdminLogin.vue # [NEW] 管理员登录页面
|
||||
│ │ └── AdminPanel.vue # [NEW] 密码管理面板
|
||||
│ ├── utils/
|
||||
│ │ └── auth.js # [NEW] 前端认证工具(token 管理、API 调用)
|
||||
│ └── config/
|
||||
│ └── index.js # [MODIFY] 添加后端 API 地址配置
|
||||
├── package.json # [MODIFY] 添加后端依赖和启动脚本
|
||||
└── vite.config.js # [MODIFY] 添加后端 API 代理配置
|
||||
```
|
||||
|
||||
## 设计风格
|
||||
|
||||
采用与现有首页一致的暗色玻璃拟态风格,确保新增页面与整体设计语言统一。
|
||||
|
||||
## 页面设计
|
||||
|
||||
### 1. 密码输入页
|
||||
|
||||
- **整体布局**:居中卡片式布局,深色背景
|
||||
- **卡片内容**:标题"访问验证"、密码输入框、提交按钮
|
||||
- **交互效果**:输入框聚焦发光、按钮悬停动画、错误提示淡入淡出
|
||||
- **视觉风格**:玻璃拟态卡片、渐变边框、模糊背景
|
||||
|
||||
### 2. 管理员登录页
|
||||
|
||||
- **布局**:与密码输入页类似的居中卡片
|
||||
- **内容**:用户名输入框、密码输入框、登录按钮
|
||||
- **区分设计**:使用不同的主题色(琥珀色系)区分管理员入口
|
||||
|
||||
### 3. 管理员面板
|
||||
|
||||
- **顶部导航**:面包屑导航、退出登录按钮
|
||||
- **主区域**:
|
||||
- 统计卡片:显示当前密码总数
|
||||
- 密码列表:表格展示所有密码(名称、创建时间、操作按钮)
|
||||
- 添加表单:名称输入、密码输入、添加按钮(可折叠)
|
||||
- **交互**:添加成功后自动刷新列表、删除操作需二次确认
|
||||
|
||||
## Agent Extensions
|
||||
|
||||
### SubAgent
|
||||
|
||||
- **code-explorer**
|
||||
- Purpose: 在实现过程中需要深入探索现有代码模式时使用
|
||||
- Expected outcome: 确保新代码与现有代码风格一致,遵循项目约定
|
||||
|
|
@ -19,6 +19,7 @@
|
|||
"devDependencies": {
|
||||
"@types/pako": "^2.0.4",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"concurrently": "^9.1.2",
|
||||
"vite": "^8.0.0"
|
||||
}
|
||||
},
|
||||
|
|
@ -693,6 +694,32 @@
|
|||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-kit": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/ast-kit/-/ast-kit-2.2.0.tgz",
|
||||
|
|
@ -764,6 +791,36 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk/node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz",
|
||||
|
|
@ -779,6 +836,41 @@
|
|||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/cliui/-/cliui-8.0.1.tgz",
|
||||
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.1",
|
||||
"wrap-ansi": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
|
|
@ -791,6 +883,31 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "9.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/concurrently/-/concurrently-9.2.1.tgz",
|
||||
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "4.1.2",
|
||||
"rxjs": "7.8.2",
|
||||
"shell-quote": "1.8.3",
|
||||
"supports-color": "8.1.1",
|
||||
"tree-kill": "1.2.2",
|
||||
"yargs": "17.7.2"
|
||||
},
|
||||
"bin": {
|
||||
"conc": "dist/bin/concurrently.js",
|
||||
"concurrently": "dist/bin/concurrently.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/confbox": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.2.4.tgz",
|
||||
|
|
@ -836,6 +953,13 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz",
|
||||
|
|
@ -893,6 +1017,16 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz",
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||
|
|
@ -982,6 +1116,16 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
|
|
@ -1031,6 +1175,16 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
|
|
@ -1076,6 +1230,16 @@
|
|||
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz",
|
||||
|
|
@ -1607,6 +1771,16 @@
|
|||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.0-rc.9",
|
||||
"resolved": "https://registry.npmmirror.com/rolldown/-/rolldown-1.0.0-rc.9.tgz",
|
||||
|
|
@ -1648,12 +1822,35 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmmirror.com/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/scule": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/scule/-/scule-1.3.0.tgz",
|
||||
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmmirror.com/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
|
|
@ -1663,6 +1860,50 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-8.1.1.tgz",
|
||||
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
|
|
@ -1679,13 +1920,22 @@
|
|||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tree-kill": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"tree-kill": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/ufo": {
|
||||
"version": "1.6.3",
|
||||
|
|
@ -1874,6 +2124,34 @@
|
|||
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz",
|
||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.8.2.tgz",
|
||||
|
|
@ -1888,6 +2166,35 @@
|
|||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "17.7.2",
|
||||
"resolved": "https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz",
|
||||
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^8.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"require-directory": "^2.1.1",
|
||||
"string-width": "^4.2.3",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "21.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,11 @@
|
|||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"server": "node server/index.js",
|
||||
"server:dev": "node --watch server/index.js",
|
||||
"start": "npm run server",
|
||||
"dev:all": "concurrently \"npm run server\" \"npm run dev\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@ffmpeg/ffmpeg": "^0.12.15",
|
||||
|
|
@ -20,6 +24,7 @@
|
|||
"devDependencies": {
|
||||
"@types/pako": "^2.0.4",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"concurrently": "^9.1.2",
|
||||
"vite": "^8.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
# JWT 密钥(生产环境必须修改为随机字符串,至少32位)
|
||||
JWT_SECRET=ai-demo-jwt-secret-key-2024-change-in-production
|
||||
|
||||
# 管理员默认账户(首次启动时自动创建)
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=admin123456
|
||||
|
||||
# 服务端口
|
||||
PORT=3001
|
||||
|
||||
# 数据库文件路径(默认在 data 目录下)
|
||||
DATABASE_PATH=./data/auth.db
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
# JWT 密钥(生产环境必须修改为随机字符串,至少32位)
|
||||
JWT_SECRET=your-random-secret-key-at-least-32-characters-long
|
||||
|
||||
# 管理员默认账户(首次启动时自动创建)
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=admin123456
|
||||
|
||||
# 服务端口
|
||||
PORT=3001
|
||||
|
||||
# 数据库文件路径(默认在 data 目录下)
|
||||
DATABASE_PATH=./data/auth.db
|
||||
Binary file not shown.
|
|
@ -0,0 +1,187 @@
|
|||
import initSqlJs from 'sql.js';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import fs from 'fs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// 数据库文件路径
|
||||
const dbPath = process.env.DATABASE_PATH || path.join(__dirname, 'data', 'auth.db');
|
||||
const dbDir = path.dirname(dbPath);
|
||||
|
||||
// 确保数据目录存在
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
let db = null;
|
||||
|
||||
// 初始化数据库
|
||||
async function initDatabase() {
|
||||
const SQL = await initSqlJs();
|
||||
|
||||
// 尝试加载现有数据库文件
|
||||
if (fs.existsSync(dbPath)) {
|
||||
const fileBuffer = fs.readFileSync(dbPath);
|
||||
db = new SQL.Database(fileBuffer);
|
||||
} else {
|
||||
db = new SQL.Database();
|
||||
}
|
||||
|
||||
// 创建表
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS access_passwords (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS admin_users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// 初始化默认管理员账户
|
||||
const adminUsername = process.env.ADMIN_USERNAME || 'admin';
|
||||
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123456';
|
||||
|
||||
const existingAdmin = db.exec('SELECT id FROM admin_users WHERE username = ?', [adminUsername]);
|
||||
|
||||
if (existingAdmin.length === 0 || existingAdmin[0].values.length === 0) {
|
||||
const passwordHash = bcrypt.hashSync(adminPassword, 10);
|
||||
db.run('INSERT INTO admin_users (username, password_hash) VALUES (?, ?)', [adminUsername, passwordHash]);
|
||||
console.log(`[DB] 默认管理员账户已创建: ${adminUsername}`);
|
||||
}
|
||||
|
||||
// 保存数据库
|
||||
saveDatabase();
|
||||
}
|
||||
|
||||
// 保存数据库到文件
|
||||
function saveDatabase() {
|
||||
if (db) {
|
||||
const data = db.export();
|
||||
const buffer = Buffer.from(data);
|
||||
fs.writeFileSync(dbPath, buffer);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 访问密码操作 ──
|
||||
|
||||
export function getAllPasswords() {
|
||||
const result = db.exec('SELECT id, name, created_at FROM access_passwords ORDER BY created_at DESC');
|
||||
if (result.length === 0) return [];
|
||||
|
||||
const columns = result[0].columns;
|
||||
return result[0].values.map(row => {
|
||||
const obj = {};
|
||||
columns.forEach((col, i) => {
|
||||
obj[col] = row[i];
|
||||
});
|
||||
return obj;
|
||||
});
|
||||
}
|
||||
|
||||
export function getPasswordByName(name) {
|
||||
const result = db.exec('SELECT * FROM access_passwords WHERE name = ?', [name]);
|
||||
if (result.length === 0 || result[0].values.length === 0) return null;
|
||||
|
||||
const columns = result[0].columns;
|
||||
const row = result[0].values[0];
|
||||
const obj = {};
|
||||
columns.forEach((col, i) => {
|
||||
obj[col] = row[i];
|
||||
});
|
||||
return obj;
|
||||
}
|
||||
|
||||
export function addPassword(name, password) {
|
||||
const passwordHash = bcrypt.hashSync(password, 10);
|
||||
try {
|
||||
db.run('INSERT INTO access_passwords (name, password_hash) VALUES (?, ?)', [name, passwordHash]);
|
||||
saveDatabase();
|
||||
|
||||
// 获取最后插入的 ID
|
||||
const idResult = db.exec('SELECT last_insert_rowid()');
|
||||
const id = idResult[0].values[0][0];
|
||||
|
||||
return { success: true, id };
|
||||
} catch (error) {
|
||||
if (error.message && error.message.includes('UNIQUE constraint failed')) {
|
||||
return { success: false, error: '密码名称已存在' };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function deletePassword(id) {
|
||||
db.run('DELETE FROM access_passwords WHERE id = ?', [id]);
|
||||
saveDatabase();
|
||||
|
||||
// 检查是否删除了记录
|
||||
const result = db.exec('SELECT changes()');
|
||||
return result[0].values[0][0] > 0;
|
||||
}
|
||||
|
||||
export function verifyPassword(name, password) {
|
||||
// 如果只提供密码,遍历所有密码记录进行匹配
|
||||
if (!name && password) {
|
||||
const allPasswords = getAllPasswords();
|
||||
for (const record of allPasswords) {
|
||||
const fullRecord = getPasswordByName(record.name);
|
||||
if (fullRecord && bcrypt.compareSync(password, fullRecord.password_hash)) {
|
||||
return { valid: true, name: record.name };
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果提供了名称和密码,按名称验证
|
||||
const record = getPasswordByName(name);
|
||||
if (!record) {
|
||||
return false;
|
||||
}
|
||||
return bcrypt.compareSync(password, record.password_hash) ? { valid: true, name } : false;
|
||||
}
|
||||
|
||||
// ── 管理员操作 ──
|
||||
|
||||
export function getAdminByUsername(username) {
|
||||
const result = db.exec('SELECT * FROM admin_users WHERE username = ?', [username]);
|
||||
if (result.length === 0 || result[0].values.length === 0) return null;
|
||||
|
||||
const columns = result[0].columns;
|
||||
const row = result[0].values[0];
|
||||
const obj = {};
|
||||
columns.forEach((col, i) => {
|
||||
obj[col] = row[i];
|
||||
});
|
||||
return obj;
|
||||
}
|
||||
|
||||
export function verifyAdminPassword(username, password) {
|
||||
const admin = getAdminByUsername(username);
|
||||
if (!admin) {
|
||||
return false;
|
||||
}
|
||||
return bcrypt.compareSync(password, admin.password_hash);
|
||||
}
|
||||
|
||||
export function updateAdminPassword(username, newPassword) {
|
||||
const passwordHash = bcrypt.hashSync(newPassword, 10);
|
||||
db.run('UPDATE admin_users SET password_hash = ? WHERE username = ?', [passwordHash, username]);
|
||||
saveDatabase();
|
||||
|
||||
const result = db.exec('SELECT changes()');
|
||||
return result[0].values[0][0] > 0;
|
||||
}
|
||||
|
||||
// 导出初始化函数
|
||||
export { initDatabase };
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import dotenv from 'dotenv';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
// 加载环境变量
|
||||
dotenv.config({ path: join(dirname(fileURLToPath(import.meta.url)), '.env') });
|
||||
|
||||
import authRoutes from './routes/auth.js';
|
||||
import adminRoutes from './routes/admin.js';
|
||||
import { apiLimiter } from './middleware/rateLimit.js';
|
||||
import { initDatabase } from './database.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
// ── 安全中间件 ──
|
||||
app.use(helmet({
|
||||
crossOriginResourcePolicy: { policy: 'cross-origin' }
|
||||
}));
|
||||
|
||||
// CORS 配置
|
||||
app.use(cors({
|
||||
origin: true, // 开发环境允许所有来源
|
||||
credentials: true
|
||||
}));
|
||||
|
||||
// JSON 解析
|
||||
app.use(express.json());
|
||||
|
||||
// ── API 路由 ──
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
|
||||
// API 速率限制
|
||||
app.use('/api', apiLimiter);
|
||||
|
||||
// ── 健康检查 ──
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '1.0.0'
|
||||
});
|
||||
});
|
||||
|
||||
// ── 错误处理 ──
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('[Error]', err);
|
||||
res.status(500).json({
|
||||
error: '服务器内部错误',
|
||||
message: process.env.NODE_ENV === 'development' ? err.message : undefined
|
||||
});
|
||||
});
|
||||
|
||||
// 404 处理
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({ error: '接口不存在' });
|
||||
});
|
||||
|
||||
// ── 启动服务 ──
|
||||
async function start() {
|
||||
try {
|
||||
// 初始化数据库
|
||||
await initDatabase();
|
||||
console.log('[DB] 数据库初始化完成');
|
||||
|
||||
// 启动服务器
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[Server] 认证服务已启动: http://localhost:${PORT}`);
|
||||
console.log(`[Server] 环境: ${process.env.NODE_ENV || 'development'}`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Server] 启动失败:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
start();
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import { verifyToken, isUserToken, isAdminToken } from '../utils/jwt.js';
|
||||
|
||||
/**
|
||||
* 用户认证中间件
|
||||
*/
|
||||
export function requireUserAuth(req, res, next) {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: '未提供认证令牌' });
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const payload = verifyToken(token);
|
||||
|
||||
if (!payload) {
|
||||
return res.status(401).json({ error: '令牌无效或已过期' });
|
||||
}
|
||||
|
||||
if (!isUserToken(payload)) {
|
||||
return res.status(403).json({ error: '需要用户令牌' });
|
||||
}
|
||||
|
||||
req.user = payload;
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员认证中间件
|
||||
*/
|
||||
export function requireAdminAuth(req, res, next) {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: '未提供认证令牌' });
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const payload = verifyToken(token);
|
||||
|
||||
if (!payload) {
|
||||
return res.status(401).json({ error: '令牌无效或已过期' });
|
||||
}
|
||||
|
||||
if (!isAdminToken(payload)) {
|
||||
return res.status(403).json({ error: '需要管理员权限' });
|
||||
}
|
||||
|
||||
req.admin = payload;
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* 可选认证中间件(不强制要求 token)
|
||||
*/
|
||||
export function optionalAuth(req, res, next) {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
const payload = verifyToken(token);
|
||||
if (payload) {
|
||||
req.user = payload;
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import rateLimit from 'express-rate-limit';
|
||||
|
||||
/**
|
||||
* 登录接口速率限制:5 次/分钟/IP
|
||||
*/
|
||||
export const loginLimiter = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 分钟
|
||||
max: 5,
|
||||
message: { error: '请求过于频繁,请稍后再试' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => {
|
||||
return req.ip || req.connection.remoteAddress;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* API 通用速率限制:100 次/分钟/IP
|
||||
*/
|
||||
export const apiLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
max: 100,
|
||||
message: { error: '请求过于频繁,请稍后再试' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => {
|
||||
return req.ip || req.connection.remoteAddress;
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"name": "ai-demo-server",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"dev": "node --watch index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"helmet": "^8.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"sql.js": "^1.12.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
import express from 'express';
|
||||
import {
|
||||
verifyAdminPassword,
|
||||
getAllPasswords,
|
||||
addPassword,
|
||||
deletePassword
|
||||
} from '../database.js';
|
||||
import { generateAdminToken } from '../utils/jwt.js';
|
||||
import { requireAdminAuth } from '../middleware/auth.js';
|
||||
import { loginLimiter } from '../middleware/rateLimit.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* 管理员登录
|
||||
* POST /api/admin/login
|
||||
* Body: { username: string, password: string }
|
||||
*/
|
||||
router.post('/login', loginLimiter, (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: '请提供用户名和密码' });
|
||||
}
|
||||
|
||||
const isValid = verifyAdminPassword(username, password);
|
||||
|
||||
if (!isValid) {
|
||||
return res.status(401).json({ error: '用户名或密码错误' });
|
||||
}
|
||||
|
||||
const token = generateAdminToken(username);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
token,
|
||||
message: '登录成功'
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取所有访问密码
|
||||
* GET /api/admin/passwords
|
||||
* 需要管理员认证
|
||||
*/
|
||||
router.get('/passwords', requireAdminAuth, (req, res) => {
|
||||
const passwords = getAllPasswords();
|
||||
res.json({
|
||||
success: true,
|
||||
passwords
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 添加新的访问密码
|
||||
* POST /api/admin/passwords
|
||||
* Body: { name: string, password: string }
|
||||
* 需要管理员认证
|
||||
*/
|
||||
router.post('/passwords', requireAdminAuth, (req, res) => {
|
||||
const { name, password } = req.body;
|
||||
|
||||
if (!name || !password) {
|
||||
return res.status(400).json({ error: '请提供密码名称和密码' });
|
||||
}
|
||||
|
||||
if (name.length < 1 || name.length > 50) {
|
||||
return res.status(400).json({ error: '密码名称长度应在 1-50 个字符之间' });
|
||||
}
|
||||
|
||||
if (password.length < 4) {
|
||||
return res.status(400).json({ error: '密码长度至少 4 个字符' });
|
||||
}
|
||||
|
||||
const result = addPassword(name, password);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(400).json({ error: result.error });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
id: result.id,
|
||||
message: '密码添加成功'
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 删除访问密码
|
||||
* DELETE /api/admin/passwords/:id
|
||||
* 需要管理员认证
|
||||
*/
|
||||
router.delete('/passwords/:id', requireAdminAuth, (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).json({ error: '无效的密码 ID' });
|
||||
}
|
||||
|
||||
const success = deletePassword(id);
|
||||
|
||||
if (!success) {
|
||||
return res.status(404).json({ error: '密码不存在' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '密码删除成功'
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 验证管理员 token
|
||||
* GET /api/admin/verify-token
|
||||
*/
|
||||
router.get('/verify-token', requireAdminAuth, (req, res) => {
|
||||
res.json({
|
||||
valid: true,
|
||||
admin: {
|
||||
username: req.admin.username
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import express from 'express';
|
||||
import { verifyPassword, getAllPasswords } from '../database.js';
|
||||
import { generateUserToken, verifyToken } from '../utils/jwt.js';
|
||||
import { loginLimiter } from '../middleware/rateLimit.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* 用户密码验证
|
||||
* POST /api/auth/verify
|
||||
* Body: { password: string } 或 { name: string, password: string }
|
||||
*/
|
||||
router.post('/verify', loginLimiter, (req, res) => {
|
||||
const { name, password } = req.body;
|
||||
|
||||
if (!password) {
|
||||
return res.status(400).json({ error: '请提供访问密码' });
|
||||
}
|
||||
|
||||
const result = verifyPassword(name, password);
|
||||
|
||||
if (!result || !result.valid) {
|
||||
return res.status(401).json({ error: '访问密码错误' });
|
||||
}
|
||||
|
||||
const token = generateUserToken(result.name);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
token,
|
||||
message: '验证成功'
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 验证 token 有效性
|
||||
* GET /api/auth/verify-token
|
||||
* Header: Authorization: Bearer <token>
|
||||
*/
|
||||
router.get('/verify-token', (req, res) => {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ valid: false, error: '未提供令牌' });
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const payload = verifyToken(token);
|
||||
|
||||
if (!payload) {
|
||||
return res.status(401).json({ valid: false, error: '令牌无效或已过期' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
valid: true,
|
||||
user: {
|
||||
name: payload.name,
|
||||
type: payload.type
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取所有可用的密码名称列表(用于下拉选择)
|
||||
* GET /api/auth/names
|
||||
*/
|
||||
router.get('/names', (req, res) => {
|
||||
const passwords = getAllPasswords();
|
||||
res.json({
|
||||
names: passwords.map(p => p.name)
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import jwt from 'jsonwebtoken';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'default-secret-key-please-change-in-production';
|
||||
const JWT_EXPIRES_IN = '7d';
|
||||
|
||||
// 用户 token 类型
|
||||
export const TOKEN_TYPE = {
|
||||
USER: 'user',
|
||||
ADMIN: 'admin'
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成用户访问 token
|
||||
* @param {string} passwordName - 密码名称
|
||||
* @returns {string} JWT token
|
||||
*/
|
||||
export function generateUserToken(passwordName) {
|
||||
return jwt.sign(
|
||||
{ type: TOKEN_TYPE.USER, name: passwordName },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: JWT_EXPIRES_IN }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成管理员 token
|
||||
* @param {string} username - 管理员用户名
|
||||
* @returns {string} JWT token
|
||||
*/
|
||||
export function generateAdminToken(username) {
|
||||
return jwt.sign(
|
||||
{ type: TOKEN_TYPE.ADMIN, username },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: JWT_EXPIRES_IN }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 token
|
||||
* @param {string} token - JWT token
|
||||
* @returns {object|null} 解码后的 payload 或 null
|
||||
*/
|
||||
export function verifyToken(token) {
|
||||
try {
|
||||
return jwt.verify(token, JWT_SECRET);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为管理员 token
|
||||
* @param {object} payload - token payload
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAdminToken(payload) {
|
||||
return payload && payload.type === TOKEN_TYPE.ADMIN;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为用户 token
|
||||
* @param {object} payload - token payload
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isUserToken(payload) {
|
||||
return payload && payload.type === TOKEN_TYPE.USER;
|
||||
}
|
||||
|
|
@ -9,10 +9,32 @@ import ProblemSolving from '../views/ProblemSolving.vue'
|
|||
import QuestionGenerator from '../views/QuestionGenerator.vue'
|
||||
import QuestionVariant from '../views/QuestionVariant.vue'
|
||||
import AudioToText from '../views/AudioToText.vue'
|
||||
import LoginPage from '../views/LoginPage.vue'
|
||||
import AdminLogin from '../views/AdminLogin.vue'
|
||||
import AdminPanel from '../views/AdminPanel.vue'
|
||||
import { isLoggedIn, isAdmin, verifyToken, verifyAdminToken } from '../utils/auth.js'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: LoginPage,
|
||||
meta: { public: true }
|
||||
},
|
||||
{
|
||||
path: '/admin/login',
|
||||
name: 'admin-login',
|
||||
component: AdminLogin,
|
||||
meta: { public: true }
|
||||
},
|
||||
{
|
||||
path: '/admin/panel',
|
||||
name: 'admin-panel',
|
||||
component: AdminPanel,
|
||||
meta: { requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
|
|
@ -66,4 +88,46 @@ const router = createRouter({
|
|||
]
|
||||
})
|
||||
|
||||
// 全局路由守卫
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
// 公开页面直接放行
|
||||
if (to.meta.public) {
|
||||
// 如果已登录,访问登录页时重定向
|
||||
if (to.path === '/login' && isLoggedIn()) {
|
||||
return next('/')
|
||||
}
|
||||
// 如果已登录管理员,访问管理员登录页时重定向到面板
|
||||
if (to.path === '/admin/login' && isLoggedIn() && isAdmin()) {
|
||||
return next('/admin/panel')
|
||||
}
|
||||
return next()
|
||||
}
|
||||
|
||||
// 需要管理员权限的页面
|
||||
if (to.meta.requiresAdmin) {
|
||||
if (!isLoggedIn() || !isAdmin()) {
|
||||
return next('/admin/login')
|
||||
}
|
||||
// 验证 token 有效性
|
||||
const result = await verifyAdminToken()
|
||||
if (!result.valid) {
|
||||
return next('/admin/login')
|
||||
}
|
||||
return next()
|
||||
}
|
||||
|
||||
// 需要用户认证的页面
|
||||
if (!isLoggedIn()) {
|
||||
return next({ path: '/login', query: { redirect: to.fullPath } })
|
||||
}
|
||||
|
||||
// 验证 token 有效性
|
||||
const result = await verifyToken()
|
||||
if (!result.valid) {
|
||||
return next({ path: '/login', query: { redirect: to.fullPath } })
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
|
|
|
|||
|
|
@ -0,0 +1,178 @@
|
|||
import axios from 'axios';
|
||||
|
||||
// API 基础路径
|
||||
const API_BASE = '/api';
|
||||
|
||||
// Token 存储 key
|
||||
const TOKEN_KEY = 'ai_demo_token';
|
||||
const TOKEN_TYPE_KEY = 'ai_demo_token_type';
|
||||
|
||||
// ── Token 管理 ──
|
||||
|
||||
/**
|
||||
* 保存 token 到 localStorage
|
||||
*/
|
||||
export function saveToken(token, type = 'user') {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
localStorage.setItem(TOKEN_TYPE_KEY, type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 token
|
||||
*/
|
||||
export function getToken() {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 token 类型
|
||||
*/
|
||||
export function getTokenType() {
|
||||
return localStorage.getItem(TOKEN_TYPE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除 token
|
||||
*/
|
||||
export function clearToken() {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(TOKEN_TYPE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已登录
|
||||
*/
|
||||
export function isLoggedIn() {
|
||||
return !!getToken();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为管理员
|
||||
*/
|
||||
export function isAdmin() {
|
||||
return getTokenType() === 'admin';
|
||||
}
|
||||
|
||||
// ── API 请求封装 ──
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
// 请求拦截器:自动添加 token
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
// 响应拦截器:处理 401 错误
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Token 无效,清除登录状态
|
||||
clearToken();
|
||||
// 如果不是登录页面,跳转到登录页
|
||||
if (window.location.pathname !== '/login' && window.location.pathname !== '/admin/login') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// ── 用户认证 API ──
|
||||
|
||||
/**
|
||||
* 用户密码验证
|
||||
* @param {string} password - 密码
|
||||
* @param {string} [name] - 密码名称(可选)
|
||||
*/
|
||||
export async function verifyPassword(password, name = null) {
|
||||
const body = name ? { name, password } : { password };
|
||||
const response = await api.post('/auth/verify', body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证当前 token 是否有效
|
||||
*/
|
||||
export async function verifyToken() {
|
||||
try {
|
||||
const response = await api.get('/auth/verify-token');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return { valid: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有可用的密码名称列表
|
||||
*/
|
||||
export async function getPasswordNames() {
|
||||
const response = await api.get('/auth/names');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// ── 管理员 API ──
|
||||
|
||||
/**
|
||||
* 管理员登录
|
||||
* @param {string} username - 用户名
|
||||
* @param {string} password - 密码
|
||||
*/
|
||||
export async function adminLogin(username, password) {
|
||||
const response = await api.post('/admin/login', { username, password });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有访问密码
|
||||
*/
|
||||
export async function getAllPasswords() {
|
||||
const response = await api.get('/admin/passwords');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加新的访问密码
|
||||
* @param {string} name - 密码名称
|
||||
* @param {string} password - 密码
|
||||
*/
|
||||
export async function addPassword(name, password) {
|
||||
const response = await api.post('/admin/passwords', { name, password });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除访问密码
|
||||
* @param {number} id - 密码 ID
|
||||
*/
|
||||
export async function deletePassword(id) {
|
||||
const response = await api.delete(`/admin/passwords/${id}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证管理员 token
|
||||
*/
|
||||
export async function verifyAdminToken() {
|
||||
try {
|
||||
const response = await api.get('/admin/verify-token');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return { valid: false };
|
||||
}
|
||||
}
|
||||
|
||||
export default api;
|
||||
|
|
@ -0,0 +1,299 @@
|
|||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { adminLogin, saveToken, isLoggedIn, isAdmin } from '@/utils/auth.js';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const username = ref('');
|
||||
const password = ref('');
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
// 检查是否已登录为管理员
|
||||
if (isLoggedIn() && isAdmin()) {
|
||||
router.replace('/admin/panel');
|
||||
}
|
||||
|
||||
// 提交登录
|
||||
async function handleSubmit() {
|
||||
if (!username.value || !password.value) {
|
||||
error.value = '请输入用户名和密码';
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
|
||||
try {
|
||||
const result = await adminLogin(username.value, password.value);
|
||||
|
||||
if (result.success) {
|
||||
saveToken(result.token, 'admin');
|
||||
router.push('/admin/panel');
|
||||
} else {
|
||||
error.value = result.error || '登录失败';
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error || '网络错误,请稍后再试';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-login-page">
|
||||
<div class="login-card">
|
||||
<div class="card-header">
|
||||
<div class="icon-wrapper">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1>管理员登录</h1>
|
||||
<p class="subtitle">请输入管理员凭据</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="login-form">
|
||||
<div class="form-group">
|
||||
<label for="username">用户名</label>
|
||||
<input
|
||||
id="username"
|
||||
v-model="username"
|
||||
type="text"
|
||||
placeholder="请输入用户名"
|
||||
:disabled="loading"
|
||||
autocomplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">密码</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
:disabled="loading"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" />
|
||||
</svg>
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="submit-btn" :disabled="loading">
|
||||
<span v-if="loading" class="loading-spinner"></span>
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="back-link">
|
||||
<router-link to="/login">返回用户登录</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 24px;
|
||||
padding: 3rem 2.5rem;
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 1.5rem;
|
||||
background: linear-gradient(135deg, #f59e0b, #d97706);
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 10px 30px -6px rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
|
||||
.icon-wrapper svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.card-header h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #fff 0%, #fcd34d 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #94a3b8;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
padding: 1rem 1.25rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
color: #f8fafc;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.form-group input::placeholder {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
border-color: rgba(245, 158, 11, 0.5);
|
||||
box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.15);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.875rem 1rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: 10px;
|
||||
color: #fca5a5;
|
||||
font-size: 0.9rem;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.error-message svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-5px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: 1rem;
|
||||
background: linear-gradient(135deg, #f59e0b, #d97706);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 30px -6px rgba(245, 158, 11, 0.5);
|
||||
}
|
||||
|
||||
.submit-btn:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.back-link {
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.back-link a {
|
||||
color: #94a3b8;
|
||||
font-size: 0.9rem;
|
||||
text-decoration: none;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.back-link a:hover {
|
||||
color: #fcd34d;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.login-card {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.card-header h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,684 @@
|
|||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { getAllPasswords, addPassword, deletePassword, clearToken, isAdmin } from '@/utils/auth.js';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const passwords = ref([]);
|
||||
const loading = ref(true);
|
||||
const showAddForm = ref(false);
|
||||
const newEntry = ref({ name: '', password: '' });
|
||||
const submitting = ref(false);
|
||||
const deleteConfirm = ref(null);
|
||||
const message = ref({ type: '', text: '' });
|
||||
|
||||
// 检查管理员权限
|
||||
if (!isAdmin()) {
|
||||
router.replace('/admin/login');
|
||||
}
|
||||
|
||||
// 加载密码列表
|
||||
async function loadPasswords() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await getAllPasswords();
|
||||
passwords.value = result.passwords || [];
|
||||
} catch (err) {
|
||||
showMessage('error', '加载密码列表失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadPasswords);
|
||||
|
||||
// 显示消息
|
||||
function showMessage(type, text) {
|
||||
message.value = { type, text };
|
||||
setTimeout(() => {
|
||||
message.value = { type: '', text: '' };
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// 添加密码
|
||||
async function handleAdd() {
|
||||
if (!newEntry.value.name || !newEntry.value.password) {
|
||||
showMessage('error', '请填写完整信息');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newEntry.value.password.length < 4) {
|
||||
showMessage('error', '密码长度至少 4 位');
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
const result = await addPassword(newEntry.value.name, newEntry.value.password);
|
||||
if (result.success) {
|
||||
showMessage('success', '密码添加成功');
|
||||
newEntry.value = { name: '', password: '' };
|
||||
showAddForm.value = false;
|
||||
await loadPasswords();
|
||||
} else {
|
||||
showMessage('error', result.error || '添加失败');
|
||||
}
|
||||
} catch (err) {
|
||||
showMessage('error', err.response?.data?.error || '网络错误');
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 删除密码
|
||||
async function handleDelete(id) {
|
||||
try {
|
||||
const result = await deletePassword(id);
|
||||
if (result.success) {
|
||||
showMessage('success', '密码删除成功');
|
||||
deleteConfirm.value = null;
|
||||
await loadPasswords();
|
||||
} else {
|
||||
showMessage('error', result.error || '删除失败');
|
||||
}
|
||||
} catch (err) {
|
||||
showMessage('error', '网络错误');
|
||||
}
|
||||
}
|
||||
|
||||
// 退出登录
|
||||
function handleLogout() {
|
||||
clearToken();
|
||||
router.push('/admin/login');
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleString('zh-CN');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-panel">
|
||||
<header class="panel-header">
|
||||
<div class="header-left">
|
||||
<h1>密码管理面板</h1>
|
||||
<span class="breadcrumb">首页 / 管理面板</span>
|
||||
</div>
|
||||
<button class="logout-btn" @click="handleLogout">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 9V5.25A2.25 2.25 0 0 1 10.5 3h6a2.25 2.25 0 0 1 2.25 2.25v13.5A2.25 2.25 0 0 1 16.5 21h-6a2.25 2.25 0 0 1-2.25-2.25V15m-3 0-3-3m0 0 3-3m-3 3H15" />
|
||||
</svg>
|
||||
退出登录
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- 消息提示 -->
|
||||
<div v-if="message.text" :class="['message-toast', message.type]">
|
||||
{{ message.text }}
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-card">
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 15.75h-1.5l-1.5 1.5H6l-1.5 1.5H3m15-9a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">{{ passwords.length }}</span>
|
||||
<span class="stat-label">访问密码总数</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加密码表单 -->
|
||||
<div class="add-section">
|
||||
<button class="toggle-add-btn" @click="showAddForm = !showAddForm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
{{ showAddForm ? '取消添加' : '添加新密码' }}
|
||||
</button>
|
||||
|
||||
<div v-if="showAddForm" class="add-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>密码名称</label>
|
||||
<input v-model="newEntry.name" type="text" placeholder="如:张三" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>访问密码</label>
|
||||
<input v-model="newEntry.password" type="text" placeholder="至少 4 位字符" />
|
||||
</div>
|
||||
</div>
|
||||
<button class="submit-add-btn" @click="handleAdd" :disabled="submitting">
|
||||
{{ submitting ? '添加中...' : '确认添加' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 密码列表 -->
|
||||
<div class="password-list">
|
||||
<div class="list-header">
|
||||
<h2>密码列表</h2>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="passwords.length === 0" class="empty-state">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
||||
</svg>
|
||||
<p>暂无密码记录</p>
|
||||
<span>点击上方按钮添加第一个访问密码</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="table-wrapper">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>序号</th>
|
||||
<th>密码名称</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(item, index) in passwords" :key="item.id">
|
||||
<td>{{ index + 1 }}</td>
|
||||
<td class="name-cell">{{ item.name }}</td>
|
||||
<td>{{ formatDate(item.created_at) }}</td>
|
||||
<td>
|
||||
<button class="delete-btn" @click="deleteConfirm = item.id">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||
</svg>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 删除确认对话框 -->
|
||||
<div v-if="deleteConfirm" class="modal-overlay" @click.self="deleteConfirm = null">
|
||||
<div class="modal-content">
|
||||
<div class="modal-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>确认删除</h3>
|
||||
<p>删除后该密码将立即失效,确定要删除吗?</p>
|
||||
<div class="modal-actions">
|
||||
<button class="cancel-btn" @click="deleteConfirm = null">取消</button>
|
||||
<button class="confirm-btn" @click="handleDelete(deleteConfirm)">确认删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-panel {
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.header-left h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #fff 0%, #fcd34d 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
color: #64748b;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: 10px;
|
||||
color: #fca5a5;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
border-color: rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
|
||||
.logout-btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* 消息提示 */
|
||||
.message-toast {
|
||||
position: fixed;
|
||||
top: 2rem;
|
||||
right: 2rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.95rem;
|
||||
animation: slideIn 0.3s ease;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.message-toast.success {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
border: 1px solid rgba(16, 185, 129, 0.4);
|
||||
color: #6ee7b7;
|
||||
}
|
||||
|
||||
.message-toast.error {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
border: 1px solid rgba(239, 68, 68, 0.4);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateX(20px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
/* 统计卡片 */
|
||||
.stats-card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: linear-gradient(135deg, #f59e0b, #d97706);
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stat-icon svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #94a3b8;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* 添加区域 */
|
||||
.add-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.toggle-add-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.875rem 1.25rem;
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.toggle-add-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px -6px rgba(16, 185, 129, 0.5);
|
||||
}
|
||||
|
||||
.toggle-add-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.add-form {
|
||||
margin-top: 1rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
color: #e2e8f0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
padding: 0.875rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
color: #f8fafc;
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
border-color: rgba(16, 185, 129, 0.5);
|
||||
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
|
||||
.submit-add-btn {
|
||||
padding: 0.875rem 1.5rem;
|
||||
background: linear-gradient(135deg, #10b981, #059669);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
color: #fff;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.submit-add-btn:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.submit-add-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 密码列表 */
|
||||
.password-list {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.list-header {
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.list-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.1);
|
||||
border-top-color: #f59e0b;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.empty-state svg {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
color: #475569;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.1rem;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.empty-state span {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 1rem 1.5rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
th {
|
||||
color: #94a3b8;
|
||||
font-weight: 500;
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
td {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.name-cell {
|
||||
font-weight: 500;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
border-radius: 8px;
|
||||
color: #fca5a5;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
.delete-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* 删除确认对话框 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #1e293b;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 20px;
|
||||
padding: 2rem;
|
||||
max-width: 400px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 0 auto 1rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-icon svg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.modal-content h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.25rem;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.modal-content p {
|
||||
margin: 0 0 1.5rem;
|
||||
color: #94a3b8;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.cancel-btn,
|
||||
.confirm-btn {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||
border: none;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.confirm-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px -6px rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.admin-panel {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -80,7 +80,7 @@ const features = ref([
|
|||
{
|
||||
id: 10,
|
||||
title: "音频转文字",
|
||||
desc: "输入音频文件URL,AI自动识别并转换为文本,支持中英日多语种,适用于会议记录、采访整理等场景。",
|
||||
desc: "输入音频URL,智能识别转文字,支持中英日多语种、最长12小时长音频,AI自动优化排版格式。",
|
||||
class: "card-10",
|
||||
icon: "file-audio",
|
||||
route: "/audio-to-text",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,306 @@
|
|||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { verifyPassword, saveToken, isLoggedIn } from '@/utils/auth.js';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const password = ref('');
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
// 检查是否已登录
|
||||
if (isLoggedIn()) {
|
||||
const redirect = route.query.redirect || '/';
|
||||
router.replace(redirect);
|
||||
}
|
||||
|
||||
// 提交验证
|
||||
async function handleSubmit() {
|
||||
if (!password.value) {
|
||||
error.value = '请输入访问密码';
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
|
||||
try {
|
||||
const result = await verifyPassword(password.value);
|
||||
|
||||
if (result.success) {
|
||||
saveToken(result.token, 'user');
|
||||
const redirect = route.query.redirect || '/';
|
||||
router.push(redirect);
|
||||
} else {
|
||||
error.value = result.error || '验证失败';
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error || '网络错误,请稍后再试';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-card">
|
||||
<div class="card-header">
|
||||
<div class="icon-wrapper">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1>访问验证</h1>
|
||||
<p class="subtitle">请输入访问密码以继续</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="login-form">
|
||||
<div class="form-group">
|
||||
<label for="password">访问密码</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
placeholder="请输入访问密码"
|
||||
:disabled="loading"
|
||||
autocomplete="current-password"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" />
|
||||
</svg>
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="submit-btn" :disabled="loading">
|
||||
<span v-if="loading" class="loading-spinner"></span>
|
||||
{{ loading ? '验证中...' : '确认访问' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="admin-link">
|
||||
<router-link to="/admin/login">管理员入口</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 24px;
|
||||
padding: 3rem 2.5rem;
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto 1.5rem;
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 10px 30px -6px rgba(99, 102, 241, 0.5);
|
||||
}
|
||||
|
||||
.icon-wrapper svg {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.card-header h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #fff 0%, #a5b4fc 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #94a3b8;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
padding: 1rem 1.25rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
color: #f8fafc;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.form-group input::placeholder {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
border-color: rgba(99, 102, 241, 0.5);
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.form-group select {
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2394a3b8'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 1rem center;
|
||||
background-size: 1.25rem;
|
||||
padding-right: 3rem;
|
||||
}
|
||||
|
||||
.form-group select option {
|
||||
background: #1e293b;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.875rem 1rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: 10px;
|
||||
color: #fca5a5;
|
||||
font-size: 0.9rem;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.error-message svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-5px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: 1rem;
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 30px -6px rgba(99, 102, 241, 0.5);
|
||||
}
|
||||
|
||||
.submit-btn:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.admin-link {
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.admin-link a {
|
||||
color: #94a3b8;
|
||||
font-size: 0.9rem;
|
||||
text-decoration: none;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.admin-link a:hover {
|
||||
color: #a5b4fc;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.login-card {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.card-header h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -22,6 +22,11 @@ export default defineConfig({
|
|||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||
},
|
||||
proxy: {
|
||||
// 后端认证 API 代理
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/tts-api': {
|
||||
target: 'https://openspeech.bytedance.com',
|
||||
changeOrigin: true,
|
||||
|
|
|
|||
Loading…
Reference in New Issue