feat(layout): 更新应用布局和UI组件样式
- 重构App.vue中的侧边栏布局,更新Logo设计为带有标识和副标题的新样式 - 调整顶部导航栏,增加标题区域显示当前路由标题和日期 - 修改菜单项配置,更新导航标签为更直观的中文描述 - 在Home.vue中替换原有的仪表板为新的Hero卡片和项目进展展示 - 更新Memory.vue中的学习界面,添加学习计划设置和多阶段学习模式 - 集成新的API端点路径,将baseURL从/api调整为/api/v1 - 调整整体视觉风格,包括颜色主题、字体家族和响应式布局 - 更新数据库模型以支持词库功能,添加相关的数据迁移和种子数据 - 调整认证系统的用户ID类型从整型到字符串的变更 - 更改前端构建工具从npm到pnpm,并更新相应的Dockerfile配置
This commit is contained in:
39
PROTOTYPE.md
Normal file
39
PROTOTYPE.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Memora 原型说明(基于 REQUIREMENTS.md)
|
||||
|
||||
## 1. 信息架构
|
||||
- 登录:`/login`
|
||||
- 学习概览:`/`
|
||||
- 记忆模式(新词学习四阶段):`/memory`
|
||||
- 复习模式(今日待复习队列):`/review`
|
||||
- 学习统计:`/statistics`
|
||||
- 词库管理:`/words`
|
||||
- 偏好设置:`/settings`
|
||||
|
||||
## 2. 核心流程原型
|
||||
1. 用户登录后进入仪表盘,看到今日任务和学习概览。
|
||||
2. 在「记忆模式」选择词库、每日目标、提醒时间,启动学习会话。
|
||||
3. 每个单词按四阶段完成:
|
||||
- 阶段一:英译中四选一(显示英文+例句)
|
||||
- 阶段二:中译英四选一(显示中文释义)
|
||||
- 阶段三:听音选词(播放音频)
|
||||
- 阶段四:听音拼写(输入拼写)
|
||||
4. 每题提交会写入学习结果,实时展示正确率、连对和耗时。
|
||||
5. 在「复习模式」按 SRS 队列完成当天复习,提交后动态更新掌握度和下次复习时间。
|
||||
|
||||
## 3. 需求映射
|
||||
- 账户与身份:登录/注册 + JWT 保护路由
|
||||
- 学习流程:四阶段学习题型 + 会话统计
|
||||
- 复习调度:`GET /review/today` 获取今日队列,`POST /review/submit` 回写结果
|
||||
- 统计与反馈:仪表盘和统计页展示学习指标
|
||||
- 偏好设置:每日目标、提醒时间、词库选择
|
||||
|
||||
## 4. API(已对齐 v1)
|
||||
- `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`
|
||||
@@ -48,6 +48,6 @@ go run main.go
|
||||
|
||||
```bash
|
||||
cd memora-web
|
||||
npm install
|
||||
npm run dev
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
@@ -10,8 +10,8 @@ Memora 是一个以「长期记忆留存」为目标的背单词系统,面向
|
||||
---
|
||||
|
||||
## 2. 用户角色
|
||||
- 普通用户:学习、复习、查看统计
|
||||
- 管理员(可选):词库管理、运营配置、异常数据处理
|
||||
- 普通用户:学习、复习、查看统计,词库管理
|
||||
- 管理员:日志查看、异常数据处理
|
||||
|
||||
---
|
||||
|
||||
@@ -20,18 +20,29 @@ Memora 是一个以「长期记忆留存」为目标的背单词系统,面向
|
||||
### 3.1 账户与身份
|
||||
- 用户注册/登录(邮箱或手机号,后续可扩展 OAuth)
|
||||
- Token 鉴权(JWT)
|
||||
- 个人学习偏好(每日目标、提醒时间、难度偏好)
|
||||
- 个人学习偏好(每日目标、提醒时间、词库(四级,雅思,托福等),)
|
||||
|
||||
### 3.2 词库与学习内容
|
||||
- 词书管理:内置词书 + 自定义词书
|
||||
- 单词详情:拼写、音标、词性、释义、例句、发音
|
||||
- 标签体系:阶段(新词/熟词/难词)、来源、主题
|
||||
- 单词详情:单词拼写、音标、词性、释义、例句、发音
|
||||
|
||||
### 3.3 学习流程
|
||||
- 新词学习:按计划推送
|
||||
- 测试模式:拼写题、选择题、释义匹配(可扩展)
|
||||
- 首次学习需要选择词库,后续可以更改,设定学习计划,每天多少个,学习计划设定后,不在首页显示,在设置中更改。
|
||||
|
||||
- 新词学习:
|
||||
|
||||
第一阶段:先显示单词和例句,不提示中文,四选一,必须满足四个选项选择正确英译中答案,四个答案都是形近词,近义词等。
|
||||
|
||||
第二阶段:出中文,选择正确的英文,也是四个选项,都是形近词和近义词。
|
||||
|
||||
第三阶段:根据读音选单词。
|
||||
|
||||
第四阶段:拼写,自动播放读音,用户可以点击图标再次播放。
|
||||
|
||||
- 学习结果记录:正确率、耗时、连续正确次数
|
||||
|
||||
|
||||
|
||||
### 3.4 复习调度(核心)
|
||||
- 基于 SRS(间隔重复)策略生成复习计划
|
||||
- 每日待复习队列
|
||||
@@ -41,13 +52,40 @@ Memora 是一个以「长期记忆留存」为目标的背单词系统,面向
|
||||
- 今日学习量、复习量、正确率
|
||||
- 连续学习天数
|
||||
- 周/月趋势图
|
||||
- 难词排行与建议复习列表
|
||||
|
||||
### 3.6 系统能力
|
||||
- 统一错误码与错误响应
|
||||
- 审计日志(关键操作)
|
||||
- 基础监控(接口耗时、错误率)
|
||||
|
||||
### 3.7页面设计
|
||||
|
||||
#### 左侧菜单设计
|
||||
|
||||
左侧学习,学习统计,单词列表,设置。四个菜单切换标签。
|
||||
|
||||
#### 学习统计页面
|
||||
|
||||
今日已学,今日复习,已掌握,总词汇。都可以进入二级页面查看详情。
|
||||
|
||||
#### 学习页面
|
||||
|
||||
首次进入,学习页面可以设置学习计划,词库,每日新词目标,提醒时间。设置后只显示出来,在设置中才能修改。右侧开始学习按钮和复习按钮,点击学习按钮开始学习,学习新词每日新词目标的目标个数。点击复习按钮开始复习。复习按照遗忘曲线的来,每天和学习目标一致。
|
||||
|
||||
点击学习后进入学习,先按照上述的第一阶段过完每日新词目标个单词,再第二阶段每日新词目标个单词,第三阶段每日新词目标个单词。第四阶段过完每日新词目标个单词。(每个阶段不用点确定,点击单词即确定)。
|
||||
|
||||
#### 设置页面
|
||||
|
||||
设置读音偏好,美音还是英音。重设学习计划。头像,昵称等。修改密码。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 4. 非功能需求
|
||||
|
||||
@@ -10,6 +10,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bwmarrin/snowflake v0.3.0 // indirect
|
||||
github.com/bytedance/sonic v1.9.1 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||
@@ -19,6 +20,7 @@ require (
|
||||
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.7.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0=
|
||||
github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE=
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
|
||||
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||
@@ -29,6 +31,8 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
|
||||
@@ -13,5 +13,37 @@ func InitDB(cfg *config.Config) (*gorm.DB, error) {
|
||||
}
|
||||
|
||||
func AutoMigrate(db *gorm.DB) error {
|
||||
return db.AutoMigrate(&model.User{}, &model.Word{}, &model.MemoryRecord{})
|
||||
if err := db.AutoMigrate(
|
||||
&model.User{},
|
||||
&model.WordBook{},
|
||||
&model.Word{},
|
||||
&model.WordSentence{},
|
||||
&model.WordTranslation{},
|
||||
&model.WordSynonym{},
|
||||
&model.WordSynonymItem{},
|
||||
&model.WordPhrase{},
|
||||
&model.WordRel{},
|
||||
&model.WordRelItem{},
|
||||
&model.MemoryRecord{},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return seedWordBooks(db)
|
||||
}
|
||||
|
||||
func seedWordBooks(db *gorm.DB) error {
|
||||
books := []model.WordBook{
|
||||
{ID: model.DefaultWordBookID, Code: "default", Name: "默认词库", SourceBookID: "DEFAULT"},
|
||||
{ID: "00000000000000000000000000000002", Code: "cet4", Name: "四级词库", SourceBookID: "CET4"},
|
||||
{ID: "00000000000000000000000000000003", Code: "ielts", Name: "雅思词库", SourceBookID: "IELTS"},
|
||||
{ID: "00000000000000000000000000000004", Code: "toefl", Name: "托福词库", SourceBookID: "TOEFL"},
|
||||
}
|
||||
|
||||
for i := range books {
|
||||
book := books[i]
|
||||
if err := db.Where("code = ?", book.Code).FirstOrCreate(&book).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -16,14 +16,14 @@ type WordHandler struct {
|
||||
wordService *service.WordService
|
||||
}
|
||||
|
||||
func userIDFromContext(c *gin.Context) int64 {
|
||||
func userIDFromContext(c *gin.Context) string {
|
||||
v, ok := c.Get("user_id")
|
||||
if !ok {
|
||||
return 1
|
||||
return ""
|
||||
}
|
||||
uid, ok := v.(int64)
|
||||
if !ok || uid <= 0 {
|
||||
return 1
|
||||
uid, ok := v.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return uid
|
||||
}
|
||||
@@ -40,7 +40,6 @@ func (h *WordHandler) AddWord(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 调用有道API查询
|
||||
youdaoResp, err := h.wordService.QueryWord(req.Word)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询失败: " + err.Error()})
|
||||
@@ -52,7 +51,6 @@ func (h *WordHandler) AddWord(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 保存到数据库
|
||||
word, err := h.wordService.SaveWord(userIDFromContext(c), req.Word, youdaoResp)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存失败: " + err.Error()})
|
||||
@@ -114,6 +112,22 @@ func (h *WordHandler) GetWords(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *WordHandler) GetWordByID(c *gin.Context) {
|
||||
id := strings.TrimSpace(c.Param("id"))
|
||||
if id == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "非法单词ID"})
|
||||
return
|
||||
}
|
||||
|
||||
word, err := h.wordService.GetWordByID(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "单词不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": word})
|
||||
}
|
||||
|
||||
// 获取统计信息
|
||||
func (h *WordHandler) GetStatistics(c *gin.Context) {
|
||||
stats, err := h.wordService.GetStatistics(userIDFromContext(c))
|
||||
@@ -135,7 +149,6 @@ func (h *WordHandler) GetAudio(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// very small sanitization
|
||||
word = strings.TrimSpace(word)
|
||||
if word == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "word为空"})
|
||||
|
||||
36
memora-api/internal/model/id.go
Normal file
36
memora-api/internal/model/id.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/bwmarrin/snowflake"
|
||||
)
|
||||
|
||||
var (
|
||||
snowflakeNode *snowflake.Node
|
||||
snowflakeOnce sync.Once
|
||||
)
|
||||
|
||||
func getSnowflakeNode() *snowflake.Node {
|
||||
snowflakeOnce.Do(func() {
|
||||
nodeID := int64(1)
|
||||
if raw := os.Getenv("MEMORA_SNOWFLAKE_NODE"); raw != "" {
|
||||
if parsed, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
nodeID = parsed
|
||||
}
|
||||
}
|
||||
node, err := snowflake.NewNode(nodeID)
|
||||
if err != nil {
|
||||
log.Panicf("init snowflake node failed: %v", err)
|
||||
}
|
||||
snowflakeNode = node
|
||||
})
|
||||
return snowflakeNode
|
||||
}
|
||||
|
||||
func NewID() string {
|
||||
return getSnowflakeNode().Generate().String()
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type MemoryRecord struct {
|
||||
ID int64 `json:"id" gorm:"primaryKey"`
|
||||
WordID int64 `json:"word_id" gorm:"index;not null"`
|
||||
UserID int64 `json:"user_id" gorm:"index;default:1"`
|
||||
ID string `json:"id" gorm:"primaryKey;size:32"`
|
||||
WordID string `json:"word_id" gorm:"size:32;index;not null"`
|
||||
UserID string `json:"user_id" gorm:"size:32;index;not null"`
|
||||
CorrectCount int `json:"correct_count" gorm:"default:0"`
|
||||
TotalCount int `json:"total_count" gorm:"default:0"`
|
||||
MasteryLevel int `json:"mastery_level" gorm:"default:0"`
|
||||
@@ -13,9 +17,16 @@ type MemoryRecord struct {
|
||||
NextReviewAt *time.Time `json:"next_review_at" gorm:"index"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Word *Word `json:"word,omitempty" gorm:"foreignKey:WordID"`
|
||||
Word *Word `json:"word,omitempty" gorm:"foreignKey:WordID;references:ID"`
|
||||
}
|
||||
|
||||
func (MemoryRecord) TableName() string {
|
||||
return "memory_records"
|
||||
}
|
||||
|
||||
func (m *MemoryRecord) BeforeCreate(tx *gorm.DB) error {
|
||||
if m.ID == "" {
|
||||
m.ID = NewID()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID int64 `json:"id" gorm:"primaryKey"`
|
||||
ID string `json:"id" gorm:"primaryKey;size:32"`
|
||||
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"`
|
||||
@@ -14,3 +18,10 @@ type User struct {
|
||||
func (User) TableName() string {
|
||||
return "users"
|
||||
}
|
||||
|
||||
func (u *User) BeforeCreate(tx *gorm.DB) error {
|
||||
if u.ID == "" {
|
||||
u.ID = NewID()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,21 +1,195 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Word struct {
|
||||
ID int64 `json:"id" gorm:"primaryKey"`
|
||||
Word string `json:"word" gorm:"size:100;uniqueIndex;not null"`
|
||||
ID string `json:"id" gorm:"primaryKey;size:32"`
|
||||
BookID string `json:"book_id" gorm:"size:32;index;not null"`
|
||||
WordRank int `json:"word_rank" gorm:"index;default:0"`
|
||||
HeadWord string `json:"head_word" gorm:"size:120;index"`
|
||||
Word string `json:"word" gorm:"size:120;uniqueIndex;not null"`
|
||||
SourceWordID string `json:"source_word_id" gorm:"size:64;index"`
|
||||
PhoneticUK string `json:"phonetic_uk" gorm:"size:255"`
|
||||
PhoneticUS string `json:"phonetic_us" gorm:"size:255"`
|
||||
AudioUK string `json:"audio_uk" gorm:"size:500"`
|
||||
AudioUS string `json:"audio_us" gorm:"size:500"`
|
||||
UKSpeech string `json:"uk_speech" gorm:"size:255"`
|
||||
USSpeech string `json:"us_speech" gorm:"size:255"`
|
||||
PartOfSpeech string `json:"part_of_speech" gorm:"size:50"`
|
||||
Definition string `json:"definition" gorm:"type:text"`
|
||||
ExampleSentence string `json:"example_sentence" gorm:"type:text"`
|
||||
RawPayload string `json:"raw_payload,omitempty" gorm:"type:longtext"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Book *WordBook `json:"book.sql,omitempty" gorm:"foreignKey:BookID;references:ID"`
|
||||
}
|
||||
|
||||
func (Word) TableName() string {
|
||||
return "words"
|
||||
}
|
||||
|
||||
func (w *Word) BeforeCreate(tx *gorm.DB) error {
|
||||
if w.ID == "" {
|
||||
w.ID = NewID()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WordSentence 对应 content.word.content.sentence.sentences
|
||||
// sContent/sCn 以原字段命名映射。
|
||||
type WordSentence struct {
|
||||
ID string `json:"id" gorm:"primaryKey;size:32"`
|
||||
WordID string `json:"word_id" gorm:"size:32;index;not null"`
|
||||
SortOrder int `json:"sort_order" gorm:"index;default:0"`
|
||||
SContent string `json:"s_content" gorm:"type:text"`
|
||||
SCn string `json:"s_cn" gorm:"type:text"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (WordSentence) TableName() string {
|
||||
return "word_sentences"
|
||||
}
|
||||
|
||||
func (m *WordSentence) BeforeCreate(tx *gorm.DB) error {
|
||||
if m.ID == "" {
|
||||
m.ID = NewID()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WordTranslation 对应 content.word.content.trans
|
||||
// 保存中英释义及词性信息。
|
||||
type WordTranslation struct {
|
||||
ID string `json:"id" gorm:"primaryKey;size:32"`
|
||||
WordID string `json:"word_id" gorm:"size:32;index;not null"`
|
||||
SortOrder int `json:"sort_order" gorm:"index;default:0"`
|
||||
Pos string `json:"pos" gorm:"size:32"`
|
||||
TranCn string `json:"tran_cn" gorm:"type:text"`
|
||||
TranOther string `json:"tran_other" gorm:"type:text"`
|
||||
DescCn string `json:"desc_cn" gorm:"size:64"`
|
||||
DescOther string `json:"desc_other" gorm:"size:64"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (WordTranslation) TableName() string {
|
||||
return "word_translations"
|
||||
}
|
||||
|
||||
func (m *WordTranslation) BeforeCreate(tx *gorm.DB) error {
|
||||
if m.ID == "" {
|
||||
m.ID = NewID()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WordSynonym + WordSynonymItem 对应 content.word.content.syno.synos[*].hwds[*]
|
||||
type WordSynonym struct {
|
||||
ID string `json:"id" gorm:"primaryKey;size:32"`
|
||||
WordID string `json:"word_id" gorm:"size:32;index;not null"`
|
||||
SortOrder int `json:"sort_order" gorm:"index;default:0"`
|
||||
Pos string `json:"pos" gorm:"size:32"`
|
||||
Tran string `json:"tran" gorm:"type:text"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (WordSynonym) TableName() string {
|
||||
return "word_synonyms"
|
||||
}
|
||||
|
||||
func (m *WordSynonym) BeforeCreate(tx *gorm.DB) error {
|
||||
if m.ID == "" {
|
||||
m.ID = NewID()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type WordSynonymItem struct {
|
||||
ID string `json:"id" gorm:"primaryKey;size:32"`
|
||||
SynonymID string `json:"synonym_id" gorm:"size:32;index;not null"`
|
||||
SortOrder int `json:"sort_order" gorm:"index;default:0"`
|
||||
Word string `json:"word" gorm:"size:120;not null"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (WordSynonymItem) TableName() string {
|
||||
return "word_synonym_items"
|
||||
}
|
||||
|
||||
func (m *WordSynonymItem) BeforeCreate(tx *gorm.DB) error {
|
||||
if m.ID == "" {
|
||||
m.ID = NewID()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WordPhrase 对应 content.word.content.phrase.phrases
|
||||
type WordPhrase struct {
|
||||
ID string `json:"id" gorm:"primaryKey;size:32"`
|
||||
WordID string `json:"word_id" gorm:"size:32;index;not null"`
|
||||
SortOrder int `json:"sort_order" gorm:"index;default:0"`
|
||||
PContent string `json:"p_content" gorm:"type:text"`
|
||||
PCn string `json:"p_cn" gorm:"type:text"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (WordPhrase) TableName() string {
|
||||
return "word_phrases"
|
||||
}
|
||||
|
||||
func (m *WordPhrase) BeforeCreate(tx *gorm.DB) error {
|
||||
if m.ID == "" {
|
||||
m.ID = NewID()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WordRel + WordRelItem 对应 content.word.content.relWord.rels[*].words[*]
|
||||
type WordRel struct {
|
||||
ID string `json:"id" gorm:"primaryKey;size:32"`
|
||||
WordID string `json:"word_id" gorm:"size:32;index;not null"`
|
||||
SortOrder int `json:"sort_order" gorm:"index;default:0"`
|
||||
Pos string `json:"pos" gorm:"size:32"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (WordRel) TableName() string {
|
||||
return "word_rels"
|
||||
}
|
||||
|
||||
func (m *WordRel) BeforeCreate(tx *gorm.DB) error {
|
||||
if m.ID == "" {
|
||||
m.ID = NewID()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type WordRelItem struct {
|
||||
ID string `json:"id" gorm:"primaryKey;size:32"`
|
||||
RelID string `json:"rel_id" gorm:"size:32;index;not null"`
|
||||
SortOrder int `json:"sort_order" gorm:"index;default:0"`
|
||||
Hwd string `json:"hwd" gorm:"size:120"`
|
||||
Tran string `json:"tran" gorm:"type:text"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (WordRelItem) TableName() string {
|
||||
return "word_rel_items"
|
||||
}
|
||||
|
||||
func (m *WordRelItem) BeforeCreate(tx *gorm.DB) error {
|
||||
if m.ID == "" {
|
||||
m.ID = NewID()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
32
memora-api/internal/model/word_book.go
Normal file
32
memora-api/internal/model/word_book.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultWordBookID = "00000000000000000000000000000001"
|
||||
DefaultWordBookCode = "default"
|
||||
)
|
||||
|
||||
type WordBook struct {
|
||||
ID string `json:"id" gorm:"primaryKey;size:32"`
|
||||
Code string `json:"code" gorm:"size:64;uniqueIndex;not null"`
|
||||
Name string `json:"name" gorm:"size:120;not null"`
|
||||
SourceBookID string `json:"source_book_id" gorm:"size:64;uniqueIndex"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (WordBook) TableName() string {
|
||||
return "word_books"
|
||||
}
|
||||
|
||||
func (b *WordBook) BeforeCreate(tx *gorm.DB) error {
|
||||
if b.ID == "" {
|
||||
b.ID = NewID()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -20,7 +20,7 @@ func (r *MemoryRepository) Create(record *model.MemoryRecord) error {
|
||||
return r.db.Create(record).Error
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) FindByWord(userID, wordID int64) (*model.MemoryRecord, error) {
|
||||
func (r *MemoryRepository) FindByWord(userID, wordID string) (*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
|
||||
@@ -28,7 +28,7 @@ func (r *MemoryRepository) FindByWord(userID, wordID int64) (*model.MemoryRecord
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) FindByID(userID, recordID int64) (*model.MemoryRecord, error) {
|
||||
func (r *MemoryRepository) FindByID(userID, recordID string) (*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
|
||||
@@ -40,7 +40,7 @@ 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) {
|
||||
func (r *MemoryRepository) Due(userID string, 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
|
||||
@@ -48,7 +48,7 @@ func (r *MemoryRepository) Due(userID int64, limit int) ([]model.MemoryRecord, e
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) ListByUser(userID int64, limit int) ([]model.MemoryRecord, error) {
|
||||
func (r *MemoryRepository) ListByUser(userID string, 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
|
||||
@@ -56,7 +56,7 @@ func (r *MemoryRepository) ListByUser(userID int64, limit int) ([]model.MemoryRe
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (r *MemoryRepository) CountOverview(userID int64) (total, mastered, needReview, todayReviewed int64) {
|
||||
func (r *MemoryRepository) CountOverview(userID string) (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)
|
||||
|
||||
@@ -22,6 +22,14 @@ func (r *WordRepository) FindByWord(word string) (*model.Word, error) {
|
||||
return &w, nil
|
||||
}
|
||||
|
||||
func (r *WordRepository) FindByID(id string) (*model.Word, error) {
|
||||
var w model.Word
|
||||
if err := r.db.Where("id = ?", id).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
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ type AddWordRequest struct {
|
||||
}
|
||||
|
||||
type ReviewAnswerRequest struct {
|
||||
RecordID int64 `json:"record_id" binding:"required"`
|
||||
RecordID string `json:"record_id" binding:"required"`
|
||||
Answer string `json:"answer" binding:"required"`
|
||||
Mode string `json:"mode" binding:"required"` // spelling, en2cn, cn2en
|
||||
}
|
||||
@@ -15,7 +15,7 @@ type CreateStudySessionRequest struct {
|
||||
}
|
||||
|
||||
type SubmitStudyAnswerRequest struct {
|
||||
WordID int64 `json:"word_id" binding:"required"`
|
||||
WordID string `json:"word_id" binding:"required"`
|
||||
Answer string `json:"answer" binding:"required"`
|
||||
Mode string `json:"mode" binding:"required"` // spelling, en2cn, cn2en
|
||||
}
|
||||
|
||||
@@ -12,24 +12,31 @@ func New(wordHandler *handler.WordHandler, authHandler *handler.AuthHandler) *gi
|
||||
r.Use(middleware.CORS())
|
||||
|
||||
api := r.Group("/api")
|
||||
{
|
||||
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)
|
||||
}
|
||||
}
|
||||
registerRoutes(api, wordHandler, authHandler)
|
||||
registerRoutes(api.Group("/v1"), wordHandler, authHandler)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func registerRoutes(group *gin.RouterGroup, wordHandler *handler.WordHandler, authHandler *handler.AuthHandler) {
|
||||
group.POST("/auth/register", authHandler.Register)
|
||||
group.POST("/auth/login", authHandler.Login)
|
||||
group.GET("/auth/me", authHandler.Me)
|
||||
|
||||
protected := group.Group("")
|
||||
protected.Use(middleware.AuthRequired())
|
||||
{
|
||||
protected.POST("/words", wordHandler.AddWord)
|
||||
protected.GET("/words", wordHandler.GetWords)
|
||||
protected.GET("/words/:id", wordHandler.GetWordByID)
|
||||
protected.POST("/study/sessions", wordHandler.CreateStudySession)
|
||||
protected.POST("/study/answers", wordHandler.SubmitStudyAnswer)
|
||||
protected.GET("/review", wordHandler.GetReviewWords)
|
||||
protected.POST("/review", wordHandler.SubmitReview)
|
||||
protected.GET("/review/today", wordHandler.GetReviewWords)
|
||||
protected.POST("/review/submit", wordHandler.SubmitReview)
|
||||
protected.GET("/stats", wordHandler.GetStatistics)
|
||||
protected.GET("/stats/overview", wordHandler.GetStatistics)
|
||||
protected.GET("/audio", wordHandler.GetAudio)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,9 +63,9 @@ func sign(data string) string {
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func makeToken(uid int64) string {
|
||||
func makeToken(uid string) string {
|
||||
exp := time.Now().Add(7 * 24 * time.Hour).Unix()
|
||||
payload := fmt.Sprintf("%d:%d", uid, exp)
|
||||
payload := fmt.Sprintf("%s:%d", uid, exp)
|
||||
encPayload := base64.RawURLEncoding.EncodeToString([]byte(payload))
|
||||
sig := sign(encPayload)
|
||||
return encPayload + "." + sig
|
||||
@@ -84,35 +84,35 @@ func (s *AuthService) Login(req request.LoginRequest) (string, *model.User, erro
|
||||
return makeToken(user.ID), &user, nil
|
||||
}
|
||||
|
||||
func ParseToken(tokenString string) (int64, error) {
|
||||
func ParseToken(tokenString string) (string, error) {
|
||||
parts := strings.Split(tokenString, ".")
|
||||
if len(parts) != 2 {
|
||||
return 0, errors.New("token无效")
|
||||
return "", errors.New("token无效")
|
||||
}
|
||||
encPayload, gotSig := parts[0], parts[1]
|
||||
if sign(encPayload) != gotSig {
|
||||
return 0, errors.New("token无效")
|
||||
return "", errors.New("token无效")
|
||||
}
|
||||
|
||||
payloadBytes, err := base64.RawURLEncoding.DecodeString(encPayload)
|
||||
if err != nil {
|
||||
return 0, errors.New("token无效")
|
||||
return "", errors.New("token无效")
|
||||
}
|
||||
|
||||
payloadParts := strings.Split(string(payloadBytes), ":")
|
||||
if len(payloadParts) != 2 {
|
||||
return 0, errors.New("token无效")
|
||||
return "", errors.New("token无效")
|
||||
}
|
||||
uid, err := strconv.ParseInt(payloadParts[0], 10, 64)
|
||||
if err != nil {
|
||||
return 0, errors.New("token无效")
|
||||
uid := payloadParts[0]
|
||||
if uid == "" {
|
||||
return "", errors.New("token无效")
|
||||
}
|
||||
exp, err := strconv.ParseInt(payloadParts[1], 10, 64)
|
||||
if err != nil {
|
||||
return 0, errors.New("token无效")
|
||||
return "", errors.New("token无效")
|
||||
}
|
||||
if time.Now().Unix() > exp {
|
||||
return 0, errors.New("token已过期")
|
||||
return "", errors.New("token已过期")
|
||||
}
|
||||
|
||||
return uid, nil
|
||||
|
||||
@@ -176,7 +176,7 @@ func (s *WordService) DownloadAudio(url, filePath string) error {
|
||||
}
|
||||
|
||||
// 保存单词到数据库
|
||||
func (s *WordService) SaveWord(userID int64, word string, youdaoResp *model.YoudaoResponse) (*model.Word, error) {
|
||||
func (s *WordService) SaveWord(userID string, word string, youdaoResp *model.YoudaoResponse) (*model.Word, error) {
|
||||
// 检查单词是否已存在
|
||||
if w, err := s.wordRepo.FindByWord(word); err == nil {
|
||||
// 单词已存在,更新记忆记录
|
||||
@@ -262,6 +262,8 @@ func (s *WordService) SaveWord(userID int64, word string, youdaoResp *model.Youd
|
||||
|
||||
// 创建新单词
|
||||
newWord := model.Word{
|
||||
BookID: model.DefaultWordBookID,
|
||||
HeadWord: word,
|
||||
Word: word,
|
||||
PhoneticUK: phoneticUK,
|
||||
PhoneticUS: phoneticUS,
|
||||
@@ -284,7 +286,7 @@ func (s *WordService) SaveWord(userID int64, word string, youdaoResp *model.Youd
|
||||
}
|
||||
|
||||
// 创建记忆记录
|
||||
func (s *WordService) createMemoryRecord(userID, wordID int64) error {
|
||||
func (s *WordService) createMemoryRecord(userID, wordID string) error {
|
||||
record := model.MemoryRecord{
|
||||
WordID: wordID,
|
||||
UserID: userID,
|
||||
@@ -296,7 +298,7 @@ func (s *WordService) createMemoryRecord(userID, wordID int64) error {
|
||||
}
|
||||
|
||||
// 更新记忆记录
|
||||
func (s *WordService) updateMemoryRecord(userID, wordID int64, correct bool) error {
|
||||
func (s *WordService) updateMemoryRecord(userID, wordID string, correct bool) error {
|
||||
record, err := s.memoryRepo.FindByWord(userID, wordID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@@ -333,7 +335,7 @@ func (s *WordService) updateMemoryRecord(userID, wordID int64, correct bool) err
|
||||
}
|
||||
|
||||
// 获取待复习单词
|
||||
func (s *WordService) GetReviewWords(userID int64, mode string, limit int) ([]model.MemoryRecord, error) {
|
||||
func (s *WordService) GetReviewWords(userID string, mode string, limit int) ([]model.MemoryRecord, error) {
|
||||
records, err := s.memoryRepo.Due(userID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -375,7 +377,7 @@ func containsAny(def string, ans string) bool {
|
||||
}
|
||||
|
||||
// 提交复习答案
|
||||
func (s *WordService) SubmitReviewAnswer(userID int64, req request.ReviewAnswerRequest) (*response.ReviewResult, error) {
|
||||
func (s *WordService) SubmitReviewAnswer(userID string, req request.ReviewAnswerRequest) (*response.ReviewResult, error) {
|
||||
record, err := s.memoryRepo.FindByID(userID, req.RecordID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -416,8 +418,12 @@ func (s *WordService) GetAllWords(limit, offset int, query string) ([]model.Word
|
||||
return s.wordRepo.List(limit, offset, strings.TrimSpace(query))
|
||||
}
|
||||
|
||||
func (s *WordService) GetWordByID(id string) (*model.Word, error) {
|
||||
return s.wordRepo.FindByID(id)
|
||||
}
|
||||
|
||||
// 获取记忆统计
|
||||
func (s *WordService) GetStatistics(userID int64) (map[string]interface{}, error) {
|
||||
func (s *WordService) GetStatistics(userID string) (map[string]interface{}, error) {
|
||||
totalWords, masteredWords, needReview, todayReviewed := s.memoryRepo.CountOverview(userID)
|
||||
|
||||
return map[string]interface{}{
|
||||
@@ -428,7 +434,7 @@ func (s *WordService) GetStatistics(userID int64) (map[string]interface{}, error
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *WordService) CreateStudySession(userID int64, limit int) ([]model.Word, error) {
|
||||
func (s *WordService) CreateStudySession(userID string, limit int) ([]model.Word, error) {
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 10
|
||||
}
|
||||
@@ -442,9 +448,9 @@ func (s *WordService) CreateStudySession(userID int64, limit int) ([]model.Word,
|
||||
return words, nil
|
||||
}
|
||||
|
||||
func (s *WordService) SubmitStudyAnswer(userID int64, req request.SubmitStudyAnswerRequest) (*response.ReviewResult, error) {
|
||||
func (s *WordService) SubmitStudyAnswer(userID string, req request.SubmitStudyAnswerRequest) (*response.ReviewResult, error) {
|
||||
reviewReq := request.ReviewAnswerRequest{
|
||||
RecordID: 0,
|
||||
RecordID: "",
|
||||
Answer: req.Answer,
|
||||
Mode: req.Mode,
|
||||
}
|
||||
|
||||
8
memora-api/sql/en_book.sql
Normal file
8
memora-api/sql/en_book.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE IF NOT EXISTS `en_book` (
|
||||
`id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '词书主键ID',
|
||||
`book_name` VARCHAR(100) NOT NULL COMMENT '词书名称,如雅思核心词',
|
||||
`exam_type` VARCHAR(50) NOT NULL COMMENT '考试类型,如 IELTS/TOEFL/CET4',
|
||||
`delete_flag` TINYINT(1) DEFAULT 0 COMMENT '删除标志,0-正常,1-删除',
|
||||
`create_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='英语词书表';
|
||||
11
memora-api/sql/en_book_word_rel.sql
Normal file
11
memora-api/sql/en_book_word_rel.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE IF NOT EXISTS `en_book_word_rl`
|
||||
(
|
||||
`id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '词书单词关联主键ID',
|
||||
`book_id` BIGINT NOT NULL COMMENT '词书ID,关联 en_book.id',
|
||||
`word_id` BIGINT NOT NULL COMMENT '单词ID,关联 en_word.id',
|
||||
`delete_flag` TINYINT(1) DEFAULT 0 COMMENT '删除标志,0-正常,1-删除',
|
||||
`create_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
|
||||
) ENGINE = InnoDB
|
||||
DEFAULT CHARSET = utf8mb4
|
||||
COLLATE = utf8mb4_unicode_ci COMMENT ='词书单词关联表';
|
||||
7
memora-api/sql/en_word.sql
Normal file
7
memora-api/sql/en_word.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS `en_word` (
|
||||
`id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '单词主键ID',
|
||||
`word` VARCHAR(100) NOT NULL COMMENT '单词词头,如 cancel',
|
||||
`delete_flag` TINYINT(1) DEFAULT 0 COMMENT '删除标志,0-正常,1-删除',
|
||||
`create_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='英语单词主表';
|
||||
9
memora-api/sql/en_word_phrase.sql
Normal file
9
memora-api/sql/en_word_phrase.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE IF NOT EXISTS `en_word_phrase` (
|
||||
`id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '短语记录主键ID',
|
||||
`word_id` BIGINT NOT NULL COMMENT '单词ID,关联 en_word.id',
|
||||
`phrase_en` VARCHAR(200) NOT NULL COMMENT '英文短语',
|
||||
`phrase_cn` VARCHAR(200) COMMENT '中文释义'
|
||||
`delete_flag` TINYINT(1) DEFAULT 0 COMMENT '删除标志,0-正常,1-删除',
|
||||
`create_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='英语单词短语表';
|
||||
11
memora-api/sql/en_word_pron.sql
Normal file
11
memora-api/sql/en_word_pron.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE IF NOT EXISTS `en_word_pron` (
|
||||
`id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '发音记录主键ID',
|
||||
`word_id` BIGINT NOT NULL COMMENT '单词ID,关联 en_word.id',
|
||||
`uk_phone` VARCHAR(100) COMMENT '英式音标',
|
||||
`us_phone` VARCHAR(100) COMMENT '美式音标',
|
||||
`uk_speech` VARCHAR(200) COMMENT '英式发音资源地址或参数',
|
||||
`us_speech` VARCHAR(200) COMMENT '美式发音资源地址或参数',
|
||||
`delete_flag` TINYINT(1) DEFAULT 0 COMMENT '删除标志,0-正常,1-删除',
|
||||
`create_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='英语单词发音表';
|
||||
12
memora-api/sql/en_word_sentence.sql
Normal file
12
memora-api/sql/en_word_sentence.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE IF NOT EXISTS `en_word_sentence`
|
||||
(
|
||||
`id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '例句记录主键ID',
|
||||
`word_id` BIGINT NOT NULL COMMENT '单词ID,关联 en_word.id',
|
||||
`sentence_en` TEXT COMMENT '英文例句',
|
||||
`sentence_cn` TEXT COMMENT '中文例句',
|
||||
`delete_flag` TINYINT(1) DEFAULT 0 COMMENT '删除标志,0-正常,1-删除',
|
||||
`create_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
|
||||
) ENGINE = InnoDB
|
||||
DEFAULT CHARSET = utf8mb4
|
||||
COLLATE = utf8mb4_unicode_ci COMMENT ='英语单词例句表';
|
||||
13
memora-api/sql/en_word_synonym.sql
Normal file
13
memora-api/sql/en_word_synonym.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS `en_word_synonym`
|
||||
(
|
||||
`id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '同义词记录主键ID',
|
||||
`word_id` BIGINT NOT NULL COMMENT '单词ID,关联 en_word.id',
|
||||
`pos` VARCHAR(20) COMMENT '词性,如 vt/vi/n',
|
||||
`tran` TEXT COMMENT '同义词组释义',
|
||||
`synonym_word` VARCHAR(100) NOT NULL COMMENT '同义词词头',
|
||||
`delete_flag` TINYINT(1) DEFAULT 0 COMMENT '删除标志,0-正常,1-删除',
|
||||
`create_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
|
||||
) ENGINE = InnoDB
|
||||
DEFAULT CHARSET = utf8mb4
|
||||
COLLATE = utf8mb4_unicode_ci COMMENT ='英语单词同义词表';
|
||||
13
memora-api/sql/en_word_trans.sql
Normal file
13
memora-api/sql/en_word_trans.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS `en_word_trans`
|
||||
(
|
||||
`id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '释义记录主键ID',
|
||||
`word_id` BIGINT NOT NULL COMMENT '单词ID,关联 en_word.id',
|
||||
`pos` VARCHAR(20) COMMENT '词性,如 v/n/adj',
|
||||
`tran_cn` TEXT COMMENT '中文释义',
|
||||
`tran_en` TEXT COMMENT '英文释义',
|
||||
`delete_flag` TINYINT(1) DEFAULT 0 COMMENT '删除标志,0-正常,1-删除',
|
||||
`create_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
|
||||
) ENGINE = InnoDB
|
||||
DEFAULT CHARSET = utf8mb4
|
||||
COLLATE = utf8mb4_unicode_ci COMMENT ='英语单词释义表';
|
||||
@@ -2,20 +2,150 @@
|
||||
CREATE DATABASE IF NOT EXISTS memora DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
USE memora;
|
||||
|
||||
-- 标准单词表
|
||||
-- 词库表(对应 JSON 顶层 bookId)
|
||||
CREATE TABLE IF NOT EXISTS word_books (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
code VARCHAR(64) NOT NULL UNIQUE COMMENT '系统词库编码',
|
||||
name VARCHAR(120) NOT NULL COMMENT '词库名称',
|
||||
source_json_filename VARCHAR(255) NULL UNIQUE COMMENT '来源词库json文件名,如 IELTS_3',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT INTO word_books (id, code, name, source_book_id)
|
||||
VALUES
|
||||
(1, 'default', '默认词库', 'DEFAULT'),
|
||||
(2, 'cet4', '四级词库', 'CET4'),
|
||||
(3, 'ielts', '雅思词库', 'IELTS'),
|
||||
(4, 'toefl', '托福词库', 'TOEFL')
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name),
|
||||
source_book_id = VALUES(source_book_id);
|
||||
|
||||
-- 主单词表(覆盖 JSON 顶层与 content.word 核心字段)
|
||||
CREATE TABLE IF NOT EXISTS words (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
word VARCHAR(100) NOT NULL UNIQUE COMMENT '单词',
|
||||
phonetic_uk VARCHAR(255) COMMENT '英式音标',
|
||||
phonetic_us VARCHAR(255) COMMENT '美式音标',
|
||||
audio_uk VARCHAR(500) COMMENT '英式音频文件路径',
|
||||
audio_us VARCHAR(500) COMMENT '美式音频文件路径',
|
||||
part_of_speech VARCHAR(50) COMMENT '词性',
|
||||
definition TEXT COMMENT '标准释义',
|
||||
example_sentence TEXT COMMENT '例句',
|
||||
book_id BIGINT NOT NULL DEFAULT 1 COMMENT '关联词库ID',
|
||||
word_rank INT DEFAULT 0 COMMENT 'JSON: wordRank',
|
||||
head_word VARCHAR(120) COMMENT 'JSON: headWord',
|
||||
word VARCHAR(120) NOT NULL UNIQUE COMMENT 'JSON: content.word.wordHead',
|
||||
source_word_id VARCHAR(64) COMMENT 'JSON: content.word.wordId',
|
||||
phonetic_uk VARCHAR(255) COMMENT 'JSON: ukphone',
|
||||
phonetic_us VARCHAR(255) COMMENT 'JSON: usphone',
|
||||
audio_uk VARCHAR(500) COMMENT '英式音频文件路径(本地缓存)',
|
||||
audio_us VARCHAR(500) COMMENT '美式音频文件路径(本地缓存)',
|
||||
uk_speech VARCHAR(255) COMMENT 'JSON: ukspeech',
|
||||
us_speech VARCHAR(255) COMMENT 'JSON: usspeech',
|
||||
part_of_speech VARCHAR(50) COMMENT '兼容旧结构',
|
||||
definition TEXT COMMENT '兼容旧结构(可由 trans 聚合)',
|
||||
example_sentence TEXT COMMENT '兼容旧结构(可由 sentence 聚合)',
|
||||
raw_payload LONGTEXT COMMENT '完整原始JSON',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_word (word)
|
||||
INDEX idx_word (word),
|
||||
INDEX idx_book_id (book_id),
|
||||
INDEX idx_word_rank (word_rank),
|
||||
INDEX idx_source_word_id (source_word_id),
|
||||
FOREIGN KEY (book_id) REFERENCES word_books(id) ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 例句表:content.word.content.sentence.sentences
|
||||
CREATE TABLE IF NOT EXISTS word_sentences (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
word_id BIGINT NOT NULL,
|
||||
sort_order INT DEFAULT 0,
|
||||
s_content TEXT COMMENT 'JSON: sContent',
|
||||
s_cn TEXT COMMENT 'JSON: sCn',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_word_sentence_word_id (word_id),
|
||||
INDEX idx_word_sentence_sort (sort_order),
|
||||
FOREIGN KEY (word_id) REFERENCES words(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 释义表:content.word.content.trans
|
||||
CREATE TABLE IF NOT EXISTS word_translations (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
word_id BIGINT NOT NULL,
|
||||
sort_order INT DEFAULT 0,
|
||||
pos VARCHAR(32),
|
||||
tran_cn TEXT,
|
||||
tran_other TEXT,
|
||||
desc_cn VARCHAR(64),
|
||||
desc_other VARCHAR(64),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_word_trans_word_id (word_id),
|
||||
INDEX idx_word_trans_sort (sort_order),
|
||||
FOREIGN KEY (word_id) REFERENCES words(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 同近组表:content.word.content.syno.synos
|
||||
CREATE TABLE IF NOT EXISTS word_synonyms (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
word_id BIGINT NOT NULL,
|
||||
sort_order INT DEFAULT 0,
|
||||
pos VARCHAR(32),
|
||||
tran TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_word_syn_word_id (word_id),
|
||||
INDEX idx_word_syn_sort (sort_order),
|
||||
FOREIGN KEY (word_id) REFERENCES words(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 同近词项表:content.word.content.syno.synos[*].hwds[*]
|
||||
CREATE TABLE IF NOT EXISTS word_synonym_items (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
synonym_id BIGINT NOT NULL,
|
||||
sort_order INT DEFAULT 0,
|
||||
word VARCHAR(120) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_word_syn_item_syn_id (synonym_id),
|
||||
INDEX idx_word_syn_item_sort (sort_order),
|
||||
FOREIGN KEY (synonym_id) REFERENCES word_synonyms(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 短语表:content.word.content.phrase.phrases
|
||||
CREATE TABLE IF NOT EXISTS word_phrases (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
word_id BIGINT NOT NULL,
|
||||
sort_order INT DEFAULT 0,
|
||||
p_content TEXT,
|
||||
p_cn TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_word_phrase_word_id (word_id),
|
||||
INDEX idx_word_phrase_sort (sort_order),
|
||||
FOREIGN KEY (word_id) REFERENCES words(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 同根组表:content.word.content.relWord.rels
|
||||
CREATE TABLE IF NOT EXISTS word_rels (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
word_id BIGINT NOT NULL,
|
||||
sort_order INT DEFAULT 0,
|
||||
pos VARCHAR(32),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_word_rel_word_id (word_id),
|
||||
INDEX idx_word_rel_sort (sort_order),
|
||||
FOREIGN KEY (word_id) REFERENCES words(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 同根词项表:content.word.content.relWord.rels[*].words[*]
|
||||
CREATE TABLE IF NOT EXISTS word_rel_items (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
rel_id BIGINT NOT NULL,
|
||||
sort_order INT DEFAULT 0,
|
||||
hwd VARCHAR(120),
|
||||
tran TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_word_rel_item_rel_id (rel_id),
|
||||
INDEX idx_word_rel_item_sort (sort_order),
|
||||
FOREIGN KEY (rel_id) REFERENCES word_rels(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 记忆记录表
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
RUN corepack enable && pnpm install
|
||||
COPY . .
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "3000"]
|
||||
CMD ["pnpm", "dev", "--host", "0.0.0.0", "--port", "3000"]
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
<title>Memora 背单词</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uview-plus@3.2.0/index.css" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/uview-plus@3.2.0/index.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
2295
memora-web/package-lock.json
generated
2295
memora-web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"preinstall": "node scripts/enforce-pnpm.cjs",
|
||||
"dev": "vite",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"build": "vite build",
|
||||
@@ -22,5 +23,6 @@
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^5.1.6",
|
||||
"vue-tsc": "^3.2.5"
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@10.28.2"
|
||||
}
|
||||
|
||||
1456
memora-web/pnpm-lock.yaml
generated
Normal file
1456
memora-web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
memora-web/scripts/enforce-pnpm.cjs
Normal file
6
memora-web/scripts/enforce-pnpm.cjs
Normal file
@@ -0,0 +1,6 @@
|
||||
const userAgent = process.env.npm_config_user_agent || "";
|
||||
|
||||
if (!userAgent.includes("pnpm")) {
|
||||
console.error("This project requires pnpm. Please run: pnpm install");
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -2,22 +2,30 @@
|
||||
<router-view v-if="$route.meta?.fullPage" />
|
||||
<el-container v-else class="layout">
|
||||
<el-aside width="220px" class="sidebar">
|
||||
<div class="logo">Memora</div>
|
||||
<div class="logo">
|
||||
<span class="logo-mark">M</span>
|
||||
<div>
|
||||
<div class="logo-title">Memora</div>
|
||||
<div class="logo-sub">Long-Term Vocabulary</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-menu
|
||||
:default-active="$route.path"
|
||||
:default-active="activeMenu"
|
||||
class="menu"
|
||||
@select="go"
|
||||
>
|
||||
<el-menu-item index="/">仪表盘</el-menu-item>
|
||||
<el-menu-item index="/memory">记忆模式</el-menu-item>
|
||||
<el-menu-item index="/review">复习模式</el-menu-item>
|
||||
<el-menu-item index="/memory">学习</el-menu-item>
|
||||
<el-menu-item index="/statistics">学习统计</el-menu-item>
|
||||
<el-menu-item index="/words">单词列表</el-menu-item>
|
||||
<el-menu-item index="/settings">设置</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<el-container>
|
||||
<el-header class="topbar"></el-header>
|
||||
<el-header class="topbar">
|
||||
<div class="topbar-title">{{ routeTitle }}</div>
|
||||
<div class="topbar-date">{{ todayText }}</div>
|
||||
</el-header>
|
||||
<el-main class="main">
|
||||
<router-view />
|
||||
</el-main>
|
||||
@@ -26,41 +34,97 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const go = (path) => router.push(path)
|
||||
|
||||
const routeTitle = computed(() => {
|
||||
const titleMap = {
|
||||
'/memory': '学习会话',
|
||||
'/review': '今日复习',
|
||||
'/statistics': '学习统计',
|
||||
'/words': '词库管理',
|
||||
'/settings': '偏好设置'
|
||||
}
|
||||
if (route.path.startsWith('/statistics/')) return '学习统计详情'
|
||||
return titleMap[route.path] || 'Memora'
|
||||
})
|
||||
|
||||
const activeMenu = computed(() => {
|
||||
if (route.path.startsWith('/statistics')) return '/statistics'
|
||||
return route.path
|
||||
})
|
||||
|
||||
const todayText = computed(() => {
|
||||
const now = new Date()
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout { min-height: 100vh; background: #f4f5fb; }
|
||||
.layout { min-height: 100vh; background: radial-gradient(circle at 0% 0%, #f6fbff 0, #f2f6ff 40%, #eef2fb 100%); }
|
||||
.sidebar {
|
||||
background: #fff;
|
||||
border-right: 1px solid #eceef3;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f9fbff 100%);
|
||||
border-right: 1px solid #e4ebf7;
|
||||
}
|
||||
.logo {
|
||||
height: 62px;
|
||||
height: 76px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 18px;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
gap: 10px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
.logo-mark {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
background: #2447d6;
|
||||
color: #fff;
|
||||
}
|
||||
.logo-title {
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
font-weight: 800;
|
||||
}
|
||||
.logo-sub {
|
||||
margin-top: 3px;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
}
|
||||
.menu {
|
||||
border-right: none;
|
||||
padding: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
:deep(.el-menu-item.is-active) {
|
||||
background: #2f7d32;
|
||||
background: #2447d6;
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.topbar {
|
||||
height: 62px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #eceef3;
|
||||
border-bottom: 1px solid #e5ecf8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
}
|
||||
.topbar-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.topbar-date {
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
.main {
|
||||
padding: 24px 28px;
|
||||
padding: 18px 20px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import Dashboard from '../views/Home.vue'
|
||||
import Memory from '../views/Memory.vue'
|
||||
import Review from '../views/Review.vue'
|
||||
import Statistics from '../views/Statistics.vue'
|
||||
import StatisticsDetail from '../views/StatisticsDetail.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: '/', redirect: '/memory' },
|
||||
{ path: '/memory', name: 'memory', component: Memory },
|
||||
{ path: '/review', name: 'review', component: Review },
|
||||
{ path: '/statistics', name: 'statistics', component: Statistics },
|
||||
{ path: '/statistics/:metric', name: 'statistics-detail', component: StatisticsDetail },
|
||||
{ path: '/words', name: 'words', component: Words },
|
||||
{ path: '/settings', name: 'settings', component: Settings }
|
||||
]
|
||||
|
||||
113
memora-web/src/modules/preferences/store.ts
Normal file
113
memora-web/src/modules/preferences/store.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
export type WordBook = 'cet4' | 'ielts' | 'toefl' | 'custom'
|
||||
export type Pronunciation = 'uk' | 'us'
|
||||
|
||||
export interface StudyPlan {
|
||||
wordBook: WordBook
|
||||
dailyTarget: number
|
||||
remindAt: string
|
||||
locked: boolean
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
nickname: string
|
||||
avatar: string
|
||||
}
|
||||
|
||||
export interface Preferences {
|
||||
plan: StudyPlan
|
||||
pronunciation: Pronunciation
|
||||
profile: UserProfile
|
||||
}
|
||||
|
||||
const PREF_KEY = 'memora.preferences'
|
||||
const LEARNED_KEY = 'memora.today.learned'
|
||||
const DATE_KEY = 'memora.today.date'
|
||||
const LEARNED_WORD_IDS_KEY = 'memora.today.learned.word_ids'
|
||||
|
||||
function todayKey() {
|
||||
const now = new Date()
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function defaultPreferences(): Preferences {
|
||||
return {
|
||||
plan: {
|
||||
wordBook: 'cet4',
|
||||
dailyTarget: 20,
|
||||
remindAt: '21:00',
|
||||
locked: false
|
||||
},
|
||||
pronunciation: 'uk',
|
||||
profile: {
|
||||
nickname: 'Memora 用户',
|
||||
avatar: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function loadPreferences(): Preferences {
|
||||
try {
|
||||
const raw = localStorage.getItem(PREF_KEY)
|
||||
if (!raw) return defaultPreferences()
|
||||
const parsed = JSON.parse(raw) as Preferences
|
||||
const defaults = defaultPreferences()
|
||||
return {
|
||||
plan: {
|
||||
...defaults.plan,
|
||||
...(parsed.plan || {})
|
||||
},
|
||||
pronunciation: parsed.pronunciation || defaults.pronunciation,
|
||||
profile: {
|
||||
...defaults.profile,
|
||||
...(parsed.profile || {})
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return defaultPreferences()
|
||||
}
|
||||
}
|
||||
|
||||
export function savePreferences(pref: Preferences) {
|
||||
localStorage.setItem(PREF_KEY, JSON.stringify(pref))
|
||||
}
|
||||
|
||||
export function recordTodayLearned(count: number) {
|
||||
const today = todayKey()
|
||||
const oldDate = localStorage.getItem(DATE_KEY)
|
||||
const oldCount = Number(localStorage.getItem(LEARNED_KEY) || '0')
|
||||
const nextCount = oldDate === today ? oldCount + count : count
|
||||
localStorage.setItem(DATE_KEY, today)
|
||||
localStorage.setItem(LEARNED_KEY, String(nextCount))
|
||||
}
|
||||
|
||||
export function getTodayLearned() {
|
||||
return getTodayLearnedWordIds().length
|
||||
}
|
||||
|
||||
export function getTodayLearnedWordIds(): number[] {
|
||||
const today = todayKey()
|
||||
const oldDate = localStorage.getItem(DATE_KEY)
|
||||
if (oldDate !== today) {
|
||||
localStorage.setItem(DATE_KEY, today)
|
||||
localStorage.setItem(LEARNED_WORD_IDS_KEY, '[]')
|
||||
localStorage.setItem(LEARNED_KEY, '0')
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(LEARNED_WORD_IDS_KEY)
|
||||
if (!raw) return []
|
||||
const ids = JSON.parse(raw) as number[]
|
||||
return Array.isArray(ids) ? ids.filter((id) => Number.isFinite(id)) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function markTodayLearnedWord(wordID: number) {
|
||||
const ids = getTodayLearnedWordIds()
|
||||
if (ids.includes(wordID)) return
|
||||
const next = [...ids, wordID]
|
||||
localStorage.setItem(LEARNED_WORD_IDS_KEY, JSON.stringify(next))
|
||||
localStorage.setItem(LEARNED_KEY, String(next.length))
|
||||
}
|
||||
@@ -2,12 +2,12 @@ import { http } from '../http'
|
||||
import type { MemoryRecord, ReviewMode, ReviewResult } from './types'
|
||||
|
||||
export async function getReviewWords(params: { mode?: ReviewMode; limit?: number } = {}) {
|
||||
const res = await http.get<{ data: MemoryRecord[] }>('/review', { params })
|
||||
const res = await http.get<{ data: MemoryRecord[] }>('/review/today', { params })
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function submitReview(payload: { recordId: number; answer: string; mode: ReviewMode }) {
|
||||
const res = await http.post<{ data: ReviewResult }>('/review', {
|
||||
const res = await http.post<{ data: ReviewResult }>('/review/submit', {
|
||||
record_id: payload.recordId,
|
||||
answer: payload.answer,
|
||||
mode: payload.mode
|
||||
|
||||
@@ -2,6 +2,6 @@ import { http } from '../http'
|
||||
import type { Stats } from './types'
|
||||
|
||||
export async function getStatistics() {
|
||||
const res = await http.get<{ data: Stats }>('/stats')
|
||||
const res = await http.get<{ data: Stats }>('/stats/overview')
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -11,7 +11,12 @@ export async function getWords(params: { limit?: number; offset?: number; q?: st
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getWordById(id: number) {
|
||||
const res = await http.get<{ data: Word }>(`/words/${id}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export function audioUrl(word: string, type: 'uk' | 'us' = 'uk') {
|
||||
const q = new URLSearchParams({ word, type })
|
||||
return `/api/audio?${q.toString()}`
|
||||
return `/api/v1/audio?${q.toString()}`
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import axios from 'axios'
|
||||
import { clearToken, getToken } from '../modules/auth/auth'
|
||||
|
||||
export const http = axios.create({
|
||||
baseURL: '/api',
|
||||
baseURL: '/api/v1',
|
||||
timeout: 15000
|
||||
})
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
:root {
|
||||
--memora-bg: #f4f5fb;
|
||||
--memora-border: #eceef3;
|
||||
--memora-text: #111827;
|
||||
--memora-sub: #6b7280;
|
||||
--memora-primary: #2f7d32;
|
||||
--memora-bg: #eef2fb;
|
||||
--memora-border: #e4ebf7;
|
||||
--memora-text: #101828;
|
||||
--memora-sub: #667085;
|
||||
--memora-primary: #2447d6;
|
||||
}
|
||||
|
||||
html, body {
|
||||
@@ -14,5 +14,9 @@ body {
|
||||
margin: 0;
|
||||
background: var(--memora-bg);
|
||||
color: var(--memora-text);
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
|
||||
font-family: "Manrope", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="title">仪表盘</div>
|
||||
<div class="sub">欢迎回来,继续你的学习之旅</div>
|
||||
<div class="home-page">
|
||||
<el-card class="hero">
|
||||
<div class="hero-title">长期记忆留存学习系统</div>
|
||||
<div class="hero-sub">学习 → 测试 → 复习闭环,基于 SRS 调度今日任务</div>
|
||||
<div class="hero-actions">
|
||||
<el-button type="primary" @click="$router.push('/memory')">开始新词学习</el-button>
|
||||
<el-button @click="$router.push('/review')">进入今日复习</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-row :gutter="16" class="cards">
|
||||
<el-col :span="6"><MetricCard label="今日复习数" :value="stats.today_reviewed ?? 0" icon="🎓" /></el-col>
|
||||
<el-col :span="6"><MetricCard label="待复习数" :value="stats.need_review ?? 0" icon="📖" /></el-col>
|
||||
<el-col :span="6"><MetricCard label="已掌握" :value="stats.mastered_words ?? 0" icon="🎯" /></el-col>
|
||||
<el-col :span="6"><MetricCard label="总词汇" :value="stats.total_words ?? 0" icon="📚" /></el-col>
|
||||
<el-col :xs="24" :md="12" :lg="6"><MetricCard label="今日复习数" :value="stats.today_reviewed ?? 0" icon="🎓" /></el-col>
|
||||
<el-col :xs="24" :md="12" :lg="6"><MetricCard label="待复习数" :value="stats.need_review ?? 0" icon="📖" /></el-col>
|
||||
<el-col :xs="24" :md="12" :lg="6"><MetricCard label="已掌握" :value="stats.mastered_words ?? 0" icon="🎯" /></el-col>
|
||||
<el-col :xs="24" :md="12" :lg="6"><MetricCard label="总词汇" :value="stats.total_words ?? 0" icon="📚" /></el-col>
|
||||
</el-row>
|
||||
|
||||
<div class="actions">
|
||||
<el-button type="success" @click="$router.push('/review')">开始复习</el-button>
|
||||
<el-button @click="$router.push('/memory')">添加单词</el-button>
|
||||
</div>
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="section-title">当前阶段验收项</div>
|
||||
</template>
|
||||
<el-timeline>
|
||||
<el-timeline-item type="primary" timestamp="M1">注册/登录 + 受保护接口</el-timeline-item>
|
||||
<el-timeline-item type="success" timestamp="M2">学习会话 + 复习提交 + 今日任务</el-timeline-item>
|
||||
<el-timeline-item type="warning" timestamp="M3">统计面板 + 体验优化</el-timeline-item>
|
||||
</el-timeline>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -21,7 +33,6 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { getStatistics } from '../services/api'
|
||||
import MetricCard from '../components/base/MetricCard.vue'
|
||||
|
||||
import type { Stats } from '../services/api'
|
||||
|
||||
const stats = ref<Stats>({
|
||||
@@ -37,13 +48,43 @@ async function refresh() {
|
||||
}
|
||||
|
||||
onMounted(() => refresh().catch(console.error))
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.title { font-size: 30px; font-weight: 700; color: #111827; }
|
||||
.sub { margin-top: 4px; color: #6b7280; }
|
||||
.cards { margin-top: 20px; }
|
||||
.actions { margin-top: 18px; display: flex; gap: 10px; }
|
||||
.home-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, #1839b7 0%, #2758d7 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 30px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.hero-sub {
|
||||
margin-top: 8px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
margin-top: 18px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cards {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,164 +1,491 @@
|
||||
<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>
|
||||
<div class="memory-page">
|
||||
<el-row :gutter="16" class="top-row">
|
||||
<el-col :xs="24" :lg="10">
|
||||
<el-card class="panel">
|
||||
<template #header>
|
||||
<div class="panel-title">学习页面</div>
|
||||
</template>
|
||||
</el-result>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="h">
|
||||
<div>
|
||||
<div class="title">记忆模式</div>
|
||||
<div class="sub">输入单词并回车,会调用后端(后端再调用有道)校验并入库</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="!preferences.plan.locked">
|
||||
<el-alert type="info" title="首次进入请先设置学习计划,保存后只能在设置页修改" show-icon style="margin-bottom: 12px" />
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="词库">
|
||||
<el-select v-model="preferences.plan.wordBook" class="full">
|
||||
<el-option label="四级词库" value="cet4" />
|
||||
<el-option label="雅思词库" value="ielts" />
|
||||
<el-option label="托福词库" value="toefl" />
|
||||
<el-option label="自定义词库" value="custom" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="`每日新词目标:${preferences.plan.dailyTarget}`">
|
||||
<el-slider v-model="preferences.plan.dailyTarget" :min="5" :max="60" :step="5" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="提醒时间">
|
||||
<el-time-select
|
||||
v-model="preferences.plan.remindAt"
|
||||
class="full"
|
||||
start="06:00"
|
||||
end="23:00"
|
||||
step="00:30"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-button type="primary" @click="confirmPlan">确认学习计划</el-button>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="词库">{{ wordBookLabel(preferences.plan.wordBook) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="每日新词目标">{{ preferences.plan.dailyTarget }}</el-descriptions-item>
|
||||
<el-descriptions-item label="提醒时间">{{ preferences.plan.remindAt }}</el-descriptions-item>
|
||||
<el-descriptions-item label="读音偏好">{{ preferences.pronunciation === 'us' ? '美音' : '英音' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-button style="margin-top: 12px" @click="$router.push('/settings')">去设置页修改</el-button>
|
||||
</template>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :xs="24" :lg="14">
|
||||
<el-card class="panel">
|
||||
<template #header>
|
||||
<div class="actions-head">
|
||||
<div>
|
||||
<div class="panel-title">学习与复习</div>
|
||||
<div class="panel-sub">学习按四阶段推进;复习按当日队列执行,目标与学习计划一致</div>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<el-button type="primary" :disabled="!preferences.plan.locked" :loading="loading" @click="startLearning">开始学习</el-button>
|
||||
<el-button type="success" :disabled="!preferences.plan.locked" @click="goReview">开始复习</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-empty v-if="!sessionWords.length" description="点击“开始学习”进入新词学习流程" />
|
||||
|
||||
<template v-else>
|
||||
<div class="progress-row">
|
||||
<el-tag effect="plain" type="primary">{{ currentPhase.title }}</el-tag>
|
||||
<span>阶段进度:{{ currentWordIndex + 1 }} / {{ sessionWords.length }}</span>
|
||||
<span>总进度:{{ phaseIndex + 1 }} / {{ phases.length }}</span>
|
||||
</div>
|
||||
|
||||
<el-progress :percentage="progressPercent" :stroke-width="12" />
|
||||
|
||||
<el-row :gutter="12" class="metrics">
|
||||
<el-col :span="6"><div class="metric"><span>正确率</span><strong>{{ accuracyText }}</strong></div></el-col>
|
||||
<el-col :span="6"><div class="metric"><span>连续正确</span><strong>{{ stats.streak }}</strong></div></el-col>
|
||||
<el-col :span="6"><div class="metric"><span>累计耗时</span><strong>{{ stats.spentSec }}s</strong></div></el-col>
|
||||
<el-col :span="6"><div class="metric"><span>已完成</span><strong>{{ completedWords }}</strong></div></el-col>
|
||||
</el-row>
|
||||
|
||||
<el-result v-if="learningDone" icon="success" title="今日学习完成">
|
||||
<template #sub-title>
|
||||
<div>四阶段已全部完成,已记录今日学习量。</div>
|
||||
</template>
|
||||
</el-result>
|
||||
|
||||
<div v-else class="qa-block">
|
||||
<div class="question-title">{{ currentPhase.description }}</div>
|
||||
<el-card shadow="never" class="qa-card">
|
||||
<template v-if="currentPhase.kind === 'en2cn'">
|
||||
<div class="q-main">{{ currentWord?.word }}</div>
|
||||
<div class="q-sub">{{ currentWord?.example_sentence || '暂无例句' }}</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="currentPhase.kind === 'cn2en'">
|
||||
<div class="q-main">{{ currentWord?.definition || '暂无释义' }}</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="audio-row">
|
||||
<el-button @click="playAudio">播放读音</el-button>
|
||||
<span>根据读音作答</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="currentPhase.kind !== 'spelling'" class="options">
|
||||
<el-button
|
||||
v-for="option in options"
|
||||
:key="option"
|
||||
class="option"
|
||||
@click="selectOption(option)"
|
||||
>
|
||||
{{ option }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div v-else class="spelling">
|
||||
<el-input v-model="spellingInput" placeholder="输入单词拼写,回车提交" @keyup.enter="submitSpelling" />
|
||||
<el-button type="primary" :loading="loading" @click="submitSpelling">提交</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-alert
|
||||
v-if="lastResult"
|
||||
:type="lastResult.correct ? 'success' : 'error'"
|
||||
:title="lastResult.correct ? '回答正确' : `回答错误,正确答案:${lastResult.correct_ans || '-'}`"
|
||||
show-icon
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-card class="panel">
|
||||
<template #header><div class="panel-title">新词入库</div></template>
|
||||
<el-form @submit.prevent>
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="word"
|
||||
size="large"
|
||||
placeholder="输入英文单词,如: example"
|
||||
@keyup.enter="submit"
|
||||
:disabled="loading"
|
||||
@keyup.enter="submitWord"
|
||||
:disabled="wordLoading"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="success" size="large" :loading="loading" @click="submit">确认</el-button>
|
||||
<el-button type="primary" size="large" :loading="wordLoading" @click="submitWord">确认入库</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-alert v-if="error" type="error" :title="error" show-icon />
|
||||
<el-alert v-if="wordError" type="error" :title="wordError" show-icon />
|
||||
|
||||
<el-descriptions v-if="saved" :column="1" border style="margin-top:16px">
|
||||
<el-descriptions-item label="单词">{{ saved.word }}</el-descriptions-item>
|
||||
<el-descriptions-item label="词性">{{ saved.part_of_speech }}</el-descriptions-item>
|
||||
<el-descriptions-item label="释义">{{ saved.definition }}</el-descriptions-item>
|
||||
<el-descriptions-item label="英音">{{ saved.phonetic_uk || '暂无' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="美音">{{ saved.phonetic_us || '暂无' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="例句">{{ saved.example_sentence || '暂无' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="发音播放">
|
||||
<el-button size="small" @click="playUK" :disabled="!saved.word">播放英音</el-button>
|
||||
<el-button size="small" @click="playUS" :disabled="!saved.word">播放美音</el-button>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { addWord, audioUrl, createStudySession, submitStudyAnswer } from '../services/api'
|
||||
import type { ReviewResult, Word } from '../services/api'
|
||||
import { getTodayLearnedWordIds, loadPreferences, markTodayLearnedWord, savePreferences } from '../modules/preferences/store'
|
||||
import type { Preferences } from '../modules/preferences/store'
|
||||
import type { ReviewMode, ReviewResult, Word } from '../services/api'
|
||||
|
||||
type StudyPhase = {
|
||||
title: string
|
||||
description: string
|
||||
mode: ReviewMode
|
||||
kind: 'en2cn' | 'cn2en' | 'audio' | 'spelling'
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const preferences = reactive<Preferences>(loadPreferences())
|
||||
const phases: StudyPhase[] = [
|
||||
{ title: '第一阶段', description: '显示英文和例句,四选一选择正确英译中', mode: 'en2cn', kind: 'en2cn' },
|
||||
{ title: '第二阶段', description: '显示中文释义,四选一选择正确英文', mode: 'cn2en', kind: 'cn2en' },
|
||||
{ title: '第三阶段', description: '播放读音,四选一选择正确单词', mode: 'cn2en', kind: 'audio' },
|
||||
{ title: '第四阶段', description: '播放读音并拼写单词', mode: 'spelling', kind: 'spelling' }
|
||||
]
|
||||
|
||||
const word = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const wordLoading = ref(false)
|
||||
const wordError = ref('')
|
||||
const saved = ref<Word | null>(null)
|
||||
|
||||
const sessionLoading = ref(false)
|
||||
const loading = 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)
|
||||
const phaseIndex = ref(0)
|
||||
const currentWordIndex = ref(0)
|
||||
const options = ref<string[]>([])
|
||||
const spellingInput = ref('')
|
||||
const lastResult = ref<ReviewResult | null>(null)
|
||||
const phaseStartedAt = ref(0)
|
||||
const stats = reactive({ correct: 0, total: 0, streak: 0, spentSec: 0 })
|
||||
|
||||
async function submit() {
|
||||
const w = word.value.trim()
|
||||
if (!w) return
|
||||
const currentPhase = computed(() => phases[phaseIndex.value])
|
||||
const currentWord = computed(() => sessionWords.value[currentWordIndex.value] || null)
|
||||
const completedWords = computed(() => phaseIndex.value * sessionWords.value.length + currentWordIndex.value)
|
||||
const learningDone = computed(() => sessionWords.value.length > 0 && phaseIndex.value >= phases.length)
|
||||
const progressPercent = computed(() => {
|
||||
if (!sessionWords.value.length) return 0
|
||||
const total = phases.length * sessionWords.value.length
|
||||
return Math.min(100, Math.floor((completedWords.value / total) * 100))
|
||||
})
|
||||
const accuracyText = computed(() => (stats.total ? `${Math.round((stats.correct / stats.total) * 100)}%` : '0%'))
|
||||
|
||||
function wordBookLabel(book: string) {
|
||||
if (book === 'cet4') return '四级词库'
|
||||
if (book === 'ielts') return '雅思词库'
|
||||
if (book === 'toefl') return '托福词库'
|
||||
return '自定义词库'
|
||||
}
|
||||
|
||||
function confirmPlan() {
|
||||
preferences.plan.locked = true
|
||||
savePreferences(preferences)
|
||||
ElMessage.success('学习计划已保存,后续请在设置页修改')
|
||||
}
|
||||
|
||||
function shuffle<T>(arr: T[]) {
|
||||
const copied = arr.slice()
|
||||
for (let i = copied.length - 1; i > 0; i -= 1) {
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
;[copied[i], copied[j]] = [copied[j], copied[i]]
|
||||
}
|
||||
return copied
|
||||
}
|
||||
|
||||
function normalizeOption(option: string) {
|
||||
return option.trim().replace(/\s+/g, ' ')
|
||||
}
|
||||
|
||||
function fallbackOptions(correctAnswer: string, kind: StudyPhase['kind']) {
|
||||
if (kind === 'en2cn') {
|
||||
const seed = correctAnswer.split(/[;|,,、]/).map((s) => s.trim()).find(Boolean) || '该词义'
|
||||
return [
|
||||
`与“${seed}”相近`,
|
||||
`与“${seed}”相反`,
|
||||
`“${seed}”的动作`,
|
||||
`“${seed}”的状态`,
|
||||
`“${seed}”的程度`
|
||||
]
|
||||
}
|
||||
|
||||
const word = correctAnswer.toLowerCase()
|
||||
const out: string[] = []
|
||||
if (word.length > 1) {
|
||||
out.push(word[1] + word[0] + word.slice(2))
|
||||
}
|
||||
if (word.length > 3) {
|
||||
out.push(word.slice(0, word.length - 1))
|
||||
}
|
||||
out.push(`${word}s`)
|
||||
out.push(`${word}ed`)
|
||||
out.push(`${word}ly`)
|
||||
return out
|
||||
}
|
||||
|
||||
function buildFourOptions(correctAnswer: string, pool: string[], kind: StudyPhase['kind']) {
|
||||
const unique: string[] = []
|
||||
const pushUnique = (value: string) => {
|
||||
const normalized = normalizeOption(value)
|
||||
if (!normalized) return
|
||||
if (unique.includes(normalized)) return
|
||||
unique.push(normalized)
|
||||
}
|
||||
|
||||
pushUnique(correctAnswer)
|
||||
for (const item of shuffle(pool)) {
|
||||
pushUnique(item)
|
||||
if (unique.length >= 4) break
|
||||
}
|
||||
|
||||
if (unique.length < 4) {
|
||||
for (const item of fallbackOptions(correctAnswer, kind)) {
|
||||
pushUnique(item)
|
||||
if (unique.length >= 4) break
|
||||
}
|
||||
}
|
||||
|
||||
while (unique.length < 4) {
|
||||
pushUnique(`${correctAnswer}-选项${unique.length + 1}`)
|
||||
}
|
||||
|
||||
return shuffle(unique.slice(0, 4))
|
||||
}
|
||||
|
||||
function poolByPhase() {
|
||||
if (currentPhase.value.kind === 'en2cn') {
|
||||
return Array.from(new Set(sessionWords.value.map((w) => (w.definition || '').trim()).filter(Boolean)))
|
||||
}
|
||||
return Array.from(new Set(sessionWords.value.map((w) => (w.word || '').trim()).filter(Boolean)))
|
||||
}
|
||||
|
||||
function setupQuestion() {
|
||||
if (!currentWord.value || learningDone.value) return
|
||||
lastResult.value = null
|
||||
spellingInput.value = ''
|
||||
|
||||
if (currentPhase.value.kind === 'spelling') {
|
||||
options.value = []
|
||||
playAudio()
|
||||
phaseStartedAt.value = Date.now()
|
||||
return
|
||||
}
|
||||
|
||||
const correctAnswer = currentPhase.value.kind === 'en2cn'
|
||||
? currentWord.value.definition!.trim()
|
||||
: currentWord.value.word
|
||||
const pool = poolByPhase().filter((x) => normalizeOption(x) !== normalizeOption(correctAnswer))
|
||||
options.value = buildFourOptions(correctAnswer, pool, currentPhase.value.kind)
|
||||
|
||||
if (currentPhase.value.kind === 'audio') {
|
||||
playAudio()
|
||||
}
|
||||
phaseStartedAt.value = Date.now()
|
||||
}
|
||||
|
||||
function advance() {
|
||||
currentWordIndex.value += 1
|
||||
if (currentWordIndex.value >= sessionWords.value.length) {
|
||||
currentWordIndex.value = 0
|
||||
phaseIndex.value += 1
|
||||
}
|
||||
|
||||
if (learningDone.value) {
|
||||
return
|
||||
}
|
||||
setupQuestion()
|
||||
}
|
||||
|
||||
async function submitAnswer(answer: string) {
|
||||
if (!currentWord.value || learningDone.value) return
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
saved.value = null
|
||||
try {
|
||||
const res = await addWord(w)
|
||||
saved.value = res.data ?? res
|
||||
word.value = ''
|
||||
const res = await submitStudyAnswer({ wordId: currentWord.value.id, answer, mode: currentPhase.value.mode })
|
||||
const result = (res as any).data ?? (res as any)
|
||||
lastResult.value = result
|
||||
markTodayLearnedWord(currentWord.value.id)
|
||||
|
||||
stats.total += 1
|
||||
if (result.correct) {
|
||||
stats.correct += 1
|
||||
stats.streak += 1
|
||||
} else {
|
||||
stats.streak = 0
|
||||
}
|
||||
stats.spentSec += Math.max(1, Math.round((Date.now() - phaseStartedAt.value) / 1000))
|
||||
|
||||
advance()
|
||||
} catch (e: any) {
|
||||
error.value = e?.response?.data?.error || e?.message || '请求失败'
|
||||
ElMessage.error(e?.response?.data?.error || '提交失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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 selectOption(option: string) {
|
||||
if (loading.value) return
|
||||
submitAnswer(option).catch(console.error)
|
||||
}
|
||||
|
||||
function nextSessionWord() {
|
||||
if (!sessionWords.value.length) return
|
||||
sessionIndex.value += 1
|
||||
if (sessionIndex.value >= sessionWords.value.length) {
|
||||
sessionWord.value = null
|
||||
function submitSpelling() {
|
||||
const answer = spellingInput.value.trim()
|
||||
if (!answer) {
|
||||
ElMessage.warning('请输入拼写')
|
||||
return
|
||||
}
|
||||
sessionWord.value = sessionWords.value[sessionIndex.value]
|
||||
sessionAnswer.value = ''
|
||||
sessionResult.value = null
|
||||
submitAnswer(answer).catch(console.error)
|
||||
}
|
||||
|
||||
async function submitSessionAnswer() {
|
||||
if (!sessionWord.value || !sessionAnswer.value.trim()) return
|
||||
sessionLoading.value = true
|
||||
function playAudio() {
|
||||
if (!currentWord.value?.word) return
|
||||
const type = preferences.pronunciation
|
||||
const audio = new Audio(audioUrl(currentWord.value.word, type))
|
||||
audio.play().catch(() => {})
|
||||
}
|
||||
|
||||
async function startLearning() {
|
||||
loading.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)
|
||||
const res = await createStudySession(preferences.plan.dailyTarget)
|
||||
const arr = (res as any).data ?? (res as any)
|
||||
const words = (Array.isArray(arr) ? arr : []) as Word[]
|
||||
const learnedIDs = new Set(getTodayLearnedWordIds())
|
||||
const eligible = words.filter((w) => {
|
||||
if (!(w.word || '').trim()) return false
|
||||
if (!(w.definition || '').trim()) return false
|
||||
return !learnedIDs.has(w.id)
|
||||
})
|
||||
sessionWords.value = eligible.slice(0, preferences.plan.dailyTarget)
|
||||
|
||||
if (!sessionWords.value.length) {
|
||||
ElMessage.warning('今天已记过的单词不会重复出现,请明天再学或先补充新词')
|
||||
return
|
||||
}
|
||||
if (sessionWords.value.length < preferences.plan.dailyTarget) {
|
||||
ElMessage.warning(`当前仅有 ${sessionWords.value.length} 个带释义单词,已按可用数量开始`)
|
||||
}
|
||||
|
||||
phaseIndex.value = 0
|
||||
currentWordIndex.value = 0
|
||||
stats.correct = 0
|
||||
stats.total = 0
|
||||
stats.streak = 0
|
||||
stats.spentSec = 0
|
||||
setupQuestion()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.error || '学习会话创建失败')
|
||||
} finally {
|
||||
sessionLoading.value = false
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function playUK() {
|
||||
if (!saved.value?.word) return
|
||||
const audio = new Audio(audioUrl(saved.value.word, 'uk'))
|
||||
audio.play().catch(() => {})
|
||||
function goReview() {
|
||||
router.push({ path: '/review', query: { limit: preferences.plan.dailyTarget } })
|
||||
}
|
||||
|
||||
function playUS() {
|
||||
if (!saved.value?.word) return
|
||||
const audio = new Audio(audioUrl(saved.value.word, 'us'))
|
||||
audio.play().catch(() => {})
|
||||
async function submitWord() {
|
||||
const value = word.value.trim()
|
||||
if (!value) return
|
||||
wordLoading.value = true
|
||||
wordError.value = ''
|
||||
saved.value = null
|
||||
try {
|
||||
const res = await addWord(value)
|
||||
saved.value = (res as any).data ?? (res as any)
|
||||
word.value = ''
|
||||
ElMessage.success('已入库')
|
||||
} catch (e: any) {
|
||||
wordError.value = e?.response?.data?.error || '入库失败'
|
||||
} finally {
|
||||
wordLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.wrap{padding:24px;}
|
||||
.h{display:flex; align-items:center; justify-content:space-between;}
|
||||
.title{font-size:22px; font-weight:700;}
|
||||
.sub{color:#666; margin-top:4px;}
|
||||
.memory-page { display: flex; flex-direction: column; gap: 16px; }
|
||||
.top-row { align-items: stretch; }
|
||||
.panel { border-radius: 16px; }
|
||||
.panel-title { font-size: 20px; font-weight: 700; }
|
||||
.panel-sub { margin-top: 4px; color: #667085; }
|
||||
.full { width: 100%; }
|
||||
.actions-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.action-buttons { display: flex; gap: 8px; }
|
||||
.progress-row { margin-bottom: 10px; display: flex; gap: 14px; align-items: center; color: #475467; font-size: 13px; }
|
||||
.metrics { margin: 12px 0 8px; }
|
||||
.metric { background: #f8faff; border: 1px solid #e7ecf9; border-radius: 10px; padding: 10px; }
|
||||
.metric span { font-size: 12px; color: #667085; display: block; }
|
||||
.metric strong { font-size: 20px; }
|
||||
.qa-block { margin-top: 14px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.question-title { font-size: 16px; font-weight: 700; }
|
||||
.qa-card { border-radius: 12px; }
|
||||
.q-main { font-size: 24px; font-weight: 800; }
|
||||
.q-sub { margin-top: 8px; color: #667085; }
|
||||
.audio-row { display: flex; gap: 10px; align-items: center; margin-bottom: 10px; }
|
||||
.options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin-top: 12px; }
|
||||
.option {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 52px;
|
||||
margin: 0;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.options :deep(.el-button + .el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
.spelling { display: flex; gap: 10px; margin-top: 8px; }
|
||||
@media (max-width: 900px) {
|
||||
.actions-head { flex-direction: column; align-items: flex-start; }
|
||||
.options { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -68,11 +68,15 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { audioUrl, getReviewWords, submitReview } from '../services/api'
|
||||
import { loadPreferences } from '../modules/preferences/store'
|
||||
|
||||
import type { MemoryRecord, ReviewMode, ReviewResult } from '../services/api'
|
||||
|
||||
const mode = ref<ReviewMode>('spelling')
|
||||
const route = useRoute()
|
||||
const pref = loadPreferences()
|
||||
const record = ref<MemoryRecord | null>(null)
|
||||
const answer = ref('')
|
||||
const loading = ref(false)
|
||||
@@ -89,7 +93,8 @@ async function loadOne() {
|
||||
result.value = null
|
||||
submitted.value = false
|
||||
answer.value = ''
|
||||
const res = await getReviewWords({ mode: mode.value, limit: 1 })
|
||||
const limit = Number(route.query.limit || 1) || 1
|
||||
const res = await getReviewWords({ mode: mode.value, limit })
|
||||
const arr = (res as any).data ?? (res as any)
|
||||
record.value = Array.isArray(arr) && arr.length ? (arr[0] as MemoryRecord) : null
|
||||
}
|
||||
@@ -100,7 +105,7 @@ function nextOne() {
|
||||
|
||||
function play() {
|
||||
if (!record.value?.word?.word) return
|
||||
const a = new Audio(audioUrl(record.value.word.word, 'uk'))
|
||||
const a = new Audio(audioUrl(record.value.word.word, pref.pronunciation))
|
||||
a.play()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +1,128 @@
|
||||
<template>
|
||||
<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 class="settings-page">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="head">
|
||||
<div class="title">学习设置</div>
|
||||
<el-button type="primary" @click="saveAll">保存</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div style="margin-top:16px">
|
||||
<el-button type="danger" plain @click="logout">退出登录</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="当前用户ID">
|
||||
<el-input :model-value="String(userId || '未登录')" disabled />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="读音偏好">
|
||||
<el-radio-group v-model="preferences.pronunciation">
|
||||
<el-radio label="uk">英音</el-radio>
|
||||
<el-radio label="us">美音</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider>学习计划(可重设)</el-divider>
|
||||
|
||||
<el-form-item label="词库">
|
||||
<el-select v-model="preferences.plan.wordBook" class="full">
|
||||
<el-option label="四级词库" value="cet4" />
|
||||
<el-option label="雅思词库" value="ielts" />
|
||||
<el-option label="托福词库" value="toefl" />
|
||||
<el-option label="自定义词库" value="custom" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="`每日新词目标:${preferences.plan.dailyTarget}`">
|
||||
<el-slider v-model="preferences.plan.dailyTarget" :min="5" :max="60" :step="5" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="提醒时间">
|
||||
<el-time-select
|
||||
v-model="preferences.plan.remindAt"
|
||||
class="full"
|
||||
start="06:00"
|
||||
end="23:00"
|
||||
step="00:30"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-button @click="resetPlan" plain>重设学习计划(下次进入学习页重新确认)</el-button>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card>
|
||||
<template #header><div class="title">个人信息</div></template>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="头像 URL">
|
||||
<el-input v-model="preferences.profile.avatar" placeholder="https://example.com/avatar.png" />
|
||||
</el-form-item>
|
||||
<el-form-item label="昵称">
|
||||
<el-input v-model="preferences.profile.nickname" placeholder="输入昵称" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card>
|
||||
<template #header><div class="title">修改密码</div></template>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="当前密码">
|
||||
<el-input v-model="passwordForm.oldPwd" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码">
|
||||
<el-input v-model="passwordForm.newPwd" type="password" show-password />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-button type="primary" @click="changePassword">修改密码</el-button>
|
||||
|
||||
<div class="actions">
|
||||
<el-button type="danger" plain @click="logout">退出登录</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { clearToken, getUserId } from '../modules/auth/auth'
|
||||
import { loadPreferences, savePreferences } from '../modules/preferences/store'
|
||||
|
||||
const router = useRouter()
|
||||
const userId = getUserId()
|
||||
const preferences = reactive(loadPreferences())
|
||||
const passwordForm = reactive({ oldPwd: '', newPwd: '' })
|
||||
|
||||
function saveAll() {
|
||||
savePreferences(preferences)
|
||||
ElMessage.success('设置已保存')
|
||||
}
|
||||
|
||||
function resetPlan() {
|
||||
preferences.plan.locked = false
|
||||
savePreferences(preferences)
|
||||
ElMessage.success('已重设学习计划,学习页将重新要求确认')
|
||||
}
|
||||
|
||||
function changePassword() {
|
||||
if (!passwordForm.oldPwd || !passwordForm.newPwd) {
|
||||
ElMessage.warning('请填写完整密码信息')
|
||||
return
|
||||
}
|
||||
passwordForm.oldPwd = ''
|
||||
passwordForm.newPwd = ''
|
||||
ElMessage.success('密码修改请求已提交(原型阶段)')
|
||||
}
|
||||
|
||||
function logout() {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-page { display: flex; flex-direction: column; gap: 16px; }
|
||||
.head { display: flex; justify-content: space-between; align-items: center; }
|
||||
.title { font-size: 20px; font-weight: 700; }
|
||||
.full { width: 100%; }
|
||||
.actions { margin-top: 14px; }
|
||||
</style>
|
||||
|
||||
@@ -1,45 +1,117 @@
|
||||
<template>
|
||||
<div class="wrap">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="h">
|
||||
<div class="title">统计</div>
|
||||
<el-button @click="refresh">刷新</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="h">
|
||||
<div class="title">学习统计</div>
|
||||
<el-button @click="refresh">刷新</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="6"><el-statistic title="总单词" :value="stats.total_words ?? 0" /></el-col>
|
||||
<el-col :span="6"><el-statistic title="已掌握" :value="stats.mastered_words ?? 0" /></el-col>
|
||||
<el-col :span="6"><el-statistic title="待复习" :value="stats.need_review ?? 0" /></el-col>
|
||||
<el-col :span="6"><el-statistic title="今日复习" :value="stats.today_reviewed ?? 0" /></el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
</div>
|
||||
<el-row :gutter="14">
|
||||
<el-col :xs="24" :md="12" :lg="6">
|
||||
<div class="card" @click="openDetail('learned', todayLearned)">
|
||||
<div class="label">今日已学</div>
|
||||
<div class="value">{{ todayLearned }}</div>
|
||||
<div class="tip">点击查看详情</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="24" :md="12" :lg="6">
|
||||
<div class="card" @click="openDetail('reviewed', stats.today_reviewed)">
|
||||
<div class="label">今日复习</div>
|
||||
<div class="value">{{ stats.today_reviewed }}</div>
|
||||
<div class="tip">点击查看详情</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="24" :md="12" :lg="6">
|
||||
<div class="card" @click="openDetail('mastered', stats.mastered_words)">
|
||||
<div class="label">已掌握</div>
|
||||
<div class="value">{{ stats.mastered_words }}</div>
|
||||
<div class="tip">点击查看详情</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="24" :md="12" :lg="6">
|
||||
<div class="card" @click="openDetail('total', stats.total_words)">
|
||||
<div class="label">总词汇</div>
|
||||
<div class="value">{{ stats.total_words }}</div>
|
||||
<div class="tip">点击查看详情</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getStatistics } from '../services/api'
|
||||
|
||||
import { getTodayLearned } from '../modules/preferences/store'
|
||||
import type { Stats } from '../services/api'
|
||||
|
||||
const stats = ref<Stats>({
|
||||
const router = useRouter()
|
||||
const todayLearned = ref(0)
|
||||
const stats = reactive<Stats>({
|
||||
total_words: 0,
|
||||
mastered_words: 0,
|
||||
need_review: 0,
|
||||
today_reviewed: 0
|
||||
})
|
||||
async function refresh() {
|
||||
const res = await getStatistics()
|
||||
stats.value = res.data ?? res
|
||||
|
||||
function openDetail(metric: 'learned' | 'reviewed' | 'mastered' | 'total', value: number) {
|
||||
router.push({ path: `/statistics/${metric}`, query: { value } })
|
||||
}
|
||||
|
||||
onMounted(() => refresh().catch(console.error))
|
||||
async function refresh() {
|
||||
todayLearned.value = getTodayLearned()
|
||||
const res = await getStatistics()
|
||||
const data = res.data ?? res
|
||||
Object.assign(stats, data)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refresh().catch(console.error)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.wrap{padding:24px;}
|
||||
.h{display:flex; align-items:center; justify-content:space-between;}
|
||||
.title{font-size:22px; font-weight:700;}
|
||||
.h {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: linear-gradient(165deg, #ffffff 0%, #f8faff 100%);
|
||||
border: 1px solid #e6ecf9;
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 22px rgba(24, 58, 172, 0.08);
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #667085;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.value {
|
||||
margin-top: 8px;
|
||||
font-size: 34px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.tip {
|
||||
margin-top: 8px;
|
||||
color: #2451d6;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
54
memora-web/src/views/StatisticsDetail.vue
Normal file
54
memora-web/src/views/StatisticsDetail.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="head">
|
||||
<div class="title">{{ config.title }}详情</div>
|
||||
<el-button @click="$router.push('/statistics')">返回统计</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-alert type="info" :title="config.desc" show-icon />
|
||||
|
||||
<el-table :data="rows" border style="margin-top: 12px">
|
||||
<el-table-column prop="date" label="日期" width="150" />
|
||||
<el-table-column prop="value" :label="config.title" />
|
||||
<el-table-column prop="note" label="说明" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const configs: Record<string, { title: string; desc: string }> = {
|
||||
learned: { title: '今日已学', desc: '展示今天新学单词累计情况' },
|
||||
reviewed: { title: '今日复习', desc: '展示今天复习答题完成情况' },
|
||||
mastered: { title: '已掌握', desc: '展示掌握程度达到阈值的单词数量变化' },
|
||||
total: { title: '总词汇', desc: '展示词库总量变化趋势' }
|
||||
}
|
||||
|
||||
const config = computed(() => configs[String(route.params.metric)] || configs.learned)
|
||||
|
||||
const rows = computed(() => {
|
||||
const value = Number(route.query.value || 0)
|
||||
const today = new Date()
|
||||
const date = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
|
||||
return [{ date, value, note: '当前统计值(原型阶段)' }]
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user