feat: 添加后端认证系统和路由守卫

This commit is contained in:
cc 2026-03-27 20:37:02 +08:00
parent 80874f6e07
commit 2074482f44
22 changed files with 3814 additions and 4 deletions

View File

@ -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
- **数据库**SQLite3better-sqlite3 同步 API
- **认证**JWTjsonwebtoken
- **密码加密**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: 确保新代码与现有代码风格一致,遵循项目约定

311
package-lock.json generated
View File

@ -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"
}
}
}
}

View File

@ -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"
}
}

12
server/.env Normal file
View File

@ -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

12
server/.env.example Normal file
View File

@ -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

BIN
server/data/auth.db Normal file

Binary file not shown.

187
server/database.js Normal file
View File

@ -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 };

81
server/index.js Normal file
View File

@ -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();

68
server/middleware/auth.js Normal file
View File

@ -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();
}

View File

@ -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;
}
});

1026
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
server/package.json Normal file
View File

@ -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"
}
}

125
server/routes/admin.js Normal file
View File

@ -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;

74
server/routes/auth.js Normal file
View File

@ -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;

67
server/utils/jwt.js Normal file
View File

@ -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;
}

View File

@ -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

178
src/utils/auth.js Normal file
View File

@ -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;

299
src/views/AdminLogin.vue Normal file
View File

@ -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>

684
src/views/AdminPanel.vue Normal file
View File

@ -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>

View File

@ -80,7 +80,7 @@ const features = ref([
{
id: 10,
title: "音频转文字",
desc: "输入音频文件URLAI自动识别并转换为文本支持中英日多语种适用于会议记录、采访整理等场景。",
desc: "输入音频URL智能识别转文字支持中英日多语种、最长12小时长音频AI自动优化排版格式。",
class: "card-10",
icon: "file-audio",
route: "/audio-to-text",

306
src/views/LoginPage.vue Normal file
View File

@ -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>

View File

@ -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,