feat: 完整的WordMaster Pro学习管理系统
主要功能: - 前后端分离架构,支持远程访问 - 试卷上传和管理系统 - 学科分类统计和数据可视化 - 统一配置管理,支持环境变量 - 响应式设计,优化的用户界面 技术栈: - 后端: FastAPI + SQLAlchemy + SQLite - 前端: Vue 3 + Element Plus + Vite - 配置: 环境变量 + 统一配置中心 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+69
@@ -0,0 +1,69 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# Node.js
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
package-lock.json
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite3
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Uploads and temporary files
|
||||
uploads/
|
||||
temp/
|
||||
*.tmp
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Git temporary files
|
||||
.git/
|
||||
@@ -0,0 +1,75 @@
|
||||
# 🎉 WordMaster Pro 部署成功摘要
|
||||
|
||||
## ✅ 部署状态
|
||||
**WordMaster Pro 已成功部署到您的本地服务器!**
|
||||
|
||||
### 🌐 访问地址
|
||||
- **前端界面**: http://localhost:3002
|
||||
- **后端API**: http://localhost:8888
|
||||
- **API文档**: http://localhost:8888/docs
|
||||
|
||||
### 📊 系统信息
|
||||
- **后端**: FastAPI + SQLAlchemy + SQLite
|
||||
- **前端**: Vue3 + Element Plus + Vite
|
||||
- **数据库**: SQLite (已初始化,包含54个初中英语单词)
|
||||
- **认证**: JWT Token
|
||||
- **状态**: 所有服务正常运行 ✅
|
||||
|
||||
### 🚀 管理命令
|
||||
|
||||
```bash
|
||||
# 系统状态检查
|
||||
./status.sh
|
||||
|
||||
# 启动所有服务
|
||||
./start.sh
|
||||
|
||||
# 停止所有服务
|
||||
./stop.sh
|
||||
|
||||
# 单独启动后端
|
||||
cd backend && source venv/bin/activate && python main.py
|
||||
|
||||
# 单独启动前端
|
||||
cd frontend && npm run dev
|
||||
```
|
||||
|
||||
### 📚 已内置功能
|
||||
- ✅ 用户注册/登录系统
|
||||
- ✅ SM-2间隔重复算法
|
||||
- ✅ 4种学习模式(抽认卡、输入、选择、完形填空)
|
||||
- ✅ 双向练习(英→中、中→英)
|
||||
- ✅ TTS语音朗读
|
||||
- ✅ 顽固词汇检测
|
||||
- ✅ 学习统计分析
|
||||
- ✅ 词汇导入导出
|
||||
- ✅ 54个初中英语单词
|
||||
|
||||
### 🎯 快速开始
|
||||
1. 在浏览器打开 http://localhost:3002
|
||||
2. 注册新用户账户
|
||||
3. 系统已预装54个初中英语单词,可立即开始学习
|
||||
4. 支持CSV/JSON格式批量导入更多词汇
|
||||
|
||||
### 🌐 远程访问
|
||||
当前可通过局域网访问:
|
||||
- 前端: http://192.168.1.83:3002
|
||||
- 后端: http://192.168.1.83:8888
|
||||
|
||||
### 🔧 技术特性
|
||||
- **响应式设计**: 支持电脑、平板、手机
|
||||
- **离线能力**: PWA支持
|
||||
- **智能算法**: 个性化复习间隔
|
||||
- **数据持久**: 自动保存学习进度
|
||||
- **可扩展**: 支持多种格式词汇导入
|
||||
|
||||
### 📈 下一步
|
||||
1. 开始使用系统学习英语单词
|
||||
2. 根据需要导入更多词汇
|
||||
3. 配置远程访问(可选)
|
||||
4. 定期查看学习统计
|
||||
|
||||
---
|
||||
**部署日期**: 2025-10-04
|
||||
**版本**: WordMaster Pro v1.0
|
||||
**状态**: 生产就绪 🚀
|
||||
@@ -0,0 +1,268 @@
|
||||
# WordMaster Pro 部署指南
|
||||
|
||||
这是一个基于 FastAPI + Vue3 的智能单词学习系统,支持远程访问和初中英语单词导入。
|
||||
|
||||
## 🚀 快速启动
|
||||
|
||||
### 后端启动
|
||||
|
||||
1. 进入后端目录:
|
||||
```bash
|
||||
cd backend
|
||||
```
|
||||
|
||||
2. 安装依赖:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. 启动后端服务:
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
后端将在 `http://0.0.0.0:8888` 启动
|
||||
|
||||
### 前端启动
|
||||
|
||||
1. 进入前端目录:
|
||||
```bash
|
||||
cd frontend
|
||||
```
|
||||
|
||||
2. 安装依赖:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. 启动开发服务器:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
前端将在 `http://localhost:3000` 启动 (如端口被占用会自动选择其他端口)
|
||||
|
||||
## 📦 生产环境部署
|
||||
|
||||
### 方法1:使用 Docker (推荐)
|
||||
|
||||
创建 `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
backend:
|
||||
build: ./backend
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- DATABASE_URL=sqlite:///./data/wordmaster.db
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- backend
|
||||
```
|
||||
|
||||
启动:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### 方法2:使用 PM2
|
||||
|
||||
1. 安装 PM2:
|
||||
```bash
|
||||
npm install -g pm2
|
||||
```
|
||||
|
||||
2. 启动后端:
|
||||
```bash
|
||||
pm2 start "uvicorn main:app --host 0.0.0.0 --port 8000" --name wordmaster-backend
|
||||
```
|
||||
|
||||
3. 构建并启动前端:
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
pm2 serve dist 3000 --name wordmaster-frontend
|
||||
```
|
||||
|
||||
### 方法3:使用 Nginx 反向代理
|
||||
|
||||
Nginx 配置示例:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
|
||||
# 前端静态文件
|
||||
location / {
|
||||
root /path/to/frontend/dist;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API 反向代理
|
||||
location /api {
|
||||
proxy_pass http://localhost:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🌐 远程访问配置
|
||||
|
||||
### 云服务器部署
|
||||
|
||||
1. **阿里云/腾讯云/AWS**:
|
||||
- 创建云服务器实例
|
||||
- 配置安全组,开放 8000 和 3000 端口
|
||||
- 使用上述部署方法
|
||||
|
||||
2. **域名配置**:
|
||||
- 购买域名并解析到服务器IP
|
||||
- 配置 HTTPS (使用 Let's Encrypt)
|
||||
|
||||
### 内网穿透 (开发测试)
|
||||
|
||||
使用 ngrok 进行内网穿透:
|
||||
|
||||
```bash
|
||||
# 安装 ngrok
|
||||
npm install -g ngrok
|
||||
|
||||
# 穿透后端
|
||||
ngrok http 8000
|
||||
|
||||
# 穿透前端
|
||||
ngrok http 3000
|
||||
```
|
||||
|
||||
## 📚 初中英语单词导入
|
||||
|
||||
系统已内置初中英语单词,启动后会自动导入。也可以手动导入:
|
||||
|
||||
### 1. 使用 API 导入
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8000/api/words/import" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-F "file=@initial_words.csv"
|
||||
```
|
||||
|
||||
### 2. 使用前端界面导入
|
||||
|
||||
1. 登录系统
|
||||
2. 进入"词库管理"页面
|
||||
3. 点击"导入单词"按钮
|
||||
4. 选择 CSV 或 JSON 文件
|
||||
|
||||
### 3. CSV 格式示例
|
||||
|
||||
```csv
|
||||
word,meaning,example,phrases,level
|
||||
abandon,放弃,He decided to abandon the project,abandon hope;abandon ship,middle_school
|
||||
ability,能力,She has the ability to learn quickly,have ability;ability to do,middle_school
|
||||
```
|
||||
|
||||
### 4. JSON 格式示例
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"word": "abandon",
|
||||
"meaning": "放弃",
|
||||
"example": "He decided to abandon the project",
|
||||
"phrases": ["abandon hope", "abandon ship"],
|
||||
"level": "middle_school"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## 🔧 环境变量配置
|
||||
|
||||
后端环境变量 (可选):
|
||||
|
||||
```bash
|
||||
# .env 文件
|
||||
SECRET_KEY=your-super-secret-key-here
|
||||
DATABASE_URL=sqlite:///./wordmaster.db
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
```
|
||||
|
||||
## 📊 功能特性
|
||||
|
||||
### 🧠 学习算法
|
||||
- **SM-2 间隔重复算法**:个性化复习间隔
|
||||
- **主动回忆模式**:输入答案,模糊匹配
|
||||
- **多种学习模式**:抽认卡、选择题、完形填空
|
||||
- **双向练习**:英→中、中→英自动切换
|
||||
|
||||
### 📈 智能分析
|
||||
- **学习统计**:准确率、连续天数、学习进度
|
||||
- **顽固词检测**:自动标记难记单词
|
||||
- **记忆曲线**:可视化复习预测
|
||||
|
||||
### 🎯 用户体验
|
||||
- **响应式设计**:支持电脑、平板、手机
|
||||
- **语音朗读**:TTS 发音功能
|
||||
- **快捷键支持**:键盘操作更高效
|
||||
- **离线缓存**:渐进式 Web 应用
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
### 后端
|
||||
- **FastAPI**:高性能 Python Web 框架
|
||||
- **SQLAlchemy**:ORM 数据库操作
|
||||
- **JWT**:用户认证
|
||||
- **Pydantic**:数据验证
|
||||
|
||||
### 前端
|
||||
- **Vue 3**:组合式 API
|
||||
- **Element Plus**:UI 组件库
|
||||
- **Pinia**:状态管理
|
||||
- **Vite**:构建工具
|
||||
|
||||
### 数据库
|
||||
- **SQLite**:轻量级数据库(可升级到 PostgreSQL/MySQL)
|
||||
|
||||
## 🔒 安全建议
|
||||
|
||||
1. **修改默认密钥**:
|
||||
```python
|
||||
SECRET_KEY = "your-production-secret-key-very-long-and-random"
|
||||
```
|
||||
|
||||
2. **CORS 配置**:
|
||||
```python
|
||||
allow_origins=["https://your-domain.com"] # 限制具体域名
|
||||
```
|
||||
|
||||
3. **HTTPS 部署**:使用 SSL 证书
|
||||
|
||||
4. **数据库安全**:定期备份,访问控制
|
||||
|
||||
## 📱 移动端适配
|
||||
|
||||
系统已完全适配移动端,支持:
|
||||
- 触摸手势操作
|
||||
- 响应式布局
|
||||
- PWA 渐进式应用
|
||||
- 离线使用
|
||||
|
||||
## 🤝 技术支持
|
||||
|
||||
如遇问题,请检查:
|
||||
1. 端口是否被占用
|
||||
2. 防火墙设置
|
||||
3. Node.js 和 Python 版本
|
||||
4. 网络连接状态
|
||||
|
||||
祝您学习愉快!🎉
|
||||
@@ -0,0 +1,55 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite3
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Uploads
|
||||
uploads/
|
||||
temp/
|
||||
*.tmp
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,55 @@
|
||||
word,meaning,example,phrases,level
|
||||
abandon,放弃,He decided to abandon the project,abandon hope;abandon ship,middle_school
|
||||
ability,能力,She has the ability to learn quickly,have ability;ability to do,middle_school
|
||||
able,能够的,I am able to help you,be able to;able student,middle_school
|
||||
about,关于;大约,Tell me about your day,about time;think about,middle_school
|
||||
above,在...上面,The bird flies above the clouds,above all;above average,middle_school
|
||||
abroad,在国外,She is studying abroad,go abroad;from abroad,middle_school
|
||||
absence,缺席,His absence was noticed,in absence of;absence from,middle_school
|
||||
absent,缺席的,Tom is absent from school today,absent from;absent minded,middle_school
|
||||
absolute,绝对的,That's absolute nonsense,absolute silence;absolute truth,middle_school
|
||||
absorb,吸收,Plants absorb sunlight,absorb water;absorb knowledge,middle_school
|
||||
abstract,抽象的,Math can be very abstract,abstract idea;abstract art,middle_school
|
||||
abundant,丰富的,There is abundant food here,abundant resources;abundant in,middle_school
|
||||
abuse,滥用;虐待,Don't abuse your power,drug abuse;child abuse,middle_school
|
||||
academic,学术的,She has good academic performance,academic year;academic achievement,middle_school
|
||||
academy,学院,He studies at the music academy,military academy;academy award,middle_school
|
||||
accelerate,加速,The car began to accelerate,accelerate development;accelerate growth,middle_school
|
||||
accent,口音,She speaks with a British accent,foreign accent;strong accent,middle_school
|
||||
accept,接受,I accept your invitation,accept offer;accept responsibility,middle_school
|
||||
access,接近;通道,Students have access to the library,have access to;access road,middle_school
|
||||
accident,事故,There was a car accident yesterday,traffic accident;by accident,middle_school
|
||||
accompany,陪伴,I will accompany you to the store,accompany someone;accompany with,middle_school
|
||||
accomplish,完成,We accomplished our goal,accomplish task;accomplish mission,middle_school
|
||||
according,根据,According to the news report,according to;according as,middle_school
|
||||
account,账户;叙述,Open a bank account,bank account;account for,middle_school
|
||||
accurate,准确的,Your answer is accurate,accurate information;accurate measurement,middle_school
|
||||
accuse,指控,They accused him of theft,accuse of;accuse someone,middle_school
|
||||
achieve,实现,Hard work helps achieve success,achieve goal;achieve success,middle_school
|
||||
acid,酸,Lemon contains citric acid,acid rain;acid test,middle_school
|
||||
acknowledge,承认,He acknowledged his mistake,acknowledge receipt;acknowledge contribution,middle_school
|
||||
acquire,获得,She acquired new skills,acquire knowledge;acquire habit,middle_school
|
||||
across,穿过,Walk across the street,across from;come across,middle_school
|
||||
act,行动;表演,We must act quickly,act on;act as,middle_school
|
||||
action,行动,Take action now,take action;in action,middle_school
|
||||
active,积极的,She is very active in sports,active role;active participation,middle_school
|
||||
activity,活动,School activities are fun,outdoor activity;social activity,middle_school
|
||||
actor,演员,He is a famous actor,movie actor;stage actor,middle_school
|
||||
actress,女演员,She is a talented actress,famous actress;leading actress,middle_school
|
||||
actual,实际的,What is the actual cost,actual situation;actual fact,middle_school
|
||||
acute,急性的;敏锐的,He has acute hearing,acute pain;acute angle,middle_school
|
||||
adapt,适应,Animals adapt to their environment,adapt to;adapt for,middle_school
|
||||
add,添加,Add sugar to the coffee,add up;add to,middle_school
|
||||
addition,加法;添加,In addition to studying,in addition;addition to,middle_school
|
||||
address,地址;演讲,What is your home address,home address;address book,middle_school
|
||||
adequate,足够的,We have adequate supplies,adequate amount;adequate preparation,middle_school
|
||||
adjust,调整,Adjust the temperature,adjust to;adjust for,middle_school
|
||||
administration,管理,School administration is efficient,public administration;administration building,middle_school
|
||||
admire,钦佩,I admire her courage,admire for;admire greatly,middle_school
|
||||
admit,承认;允许进入,He admitted his error,admit to;admit into,middle_school
|
||||
adopt,采用;收养,They decided to adopt a child,adopt policy;adopt child,middle_school
|
||||
adult,成年人,Adults need to vote,young adult;adult education,middle_school
|
||||
advance,前进;提前,Technology continues to advance,in advance;advance payment,middle_school
|
||||
advantage,优势,Hard work gives you an advantage,take advantage of;competitive advantage,middle_school
|
||||
adventure,冒险,They went on an adventure,adventure story;adventure trip,middle_school
|
||||
adverb,副词,Quickly is an adverb,adverb phrase;adverb clause,middle_school
|
||||
|
+1481
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn==0.24.0
|
||||
sqlalchemy==2.0.23
|
||||
pydantic==2.5.0
|
||||
PyJWT==2.8.0
|
||||
bcrypt==4.1.2
|
||||
pandas==2.1.4
|
||||
python-multipart==0.0.6
|
||||
@@ -0,0 +1 @@
|
||||
test content
|
||||
@@ -0,0 +1 @@
|
||||
54899
|
||||
@@ -0,0 +1,16 @@
|
||||
# 开发环境配置
|
||||
NODE_ENV=development
|
||||
VITE_MODE=development
|
||||
|
||||
# API配置 - 开发环境使用代理
|
||||
VITE_API_BASE_URL=/api
|
||||
VITE_FILE_BASE_URL=/api
|
||||
VITE_WS_BASE_URL=ws://localhost:8888/ws
|
||||
|
||||
# 后端服务地址 (仅用于代理配置)
|
||||
VITE_BACKEND_HOST=192.168.1.83
|
||||
VITE_BACKEND_PORT=8888
|
||||
|
||||
# 调试配置
|
||||
VITE_DEBUG=true
|
||||
VITE_LOG_LEVEL=debug
|
||||
@@ -0,0 +1 @@
|
||||
54914
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WordMaster Pro - 智能单词记忆大师</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "wordmaster-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"serve": "vite preview --port 3000"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.3.8",
|
||||
"vue-router": "^4.2.5",
|
||||
"pinia": "^2.1.7",
|
||||
"axios": "^1.6.2",
|
||||
"element-plus": "^2.4.4",
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"chart.js": "^4.4.0",
|
||||
"vue-chartjs": "^5.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.5.0",
|
||||
"vite": "^5.0.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
onMounted(() => {
|
||||
// 检查本地存储的token
|
||||
authStore.checkAuth()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#app {
|
||||
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.gradient-bg {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.glass-effect {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
.learning-card {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
color: white;
|
||||
text-align: center;
|
||||
min-height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.learning-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%);
|
||||
animation: rotate 20s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.word-display {
|
||||
font-size: 3em;
|
||||
font-weight: 700;
|
||||
margin-bottom: 20px;
|
||||
text-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.meaning-display {
|
||||
font-size: 1.5em;
|
||||
margin-bottom: 30px;
|
||||
opacity: 0.9;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.example-display {
|
||||
font-size: 1.1em;
|
||||
font-style: italic;
|
||||
opacity: 0.8;
|
||||
margin-bottom: 30px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 25px;
|
||||
border-radius: 15px;
|
||||
text-align: center;
|
||||
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.btn-gradient {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border-radius: 25px;
|
||||
cursor: pointer;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.btn-gradient:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.word-display {
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
.meaning-display {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,105 @@
|
||||
// 统一配置管理
|
||||
const config = {
|
||||
// 开发环境配置
|
||||
development: {
|
||||
// API 基础地址 - 开发环境使用代理或环境变量
|
||||
API_BASE_URL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
// 文件服务地址 - 开发环境使用代理或环境变量
|
||||
FILE_BASE_URL: import.meta.env.VITE_FILE_BASE_URL || '/api',
|
||||
// WebSocket 地址
|
||||
WS_BASE_URL: import.meta.env.VITE_WS_BASE_URL || 'ws://localhost:8888/ws',
|
||||
// 调试模式
|
||||
DEBUG: import.meta.env.VITE_DEBUG === 'true' || true
|
||||
},
|
||||
|
||||
// 生产环境配置
|
||||
production: {
|
||||
// API 基础地址 - 优先使用环境变量,兜底使用动态获取
|
||||
API_BASE_URL: import.meta.env.VITE_API_BASE_URL ||
|
||||
`${window.location.protocol}//${window.location.hostname}:8888/api`,
|
||||
// 文件服务地址
|
||||
FILE_BASE_URL: import.meta.env.VITE_FILE_BASE_URL ||
|
||||
`${window.location.protocol}//${window.location.hostname}:8888/api`,
|
||||
// WebSocket 地址
|
||||
WS_BASE_URL: import.meta.env.VITE_WS_BASE_URL ||
|
||||
`ws://${window.location.hostname}:8888/ws`,
|
||||
// 调试模式
|
||||
DEBUG: import.meta.env.VITE_DEBUG === 'true' || false
|
||||
},
|
||||
|
||||
// 测试环境配置
|
||||
test: {
|
||||
API_BASE_URL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8888/api',
|
||||
FILE_BASE_URL: import.meta.env.VITE_FILE_BASE_URL || 'http://localhost:8888/api',
|
||||
WS_BASE_URL: import.meta.env.VITE_WS_BASE_URL || 'ws://localhost:8888/ws',
|
||||
DEBUG: import.meta.env.VITE_DEBUG === 'true' || true
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前环境
|
||||
const getEnvironment = () => {
|
||||
// 优先使用 Vite 的环境变量
|
||||
if (import.meta.env.MODE) {
|
||||
return import.meta.env.MODE
|
||||
}
|
||||
// 兜底使用 NODE_ENV
|
||||
return process.env.NODE_ENV || 'development'
|
||||
}
|
||||
|
||||
// 获取当前环境的配置
|
||||
const getCurrentConfig = () => {
|
||||
const env = getEnvironment()
|
||||
return config[env] || config.development
|
||||
}
|
||||
|
||||
// 导出配置对象
|
||||
export const appConfig = getCurrentConfig()
|
||||
|
||||
// 导出具体配置项
|
||||
export const {
|
||||
API_BASE_URL,
|
||||
FILE_BASE_URL,
|
||||
WS_BASE_URL,
|
||||
DEBUG
|
||||
} = appConfig
|
||||
|
||||
// 工具函数:构建完整的API URL
|
||||
export const buildApiUrl = (endpoint) => {
|
||||
// 如果已经是完整URL,直接返回
|
||||
if (endpoint.startsWith('http')) {
|
||||
return endpoint
|
||||
}
|
||||
// 移除开头的斜杠,避免重复
|
||||
const cleanEndpoint = endpoint.replace(/^\/+/, '')
|
||||
return `${API_BASE_URL}/${cleanEndpoint}`
|
||||
}
|
||||
|
||||
// 工具函数:构建文件URL
|
||||
export const buildFileUrl = (endpoint, params = {}) => {
|
||||
const cleanEndpoint = endpoint.replace(/^\/+/, '')
|
||||
let url = `${FILE_BASE_URL}/${cleanEndpoint}`
|
||||
|
||||
// 添加查询参数
|
||||
if (Object.keys(params).length > 0) {
|
||||
const queryString = new URLSearchParams(params).toString()
|
||||
url += `?${queryString}`
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
// 工具函数:获取环境信息
|
||||
export const getEnvInfo = () => ({
|
||||
environment: getEnvironment(),
|
||||
config: getCurrentConfig(),
|
||||
isProduction: getEnvironment() === 'production',
|
||||
isDevelopment: getEnvironment() === 'development'
|
||||
})
|
||||
|
||||
// 调试模式下输出配置信息
|
||||
if (DEBUG) {
|
||||
console.log('App Config:', appConfig)
|
||||
console.log('Environment:', getEnvironment())
|
||||
}
|
||||
|
||||
export default appConfig
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// 注册所有图标
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,108 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import Login from '../views/Login.vue'
|
||||
import Register from '../views/Register.vue'
|
||||
import Learning from '../views/Learning.vue'
|
||||
import Statistics from '../views/Statistics.vue'
|
||||
import WordManagement from '../views/WordManagement.vue'
|
||||
import MainDashboard from '../views/MainDashboard.vue'
|
||||
import SubjectPapers from '../views/SubjectPapers.vue'
|
||||
import PaperDetail from '../views/PaperDetail.vue'
|
||||
import PaperViewer from '../views/PaperViewer.vue'
|
||||
import MistakeReview from '../views/MistakeReview.vue'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'MainDashboard',
|
||||
component: MainDashboard,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
redirect: '/'
|
||||
},
|
||||
{
|
||||
path: '/subjects/:subject',
|
||||
name: 'SubjectPapers',
|
||||
component: SubjectPapers,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/papers/:id',
|
||||
name: 'PaperDetail',
|
||||
component: PaperDetail,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/papers/:id/view',
|
||||
name: 'PaperViewer',
|
||||
component: PaperViewer,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/review',
|
||||
name: 'MistakeReview',
|
||||
component: MistakeReview,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/review/today',
|
||||
name: 'TodayReview',
|
||||
component: MistakeReview,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/words',
|
||||
name: 'WordLearning',
|
||||
component: Learning,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/learning',
|
||||
name: 'Learning',
|
||||
component: Learning,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/statistics',
|
||||
name: 'Statistics',
|
||||
component: Statistics,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/word-management',
|
||||
name: 'WordManagement',
|
||||
component: WordManagement,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: Login
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: Register
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||
next('/login')
|
||||
} else if (to.path === '/login' && authStore.isAuthenticated) {
|
||||
next('/')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,46 @@
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { API_BASE_URL } from '../config'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
return response
|
||||
},
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/login'
|
||||
ElMessage.error('登录已过期,请重新登录')
|
||||
} else if (error.response?.status === 422) {
|
||||
ElMessage.error('请求参数错误')
|
||||
} else {
|
||||
ElMessage.error(error.response?.data?.detail || '网络错误')
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default api
|
||||
@@ -0,0 +1,65 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import api from '../services/api'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref(localStorage.getItem('token'))
|
||||
const user = ref(null)
|
||||
|
||||
const isAuthenticated = computed(() => !!token.value)
|
||||
|
||||
const setToken = (newToken) => {
|
||||
token.value = newToken
|
||||
localStorage.setItem('token', newToken)
|
||||
api.defaults.headers.common['Authorization'] = `Bearer ${newToken}`
|
||||
}
|
||||
|
||||
const clearAuth = () => {
|
||||
token.value = null
|
||||
user.value = null
|
||||
localStorage.removeItem('token')
|
||||
delete api.defaults.headers.common['Authorization']
|
||||
}
|
||||
|
||||
const login = async (credentials) => {
|
||||
try {
|
||||
const response = await api.post('/auth/login', credentials)
|
||||
const { access_token } = response.data
|
||||
setToken(access_token)
|
||||
return true
|
||||
} catch (error) {
|
||||
throw new Error(error.response?.data?.detail || '登录失败')
|
||||
}
|
||||
}
|
||||
|
||||
const register = async (userData) => {
|
||||
try {
|
||||
const response = await api.post('/auth/register', userData)
|
||||
const { access_token } = response.data
|
||||
setToken(access_token)
|
||||
return true
|
||||
} catch (error) {
|
||||
throw new Error(error.response?.data?.detail || '注册失败')
|
||||
}
|
||||
}
|
||||
|
||||
const checkAuth = () => {
|
||||
if (token.value) {
|
||||
api.defaults.headers.common['Authorization'] = `Bearer ${token.value}`
|
||||
}
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
clearAuth()
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
isAuthenticated,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
checkAuth
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import api from '../services/api'
|
||||
|
||||
export const useWordStore = defineStore('word', () => {
|
||||
const words = ref([])
|
||||
const dueWords = ref([])
|
||||
const currentWord = ref(null)
|
||||
const currentIndex = ref(0)
|
||||
const sessionWords = ref([])
|
||||
const learningMode = ref('flashcard')
|
||||
const direction = ref('en2zh')
|
||||
|
||||
const fetchWords = async (level = null) => {
|
||||
try {
|
||||
const params = level ? { level } : {}
|
||||
const response = await api.get('/words', { params })
|
||||
words.value = response.data
|
||||
return response.data
|
||||
} catch (error) {
|
||||
throw new Error('获取单词失败')
|
||||
}
|
||||
}
|
||||
|
||||
const fetchDueWords = async () => {
|
||||
try {
|
||||
const response = await api.get('/study/due')
|
||||
dueWords.value = response.data
|
||||
sessionWords.value = [...response.data]
|
||||
return response.data
|
||||
} catch (error) {
|
||||
throw new Error('获取待学习单词失败')
|
||||
}
|
||||
}
|
||||
|
||||
const submitStudySession = async (wordId, quality, timeSpent = 0) => {
|
||||
try {
|
||||
const response = await api.post('/study/submit', {
|
||||
word_id: wordId,
|
||||
quality,
|
||||
time_spent: timeSpent
|
||||
})
|
||||
return response.data
|
||||
} catch (error) {
|
||||
throw new Error('提交学习结果失败')
|
||||
}
|
||||
}
|
||||
|
||||
const importWords = async (file) => {
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const response = await api.post('/words/import', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
return response.data
|
||||
} catch (error) {
|
||||
throw new Error(error.response?.data?.detail || '导入单词失败')
|
||||
}
|
||||
}
|
||||
|
||||
const createWord = async (wordData) => {
|
||||
try {
|
||||
const response = await api.post('/words', wordData)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
throw new Error('创建单词失败')
|
||||
}
|
||||
}
|
||||
|
||||
const nextWord = () => {
|
||||
if (currentIndex.value < sessionWords.value.length) {
|
||||
currentWord.value = sessionWords.value[currentIndex.value]
|
||||
currentIndex.value++
|
||||
return currentWord.value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const startNewSession = async () => {
|
||||
await fetchDueWords()
|
||||
currentIndex.value = 0
|
||||
if (sessionWords.value.length > 0) {
|
||||
currentWord.value = sessionWords.value[0]
|
||||
currentIndex.value = 1
|
||||
}
|
||||
}
|
||||
|
||||
const toggleDirection = () => {
|
||||
direction.value = direction.value === 'en2zh' ? 'zh2en' : 'en2zh'
|
||||
}
|
||||
|
||||
const setLearningMode = (mode) => {
|
||||
learningMode.value = mode
|
||||
}
|
||||
|
||||
return {
|
||||
words,
|
||||
dueWords,
|
||||
currentWord,
|
||||
currentIndex,
|
||||
sessionWords,
|
||||
learningMode,
|
||||
direction,
|
||||
fetchWords,
|
||||
fetchDueWords,
|
||||
submitStudySession,
|
||||
importWords,
|
||||
createWord,
|
||||
nextWord,
|
||||
startNewSession,
|
||||
toggleDirection,
|
||||
setLearningMode
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,461 @@
|
||||
<template>
|
||||
<div class="gradient-bg">
|
||||
<div class="dashboard-container">
|
||||
<!-- 导航栏 -->
|
||||
<el-header class="header glass-effect">
|
||||
<div class="header-content">
|
||||
<h1>🧠 WordMaster Pro</h1>
|
||||
<el-menu
|
||||
:default-active="$route.name"
|
||||
mode="horizontal"
|
||||
:router="true"
|
||||
background-color="transparent"
|
||||
text-color="white"
|
||||
active-text-color="#00f2fe"
|
||||
>
|
||||
<el-menu-item index="Dashboard">📊 仪表盘</el-menu-item>
|
||||
<el-menu-item index="Learning">🎯 学习</el-menu-item>
|
||||
<el-menu-item index="Statistics">📈 统计</el-menu-item>
|
||||
<el-menu-item index="WordManagement">📚 词库</el-menu-item>
|
||||
</el-menu>
|
||||
<el-button @click="logout" type="danger" plain>退出登录</el-button>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<el-main class="main-content">
|
||||
<div class="dashboard-grid">
|
||||
<!-- 学习状态卡片 -->
|
||||
<el-row :gutter="20" style="margin-bottom: 20px;">
|
||||
<el-col :span="6">
|
||||
<div class="stats-card">
|
||||
<h3>{{ stats.total_words }}</h3>
|
||||
<p>总词汇量</p>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stats-card">
|
||||
<h3>{{ stats.due_today }}</h3>
|
||||
<p>今日待复习</p>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stats-card">
|
||||
<h3>{{ stats.completed_today }}</h3>
|
||||
<p>今日已完成</p>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stats-card">
|
||||
<h3>{{ Math.round(stats.accuracy_rate) }}%</h3>
|
||||
<p>准确率</p>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 快速开始 -->
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-card class="glass-effect quick-start-card">
|
||||
<template #header>
|
||||
<h2>🚀 快速开始</h2>
|
||||
</template>
|
||||
<div class="quick-actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="startLearning"
|
||||
:disabled="stats.due_today === 0"
|
||||
class="btn-gradient"
|
||||
>
|
||||
开始学习 ({{ stats.due_today }} 个单词)
|
||||
</el-button>
|
||||
<el-button
|
||||
size="large"
|
||||
@click="quickReview"
|
||||
class="btn-gradient"
|
||||
>
|
||||
快速复习 (3分钟)
|
||||
</el-button>
|
||||
<el-button
|
||||
size="large"
|
||||
@click="reviewLeechWords"
|
||||
class="btn-gradient"
|
||||
>
|
||||
顽固词汇专练
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="12">
|
||||
<el-card class="glass-effect">
|
||||
<template #header>
|
||||
<h2>📊 学习进度</h2>
|
||||
</template>
|
||||
<div class="progress-info">
|
||||
<div class="progress-item">
|
||||
<span>今日完成度</span>
|
||||
<el-progress
|
||||
:percentage="todayProgress"
|
||||
:color="progressColor"
|
||||
/>
|
||||
</div>
|
||||
<div class="progress-item">
|
||||
<span>连续学习天数</span>
|
||||
<div class="streak-days">{{ stats.streak_days }} 天</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 词汇管理 -->
|
||||
<el-row :gutter="20" style="margin-top: 20px;">
|
||||
<el-col :span="24">
|
||||
<el-card class="glass-effect">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<h2>📚 词汇管理</h2>
|
||||
<div>
|
||||
<el-upload
|
||||
:before-upload="handleImport"
|
||||
:show-file-list="false"
|
||||
accept=".csv,.json"
|
||||
style="display: inline-block; margin-right: 10px;"
|
||||
>
|
||||
<el-button type="primary" icon="Upload">导入单词</el-button>
|
||||
</el-upload>
|
||||
<el-button @click="showAddWordDialog = true" icon="Plus">添加单词</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-alert
|
||||
title="提示"
|
||||
description="支持导入CSV和JSON格式的单词文件。CSV格式:word,meaning,example,phrases,level"
|
||||
type="info"
|
||||
show-icon
|
||||
:closable="false"
|
||||
style="margin-bottom: 20px;"
|
||||
/>
|
||||
|
||||
<div class="recent-words">
|
||||
<h3>最近添加的单词</h3>
|
||||
<el-table :data="recentWords" style="width: 100%">
|
||||
<el-table-column prop="word" label="单词" width="150" />
|
||||
<el-table-column prop="meaning" label="中文意思" width="200" />
|
||||
<el-table-column prop="example" label="例句" />
|
||||
<el-table-column prop="level" label="级别" width="100" />
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</el-main>
|
||||
</div>
|
||||
|
||||
<!-- 添加单词对话框 -->
|
||||
<el-dialog v-model="showAddWordDialog" title="添加新单词" width="500px">
|
||||
<el-form :model="newWord" label-width="80px">
|
||||
<el-form-item label="单词" required>
|
||||
<el-input v-model="newWord.word" placeholder="请输入英文单词" />
|
||||
</el-form-item>
|
||||
<el-form-item label="中文意思" required>
|
||||
<el-input v-model="newWord.meaning" placeholder="请输入中文意思" />
|
||||
</el-form-item>
|
||||
<el-form-item label="例句">
|
||||
<el-input
|
||||
v-model="newWord.example"
|
||||
type="textarea"
|
||||
placeholder="请输入例句(可选)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="级别">
|
||||
<el-select v-model="newWord.level" placeholder="请选择级别">
|
||||
<el-option label="小学" value="elementary" />
|
||||
<el-option label="初中" value="middle_school" />
|
||||
<el-option label="高中" value="high_school" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showAddWordDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="addWord" :loading="addingWord">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useWordStore } from '../stores/word'
|
||||
import api from '../services/api'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const wordStore = useWordStore()
|
||||
|
||||
const stats = ref({
|
||||
total_words: 0,
|
||||
due_today: 0,
|
||||
completed_today: 0,
|
||||
accuracy_rate: 0,
|
||||
streak_days: 1
|
||||
})
|
||||
|
||||
const recentWords = ref([])
|
||||
const showAddWordDialog = ref(false)
|
||||
const addingWord = ref(false)
|
||||
|
||||
const newWord = reactive({
|
||||
word: '',
|
||||
meaning: '',
|
||||
example: '',
|
||||
level: 'middle_school'
|
||||
})
|
||||
|
||||
const todayProgress = computed(() => {
|
||||
if (stats.value.due_today === 0) return 100
|
||||
return Math.round((stats.value.completed_today / (stats.value.due_today + stats.value.completed_today)) * 100)
|
||||
})
|
||||
|
||||
const progressColor = computed(() => {
|
||||
if (todayProgress.value >= 80) return '#67c23a'
|
||||
if (todayProgress.value >= 50) return '#e6a23c'
|
||||
return '#f56c6c'
|
||||
})
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const response = await api.get('/stats')
|
||||
stats.value = response.data
|
||||
} catch (error) {
|
||||
ElMessage.error('获取统计信息失败')
|
||||
}
|
||||
}
|
||||
|
||||
const fetchRecentWords = async () => {
|
||||
try {
|
||||
const response = await wordStore.fetchWords()
|
||||
recentWords.value = response.slice(-5).reverse()
|
||||
} catch (error) {
|
||||
ElMessage.error('获取单词列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
const startLearning = () => {
|
||||
router.push('/learning')
|
||||
}
|
||||
|
||||
const quickReview = () => {
|
||||
router.push('/learning?mode=quick')
|
||||
}
|
||||
|
||||
const reviewLeechWords = () => {
|
||||
router.push('/learning?mode=leech')
|
||||
}
|
||||
|
||||
const handleImport = async (file) => {
|
||||
try {
|
||||
await wordStore.importWords(file)
|
||||
ElMessage.success('导入成功')
|
||||
fetchStats()
|
||||
fetchRecentWords()
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message)
|
||||
}
|
||||
return false // 阻止自动上传
|
||||
}
|
||||
|
||||
const addWord = async () => {
|
||||
if (!newWord.word || !newWord.meaning) {
|
||||
ElMessage.error('请填写单词和中文意思')
|
||||
return
|
||||
}
|
||||
|
||||
addingWord.value = true
|
||||
try {
|
||||
await wordStore.createWord(newWord)
|
||||
ElMessage.success('添加成功')
|
||||
showAddWordDialog.value = false
|
||||
Object.assign(newWord, {
|
||||
word: '',
|
||||
meaning: '',
|
||||
example: '',
|
||||
level: 'middle_school'
|
||||
})
|
||||
fetchStats()
|
||||
fetchRecentWords()
|
||||
} catch (error) {
|
||||
ElMessage.error('添加失败')
|
||||
} finally {
|
||||
addingWord.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchStats()
|
||||
fetchRecentWords()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: white;
|
||||
margin: 0;
|
||||
font-size: 1.5em;
|
||||
text-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.quick-start-card {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.quick-actions .el-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.progress-item {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.progress-item span {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.streak-days {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
color: #00f2fe;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
margin: 0;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.recent-words h3 {
|
||||
color: white;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
:deep(.el-card__header) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
:deep(.el-card__body) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
:deep(.el-table) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:deep(.el-table th) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
:deep(.el-table td) {
|
||||
background: transparent;
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
:deep(.el-table tr) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:deep(.el-table--border) {
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
:deep(.el-menu--horizontal) {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
:deep(.el-menu-item) {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,759 @@
|
||||
<template>
|
||||
<div class="gradient-bg">
|
||||
<div class="learning-container">
|
||||
<!-- 导航栏 -->
|
||||
<el-header class="header glass-effect">
|
||||
<div class="header-content">
|
||||
<el-button @click="$router.back()" icon="ArrowLeft" circle />
|
||||
<h1>🎯 单词学习</h1>
|
||||
<div class="header-actions">
|
||||
<el-button @click="toggleDirection" icon="Refresh">
|
||||
{{ direction === 'en2zh' ? '英→中' : '中→英' }}
|
||||
</el-button>
|
||||
<el-button @click="speakWord" icon="VideoPlay" circle v-if="currentWord" />
|
||||
</div>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<!-- 学习模式选择 -->
|
||||
<div class="mode-selector">
|
||||
<el-button-group>
|
||||
<el-button
|
||||
:type="learningMode === 'flashcard' ? 'primary' : ''"
|
||||
@click="setLearningMode('flashcard')"
|
||||
>
|
||||
🃏 抽认卡
|
||||
</el-button>
|
||||
<el-button
|
||||
:type="learningMode === 'typing' ? 'primary' : ''"
|
||||
@click="setLearningMode('typing')"
|
||||
>
|
||||
⌨️ 输入
|
||||
</el-button>
|
||||
<el-button
|
||||
:type="learningMode === 'choice' ? 'primary' : ''"
|
||||
@click="setLearningMode('choice')"
|
||||
>
|
||||
🎯 选择
|
||||
</el-button>
|
||||
<el-button
|
||||
:type="learningMode === 'cloze' ? 'primary' : ''"
|
||||
@click="setLearningMode('cloze')"
|
||||
>
|
||||
📝 完形填空
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
</div>
|
||||
|
||||
<!-- 学习卡片 -->
|
||||
<div class="learning-content">
|
||||
<div v-if="!currentWord" class="learning-card">
|
||||
<div class="content">
|
||||
<div class="word-display">准备开始学习</div>
|
||||
<div class="meaning-display">点击开始按钮开始学习之旅</div>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="startLearning"
|
||||
class="btn-gradient"
|
||||
>
|
||||
🚀 开始学习
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="learning-card">
|
||||
<div class="content">
|
||||
<!-- 抽认卡模式 -->
|
||||
<div v-if="learningMode === 'flashcard'">
|
||||
<div class="word-display">{{ displayWord }}</div>
|
||||
<div class="meaning-display">{{ displayMeaning }}</div>
|
||||
<div class="example-display" v-if="currentWord.example">
|
||||
{{ currentWord.example }}
|
||||
</div>
|
||||
|
||||
<!-- SM-2评分按钮 -->
|
||||
<div class="rating-buttons">
|
||||
<el-button
|
||||
v-for="(rating, index) in ratings"
|
||||
:key="index"
|
||||
:type="getRatingType(index)"
|
||||
@click="rateWord(index)"
|
||||
size="large"
|
||||
round
|
||||
>
|
||||
{{ index }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="rating-labels">
|
||||
<span v-for="(rating, index) in ratings" :key="index">
|
||||
{{ rating }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输入模式 -->
|
||||
<div v-if="learningMode === 'typing'">
|
||||
<div class="word-display">{{ direction === 'en2zh' ? currentWord.word : currentWord.meaning }}</div>
|
||||
<div class="meaning-display">
|
||||
请输入{{ direction === 'en2zh' ? '中文意思' : '英文单词' }}:
|
||||
</div>
|
||||
<div class="example-display" v-if="currentWord.example">
|
||||
{{ currentWord.example }}
|
||||
</div>
|
||||
|
||||
<div class="input-area">
|
||||
<el-input
|
||||
v-model="userInput"
|
||||
size="large"
|
||||
placeholder="请输入答案..."
|
||||
@keyup.enter="checkAnswer"
|
||||
ref="inputRef"
|
||||
/>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="checkAnswer"
|
||||
:disabled="!userInput.trim()"
|
||||
style="margin-top: 15px;"
|
||||
>
|
||||
检查答案
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 答案反馈 -->
|
||||
<div v-if="showResult" class="result-feedback">
|
||||
<div :class="['result-text', isCorrect ? 'correct' : 'incorrect']">
|
||||
{{ isCorrect ? '✓ 正确!' : '✗ 错误!' }}
|
||||
</div>
|
||||
<div class="correct-answer" v-if="!isCorrect">
|
||||
正确答案:{{ correctAnswer }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 选择模式 -->
|
||||
<div v-if="learningMode === 'choice'">
|
||||
<div class="word-display">{{ direction === 'en2zh' ? currentWord.word : currentWord.meaning }}</div>
|
||||
<div class="meaning-display">
|
||||
请选择正确的{{ direction === 'en2zh' ? '中文意思' : '英文单词' }}:
|
||||
</div>
|
||||
|
||||
<div class="choices">
|
||||
<el-button
|
||||
v-for="(choice, index) in choices"
|
||||
:key="index"
|
||||
size="large"
|
||||
@click="selectChoice(choice, index)"
|
||||
:disabled="choiceSelected"
|
||||
:type="getChoiceType(choice, index)"
|
||||
style="margin: 5px; width: 45%;"
|
||||
>
|
||||
{{ direction === 'en2zh' ? choice.meaning : choice.word }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 完形填空模式 -->
|
||||
<div v-if="learningMode === 'cloze'">
|
||||
<div class="word-display">完形填空</div>
|
||||
<div class="meaning-display">{{ currentWord.meaning }}</div>
|
||||
<div class="cloze-sentence">{{ clozeText }}</div>
|
||||
|
||||
<div class="input-area">
|
||||
<el-input
|
||||
v-model="userInput"
|
||||
size="large"
|
||||
placeholder="请填入空缺的单词..."
|
||||
@keyup.enter="checkAnswer"
|
||||
ref="inputRef"
|
||||
/>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="checkAnswer"
|
||||
:disabled="!userInput.trim()"
|
||||
style="margin-top: 15px;"
|
||||
>
|
||||
检查答案
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 答案反馈 -->
|
||||
<div v-if="showResult" class="result-feedback">
|
||||
<div :class="['result-text', isCorrect ? 'correct' : 'incorrect']">
|
||||
{{ isCorrect ? '✓ 正确!' : '✗ 错误!' }}
|
||||
</div>
|
||||
<div class="correct-answer" v-if="!isCorrect">
|
||||
正确答案:{{ currentWord.word }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div class="progress-area" v-if="sessionWords.length > 0">
|
||||
<el-progress
|
||||
:percentage="sessionProgress"
|
||||
:color="progressColor"
|
||||
:stroke-width="8"
|
||||
/>
|
||||
<div class="progress-text">
|
||||
进度:{{ currentIndex }} / {{ sessionWords.length }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 控制按钮 -->
|
||||
<div class="controls">
|
||||
<el-button @click="nextWord" size="large" :disabled="!currentWord">
|
||||
⏭️ 下一个
|
||||
</el-button>
|
||||
<el-button @click="skipWord" size="large" :disabled="!currentWord">
|
||||
⏩ 跳过
|
||||
</el-button>
|
||||
<el-button @click="endSession" size="large" type="danger">
|
||||
🛑 结束学习
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useWordStore } from '../stores/word'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const wordStore = useWordStore()
|
||||
|
||||
// 响应式数据
|
||||
const currentWord = ref(null)
|
||||
const sessionWords = ref([])
|
||||
const currentIndex = ref(0)
|
||||
const learningMode = ref('flashcard')
|
||||
const direction = ref('en2zh')
|
||||
const userInput = ref('')
|
||||
const showResult = ref(false)
|
||||
const isCorrect = ref(false)
|
||||
const correctAnswer = ref('')
|
||||
const choices = ref([])
|
||||
const choiceSelected = ref(false)
|
||||
const selectedChoiceIndex = ref(-1)
|
||||
const clozeText = ref('')
|
||||
const inputRef = ref()
|
||||
|
||||
const ratings = ['完全不记得', '有印象', '想起来了', '记得', '很容易', '太容易']
|
||||
|
||||
// 计算属性
|
||||
const displayWord = computed(() => {
|
||||
if (!currentWord.value) return ''
|
||||
return direction.value === 'en2zh' ? currentWord.value.word : currentWord.value.meaning
|
||||
})
|
||||
|
||||
const displayMeaning = computed(() => {
|
||||
if (!currentWord.value) return ''
|
||||
return direction.value === 'en2zh' ? currentWord.value.meaning : currentWord.value.word
|
||||
})
|
||||
|
||||
const sessionProgress = computed(() => {
|
||||
if (sessionWords.value.length === 0) return 0
|
||||
return Math.round((currentIndex.value / sessionWords.value.length) * 100)
|
||||
})
|
||||
|
||||
const progressColor = computed(() => {
|
||||
if (sessionProgress.value >= 80) return '#67c23a'
|
||||
if (sessionProgress.value >= 50) return '#e6a23c'
|
||||
return '#f56c6c'
|
||||
})
|
||||
|
||||
// 方法
|
||||
const startLearning = async () => {
|
||||
try {
|
||||
await wordStore.startNewSession()
|
||||
sessionWords.value = wordStore.sessionWords
|
||||
currentWord.value = wordStore.currentWord
|
||||
currentIndex.value = wordStore.currentIndex
|
||||
|
||||
if (sessionWords.value.length === 0) {
|
||||
ElMessage.info('暂无需要学习的单词')
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.success(`开始学习,共 ${sessionWords.value.length} 个单词`)
|
||||
displayCurrentWord()
|
||||
} catch (error) {
|
||||
ElMessage.error('获取学习单词失败')
|
||||
}
|
||||
}
|
||||
|
||||
const setLearningMode = (mode) => {
|
||||
learningMode.value = mode
|
||||
wordStore.setLearningMode(mode)
|
||||
|
||||
// 重置状态
|
||||
resetCurrentState()
|
||||
|
||||
// 重新显示当前单词
|
||||
if (currentWord.value) {
|
||||
displayCurrentWord()
|
||||
}
|
||||
}
|
||||
|
||||
const toggleDirection = () => {
|
||||
direction.value = direction.value === 'en2zh' ? 'zh2en' : 'en2zh'
|
||||
wordStore.toggleDirection()
|
||||
|
||||
// 重置状态
|
||||
resetCurrentState()
|
||||
|
||||
// 重新显示当前单词
|
||||
if (currentWord.value) {
|
||||
displayCurrentWord()
|
||||
}
|
||||
}
|
||||
|
||||
const displayCurrentWord = () => {
|
||||
if (!currentWord.value) return
|
||||
|
||||
resetCurrentState()
|
||||
|
||||
switch (learningMode.value) {
|
||||
case 'choice':
|
||||
generateChoices()
|
||||
break
|
||||
case 'cloze':
|
||||
generateCloze()
|
||||
break
|
||||
case 'typing':
|
||||
nextTick(() => {
|
||||
if (inputRef.value) {
|
||||
inputRef.value.focus()
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
// 自动发音
|
||||
if (direction.value === 'en2zh') {
|
||||
setTimeout(() => speakWord(), 500)
|
||||
}
|
||||
}
|
||||
|
||||
const generateChoices = () => {
|
||||
if (!currentWord.value) return
|
||||
|
||||
// 简单实现:随机选择其他单词作为干扰项
|
||||
const otherWords = sessionWords.value.filter(w => w.id !== currentWord.value.id)
|
||||
const distractors = otherWords.slice(0, 3)
|
||||
|
||||
choices.value = [currentWord.value, ...distractors]
|
||||
|
||||
// 随机打乱选项
|
||||
for (let i = choices.value.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[choices.value[i], choices.value[j]] = [choices.value[j], choices.value[i]]
|
||||
}
|
||||
}
|
||||
|
||||
const generateCloze = () => {
|
||||
if (!currentWord.value || !currentWord.value.example) {
|
||||
// 如果没有例句,使用短语
|
||||
if (currentWord.value.phrases && currentWord.value.phrases.length > 0) {
|
||||
const phrase = currentWord.value.phrases[Math.floor(Math.random() * currentWord.value.phrases.length)]
|
||||
clozeText.value = phrase.replace(new RegExp(currentWord.value.word, 'i'), '____')
|
||||
} else {
|
||||
// 降级到输入模式
|
||||
learningMode.value = 'typing'
|
||||
displayCurrentWord()
|
||||
return
|
||||
}
|
||||
} else {
|
||||
clozeText.value = currentWord.value.example.replace(
|
||||
new RegExp(`\\b${currentWord.value.word}\\b`, 'i'),
|
||||
'____'
|
||||
)
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
if (inputRef.value) {
|
||||
inputRef.value.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const checkAnswer = () => {
|
||||
if (!userInput.value.trim()) return
|
||||
|
||||
const input = userInput.value.trim().toLowerCase()
|
||||
let target = ''
|
||||
|
||||
if (learningMode.value === 'typing') {
|
||||
target = direction.value === 'en2zh' ? currentWord.value.meaning : currentWord.value.word
|
||||
} else if (learningMode.value === 'cloze') {
|
||||
target = currentWord.value.word
|
||||
}
|
||||
|
||||
correctAnswer.value = target
|
||||
isCorrect.value = isAnswerCorrect(input, target.toLowerCase())
|
||||
showResult.value = true
|
||||
|
||||
// 提交学习结果
|
||||
const quality = isCorrect.value ? 4 : 1
|
||||
submitLearningResult(quality)
|
||||
|
||||
// 2秒后自动下一题
|
||||
setTimeout(() => {
|
||||
nextWord()
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
const isAnswerCorrect = (input, target) => {
|
||||
// 完全匹配
|
||||
if (input === target) return true
|
||||
|
||||
// 模糊匹配(简单实现)
|
||||
const similarity = calculateSimilarity(input, target)
|
||||
return similarity >= 0.8
|
||||
}
|
||||
|
||||
const calculateSimilarity = (str1, str2) => {
|
||||
const len1 = str1.length
|
||||
const len2 = str2.length
|
||||
const maxLen = Math.max(len1, len2)
|
||||
|
||||
if (maxLen === 0) return 1
|
||||
|
||||
// 计算编辑距离
|
||||
const dp = Array(len1 + 1).fill().map(() => Array(len2 + 1).fill(0))
|
||||
|
||||
for (let i = 0; i <= len1; i++) dp[i][0] = i
|
||||
for (let j = 0; j <= len2; j++) dp[0][j] = j
|
||||
|
||||
for (let i = 1; i <= len1; i++) {
|
||||
for (let j = 1; j <= len2; j++) {
|
||||
const cost = str1[i - 1] === str2[j - 1] ? 0 : 1
|
||||
dp[i][j] = Math.min(
|
||||
dp[i - 1][j] + 1,
|
||||
dp[i][j - 1] + 1,
|
||||
dp[i - 1][j - 1] + cost
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return 1 - dp[len1][len2] / maxLen
|
||||
}
|
||||
|
||||
const selectChoice = (choice, index) => {
|
||||
if (choiceSelected.value) return
|
||||
|
||||
choiceSelected.value = true
|
||||
selectedChoiceIndex.value = index
|
||||
|
||||
const isCorrectChoice = choice.id === currentWord.value.id
|
||||
const quality = isCorrectChoice ? 4 : 1
|
||||
|
||||
submitLearningResult(quality)
|
||||
|
||||
// 1.5秒后自动下一题
|
||||
setTimeout(() => {
|
||||
nextWord()
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
const rateWord = (quality) => {
|
||||
submitLearningResult(quality)
|
||||
|
||||
// 1秒后自动下一题
|
||||
setTimeout(() => {
|
||||
nextWord()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const submitLearningResult = async (quality) => {
|
||||
try {
|
||||
await wordStore.submitStudySession(currentWord.value.id, quality)
|
||||
} catch (error) {
|
||||
console.error('提交学习结果失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const nextWord = () => {
|
||||
if (currentIndex.value >= sessionWords.value.length) {
|
||||
endSession()
|
||||
return
|
||||
}
|
||||
|
||||
currentWord.value = sessionWords.value[currentIndex.value]
|
||||
currentIndex.value++
|
||||
|
||||
displayCurrentWord()
|
||||
}
|
||||
|
||||
const skipWord = () => {
|
||||
// 跳过的单词评分为2
|
||||
submitLearningResult(2)
|
||||
nextWord()
|
||||
}
|
||||
|
||||
const endSession = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要结束学习吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
ElMessage.success('学习完成!')
|
||||
router.push('/dashboard')
|
||||
} catch {
|
||||
// 取消操作
|
||||
}
|
||||
}
|
||||
|
||||
const resetCurrentState = () => {
|
||||
userInput.value = ''
|
||||
showResult.value = false
|
||||
isCorrect.value = false
|
||||
correctAnswer.value = ''
|
||||
choiceSelected.value = false
|
||||
selectedChoiceIndex.value = -1
|
||||
choices.value = []
|
||||
clozeText.value = ''
|
||||
}
|
||||
|
||||
const speakWord = () => {
|
||||
if (!currentWord.value) return
|
||||
|
||||
const text = currentWord.value.word
|
||||
const utterance = new SpeechSynthesisUtterance(text)
|
||||
utterance.lang = 'en-US'
|
||||
utterance.rate = 1
|
||||
utterance.pitch = 1
|
||||
speechSynthesis.speak(utterance)
|
||||
}
|
||||
|
||||
const getRatingType = (index) => {
|
||||
if (index <= 2) return 'danger'
|
||||
if (index <= 3) return 'warning'
|
||||
return 'success'
|
||||
}
|
||||
|
||||
const getChoiceType = (choice, index) => {
|
||||
if (!choiceSelected.value) return ''
|
||||
|
||||
if (choice.id === currentWord.value.id) return 'success'
|
||||
if (index === selectedChoiceIndex.value) return 'danger'
|
||||
return ''
|
||||
}
|
||||
|
||||
// 监听学习模式变化
|
||||
watch(learningMode, () => {
|
||||
if (currentWord.value) {
|
||||
displayCurrentWord()
|
||||
}
|
||||
})
|
||||
|
||||
// 检查路由参数
|
||||
onMounted(() => {
|
||||
const mode = route.query.mode
|
||||
if (mode === 'quick') {
|
||||
ElMessage.info('快速复习模式(3分钟)')
|
||||
} else if (mode === 'leech') {
|
||||
ElMessage.info('顽固词汇专练模式')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.learning-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: white;
|
||||
margin: 0;
|
||||
font-size: 1.5em;
|
||||
text-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mode-selector {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.learning-content {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input-area {
|
||||
margin: 30px 0;
|
||||
}
|
||||
|
||||
.input-area .el-input {
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.choices {
|
||||
margin: 30px 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rating-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
margin: 30px 0 10px 0;
|
||||
}
|
||||
|
||||
.rating-labels {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 22px;
|
||||
font-size: 0.8em;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.result-feedback {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.result-text {
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.result-text.correct {
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.result-text.incorrect {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.correct-answer {
|
||||
font-size: 1.2em;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.cloze-sentence {
|
||||
font-size: 1.3em;
|
||||
line-height: 1.6;
|
||||
margin: 20px 0;
|
||||
padding: 20px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
color: white;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.progress-area {
|
||||
margin: 30px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
margin-top: 10px;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 15px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper) {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
:deep(.el-input__inner) {
|
||||
color: white;
|
||||
font-size: 1.2em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
:deep(.el-input__inner::placeholder) {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
:deep(.el-button-group .el-button) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
}
|
||||
|
||||
:deep(.el-button-group .el-button.el-button--primary) {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
border-color: #4facfe;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.word-display {
|
||||
font-size: 2em !important;
|
||||
}
|
||||
|
||||
.rating-buttons {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.choices {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.choices .el-button {
|
||||
width: 90% !important;
|
||||
}
|
||||
|
||||
.controls {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,302 @@
|
||||
<template>
|
||||
<div class="gradient-bg">
|
||||
<div class="login-container">
|
||||
<el-card class="login-card glass-effect">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<h1>🧠 WordMaster Pro</h1>
|
||||
<p>智能单词记忆大师</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-tabs v-model="activeTab" class="login-tabs">
|
||||
<el-tab-pane label="登录" name="login">
|
||||
<el-form
|
||||
ref="loginFormRef"
|
||||
:model="loginForm"
|
||||
:rules="loginRules"
|
||||
label-position="top"
|
||||
@keyup.enter="handleLogin"
|
||||
>
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input
|
||||
v-model="loginForm.username"
|
||||
placeholder="请输入用户名"
|
||||
size="large"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input
|
||||
v-model="loginForm.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
style="width: 100%"
|
||||
:loading="loading"
|
||||
@click="handleLogin"
|
||||
class="btn-gradient"
|
||||
>
|
||||
登录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="注册" name="register">
|
||||
<el-form
|
||||
ref="registerFormRef"
|
||||
:model="registerForm"
|
||||
:rules="registerRules"
|
||||
label-position="top"
|
||||
@keyup.enter="handleRegister"
|
||||
>
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input
|
||||
v-model="registerForm.username"
|
||||
placeholder="请输入用户名"
|
||||
size="large"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input
|
||||
v-model="registerForm.email"
|
||||
placeholder="请输入邮箱"
|
||||
size="large"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input
|
||||
v-model="registerForm.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码" prop="confirmPassword">
|
||||
<el-input
|
||||
v-model="registerForm.confirmPassword"
|
||||
type="password"
|
||||
placeholder="请确认密码"
|
||||
size="large"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
style="width: 100%"
|
||||
:loading="loading"
|
||||
@click="handleRegister"
|
||||
class="btn-gradient"
|
||||
>
|
||||
注册
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const activeTab = ref('login')
|
||||
const loading = ref(false)
|
||||
const loginFormRef = ref()
|
||||
const registerFormRef = ref()
|
||||
|
||||
const loginForm = reactive({
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const registerForm = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
const loginRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
const registerRules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ min: 3, max: 20, message: '用户名长度在3-20个字符', trigger: 'blur' }
|
||||
],
|
||||
email: [
|
||||
{ required: true, message: '请输入邮箱', trigger: 'blur' },
|
||||
{ type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码长度至少6个字符', trigger: 'blur' }
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: '请确认密码', trigger: 'blur' },
|
||||
{
|
||||
validator: (rule, value, callback) => {
|
||||
if (value !== registerForm.password) {
|
||||
callback(new Error('两次输入密码不一致'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!loginFormRef.value) return
|
||||
|
||||
await loginFormRef.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
loading.value = true
|
||||
try {
|
||||
await authStore.login(loginForm)
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/dashboard')
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!registerFormRef.value) return
|
||||
|
||||
await registerFormRef.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
loading.value = true
|
||||
try {
|
||||
await authStore.register({
|
||||
username: registerForm.username,
|
||||
email: registerForm.email,
|
||||
password: registerForm.password
|
||||
})
|
||||
ElMessage.success('注册成功')
|
||||
router.push('/dashboard')
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
border: none;
|
||||
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
text-align: center;
|
||||
color: white;
|
||||
text-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.card-header h1 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 2.5em;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.card-header p {
|
||||
margin: 0;
|
||||
font-size: 1.2em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.login-tabs {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__header) {
|
||||
margin: 0 0 20px 0;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__item) {
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__item.is-active) {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__active-bar) {
|
||||
background-color: #409eff;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__label) {
|
||||
color: white !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper) {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
:deep(.el-input__inner) {
|
||||
color: white;
|
||||
}
|
||||
|
||||
:deep(.el-input__inner::placeholder) {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.btn-gradient {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%) !important;
|
||||
border: none !important;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-gradient:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0,0,0,0.3);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,817 @@
|
||||
<template>
|
||||
<div class="gradient-bg">
|
||||
<div class="main-dashboard-container">
|
||||
<!-- 导航栏 -->
|
||||
<el-header class="header glass-effect">
|
||||
<div class="header-content">
|
||||
<h1>🎓 学习管理平台</h1>
|
||||
<div class="user-actions">
|
||||
<el-button @click="logout" type="danger" plain>退出登录</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<el-main class="main-content" v-loading="loading">
|
||||
<!-- 今日任务概览 -->
|
||||
<el-row :gutter="20" style="margin-bottom: 30px;">
|
||||
<el-col :span="6">
|
||||
<div class="stats-card urgent clickable" @click="goToTodayReview">
|
||||
<div class="stats-icon">⏰</div>
|
||||
<h3>{{ dashboardStats.due_reviews }}</h3>
|
||||
<p>今日待复习错题</p>
|
||||
<div class="stats-action">
|
||||
<el-button type="primary" size="small" icon="ArrowRight">
|
||||
立即复习
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stats-card papers clickable" @click="goToAllPapers">
|
||||
<div class="stats-icon">📄</div>
|
||||
<h3>{{ dashboardStats.papers_count }}</h3>
|
||||
<p>试卷总数</p>
|
||||
<div class="stats-action">
|
||||
<el-button type="success" size="small" icon="ArrowRight">
|
||||
查看试卷
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stats-card mistakes clickable" @click="goToMistakes">
|
||||
<div class="stats-icon">❌</div>
|
||||
<h3>{{ dashboardStats.mistakes_count }}</h3>
|
||||
<p>错题总数</p>
|
||||
<div class="stats-action">
|
||||
<el-button type="warning" size="small" icon="ArrowRight">
|
||||
错题本
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stats-card accuracy clickable" @click="goToStatistics">
|
||||
<div class="stats-icon">📊</div>
|
||||
<h3>{{ Math.round(dashboardStats.recent_accuracy) }}%</h3>
|
||||
<p>最近准确率</p>
|
||||
<div class="stats-action">
|
||||
<el-button type="info" size="small" icon="ArrowRight">
|
||||
查看统计
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 学科导航 -->
|
||||
<div class="subjects-grid">
|
||||
<div
|
||||
class="subject-card"
|
||||
v-for="subject in subjects"
|
||||
:key="subject.key"
|
||||
@click="enterSubject(subject.key)"
|
||||
>
|
||||
<div class="subject-content">
|
||||
<div class="subject-icon">{{ subject.icon }}</div>
|
||||
<h2>{{ subject.name }}</h2>
|
||||
<p>{{ subject.description }}</p>
|
||||
|
||||
<div class="subject-stats" v-if="getSubjectStats(subject.key)">
|
||||
<div class="stat-item">
|
||||
<span class="stat-number">{{ getSubjectStats(subject.key).papers || 0 }}</span>
|
||||
<span class="stat-label">试卷</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-number">{{ getSubjectStats(subject.key).mistakes || 0 }}</span>
|
||||
<span class="stat-label">错题</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-number">{{ getSubjectStats(subject.key).due || 0 }}</span>
|
||||
<span class="stat-label">待复习</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="subject-actions">
|
||||
<el-button @click.stop="uploadPaper(subject.key)" icon="Upload" size="small">
|
||||
上传试卷
|
||||
</el-button>
|
||||
<el-button @click.stop="quickReview(subject.key)" type="primary" size="small">
|
||||
快速复习
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 薄弱知识点分析 -->
|
||||
<el-row :gutter="20" style="margin-top: 30px;">
|
||||
<el-col :span="12">
|
||||
<el-card class="glass-effect">
|
||||
<template #header>
|
||||
<h2>📉 薄弱学科分析</h2>
|
||||
</template>
|
||||
<div class="weak-subjects">
|
||||
<div
|
||||
v-for="subject in dashboardStats.weak_subjects"
|
||||
:key="subject.subject"
|
||||
class="weak-subject-item"
|
||||
>
|
||||
<div class="subject-info">
|
||||
<span class="subject-name">{{ getSubjectName(subject.subject) }}</span>
|
||||
<span class="mistake-count">{{ subject.mistake_count }} 个错题</span>
|
||||
</div>
|
||||
<div class="accuracy-score">
|
||||
<el-tag :type="getAccuracyType(subject.avg_score)">
|
||||
{{ Math.round(subject.avg_score * 20) }}%
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="dashboardStats.weak_subjects.length === 0" class="no-data">
|
||||
<div class="success-icon">🎉</div>
|
||||
<p>暂无薄弱学科,继续保持!</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="12">
|
||||
<el-card class="glass-effect">
|
||||
<template #header>
|
||||
<h2>📈 最近学习趋势</h2>
|
||||
</template>
|
||||
<div class="trend-chart">
|
||||
<canvas id="trend-chart" width="400" height="200"></canvas>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 快速操作 -->
|
||||
<el-row style="margin-top: 30px;">
|
||||
<el-col :span="24">
|
||||
<el-card class="glass-effect quick-actions-card">
|
||||
<template #header>
|
||||
<h2>🚀 快速操作</h2>
|
||||
</template>
|
||||
<div class="quick-actions">
|
||||
<el-button @click="showUploadDialog = true" type="primary" icon="Upload" size="large">
|
||||
上传新试卷
|
||||
</el-button>
|
||||
<el-button @click="goToTodayReview" icon="Clock" size="large">
|
||||
今日复习任务
|
||||
</el-button>
|
||||
<el-button @click="goToMistakes" icon="List" size="large">
|
||||
查看错题本
|
||||
</el-button>
|
||||
<el-button @click="goToAnalytics" icon="TrendCharts" size="large">
|
||||
学习分析
|
||||
</el-button>
|
||||
<el-button @click="goToEnglishWords" icon="Reading" size="large">
|
||||
英语单词学习
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-main>
|
||||
</div>
|
||||
|
||||
<!-- 上传试卷对话框 -->
|
||||
<el-dialog v-model="showUploadDialog" title="上传试卷" width="600px">
|
||||
<el-form :model="uploadForm" label-width="100px">
|
||||
<el-form-item label="试卷标题" required>
|
||||
<el-input v-model="uploadForm.title" placeholder="请输入试卷标题" />
|
||||
</el-form-item>
|
||||
<el-form-item label="学科" required>
|
||||
<el-select v-model="uploadForm.subject" placeholder="请选择学科">
|
||||
<el-option label="数学" value="math" />
|
||||
<el-option label="化学" value="chemistry" />
|
||||
<el-option label="物理" value="physics" />
|
||||
<el-option label="英语" value="english" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="年级" required>
|
||||
<el-select v-model="uploadForm.grade" placeholder="请选择年级">
|
||||
<el-option label="七年级" value="grade7" />
|
||||
<el-option label="八年级" value="grade8" />
|
||||
<el-option label="九年级" value="grade9" />
|
||||
<el-option label="高一" value="grade10" />
|
||||
<el-option label="高二" value="grade11" />
|
||||
<el-option label="高三" value="grade12" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="考试类型" required>
|
||||
<el-select v-model="uploadForm.exam_type" placeholder="请选择考试类型">
|
||||
<el-option label="期中考试" value="midterm" />
|
||||
<el-option label="期末考试" value="final" />
|
||||
<el-option label="月考" value="monthly" />
|
||||
<el-option label="单元测试" value="unit" />
|
||||
<el-option label="练习题" value="practice" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="考试日期" required>
|
||||
<el-date-picker
|
||||
v-model="uploadForm.exam_date"
|
||||
type="date"
|
||||
placeholder="选择考试日期"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="总分" required>
|
||||
<el-input-number v-model="uploadForm.total_score" :min="1" :max="1000" />
|
||||
</el-form-item>
|
||||
<el-form-item label="得分">
|
||||
<el-input-number v-model="uploadForm.achieved_score" :min="0" :max="uploadForm.total_score" />
|
||||
</el-form-item>
|
||||
<el-form-item label="试卷文件" required>
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
:auto-upload="false"
|
||||
:on-change="handleFileChange"
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
drag
|
||||
>
|
||||
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
|
||||
<div class="el-upload__text">
|
||||
将试卷拖到此处,或<em>点击上传</em>
|
||||
</div>
|
||||
<template #tip>
|
||||
<div class="el-upload__tip">
|
||||
支持 PDF、JPG、PNG 格式,文件大小不超过 50MB
|
||||
</div>
|
||||
</template>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showUploadDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitUpload" :loading="uploading">
|
||||
上传
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { UploadFilled, ArrowRight } from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import api from '../services/api'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const dashboardStats = ref({
|
||||
papers_count: 0,
|
||||
mistakes_count: 0,
|
||||
due_reviews: 0,
|
||||
weak_subjects: [],
|
||||
recent_accuracy: 0
|
||||
})
|
||||
|
||||
const showUploadDialog = ref(false)
|
||||
const uploading = ref(false)
|
||||
const selectedFile = ref(null)
|
||||
|
||||
const uploadForm = reactive({
|
||||
title: '',
|
||||
subject: '',
|
||||
grade: '',
|
||||
exam_type: '',
|
||||
exam_date: null,
|
||||
total_score: 100,
|
||||
achieved_score: null
|
||||
})
|
||||
|
||||
const subjects = [
|
||||
{
|
||||
key: 'math',
|
||||
name: '数学',
|
||||
icon: '📐',
|
||||
description: '代数、几何、函数等数学知识点练习'
|
||||
},
|
||||
{
|
||||
key: 'chemistry',
|
||||
name: '化学',
|
||||
icon: '🧪',
|
||||
description: '化学方程式、实验、元素化合物'
|
||||
},
|
||||
{
|
||||
key: 'physics',
|
||||
name: '物理',
|
||||
icon: '⚡',
|
||||
description: '力学、电学、光学等物理概念'
|
||||
},
|
||||
{
|
||||
key: 'english',
|
||||
name: '英语',
|
||||
icon: '📚',
|
||||
description: '单词记忆、语法、阅读理解'
|
||||
}
|
||||
]
|
||||
|
||||
const subjectStatsMap = computed(() => {
|
||||
// 优先使用后端的 subject_stats 数据
|
||||
if (dashboardStats.value.subject_stats) {
|
||||
return dashboardStats.value.subject_stats
|
||||
}
|
||||
|
||||
// 兜底使用 weak_subjects 数据
|
||||
const statsMap = {}
|
||||
dashboardStats.value.weak_subjects.forEach(ws => {
|
||||
statsMap[ws.subject] = {
|
||||
papers: 0,
|
||||
mistakes: ws.mistake_count,
|
||||
due: 0
|
||||
}
|
||||
})
|
||||
return statsMap
|
||||
})
|
||||
|
||||
const fetchDashboardStats = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await api.get('/dashboard')
|
||||
dashboardStats.value = response.data
|
||||
console.log('Dashboard stats fetched:', response.data)
|
||||
} catch (error) {
|
||||
console.error('Dashboard stats error:', error)
|
||||
ElMessage.error('获取统计数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getSubjectStats = (subjectKey) => {
|
||||
return subjectStatsMap.value[subjectKey] || { papers: 0, mistakes: 0, due: 0 }
|
||||
}
|
||||
|
||||
const getSubjectName = (subjectKey) => {
|
||||
const subject = subjects.find(s => s.key === subjectKey)
|
||||
return subject ? subject.name : subjectKey
|
||||
}
|
||||
|
||||
const getAccuracyType = (avgScore) => {
|
||||
const percentage = avgScore * 20
|
||||
if (percentage >= 80) return 'success'
|
||||
if (percentage >= 60) return 'warning'
|
||||
return 'danger'
|
||||
}
|
||||
|
||||
const enterSubject = (subjectKey) => {
|
||||
if (subjectKey === 'english') {
|
||||
router.push('/words')
|
||||
} else {
|
||||
router.push(`/subjects/${subjectKey}`)
|
||||
}
|
||||
}
|
||||
|
||||
const uploadPaper = (subjectKey) => {
|
||||
uploadForm.subject = subjectKey
|
||||
showUploadDialog.value = true
|
||||
}
|
||||
|
||||
const quickReview = (subjectKey) => {
|
||||
router.push(`/review?subject=${subjectKey}`)
|
||||
}
|
||||
|
||||
const goToTodayReview = () => {
|
||||
router.push('/review/today')
|
||||
}
|
||||
|
||||
const goToMistakes = () => {
|
||||
router.push('/mistakes')
|
||||
}
|
||||
|
||||
const goToAnalytics = () => {
|
||||
router.push('/analytics')
|
||||
}
|
||||
|
||||
const goToEnglishWords = () => {
|
||||
router.push('/learning')
|
||||
}
|
||||
|
||||
const goToAllPapers = () => {
|
||||
// 显示所有试卷,滚动到学科导航区域
|
||||
const subjectsElement = document.querySelector('.subjects-grid')
|
||||
if (subjectsElement) {
|
||||
subjectsElement.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
const goToStatistics = () => {
|
||||
router.push('/statistics')
|
||||
}
|
||||
|
||||
const handleFileChange = (file) => {
|
||||
selectedFile.value = file.raw
|
||||
}
|
||||
|
||||
const submitUpload = async () => {
|
||||
if (!selectedFile.value) {
|
||||
ElMessage.error('请选择要上传的文件')
|
||||
return
|
||||
}
|
||||
|
||||
if (!uploadForm.title || !uploadForm.subject || !uploadForm.grade || !uploadForm.exam_type || !uploadForm.exam_date) {
|
||||
ElMessage.error('请填写完整的试卷信息')
|
||||
return
|
||||
}
|
||||
|
||||
uploading.value = true
|
||||
try {
|
||||
// 1. 直接上传文件到我们的服务器
|
||||
const formData = new FormData()
|
||||
formData.append('file', selectedFile.value)
|
||||
|
||||
// 使用我们的API上传文件
|
||||
const uploadResponse = await api.post('/file/upload/', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
|
||||
// 2. 创建试卷记录
|
||||
await api.post('/papers', {
|
||||
title: uploadForm.title,
|
||||
subject: uploadForm.subject,
|
||||
grade: uploadForm.grade,
|
||||
exam_type: uploadForm.exam_type,
|
||||
exam_date: uploadForm.exam_date,
|
||||
total_score: uploadForm.total_score,
|
||||
achieved_score: uploadForm.achieved_score,
|
||||
file_key: uploadResponse.data.file_key
|
||||
})
|
||||
|
||||
ElMessage.success('试卷上传成功')
|
||||
showUploadDialog.value = false
|
||||
|
||||
// 重置表单
|
||||
Object.assign(uploadForm, {
|
||||
title: '',
|
||||
subject: '',
|
||||
grade: '',
|
||||
exam_type: '',
|
||||
exam_date: null,
|
||||
total_score: 100,
|
||||
achieved_score: null
|
||||
})
|
||||
selectedFile.value = null
|
||||
|
||||
// 刷新统计数据
|
||||
fetchDashboardStats()
|
||||
} catch (error) {
|
||||
ElMessage.error('上传失败:' + (error.response?.data?.detail || error.message))
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const drawTrendChart = () => {
|
||||
const canvas = document.getElementById('trend-chart')
|
||||
if (!canvas) return
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// 模拟数据 - 最近7天的复习准确率
|
||||
const data = [75, 80, 85, 78, 90, 88, 92]
|
||||
const width = canvas.width
|
||||
const height = canvas.height
|
||||
const margin = 40
|
||||
|
||||
// 绘制趋势线
|
||||
ctx.strokeStyle = '#4facfe'
|
||||
ctx.lineWidth = 3
|
||||
ctx.beginPath()
|
||||
|
||||
data.forEach((value, index) => {
|
||||
const x = margin + (index * (width - 2 * margin)) / (data.length - 1)
|
||||
const y = height - margin - ((value - 60) / 40) * (height - 2 * margin)
|
||||
|
||||
if (index === 0) {
|
||||
ctx.moveTo(x, y)
|
||||
} else {
|
||||
ctx.lineTo(x, y)
|
||||
}
|
||||
|
||||
// 绘制数据点
|
||||
ctx.fillStyle = '#409eff'
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, 4, 0, 2 * Math.PI)
|
||||
ctx.fill()
|
||||
})
|
||||
|
||||
ctx.stroke()
|
||||
|
||||
// 添加标题
|
||||
ctx.fillStyle = '#333'
|
||||
ctx.font = 'bold 14px Arial'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('最近7天复习准确率趋势', width / 2, 20)
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchDashboardStats()
|
||||
setTimeout(() => {
|
||||
drawTrendChart()
|
||||
}, 100)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.main-dashboard-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: white;
|
||||
margin: 0;
|
||||
font-size: 1.8em;
|
||||
text-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 25px;
|
||||
border-radius: 15px;
|
||||
text-align: center;
|
||||
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 180px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stats-card.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stats-card.clickable:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 15px 35px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.stats-card.urgent {
|
||||
background: linear-gradient(135deg, #ff6b6b 0%, #ff8e53 100%);
|
||||
}
|
||||
|
||||
.stats-card.papers {
|
||||
background: linear-gradient(135deg, #4ecdc4 0%, #26d0ce 100%);
|
||||
}
|
||||
|
||||
.stats-card.mistakes {
|
||||
background: linear-gradient(135deg, #ffa726 0%, #ff7043 100%);
|
||||
}
|
||||
|
||||
.stats-card.accuracy {
|
||||
background: linear-gradient(135deg, #42a5f5 0%, #478ed1 100%);
|
||||
}
|
||||
|
||||
.stats-card h3 {
|
||||
font-size: 2.5em;
|
||||
margin: 10px 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.stats-card p {
|
||||
margin: 0;
|
||||
opacity: 0.9;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.stats-icon {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
font-size: 2em;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.stats-action {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.stats-action .el-button {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stats-action .el-button:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.subjects-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
margin: 30px 0;
|
||||
}
|
||||
|
||||
.subject-card {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 20px;
|
||||
padding: 30px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.subject-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 15px 35px rgba(0,0,0,0.2);
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.subject-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.subject-icon {
|
||||
font-size: 4em;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.subject-card h2 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 1.8em;
|
||||
}
|
||||
|
||||
.subject-card p {
|
||||
margin: 0 0 20px 0;
|
||||
opacity: 0.8;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.subject-stats {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin: 20px 0;
|
||||
padding: 15px 0;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
display: block;
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
color: #00f2fe;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.9em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.subject-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.weak-subjects {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.weak-subject-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.weak-subject-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.subject-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.subject-name {
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.mistake-count {
|
||||
font-size: 0.9em;
|
||||
opacity: 0.7;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.quick-actions-card {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 50px 20px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
font-size: 3em;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.trend-chart {
|
||||
height: 220px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:deep(.el-card__header) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
:deep(.el-card__body) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
:deep(.el-card h2) {
|
||||
margin: 0;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.subjects-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.subject-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stats-card h3 {
|
||||
font-size: 2em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,654 @@
|
||||
<template>
|
||||
<div class="gradient-bg">
|
||||
<div class="review-container">
|
||||
<!-- 导航栏 -->
|
||||
<el-header class="header glass-effect">
|
||||
<div class="header-content">
|
||||
<div class="nav-breadcrumb">
|
||||
<el-button @click="goBack" icon="ArrowLeft" text>返回</el-button>
|
||||
<span class="separator">/</span>
|
||||
<h1>错题复习</h1>
|
||||
</div>
|
||||
<div class="progress-info">
|
||||
<span>进度: {{ currentIndex + 1 }} / {{ mistakes.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<el-main class="main-content" v-loading="loading">
|
||||
<div v-if="currentMistake" class="review-content">
|
||||
<!-- 错题信息卡片 -->
|
||||
<el-card class="glass-effect mistake-card">
|
||||
<template #header>
|
||||
<div class="mistake-header">
|
||||
<h2>{{ getSubjectName(currentMistake.subject) }} - 第{{ currentMistake.page }}页</h2>
|
||||
<div class="mistake-meta">
|
||||
<el-tag type="info">{{ getReasonLabel(currentMistake.reason_tag) }}</el-tag>
|
||||
<span class="difficulty">难度: {{ '★'.repeat(currentMistake.difficulty) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 知识点 -->
|
||||
<div class="knowledge-section">
|
||||
<h3>涉及知识点:</h3>
|
||||
<div class="knowledge-points">
|
||||
<el-tag
|
||||
v-for="point in currentMistake.knowledge_points"
|
||||
:key="point"
|
||||
size="small"
|
||||
type="primary"
|
||||
>
|
||||
{{ point }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 题目答案 -->
|
||||
<div v-if="currentMistake.answer" class="answer-section">
|
||||
<h3>正确答案:</h3>
|
||||
<div class="answer-content">{{ currentMistake.answer }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 题目解析 -->
|
||||
<div v-if="currentMistake.analysis" class="analysis-section">
|
||||
<h3>解题分析:</h3>
|
||||
<div class="analysis-content">{{ currentMistake.analysis }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 备注 -->
|
||||
<div v-if="currentMistake.notes" class="notes-section">
|
||||
<h3>备注:</h3>
|
||||
<div class="notes-content">{{ currentMistake.notes }}</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 复习评分 -->
|
||||
<el-card class="glass-effect rating-card">
|
||||
<template #header>
|
||||
<h2>复习评分</h2>
|
||||
</template>
|
||||
|
||||
<div class="rating-content">
|
||||
<p class="rating-instruction">请根据您的掌握程度进行评分:</p>
|
||||
|
||||
<div class="rating-options">
|
||||
<div
|
||||
class="rating-option"
|
||||
v-for="option in ratingOptions"
|
||||
:key="option.value"
|
||||
:class="{ active: reviewForm.rating === option.value }"
|
||||
@click="reviewForm.rating = option.value"
|
||||
>
|
||||
<div class="option-score">{{ option.value }}</div>
|
||||
<div class="option-label">{{ option.label }}</div>
|
||||
<div class="option-description">{{ option.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form :model="reviewForm" style="margin-top: 20px;">
|
||||
<el-form-item label="学习心得">
|
||||
<el-input
|
||||
v-model="reviewForm.comment"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="记录学习心得或总结..."
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="review-actions">
|
||||
<el-button @click="skipReview" size="large" plain>
|
||||
跳过
|
||||
</el-button>
|
||||
<el-button
|
||||
@click="submitReview"
|
||||
type="primary"
|
||||
size="large"
|
||||
:disabled="!reviewForm.rating"
|
||||
:loading="submitting"
|
||||
>
|
||||
确认并继续
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 复习完成 -->
|
||||
<div v-else-if="!loading && mistakes.length > 0" class="completion-screen">
|
||||
<div class="completion-content glass-effect">
|
||||
<div class="completion-icon">🎉</div>
|
||||
<h1>复习完成!</h1>
|
||||
<p>恭喜您完成了今天的错题复习</p>
|
||||
|
||||
<div class="completion-stats">
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{ mistakes.length }}</div>
|
||||
<div class="stat-label">已复习题目</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{ completedReviews }}</div>
|
||||
<div class="stat-label">评分题目</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{ Math.round(averageRating * 100) / 100 }}</div>
|
||||
<div class="stat-label">平均评分</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="completion-actions">
|
||||
<el-button @click="goToDashboard" type="primary" size="large">
|
||||
返回主页
|
||||
</el-button>
|
||||
<el-button @click="goToMistakes" size="large">
|
||||
查看错题本
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else-if="!loading && mistakes.length === 0" class="empty-state">
|
||||
<div class="empty-icon">📝</div>
|
||||
<h2>暂无待复习错题</h2>
|
||||
<p>恭喜您,目前没有需要复习的错题!</p>
|
||||
<el-button @click="goToDashboard" type="primary" size="large">
|
||||
返回主页
|
||||
</el-button>
|
||||
</div>
|
||||
</el-main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ArrowLeft } from '@element-plus/icons-vue'
|
||||
import api from '../services/api'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const mistakes = ref([])
|
||||
const currentIndex = ref(0)
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const reviewedItems = ref([])
|
||||
|
||||
const reviewForm = reactive({
|
||||
rating: null,
|
||||
comment: ''
|
||||
})
|
||||
|
||||
const ratingOptions = [
|
||||
{
|
||||
value: 1,
|
||||
label: '完全不会',
|
||||
description: '需要重新学习基础概念'
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
label: '基本不会',
|
||||
description: '有一定印象但掌握不牢'
|
||||
},
|
||||
{
|
||||
value: 3,
|
||||
label: '一般掌握',
|
||||
description: '基本理解但仍有疑惑'
|
||||
},
|
||||
{
|
||||
value: 4,
|
||||
label: '较好掌握',
|
||||
description: '理解较好,少量错误'
|
||||
},
|
||||
{
|
||||
value: 5,
|
||||
label: '完全掌握',
|
||||
description: '完全理解,能够灵活运用'
|
||||
}
|
||||
]
|
||||
|
||||
const currentMistake = computed(() => {
|
||||
return mistakes.value[currentIndex.value] || null
|
||||
})
|
||||
|
||||
const completedReviews = computed(() => {
|
||||
return reviewedItems.value.filter(item => item.rating).length
|
||||
})
|
||||
|
||||
const averageRating = computed(() => {
|
||||
const ratedItems = reviewedItems.value.filter(item => item.rating)
|
||||
if (ratedItems.length === 0) return 0
|
||||
return ratedItems.reduce((sum, item) => sum + item.rating, 0) / ratedItems.length
|
||||
})
|
||||
|
||||
const fetchMistakes = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {}
|
||||
|
||||
// 从路由参数获取过滤条件
|
||||
if (route.query.paper_id) {
|
||||
params.paper_id = route.query.paper_id
|
||||
}
|
||||
if (route.query.subject) {
|
||||
params.subject = route.query.subject
|
||||
}
|
||||
if (route.query.due) {
|
||||
params.due = route.query.due
|
||||
} else {
|
||||
params.due = 'today' // 默认获取今日待复习
|
||||
}
|
||||
|
||||
const response = await api.get('/mistakes', { params })
|
||||
mistakes.value = response.data
|
||||
} catch (error) {
|
||||
ElMessage.error('获取错题失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getSubjectName = (subject) => {
|
||||
const subjects = {
|
||||
math: '数学',
|
||||
chemistry: '化学',
|
||||
physics: '物理',
|
||||
english: '英语'
|
||||
}
|
||||
return subjects[subject] || subject
|
||||
}
|
||||
|
||||
const getReasonLabel = (reason) => {
|
||||
const labels = {
|
||||
concept: '概念理解错误',
|
||||
calc: '计算错误',
|
||||
careless: '粗心大意',
|
||||
memory: '记忆错误',
|
||||
steps: '解题步骤错误',
|
||||
reading: '题目理解错误'
|
||||
}
|
||||
return labels[reason] || reason
|
||||
}
|
||||
|
||||
const submitReview = async () => {
|
||||
if (!reviewForm.rating) {
|
||||
ElMessage.warning('请选择评分')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await api.post(`/mistakes/${currentMistake.value.id}/review`, {
|
||||
mistake_id: currentMistake.value.id,
|
||||
rating: reviewForm.rating,
|
||||
comment: reviewForm.comment,
|
||||
time_spent: Math.floor(Math.random() * 300) + 60 // 模拟学习时间
|
||||
})
|
||||
|
||||
// 记录复习结果
|
||||
reviewedItems.value.push({
|
||||
mistake_id: currentMistake.value.id,
|
||||
rating: reviewForm.rating,
|
||||
comment: reviewForm.comment
|
||||
})
|
||||
|
||||
nextMistake()
|
||||
} catch (error) {
|
||||
ElMessage.error('提交复习失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const skipReview = () => {
|
||||
// 记录跳过
|
||||
reviewedItems.value.push({
|
||||
mistake_id: currentMistake.value.id,
|
||||
rating: null,
|
||||
comment: '跳过'
|
||||
})
|
||||
|
||||
nextMistake()
|
||||
}
|
||||
|
||||
const nextMistake = () => {
|
||||
// 重置表单
|
||||
reviewForm.rating = null
|
||||
reviewForm.comment = ''
|
||||
|
||||
// 移动到下一题
|
||||
currentIndex.value++
|
||||
|
||||
if (currentIndex.value >= mistakes.value.length) {
|
||||
// 复习完成
|
||||
ElMessage.success('恭喜您完成了所有错题复习!')
|
||||
}
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.go(-1)
|
||||
}
|
||||
|
||||
const goToDashboard = () => {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
const goToMistakes = () => {
|
||||
router.push('/mistakes')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchMistakes()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.review-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.separator {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: white;
|
||||
margin: 0;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.review-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.mistake-card {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.mistake-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mistake-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.difficulty {
|
||||
color: #ffd700;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.knowledge-section,
|
||||
.answer-section,
|
||||
.analysis-section,
|
||||
.notes-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.knowledge-section h3,
|
||||
.answer-section h3,
|
||||
.analysis-section h3,
|
||||
.notes-section h3 {
|
||||
color: white;
|
||||
margin-bottom: 10px;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.knowledge-points {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.answer-content,
|
||||
.analysis-content,
|
||||
.notes-content {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.rating-card {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.rating-instruction {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin-bottom: 20px;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.rating-options {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rating-option {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 15px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.rating-option:hover {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.rating-option.active {
|
||||
border-color: #409eff;
|
||||
background: rgba(64, 158, 255, 0.1);
|
||||
}
|
||||
|
||||
.option-score {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
color: #409eff;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.option-label {
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.option-description {
|
||||
font-size: 0.9em;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.review-actions {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.completion-screen,
|
||||
.empty-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
.completion-content {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.completion-icon,
|
||||
.empty-icon {
|
||||
font-size: 4em;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.completion-content h1,
|
||||
.empty-state h2 {
|
||||
color: white;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.completion-content p,
|
||||
.empty-state p {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-bottom: 30px;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.completion-stats {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin: 30px 0;
|
||||
padding: 20px 0;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
color: #00f2fe;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.completion-actions {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
:deep(.el-card__header) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
:deep(.el-card__body) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
:deep(.el-card h2) {
|
||||
margin: 0;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mistake-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.rating-options {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.rating-option {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.review-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.completion-stats {
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.completion-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,745 @@
|
||||
<template>
|
||||
<div class="gradient-bg">
|
||||
<div class="paper-detail-container">
|
||||
<!-- 导航栏 -->
|
||||
<el-header class="header glass-effect">
|
||||
<div class="header-content">
|
||||
<div class="nav-breadcrumb">
|
||||
<el-button @click="goBack" icon="ArrowLeft" text>返回</el-button>
|
||||
<span class="separator">/</span>
|
||||
<h1>{{ paper?.title || '加载中...' }}</h1>
|
||||
</div>
|
||||
<div class="header-actions" v-if="paper">
|
||||
<el-button @click="viewPaper" icon="View" type="success">
|
||||
查看试卷
|
||||
</el-button>
|
||||
<el-button @click="downloadPaper" icon="Download" type="primary">
|
||||
下载试卷
|
||||
</el-button>
|
||||
<el-button @click="showExtractDialog = true" icon="DocumentAdd" type="warning">
|
||||
提取错题
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<el-main class="main-content" v-loading="loading">
|
||||
<div v-if="paper" class="paper-content">
|
||||
<!-- 试卷信息 -->
|
||||
<el-card class="glass-effect paper-info-card">
|
||||
<template #header>
|
||||
<h2>试卷信息</h2>
|
||||
</template>
|
||||
<div class="paper-info-grid">
|
||||
<div class="info-item">
|
||||
<span class="label">学科:</span>
|
||||
<span class="value">{{ getSubjectName(paper.subject) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">年级:</span>
|
||||
<span class="value">{{ getGradeLabel(paper.grade) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">考试类型:</span>
|
||||
<span class="value">{{ getExamTypeLabel(paper.exam_type) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">考试日期:</span>
|
||||
<span class="value">{{ formatDate(paper.exam_date) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">总分:</span>
|
||||
<span class="value">{{ paper.total_score }}分</span>
|
||||
</div>
|
||||
<div class="info-item" v-if="paper.achieved_score">
|
||||
<span class="label">得分:</span>
|
||||
<span class="value score-highlight">{{ paper.achieved_score }}分</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="paper.tags && paper.tags.length" class="paper-tags">
|
||||
<el-tag v-for="tag in paper.tags" :key="tag" size="small">{{ tag }}</el-tag>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 错题统计 -->
|
||||
<el-card class="glass-effect mistakes-stats-card">
|
||||
<template #header>
|
||||
<h2>错题统计</h2>
|
||||
</template>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{ mistakeStats.total }}</div>
|
||||
<div class="stat-label">总错题数</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{ mistakeStats.due_today }}</div>
|
||||
<div class="stat-label">今日待复习</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{ mistakeStats.mastered }}</div>
|
||||
<div class="stat-label">已掌握</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-number">{{ Math.round(mistakeStats.accuracy) }}%</div>
|
||||
<div class="stat-label">复习准确率</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 错题列表 -->
|
||||
<el-card class="glass-effect mistakes-list-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<h2>错题列表</h2>
|
||||
<el-button @click="reviewAllMistakes" type="primary" icon="Reading">
|
||||
开始复习
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="mistakes.length === 0" class="empty-mistakes">
|
||||
<div class="empty-icon">📝</div>
|
||||
<p>暂无错题记录</p>
|
||||
<el-button @click="showExtractDialog = true" type="primary">
|
||||
提取错题
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div v-else class="mistakes-list">
|
||||
<div
|
||||
class="mistake-item"
|
||||
v-for="mistake in mistakes"
|
||||
:key="mistake.id"
|
||||
@click="viewMistake(mistake)"
|
||||
>
|
||||
<div class="mistake-header">
|
||||
<span class="mistake-subject">{{ getSubjectName(mistake.subject) }}</span>
|
||||
<span class="mistake-difficulty">
|
||||
难度: {{ '★'.repeat(mistake.difficulty) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mistake-content">
|
||||
<div class="knowledge-points">
|
||||
<el-tag
|
||||
v-for="point in mistake.knowledge_points"
|
||||
:key="point"
|
||||
size="small"
|
||||
type="info"
|
||||
>
|
||||
{{ point }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="mistake-reason">
|
||||
错因: {{ getReasonLabel(mistake.reason_tag) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mistake-footer">
|
||||
<span class="review-level">
|
||||
复习级别: {{ mistake.review_level }}
|
||||
</span>
|
||||
<span class="next-review" :class="getReviewStatusClass(mistake.next_review_date)">
|
||||
{{ getReviewStatus(mistake.next_review_date) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-main>
|
||||
</div>
|
||||
|
||||
<!-- 提取错题对话框 -->
|
||||
<el-dialog v-model="showExtractDialog" title="提取错题" width="800px">
|
||||
<div class="extract-form">
|
||||
<el-form :model="extractForm" label-width="100px">
|
||||
<el-form-item label="错题页码" required>
|
||||
<el-input-number v-model="extractForm.page" :min="1" :max="100" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="错题位置" required>
|
||||
<div class="bbox-inputs">
|
||||
<el-input-number v-model="extractForm.bbox.x" placeholder="X坐标" style="width: 120px;" />
|
||||
<el-input-number v-model="extractForm.bbox.y" placeholder="Y坐标" style="width: 120px;" />
|
||||
<el-input-number v-model="extractForm.bbox.width" placeholder="宽度" style="width: 120px;" />
|
||||
<el-input-number v-model="extractForm.bbox.height" placeholder="高度" style="width: 120px;" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="知识点" required>
|
||||
<el-select
|
||||
v-model="extractForm.knowledge_points"
|
||||
multiple
|
||||
filterable
|
||||
allow-create
|
||||
placeholder="选择或输入知识点"
|
||||
style="width: 100%;"
|
||||
>
|
||||
<el-option
|
||||
v-for="point in knowledgePointOptions"
|
||||
:key="point"
|
||||
:label="point"
|
||||
:value="point"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="错误原因" required>
|
||||
<el-select v-model="extractForm.reason_tag" placeholder="选择错误原因">
|
||||
<el-option label="概念理解错误" value="concept" />
|
||||
<el-option label="计算错误" value="calc" />
|
||||
<el-option label="粗心大意" value="careless" />
|
||||
<el-option label="记忆错误" value="memory" />
|
||||
<el-option label="解题步骤错误" value="steps" />
|
||||
<el-option label="题目理解错误" value="reading" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="难度等级">
|
||||
<el-rate v-model="extractForm.difficulty" :max="5" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="正确答案">
|
||||
<el-input
|
||||
v-model="extractForm.answer"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="输入正确答案"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="解析">
|
||||
<el-input
|
||||
v-model="extractForm.analysis"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="输入题目解析"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="备注">
|
||||
<el-input
|
||||
v-model="extractForm.notes"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="添加备注信息"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="showExtractDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitExtract" :loading="extracting">
|
||||
提取错题
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ArrowLeft, Download, DocumentAdd, Reading } from '@element-plus/icons-vue'
|
||||
import api from '../services/api'
|
||||
import { buildFileUrl } from '../config'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const paperId = ref(route.params.id)
|
||||
const paper = ref(null)
|
||||
const mistakes = ref([])
|
||||
const loading = ref(false)
|
||||
const extracting = ref(false)
|
||||
const showExtractDialog = ref(false)
|
||||
|
||||
const extractForm = reactive({
|
||||
page: 1,
|
||||
bbox: { x: 0, y: 0, width: 100, height: 50 },
|
||||
knowledge_points: [],
|
||||
reason_tag: '',
|
||||
difficulty: 3,
|
||||
answer: '',
|
||||
analysis: '',
|
||||
notes: ''
|
||||
})
|
||||
|
||||
const mistakeStats = computed(() => {
|
||||
const total = mistakes.value.length
|
||||
const dueToday = mistakes.value.filter(m =>
|
||||
new Date(m.next_review_date) <= new Date()
|
||||
).length
|
||||
const mastered = mistakes.value.filter(m => m.review_level >= 5).length
|
||||
|
||||
// 简单计算准确率 (可以根据实际复习记录计算)
|
||||
const accuracy = total > 0 ? (mastered / total) * 100 : 0
|
||||
|
||||
return { total, due_today: dueToday, mastered, accuracy }
|
||||
})
|
||||
|
||||
const knowledgePointOptions = ref([
|
||||
'函数图像', '二次函数', '一元二次方程', '几何证明', '三角形',
|
||||
'化学方程式', '酸碱反应', '氧化还原', '有机化合物', '元素周期表',
|
||||
'牛顿定律', '电路分析', '光学', '热力学', '波动',
|
||||
'语法', '词汇', '阅读理解', '写作', '听力'
|
||||
])
|
||||
|
||||
const fetchPaper = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await api.get(`/papers/${paperId.value}`)
|
||||
paper.value = response.data
|
||||
} catch (error) {
|
||||
ElMessage.error('获取试卷详情失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchMistakes = async () => {
|
||||
try {
|
||||
const response = await api.get('/mistakes', {
|
||||
params: { paper_id: paperId.value }
|
||||
})
|
||||
mistakes.value = response.data
|
||||
} catch (error) {
|
||||
ElMessage.error('获取错题列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
const getSubjectName = (subject) => {
|
||||
const subjects = {
|
||||
math: '数学',
|
||||
chemistry: '化学',
|
||||
physics: '物理',
|
||||
english: '英语'
|
||||
}
|
||||
return subjects[subject] || subject
|
||||
}
|
||||
|
||||
const getGradeLabel = (grade) => {
|
||||
const labels = {
|
||||
grade7: '七年级',
|
||||
grade8: '八年级',
|
||||
grade9: '九年级',
|
||||
grade10: '高一',
|
||||
grade11: '高二',
|
||||
grade12: '高三'
|
||||
}
|
||||
return labels[grade] || grade
|
||||
}
|
||||
|
||||
const getExamTypeLabel = (examType) => {
|
||||
const labels = {
|
||||
midterm: '期中考试',
|
||||
final: '期末考试',
|
||||
monthly: '月考',
|
||||
unit: '单元测试',
|
||||
practice: '练习题'
|
||||
}
|
||||
return labels[examType] || examType
|
||||
}
|
||||
|
||||
const getReasonLabel = (reason) => {
|
||||
const labels = {
|
||||
concept: '概念理解错误',
|
||||
calc: '计算错误',
|
||||
careless: '粗心大意',
|
||||
memory: '记忆错误',
|
||||
steps: '解题步骤错误',
|
||||
reading: '题目理解错误'
|
||||
}
|
||||
return labels[reason] || reason
|
||||
}
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
return new Date(dateStr).toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
const getReviewStatus = (nextReviewDate) => {
|
||||
const now = new Date()
|
||||
const reviewDate = new Date(nextReviewDate)
|
||||
const diffDays = Math.ceil((reviewDate - now) / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffDays < 0) return `逾期${Math.abs(diffDays)}天`
|
||||
if (diffDays === 0) return '今日复习'
|
||||
return `${diffDays}天后复习`
|
||||
}
|
||||
|
||||
const getReviewStatusClass = (nextReviewDate) => {
|
||||
const now = new Date()
|
||||
const reviewDate = new Date(nextReviewDate)
|
||||
const diffDays = Math.ceil((reviewDate - now) / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffDays < 0) return 'overdue'
|
||||
if (diffDays === 0) return 'due-today'
|
||||
return 'upcoming'
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.go(-1)
|
||||
}
|
||||
|
||||
const downloadPaper = () => {
|
||||
// 实现试卷下载功能
|
||||
const url = buildFileUrl(`papers/${paperId.value}/file`)
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
const viewPaper = () => {
|
||||
router.push(`/papers/${paperId.value}/view`)
|
||||
}
|
||||
|
||||
const viewMistake = (mistake) => {
|
||||
router.push(`/mistakes/${mistake.id}`)
|
||||
}
|
||||
|
||||
const reviewAllMistakes = () => {
|
||||
if (mistakes.value.length === 0) {
|
||||
ElMessage.warning('暂无错题可复习')
|
||||
return
|
||||
}
|
||||
router.push(`/review?paper_id=${paperId.value}`)
|
||||
}
|
||||
|
||||
const submitExtract = async () => {
|
||||
if (!extractForm.knowledge_points.length || !extractForm.reason_tag) {
|
||||
ElMessage.error('请填写必填项')
|
||||
return
|
||||
}
|
||||
|
||||
extracting.value = true
|
||||
try {
|
||||
await api.post('/mistakes', {
|
||||
paper_id: parseInt(paperId.value),
|
||||
page: extractForm.page,
|
||||
bbox: extractForm.bbox,
|
||||
subject: paper.value.subject,
|
||||
knowledge_points: extractForm.knowledge_points,
|
||||
reason_tag: extractForm.reason_tag,
|
||||
difficulty: extractForm.difficulty,
|
||||
answer: extractForm.answer,
|
||||
analysis: extractForm.analysis,
|
||||
notes: extractForm.notes
|
||||
})
|
||||
|
||||
ElMessage.success('错题提取成功')
|
||||
showExtractDialog.value = false
|
||||
|
||||
// 重置表单
|
||||
Object.assign(extractForm, {
|
||||
page: 1,
|
||||
bbox: { x: 0, y: 0, width: 100, height: 50 },
|
||||
knowledge_points: [],
|
||||
reason_tag: '',
|
||||
difficulty: 3,
|
||||
answer: '',
|
||||
analysis: '',
|
||||
notes: ''
|
||||
})
|
||||
|
||||
// 刷新错题列表
|
||||
fetchMistakes()
|
||||
} catch (error) {
|
||||
ElMessage.error('提取失败:' + (error.response?.data?.detail || error.message))
|
||||
} finally {
|
||||
extracting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchPaper()
|
||||
fetchMistakes()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.paper-detail-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.separator {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: white;
|
||||
margin: 0;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.paper-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.paper-info-card {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.paper-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.score-highlight {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.paper-tags {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mistakes-stats-card {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
color: #00f2fe;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.mistakes-list-card {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.empty-mistakes {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4em;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.mistakes-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.mistake-item {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
padding: 15px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.mistake-item:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.mistake-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.mistake-subject {
|
||||
font-weight: bold;
|
||||
color: #00f2fe;
|
||||
}
|
||||
|
||||
.mistake-difficulty {
|
||||
color: #ffd700;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.mistake-content {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.knowledge-points {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.knowledge-points .el-tag {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.mistake-reason {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.mistake-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.review-level {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.next-review.overdue {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.next-review.due-today {
|
||||
color: #ffa726;
|
||||
}
|
||||
|
||||
.next-review.upcoming {
|
||||
color: #66bb6a;
|
||||
}
|
||||
|
||||
.extract-form {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.bbox-inputs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
:deep(.el-card__header) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
:deep(.el-card__body) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
:deep(.el-card h2) {
|
||||
margin: 0;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.paper-info-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.bbox-inputs {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bbox-inputs > * {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,839 @@
|
||||
<template>
|
||||
<div class="gradient-bg">
|
||||
<div class="paper-viewer-container">
|
||||
<!-- 导航栏 -->
|
||||
<el-header class="header glass-effect">
|
||||
<div class="header-content">
|
||||
<div class="nav-breadcrumb">
|
||||
<el-button @click="goBack" icon="ArrowLeft" text>返回</el-button>
|
||||
<span class="separator">/</span>
|
||||
<h1>{{ paper?.title || '加载中...' }}</h1>
|
||||
</div>
|
||||
<div class="header-actions" v-if="paper">
|
||||
<el-button @click="showQuestionBank = true" icon="Collection" type="primary">
|
||||
题库管理
|
||||
</el-button>
|
||||
<el-button @click="addQuestionMode = true" icon="Plus" type="success">
|
||||
添加题目
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<el-main class="main-content" v-loading="loading">
|
||||
<div v-if="paper" class="paper-content">
|
||||
<!-- 试卷预览和题目提取 -->
|
||||
<el-row :gutter="20">
|
||||
<!-- 左侧:试卷预览 -->
|
||||
<el-col :span="12">
|
||||
<el-card class="glass-effect paper-preview-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<h2>📄 试卷预览</h2>
|
||||
<el-button @click="downloadPaper" icon="Download" size="small">
|
||||
下载原文件
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="paper-preview">
|
||||
<!-- 如果是图片格式 -->
|
||||
<div v-if="isImageFile" class="image-preview">
|
||||
<img :src="paperFileUrl" alt="试卷预览" @load="onImageLoad" />
|
||||
</div>
|
||||
|
||||
<!-- 如果是PDF格式 -->
|
||||
<div v-else-if="isPdfFile" class="pdf-preview">
|
||||
<div class="pdf-container">
|
||||
<div class="pdf-options">
|
||||
<el-button @click="openInNewTab" type="primary" icon="View">在新标签页中打开</el-button>
|
||||
<el-button @click="downloadPaper" type="success" icon="Download">下载文件</el-button>
|
||||
</div>
|
||||
<iframe
|
||||
:src="paperFileUrl"
|
||||
width="100%"
|
||||
height="600px"
|
||||
frameborder="0"
|
||||
@load="onPdfLoad"
|
||||
@error="onPdfError"
|
||||
></iframe>
|
||||
<div v-if="pdfError" class="pdf-error">
|
||||
<p>PDF预览失败,这可能是由于浏览器安全限制</p>
|
||||
<p>请尝试以下方案:</p>
|
||||
<el-button @click="openInNewTab" type="primary">在新标签页中打开</el-button>
|
||||
<el-button @click="downloadPaper" type="success">下载到本地</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文件信息 -->
|
||||
<div class="file-info">
|
||||
<p><strong>文件类型:</strong> {{ getFileType(paper.file_key) }}</p>
|
||||
<p><strong>上传时间:</strong> {{ formatDate(paper.created_at) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 右侧:题目提取 -->
|
||||
<el-col :span="12">
|
||||
<el-card class="glass-effect question-extract-card">
|
||||
<template #header>
|
||||
<h2>🎯 题目提取</h2>
|
||||
</template>
|
||||
|
||||
<!-- 快速添加题目 -->
|
||||
<div v-if="addQuestionMode" class="quick-add-question">
|
||||
<el-form :model="newQuestion" label-width="80px" size="small">
|
||||
<el-form-item label="题目类型">
|
||||
<el-select v-model="newQuestion.question_type" placeholder="选择题目类型">
|
||||
<el-option label="选择题" value="choice" />
|
||||
<el-option label="填空题" value="fill_blank" />
|
||||
<el-option label="计算题" value="calculation" />
|
||||
<el-option label="问答题" value="essay" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="题目内容">
|
||||
<el-input
|
||||
v-model="newQuestion.question_content"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="输入题目内容..."
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 选择题选项 -->
|
||||
<div v-if="newQuestion.question_type === 'choice'" class="choice-options">
|
||||
<el-form-item label="选项A">
|
||||
<el-input v-model="newQuestion.options.A" placeholder="选项A内容" />
|
||||
</el-form-item>
|
||||
<el-form-item label="选项B">
|
||||
<el-input v-model="newQuestion.options.B" placeholder="选项B内容" />
|
||||
</el-form-item>
|
||||
<el-form-item label="选项C">
|
||||
<el-input v-model="newQuestion.options.C" placeholder="选项C内容" />
|
||||
</el-form-item>
|
||||
<el-form-item label="选项D">
|
||||
<el-input v-model="newQuestion.options.D" placeholder="选项D内容" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<el-form-item label="正确答案">
|
||||
<el-input v-model="newQuestion.correct_answer" placeholder="输入正确答案" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="知识点">
|
||||
<el-select
|
||||
v-model="newQuestion.knowledge_points"
|
||||
multiple
|
||||
filterable
|
||||
allow-create
|
||||
placeholder="选择或输入知识点"
|
||||
style="width: 100%;"
|
||||
>
|
||||
<el-option
|
||||
v-for="point in knowledgePointOptions"
|
||||
:key="point"
|
||||
:label="point"
|
||||
:value="point"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="难度等级">
|
||||
<el-rate v-model="newQuestion.difficulty" :max="5" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="解析">
|
||||
<el-input
|
||||
v-model="newQuestion.explanation"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="题目解析(可选)"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<div class="question-actions">
|
||||
<el-button @click="cancelAddQuestion" size="small">取消</el-button>
|
||||
<el-button @click="saveQuestion" type="primary" size="small" :loading="saving">
|
||||
保存到题库
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 题目提取提示 -->
|
||||
<div v-else class="extract-tips">
|
||||
<div class="tip-item">
|
||||
<el-icon class="tip-icon"><Document /></el-icon>
|
||||
<div class="tip-content">
|
||||
<h4>📝 手动提取题目</h4>
|
||||
<p>点击"添加题目"按钮,手动输入试卷中的题目内容,建立个人题库</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tip-item">
|
||||
<el-icon class="tip-icon"><Search /></el-icon>
|
||||
<div class="tip-content">
|
||||
<h4>🔍 题目搜索</h4>
|
||||
<p>支持按学科、难度、知识点等条件搜索和筛选题目</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tip-item">
|
||||
<el-icon class="tip-icon"><Collection /></el-icon>
|
||||
<div class="tip-content">
|
||||
<h4>📚 题库管理</h4>
|
||||
<p>创建题目集合,分类管理题目,方便复习和练习</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 已提取的题目列表 -->
|
||||
<el-card class="glass-effect questions-list-card" style="margin-top: 20px;">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<h2>📋 试卷题目 ({{ questions.length }})</h2>
|
||||
<div class="search-controls">
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索题目..."
|
||||
style="width: 200px; margin-right: 10px;"
|
||||
clearable
|
||||
@input="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-select v-model="filterDifficulty" placeholder="难度" style="width: 100px;" @change="handleFilter">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="1星" :value="1" />
|
||||
<el-option label="2星" :value="2" />
|
||||
<el-option label="3星" :value="3" />
|
||||
<el-option label="4星" :value="4" />
|
||||
<el-option label="5星" :value="5" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="filteredQuestions.length === 0" class="empty-questions">
|
||||
<div class="empty-icon">📝</div>
|
||||
<p>暂无题目,点击"添加题目"开始提取</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="questions-list">
|
||||
<div
|
||||
class="question-item"
|
||||
v-for="question in filteredQuestions"
|
||||
:key="question.id"
|
||||
>
|
||||
<div class="question-header">
|
||||
<div class="question-meta">
|
||||
<el-tag :type="getQuestionTypeColor(question.question_type)">
|
||||
{{ getQuestionTypeLabel(question.question_type) }}
|
||||
</el-tag>
|
||||
<span class="difficulty">
|
||||
难度: {{ '★'.repeat(question.difficulty) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="question-actions">
|
||||
<el-button @click="editQuestion(question)" size="small" icon="Edit">编辑</el-button>
|
||||
<el-button @click="deleteQuestion(question.id)" size="small" type="danger" icon="Delete">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="question-content">
|
||||
<div class="question-text">{{ question.question_content }}</div>
|
||||
|
||||
<!-- 选择题选项 -->
|
||||
<div v-if="question.question_type === 'choice' && question.options" class="choice-options-display">
|
||||
<div v-for="(option, key) in question.options" :key="key" class="option-item">
|
||||
<strong>{{ key }}.</strong> {{ option }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="question-answer">
|
||||
<strong>答案:</strong> {{ question.correct_answer }}
|
||||
</div>
|
||||
|
||||
<div v-if="question.explanation" class="question-explanation">
|
||||
<strong>解析:</strong> {{ question.explanation }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="question-footer">
|
||||
<div class="knowledge-points">
|
||||
<el-tag
|
||||
v-for="point in question.knowledge_points"
|
||||
:key="point"
|
||||
size="small"
|
||||
type="info"
|
||||
>
|
||||
{{ point }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<span class="created-time">{{ formatDate(question.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-main>
|
||||
</div>
|
||||
|
||||
<!-- 题库管理对话框 -->
|
||||
<el-dialog v-model="showQuestionBank" title="题库管理" width="800px">
|
||||
<div class="question-bank-content">
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="我的题库" name="mybank">
|
||||
<div class="my-questions">
|
||||
<p>题库功能开发中,敬请期待...</p>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="题目搜索" name="search">
|
||||
<div class="question-search">
|
||||
<p>搜索功能开发中,敬请期待...</p>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft, Download, Document, Search, Collection, Edit, Delete, Plus } from '@element-plus/icons-vue'
|
||||
import api from '../services/api'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { buildFileUrl } from '../config'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const paperId = ref(route.params.id)
|
||||
const paper = ref(null)
|
||||
const questions = ref([])
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const addQuestionMode = ref(false)
|
||||
const showQuestionBank = ref(false)
|
||||
const activeTab = ref('mybank')
|
||||
const pdfError = ref(false)
|
||||
|
||||
const searchQuery = ref('')
|
||||
const filterDifficulty = ref('')
|
||||
|
||||
const newQuestion = reactive({
|
||||
question_type: 'choice',
|
||||
question_content: '',
|
||||
options: { A: '', B: '', C: '', D: '' },
|
||||
correct_answer: '',
|
||||
knowledge_points: [],
|
||||
difficulty: 3,
|
||||
explanation: ''
|
||||
})
|
||||
|
||||
const knowledgePointOptions = ref([
|
||||
'函数图像', '二次函数', '一元二次方程', '几何证明', '三角形',
|
||||
'化学方程式', '酸碱反应', '氧化还原', '有机化合物', '元素周期表',
|
||||
'牛顿定律', '电路分析', '光学', '热力学', '波动',
|
||||
'语法', '词汇', '阅读理解', '写作', '听力'
|
||||
])
|
||||
|
||||
const paperFileUrl = computed(() => {
|
||||
if (!paper.value || !authStore.token) {
|
||||
console.log('paperFileUrl: Missing paper or token', { paper: !!paper.value, token: !!authStore.token })
|
||||
return ''
|
||||
}
|
||||
const url = buildFileUrl(`papers/${paperId.value}/file`, { access_token: authStore.token })
|
||||
console.log('paperFileUrl generated:', url)
|
||||
return url
|
||||
})
|
||||
|
||||
const isImageFile = computed(() => {
|
||||
if (!paper.value) return false
|
||||
const ext = paper.value.file_key.toLowerCase()
|
||||
return ext.includes('.jpg') || ext.includes('.jpeg') || ext.includes('.png')
|
||||
})
|
||||
|
||||
const isPdfFile = computed(() => {
|
||||
if (!paper.value) return false
|
||||
return paper.value.file_key.toLowerCase().includes('.pdf')
|
||||
})
|
||||
|
||||
const filteredQuestions = computed(() => {
|
||||
return questions.value.filter(question => {
|
||||
const matchesSearch = !searchQuery.value ||
|
||||
question.question_content.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
const matchesDifficulty = !filterDifficulty.value || question.difficulty === filterDifficulty.value
|
||||
|
||||
return matchesSearch && matchesDifficulty
|
||||
})
|
||||
})
|
||||
|
||||
const fetchPaper = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await api.get(`/papers/${paperId.value}`)
|
||||
paper.value = response.data
|
||||
} catch (error) {
|
||||
ElMessage.error('获取试卷详情失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchQuestions = async () => {
|
||||
try {
|
||||
const response = await api.get('/questions', {
|
||||
params: { paper_id: paperId.value }
|
||||
})
|
||||
questions.value = response.data
|
||||
} catch (error) {
|
||||
ElMessage.error('获取题目列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
const getFileType = (filename) => {
|
||||
const ext = filename.toLowerCase()
|
||||
if (ext.includes('.pdf')) return 'PDF'
|
||||
if (ext.includes('.jpg') || ext.includes('.jpeg')) return 'JPEG图片'
|
||||
if (ext.includes('.png')) return 'PNG图片'
|
||||
return '未知'
|
||||
}
|
||||
|
||||
const getQuestionTypeLabel = (type) => {
|
||||
const labels = {
|
||||
choice: '选择题',
|
||||
fill_blank: '填空题',
|
||||
calculation: '计算题',
|
||||
essay: '问答题'
|
||||
}
|
||||
return labels[type] || type
|
||||
}
|
||||
|
||||
const getQuestionTypeColor = (type) => {
|
||||
const colors = {
|
||||
choice: 'primary',
|
||||
fill_blank: 'success',
|
||||
calculation: 'warning',
|
||||
essay: 'info'
|
||||
}
|
||||
return colors[type] || 'info'
|
||||
}
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
return new Date(dateStr).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const saveQuestion = async () => {
|
||||
if (!newQuestion.question_content || !newQuestion.correct_answer) {
|
||||
ElMessage.error('请填写题目内容和正确答案')
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await api.post('/questions', {
|
||||
paper_id: parseInt(paperId.value),
|
||||
question_type: newQuestion.question_type,
|
||||
subject: paper.value.subject,
|
||||
grade: paper.value.grade,
|
||||
question_content: newQuestion.question_content,
|
||||
options: newQuestion.question_type === 'choice' ? newQuestion.options : null,
|
||||
correct_answer: newQuestion.correct_answer,
|
||||
knowledge_points: newQuestion.knowledge_points,
|
||||
difficulty: newQuestion.difficulty,
|
||||
explanation: newQuestion.explanation
|
||||
})
|
||||
|
||||
ElMessage.success('题目保存成功')
|
||||
cancelAddQuestion()
|
||||
fetchQuestions()
|
||||
} catch (error) {
|
||||
ElMessage.error('保存失败:' + (error.response?.data?.detail || error.message))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const cancelAddQuestion = () => {
|
||||
addQuestionMode.value = false
|
||||
Object.assign(newQuestion, {
|
||||
question_type: 'choice',
|
||||
question_content: '',
|
||||
options: { A: '', B: '', C: '', D: '' },
|
||||
correct_answer: '',
|
||||
knowledge_points: [],
|
||||
difficulty: 3,
|
||||
explanation: ''
|
||||
})
|
||||
}
|
||||
|
||||
const editQuestion = (question) => {
|
||||
ElMessage.info('编辑功能开发中...')
|
||||
}
|
||||
|
||||
const deleteQuestion = async (questionId) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除这个题目吗?', '确认删除', {
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
await api.delete(`/questions/${questionId}`)
|
||||
ElMessage.success('删除成功')
|
||||
fetchQuestions()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const downloadPaper = () => {
|
||||
if (!paper.value || !authStore.token) return
|
||||
const url = buildFileUrl(`papers/${paperId.value}/file`, { access_token: authStore.token })
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
const openInNewTab = () => {
|
||||
if (!paper.value || !authStore.token) return
|
||||
const url = buildFileUrl(`papers/${paperId.value}/file`, { access_token: authStore.token })
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
// 搜索逻辑已在computed中处理
|
||||
}
|
||||
|
||||
const handleFilter = () => {
|
||||
// 过滤逻辑已在computed中处理
|
||||
}
|
||||
|
||||
const onImageLoad = () => {
|
||||
// 图片加载完成
|
||||
}
|
||||
|
||||
const onPdfLoad = () => {
|
||||
console.log('PDF loaded successfully')
|
||||
pdfError.value = false
|
||||
}
|
||||
|
||||
const onPdfError = (error) => {
|
||||
console.error('PDF load error:', error)
|
||||
pdfError.value = true
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.go(-1)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchPaper()
|
||||
fetchQuestions()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.paper-viewer-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.separator {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: white;
|
||||
margin: 0;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.paper-preview img {
|
||||
width: 100%;
|
||||
max-height: 600px;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.file-info {
|
||||
margin-top: 15px;
|
||||
padding: 10px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.pdf-options {
|
||||
margin-bottom: 15px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pdf-error {
|
||||
margin-top: 15px;
|
||||
padding: 20px;
|
||||
background: rgba(255, 193, 7, 0.1);
|
||||
border: 1px solid rgba(255, 193, 7, 0.3);
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.pdf-error p {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.pdf-error .el-button {
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.extract-tips {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.tip-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.tip-icon {
|
||||
font-size: 24px;
|
||||
color: #409eff;
|
||||
margin-right: 15px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.tip-content h4 {
|
||||
margin: 0 0 8px 0;
|
||||
color: white;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.tip-content p {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.quick-add-question {
|
||||
padding: 20px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.choice-options {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.question-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.search-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.empty-questions {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4em;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.questions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.question-item {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.question-item:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.question-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.question-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.difficulty {
|
||||
color: #ffd700;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.question-content {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.question-text {
|
||||
color: white;
|
||||
font-size: 1.1em;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.choice-options-display {
|
||||
margin: 10px 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.option-item {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.question-answer {
|
||||
color: #67c23a;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.question-explanation {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.question-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.knowledge-points {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.created-time {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
:deep(.el-card__header) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
:deep(.el-card__body) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
:deep(.el-card h2) {
|
||||
margin: 0;
|
||||
color: white;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.question-header {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.question-footer {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,260 @@
|
||||
<template>
|
||||
<div class="gradient-bg">
|
||||
<div class="register-container">
|
||||
<div class="register-card glass-effect">
|
||||
<div class="card-header">
|
||||
<h1>🎓 注册新账户</h1>
|
||||
<p>开始您的智能学习之旅</p>
|
||||
</div>
|
||||
|
||||
<el-form :model="registerForm" :rules="rules" ref="registerFormRef" class="register-form">
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
v-model="registerForm.username"
|
||||
placeholder="请输入用户名"
|
||||
size="large"
|
||||
prefix-icon="User"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="email">
|
||||
<el-input
|
||||
v-model="registerForm.email"
|
||||
placeholder="请输入邮箱"
|
||||
size="large"
|
||||
prefix-icon="Message"
|
||||
type="email"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="registerForm.password"
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
prefix-icon="Lock"
|
||||
type="password"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item prop="confirmPassword">
|
||||
<el-input
|
||||
v-model="registerForm.confirmPassword"
|
||||
placeholder="请确认密码"
|
||||
size="large"
|
||||
prefix-icon="Lock"
|
||||
type="password"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="handleRegister"
|
||||
:loading="loading"
|
||||
class="register-btn"
|
||||
>
|
||||
注册账户
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="register-footer">
|
||||
<p>已有账户?<el-button @click="goToLogin" type="primary" text>立即登录</el-button></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const registerFormRef = ref()
|
||||
const loading = ref(false)
|
||||
|
||||
const registerForm = reactive({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
const validateConfirmPassword = (rule, value, callback) => {
|
||||
if (value !== registerForm.password) {
|
||||
callback(new Error('两次输入的密码不一致'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
const rules = {
|
||||
username: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ min: 3, max: 20, message: '用户名长度应在3-20个字符', trigger: 'blur' }
|
||||
],
|
||||
email: [
|
||||
{ required: true, message: '请输入邮箱', trigger: 'blur' },
|
||||
{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码长度至少6个字符', trigger: 'blur' }
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: '请确认密码', trigger: 'blur' },
|
||||
{ validator: validateConfirmPassword, trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
const handleRegister = async () => {
|
||||
try {
|
||||
await registerFormRef.value.validate()
|
||||
|
||||
loading.value = true
|
||||
await authStore.register(registerForm.username, registerForm.email, registerForm.password)
|
||||
|
||||
ElMessage.success('注册成功,正在跳转...')
|
||||
router.push('/')
|
||||
} catch (error) {
|
||||
if (error.response?.data?.detail) {
|
||||
ElMessage.error(error.response.data.detail)
|
||||
} else if (typeof error === 'string') {
|
||||
ElMessage.error(error)
|
||||
} else {
|
||||
ElMessage.error('注册失败,请重试')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const goToLogin = () => {
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.register-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.register-card {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.card-header h1 {
|
||||
color: white;
|
||||
font-size: 2em;
|
||||
margin-bottom: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.card-header p {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.register-form {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.register-form .el-form-item {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.register-btn {
|
||||
width: 100%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
font-size: 1.1em;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.register-btn:hover {
|
||||
background: linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%);
|
||||
}
|
||||
|
||||
.register-footer {
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.register-footer p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper:hover) {
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper.is-focus) {
|
||||
border-color: #409eff;
|
||||
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
|
||||
}
|
||||
|
||||
:deep(.el-input__inner) {
|
||||
color: white;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:deep(.el-input__inner::placeholder) {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
:deep(.el-icon) {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
:deep(.el-form-item__error) {
|
||||
background: rgba(245, 108, 108, 0.9);
|
||||
color: white;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.register-card {
|
||||
padding: 30px 20px;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.card-header h1 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,665 @@
|
||||
<template>
|
||||
<div class="gradient-bg">
|
||||
<div class="statistics-container">
|
||||
<!-- 导航栏 -->
|
||||
<el-header class="header glass-effect">
|
||||
<div class="header-content">
|
||||
<el-button @click="$router.back()" icon="ArrowLeft" circle />
|
||||
<h1>📈 学习统计</h1>
|
||||
<el-button @click="refreshData" icon="Refresh" circle />
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<el-main class="main-content">
|
||||
<!-- 统计卡片 -->
|
||||
<el-row :gutter="20" style="margin-bottom: 30px;">
|
||||
<el-col :span="6">
|
||||
<div class="stats-card">
|
||||
<h3>{{ stats.total_words }}</h3>
|
||||
<p>总词汇量</p>
|
||||
<div class="stats-icon">📚</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stats-card">
|
||||
<h3>{{ stats.due_today }}</h3>
|
||||
<p>今日待复习</p>
|
||||
<div class="stats-icon">⏰</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stats-card">
|
||||
<h3>{{ stats.completed_today }}</h3>
|
||||
<p>今日已完成</p>
|
||||
<div class="stats-icon">✅</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stats-card">
|
||||
<h3>{{ Math.round(stats.accuracy_rate) }}%</h3>
|
||||
<p>准确率</p>
|
||||
<div class="stats-icon">🎯</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 图表区域 -->
|
||||
<el-row :gutter="20" style="margin-bottom: 30px;">
|
||||
<el-col :span="12">
|
||||
<el-card class="glass-effect chart-card">
|
||||
<template #header>
|
||||
<h2>📊 学习进度趋势</h2>
|
||||
</template>
|
||||
<div class="chart-container">
|
||||
<canvas id="progress-chart" width="400" height="200"></canvas>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="12">
|
||||
<el-card class="glass-effect chart-card">
|
||||
<template #header>
|
||||
<h2>📅 未来7天复习预测</h2>
|
||||
</template>
|
||||
<div class="chart-container">
|
||||
<canvas id="forecast-chart" width="400" height="200"></canvas>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 学习成就 -->
|
||||
<el-row :gutter="20" style="margin-bottom: 30px;">
|
||||
<el-col :span="8">
|
||||
<el-card class="glass-effect achievement-card">
|
||||
<template #header>
|
||||
<h3>🏆 学习成就</h3>
|
||||
</template>
|
||||
<div class="achievement-list">
|
||||
<div class="achievement-item" v-for="achievement in achievements" :key="achievement.id">
|
||||
<div class="achievement-icon">{{ achievement.icon }}</div>
|
||||
<div class="achievement-info">
|
||||
<h4>{{ achievement.title }}</h4>
|
||||
<p>{{ achievement.description }}</p>
|
||||
</div>
|
||||
<div class="achievement-status" :class="achievement.unlocked ? 'unlocked' : 'locked'">
|
||||
{{ achievement.unlocked ? '✅' : '🔒' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="8">
|
||||
<el-card class="glass-effect">
|
||||
<template #header>
|
||||
<h3>📈 学习分析</h3>
|
||||
</template>
|
||||
<div class="analysis-info">
|
||||
<div class="analysis-item">
|
||||
<span class="label">连续学习天数</span>
|
||||
<span class="value">{{ stats.streak_days }} 天</span>
|
||||
</div>
|
||||
<div class="analysis-item">
|
||||
<span class="label">平均准确率</span>
|
||||
<span class="value">{{ Math.round(stats.accuracy_rate) }}%</span>
|
||||
</div>
|
||||
<div class="analysis-item">
|
||||
<span class="label">学习效率</span>
|
||||
<span class="value">{{ efficiency }}</span>
|
||||
</div>
|
||||
<div class="analysis-item">
|
||||
<span class="label">推荐复习时间</span>
|
||||
<span class="value">{{ recommendedTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="8">
|
||||
<el-card class="glass-effect">
|
||||
<template #header>
|
||||
<h3>📊 学习目标</h3>
|
||||
</template>
|
||||
<div class="goals-info">
|
||||
<div class="goal-item" v-for="goal in goals" :key="goal.id">
|
||||
<h4>{{ goal.title }}</h4>
|
||||
<el-progress
|
||||
:percentage="goal.progress"
|
||||
:color="goal.color"
|
||||
:stroke-width="8"
|
||||
/>
|
||||
<p>{{ goal.current }} / {{ goal.target }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 顽固词汇 -->
|
||||
<el-row>
|
||||
<el-col :span="24">
|
||||
<el-card class="glass-effect leech-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<h2>🐛 顽固词汇分析</h2>
|
||||
<el-button @click="reviewAllLeech" type="primary" v-if="leechWords.length > 0">
|
||||
专项练习
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="leechWords.length === 0" class="no-leech">
|
||||
<div class="success-icon">🎉</div>
|
||||
<h3>恭喜!暂无顽固词汇</h3>
|
||||
<p>您的学习状态很棒,继续保持!</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<el-table :data="leechWords" style="width: 100%">
|
||||
<el-table-column prop="word" label="单词" width="150">
|
||||
<template #default="scope">
|
||||
<strong>{{ scope.row.word }}</strong>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="meaning" label="中文意思" width="200" />
|
||||
<el-table-column prop="wrong_count" label="错误次数" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag type="danger">{{ scope.row.wrong_count }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="streak_wrong" label="连续错误" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag type="warning">{{ scope.row.streak_wrong }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作">
|
||||
<template #default="scope">
|
||||
<el-button @click="reviewWord(scope.row)" type="primary" size="small">
|
||||
单独练习
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import api from '../services/api'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const stats = ref({
|
||||
total_words: 0,
|
||||
due_today: 0,
|
||||
completed_today: 0,
|
||||
accuracy_rate: 0,
|
||||
streak_days: 1
|
||||
})
|
||||
|
||||
const leechWords = ref([])
|
||||
|
||||
const achievements = ref([
|
||||
{
|
||||
id: 1,
|
||||
icon: '🚀',
|
||||
title: '初学者',
|
||||
description: '完成第一次学习',
|
||||
unlocked: true
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
icon: '📚',
|
||||
title: '词汇达人',
|
||||
description: '学习100个单词',
|
||||
unlocked: false
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
icon: '🔥',
|
||||
title: '连续学习',
|
||||
description: '连续学习7天',
|
||||
unlocked: false
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
icon: '🎯',
|
||||
title: '精准射手',
|
||||
description: '准确率达到90%',
|
||||
unlocked: false
|
||||
}
|
||||
])
|
||||
|
||||
const goals = ref([
|
||||
{
|
||||
id: 1,
|
||||
title: '每日学习目标',
|
||||
current: 15,
|
||||
target: 20,
|
||||
progress: 75,
|
||||
color: '#67c23a'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '月度词汇目标',
|
||||
current: 150,
|
||||
target: 500,
|
||||
progress: 30,
|
||||
color: '#409eff'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '准确率目标',
|
||||
current: 85,
|
||||
target: 90,
|
||||
progress: 94,
|
||||
color: '#e6a23c'
|
||||
}
|
||||
])
|
||||
|
||||
const efficiency = computed(() => {
|
||||
if (stats.value.accuracy_rate >= 90) return '优秀'
|
||||
if (stats.value.accuracy_rate >= 80) return '良好'
|
||||
if (stats.value.accuracy_rate >= 70) return '一般'
|
||||
return '需要改进'
|
||||
})
|
||||
|
||||
const recommendedTime = computed(() => {
|
||||
const hour = new Date().getHours()
|
||||
if (hour >= 6 && hour < 9) return '早晨黄金时间'
|
||||
if (hour >= 19 && hour < 22) return '晚间复习时间'
|
||||
return '建议早晨或晚间学习'
|
||||
})
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const response = await api.get('/stats')
|
||||
stats.value = response.data
|
||||
|
||||
// 更新成就状态
|
||||
achievements.value[1].unlocked = stats.value.total_words >= 100
|
||||
achievements.value[2].unlocked = stats.value.streak_days >= 7
|
||||
achievements.value[3].unlocked = stats.value.accuracy_rate >= 90
|
||||
|
||||
// 更新目标进度
|
||||
goals.value[0].current = stats.value.completed_today
|
||||
goals.value[0].progress = Math.round((stats.value.completed_today / goals.value[0].target) * 100)
|
||||
goals.value[2].current = Math.round(stats.value.accuracy_rate)
|
||||
goals.value[2].progress = Math.round((stats.value.accuracy_rate / goals.value[2].target) * 100)
|
||||
|
||||
} catch (error) {
|
||||
ElMessage.error('获取统计数据失败')
|
||||
}
|
||||
}
|
||||
|
||||
const fetchLeechWords = async () => {
|
||||
try {
|
||||
const response = await api.get('/words/leech')
|
||||
leechWords.value = response.data
|
||||
} catch (error) {
|
||||
ElMessage.error('获取顽固词汇失败')
|
||||
}
|
||||
}
|
||||
|
||||
const drawProgressChart = () => {
|
||||
const canvas = document.getElementById('progress-chart')
|
||||
if (!canvas) return
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// 简单的进度图表
|
||||
const data = [65, 70, 75, 80, 85, 90, 88] // 模拟数据
|
||||
const width = canvas.width
|
||||
const height = canvas.height
|
||||
const margin = 40
|
||||
|
||||
ctx.strokeStyle = '#4facfe'
|
||||
ctx.lineWidth = 3
|
||||
ctx.beginPath()
|
||||
|
||||
data.forEach((value, index) => {
|
||||
const x = margin + (index * (width - 2 * margin)) / (data.length - 1)
|
||||
const y = height - margin - ((value - 50) / 50) * (height - 2 * margin)
|
||||
|
||||
if (index === 0) {
|
||||
ctx.moveTo(x, y)
|
||||
} else {
|
||||
ctx.lineTo(x, y)
|
||||
}
|
||||
})
|
||||
|
||||
ctx.stroke()
|
||||
|
||||
// 添加标题
|
||||
ctx.fillStyle = '#333'
|
||||
ctx.font = 'bold 14px Arial'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('最近7天准确率趋势', width / 2, 20)
|
||||
}
|
||||
|
||||
const drawForecastChart = () => {
|
||||
const canvas = document.getElementById('forecast-chart')
|
||||
if (!canvas) return
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// 未来7天复习预测
|
||||
const forecast = [25, 30, 15, 40, 20, 35, 28] // 模拟数据
|
||||
const width = canvas.width
|
||||
const height = canvas.height
|
||||
const margin = 40
|
||||
const barWidth = (width - 2 * margin) / forecast.length * 0.8
|
||||
const maxValue = Math.max(...forecast)
|
||||
|
||||
forecast.forEach((value, index) => {
|
||||
const barHeight = (value / maxValue) * (height - 2 * margin)
|
||||
const x = margin + index * (width - 2 * margin) / forecast.length + (width - 2 * margin) / forecast.length * 0.1
|
||||
const y = height - margin - barHeight
|
||||
|
||||
// 渐变色
|
||||
const gradient = ctx.createLinearGradient(0, y, 0, y + barHeight)
|
||||
gradient.addColorStop(0, '#4facfe')
|
||||
gradient.addColorStop(1, '#00f2fe')
|
||||
|
||||
ctx.fillStyle = gradient
|
||||
ctx.fillRect(x, y, barWidth, barHeight)
|
||||
|
||||
// 数值标签
|
||||
ctx.fillStyle = '#333'
|
||||
ctx.font = '12px Arial'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(value.toString(), x + barWidth / 2, y - 5)
|
||||
})
|
||||
|
||||
// 添加标题
|
||||
ctx.fillStyle = '#333'
|
||||
ctx.font = 'bold 14px Arial'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('未来7天复习量预测', width / 2, 20)
|
||||
}
|
||||
|
||||
const refreshData = async () => {
|
||||
await Promise.all([fetchStats(), fetchLeechWords()])
|
||||
ElMessage.success('数据已更新')
|
||||
|
||||
// 重新绘制图表
|
||||
setTimeout(() => {
|
||||
drawProgressChart()
|
||||
drawForecastChart()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const reviewWord = (word) => {
|
||||
router.push(`/learning?word=${word.id}`)
|
||||
}
|
||||
|
||||
const reviewAllLeech = () => {
|
||||
router.push('/learning?mode=leech')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.statistics-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: white;
|
||||
margin: 0;
|
||||
font-size: 1.5em;
|
||||
text-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 25px;
|
||||
border-radius: 15px;
|
||||
text-align: center;
|
||||
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stats-card h3 {
|
||||
font-size: 2.5em;
|
||||
margin: 0 0 10px 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.stats-card p {
|
||||
margin: 0;
|
||||
opacity: 0.9;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.stats-icon {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
font-size: 2em;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 220px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.achievement-card .achievement-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.achievement-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 15px 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.achievement-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.achievement-icon {
|
||||
font-size: 2em;
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.achievement-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.achievement-info h4 {
|
||||
margin: 0 0 5px 0;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.achievement-info p {
|
||||
margin: 0;
|
||||
color: rgba(255,255,255,0.7);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.achievement-status {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.achievement-status.unlocked {
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.achievement-status.locked {
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.analysis-info, .goals-info {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.analysis-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.analysis-item .label {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.analysis-item .value {
|
||||
font-weight: bold;
|
||||
color: #00f2fe;
|
||||
}
|
||||
|
||||
.goal-item {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.goal-item h4 {
|
||||
margin: 0 0 10px 0;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.goal-item p {
|
||||
margin: 5px 0 0 0;
|
||||
color: rgba(255,255,255,0.7);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.leech-card {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
margin: 0;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.no-leech {
|
||||
text-align: center;
|
||||
padding: 50px 20px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
font-size: 4em;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.no-leech h3 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.no-leech p {
|
||||
margin: 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
:deep(.el-card__header) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
:deep(.el-card__body) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
:deep(.el-table) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:deep(.el-table th) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
:deep(.el-table td) {
|
||||
background: transparent;
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
:deep(.el-table tr) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stats-card h3 {
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
height: 250px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 170px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,526 @@
|
||||
<template>
|
||||
<div class="gradient-bg">
|
||||
<div class="subject-papers-container">
|
||||
<!-- 导航栏 -->
|
||||
<el-header class="header glass-effect">
|
||||
<div class="header-content">
|
||||
<div class="nav-breadcrumb">
|
||||
<el-button @click="goBack" icon="ArrowLeft" text>返回</el-button>
|
||||
<span class="separator">/</span>
|
||||
<h1>{{ subjectInfo.name }} - 试卷管理</h1>
|
||||
</div>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<el-main class="main-content">
|
||||
<!-- 操作工具栏 -->
|
||||
<div class="toolbar glass-effect">
|
||||
<div class="toolbar-left">
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索试卷..."
|
||||
style="width: 300px;"
|
||||
clearable
|
||||
@input="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-select v-model="filterGrade" placeholder="年级" style="width: 120px;" @change="handleFilter">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="七年级" value="grade7" />
|
||||
<el-option label="八年级" value="grade8" />
|
||||
<el-option label="九年级" value="grade9" />
|
||||
<el-option label="高一" value="grade10" />
|
||||
<el-option label="高二" value="grade11" />
|
||||
<el-option label="高三" value="grade12" />
|
||||
</el-select>
|
||||
<el-select v-model="filterExamType" placeholder="考试类型" style="width: 120px;" @change="handleFilter">
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="期中考试" value="midterm" />
|
||||
<el-option label="期末考试" value="final" />
|
||||
<el-option label="月考" value="monthly" />
|
||||
<el-option label="单元测试" value="unit" />
|
||||
<el-option label="练习题" value="practice" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<el-button @click="showUploadDialog = true" type="primary" icon="Upload">
|
||||
上传试卷
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 试卷列表 -->
|
||||
<div class="papers-grid" v-loading="loading">
|
||||
<div
|
||||
class="paper-card glass-effect"
|
||||
v-for="paper in filteredPapers"
|
||||
:key="paper.id"
|
||||
@click="viewPaper(paper)"
|
||||
>
|
||||
<div class="paper-header">
|
||||
<div class="paper-title">{{ paper.title }}</div>
|
||||
<div class="paper-score" v-if="paper.achieved_score">
|
||||
{{ paper.achieved_score }}/{{ paper.total_score }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="paper-info">
|
||||
<div class="info-row">
|
||||
<span class="label">年级:</span>
|
||||
<span class="value">{{ getGradeLabel(paper.grade) }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">类型:</span>
|
||||
<span class="value">{{ getExamTypeLabel(paper.exam_type) }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">考试日期:</span>
|
||||
<span class="value">{{ formatDate(paper.exam_date) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="paper-tags" v-if="paper.tags && paper.tags.length">
|
||||
<el-tag v-for="tag in paper.tags" :key="tag" size="small">{{ tag }}</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="paper-actions">
|
||||
<el-button @click.stop="viewPaper(paper)" size="small" icon="View">
|
||||
查看详情
|
||||
</el-button>
|
||||
<el-button @click.stop="extractMistakes(paper)" size="small" type="primary" icon="DocumentAdd">
|
||||
提取错题
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="!loading && filteredPapers.length === 0" class="empty-state">
|
||||
<div class="empty-icon">📄</div>
|
||||
<p>暂无试卷,点击上传按钮添加试卷</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-main>
|
||||
</div>
|
||||
|
||||
<!-- 上传试卷对话框 -->
|
||||
<el-dialog v-model="showUploadDialog" title="上传试卷" width="600px">
|
||||
<el-form :model="uploadForm" label-width="100px">
|
||||
<el-form-item label="试卷标题" required>
|
||||
<el-input v-model="uploadForm.title" placeholder="请输入试卷标题" />
|
||||
</el-form-item>
|
||||
<el-form-item label="年级" required>
|
||||
<el-select v-model="uploadForm.grade" placeholder="请选择年级">
|
||||
<el-option label="七年级" value="grade7" />
|
||||
<el-option label="八年级" value="grade8" />
|
||||
<el-option label="九年级" value="grade9" />
|
||||
<el-option label="高一" value="grade10" />
|
||||
<el-option label="高二" value="grade11" />
|
||||
<el-option label="高三" value="grade12" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="考试类型" required>
|
||||
<el-select v-model="uploadForm.exam_type" placeholder="请选择考试类型">
|
||||
<el-option label="期中考试" value="midterm" />
|
||||
<el-option label="期末考试" value="final" />
|
||||
<el-option label="月考" value="monthly" />
|
||||
<el-option label="单元测试" value="unit" />
|
||||
<el-option label="练习题" value="practice" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="考试日期" required>
|
||||
<el-date-picker
|
||||
v-model="uploadForm.exam_date"
|
||||
type="date"
|
||||
placeholder="选择考试日期"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="总分" required>
|
||||
<el-input-number v-model="uploadForm.total_score" :min="1" :max="1000" />
|
||||
</el-form-item>
|
||||
<el-form-item label="得分">
|
||||
<el-input-number v-model="uploadForm.achieved_score" :min="0" :max="uploadForm.total_score" />
|
||||
</el-form-item>
|
||||
<el-form-item label="试卷文件" required>
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
:auto-upload="false"
|
||||
:on-change="handleFileChange"
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
drag
|
||||
>
|
||||
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
|
||||
<div class="el-upload__text">
|
||||
将试卷拖到此处,或<em>点击上传</em>
|
||||
</div>
|
||||
<template #tip>
|
||||
<div class="el-upload__tip">
|
||||
支持 PDF、JPG、PNG 格式,文件大小不超过 50MB
|
||||
</div>
|
||||
</template>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showUploadDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitUpload" :loading="uploading">
|
||||
上传
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { UploadFilled, Search, ArrowLeft, View, DocumentAdd } from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import api from '../services/api'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const subject = ref(route.params.subject)
|
||||
const papers = ref([])
|
||||
const loading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const filterGrade = ref('')
|
||||
const filterExamType = ref('')
|
||||
|
||||
const showUploadDialog = ref(false)
|
||||
const uploading = ref(false)
|
||||
const selectedFile = ref(null)
|
||||
|
||||
const uploadForm = reactive({
|
||||
title: '',
|
||||
grade: '',
|
||||
exam_type: '',
|
||||
exam_date: null,
|
||||
total_score: 100,
|
||||
achieved_score: null
|
||||
})
|
||||
|
||||
const subjectInfo = computed(() => {
|
||||
const subjects = {
|
||||
math: { name: '数学', icon: '📐' },
|
||||
chemistry: { name: '化学', icon: '🧪' },
|
||||
physics: { name: '物理', icon: '⚡' },
|
||||
english: { name: '英语', icon: '📚' }
|
||||
}
|
||||
return subjects[subject.value] || { name: '未知学科', icon: '📖' }
|
||||
})
|
||||
|
||||
const filteredPapers = computed(() => {
|
||||
return papers.value.filter(paper => {
|
||||
const matchesSearch = !searchQuery.value ||
|
||||
paper.title.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
const matchesGrade = !filterGrade.value || paper.grade === filterGrade.value
|
||||
const matchesExamType = !filterExamType.value || paper.exam_type === filterExamType.value
|
||||
|
||||
return matchesSearch && matchesGrade && matchesExamType
|
||||
})
|
||||
})
|
||||
|
||||
const fetchPapers = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await api.get('/papers', {
|
||||
params: { subject: subject.value }
|
||||
})
|
||||
papers.value = response.data
|
||||
} catch (error) {
|
||||
ElMessage.error('获取试卷列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getGradeLabel = (grade) => {
|
||||
const labels = {
|
||||
grade7: '七年级',
|
||||
grade8: '八年级',
|
||||
grade9: '九年级',
|
||||
grade10: '高一',
|
||||
grade11: '高二',
|
||||
grade12: '高三'
|
||||
}
|
||||
return labels[grade] || grade
|
||||
}
|
||||
|
||||
const getExamTypeLabel = (examType) => {
|
||||
const labels = {
|
||||
midterm: '期中考试',
|
||||
final: '期末考试',
|
||||
monthly: '月考',
|
||||
unit: '单元测试',
|
||||
practice: '练习题'
|
||||
}
|
||||
return labels[examType] || examType
|
||||
}
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
return new Date(dateStr).toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
// 搜索逻辑已在computed中处理
|
||||
}
|
||||
|
||||
const handleFilter = () => {
|
||||
// 过滤逻辑已在computed中处理
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
const viewPaper = (paper) => {
|
||||
router.push(`/papers/${paper.id}`)
|
||||
}
|
||||
|
||||
const extractMistakes = (paper) => {
|
||||
router.push(`/papers/${paper.id}/mistakes`)
|
||||
}
|
||||
|
||||
const handleFileChange = (file) => {
|
||||
selectedFile.value = file.raw
|
||||
}
|
||||
|
||||
const submitUpload = async () => {
|
||||
if (!selectedFile.value) {
|
||||
ElMessage.error('请选择要上传的文件')
|
||||
return
|
||||
}
|
||||
|
||||
if (!uploadForm.title || !uploadForm.grade || !uploadForm.exam_type || !uploadForm.exam_date) {
|
||||
ElMessage.error('请填写完整的试卷信息')
|
||||
return
|
||||
}
|
||||
|
||||
uploading.value = true
|
||||
try {
|
||||
// 1. 直接上传文件到我们的服务器
|
||||
const formData = new FormData()
|
||||
formData.append('file', selectedFile.value)
|
||||
|
||||
// 使用我们的API上传文件
|
||||
const uploadResponse = await api.post('/file/upload/', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
|
||||
// 2. 创建试卷记录
|
||||
await api.post('/papers', {
|
||||
title: uploadForm.title,
|
||||
subject: subject.value,
|
||||
grade: uploadForm.grade,
|
||||
exam_type: uploadForm.exam_type,
|
||||
exam_date: uploadForm.exam_date,
|
||||
total_score: uploadForm.total_score,
|
||||
achieved_score: uploadForm.achieved_score,
|
||||
file_key: uploadResponse.data.file_key
|
||||
})
|
||||
|
||||
ElMessage.success('试卷上传成功')
|
||||
showUploadDialog.value = false
|
||||
|
||||
// 重置表单
|
||||
Object.assign(uploadForm, {
|
||||
title: '',
|
||||
grade: '',
|
||||
exam_type: '',
|
||||
exam_date: null,
|
||||
total_score: 100,
|
||||
achieved_score: null
|
||||
})
|
||||
selectedFile.value = null
|
||||
|
||||
// 刷新试卷列表
|
||||
fetchPapers()
|
||||
} catch (error) {
|
||||
ElMessage.error('上传失败:' + (error.response?.data?.detail || error.message))
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchPapers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.subject-papers-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.separator {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: white;
|
||||
margin: 0;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 15px;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.papers-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.paper-card {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 15px;
|
||||
padding: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.paper-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 15px 35px rgba(0,0,0,0.2);
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.paper-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.paper-title {
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.paper-score {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 5px 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.paper-info {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.label {
|
||||
opacity: 0.8;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.paper-tags {
|
||||
margin-bottom: 15px;
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.paper-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4em;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.papers-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toolbar-left > * {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,851 @@
|
||||
<template>
|
||||
<div class="gradient-bg">
|
||||
<div class="word-management-container">
|
||||
<!-- 导航栏 -->
|
||||
<el-header class="header glass-effect">
|
||||
<div class="header-content">
|
||||
<el-button @click="$router.back()" icon="ArrowLeft" circle />
|
||||
<h1>📚 词库管理</h1>
|
||||
<div class="header-actions">
|
||||
<el-button @click="showImportDialog = true" type="primary" icon="Upload">
|
||||
导入单词
|
||||
</el-button>
|
||||
<el-button @click="exportWords" icon="Download">
|
||||
导出单词
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<!-- 主要内容 -->
|
||||
<el-main class="main-content">
|
||||
<!-- 操作栏 -->
|
||||
<div class="action-bar">
|
||||
<div class="search-area">
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索单词或中文意思..."
|
||||
prefix-icon="Search"
|
||||
size="large"
|
||||
style="width: 400px;"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
<el-select
|
||||
v-model="selectedLevel"
|
||||
placeholder="选择级别"
|
||||
size="large"
|
||||
style="width: 150px; margin-left: 15px;"
|
||||
@change="handleLevelFilter"
|
||||
>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="小学" value="elementary" />
|
||||
<el-option label="初中" value="middle_school" />
|
||||
<el-option label="高中" value="high_school" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<el-button @click="showAddDialog = true" type="primary" icon="Plus" size="large">
|
||||
添加单词
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<el-row :gutter="20" style="margin-bottom: 30px;">
|
||||
<el-col :span="6">
|
||||
<div class="stats-card">
|
||||
<h3>{{ totalWords }}</h3>
|
||||
<p>总单词数</p>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stats-card">
|
||||
<h3>{{ filteredWords.length }}</h3>
|
||||
<p>当前显示</p>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stats-card">
|
||||
<h3>{{ leechCount }}</h3>
|
||||
<p>顽固词汇</p>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stats-card">
|
||||
<h3>{{ recentCount }}</h3>
|
||||
<p>最近添加</p>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 单词列表 -->
|
||||
<el-card class="glass-effect word-list-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<h2>单词列表</h2>
|
||||
<div class="list-actions">
|
||||
<el-button @click="selectAll" :disabled="filteredWords.length === 0">
|
||||
{{ isAllSelected ? '取消全选' : '全选' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
@click="batchDelete"
|
||||
type="danger"
|
||||
:disabled="selectedWords.length === 0"
|
||||
>
|
||||
批量删除 ({{ selectedWords.length }})
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table
|
||||
:data="paginatedWords"
|
||||
style="width: 100%"
|
||||
@selection-change="handleSelectionChange"
|
||||
v-loading="loading"
|
||||
>
|
||||
<el-table-column type="selection" width="55" />
|
||||
<el-table-column prop="word" label="单词" width="200" sortable>
|
||||
<template #default="scope">
|
||||
<div class="word-cell">
|
||||
<strong>{{ scope.row.word }}</strong>
|
||||
<el-button
|
||||
@click="speakWord(scope.row.word)"
|
||||
icon="VideoPlay"
|
||||
size="small"
|
||||
circle
|
||||
style="margin-left: 10px;"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="meaning" label="中文意思" width="250" />
|
||||
<el-table-column prop="example" label="例句" min-width="300">
|
||||
<template #default="scope">
|
||||
<div class="example-cell">
|
||||
{{ scope.row.example || '暂无例句' }}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="level" label="级别" width="100">
|
||||
<template #default="scope">
|
||||
<el-tag :type="getLevelType(scope.row.level)">
|
||||
{{ getLevelText(scope.row.level) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="phrases" label="短语" width="200">
|
||||
<template #default="scope">
|
||||
<div class="phrases-cell">
|
||||
<el-tag
|
||||
v-for="phrase in (scope.row.phrases || []).slice(0, 2)"
|
||||
:key="phrase"
|
||||
size="small"
|
||||
style="margin: 2px;"
|
||||
>
|
||||
{{ phrase }}
|
||||
</el-tag>
|
||||
<span v-if="(scope.row.phrases || []).length > 2" class="more-phrases">
|
||||
+{{ (scope.row.phrases || []).length - 2 }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="scope">
|
||||
<el-button @click="editWord(scope.row)" icon="Edit" size="small" />
|
||||
<el-button @click="deleteWord(scope.row)" type="danger" icon="Delete" size="small" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-container">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="filteredWords.length"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-main>
|
||||
</div>
|
||||
|
||||
<!-- 添加单词对话框 -->
|
||||
<el-dialog v-model="showAddDialog" title="添加新单词" width="600px">
|
||||
<el-form :model="newWord" :rules="wordRules" ref="addFormRef" label-width="100px">
|
||||
<el-form-item label="英文单词" prop="word">
|
||||
<el-input v-model="newWord.word" placeholder="请输入英文单词" />
|
||||
</el-form-item>
|
||||
<el-form-item label="中文意思" prop="meaning">
|
||||
<el-input v-model="newWord.meaning" placeholder="请输入中文意思" />
|
||||
</el-form-item>
|
||||
<el-form-item label="例句">
|
||||
<el-input
|
||||
v-model="newWord.example"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入例句(可选)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="常用短语">
|
||||
<el-input
|
||||
v-model="phrasesInput"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="请输入常用短语,用分号分隔(可选)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="级别">
|
||||
<el-select v-model="newWord.level" placeholder="请选择级别">
|
||||
<el-option label="小学" value="elementary" />
|
||||
<el-option label="初中" value="middle_school" />
|
||||
<el-option label="高中" value="high_school" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showAddDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="addWord" :loading="submitting">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 编辑单词对话框 -->
|
||||
<el-dialog v-model="showEditDialog" title="编辑单词" width="600px">
|
||||
<el-form :model="editingWord" :rules="wordRules" ref="editFormRef" label-width="100px">
|
||||
<el-form-item label="英文单词" prop="word">
|
||||
<el-input v-model="editingWord.word" placeholder="请输入英文单词" />
|
||||
</el-form-item>
|
||||
<el-form-item label="中文意思" prop="meaning">
|
||||
<el-input v-model="editingWord.meaning" placeholder="请输入中文意思" />
|
||||
</el-form-item>
|
||||
<el-form-item label="例句">
|
||||
<el-input
|
||||
v-model="editingWord.example"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入例句(可选)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="常用短语">
|
||||
<el-input
|
||||
v-model="editPhrasesInput"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="请输入常用短语,用分号分隔(可选)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="级别">
|
||||
<el-select v-model="editingWord.level" placeholder="请选择级别">
|
||||
<el-option label="小学" value="elementary" />
|
||||
<el-option label="初中" value="middle_school" />
|
||||
<el-option label="高中" value="high_school" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showEditDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="updateWord" :loading="submitting">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 导入对话框 -->
|
||||
<el-dialog v-model="showImportDialog" title="导入单词" width="500px">
|
||||
<div class="import-content">
|
||||
<el-alert
|
||||
title="导入说明"
|
||||
type="info"
|
||||
:closable="false"
|
||||
style="margin-bottom: 20px;"
|
||||
>
|
||||
<template #default>
|
||||
<p>支持 CSV 和 JSON 格式文件</p>
|
||||
<p><strong>CSV 格式:</strong>word,meaning,example,phrases,level</p>
|
||||
<p><strong>短语分隔:</strong>多个短语用分号(;)分隔</p>
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
:auto-upload="false"
|
||||
:on-change="handleFileChange"
|
||||
:before-remove="handleFileRemove"
|
||||
accept=".csv,.json"
|
||||
drag
|
||||
>
|
||||
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
|
||||
<div class="el-upload__text">
|
||||
将文件拖到此处,或<em>点击上传</em>
|
||||
</div>
|
||||
<template #tip>
|
||||
<div class="el-upload__tip">
|
||||
只能上传 CSV/JSON 文件,且不超过 10MB
|
||||
</div>
|
||||
</template>
|
||||
</el-upload>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="showImportDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="importWords" :loading="importing" :disabled="!selectedFile">
|
||||
导入
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { UploadFilled } from '@element-plus/icons-vue'
|
||||
import { useWordStore } from '../stores/word'
|
||||
import api from '../services/api'
|
||||
|
||||
const router = useRouter()
|
||||
const wordStore = useWordStore()
|
||||
|
||||
// 响应式数据
|
||||
const words = ref([])
|
||||
const loading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const selectedLevel = ref('')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const selectedWords = ref([])
|
||||
const showAddDialog = ref(false)
|
||||
const showEditDialog = ref(false)
|
||||
const showImportDialog = ref(false)
|
||||
const submitting = ref(false)
|
||||
const importing = ref(false)
|
||||
const selectedFile = ref(null)
|
||||
|
||||
const newWord = reactive({
|
||||
word: '',
|
||||
meaning: '',
|
||||
example: '',
|
||||
level: 'middle_school'
|
||||
})
|
||||
|
||||
const editingWord = reactive({
|
||||
id: null,
|
||||
word: '',
|
||||
meaning: '',
|
||||
example: '',
|
||||
level: 'middle_school'
|
||||
})
|
||||
|
||||
const phrasesInput = ref('')
|
||||
const editPhrasesInput = ref('')
|
||||
|
||||
// 表单验证规则
|
||||
const wordRules = {
|
||||
word: [
|
||||
{ required: true, message: '请输入英文单词', trigger: 'blur' },
|
||||
{ min: 1, max: 50, message: '单词长度应在1-50字符', trigger: 'blur' }
|
||||
],
|
||||
meaning: [
|
||||
{ required: true, message: '请输入中文意思', trigger: 'blur' },
|
||||
{ min: 1, max: 100, message: '意思长度应在1-100字符', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 计算属性
|
||||
const filteredWords = computed(() => {
|
||||
let result = words.value
|
||||
|
||||
// 级别过滤
|
||||
if (selectedLevel.value) {
|
||||
result = result.filter(word => word.level === selectedLevel.value)
|
||||
}
|
||||
|
||||
// 搜索过滤
|
||||
if (searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
result = result.filter(word =>
|
||||
word.word.toLowerCase().includes(query) ||
|
||||
word.meaning.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const paginatedWords = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
const end = start + pageSize.value
|
||||
return filteredWords.value.slice(start, end)
|
||||
})
|
||||
|
||||
const totalWords = computed(() => words.value.length)
|
||||
const leechCount = computed(() => words.value.filter(w => w.leech).length)
|
||||
const recentCount = computed(() => {
|
||||
const oneWeekAgo = Date.now() - 7 * 24 * 3600 * 1000
|
||||
return words.value.filter(w => new Date(w.created_at || 0).getTime() > oneWeekAgo).length
|
||||
})
|
||||
|
||||
const isAllSelected = computed(() => {
|
||||
return paginatedWords.value.length > 0 && selectedWords.value.length === paginatedWords.value.length
|
||||
})
|
||||
|
||||
// 方法
|
||||
const fetchWords = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await wordStore.fetchWords()
|
||||
words.value = response
|
||||
} catch (error) {
|
||||
ElMessage.error('获取单词列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
const handleLevelFilter = () => {
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
const handleSizeChange = (val) => {
|
||||
pageSize.value = val
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
const handleCurrentChange = (val) => {
|
||||
currentPage.value = val
|
||||
}
|
||||
|
||||
const handleSelectionChange = (selection) => {
|
||||
selectedWords.value = selection
|
||||
}
|
||||
|
||||
const selectAll = () => {
|
||||
// 这里需要通过表格引用来实现全选/取消全选
|
||||
// 简化实现
|
||||
if (isAllSelected.value) {
|
||||
selectedWords.value = []
|
||||
} else {
|
||||
selectedWords.value = [...paginatedWords.value]
|
||||
}
|
||||
}
|
||||
|
||||
const getLevelType = (level) => {
|
||||
const types = {
|
||||
elementary: 'success',
|
||||
middle_school: 'warning',
|
||||
high_school: 'danger'
|
||||
}
|
||||
return types[level] || 'info'
|
||||
}
|
||||
|
||||
const getLevelText = (level) => {
|
||||
const texts = {
|
||||
elementary: '小学',
|
||||
middle_school: '初中',
|
||||
high_school: '高中'
|
||||
}
|
||||
return texts[level] || '未知'
|
||||
}
|
||||
|
||||
const speakWord = (word) => {
|
||||
const utterance = new SpeechSynthesisUtterance(word)
|
||||
utterance.lang = 'en-US'
|
||||
speechSynthesis.speak(utterance)
|
||||
}
|
||||
|
||||
const addWord = async () => {
|
||||
const addFormRef = ref()
|
||||
if (!addFormRef.value) return
|
||||
|
||||
await addFormRef.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
submitting.value = true
|
||||
try {
|
||||
const wordData = {
|
||||
...newWord,
|
||||
phrases: phrasesInput.value ? phrasesInput.value.split(';').map(p => p.trim()).filter(p => p) : []
|
||||
}
|
||||
|
||||
await wordStore.createWord(wordData)
|
||||
ElMessage.success('添加成功')
|
||||
showAddDialog.value = false
|
||||
|
||||
// 重置表单
|
||||
Object.assign(newWord, {
|
||||
word: '',
|
||||
meaning: '',
|
||||
example: '',
|
||||
level: 'middle_school'
|
||||
})
|
||||
phrasesInput.value = ''
|
||||
|
||||
// 刷新列表
|
||||
await fetchWords()
|
||||
} catch (error) {
|
||||
ElMessage.error('添加失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const editWord = (word) => {
|
||||
Object.assign(editingWord, {
|
||||
id: word.id,
|
||||
word: word.word,
|
||||
meaning: word.meaning,
|
||||
example: word.example || '',
|
||||
level: word.level
|
||||
})
|
||||
editPhrasesInput.value = (word.phrases || []).join('; ')
|
||||
showEditDialog.value = true
|
||||
}
|
||||
|
||||
const updateWord = async () => {
|
||||
const editFormRef = ref()
|
||||
if (!editFormRef.value) return
|
||||
|
||||
await editFormRef.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
submitting.value = true
|
||||
try {
|
||||
const wordData = {
|
||||
...editingWord,
|
||||
phrases: editPhrasesInput.value ? editPhrasesInput.value.split(';').map(p => p.trim()).filter(p => p) : []
|
||||
}
|
||||
|
||||
// 这里需要实现更新API
|
||||
await api.put(`/words/${editingWord.id}`, wordData)
|
||||
ElMessage.success('更新成功')
|
||||
showEditDialog.value = false
|
||||
|
||||
// 刷新列表
|
||||
await fetchWords()
|
||||
} catch (error) {
|
||||
ElMessage.error('更新失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const deleteWord = async (word) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除单词 "${word.word}" 吗?`, '确认删除', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
// 这里需要实现删除API
|
||||
await api.delete(`/words/${word.id}`)
|
||||
ElMessage.success('删除成功')
|
||||
|
||||
// 刷新列表
|
||||
await fetchWords()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const batchDelete = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除选中的 ${selectedWords.value.length} 个单词吗?`, '批量删除', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
// 这里需要实现批量删除API
|
||||
const promises = selectedWords.value.map(word => api.delete(`/words/${word.id}`))
|
||||
await Promise.all(promises)
|
||||
|
||||
ElMessage.success(`成功删除 ${selectedWords.value.length} 个单词`)
|
||||
selectedWords.value = []
|
||||
|
||||
// 刷新列表
|
||||
await fetchWords()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('批量删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileChange = (file) => {
|
||||
selectedFile.value = file.raw
|
||||
}
|
||||
|
||||
const handleFileRemove = () => {
|
||||
selectedFile.value = null
|
||||
}
|
||||
|
||||
const importWords = async () => {
|
||||
if (!selectedFile.value) {
|
||||
ElMessage.error('请选择要导入的文件')
|
||||
return
|
||||
}
|
||||
|
||||
importing.value = true
|
||||
try {
|
||||
await wordStore.importWords(selectedFile.value)
|
||||
ElMessage.success('导入成功')
|
||||
showImportDialog.value = false
|
||||
selectedFile.value = null
|
||||
|
||||
// 刷新列表
|
||||
await fetchWords()
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '导入失败')
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const exportWords = () => {
|
||||
const csvContent = [
|
||||
'word,meaning,example,phrases,level',
|
||||
...words.value.map(w => `"${w.word}","${w.meaning}","${w.example || ''}","${(w.phrases || []).join(';')}","${w.level}"`)
|
||||
].join('\n')
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(blob)
|
||||
link.download = `wordmaster_export_${new Date().toISOString().slice(0, 10)}.csv`
|
||||
link.click()
|
||||
|
||||
ElMessage.success('导出成功')
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
fetchWords()
|
||||
})
|
||||
|
||||
// 监听搜索和过滤变化
|
||||
watch([searchQuery, selectedLevel], () => {
|
||||
currentPage.value = 1
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.word-management-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: white;
|
||||
margin: 0;
|
||||
font-size: 1.5em;
|
||||
text-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 20px;
|
||||
border-radius: 15px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.search-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
border-radius: 15px;
|
||||
text-align: center;
|
||||
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.stats-card h3 {
|
||||
font-size: 2em;
|
||||
margin: 0 0 10px 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.stats-card p {
|
||||
margin: 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.word-list-card {
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
margin: 0;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.list-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.word-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.example-cell {
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.phrases-cell {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.more-phrases {
|
||||
color: #909399;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.import-content {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
:deep(.el-card__header) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
:deep(.el-card__body) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
:deep(.el-table) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:deep(.el-table th) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
:deep(.el-table td) {
|
||||
background: transparent;
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
:deep(.el-table tr) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper) {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
:deep(.el-input__inner) {
|
||||
color: white;
|
||||
}
|
||||
|
||||
:deep(.el-input__inner::placeholder) {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
:deep(.el-select .el-input.is-focus .el-input__wrapper) {
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.search-area {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.search-area .el-input {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.search-area .el-select {
|
||||
margin-left: 0 !important;
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,45 @@
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
// 加载环境变量
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
|
||||
// 获取后端服务地址
|
||||
const backendHost = env.VITE_BACKEND_HOST || 'localhost'
|
||||
const backendPort = env.VITE_BACKEND_PORT || '8888'
|
||||
const backendUrl = `http://${backendHost}:${backendPort}`
|
||||
|
||||
return {
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: backendUrl,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
// 开发环境下打印代理信息
|
||||
configure: (proxy, _options) => {
|
||||
proxy.on('error', (err, _req, _res) => {
|
||||
console.log('proxy error', err)
|
||||
})
|
||||
proxy.on('proxyReq', (proxyReq, req, _res) => {
|
||||
console.log(`Proxying ${req.method} ${req.url} to ${backendUrl}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets'
|
||||
},
|
||||
// 环境变量配置
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(env.VITE_APP_VERSION || '1.0.0'),
|
||||
__BUILD_TIME__: JSON.stringify(new Date().toISOString())
|
||||
}
|
||||
}
|
||||
})
|
||||
+1583
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
# 启动脚本
|
||||
#!/bin/bash
|
||||
|
||||
echo "🚀 启动 WordMaster Pro 系统..."
|
||||
|
||||
# 检查 Python 和 Node.js
|
||||
if ! command -v python3 &> /dev/null; then
|
||||
echo "❌ Python3 未安装,请先安装 Python 3.7+"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v node &> /dev/null; then
|
||||
echo "❌ Node.js 未安装,请先安装 Node.js 16+"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 启动后端
|
||||
echo "📡 启动后端服务..."
|
||||
cd backend
|
||||
source venv/bin/activate
|
||||
uvicorn main:app --host 0.0.0.0 --port 8888 &
|
||||
BACKEND_PID=$!
|
||||
echo "✅ 后端已启动 (PID: $BACKEND_PID) - http://0.0.0.0:8888"
|
||||
|
||||
# 等待后端启动
|
||||
sleep 3
|
||||
|
||||
# 启动前端
|
||||
echo "🎨 启动前端服务..."
|
||||
cd ../frontend
|
||||
npm run dev &
|
||||
FRONTEND_PID=$!
|
||||
echo "✅ 前端已启动 (PID: $FRONTEND_PID) - 端口将自动选择"
|
||||
|
||||
echo ""
|
||||
echo "🎉 WordMaster Pro 启动成功!"
|
||||
echo "📖 前端访问地址: 请查看上方显示的实际端口 (支持远程访问)"
|
||||
echo "🔧 后端 API 地址: http://0.0.0.0:8888 (支持远程访问)"
|
||||
echo "📚 API 文档: http://localhost:8888/docs 或 http://[你的IP]:8888/docs"
|
||||
echo ""
|
||||
echo "按 Ctrl+C 停止服务"
|
||||
|
||||
# 保存进程ID以便停止
|
||||
echo $BACKEND_PID > .backend.pid
|
||||
echo $FRONTEND_PID > .frontend.pid
|
||||
|
||||
# 等待用户中断
|
||||
wait
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "🔍 WordMaster Pro 系统状态检查"
|
||||
echo "================================"
|
||||
|
||||
# 检查后端状态
|
||||
echo "📡 后端服务状态:"
|
||||
if curl -s http://localhost:8888/docs > /dev/null; then
|
||||
echo " ✅ 后端服务运行正常 (http://localhost:8888)"
|
||||
echo " 📚 API文档可访问 (http://localhost:8888/docs)"
|
||||
|
||||
# 检查API健康状态
|
||||
if curl -s http://localhost:8888/api/words > /dev/null; then
|
||||
echo " ✅ API接口正常"
|
||||
else
|
||||
echo " ❌ API接口异常"
|
||||
fi
|
||||
else
|
||||
echo " ❌ 后端服务未运行"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# 检查前端状态
|
||||
echo "🎨 前端服务状态:"
|
||||
if curl -s http://localhost:3002 > /dev/null; then
|
||||
echo " ✅ 前端服务运行正常 (http://localhost:3002)"
|
||||
else
|
||||
echo " ❌ 前端服务未运行"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# 检查数据库
|
||||
echo "💾 数据库状态:"
|
||||
if [ -f "/Users/brunogao/work/codes/pingan/words/backend/wordmaster.db" ]; then
|
||||
echo " ✅ 数据库文件存在"
|
||||
# 简单检查数据库大小
|
||||
DB_SIZE=$(du -h "/Users/brunogao/work/codes/pingan/words/backend/wordmaster.db" | cut -f1)
|
||||
echo " 📊 数据库大小: $DB_SIZE"
|
||||
else
|
||||
echo " ❌ 数据库文件不存在"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# 检查端口占用
|
||||
echo "🔌 端口占用状态:"
|
||||
if lsof -i :8888 > /dev/null 2>&1; then
|
||||
echo " ✅ 端口 8888 (后端) 已占用"
|
||||
else
|
||||
echo " ❌ 端口 8888 (后端) 未占用"
|
||||
fi
|
||||
|
||||
if lsof -i :3002 > /dev/null 2>&1; then
|
||||
echo " ✅ 端口 3002 (前端) 已占用"
|
||||
else
|
||||
echo " ❌ 端口 3002 (前端) 未占用"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# 检查进程
|
||||
echo "⚙️ 进程状态:"
|
||||
BACKEND_PID=$(ps aux | grep "python main.py" | grep -v grep | awk '{print $2}')
|
||||
FRONTEND_PID=$(ps aux | grep "vite" | grep -v grep | awk '{print $2}')
|
||||
|
||||
if [ ! -z "$BACKEND_PID" ]; then
|
||||
echo " ✅ 后端进程运行中 (PID: $BACKEND_PID)"
|
||||
else
|
||||
echo " ❌ 后端进程未运行"
|
||||
fi
|
||||
|
||||
if [ ! -z "$FRONTEND_PID" ]; then
|
||||
echo " ✅ 前端进程运行中 (PID: $FRONTEND_PID)"
|
||||
else
|
||||
echo " ❌ 前端进程未运行"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# 系统资源
|
||||
echo "📊 系统资源:"
|
||||
echo " 💾 内存使用率: $(vm_stat | head -n 10 | tail -n 1 | awk '{print $2}' | sed 's/[^0-9]//g')MB"
|
||||
echo " 💽 磁盘空间: $(df -h / | tail -n 1 | awk '{print $4}') 可用"
|
||||
|
||||
echo ""
|
||||
echo "🎉 检查完成!"
|
||||
|
||||
if curl -s http://localhost:3002 > /dev/null && curl -s http://localhost:8888/docs > /dev/null; then
|
||||
echo ""
|
||||
echo "🌟 系统运行正常,可以访问:"
|
||||
echo " 前端: http://localhost:3002"
|
||||
echo " 后端: http://localhost:8888"
|
||||
echo " API文档: http://localhost:8888/docs"
|
||||
else
|
||||
echo ""
|
||||
echo "⚠️ 系统可能存在问题,请检查服务状态"
|
||||
fi
|
||||
@@ -0,0 +1,31 @@
|
||||
# 停止脚本
|
||||
#!/bin/bash
|
||||
|
||||
echo "🛑 停止 WordMaster Pro 系统..."
|
||||
|
||||
# 停止后端
|
||||
if [ -f .backend.pid ]; then
|
||||
BACKEND_PID=$(cat .backend.pid)
|
||||
if ps -p $BACKEND_PID > /dev/null; then
|
||||
kill $BACKEND_PID
|
||||
echo "✅ 后端服务已停止"
|
||||
fi
|
||||
rm .backend.pid
|
||||
fi
|
||||
|
||||
# 停止前端
|
||||
if [ -f .frontend.pid ]; then
|
||||
FRONTEND_PID=$(cat .frontend.pid)
|
||||
if ps -p $FRONTEND_PID > /dev/null; then
|
||||
kill $FRONTEND_PID
|
||||
echo "✅ 前端服务已停止"
|
||||
fi
|
||||
rm .frontend.pid
|
||||
fi
|
||||
|
||||
# 清理端口(可选)
|
||||
echo "🧹 清理端口..."
|
||||
lsof -ti:8888 | xargs kill -9 2>/dev/null || true
|
||||
lsof -ti:3000 | xargs kill -9 2>/dev/null || true
|
||||
|
||||
echo "🎉 WordMaster Pro 已完全停止!"
|
||||
Reference in New Issue
Block a user