Compare commits
6 Commits
cf0685d102
...
a62c2a3aa1
| Author | SHA1 | Date | |
|---|---|---|---|
| a62c2a3aa1 | |||
| 906c5c37ba | |||
| 9a5e8cb58f | |||
| eb88494c5a | |||
| 60ce70f532 | |||
| f2a1e2d6fe |
88
PROJECT_STRUCTURE.md
Normal file
88
PROJECT_STRUCTURE.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Memora 前后端项目结构(重设计)
|
||||
|
||||
> 目标:在现有 `memora-api` / `memora-web` 基础上,采用更清晰的领域分层与模块化结构,便于持续开发。
|
||||
|
||||
## 1. 根目录结构
|
||||
|
||||
```text
|
||||
memora/
|
||||
├── README.md
|
||||
├── REQUIREMENTS.md # 需求文档(本次新增)
|
||||
├── PROJECT_STRUCTURE.md # 项目结构文档(本次新增)
|
||||
├── docker-compose.yml
|
||||
├── memora-api/ # 后端服务(Go + Gin)
|
||||
├── memora-web/ # 前端应用(Vue3 + Vite)
|
||||
└── memora-engine/ # 记忆引擎(可逐步独立)
|
||||
```
|
||||
|
||||
## 2. 后端结构(memora-api)
|
||||
|
||||
```text
|
||||
memora-api/
|
||||
├── cmd/
|
||||
│ └── server/ # 程序入口(建议逐步迁移 main)
|
||||
├── internal/
|
||||
│ ├── app/ # 应用启动编排
|
||||
│ ├── bootstrap/ # 配置、数据库、中间件初始化
|
||||
│ ├── domain/ # 领域模型与领域服务(建议新增)
|
||||
│ ├── repository/ # 数据访问抽象(建议新增)
|
||||
│ ├── service/ # 业务服务
|
||||
│ ├── handler/ # HTTP Handler
|
||||
│ ├── router/ # 路由注册
|
||||
│ ├── middleware/ # 中间件
|
||||
│ ├── request/ # 请求 DTO
|
||||
│ ├── response/ # 响应 DTO
|
||||
│ └── model/ # 数据库模型
|
||||
├── sql/
|
||||
├── audio/
|
||||
└── config*.yaml
|
||||
```
|
||||
|
||||
### 后端分层规则
|
||||
- Handler 只做参数校验和调用 service
|
||||
- Service 负责业务流程编排
|
||||
- Repository 负责数据库读写(GORM)
|
||||
- Domain 放核心规则(SRS 计算、复习策略)
|
||||
|
||||
## 3. 前端结构(memora-web)
|
||||
|
||||
```text
|
||||
memora-web/
|
||||
├── src/
|
||||
│ ├── app/ # 应用启动、全局注入
|
||||
│ ├── router/ # 路由
|
||||
│ ├── stores/ # Pinia 状态管理
|
||||
│ ├── services/ # API 请求封装
|
||||
│ ├── modules/ # 业务模块(建议新增)
|
||||
│ │ ├── auth/
|
||||
│ │ ├── words/
|
||||
│ │ ├── study/
|
||||
│ │ ├── review/
|
||||
│ │ └── stats/
|
||||
│ ├── views/ # 页面视图
|
||||
│ ├── components/ # 通用组件
|
||||
│ ├── composables/ # 组合式逻辑
|
||||
│ ├── utils/
|
||||
│ ├── styles/
|
||||
│ └── assets/
|
||||
└── public/
|
||||
```
|
||||
|
||||
### 前端模块化规则
|
||||
- 以 `modules/*` 聚合业务(API、store、types、hooks)
|
||||
- `views` 负责页面组合,不写重业务逻辑
|
||||
- 通用组件与业务组件分离
|
||||
|
||||
## 4. 开发顺序建议(不断完善)
|
||||
1. **认证闭环**:登录、鉴权拦截、会话保持
|
||||
2. **词库模块**:列表、详情、搜索、标签过滤
|
||||
3. **学习模块**:学习会话 + 答题提交
|
||||
4. **复习模块**:SRS 调度 + 每日任务
|
||||
5. **统计模块**:趋势图 + 连续学习天数
|
||||
6. **稳定性**:日志、错误码、单测、性能优化
|
||||
|
||||
## 5. 本次交付
|
||||
- 已在根目录新增:
|
||||
- `REQUIREMENTS.md`
|
||||
- `PROJECT_STRUCTURE.md`
|
||||
- 后续将按本文档持续推进实现与代码重构。
|
||||
86
REQUIREMENTS.md
Normal file
86
REQUIREMENTS.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# Memora 需求文档(重设计版)
|
||||
|
||||
## 1. 项目目标
|
||||
Memora 是一个以「长期记忆留存」为目标的背单词系统,面向中文学习者,提供:
|
||||
- 单词学习与复习闭环(学习 → 测试 → 复习)
|
||||
- 基于遗忘曲线的复习调度
|
||||
- 数据可视化与学习反馈
|
||||
- Web 端优先,后续支持移动端复用 API
|
||||
|
||||
---
|
||||
|
||||
## 2. 用户角色
|
||||
- 普通用户:学习、复习、查看统计
|
||||
- 管理员(可选):词库管理、运营配置、异常数据处理
|
||||
|
||||
---
|
||||
|
||||
## 3. 核心功能需求
|
||||
|
||||
### 3.1 账户与身份
|
||||
- 用户注册/登录(邮箱或手机号,后续可扩展 OAuth)
|
||||
- Token 鉴权(JWT)
|
||||
- 个人学习偏好(每日目标、提醒时间、难度偏好)
|
||||
|
||||
### 3.2 词库与学习内容
|
||||
- 词书管理:内置词书 + 自定义词书
|
||||
- 单词详情:拼写、音标、词性、释义、例句、发音
|
||||
- 标签体系:阶段(新词/熟词/难词)、来源、主题
|
||||
|
||||
### 3.3 学习流程
|
||||
- 新词学习:按计划推送
|
||||
- 测试模式:拼写题、选择题、释义匹配(可扩展)
|
||||
- 学习结果记录:正确率、耗时、连续正确次数
|
||||
|
||||
### 3.4 复习调度(核心)
|
||||
- 基于 SRS(间隔重复)策略生成复习计划
|
||||
- 每日待复习队列
|
||||
- 复习结果回写并动态调整下次复习时间
|
||||
|
||||
### 3.5 统计与反馈
|
||||
- 今日学习量、复习量、正确率
|
||||
- 连续学习天数
|
||||
- 周/月趋势图
|
||||
- 难词排行与建议复习列表
|
||||
|
||||
### 3.6 系统能力
|
||||
- 统一错误码与错误响应
|
||||
- 审计日志(关键操作)
|
||||
- 基础监控(接口耗时、错误率)
|
||||
|
||||
---
|
||||
|
||||
## 4. 非功能需求
|
||||
- 可维护性:前后端分层清晰,模块边界明确
|
||||
- 可扩展性:后续可拆分记忆引擎为独立服务
|
||||
- 性能:常用接口 P95 < 300ms(本地单机目标)
|
||||
- 安全性:鉴权、限流、输入校验、敏感信息脱敏
|
||||
- 可测试性:核心调度逻辑具备单元测试
|
||||
|
||||
---
|
||||
|
||||
## 5. API 需求(第一阶段)
|
||||
- `POST /api/v1/auth/register`
|
||||
- `POST /api/v1/auth/login`
|
||||
- `GET /api/v1/words`(分页)
|
||||
- `GET /api/v1/words/:id`
|
||||
- `POST /api/v1/study/sessions`
|
||||
- `POST /api/v1/study/answers`
|
||||
- `GET /api/v1/review/today`
|
||||
- `POST /api/v1/review/submit`
|
||||
- `GET /api/v1/stats/overview`
|
||||
|
||||
---
|
||||
|
||||
## 6. 里程碑
|
||||
- M1:基础骨架 + 认证 + 词库查询 + 学习记录
|
||||
- M2:SRS 调度 + 每日复习 + 统计面板
|
||||
- M3:体验优化 + 管理能力 + 稳定性增强
|
||||
|
||||
---
|
||||
|
||||
## 7. 验收标准(当前阶段)
|
||||
1. 能完成注册/登录并访问受保护接口
|
||||
2. 能查看单词并完成一次学习-复习提交
|
||||
3. 能生成当日复习任务
|
||||
4. Web 端可展示学习概览数据
|
||||
0
memora-api/cmd/server/.gitkeep
Normal file
0
memora-api/cmd/server/.gitkeep
Normal file
@@ -32,8 +32,10 @@ func New(configPath string) (*App, error) {
|
||||
}
|
||||
|
||||
wordService := service.NewWordService(db)
|
||||
authService := service.NewAuthService(db)
|
||||
wordHandler := handler.NewWordHandler(wordService)
|
||||
engine := router.New(wordHandler)
|
||||
authHandler := handler.NewAuthHandler(authService)
|
||||
engine := router.New(wordHandler, authHandler)
|
||||
|
||||
return &App{
|
||||
engine: engine,
|
||||
|
||||
@@ -13,5 +13,5 @@ func InitDB(cfg *config.Config) (*gorm.DB, error) {
|
||||
}
|
||||
|
||||
func AutoMigrate(db *gorm.DB) error {
|
||||
return db.AutoMigrate(&model.Word{}, &model.MemoryRecord{})
|
||||
return db.AutoMigrate(&model.User{}, &model.Word{}, &model.MemoryRecord{})
|
||||
}
|
||||
|
||||
0
memora-api/internal/domain/.gitkeep
Normal file
0
memora-api/internal/domain/.gitkeep
Normal file
68
memora-api/internal/handler/auth.go
Normal file
68
memora-api/internal/handler/auth.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"memora-api/internal/request"
|
||||
"memora-api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
authService *service.AuthService
|
||||
}
|
||||
|
||||
func NewAuthHandler(authService *service.AuthService) *AuthHandler {
|
||||
return &AuthHandler{authService: authService}
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Register(c *gin.Context) {
|
||||
var req request.RegisterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.authService.Register(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"id": user.ID, "email": user.Email, "name": user.Name}})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
var req request.LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
token, user, err := h.authService.Login(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"token": token, "user": gin.H{"id": user.ID, "email": user.Email, "name": user.Name}}})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Me(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||||
return
|
||||
}
|
||||
|
||||
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
uid, err := service.ParseToken(token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已失效"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"user_id": uid}})
|
||||
}
|
||||
@@ -16,6 +16,18 @@ type WordHandler struct {
|
||||
wordService *service.WordService
|
||||
}
|
||||
|
||||
func userIDFromContext(c *gin.Context) int64 {
|
||||
v, ok := c.Get("user_id")
|
||||
if !ok {
|
||||
return 1
|
||||
}
|
||||
uid, ok := v.(int64)
|
||||
if !ok || uid <= 0 {
|
||||
return 1
|
||||
}
|
||||
return uid
|
||||
}
|
||||
|
||||
func NewWordHandler(wordService *service.WordService) *WordHandler {
|
||||
return &WordHandler{wordService: wordService}
|
||||
}
|
||||
@@ -41,7 +53,7 @@ func (h *WordHandler) AddWord(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 保存到数据库
|
||||
word, err := h.wordService.SaveWord(req.Word, youdaoResp)
|
||||
word, err := h.wordService.SaveWord(userIDFromContext(c), req.Word, youdaoResp)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存失败: " + err.Error()})
|
||||
return
|
||||
@@ -58,7 +70,7 @@ func (h *WordHandler) GetReviewWords(c *gin.Context) {
|
||||
mode := c.DefaultQuery("mode", "spelling")
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||
|
||||
words, err := h.wordService.GetReviewWords(mode, limit)
|
||||
words, err := h.wordService.GetReviewWords(userIDFromContext(c), mode, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -75,7 +87,7 @@ func (h *WordHandler) SubmitReview(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.wordService.SubmitReviewAnswer(req)
|
||||
result, err := h.wordService.SubmitReviewAnswer(userIDFromContext(c), req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -88,8 +100,9 @@ func (h *WordHandler) SubmitReview(c *gin.Context) {
|
||||
func (h *WordHandler) GetWords(c *gin.Context) {
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
query := strings.TrimSpace(c.DefaultQuery("q", ""))
|
||||
|
||||
words, total, err := h.wordService.GetAllWords(limit, offset)
|
||||
words, total, err := h.wordService.GetAllWords(limit, offset, query)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -103,7 +116,7 @@ func (h *WordHandler) GetWords(c *gin.Context) {
|
||||
|
||||
// 获取统计信息
|
||||
func (h *WordHandler) GetStatistics(c *gin.Context) {
|
||||
stats, err := h.wordService.GetStatistics()
|
||||
stats, err := h.wordService.GetStatistics(userIDFromContext(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -138,3 +151,32 @@ func (h *WordHandler) GetAudio(c *gin.Context) {
|
||||
path := "./audio/" + filename
|
||||
c.File(path)
|
||||
}
|
||||
|
||||
func (h *WordHandler) CreateStudySession(c *gin.Context) {
|
||||
var req request.CreateStudySessionRequest
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
|
||||
words, err := h.wordService.CreateStudySession(userIDFromContext(c), req.Limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": words})
|
||||
}
|
||||
|
||||
func (h *WordHandler) SubmitStudyAnswer(c *gin.Context) {
|
||||
var req request.SubmitStudyAnswerRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.wordService.SubmitStudyAnswer(userIDFromContext(c), req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": result})
|
||||
}
|
||||
|
||||
30
memora-api/internal/middleware/auth.go
Normal file
30
memora-api/internal/middleware/auth.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"memora-api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func AuthRequired() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
|
||||
return
|
||||
}
|
||||
|
||||
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
uid, err := service.ParseToken(token)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "登录已失效"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user_id", uid)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
16
memora-api/internal/model/user.go
Normal file
16
memora-api/internal/model/user.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type User struct {
|
||||
ID int64 `json:"id" gorm:"primaryKey"`
|
||||
Email string `json:"email" gorm:"size:120;uniqueIndex;not null"`
|
||||
Name string `json:"name" gorm:"size:80;not null"`
|
||||
PasswordHash string `json:"-" gorm:"size:255;not null"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (User) TableName() string {
|
||||
return "users"
|
||||
}
|
||||
0
memora-api/internal/repository/.gitkeep
Normal file
0
memora-api/internal/repository/.gitkeep
Normal file
65
memora-api/internal/repository/memory_repository.go
Normal file
65
memora-api/internal/repository/memory_repository.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"memora-api/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type MemoryRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewMemoryRepository(db *gorm.DB) *MemoryRepository {
|
||||
return &MemoryRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) Create(record *model.MemoryRecord) error {
|
||||
return r.db.Create(record).Error
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) FindByWord(userID, wordID int64) (*model.MemoryRecord, error) {
|
||||
var record model.MemoryRecord
|
||||
if err := r.db.Where("word_id = ? AND user_id = ?", wordID, userID).First(&record).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) FindByID(userID, recordID int64) (*model.MemoryRecord, error) {
|
||||
var record model.MemoryRecord
|
||||
if err := r.db.Preload("Word").Where("id = ? AND user_id = ?", recordID, userID).First(&record).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) Save(record *model.MemoryRecord) error {
|
||||
return r.db.Save(record).Error
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) Due(userID int64, limit int) ([]model.MemoryRecord, error) {
|
||||
var records []model.MemoryRecord
|
||||
if err := r.db.Preload("Word").Where("user_id = ? AND (next_review_at <= ? OR next_review_at IS NULL)", userID, time.Now()).Limit(limit).Find(&records).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) ListByUser(userID int64, limit int) ([]model.MemoryRecord, error) {
|
||||
var records []model.MemoryRecord
|
||||
if err := r.db.Preload("Word").Where("user_id = ?", userID).Limit(limit).Find(&records).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) CountOverview(userID int64) (total, mastered, needReview, todayReviewed int64) {
|
||||
r.db.Model(&model.MemoryRecord{}).Where("user_id = ?", userID).Count(&total)
|
||||
r.db.Model(&model.MemoryRecord{}).Where("user_id = ? AND mastery_level >= 4", userID).Count(&mastered)
|
||||
r.db.Model(&model.MemoryRecord{}).Where("user_id = ? AND next_review_at <= ?", userID, time.Now()).Count(&needReview)
|
||||
r.db.Model(&model.MemoryRecord{}).Where("user_id = ? AND last_reviewed_at >= ?", userID, time.Now().Format("2006-01-02")).Count(&todayReviewed)
|
||||
return
|
||||
}
|
||||
54
memora-api/internal/repository/word_repository.go
Normal file
54
memora-api/internal/repository/word_repository.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"memora-api/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type WordRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewWordRepository(db *gorm.DB) *WordRepository {
|
||||
return &WordRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *WordRepository) FindByWord(word string) (*model.Word, error) {
|
||||
var w model.Word
|
||||
if err := r.db.Where("word = ?", word).First(&w).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &w, nil
|
||||
}
|
||||
|
||||
func (r *WordRepository) Create(word *model.Word) error {
|
||||
return r.db.Create(word).Error
|
||||
}
|
||||
|
||||
func (r *WordRepository) List(limit, offset int, query string) ([]model.Word, int64, error) {
|
||||
var words []model.Word
|
||||
var total int64
|
||||
|
||||
db := r.db.Model(&model.Word{})
|
||||
if query != "" {
|
||||
like := "%" + query + "%"
|
||||
db = db.Where("word LIKE ? OR definition LIKE ?", like, like)
|
||||
}
|
||||
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := db.Limit(limit).Offset(offset).Order("created_at DESC").Find(&words).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return words, total, nil
|
||||
}
|
||||
|
||||
func (r *WordRepository) Latest(limit int) ([]model.Word, error) {
|
||||
var words []model.Word
|
||||
if err := r.db.Order("created_at DESC").Limit(limit).Find(&words).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return words, nil
|
||||
}
|
||||
12
memora-api/internal/request/auth.go
Normal file
12
memora-api/internal/request/auth.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package request
|
||||
|
||||
type RegisterRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Name string `json:"name" binding:"required,min=2,max=30"`
|
||||
Password string `json:"password" binding:"required,min=6,max=64"`
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6,max=64"`
|
||||
}
|
||||
@@ -9,3 +9,13 @@ type ReviewAnswerRequest struct {
|
||||
Answer string `json:"answer" binding:"required"`
|
||||
Mode string `json:"mode" binding:"required"` // spelling, en2cn, cn2en
|
||||
}
|
||||
|
||||
type CreateStudySessionRequest struct {
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
type SubmitStudyAnswerRequest struct {
|
||||
WordID int64 `json:"word_id" binding:"required"`
|
||||
Answer string `json:"answer" binding:"required"`
|
||||
Mode string `json:"mode" binding:"required"` // spelling, en2cn, cn2en
|
||||
}
|
||||
|
||||
6
memora-api/internal/response/auth.go
Normal file
6
memora-api/internal/response/auth.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package response
|
||||
|
||||
type AuthResponse struct {
|
||||
Token string `json:"token"`
|
||||
User interface{} `json:"user"`
|
||||
}
|
||||
@@ -7,18 +7,28 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func New(wordHandler *handler.WordHandler) *gin.Engine {
|
||||
func New(wordHandler *handler.WordHandler, authHandler *handler.AuthHandler) *gin.Engine {
|
||||
r := gin.Default()
|
||||
r.Use(middleware.CORS())
|
||||
|
||||
api := r.Group("/api")
|
||||
{
|
||||
api.POST("/words", wordHandler.AddWord)
|
||||
api.GET("/words", wordHandler.GetWords)
|
||||
api.GET("/review", wordHandler.GetReviewWords)
|
||||
api.POST("/review", wordHandler.SubmitReview)
|
||||
api.GET("/stats", wordHandler.GetStatistics)
|
||||
api.GET("/audio", wordHandler.GetAudio)
|
||||
api.POST("/auth/register", authHandler.Register)
|
||||
api.POST("/auth/login", authHandler.Login)
|
||||
api.GET("/auth/me", authHandler.Me)
|
||||
|
||||
protected := api.Group("")
|
||||
protected.Use(middleware.AuthRequired())
|
||||
{
|
||||
protected.POST("/words", wordHandler.AddWord)
|
||||
protected.GET("/words", wordHandler.GetWords)
|
||||
protected.POST("/study/sessions", wordHandler.CreateStudySession)
|
||||
protected.POST("/study/answers", wordHandler.SubmitStudyAnswer)
|
||||
protected.GET("/review", wordHandler.GetReviewWords)
|
||||
protected.POST("/review", wordHandler.SubmitReview)
|
||||
protected.GET("/stats", wordHandler.GetStatistics)
|
||||
protected.GET("/audio", wordHandler.GetAudio)
|
||||
}
|
||||
}
|
||||
|
||||
return r
|
||||
|
||||
119
memora-api/internal/service/auth.go
Normal file
119
memora-api/internal/service/auth.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"memora-api/internal/model"
|
||||
"memora-api/internal/request"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AuthService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewAuthService(db *gorm.DB) *AuthService {
|
||||
return &AuthService{db: db}
|
||||
}
|
||||
|
||||
func jwtSecret() []byte {
|
||||
secret := os.Getenv("MEMORA_JWT_SECRET")
|
||||
if secret == "" {
|
||||
secret = "memora-dev-secret-change-me"
|
||||
}
|
||||
return []byte(secret)
|
||||
}
|
||||
|
||||
func (s *AuthService) Register(req request.RegisterRequest) (*model.User, error) {
|
||||
var exists model.User
|
||||
if err := s.db.Where("email = ?", req.Email).First(&exists).Error; err == nil {
|
||||
return nil, errors.New("邮箱已注册")
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user := model.User{
|
||||
Email: req.Email,
|
||||
Name: req.Name,
|
||||
PasswordHash: string(hash),
|
||||
}
|
||||
|
||||
if err := s.db.Create(&user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func sign(data string) string {
|
||||
mac := hmac.New(sha256.New, jwtSecret())
|
||||
_, _ = mac.Write([]byte(data))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func makeToken(uid int64) string {
|
||||
exp := time.Now().Add(7 * 24 * time.Hour).Unix()
|
||||
payload := fmt.Sprintf("%d:%d", uid, exp)
|
||||
encPayload := base64.RawURLEncoding.EncodeToString([]byte(payload))
|
||||
sig := sign(encPayload)
|
||||
return encPayload + "." + sig
|
||||
}
|
||||
|
||||
func (s *AuthService) Login(req request.LoginRequest) (string, *model.User, error) {
|
||||
var user model.User
|
||||
if err := s.db.Where("email = ?", req.Email).First(&user).Error; err != nil {
|
||||
return "", nil, errors.New("账号或密码错误")
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
|
||||
return "", nil, errors.New("账号或密码错误")
|
||||
}
|
||||
|
||||
return makeToken(user.ID), &user, nil
|
||||
}
|
||||
|
||||
func ParseToken(tokenString string) (int64, error) {
|
||||
parts := strings.Split(tokenString, ".")
|
||||
if len(parts) != 2 {
|
||||
return 0, errors.New("token无效")
|
||||
}
|
||||
encPayload, gotSig := parts[0], parts[1]
|
||||
if sign(encPayload) != gotSig {
|
||||
return 0, errors.New("token无效")
|
||||
}
|
||||
|
||||
payloadBytes, err := base64.RawURLEncoding.DecodeString(encPayload)
|
||||
if err != nil {
|
||||
return 0, errors.New("token无效")
|
||||
}
|
||||
|
||||
payloadParts := strings.Split(string(payloadBytes), ":")
|
||||
if len(payloadParts) != 2 {
|
||||
return 0, errors.New("token无效")
|
||||
}
|
||||
uid, err := strconv.ParseInt(payloadParts[0], 10, 64)
|
||||
if err != nil {
|
||||
return 0, errors.New("token无效")
|
||||
}
|
||||
exp, err := strconv.ParseInt(payloadParts[1], 10, 64)
|
||||
if err != nil {
|
||||
return 0, errors.New("token无效")
|
||||
}
|
||||
if time.Now().Unix() > exp {
|
||||
return 0, errors.New("token已过期")
|
||||
}
|
||||
|
||||
return uid, nil
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
|
||||
"memora-api/internal/config"
|
||||
"memora-api/internal/model"
|
||||
"memora-api/internal/repository"
|
||||
"memora-api/internal/request"
|
||||
"memora-api/internal/response"
|
||||
|
||||
@@ -22,11 +24,17 @@ import (
|
||||
)
|
||||
|
||||
type WordService struct {
|
||||
db *gorm.DB
|
||||
db *gorm.DB
|
||||
wordRepo *repository.WordRepository
|
||||
memoryRepo *repository.MemoryRepository
|
||||
}
|
||||
|
||||
func NewWordService(db *gorm.DB) *WordService {
|
||||
return &WordService{db: db}
|
||||
return &WordService{
|
||||
db: db,
|
||||
wordRepo: repository.NewWordRepository(db),
|
||||
memoryRepo: repository.NewMemoryRepository(db),
|
||||
}
|
||||
}
|
||||
|
||||
type dictAPIEntry struct {
|
||||
@@ -168,14 +176,12 @@ func (s *WordService) DownloadAudio(url, filePath string) error {
|
||||
}
|
||||
|
||||
// 保存单词到数据库
|
||||
func (s *WordService) SaveWord(word string, youdaoResp *model.YoudaoResponse) (*model.Word, error) {
|
||||
var existingWord model.Word
|
||||
|
||||
func (s *WordService) SaveWord(userID int64, word string, youdaoResp *model.YoudaoResponse) (*model.Word, error) {
|
||||
// 检查单词是否已存在
|
||||
if err := s.db.Where("word = ?", word).First(&existingWord).Error; err == nil {
|
||||
if w, err := s.wordRepo.FindByWord(word); err == nil {
|
||||
// 单词已存在,更新记忆记录
|
||||
s.updateMemoryRecord(existingWord.ID, true)
|
||||
return &existingWord, nil
|
||||
s.updateMemoryRecord(userID, w.ID, true)
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// 解析有道API响应
|
||||
@@ -266,34 +272,44 @@ func (s *WordService) SaveWord(word string, youdaoResp *model.YoudaoResponse) (*
|
||||
ExampleSentence: exampleSentence,
|
||||
}
|
||||
|
||||
if err := s.db.Create(&newWord).Error; err != nil {
|
||||
if err := s.wordRepo.Create(&newWord); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 创建记忆记录 + 记一次"背过"
|
||||
_ = s.createMemoryRecord(newWord.ID)
|
||||
_ = s.updateMemoryRecord(newWord.ID, true)
|
||||
_ = s.createMemoryRecord(userID, newWord.ID)
|
||||
_ = s.updateMemoryRecord(userID, newWord.ID, true)
|
||||
|
||||
return &newWord, nil
|
||||
}
|
||||
|
||||
// 创建记忆记录
|
||||
func (s *WordService) createMemoryRecord(wordID int64) error {
|
||||
func (s *WordService) createMemoryRecord(userID, wordID int64) error {
|
||||
record := model.MemoryRecord{
|
||||
WordID: wordID,
|
||||
UserID: 1,
|
||||
UserID: userID,
|
||||
CorrectCount: 0,
|
||||
TotalCount: 0,
|
||||
MasteryLevel: 0,
|
||||
}
|
||||
return s.db.Create(&record).Error
|
||||
return s.memoryRepo.Create(&record)
|
||||
}
|
||||
|
||||
// 更新记忆记录
|
||||
func (s *WordService) updateMemoryRecord(wordID int64, correct bool) error {
|
||||
var record model.MemoryRecord
|
||||
if err := s.db.Where("word_id = ?", wordID).First(&record).Error; err != nil {
|
||||
return err
|
||||
func (s *WordService) updateMemoryRecord(userID, wordID int64, correct bool) error {
|
||||
record, err := s.memoryRepo.FindByWord(userID, wordID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
if createErr := s.createMemoryRecord(userID, wordID); createErr != nil {
|
||||
return createErr
|
||||
}
|
||||
record, err = s.memoryRepo.FindByWord(userID, wordID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
record.TotalCount++
|
||||
@@ -313,22 +329,20 @@ func (s *WordService) updateMemoryRecord(wordID int64, correct bool) error {
|
||||
nextReview := now.Add(reviewInterval)
|
||||
record.NextReviewAt = &nextReview
|
||||
|
||||
return s.db.Save(&record).Error
|
||||
return s.memoryRepo.Save(record)
|
||||
}
|
||||
|
||||
// 获取待复习单词
|
||||
func (s *WordService) GetReviewWords(mode string, limit int) ([]model.MemoryRecord, error) {
|
||||
var records []model.MemoryRecord
|
||||
|
||||
query := s.db.Preload("Word").Where("next_review_at <= ? OR next_review_at IS NULL", time.Now())
|
||||
|
||||
if err := query.Limit(limit).Find(&records).Error; err != nil {
|
||||
func (s *WordService) GetReviewWords(userID int64, mode string, limit int) ([]model.MemoryRecord, error) {
|
||||
records, err := s.memoryRepo.Due(userID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 如果没有需要复习的,随机获取一些
|
||||
if len(records) == 0 {
|
||||
if err := s.db.Preload("Word").Limit(limit).Find(&records).Error; err != nil {
|
||||
records, err = s.memoryRepo.ListByUser(userID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -361,9 +375,9 @@ func containsAny(def string, ans string) bool {
|
||||
}
|
||||
|
||||
// 提交复习答案
|
||||
func (s *WordService) SubmitReviewAnswer(req request.ReviewAnswerRequest) (*response.ReviewResult, error) {
|
||||
var record model.MemoryRecord
|
||||
if err := s.db.Preload("Word").Where("id = ?", req.RecordID).First(&record).Error; err != nil {
|
||||
func (s *WordService) SubmitReviewAnswer(userID int64, req request.ReviewAnswerRequest) (*response.ReviewResult, error) {
|
||||
record, err := s.memoryRepo.FindByID(userID, req.RecordID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -387,7 +401,7 @@ func (s *WordService) SubmitReviewAnswer(req request.ReviewAnswerRequest) (*resp
|
||||
}
|
||||
|
||||
// 更新记忆记录
|
||||
s.updateMemoryRecord(record.WordID, correct)
|
||||
s.updateMemoryRecord(userID, record.WordID, correct)
|
||||
|
||||
return &response.ReviewResult{
|
||||
Word: word,
|
||||
@@ -398,30 +412,13 @@ func (s *WordService) SubmitReviewAnswer(req request.ReviewAnswerRequest) (*resp
|
||||
}
|
||||
|
||||
// 获取所有单词
|
||||
func (s *WordService) GetAllWords(limit, offset int) ([]model.Word, int64, error) {
|
||||
var words []model.Word
|
||||
var total int64
|
||||
|
||||
s.db.Model(&model.Word{}).Count(&total)
|
||||
|
||||
if err := s.db.Limit(limit).Offset(offset).Order("created_at DESC").Find(&words).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return words, total, nil
|
||||
func (s *WordService) GetAllWords(limit, offset int, query string) ([]model.Word, int64, error) {
|
||||
return s.wordRepo.List(limit, offset, strings.TrimSpace(query))
|
||||
}
|
||||
|
||||
// 获取记忆统计
|
||||
func (s *WordService) GetStatistics() (map[string]interface{}, error) {
|
||||
var totalWords int64
|
||||
var masteredWords int64
|
||||
var needReview int64
|
||||
var todayReviewed int64
|
||||
|
||||
s.db.Model(&model.Word{}).Count(&totalWords)
|
||||
s.db.Model(&model.MemoryRecord{}).Where("mastery_level >= 4").Count(&masteredWords)
|
||||
s.db.Model(&model.MemoryRecord{}).Where("next_review_at <= ?", time.Now()).Count(&needReview)
|
||||
s.db.Model(&model.MemoryRecord{}).Where("last_reviewed_at >= ?", time.Now().Format("2006-01-02")).Count(&todayReviewed)
|
||||
func (s *WordService) GetStatistics(userID int64) (map[string]interface{}, error) {
|
||||
totalWords, masteredWords, needReview, todayReviewed := s.memoryRepo.CountOverview(userID)
|
||||
|
||||
return map[string]interface{}{
|
||||
"total_words": totalWords,
|
||||
@@ -430,3 +427,43 @@ func (s *WordService) GetStatistics() (map[string]interface{}, error) {
|
||||
"today_reviewed": todayReviewed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *WordService) CreateStudySession(userID int64, limit int) ([]model.Word, error) {
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 10
|
||||
}
|
||||
words, err := s.wordRepo.Latest(limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range words {
|
||||
_ = s.createMemoryRecord(userID, words[i].ID)
|
||||
}
|
||||
return words, nil
|
||||
}
|
||||
|
||||
func (s *WordService) SubmitStudyAnswer(userID int64, req request.SubmitStudyAnswerRequest) (*response.ReviewResult, error) {
|
||||
reviewReq := request.ReviewAnswerRequest{
|
||||
RecordID: 0,
|
||||
Answer: req.Answer,
|
||||
Mode: req.Mode,
|
||||
}
|
||||
|
||||
record, err := s.memoryRepo.FindByWord(userID, req.WordID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
if createErr := s.createMemoryRecord(userID, req.WordID); createErr != nil {
|
||||
return nil, createErr
|
||||
}
|
||||
record, err = s.memoryRepo.FindByWord(userID, req.WordID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
reviewReq.RecordID = record.ID
|
||||
return s.SubmitReviewAnswer(userID, reviewReq)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<el-container class="layout">
|
||||
<router-view v-if="$route.meta?.fullPage" />
|
||||
<el-container v-else class="layout">
|
||||
<el-aside width="220px" class="sidebar">
|
||||
<div class="logo">Memora</div>
|
||||
<el-menu
|
||||
|
||||
@@ -4,8 +4,10 @@ import Review from '../views/Review.vue'
|
||||
import Statistics from '../views/Statistics.vue'
|
||||
import Words from '../views/Words.vue'
|
||||
import Settings from '../views/Settings.vue'
|
||||
import Login from '../views/Login.vue'
|
||||
|
||||
export const routes = [
|
||||
{ path: '/login', name: 'login', component: Login, meta: { public: true, fullPage: true } },
|
||||
{ path: '/', name: 'dashboard', component: Dashboard },
|
||||
{ path: '/memory', name: 'memory', component: Memory },
|
||||
{ path: '/review', name: 'review', component: Review },
|
||||
|
||||
0
memora-web/src/modules/auth/.gitkeep
Normal file
0
memora-web/src/modules/auth/.gitkeep
Normal file
13
memora-web/src/modules/auth/auth.ts
Normal file
13
memora-web/src/modules/auth/auth.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
const TOKEN_KEY = 'memora_token'
|
||||
const USER_KEY = 'memora_user_id'
|
||||
|
||||
export const getToken = () => localStorage.getItem(TOKEN_KEY)
|
||||
export const setToken = (token: string) => localStorage.setItem(TOKEN_KEY, token)
|
||||
export const clearToken = () => {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(USER_KEY)
|
||||
}
|
||||
export const isAuthed = () => !!getToken()
|
||||
|
||||
export const setUserId = (userId: number) => localStorage.setItem(USER_KEY, String(userId))
|
||||
export const getUserId = () => Number(localStorage.getItem(USER_KEY) || 0)
|
||||
0
memora-web/src/modules/review/.gitkeep
Normal file
0
memora-web/src/modules/review/.gitkeep
Normal file
0
memora-web/src/modules/stats/.gitkeep
Normal file
0
memora-web/src/modules/stats/.gitkeep
Normal file
0
memora-web/src/modules/study/.gitkeep
Normal file
0
memora-web/src/modules/study/.gitkeep
Normal file
0
memora-web/src/modules/words/.gitkeep
Normal file
0
memora-web/src/modules/words/.gitkeep
Normal file
@@ -1,9 +1,20 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { routes } from '../app/routes'
|
||||
import { isAuthed } from '../modules/auth/auth'
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const isPublic = !!to.meta?.public
|
||||
if (!isPublic && !isAuthed()) {
|
||||
return '/login'
|
||||
}
|
||||
if (to.path === '/login' && isAuthed()) {
|
||||
return '/'
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from './types'
|
||||
export * from './stats'
|
||||
export * from './words'
|
||||
export * from './review'
|
||||
export * from './study'
|
||||
|
||||
16
memora-web/src/services/api/study.ts
Normal file
16
memora-web/src/services/api/study.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { http } from '../http'
|
||||
import type { ReviewMode, ReviewResult, Word } from './types'
|
||||
|
||||
export async function createStudySession(limit = 10) {
|
||||
const res = await http.post<{ data: Word[] }>('/study/sessions', { limit })
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function submitStudyAnswer(payload: { wordId: number; answer: string; mode: ReviewMode }) {
|
||||
const res = await http.post<{ data: ReviewResult }>('/study/answers', {
|
||||
word_id: payload.wordId,
|
||||
answer: payload.answer,
|
||||
mode: payload.mode
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export async function addWord(word: string) {
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getWords(params: { limit?: number; offset?: number } = {}) {
|
||||
export async function getWords(params: { limit?: number; offset?: number; q?: string } = {}) {
|
||||
const res = await http.get<{ data: Word[]; total: number }>('/words', { params })
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
import axios from 'axios'
|
||||
import { clearToken, getToken } from '../modules/auth/auth'
|
||||
|
||||
export const http = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 15000
|
||||
})
|
||||
|
||||
http.interceptors.request.use((config) => {
|
||||
const token = getToken()
|
||||
if (token) {
|
||||
config.headers = config.headers || {}
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
http.interceptors.response.use(
|
||||
(res) => res,
|
||||
(err) => {
|
||||
// 统一错误抛出
|
||||
if (err?.response?.status === 401) {
|
||||
clearToken()
|
||||
if (location.pathname !== '/login') {
|
||||
location.href = '/login'
|
||||
}
|
||||
}
|
||||
return Promise.reject(err)
|
||||
}
|
||||
)
|
||||
|
||||
74
memora-web/src/views/Login.vue
Normal file
74
memora-web/src/views/Login.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<el-card class="login-card" shadow="hover">
|
||||
<template #header><strong>登录 Memora</strong></template>
|
||||
<el-form :model="form" @submit.prevent="onSubmit">
|
||||
<el-form-item>
|
||||
<el-input v-model="form.email" placeholder="邮箱" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input v-model="form.password" type="password" show-password placeholder="密码" />
|
||||
</el-form-item>
|
||||
<el-button type="primary" :loading="loading" style="width:100%" @click="onSubmit">登录</el-button>
|
||||
</el-form>
|
||||
<div class="tips">没有账号会自动注册一个新账号</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { http } from '../services/http'
|
||||
import { setToken, setUserId } from '../modules/auth/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const form = reactive({ email: '', password: '' })
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!form.email || !form.password) {
|
||||
ElMessage.warning('请填写邮箱和密码')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const loginRes = await http.post('/auth/login', form)
|
||||
const token = loginRes.data?.data?.token
|
||||
if (token) {
|
||||
setToken(token)
|
||||
const me = await http.get('/auth/me')
|
||||
setUserId(me.data?.data?.user_id || 0)
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/')
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// ignore and try register
|
||||
}
|
||||
|
||||
try {
|
||||
await http.post('/auth/register', { ...form, name: form.email.split('@')[0] || 'memora' })
|
||||
const loginRes = await http.post('/auth/login', form)
|
||||
const token = loginRes.data?.data?.token
|
||||
if (token) {
|
||||
setToken(token)
|
||||
const me = await http.get('/auth/me')
|
||||
setUserId(me.data?.data?.user_id || 0)
|
||||
ElMessage.success('注册并登录成功')
|
||||
router.push('/')
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.error || '登录失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page { min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f4f5fb; }
|
||||
.login-card { width: 360px; }
|
||||
.tips { margin-top: 12px; font-size: 12px; color: #999; text-align: center; }
|
||||
</style>
|
||||
@@ -1,5 +1,32 @@
|
||||
<template>
|
||||
<div class="wrap">
|
||||
<el-card style="margin-bottom: 16px">
|
||||
<template #header>
|
||||
<div class="h">
|
||||
<div>
|
||||
<div class="title">学习会话</div>
|
||||
<div class="sub">系统按最新单词生成学习队列,完成后自动计入复习数据</div>
|
||||
</div>
|
||||
<el-button type="primary" @click="startSession" :loading="sessionLoading">开始学习</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-empty v-if="!sessionWord" description="点击“开始学习”获取单词" />
|
||||
<div v-else>
|
||||
<el-alert type="info" :title="`当前单词:${sessionWord.word}`" show-icon style="margin-bottom:12px" />
|
||||
<el-input v-model="sessionAnswer" placeholder="输入答案(默认拼写)" @keyup.enter="submitSessionAnswer" />
|
||||
<div style="margin-top: 12px; display:flex; gap:8px">
|
||||
<el-button type="success" :loading="sessionLoading" @click="submitSessionAnswer">提交</el-button>
|
||||
<el-button @click="nextSessionWord" :disabled="sessionLoading">下一个</el-button>
|
||||
</div>
|
||||
<el-result v-if="sessionResult" :icon="sessionResult.correct ? 'success' : 'error'" :title="sessionResult.correct ? '正确' : '不对'">
|
||||
<template #sub-title>
|
||||
<div>正确答案:{{ sessionResult.correct_ans || sessionWord.word }}</div>
|
||||
</template>
|
||||
</el-result>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="h">
|
||||
@@ -46,15 +73,21 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { addWord, audioUrl } from '../services/api'
|
||||
import { addWord, audioUrl, createStudySession, submitStudyAnswer } from '../services/api'
|
||||
import type { ReviewResult, Word } from '../services/api'
|
||||
|
||||
const word = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
import type { Word } from '../services/api'
|
||||
|
||||
const saved = ref<Word | null>(null)
|
||||
|
||||
const sessionLoading = ref(false)
|
||||
const sessionWords = ref<Word[]>([])
|
||||
const sessionIndex = ref(0)
|
||||
const sessionWord = ref<Word | null>(null)
|
||||
const sessionAnswer = ref('')
|
||||
const sessionResult = ref<ReviewResult | null>(null)
|
||||
|
||||
async function submit() {
|
||||
const w = word.value.trim()
|
||||
if (!w) return
|
||||
@@ -63,7 +96,6 @@ async function submit() {
|
||||
saved.value = null
|
||||
try {
|
||||
const res = await addWord(w)
|
||||
// 后端建议返回 { data: Word }
|
||||
saved.value = res.data ?? res
|
||||
word.value = ''
|
||||
} catch (e: any) {
|
||||
@@ -73,6 +105,44 @@ async function submit() {
|
||||
}
|
||||
}
|
||||
|
||||
async function startSession() {
|
||||
sessionLoading.value = true
|
||||
try {
|
||||
const res = await createStudySession(10)
|
||||
const arr = (res as any).data ?? (res as any)
|
||||
sessionWords.value = Array.isArray(arr) ? arr : []
|
||||
sessionIndex.value = 0
|
||||
sessionWord.value = sessionWords.value[0] || null
|
||||
sessionAnswer.value = ''
|
||||
sessionResult.value = null
|
||||
} finally {
|
||||
sessionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function nextSessionWord() {
|
||||
if (!sessionWords.value.length) return
|
||||
sessionIndex.value += 1
|
||||
if (sessionIndex.value >= sessionWords.value.length) {
|
||||
sessionWord.value = null
|
||||
return
|
||||
}
|
||||
sessionWord.value = sessionWords.value[sessionIndex.value]
|
||||
sessionAnswer.value = ''
|
||||
sessionResult.value = null
|
||||
}
|
||||
|
||||
async function submitSessionAnswer() {
|
||||
if (!sessionWord.value || !sessionAnswer.value.trim()) return
|
||||
sessionLoading.value = true
|
||||
try {
|
||||
const res = await submitStudyAnswer({ wordId: sessionWord.value.id, answer: sessionAnswer.value, mode: 'spelling' })
|
||||
sessionResult.value = (res as any).data ?? (res as any)
|
||||
} finally {
|
||||
sessionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function playUK() {
|
||||
if (!saved.value?.word) return
|
||||
const audio = new Audio(audioUrl(saved.value.word, 'uk'))
|
||||
|
||||
@@ -29,16 +29,36 @@
|
||||
</template>
|
||||
</el-card>
|
||||
|
||||
<el-input v-model="answer" placeholder="输入答案并回车" @keyup.enter="submit" />
|
||||
<div style="margin-top:12px; display:flex; gap:12px">
|
||||
<el-button type="primary" :loading="loading" @click="submit">提交</el-button>
|
||||
<el-input
|
||||
v-model="answer"
|
||||
placeholder="输入答案并回车"
|
||||
:disabled="loading || submitted"
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
|
||||
<div style="margin-top:12px; display:flex; gap:12px; flex-wrap:wrap;">
|
||||
<el-button type="primary" :loading="loading" :disabled="submitted" @click="submit">提交</el-button>
|
||||
<el-button type="success" :disabled="!submitted" @click="nextOne">下一题</el-button>
|
||||
<el-button @click="loadOne" :disabled="loading">换一个</el-button>
|
||||
</div>
|
||||
|
||||
<el-result v-if="result" :icon="result.correct ? 'success' : 'error'" :title="result.correct ? '正确' : '不对'" style="margin-top:16px">
|
||||
<el-alert
|
||||
v-if="result && !result.correct"
|
||||
type="error"
|
||||
show-icon
|
||||
style="margin-top:12px"
|
||||
:title="`答案不正确:你的答案「${result.answer}」,正确答案「${result.correct_ans || '-'}」`"
|
||||
/>
|
||||
|
||||
<el-result
|
||||
v-if="result"
|
||||
:icon="result.correct ? 'success' : 'error'"
|
||||
:title="result.correct ? '回答正确' : '回答错误'"
|
||||
style="margin-top:16px"
|
||||
>
|
||||
<template #sub-title>
|
||||
<div>单词:{{ result.word.word }} / 释义:{{ result.word.definition }}</div>
|
||||
<div v-if="!result.correct">你的答案:{{ result.answer }};正确:{{ result.correct_ans }}</div>
|
||||
<div v-if="!result.correct">请点击“下一题”继续复习。</div>
|
||||
</template>
|
||||
</el-result>
|
||||
</div>
|
||||
@@ -53,10 +73,11 @@ import { audioUrl, getReviewWords, submitReview } from '../services/api'
|
||||
import type { MemoryRecord, ReviewMode, ReviewResult } from '../services/api'
|
||||
|
||||
const mode = ref<ReviewMode>('spelling')
|
||||
const record = ref<MemoryRecord | null>(null) // MemoryRecord (含 word)
|
||||
const record = ref<MemoryRecord | null>(null)
|
||||
const answer = ref('')
|
||||
const loading = ref(false)
|
||||
const result = ref<ReviewResult | null>(null)
|
||||
const submitted = ref(false)
|
||||
|
||||
const modeHint = computed(() => {
|
||||
if (mode.value === 'spelling') return '听读音,拼写单词'
|
||||
@@ -66,12 +87,17 @@ const modeHint = computed(() => {
|
||||
|
||||
async function loadOne() {
|
||||
result.value = null
|
||||
submitted.value = false
|
||||
answer.value = ''
|
||||
const res = await getReviewWords({ mode: mode.value, limit: 1 })
|
||||
const res = await getReviewWords({ mode: mode.value, limit: 1 })
|
||||
const arr = (res as any).data ?? (res as any)
|
||||
record.value = Array.isArray(arr) && arr.length ? (arr[0] as MemoryRecord) : null
|
||||
}
|
||||
|
||||
function nextOne() {
|
||||
loadOne().catch(console.error)
|
||||
}
|
||||
|
||||
function play() {
|
||||
if (!record.value?.word?.word) return
|
||||
const a = new Audio(audioUrl(record.value.word.word, 'uk'))
|
||||
@@ -79,12 +105,14 @@ function play() {
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!record.value) return
|
||||
if (!record.value || submitted.value) return
|
||||
if (!answer.value.trim()) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await submitReview({ recordId: record.value.id, answer: answer.value, mode: mode.value })
|
||||
result.value = (res as any).data ?? (res as any)
|
||||
submitted.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -2,12 +2,27 @@
|
||||
<el-card>
|
||||
<template #header>设置</template>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="当前用户ID">{{ userId || '未登录' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="有道 API">已在后端 config.yaml 配置</el-descriptions-item>
|
||||
<el-descriptions-item label="音频缓存目录">memora-api/audio</el-descriptions-item>
|
||||
<el-descriptions-item label="当前前端框架">Vue3 + Element Plus</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div style="margin-top:16px">
|
||||
<el-button type="danger" plain @click="logout">退出登录</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { clearToken, getUserId } from '../modules/auth/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const userId = getUserId()
|
||||
|
||||
function logout() {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,32 +1,82 @@
|
||||
<template>
|
||||
<el-card>
|
||||
<template #header>单词列表</template>
|
||||
<el-input v-model="kw" placeholder="搜索单词" style="max-width:280px;margin-bottom:12px" />
|
||||
<el-table :data="filtered" border>
|
||||
|
||||
<div style="display:flex; gap:12px; margin-bottom:12px; align-items:center;">
|
||||
<el-input
|
||||
v-model="kw"
|
||||
placeholder="搜索单词/释义"
|
||||
clearable
|
||||
style="max-width:320px"
|
||||
@keyup.enter="onSearch"
|
||||
@clear="onSearch"
|
||||
/>
|
||||
<el-button type="primary" @click="onSearch">搜索</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="rows" border v-loading="loading">
|
||||
<el-table-column prop="word" label="单词" width="180" />
|
||||
<el-table-column prop="part_of_speech" label="词性" width="120" />
|
||||
<el-table-column prop="definition" label="释义" />
|
||||
</el-table>
|
||||
|
||||
<div style="margin-top:12px; display:flex; justify-content:flex-end;">
|
||||
<el-pagination
|
||||
background
|
||||
layout="total, prev, pager, next, sizes"
|
||||
:total="total"
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
@current-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { getWords } from '../services/api'
|
||||
|
||||
const kw = ref('')
|
||||
import type { Word } from '../services/api'
|
||||
|
||||
const kw = ref('')
|
||||
const rows = ref<Word[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = kw.value.trim().toLowerCase()
|
||||
if (!q) return rows.value
|
||||
return rows.value.filter(r => (r.word || '').toLowerCase().includes(q))
|
||||
})
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
onMounted(async () => {
|
||||
const res = await getWords({ limit: 200, offset: 0 })
|
||||
rows.value = res.data || []
|
||||
async function fetchWords() {
|
||||
loading.value = true
|
||||
try {
|
||||
const offset = (currentPage.value - 1) * pageSize.value
|
||||
const res = await getWords({ limit: pageSize.value, offset, q: kw.value.trim() || undefined })
|
||||
rows.value = res.data || []
|
||||
total.value = res.total || 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
currentPage.value = 1
|
||||
fetchWords().catch(console.error)
|
||||
}
|
||||
|
||||
function onPageChange(page: number) {
|
||||
currentPage.value = page
|
||||
fetchWords().catch(console.error)
|
||||
}
|
||||
|
||||
function onSizeChange(size: number) {
|
||||
pageSize.value = size
|
||||
currentPage.value = 1
|
||||
fetchWords().catch(console.error)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchWords().catch(console.error)
|
||||
})
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user