Compare commits

..

1 Commits
main ... auth

Author SHA1 Message Date
cc 2074482f44 feat: 添加后端认证系统和路由守卫 2026-03-27 20:37:02 +08:00
34 changed files with 4172 additions and 8165 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: 确保新代码与现有代码风格一致,遵循项目约定

View File

@ -1,250 +0,0 @@
---
name: 试题讲解生成页面开发
overview: 开发一个试题讲解生成页面用户输入试题后系统调用AI文本模型分析试题复杂度并拆分为多个讲解点然后为每个讲解点调用豆包图片生成模型创建讲解图片最后调用阿里云语音合成模型为每张图片生成讲解音频实现幻灯片式自动播放。
design:
architecture:
framework: vue
styleKeywords:
- Glassmorphism
- Dark Theme
- Modern
- Smooth Animations
fontSystem:
fontFamily: PingFang SC
heading:
size: 32px
weight: 600
subheading:
size: 18px
weight: 500
body:
size: 16px
weight: 400
colorSystem:
primary:
- "#8b5cf6"
- "#6366f1"
background:
- "#0f172a"
- "#1e293b"
text:
- "#f8fafc"
- "#94a3b8"
functional:
- "#10b981"
- "#ef4444"
- "#f59e0b"
todos:
- id: add-api-config
content: 在config/index.js中添加试题讲解API配置文本分析、图片生成、音频合成
status: completed
- id: create-page-component
content: 创建QuestionExplanation.vue页面组件实现试题输入和状态管理
status: completed
dependencies:
- add-api-config
- id: implement-analysis-logic
content: 实现试题分析逻辑,调用文本模型拆分讲解点并生成内容
status: completed
dependencies:
- create-page-component
- id: implement-resource-generation
content: 实现图片生成和音频合成的批量生成逻辑
status: completed
dependencies:
- implement-analysis-logic
- id: implement-slideshow-player
content: 实现幻灯片播放器和音频同步播放逻辑
status: completed
dependencies:
- implement-resource-generation
- id: add-route-and-card
content: 在router添加路由配置并在HomePage添加功能卡片
status: completed
dependencies:
- create-page-component
---
## 产品概述
开发一个智能试题讲解生成页面,实现根据试题内容自动生成图文并茂的幻灯片式讲解。
## 核心功能
- 试题输入:提供文本框供用户输入试题内容
- 智能分析调用AI文本模型分析试题复杂度动态决定讲解点数量3-8个
- 内容生成:为每个讲解点生成详细讲解文本和对应的图片生成提示词
- 图片生成调用豆包AI图片生成模型批量生成讲解图片支持组图生成
- 音频合成:调用阿里云语音合成模型,为每个讲解文本生成配套音频
- 幻灯片播放:实现自动播放逻辑,图片与音频同步,音频播完自动切换下一张
- 播放控制:支持播放/暂停、上一张/下一张、进度指示器等交互控制
## 技术栈
- 前端框架Vue 3 + Composition API
- 路由管理Vue Router
- HTTP客户端Axios
- 样式方案Scoped CSS + Glassmorphism UI风格与现有项目保持一致
- API调用前端直接调用演示模式
## 技术架构
### 系统流程
```mermaid
graph TD
A[用户输入试题] --> B[调用文本模型分析]
B --> C{分析试题复杂度}
C -->|简单| D[生成3个讲解点]
C -->|中等| E[生成5个讲解点]
C -->|复杂| F[生成7-8个讲解点]
D --> G[为每个讲解点生成详细文本和图片提示词]
E --> G
F --> G
G --> H[批量生成图片]
G --> I[批量合成音频]
H --> J[加载图片资源]
I --> K[加载音频资源]
J --> L[幻灯片播放]
K --> L
L --> M{音频播放完成?}
M -->|是| N[切换下一张]
N --> L
M -->|否| L
```
### 模块划分
1. **输入模块**:试题文本输入框、提交按钮、清空按钮
2. **处理模块**试题分析、讲解文本生成、图片prompt生成
3. **资源生成模块**图片生成API调用、音频合成API调用
4. **播放模块**:幻灯片展示、音频播放、自动切换逻辑
5. **控制模块**:播放/暂停、上下切换、进度指示
### API集成
**文本分析模型**doubao-seed-2-0-lite-260215
- 用途:分析试题复杂度、拆分讲解点、生成讲解文本和图片提示词
- APIhttps://ark.cn-beijing.volces.com/api/v3/chat/completions
- 认证:使用 DOUBAO_KEY
**图片生成模型**doubao-seedream-5-0-260128
- 用途:批量生成讲解图片(支持组图生成)
- APIhttps://ark.cn-beijing.volces.com/api/v3/images/generations
- 认证:使用 DOUBAO_KEY
- 特性sequential_image_generation="auto"max_images=8
**语音合成模型**qwen3-tts-flash
- 用途:为讲解文本生成音频
- APIhttps://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation
- 认证:使用 BAILIAN_API_KEY
- 音色Cherry芊悦阳光积极的小姐姐音色
## 实现要点
### 性能优化
- 图片生成采用组图模式一次API调用生成所有图片减少网络请求
- 音频合成采用并发请求,提升生成速度
- 资源预加载:在播放第一张时预加载后续图片和音频
- 使用URL.createObjectURL管理音频资源避免内存泄漏
### 错误处理
- API调用失败重试机制最多重试2次
- 部分资源生成失败时,显示占位图/文本提示,不影响其他讲解点播放
- 超时处理图片生成60秒超时音频生成30秒超时
### 播放逻辑
- 使用HTML5 Audio API监听'ended'事件触发自动切换
- 当前音频播放完毕后立即切换到下一张图片并播放对应音频
- 播放完成后显示结束状态,提供重新播放按钮
### 状态管理
```
- idle: 初始状态
- analyzing: 正在分析试题
- generating: 正在生成图片和音频
- ready: 资源准备完成,等待播放
- playing: 正在播放
- paused: 暂停
- completed: 播放完成
- error: 错误状态
```
## 目录结构
### 新增文件
```
src/
├── views/
│ └── QuestionExplanation.vue # [NEW] 试题讲解页面主组件
├── config/
│ └── index.js # [MODIFY] 添加新API配置
├── router/
│ └── index.js # [MODIFY] 添加路由配置
└── views/
└── HomePage.vue # [MODIFY] 添加功能卡片
```
### 详细文件说明
**QuestionExplanation.vue** - 试题讲解页面主组件
- 实现试题输入界面
- 调用文本模型分析试题并生成讲解内容
- 调用图片生成API批量生成讲解图片
- 调用语音合成API生成配套音频
- 实现幻灯片式自动播放逻辑
- 提供播放控制(播放/暂停、上下切换、进度指示)
- 采用glassmorphism UI风格深色主题
**config/index.js** - 添加API配置
- 试题讲解文本分析API配置
- 图片生成API配置已有部分配置可能需要补充
- 语音合成API配置已有部分配置可能需要补充
**router/index.js** - 添加路由
- 路径:/question-explanation
- 组件QuestionExplanation
**HomePage.vue** - 添加功能卡片
- 标题AI试题讲解生成
- 描述:智能分析试题,自动生成图文讲解,幻灯片式播放,让解题过程清晰易懂
- 图标presentation/slides相关图标
- 路由:/question-explanation
## 设计风格
采用现代深色主题 + Glassmorphism玻璃态设计风格与现有项目保持视觉一致性。
## 页面布局
采用垂直布局,分为三个主要区域:
1. **顶部输入区**:标题 + 试题输入框 + 操作按钮
2. **中间展示区**:幻灯片播放区域(图片 + 讲解文本)
3. **底部控制区**:播放控制按钮 + 进度指示器
## 视觉特点
- 深色背景(#0f172a配合玻璃态卡片
- 渐变色彩点缀(紫色/蓝色渐变)
- 流畅的过渡动画
- 响应式设计,适配不同屏幕尺寸
## 交互设计
- 加载状态:骨架屏 + 进度提示
- 播放状态:当前幻灯片高亮,进度条动态更新
- 控制按钮hover效果 + 点击反馈
- 平滑过渡:图片切换使用淡入淡出动画

View File

@ -1,144 +0,0 @@
---
name: 试题讲解视频生成功能开发
overview: 在 QuestionExplanation.vue 页面添加视频生成功能,将已生成的图片和音频合成为完整视频,支持在线预览、下载,并显示详细的生成进度。
design:
architecture:
framework: vue
styleKeywords:
- Dark Theme
- Glassmorphism
- Minimalist
fontSystem:
fontFamily: PingFang SC
heading:
size: 1.25rem
weight: 600
subheading:
size: 1rem
weight: 500
body:
size: 0.875rem
weight: 400
colorSystem:
primary:
- "#8b5cf6"
- "#3b82f6"
background:
- "#0f172a"
- "#1e293b"
text:
- "#ffffff"
- rgba(255,255,255,0.6)
functional:
- "#22c55e"
- "#ef4444"
todos:
- id: create-composer
content: 创建 videoComposer.js 封装 ffmpeg.wasm 合成逻辑
status: completed
- id: add-video-config
content: 在 config/index.js 添加视频相关配置
status: completed
- id: implement-compose-ui
content: 在 QuestionExplanation.vue 添加合成按钮和进度显示
status: completed
dependencies:
- create-composer
- add-video-config
- id: implement-preview-download
content: 实现视频预览和下载功能
status: completed
dependencies:
- implement-compose-ui
- id: integrate-workflow
content: 整合视频合成到现有生成流程
status: completed
dependencies:
- implement-preview-download
---
## 产品概述
在现有试题讲解页面基础上,新增视频合成功能,将已生成的图片和音频合成为完整的教学讲解视频。
## 核心功能
- 将多个讲解点的图片和音频按顺序合成为单一视频文件
- 视频生成过程中显示详细进度(资源下载、片段合成、视频编码等步骤)
- 支持在线预览生成的视频
- 支持下载视频到本地
## 技术栈
- 视频合成ffmpeg.wasm已安装 @ffmpeg/ffmpeg@0.12.15
- 现有框架Vue 3 + Vite
- HTTP 请求axios已有
## 技术架构
### 实现方案
使用 ffmpeg.wasm 在浏览器端完成视频合成,无需后端服务:
```mermaid
flowchart LR
A[slides数据] --> B[下载图片/音频]
B --> C[写入FFmpeg虚拟文件系统]
C --> D[生成视频片段]
D --> E[合并所有片段]
E --> F[输出MP4视频]
F --> G[预览/下载]
```
### 核心流程
1. **资源准备阶段**:遍历 slides 数组fetch 所有图片和音频到内存
2. **文件系统写入**:将资源写入 ffmpeg.wasm 的虚拟文件系统
3. **片段生成**:每个 slide 生成一个视频片段(图片+音频)
4. **视频合并**:使用 concat demuxer 将所有片段合并为完整视频
5. **输出展示**:生成 Blob URL 用于预览和下载
### 性能考量
- 音频时长检测:需要预先获取每个音频时长,用于视频片段时长控制
- 内存管理:大文件处理时注意释放内存
- 进度反馈:通过 ffmpeg 日志解析当前进度
## 目录结构
```
src/
├── views/
│ └── QuestionExplanation.vue # [MODIFY] 添加视频合成功能
├── utils/
│ └── videoComposer.js # [NEW] ffmpeg.wasm 封装模块
└── config/
└── index.js # [MODIFY] 添加视频相关配置
```
## 实现要点
### videoComposer.js 核心接口
- `initFFmpeg()`: 初始化并加载 ffmpeg.wasm
- `composeVideo(slides, onProgress)`: 主合成函数,返回视频 Blob
- `downloadVideo(blob, filename)`: 触发下载
- `getAudioDuration(audioUrl)`: 获取音频时长
### 状态管理
新增状态:
- `videoStatus`: idle | preparing | composing | ready | error
- `videoProgress`: { stage: string, current: number, total: number, message: string }
- `videoBlobUrl`: 合成完成的视频 URL
## 设计说明
在现有播放器界面基础上,新增视频合成功能区,位于播放控制区域下方。采用深色主题风格与现有界面保持一致。
## 新增区块
1. **视频合成按钮区**:在播放控制区底部添加"生成视频"按钮
2. **进度显示区**:合成过程中显示分步骤进度条和当前状态描述
3. **视频预览区**:合成完成后显示视频播放器和下载按钮

156
DEPLOY.md
View File

@ -4,7 +4,7 @@
- **项目名称**AI 英语学习辅助平台 - **项目名称**AI 英语学习辅助平台
- **技术栈**Vue 3 + Vite + Vue RouterHistory 模式) - **技术栈**Vue 3 + Vite + Vue RouterHistory 模式)
- **项目结构**:纯前端静态文件HTML / CSS / JS可部署到任意静态托管服务 - **建产物**纯静态文件HTML / CSS / JS可部署到任意静态托管服务
--- ---
@ -12,13 +12,12 @@
### 1.1 环境要求 ### 1.1 环境要求
| 工具 | 版本要求 | 用途 | | 工具 | 版本要求 |
|------|----------|------| |------|----------|
| Node.js | >= 18.x推荐 20.x LTS | 前端构建 | | Node.js | >= 18.x推荐 20.x LTS |
| npm | >= 9.x | 包管理器 | | npm | >= 9.x |
```bash ```bash
# 检查 Node.js 和 npm 版本
node -v node -v
npm -v npm -v
``` ```
@ -26,7 +25,6 @@ npm -v
### 1.2 安装依赖 ### 1.2 安装依赖
```bash ```bash
# 安装前端依赖
cd AI_Demo cd AI_Demo
npm install npm install
``` ```
@ -52,7 +50,7 @@ npm install
### 2.2 跨域响应头要求(重要) ### 2.2 跨域响应头要求(重要)
项目使用了 `@ffmpeg/ffmpeg`WebAssembly需要服务器返回以下响应头 项目使用了 `@ffmpeg/ffmpeg`WebAssembly需要服务器返回以下响应头,否则视频讲解功能将无法运行
``` ```
Cross-Origin-Opener-Policy: same-origin Cross-Origin-Opener-Policy: same-origin
@ -65,12 +63,12 @@ Cross-Origin-Embedder-Policy: require-corp
项目在开发环境通过 Vite 代理转发以下 API 请求,**生产环境必须在服务器/网关层配置对应的反向代理** 项目在开发环境通过 Vite 代理转发以下 API 请求,**生产环境必须在服务器/网关层配置对应的反向代理**
| 前端请求路径前缀 | 代理目标 | 用途 | 备注 | | 前端请求路径前缀 | 代理目标 | 用途 |
|-----------------|----------|------|------| |-----------------|----------|------|
| `/tts-api` | `https://openspeech.bytedance.com` | 豆包 TTS 语音合成 | 需要反向代理 | | `/tts-api` | `https://openspeech.bytedance.com` | 豆包 TTS 语音合成 |
| `/ark-api` | `https://ark.cn-beijing.volces.com` | 火山引擎 Ark 大模型 | 需要反向代理 | | `/ark-api` | `https://ark.cn-beijing.volces.com` | 火山引擎 Ark 大模型 |
| `/dashscope-api` | `https://dashscope.aliyuncs.com` | 阿里云百炼 | 需要反向代理 | | `/dashscope-api` | `https://dashscope.aliyuncs.com` | 阿里云百炼 |
| `/asr-ws` | `wss://openspeech.bytedance.com` | 豆包 ASR WebSocket | 需要反向代理+注入鉴权 Header | | `/asr-ws` | `wss://openspeech.bytedance.com` | 豆包 ASR WebSocket |
> **ASR WebSocket 特殊说明**`/asr-ws` 代理需要在代理层注入以下鉴权 Header浏览器原生 WebSocket 不支持自定义 Header > **ASR WebSocket 特殊说明**`/asr-ws` 代理需要在代理层注入以下鉴权 Header浏览器原生 WebSocket 不支持自定义 Header
> - `X-Api-App-Key` > - `X-Api-App-Key`
@ -82,8 +80,6 @@ Cross-Origin-Embedder-Policy: require-corp
## 三、构建生产包 ## 三、构建生产包
### 3.1 前端构建
```bash ```bash
npm run build npm run build
``` ```
@ -131,7 +127,7 @@ server {
root /var/www/ai-demo; root /var/www/ai-demo;
index index.html; index index.html;
# 跨域响应头 # 跨域响应头FFmpeg WASM 必需)
add_header Cross-Origin-Opener-Policy "same-origin" always; add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always; add_header Cross-Origin-Embedder-Policy "require-corp" always;
@ -184,7 +180,6 @@ server {
} }
} }
``` ```
```
#### 4.A.3 启用配置并重启 Nginx #### 4.A.3 启用配置并重启 Nginx
@ -209,11 +204,17 @@ certbot --nginx -d your-domain.com
--- ---
### 方案 BNode.js + Express 部署 ### 方案 BNode.js + Express 静态服务器
适用于需要在 Node.js 环境中自定义响应头的场景。 适用于需要在 Node.js 环境中自定义响应头的场景。
创建前端静态服务器文件 `frontend-server.js`(项目根目录): #### 4.B.1 安装依赖
```bash
npm install express http-proxy-middleware
```
#### 4.B.2 创建服务器文件 `server.js`
```js ```js
import express from 'express' import express from 'express'
@ -226,7 +227,14 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url))
const app = express() const app = express()
const PORT = process.env.PORT || 3000 const PORT = process.env.PORT || 3000
// 第三方 API 代理 // 全局注入 COOP/COEP 响应头FFmpeg WASM 必需)
app.use((req, res, next) => {
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin')
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp')
next()
})
// API 代理
app.use('/tts-api', createProxyMiddleware({ app.use('/tts-api', createProxyMiddleware({
target: 'https://openspeech.bytedance.com', target: 'https://openspeech.bytedance.com',
changeOrigin: true, changeOrigin: true,
@ -269,38 +277,22 @@ app.get('*', (req, res) => {
}) })
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Frontend server running at http://localhost:${PORT}`) console.log(`Server running at http://localhost:${PORT}`)
}) })
``` ```
#### 4.B.3 安装前端服务器依赖 #### 4.B.3 启动服务
```bash ```bash
# 在项目根目录安装 DOUBAO_APP_ID=xxx DOUBAO_ACCESS_TOKEN=xxx node server.js
npm install express http-proxy-middleware
``` ```
#### 4.B.4 启动前端服务器
```bash
# 在项目根目录启动
DOUBAO_APP_ID=xxx DOUBAO_ACCESS_TOKEN=xxx pm2 start frontend-server.js --name ai-demo-frontend
# 查看两个服务的状态
pm2 status
# 设置开机自启
pm2 save
```
前端服务运行在 `http://localhost:3000`(提供静态文件和代理)
--- ---
### 方案 CEdgeOne Pages / Vercel / Netlify纯静态托管 ### 方案 CEdgeOne Pages / Vercel / Netlify纯静态托管
> **注意**:此类平台**不支持服务端代理**`/tts-api`、`/ark-api`、`/dashscope-api`、`/asr-ws` 等接口将因跨域或缺少鉴权 Header 而失败。 > **注意**:此类平台**不支持服务端代理**`/tts-api`、`/ark-api`、`/dashscope-api`、`/asr-ws` 等接口将因跨域或缺少鉴权 Header 而失败。
> 建议仅用于演示。 > 建议仅用于演示或配合独立后端服务使用。
#### 部署步骤(以 Vercel 为例) #### 部署步骤(以 Vercel 为例)
@ -328,11 +320,9 @@ pm2 save
## 五、部署后验证清单 ## 五、部署后验证清单
### 5.1 部署验证
| 验证项 | 检查方法 | | 验证项 | 检查方法 |
|--------|----------| |--------|----------|
| 页面正常加载 | 访问首页,确认功能卡片显示正常 | | 页面正常加载 | 访问首页,确认 6 个功能卡片显示正常 |
| 路由刷新不 404 | 直接访问 `/pronunciation`、`/speaking` 等子路由 | | 路由刷新不 404 | 直接访问 `/pronunciation`、`/speaking` 等子路由 |
| COOP/COEP 响应头 | 浏览器 DevTools → Network → 查看 index.html 响应头 | | COOP/COEP 响应头 | 浏览器 DevTools → Network → 查看 index.html 响应头 |
| TTS 语音合成 | 进入发音练习页,测试语音播放 | | TTS 语音合成 | 进入发音练习页,测试语音播放 |
@ -345,7 +335,7 @@ pm2 save
## 六、常见问题排查 ## 六、常见问题排查
### Q1页面报错 `SharedArrayBuffer is not defined` ### Q1视频讲解页面报错 `SharedArrayBuffer is not defined`
**原因**:服务器未返回 `Cross-Origin-Opener-Policy: same-origin``Cross-Origin-Embedder-Policy: require-corp` 响应头。 **原因**:服务器未返回 `Cross-Origin-Opener-Policy: same-origin``Cross-Origin-Embedder-Policy: require-corp` 响应头。
**解决**:参考第二节 2.2,在 Nginx 或 Node.js 服务器中添加对应响应头。 **解决**:参考第二节 2.2,在 Nginx 或 Node.js 服务器中添加对应响应头。
@ -367,7 +357,7 @@ pm2 save
### Q5构建时内存不足 ### Q5构建时内存不足
**原因**:项目依赖较多,构建产物较大。 **原因**:项目包含 FFmpeg WASM,构建产物较大。
**解决** **解决**
```bash ```bash
@ -387,17 +377,11 @@ NODE_OPTIONS=--max-old-space-size=4096 npm run build
| `/essay-correction` | 作文批改 | 图片/文本作文批改 | | `/essay-correction` | 作文批改 | 图片/文本作文批改 |
| `/exam-analysis` | 试题分析 | 图片题目分析解答 | | `/exam-analysis` | 试题分析 | 图片题目分析解答 |
| `/spell-practice` | 单词拼写 | 单词拼写练习检测 | | `/spell-practice` | 单词拼写 | 单词拼写练习检测 |
| `/problem-solving` | 解题指导 | AI 解题步骤指导 |
| `/question-generator` | 题目生成 | 自动生成练习题 |
| `/question-variant` | 题目变体 | 生成相似题目变体 |
| `/audio-to-text` | 语音转文字 | 音频转文本功能 |
--- ---
## 八、依赖版本参考 ## 八、依赖版本参考
### 前端依赖package.json
| 依赖包 | 版本 | | 依赖包 | 版本 |
|--------|------| |--------|------|
| vue | ^3.5.30 | | vue | ^3.5.30 |
@ -409,12 +393,14 @@ NODE_OPTIONS=--max-old-space-size=4096 npm run build
| marked | ^17.0.5 | | marked | ^17.0.5 |
| pako | ^2.1.0 | | pako | ^2.1.0 |
---
## 九、目录结构参考 ## 九、目录结构参考
``` ```
AI_Demo/ AI_Demo/
├── dist/ # 前端构建产物(部署此目录) ├── dist/ # 构建产物(部署此目录)
├── src/ # 前端源码 ├── src/
│ ├── config/index.js # API 密钥配置(部署前确认) │ ├── config/index.js # API 密钥配置(部署前确认)
│ ├── router/index.js # 路由定义 │ ├── router/index.js # 路由定义
│ ├── views/ # 页面组件 │ ├── views/ # 页面组件
@ -423,69 +409,11 @@ AI_Demo/
│ │ ├── Speaking.vue # 口语对话 │ │ ├── Speaking.vue # 口语对话
│ │ ├── EssayCorrection.vue # 作文批改 │ │ ├── EssayCorrection.vue # 作文批改
│ │ ├── ExamAnalysis.vue # 试题分析 │ │ ├── ExamAnalysis.vue # 试题分析
│ │ ├── SpellPractice.vue # 单词拼写 │ │ └── SpellPractice.vue # 单词拼写
│ │ ├── ProblemSolving.vue # 解题指导
│ │ ├── QuestionGenerator.vue # 题目生成
│ │ ├── QuestionVariant.vue # 题目变体
│ │ └── AudioToText.vue # 语音转文字
│ ├── components/ # 公共组件 │ ├── components/ # 公共组件
│ ├── assets/ # 静态资源 │ ├── assets/ # 静态资源
│ └── MainLayout.vue # 布局组件 │ └── MainLayout.vue # 布局组件
├── vite.config.js # 开发代理配置(生产环境需在服务器复现) ├── vite.config.js # 开发代理配置(生产环境需在服务器复现)
├── package.json # 前端依赖配置 ├── package.json
└── DEPLOY.md # 本文档 └── DEPLOY.md # 本文档
``` ```
---
## 十、Docker 部署(可选)
### 10.1 构建镜像
```dockerfile
FROM node:20-alpine
WORKDIR /app
# 复制依赖文件
COPY package*.json ./
RUN npm ci --only=production
# 复制源码
COPY . ./
# 构建
RUN npm run build
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
```
---
## 十一、安全建议
1. **HTTPS 强制**:生产环境务必启用 HTTPS
2. **API 密钥保护**:不要将 API 密钥提交到代码仓库
3. **输入验证**:对用户输入进行验证和过滤
4. **日志审计**:记录关键操作和错误信息
---
## 十二、更新与维护
### 更新前端
```bash
git pull
npm install
npm run build
# 重新部署 dist/ 目录
```
---
**文档版本**v2.1
**更新日期**2025-04-16
**更新内容**:移除视频讲解功能及相关后端服务

View File

@ -5,8 +5,6 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI 英语学习辅助平台</title> <title>AI 英语学习辅助平台</title>
<!-- COEP 策略下暂时禁用 umami需要服务端支持 CORP 响应头) -->
<!-- <script defer src="https://umami.23544.com/script.js" data-website-id="758c5bd3-4189-4dcb-a2a8-21531c92dcd8"></script> -->
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

311
package-lock.json generated
View File

@ -19,6 +19,7 @@
"devDependencies": { "devDependencies": {
"@types/pako": "^2.0.4", "@types/pako": "^2.0.4",
"@vitejs/plugin-vue": "^6.0.5", "@vitejs/plugin-vue": "^6.0.5",
"concurrently": "^9.1.2",
"vite": "^8.0.0" "vite": "^8.0.0"
} }
}, },
@ -693,6 +694,32 @@
"node": ">=0.4.0" "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": { "node_modules/ast-kit": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmmirror.com/ast-kit/-/ast-kit-2.2.0.tgz", "resolved": "https://registry.npmmirror.com/ast-kit/-/ast-kit-2.2.0.tgz",
@ -764,6 +791,36 @@
"node": ">= 0.4" "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": { "node_modules/chokidar": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz", "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz",
@ -779,6 +836,41 @@
"url": "https://paulmillr.com/funding/" "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": { "node_modules/combined-stream": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
@ -791,6 +883,31 @@
"node": ">= 0.8" "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": { "node_modules/confbox": {
"version": "0.2.4", "version": "0.2.4",
"resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.2.4.tgz", "resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.2.4.tgz",
@ -836,6 +953,13 @@
"node": ">= 0.4" "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": { "node_modules/entities": {
"version": "7.0.1", "version": "7.0.1",
"resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz",
@ -893,6 +1017,16 @@
"node": ">= 0.4" "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": { "node_modules/estree-walker": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
@ -982,6 +1116,16 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/get-intrinsic": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@ -1031,6 +1175,16 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/has-symbols": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
@ -1076,6 +1230,16 @@
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
"license": "MIT" "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": { "node_modules/jsesc": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz",
@ -1607,6 +1771,16 @@
"url": "https://paulmillr.com/funding/" "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": { "node_modules/rolldown": {
"version": "1.0.0-rc.9", "version": "1.0.0-rc.9",
"resolved": "https://registry.npmmirror.com/rolldown/-/rolldown-1.0.0-rc.9.tgz", "resolved": "https://registry.npmmirror.com/rolldown/-/rolldown-1.0.0-rc.9.tgz",
@ -1648,12 +1822,35 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/scule": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmmirror.com/scule/-/scule-1.3.0.tgz", "resolved": "https://registry.npmmirror.com/scule/-/scule-1.3.0.tgz",
"integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==",
"license": "MIT" "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": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
@ -1663,6 +1860,50 @@
"node": ">=0.10.0" "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": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz",
@ -1679,13 +1920,22 @@
"url": "https://github.com/sponsors/SuperchupuDev" "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": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true, "dev": true,
"license": "0BSD", "license": "0BSD"
"optional": true
}, },
"node_modules/ufo": { "node_modules/ufo": {
"version": "1.6.3", "version": "1.6.3",
@ -1874,6 +2124,34 @@
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"license": "MIT" "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": { "node_modules/yaml": {
"version": "2.8.2", "version": "2.8.2",
"resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.8.2.tgz", "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.8.2.tgz",
@ -1888,6 +2166,35 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/eemeli" "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,9 +6,15 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "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": { "dependencies": {
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
"axios": "^1.13.6", "axios": "^1.13.6",
"marked": "^17.0.5", "marked": "^17.0.5",
"pako": "^2.1.0", "pako": "^2.1.0",
@ -18,6 +24,7 @@
"devDependencies": { "devDependencies": {
"@types/pako": "^2.0.4", "@types/pako": "^2.0.4",
"@vitejs/plugin-vue": "^6.0.5", "@vitejs/plugin-vue": "^6.0.5",
"concurrently": "^9.1.2",
"vite": "^8.0.0" "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;
}

Binary file not shown.

View File

@ -226,4 +226,3 @@ export const AUDIO_TEXT_OPTIMIZE_PROMPT = `你是一位专业的文字编辑和
- 只输出优化后的文本不要添加"优化后文本:"等前缀 - 只输出优化后的文本不要添加"优化后文本:"等前缀
- 保持原文语言中文/英文/日语 - 保持原文语言中文/英文/日语
- 如果原文已经很好只需做必要的格式调整`; - 如果原文已经很好只需做必要的格式调整`;

File diff suppressed because it is too large Load Diff

View File

@ -1,722 +0,0 @@
通过文生图API您可以基于文本描述创造出全新的图像。阿里云百炼提供两大系列模型
- 千问Qwen-Image: 擅长渲染复杂的中英文文本。
- 万相Wan系列: 用于生成写实图像和摄影级视觉效果。
**在线体验**[北京](https://bailian.console.aliyun.com/?tab=model#/efm/model_experience_center/vision?currentTab=imageGenerate&modelId=qwen-image)[新加坡](https://modelstudio.console.aliyun.com/?tab=dashboard#/efm/model_experience_center/vision?currentTab=imageGenerate)
## **模型效果**
#### **千问Qwen-image**
| **复杂布局** ![image (10)-2026-03-10-15-57-40](https://help-static-aliyun-doc.aliyuncs.com/assets/img/zh-CN/9024463771/p1058364.webp) | **超长段落** ![image (11)-2026-03-10-15-57-40](https://help-static-aliyun-doc.aliyuncs.com/assets/img/zh-CN/9024463771/p1058368.webp) | **写实人像** ![image (13)-2026-03-10-15-57-39](https://help-static-aliyun-doc.aliyuncs.com/assets/img/zh-CN/9024463771/p1058369.webp) |
| --- | --- | --- |
| **自然景观** ![image (12)-2026-03-10-15-57-39](https://help-static-aliyun-doc.aliyuncs.com/assets/img/zh-CN/9024463771/p1058371.webp) | **逻辑架构** ![image (14)-2026-03-10-15-57-38](https://help-static-aliyun-doc.aliyuncs.com/assets/img/zh-CN/9024463771/p1058372.webp) | **电商海报** ![fcd74cd8-c0f6-454b-93b1-e95f337127af-2026-03-10-16-25-42](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=) |
**点击查看提示词**
**复杂布局**:冬日北京的都市街景,青灰瓦顶、朱红色外墙的两间相邻中式商铺比肩而立,檐下悬挂印有剪纸马的暖光灯笼,在阴天漫射光中投下柔和光晕,映照湿润鹅卵石路面泛起细腻反光。左侧为书法店:靛蓝色老旧的牌匾上以遒劲行书刻着“**文字渲染**”。店门口的玻璃上挂着一幅字,自上而下,用田英章硬笔写着“**专业幻灯片 中英文海报 高级信息图**”,落款印章为“**1k token**”朱砂印。店内的墙上,可以模糊的辨认有三幅竖排的书法作品,第一幅写着着“**阿里巴巴**”,第二幅写着“**通义千问**”,第三福写着“**图像生成**”。一位白发苍苍的老人背对着镜头观赏。右侧为花店,牌匾上以鲜花做成文字“**真实质感**”;店内多层花架陈列红玫瑰、粉洋牡丹和绿植,门上贴了一个圆形花边标识,标识上写着“**2k resolution**”,门口摆放了一个彩色霓虹灯,上面写着“**细腻刻画 人物 自然 建筑**”。两家店中间堆放了一个雪人,举了一老式小黑板,上面用粉笔字写着“**Qwen-Image-2.0 正式发布**”。街道左侧,年轻情侣依偎在一起,女孩是瘦脸,身穿米白色羊绒大衣,肉色光腿神器。女孩举着心形透明气球,气球印有白色的字:“**生图编辑二合一**”。里面有一个毛茸茸的卡皮巴拉玩偶。男孩身着剪裁合体的深灰色呢子外套,内搭浅色高领毛衣。街道右侧,一个后背上写着“**更小模型,更快速度**”的骑手疾驰而过。整条街光影交织、动静相宜。
**超长段落**中国古典水墨长卷风格竖幅构图画面自上而下、自右向左以行书题写柳永《雨霖铃·寒蝉凄切》全文共12行含标点与换行“**寒蝉凄切,对长亭晚,骤雨初歇。都门帐饮无绪,留恋处、兰舟催发。执手相看泪眼,竟无语凝噎。念去去,千里烟波,暮霭沉沉楚天阔。多情自古伤离别,更那堪、冷落清秋节!今宵酒醒何处?杨柳岸,晓风残月。此去经年,应是良辰好景虚设。便纵有千种风情,更与何人说**?”书法墨色浓淡相宜,飞白自然,笔锋遒劲中见婉转,行气连贯如流水;字迹略带微洇,仿宣纸渗透效果。背景为极简留白水墨意境:右下角绘一叶孤舟泊于浅滩,舟头微翘,缆绳轻系枯柳;左侧远景以淡墨晕染出层叠低垂的暮霭与空阔楚天,天际线处一抹青灰远山若隐若现;近景岸边斜出三两枝细柳,枝条纤柔,叶已疏落,承袭清秋萧瑟之气;柳梢悬一弯将隐未隐的残月,清冷微光映照薄雾中拂面的晓风痕迹(以几缕轻扬的柳丝与水纹示意)。整幅画气息沉郁隽永,哀而不伤,严格遵循宋词意境与传统文人画\*\*“诗书画一体”\*\*范式,无印章、无题跋、无现代元素。
**写实人像**一位约20岁出头的亚洲年轻女性留着齐刘海、乌黑光滑的长直发自然垂落于双肩两侧。她侧坐于一张复古碎花布艺沙发上沙发图案为米白底色配粉色与绿色花卉质地略显陈旧带有生活感。她身穿一件宽松的浅绿色马海毛针织毛衣质感蓬松柔软下身搭配浅灰蓝色亚麻长裙整体造型清新自然、慵懒随性。右手轻轻握住一颗红色番茄抬至下巴附近姿态随意眼神直视镜头神情平静、略带冷淡带有一种漫不经心的疏离感。沙发右侧放有一个浅色陶盘盘内盛放着三至四颗饱满鲜红的番茄带有绿色蒂头色彩鲜艳与画面整体的冷绿色调形成强烈对比。背景为做旧的青绿色墙面斑驳而有质感。窗外射入的自然光形成明显的光束斜斜打在人物与背景上光影层次丰富。窗台及背景角落摆有数盆绿植左侧隐约可见一个深棕色老式木柜。整张照片色调偏冷绿叠有明显的胶片颗粒感与轻微漏光效果构图饱满氛围静谧、文艺带有强烈的复古胶片人文摄影风格。
**自然景观**一幅写实风格的夏日森林场景画面中央是一片幽深静谧的林间空地高大挺拔的橡树与山毛榉构成主体乔木层其浓密树冠呈现深邃厚重的墨绿色叶片表面带有细微的蜡质反光树冠间隙中透下柔和而强烈的阳光在空气中形成清晰可见的丁达尔光束光束边缘略带暖金色调与冷调绿影形成微妙对比。中景处一丛新生的枫树嫩枝舒展着鲜亮明快的翠绿色叶片叶脉清晰、半透明感强边缘微微卷曲仿佛刚经历晨露洗礼。前景左侧低矮的冬青与荚蒾灌木丛披覆着哑光柔和的橄榄绿色枝叶交错纹理细腻部分叶片背面泛出浅灰绿光泽。地面覆盖着厚实湿润的苔藓层由多种苔类组成近处是绒状垂穗藓呈现饱满润泽的青绿色表面凝结细小露珠稍远处为鳞叶藓与泥炭藓交织显出微带蓝调的灰青绿与棕绿过渡腐叶层隐约可见呈深褐与墨绿混融的有机质感。所有植被表面均带有自然微湿反光空气中有极细微的悬浮微粒在光束中浮动。背景林区渐次虚化保留层次但不抢主体远景融入一层薄薄的蓝绿雾霭。整体光影为上午10点左右的斜射日光明暗对比适中绿色系通过23种以上不同明度、饱和度、冷暖倾向与材质表现如蜡质、绒面、革质、胶质精确区分毫无重复感营造出丰饶、呼吸感强烈、充满生物细节与生态真实性的夏日森林秘境。
**逻辑架构:**一幅充满生活气息的插画,采用细腻、柔和的画风,色彩鲜艳且层次丰富,呈现出阳光明媚的街头景象,整体氛围轻松愉快。画面中是一条热闹的商业街道,天空湛蓝,点缀着几朵蓬松白云,几只海鸥在空中自由翱翔,为画面增添动感与生机。街道两旁的建筑风格现代而富有特色,外墙色彩明快,墙上悬挂着多个招牌,分别写着“**阿里巴巴**”、“**百炼**”、“**文生图**”,字体清晰可辨,排列错落有致,营造出浓厚的商业氛围。街道上人流如织,展现出繁忙而温馨的日常场景。画面前景中,一个穿着白色衬衫和短裤的男孩正站在一个摆满商品的货摊前专注挑选。他神情认真,身体微微前倾,体现出对商品的兴趣。货摊上陈列着各类饮料、零食和日用品,摆放整齐,细节丰富。摊主是一名中年男子,身穿深色围裙,神情专注地整理商品或与顾客交流,展现出市井生活的亲切感。货摊上方悬挂着一块木质标牌,清晰写着“**Qwen-Image**”,字体为手写风格,增添艺术感。整个场景通过细腻的描绘和温暖的色调,展现了日常生活中那些简单却美好的瞬间。画面风格为现实主义插画风格,带有轻微的手绘质感,强调光影与细节表现,整体构图饱满,空间感强。
**电商海报:**一张高质量的 C4D 风格电商海报,清新蓝色调。画面顶部为巨大的立体艺术字体 “**天猫 双十一 预售来了**”,极具视觉张力。主体是一袋蓝色包装的 “**萌宠家园**” 宠物粮包装袋有透明窗口展示诱人的肉块旁边有一只可爱的3D建模小猫。场景中点缀着精致的动物小模型和蓝色的科技感机械装置营造热闹的促销氛围。底部醒目显示红色立体字 “**全场满 399 元减 99**”。明亮的商业工作室灯光,超高清,渲染细腻,质感通透,构图严谨。"
#### **万相**
| **人像写真** ![p1023408-转换自-png](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=) | **写实摄影** ![p1023409-转换自-png](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=) | **绘画流派** ![p1023411](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=) |
| --- | --- | --- |
| **文字生成** ![p1023399](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=) | **海报设计** ![image.png](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=) | **组图生成** ![p1023424-转换自-png](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=) |
**点击查看提示词**
**人像写真**:照片,摄影人像,写实人像:背景是故宫红墙,女子身穿黑色旗袍,手握扇子,长曝光摄影,王家卫电影感,故事感,人来人往,迷离的光线,形成迷离的轨迹,柔焦摄影,眼神深邃神秘,艺术气息十足。
**写实摄影**:写实摄影,一只狐狸在森林中凝视镜头,鱼眼视角带来强烈的透视效果,毛发细节清晰,背景树木呈圆形拉伸,水彩风格,柔和色调。
**绘画流派**:一束野花插在旧陶罐中,背景是乡村厨房,印象派风格,柔和笔触,温暖光线,油画质感
**文字生成:**毛笔水墨画风格,宣纸纹理清晰可见,淡墨晕染出朦胧的客厅轮廓。一位身着素色长裙的东方少女盘坐于虚化边缘的旧式布艺沙发上,侧脸低垂,手持一卷展开的诗稿,窗外竹影婆娑,微风拂动帘栊。画面大量留白,右侧题有小楷诗句“闲坐悲双鬓,幽梦入青烟”,左下角钤朱文印章。墨色浓淡相宜,飞白笔触勾勒出光影流动感,意境空寂深远,似有古琴余音缭绕其间。
**海报设计:**扁平几何插画风格,一张端午节海报,杂志封面,色调与背景:以粉色渐变为主色调,营造出柔和且富有节日氛围的背景,奠定温馨且传统的基调。 文字元素:绿色字体搭配阴影效果,主文案突出"DRAGONBOAT FESTIVAL"与"端午"分两行不分开,正文信息下方"2025/05/31"、"农历五月初五"突出端午数字时间信息"2025/05/31"。 主体图案: 一艘绿色龙身搭配粉色龙鳍的龙船,高饱和色调,色彩对比强烈,高周围点缀祥云元素,船上坐着人物,进一步呼应赛龙舟的场景,增添节日活力。 细节点缀:添加 "中国传统节日" 字样,搭配小型粽子图标,丰富文化细节。高级简约排版方式,大师杰出作品。简约,时尚,大气,新中式传统海报,字体不要有阴影样式。
**组图生成**四宫格日系Q版漫画赛璐璐风格。第一格戴黑框眼镜的程序员面对屏幕弹出的红色报错瞳孔地震冷汗飞溅背景变为裂开的像素深渊。第二格他撸起袖子敲击键盘自信挑眉头顶冒出“这不过是五行代码的事”对话框。第三格屏幕布满混乱的报错符号他头发炸立眼圈发黑椅子后仰45度天花板飘满废弃的流程图。第四格误删一行灰色注释后绿色对勾闪现他歪头呆滞屏幕上浮起问号气泡“……所以它只是个幻觉
## **支持的模型**
- [千问文生图](https://help.aliyun.com/zh/model-studio/models#34e47bbcf57v1)
- [万相文生图](https://help.aliyun.com/zh/model-studio/models#b4eb59e706n17)
## **模型选型**
- **复杂文字渲染**(如海报、对联):首选`**qwen-image-2.0-pro**`**、**`**wan2.6-t2i**`。
- **写实场景和摄影风格**(通用场景):可选万相模型,如`**wan2.6-t2i**`、`**wan2.5-t2i-preview**`。
- **需要自定义输出图像分辨率:**推荐`**qwen-image-2.0**`系列或万相模型。qwen-image-2.0系列支持自由设置宽高,输出图像总像素在\[512\*512, 2048\*2048\]之间;万相模型如`**wan2.6-t2i**`,输出图像总像素在\[1280\*1280, 1440\*1440\]之间。
> qwen-image-max、qwen-image-plus系列模型仅支持5种固定尺寸1664\*928(16:9)、928\*1664(9:16)、1328\*1328(1:1)、1472\*1104(4:3)、1104\*1472(3:4)。
- **成本极度敏感,可接受基础质量:**可选择`**wanx2.0-t2i-turbo**`,价格较低,请参见[计费与限流](#a585cbf27dck8)。
## 快速开始
#### **前提条件**
在调用前,请[获取API Key](https://help.aliyun.com/zh/model-studio/get-api-key),再[配置API Key到环境变量](https://help.aliyun.com/zh/model-studio/configure-api-key-through-environment-variables)。如果通过DashScope SDK进行调用还需要[安装SDK](https://help.aliyun.com/zh/model-studio/install-sdk)。
#### **示例代码**
**调用方式说明**
- 千问文生图模型均支持同步调用其中qwen-image-plus、qwen-image模型支持异步调用详情请参见[千问-文生图](https://help.aliyun.com/zh/model-studio/qwen-image-api)。
- 万相文生图模型均支持异步调用其中wan2.6-t2i支持同步调用详情请参见[万相-文生图V2](https://help.aliyun.com/zh/model-studio/text-to-image-v2-api-reference)。
## 同步调用
## Python
### **请求示例**
```
import json
import os
import dashscope
from dashscope import MultiModalConversation
# 以下为北京地域url若使用新加坡地域的模型需将url替换为https://dashscope-intl.aliyuncs.com/api/v1
dashscope.base_http_api_url = 'https://dashscope.aliyuncs.com/api/v1'
messages = [
{
"role": "user",
"content": [
{"text": "冬日北京的都市街景,青灰瓦顶、朱红色外墙的两间相邻中式商铺比肩而立,檐下悬挂印有剪纸马的暖光灯笼,在阴天漫射光中投下柔和光晕,映照湿润鹅卵石路面泛起细腻反光。左侧为书法店:靛蓝色老旧的牌匾上以遒劲行书刻着“文字渲染”。店门口的玻璃上挂着一幅字,自上而下,用田英章硬笔写着“专业幻灯片 中英文海报 高级信息图”落款印章为“1k token”朱砂印。店内的墙上可以模糊的辨认有三幅竖排的书法作品第一幅写着着“阿里巴巴”第二幅写着“通义千问”第三福写着“图像生成”。一位白发苍苍的老人背对着镜头观赏。右侧为花店牌匾上以鲜花做成文字“真实质感”店内多层花架陈列红玫瑰、粉洋牡丹和绿植门上贴了一个圆形花边标识标识上写着“2k resolution”门口摆放了一个彩色霓虹灯上面写着“细腻刻画 人物 自然 建筑”。两家店中间堆放了一个雪人举了一老式小黑板上面用粉笔字写着“Qwen-Image-2.0 正式发布”。街道左侧,年轻情侣依偎在一起,女孩是瘦脸,身穿米白色羊绒大衣,肉色光腿神器。女孩举着心形透明气球,气球印有白色的字:“生图编辑二合一”。里面有一个毛茸茸的卡皮巴拉玩偶。男孩身着剪裁合体的深灰色呢子外套,内搭浅色高领毛衣。街道右侧,一个后背上写着“更小模型,更快速度”的骑手疾驰而过。整条街光影交织、动静相宜。"}
]
}
]
# 新加坡和北京地域的API Key不同。获取API Keyhttps://help.aliyun.com/zh/model-studio/get-api-key
# 若没有配置环境变量请用百炼API Key将下行替换为api_key="sk-xxx"
api_key = os.getenv("DASHSCOPE_API_KEY")
response = MultiModalConversation.call(
api_key=api_key,
model="qwen-image-2.0-pro",
messages=messages,
result_format='message',
stream=False,
watermark=False,
prompt_extend=True,
negative_prompt="低分辨率低画质肢体畸形手指畸形画面过饱和蜡像感人脸无细节过度光滑画面具有AI感。构图混乱。文字模糊扭曲。",
size='2048*2048'
)
if response.status_code == 200:
print(json.dumps(response, ensure_ascii=False))
else:
print(f"HTTP返回码{response.status_code}")
print(f"错误码:{response.code}")
print(f"错误信息:{response.message}")
print("请参考文档https://help.aliyun.com/zh/model-studio/developer-reference/error-code")
```
### **响应示例**
> 图像链接的有效期为24小时请及时下载图像。
```
{
"status_code": 200,
"request_id": "d2d1a8c0-325f-9b9d-8b90-xxxxxx",
"code": "",
"message": "",
"output": {
"text": null,
"finish_reason": null,
"choices": [
{
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": [
{
"image": "https://dashscope-result-wlcb.oss-cn-wulanchabu.aliyuncs.com/xxx.png?Expires=xxx"
}
]
}
}
]
},
"usage": {
"input_tokens": 0,
"output_tokens": 0,
"width": 2048,
"image_count": 1,
"height": 2048
}
}
```
## Java
### **请求示例**
```
import com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversation;
import com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversationParam;
import com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversationResult;
import com.alibaba.dashscope.common.MultiModalMessage;
import com.alibaba.dashscope.common.Role;
import com.alibaba.dashscope.exception.ApiException;
import com.alibaba.dashscope.exception.NoApiKeyException;
import com.alibaba.dashscope.exception.UploadFileException;
import com.alibaba.dashscope.utils.Constants;
import com.alibaba.dashscope.utils.JsonUtils;
import java.io.IOException;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
public class QwenImage {
static {
// 以下为北京地域url若使用新加坡地域的模型需将url替换为https://dashscope-intl.aliyuncs.com/api/v1
Constants.baseHttpApiUrl = "https://dashscope.aliyuncs.com/api/v1";
}
// 新加坡和北京地域的API Key不同。获取API Keyhttps://help.aliyun.com/zh/model-studio/get-api-key
// 若没有配置环境变量请用百炼API Key将下行替换为static String apiKey ="sk-xxx"
static String apiKey = System.getenv("DASHSCOPE_API_KEY");
public static void call() throws ApiException, NoApiKeyException, UploadFileException, IOException {
MultiModalConversation conv = new MultiModalConversation();
MultiModalMessage userMessage = MultiModalMessage.builder().role(Role.USER.getValue())
.content(Arrays.asList(
Collections.singletonMap("text", "冬日北京的都市街景,青灰瓦顶、朱红色外墙的两间相邻中式商铺比肩而立,檐下悬挂印有剪纸马的暖光灯笼,在阴天漫射光中投下柔和光晕,映照湿润鹅卵石路面泛起细腻反光。左侧为书法店:靛蓝色老旧的牌匾上以遒劲行书刻着“文字渲染”。店门口的玻璃上挂着一幅字,自上而下,用田英章硬笔写着“专业幻灯片 中英文海报 高级信息图”落款印章为“1k token”朱砂印。店内的墙上可以模糊的辨认有三幅竖排的书法作品第一幅写着着“阿里巴巴”第二幅写着“通义千问”第三福写着“图像生成”。一位白发苍苍的老人背对着镜头观赏。右侧为花店牌匾上以鲜花做成文字“真实质感”店内多层花架陈列红玫瑰、粉洋牡丹和绿植门上贴了一个圆形花边标识标识上写着“2k resolution”门口摆放了一个彩色霓虹灯上面写着“细腻刻画 人物 自然 建筑”。两家店中间堆放了一个雪人举了一老式小黑板上面用粉笔字写着“Qwen-Image-2.0 正式发布”。街道左侧,年轻情侣依偎在一起,女孩是瘦脸,身穿米白色羊绒大衣,肉色光腿神器。女孩举着心形透明气球,气球印有白色的字:“生图编辑二合一”。里面有一个毛茸茸的卡皮巴拉玩偶。男孩身着剪裁合体的深灰色呢子外套,内搭浅色高领毛衣。街道右侧,一个后背上写着“更小模型,更快速度”的骑手疾驰而过。整条街光影交织、动静相宜。")
)).build();
Map<String, Object> parameters = new HashMap<>();
parameters.put("watermark", false);
parameters.put("prompt_extend", true);
parameters.put("negative_prompt", "低分辨率低画质肢体畸形手指畸形画面过饱和蜡像感人脸无细节过度光滑画面具有AI感。构图混乱。文字模糊扭曲。");
parameters.put("size", "2048*2048");
MultiModalConversationParam param = MultiModalConversationParam.builder()
.apiKey(apiKey)
.model("qwen-image-2.0-pro")
.messages(Collections.singletonList(userMessage))
.parameters(parameters)
.build();
MultiModalConversationResult result = conv.call(param);
System.out.println(JsonUtils.toJson(result));
}
public static void main(String[] args) {
try {
call();
} catch (ApiException | NoApiKeyException | UploadFileException | IOException e) {
System.out.println(e.getMessage());
}
System.exit(0);
}
}
```
### **响应示例**
> 图像链接的有效期为24小时请及时下载图像。
```
{
"requestId": "5b6f2d04-b019-40db-a5cc-xxxxxx",
"usage": {
"image_count": 1,
"width": 2048,
"height": 2048
},
"output": {
"choices": [
{
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": [
{
"image": "https://dashscope-result-wlcb.oss-cn-wulanchabu.aliyuncs.com/xxx.png?Expires=xxx"
}
]
}
}
]
}
}
```
## curl
##### **请求示例**
```
curl --location 'https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation' \
--header 'Content-Type: application/json' \
--header "Authorization: Bearer $DASHSCOPE_API_KEY" \
--data '{
"model": "qwen-image-2.0-pro",
"input": {
"messages": [
{
"role": "user",
"content": [
{
"text": "冬日北京的都市街景,青灰瓦顶、朱红色外墙的两间相邻中式商铺比肩而立,檐下悬挂印有剪纸马的暖光灯笼,在阴天漫射光中投下柔和光晕,映照湿润鹅卵石路面泛起细腻反光。左侧为书法店:靛蓝色老旧的牌匾上以遒劲行书刻着“文字渲染”。店门口的玻璃上挂着一幅字,自上而下,用田英章硬笔写着“专业幻灯片 中英文海报 高级信息图”落款印章为“1k token”朱砂印。店内的墙上可以模糊的辨认有三幅竖排的书法作品第一幅写着着“阿里巴巴”第二幅写着“通义千问”第三福写着“图像生成”。一位白发苍苍的老人背对着镜头观赏。右侧为花店牌匾上以鲜花做成文字“真实质感”店内多层花架陈列红玫瑰、粉洋牡丹和绿植门上贴了一个圆形花边标识标识上写着“2k resolution”门口摆放了一个彩色霓虹灯上面写着“细腻刻画 人物 自然 建筑”。两家店中间堆放了一个雪人举了一老式小黑板上面用粉笔字写着“Qwen-Image-2.0 正式发布”。街道左侧,年轻情侣依偎在一起,女孩是瘦脸,身穿米白色羊绒大衣,肉色光腿神器。女孩举着心形透明气球,气球印有白色的字:“生图编辑二合一”。里面有一个毛茸茸的卡皮巴拉玩偶。男孩身着剪裁合体的深灰色呢子外套,内搭浅色高领毛衣。街道右侧,一个后背上写着“更小模型,更快速度”的骑手疾驰而过。整条街光影交织、动静相宜。"
}
]
}
]
},
"parameters": {
"negative_prompt": "低分辨率低画质肢体畸形手指畸形画面过饱和蜡像感人脸无细节过度光滑画面具有AI感。构图混乱。文字模糊扭曲。",
"prompt_extend": true,
"watermark": false,
"size": "2048*2048"
}
}'
```
```
curl --location 'https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation' \
--header 'Content-Type: application/json' \
--header "Authorization: Bearer $DASHSCOPE_API_KEY" \
--data '{
"model": "qwen-image-2.0-pro",
"input": {
"messages": [
{
"role": "user",
"content": [
{
"text": "Healing-style hand-drawn poster featuring three puppies playing with a ball on lush green grass, adorned with decorative elements such as birds and stars. The main title “Come Play Ball!” is prominently displayed at the top in bold, blue cartoon font. Below it, the subtitle “Come [Show Off Your Skills]!” appears in green font. A speech bubble adds playful charm with the text: “Hehe, watch me amaze my little friends next!” At the bottom, supplementary text reads: “We get to play ball with our friends again!” The color palette centers on fresh greens and blues, accented with bright pink and yellow tones to highlight a cheerful, childlike atmosphere."
}
]
}
]
},
"parameters": {
"negative_prompt": "低分辨率低画质肢体畸形手指畸形画面过饱和蜡像感人脸无细节过度光滑画面具有AI感。构图混乱。文字模糊扭曲。",
"prompt_extend": true,
"watermark": false,
"size": "2048*2048"
}
}'
```
##### **响应示例**
```
{
"output": {
"choices": [
{
"finish_reason": "stop",
"message": {
"content": [
{
"image": "https://dashscope-result-sh.oss-cn-shanghai.aliyuncs.com/xxx.png?Expires=xxx"
}
],
"role": "assistant"
}
}
]
},
"usage": {
"height": 2048,
"image_count": 1,
"width": 2048
},
"request_id": "d0250a3d-b07f-49e1-bdc8-6793f4929xxx"
}
```
## 异步调用
> SDK 在底层封装了异步处理逻辑,上层接口表现为同步调用(即单次请求并等待最终结果返回);而 curl 示例则对应两个独立的异步 API 接口:一个用于提交任务,另一个用于查询结果。
## Python
### **请求示例**
```
import os
import dashscope
from dashscope.aigc.image_generation import ImageGeneration
from dashscope.api_entities.dashscope_response import Message
# 以下为北京地域url各地域的base_url不同
dashscope.base_http_api_url = 'https://dashscope.aliyuncs.com/api/v1'
# 若没有配置环境变量请用百炼API Key将下行替换为api_key="sk-xxx"
# 各地域的API Key不同。获取API Keyhttps://help.aliyun.com/zh/model-studio/get-api-key
api_key = os.getenv("DASHSCOPE_API_KEY")
message = Message(
role="user",
content=[
{
'text': '一间有着精致窗户的花店,漂亮的木质门,摆放着花朵'
}
]
)
print("----sync call, please wait a moment----")
rsp = ImageGeneration.call(
model="wan2.6-t2i",
api_key=api_key,
messages=[message],
negative_prompt="",
prompt_extend=True,
watermark=False,
n=1,
size="1280*1280"
)
print(rsp)
```
### 响应示例
> url 有效期24小时请及时下载图像。
```
{
"status_code": 200,
"request_id": "820dd0db-eb42-4e05-8d6a-1ddb4axxxxxx",
"code": "",
"message": "",
"output": {
"text": null,
"finish_reason": null,
"choices": [
{
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": [
{
"image": "https://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/xxxxxx.png?Expires=xxxxxx",
"type": "image"
}
]
}
}
],
"audio": null,
"finished": true
},
"usage": {
"input_tokens": 0,
"output_tokens": 0,
"characters": 0,
"image_count": 1,
"size": "1280*1280",
"total_tokens": 0
}
}
```
## Java
### **请求示例**
```
import com.alibaba.dashscope.aigc.imagegeneration.*;
import com.alibaba.dashscope.exception.ApiException;
import com.alibaba.dashscope.exception.NoApiKeyException;
import com.alibaba.dashscope.exception.UploadFileException;
import com.alibaba.dashscope.utils.Constants;
import com.alibaba.dashscope.utils.JsonUtils;
import java.util.Collections;
public class Main {
static {
// 以下为北京地域url各地域的base_url不同
Constants.baseHttpApiUrl = "https://dashscope.aliyuncs.com/api/v1";
}
// 若没有配置环境变量请用百炼API Key将下行替换为apiKey="sk-xxx"
// 各地域的API Key不同。获取API Keyhttps://help.aliyun.com/zh/model-studio/get-api-key
static String apiKey = System.getenv("DASHSCOPE_API_KEY");
public static void basicCall() throws ApiException, NoApiKeyException, UploadFileException {
ImageGenerationMessage message = ImageGenerationMessage.builder()
.role("user")
.content(Collections.singletonList(
Collections.singletonMap("text", "一间有着精致窗户的花店,漂亮的木质门,摆放着花朵")
)).build();
ImageGenerationParam param = ImageGenerationParam.builder()
.apiKey(apiKey)
.model("wan2.6-t2i")
.n(1)
.size("1280*1280")
.negativePrompt("")
.promptExtend(true)
.watermark(false)
.messages(Collections.singletonList(message))
.build();
ImageGeneration imageGeneration = new ImageGeneration();
ImageGenerationResult result = null;
try {
System.out.println("---sync call, please wait a moment----");
result = imageGeneration.call(param);
} catch (ApiException | NoApiKeyException | UploadFileException e) {
throw new RuntimeException(e.getMessage());
}
System.out.println(JsonUtils.toJson(result));
}
public static void main(String[] args) {
try {
basicCall();
} catch (ApiException | NoApiKeyException | UploadFileException e) {
System.out.println(e.getMessage());
}
}
}
```
### 响应示例
> url 有效期24小时请及时下载图像。
```
{
"status_code": 200,
"request_id": "50b57166-eaaa-4f17-b1e0-35a5ca88672c",
"code": "",
"message": "",
"output": {
"choices": [
{
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": [
{
"image": "https://dashscope-result-sh.oss-cn-shanghai.aliyuncs.com/xxx.png?Expires=xxx",
"type": "image"
}
]
}
}
],
"finished": true
},
"usage": {
"input_tokens": 0,
"output_tokens": 0,
"image_count": 1,
"size": "1280*1280",
"total_tokens": 0
}
}
```
## curl
**说明**
- 异步调用必须设置 Header 参数`X-DashScope-Async` 为`enable`。
- 异步任务的 `task_id` 查询有效期为 24 小时,过期后任务状态将变为 `UNKNOWN`
- 适用于所有模型,新手建议使用 [Postman](https://help.aliyun.com/zh/model-studio/first-call-to-image-and-video-api)调用API。
##### **步骤1发起创建任务请求**
该请求会返回一个任务ID`task_id`)。
```
curl --location 'https://dashscope.aliyuncs.com/api/v1/services/aigc/image-generation/generation' \
--header 'Content-Type: application/json' \
--header "Authorization: Bearer $DASHSCOPE_API_KEY" \
--header 'X-DashScope-Async: enable' \
--data '{
"model": "wan2.6-t2i",
"input": {
"messages": [
{
"role": "user",
"content": [
{
"text": "一间有着精致窗户的花店,漂亮的木质门,摆放着花朵"
}
]
}
]
},
"parameters": {
"prompt_extend": true,
"watermark": false,
"n": 1,
"negative_prompt": "",
"size": "1280*1280"
}
}'
```
##### **步骤2根据任务ID查询结果**
使用上一步获取的 `task_id`,通过接口轮询任务状态,直到 `task_status` 变为 SUCCEEDED 或 FAILED。
将`{task_id}`完整替换为上一步接口返回的`task_id`的值。`task_id`查询有效期为24小时。
```
curl -X GET https://dashscope.aliyuncs.com/api/v1/tasks/{task_id} \
--header "Authorization: Bearer $DASHSCOPE_API_KEY"
```
## 关键能力
### **1\. 指令遵循(提示词)**
参数:`input.prompt`(必选)、`input.negative_prompt`(可选)。
- **prompt正向提示词**:描述希望在画面中看到的内容、主体、场景、风格、光照和构图。文生图的核心控制参数。
- **negative\_prompt反向提示词**:描述不希望在画面中出现的内容,如“模糊”、“多余的手指”等。仅用于辅助优化生成质量。
撰写技巧:一个结构化的 Prompt 通常能带来更好的效果,撰写技巧请参见[文生图Prompt指南](https://help.aliyun.com/zh/model-studio/text-to-image-prompt)。
### **2\. 开启prompt智能改写**
参数: `parameters.prompt_extend` (bool, **默认为 true**)。
此功能可自动扩展和优化**较短的Prompt**,提升出图效果。开启此功能额外耗时 3-5 秒。此耗时为使用大模型改写文本。
实践建议:
- 建议开启:当输入 Prompt 较简洁或宽泛时,此功能可显著提升图像效果。
- 建议关闭:若需控制画面细节、或已提供详细描述,或对响应延迟敏感。请将参数 `prompt_extend` **显式设为** `false`
### **3\. 设置输出图像分辨率**
参数: parameters.size (string),格式为 `**"宽*高"**`
**qwen-image-2.0 系列**支持自由设置宽高输出图像总像素需在512\*512至2048\*2048之间。默认分辨率为2048\*2048。推荐分辨率
- `2688*1536` 16:9
- `1536*2688` 9:16
- `2048*2048`默认值1:1
- `2368*1728` 4:3
- `1728*2368` 3:4
**qwen-image-max、qwen-image-plus 系列**:仅支持以下 5 种固定的分辨率:
- `1664*928`默认值16:9
- `1472*1104`4:3
- `1328*1328`1:1
- `1104*1472`3:4
- `928*1664`9:16
**万相 V2 版模型 (2.0 及以上版本)**:支持在 `[512, 1440]` 像素范围内任意组合宽高,总像素不超过 1440\*1440。常用分辨率
- `1024*1024`默认值1:1
- `1440*810`: 16:9
- `810*1440`: 9:16
- `1440*1080`: 4:3
- `1080*1440`: 3:4
## **应用于生产环境**
- **容错策略**
- **处理限流**:当 API 返回 `Throttling` 错误码或 HTTP 429 状态码时,表明已触发限流,限流处理请参见[限流](https://help.aliyun.com/zh/model-studio/rate-limit)。
- **异步任务轮询**轮询查询异步任务结果时建议采用合理的轮询策略如前30秒每3秒一次之后拉长间隔避免因过于频繁的请求而触发限流。为任务设置一个最终超时时间如 2 分钟),超时后标记为失败。
- **风险防范**
- **结果持久化**API 返回的图片 URL 有 24 小时有效期。生产系统必须在获取 URL 后立即下载图片,并转存至您自己的持久化存储服务中(如阿里云对象存储 OSS
- **内容安全审核**:所有 `prompt``negative_prompt` 都会经过内容安全审核。若输入内容不合规,请求将被拦截并返回 `DataInspectionFailed` 错误。
- **生成内容的版权与合规风险**:请确保您的提示词内容符合相关法律法规。生成包含品牌商标、名人肖像、受版权保护的 IP 形象等内容可能涉及侵权风险,请您自行评估并承担相应责任。
## **API文档**
- [千问 Qwen-Image](https://help.aliyun.com/zh/model-studio/qwen-image-api)
- [万相-文生图V2](https://help.aliyun.com/zh/model-studio/text-to-image-v2-api-reference)
## **计费与限流**
- 模型免费额度和计费单价请参见[模型价格](https://help.aliyun.com/zh/model-studio/models#4611ffaa38hnp)。
- 模型限流请参见[限流-图像生成](https://help.aliyun.com/zh/model-studio/rate-limit#5998fd159df49)。
- 计费说明:按成功生成的 **图像张数** 计费。模型调用失败或处理错误不产生任何费用,也不消耗[新人免费额度](https://help.aliyun.com/zh/model-studio/new-free-quota)。
## **错误码**
如果模型调用失败并返回报错信息,请参见[错误信息](https://help.aliyun.com/zh/model-studio/error-code)进行解决。
## **常见问题**
**Q: 图片 URL 多久会失效?我应该如何永久保存图片?**
A: 图片 URL 的有效期为 24 小时。您必须在获取到 URL 后,立即通过程序下载图片,并将其保存到您自己的持久化存储中,例如本地服务器或阿里云对象存储 OSS。
**Q: 调用API返回DataInspectionFailed错误如何处理**
A: 该错误表示输入文本触发了内容安全审核。请检查并修改prompt或negative\_prompt中的文本移除可能违规的内容后重试。
**Q: prompt\_extend参数应该开启还是关闭**
A: 当输入的prompt比较简洁或希望模型发挥更多创意时建议保持开启默认。当prompt已经非常详细、专业或对API响应延迟有严格要求时建议显式设置为false。
**Q: 如何提升图像中文字的生成效果?**
A: 推荐使用`qwen-image-2.0-pro`模型,它具备更专业的文字渲染能力。
/\*表格图片设置为块元素(独占一行),居中展示,鼠标放在图片上可以点击查看原图\*/ .unionContainer .markdown-body .image.break { margin: 0px; display: inline-block; vertical-align: middle } /\* 让表格显示成类似钉钉文档的分栏卡片 \*/ table.help-table-card td { border: 10px solid #FFF !important; background: #F4F6F9; padding: 16px !important; vertical-align: top; } /\* 减少表格中的代码块 margin让表格信息显示更紧凑 \*/ .unionContainer .markdown-body table .help-code-block { margin: 0 !important; } /\* 减少表格中的代码块字号,让表格信息显示更紧凑 \*/ .unionContainer .markdown-body .help-code-block pre { font-size: 12px !important; } /\* 减少表格中的代码块字号,让表格信息显示更紧凑 \*/ .unionContainer .markdown-body .help-code-block pre code { font-size: 12px !important; } /\* 表格中的引用上下间距调小,避免内容显示过于稀疏 \*/ .unionContainer .markdown-body table blockquote { margin: 4px 0 0 0; }

File diff suppressed because it is too large Load Diff

View File

@ -9,11 +9,32 @@ import ProblemSolving from '../views/ProblemSolving.vue'
import QuestionGenerator from '../views/QuestionGenerator.vue' import QuestionGenerator from '../views/QuestionGenerator.vue'
import QuestionVariant from '../views/QuestionVariant.vue' import QuestionVariant from '../views/QuestionVariant.vue'
import AudioToText from '../views/AudioToText.vue' import AudioToText from '../views/AudioToText.vue'
import SpeakingEvaluation from '../views/SpeakingEvaluation.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({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
routes: [ 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: '/', path: '/',
name: 'home', name: 'home',
@ -64,12 +85,49 @@ const router = createRouter({
name: 'audio-to-text', name: 'audio-to-text',
component: AudioToText component: AudioToText
}, },
{
path: '/speaking-evaluation',
name: 'speaking-evaluation',
component: SpeakingEvaluation
},
] ]
}) })
// 全局路由守卫
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 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

@ -1028,30 +1028,6 @@ const reset = () => {
overflow-y: auto; overflow-y: auto;
} }
/* result-content 滚动条样式 */
.result-content::-webkit-scrollbar {
width: 6px;
}
.result-content::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.15);
border-radius: 3px;
}
.result-content::-webkit-scrollbar-thumb {
background: rgba(139, 92, 246, 0.35);
border-radius: 3px;
transition: background 0.2s;
}
.result-content::-webkit-scrollbar-thumb:hover {
background: rgba(139, 92, 246, 0.55);
}
.result-content::-webkit-scrollbar-thumb:active {
background: rgba(139, 92, 246, 0.75);
}
.result-card.is-optimizing { .result-card.is-optimizing {
border-color: rgba(16, 185, 129, 0.3); border-color: rgba(16, 185, 129, 0.3);
box-shadow: 0 0 20px rgba(16, 185, 129, 0.1); box-shadow: 0 0 20px rgba(16, 185, 129, 0.1);

File diff suppressed because it is too large Load Diff

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>

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, computed, onUnmounted, reactive } from "vue"; import { ref, computed, onUnmounted } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { import {
DOUBAO_APP_ID, DOUBAO_APP_ID,
@ -90,65 +90,6 @@ const completedWords = ref(0);
let audioInstance = null; let audioInstance = null;
let audioCache = new Map(); // let audioCache = new Map(); //
//
const longPressState = reactive({
repeat: false, //
});
//
const isRecording = ref(false);
let mediaRecorder = null;
let recordedChunks = [];
//
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder = new MediaRecorder(stream);
recordedChunks = [];
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
recordedChunks.push(event.data);
}
};
mediaRecorder.onstop = () => {
const blob = new Blob(recordedChunks, { type: 'audio/webm' });
//
console.log('录音完成,大小:', blob.size);
//
stream.getTracks().forEach(track => track.stop());
};
mediaRecorder.start();
isRecording.value = true;
} catch (err) {
console.error('无法访问麦克风:', err);
alert('无法访问麦克风,请检查权限设置');
}
};
//
const stopRecording = () => {
if (mediaRecorder && isRecording.value) {
mediaRecorder.stop();
isRecording.value = false;
}
};
//
const handlePressStart = () => {
longPressState.repeat = true;
startRecording();
};
//
const handlePressEnd = () => {
longPressState.repeat = false;
stopRecording();
};
// //
const progress = computed(() => { const progress = computed(() => {
if (wordList.value.length === 0) return 0; if (wordList.value.length === 0) return 0;
@ -425,10 +366,6 @@ onUnmounted(() => {
// URL // URL
audioCache.forEach((url) => URL.revokeObjectURL(url)); audioCache.forEach((url) => URL.revokeObjectURL(url));
audioCache.clear(); audioCache.clear();
//
if (isRecording.value) {
stopRecording();
}
}); });
</script> </script>
@ -569,10 +506,7 @@ onUnmounted(() => {
<!-- 播放中 --> <!-- 播放中 -->
<template v-else-if="dictationState === 'playing'"> <template v-else-if="dictationState === 'playing'">
<button <button class="control-btn pause-btn" @click="pauseDictation">
class="control-btn pause-btn"
@click="pauseDictation"
>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
@ -587,16 +521,7 @@ onUnmounted(() => {
/> />
</svg> </svg>
</button> </button>
<button <button class="control-btn replay-btn" @click="replayCurrentWord">
class="control-btn replay-btn"
:class="{ pressing: longPressState.repeat, recording: isRecording }"
@mousedown="handlePressStart"
@mouseup="handlePressEnd"
@mouseleave="handlePressEnd"
@touchstart.prevent="handlePressStart"
@touchend="handlePressEnd"
@touchcancel="handlePressEnd"
>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
@ -607,7 +532,7 @@ onUnmounted(() => {
<path <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 01-3-3V4.5a3 3 0 116 0v8.25a3 3 0 01-3 3z" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/> />
</svg> </svg>
</button> </button>
@ -645,16 +570,7 @@ onUnmounted(() => {
/> />
</svg> </svg>
</button> </button>
<button <button class="control-btn replay-btn" @click="replayCurrentWord">
class="control-btn replay-btn"
:class="{ pressing: longPressState.repeat, recording: isRecording }"
@mousedown="handlePressStart"
@mouseup="handlePressEnd"
@mouseleave="handlePressEnd"
@touchstart.prevent="handlePressStart"
@touchend="handlePressEnd"
@touchcancel="handlePressEnd"
>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
@ -665,7 +581,7 @@ onUnmounted(() => {
<path <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 01-3-3V4.5a3 3 0 116 0v8.25a3 3 0 01-3 3z" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/> />
</svg> </svg>
</button> </button>
@ -1403,60 +1319,6 @@ onUnmounted(() => {
transform: scale(1.05); transform: scale(1.05);
} }
/* 长按状态 */
.control-btn.pressing {
transform: scale(0.95);
opacity: 0.8;
}
.replay-btn.pressing {
background: linear-gradient(135deg, var(--accent-5), #d97706);
color: white;
box-shadow: 0 4px 15px rgba(245, 158, 11, 0.3);
}
/* 录音中状态 */
.replay-btn.recording {
background: linear-gradient(135deg, #ef4444, #dc2626);
color: white;
box-shadow: 0 4px 15px rgba(239, 68, 68, 0.4);
animation: pulse 1s ease-in-out infinite;
}
/* 动画效果 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translate(-50%, -50%) scale(0.8);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
@keyframes fadeOut {
from {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
to {
opacity: 0;
transform: translate(-50%, -50%) scale(0.8);
}
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
box-shadow: 0 4px 15px rgba(239, 68, 68, 0.4);
}
50% {
transform: scale(1.05);
box-shadow: 0 6px 25px rgba(239, 68, 68, 0.6);
}
}
@media (max-width: 900px) { @media (max-width: 900px) {
.content-grid { .content-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

@ -17,7 +17,16 @@ export default defineConfig({
}, },
server: { server: {
host: '0.0.0.0', host: '0.0.0.0',
headers: {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
},
proxy: { proxy: {
// 后端认证 API 代理
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/tts-api': { '/tts-api': {
target: 'https://openspeech.bytedance.com', target: 'https://openspeech.bytedance.com',
changeOrigin: true, changeOrigin: true,
@ -47,29 +56,6 @@ export default defineConfig({
proxyReq.setHeader('X-Api-Connect-Id', crypto.randomUUID()) proxyReq.setHeader('X-Api-Connect-Id', crypto.randomUUID())
}) })
} }
},
// 阿里云OSS音频代理解决COEP策略问题支持多个OSS域名
'/oss-audio': {
target: 'https://dashscope-result-bj.oss-cn-beijing.aliyuncs.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/oss-audio/, ''),
configure: (proxy) => {
proxy.on('proxyRes', (proxyRes) => {
// 添加CORS头以符合COEP要求
proxyRes.headers['access-control-allow-origin'] = '*'
})
}
},
// 阿里云OSS图片代理支持多个OSS域名
'/oss-image': {
target: 'https://dashscope-7c2c.oss-accelerate.aliyuncs.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/oss-image/, ''),
configure: (proxy) => {
proxy.on('proxyRes', (proxyRes) => {
proxyRes.headers['access-control-allow-origin'] = '*'
})
}
} }
} }
} }