refactor(web): restructure Vue3 app layout
This commit is contained in:
53
README.md
Normal file
53
README.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Memora
|
||||||
|
|
||||||
|
一个背单词应用(Web + Go API + 记忆引擎)。
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- `memora-web`:前端(Vue3 + Vite + Element Plus)
|
||||||
|
- `memora-api`:后端(Go + Gin + GORM + MySQL)
|
||||||
|
- `memora-engine`:记忆引擎(后续可抽成独立服务/库)
|
||||||
|
|
||||||
|
## 快速启动(推荐 Docker)
|
||||||
|
|
||||||
|
> 由于当前机器可能未安装 Go,本项目提供 Docker 方式启动。
|
||||||
|
|
||||||
|
1. 进入项目根目录:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/wsy182/Documents/code/memora
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 复制并编辑后端配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp memora-api/config.yaml memora-api/config.local.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
- API: http://localhost:8080
|
||||||
|
- Web: http://localhost:3000
|
||||||
|
|
||||||
|
## 本地启动(不使用 Docker)
|
||||||
|
|
||||||
|
### 启动 API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd memora-api
|
||||||
|
# 确保已安装 Go 1.21+
|
||||||
|
go mod tidy
|
||||||
|
go run main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
### 启动 Web
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd memora-web
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
33
docker-compose.yml
Normal file
33
docker-compose.yml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.0
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: root
|
||||||
|
MYSQL_DATABASE: memora
|
||||||
|
ports:
|
||||||
|
- "3306:3306"
|
||||||
|
command: ["--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci"]
|
||||||
|
volumes:
|
||||||
|
- memora_mysql:/var/lib/mysql
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ./memora-api
|
||||||
|
depends_on:
|
||||||
|
- mysql
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./memora-api/audio:/app/audio
|
||||||
|
- ./memora-api/config.yaml:/app/config.yaml:ro
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: ./memora-web
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
memora_mysql:
|
||||||
15
memora-api/Dockerfile
Normal file
15
memora-api/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
FROM golang:1.21-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
COPY go.mod ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN go build -o /out/memora-api ./main.go
|
||||||
|
|
||||||
|
FROM alpine:3.19
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /out/memora-api /app/memora-api
|
||||||
|
COPY config.yaml /app/config.yaml
|
||||||
|
RUN mkdir -p /app/audio
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["/app/memora-api"]
|
||||||
18
memora-api/config.yaml
Normal file
18
memora-api/config.yaml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
server:
|
||||||
|
host: 0.0.0.0
|
||||||
|
port: 8080
|
||||||
|
|
||||||
|
database:
|
||||||
|
host: localhost
|
||||||
|
port: 3306
|
||||||
|
user: root
|
||||||
|
password: root
|
||||||
|
name: memora
|
||||||
|
charset: utf8mb4
|
||||||
|
|
||||||
|
youdao:
|
||||||
|
app_id: "29a8d1df97e9e709"
|
||||||
|
app_secret: "o3bC1llh9XQoVPuWuE3fbbqYU58zpTTn"
|
||||||
|
|
||||||
|
audio:
|
||||||
|
path: ./audio
|
||||||
39
memora-api/go.mod
Normal file
39
memora-api/go.mod
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
module memora-api
|
||||||
|
|
||||||
|
go 1.21
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-gonic/gin v1.9.1
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
gorm.io/driver/mysql v1.5.2
|
||||||
|
gorm.io/gorm v1.25.5
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
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
|
||||||
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
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/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||||
|
github.com/leodido/go-urn v1.2.4 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||||
|
golang.org/x/arch v0.3.0 // indirect
|
||||||
|
golang.org/x/crypto v0.9.0 // indirect
|
||||||
|
golang.org/x/net v0.10.0 // indirect
|
||||||
|
golang.org/x/sys v0.8.0 // indirect
|
||||||
|
golang.org/x/text v0.9.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.30.0 // indirect
|
||||||
|
)
|
||||||
61
memora-api/internal/config/config.go
Normal file
61
memora-api/internal/config/config.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Server ServerConfig `yaml:"server"`
|
||||||
|
Database DatabaseConfig `yaml:"database"`
|
||||||
|
Youdao YoudaoConfig `yaml:"youdao"`
|
||||||
|
Audio AudioConfig `yaml:"audio"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerConfig struct {
|
||||||
|
Host string `yaml:"host"`
|
||||||
|
Port int `yaml:"port"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DatabaseConfig struct {
|
||||||
|
Host string `yaml:"host"`
|
||||||
|
Port int `yaml:"port"`
|
||||||
|
User string `yaml:"user"`
|
||||||
|
Password string `yaml:"password"`
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Charset string `yaml:"charset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type YoudaoConfig struct {
|
||||||
|
AppID string `yaml:"app_id"`
|
||||||
|
AppKey string `yaml:"app_key"` // 兼容旧字段
|
||||||
|
AppSecret string `yaml:"app_secret"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AudioConfig struct {
|
||||||
|
Path string `yaml:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DatabaseConfig) DSN() string {
|
||||||
|
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=True&loc=Local",
|
||||||
|
d.User, d.Password, d.Host, d.Port, d.Name, d.Charset)
|
||||||
|
}
|
||||||
|
|
||||||
|
var AppConfig *Config
|
||||||
|
|
||||||
|
func LoadConfig(path string) (*Config, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg Config
|
||||||
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
AppConfig = &cfg
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
140
memora-api/internal/handler/word.go
Normal file
140
memora-api/internal/handler/word.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"memora-api/internal/model"
|
||||||
|
"memora-api/internal/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WordHandler struct {
|
||||||
|
wordService *service.WordService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWordHandler(wordService *service.WordService) *WordHandler {
|
||||||
|
return &WordHandler{wordService: wordService}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加单词(记忆模式)
|
||||||
|
func (h *WordHandler) AddWord(c *gin.Context) {
|
||||||
|
var req model.AddWordRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用有道API查询
|
||||||
|
youdaoResp, err := h.wordService.QueryWord(req.Word)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if youdaoResp.ErrorCode != "0" && youdaoResp.ErrorCode != "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("有道API错误: %s", youdaoResp.ErrorCode)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存到数据库
|
||||||
|
word, err := h.wordService.SaveWord(req.Word, youdaoResp)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "单词添加成功",
|
||||||
|
"data": word,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取复习单词列表
|
||||||
|
func (h *WordHandler) GetReviewWords(c *gin.Context) {
|
||||||
|
mode := c.DefaultQuery("mode", "spelling")
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||||
|
|
||||||
|
words, err := h.wordService.GetReviewWords(mode, limit)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": words})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交复习答案
|
||||||
|
func (h *WordHandler) SubmitReview(c *gin.Context) {
|
||||||
|
var req model.ReviewAnswerRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.wordService.SubmitReviewAnswer(req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": result})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有单词
|
||||||
|
func (h *WordHandler) GetWords(c *gin.Context) {
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||||
|
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||||
|
|
||||||
|
words, total, err := h.wordService.GetAllWords(limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"data": words,
|
||||||
|
"total": total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取统计信息
|
||||||
|
func (h *WordHandler) GetStatistics(c *gin.Context) {
|
||||||
|
stats, err := h.wordService.GetStatistics()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": stats})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取音频文件
|
||||||
|
func (h *WordHandler) GetAudio(c *gin.Context) {
|
||||||
|
word := c.Query("word")
|
||||||
|
audioType := c.DefaultQuery("type", "uk")
|
||||||
|
|
||||||
|
if word == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少word参数"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// very small sanitization
|
||||||
|
word = strings.TrimSpace(word)
|
||||||
|
if word == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "word为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, r := range word {
|
||||||
|
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z') {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "word包含非法字符"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filename := word + "_" + audioType + ".mp3"
|
||||||
|
path := "./audio/" + filename
|
||||||
|
c.File(path)
|
||||||
|
}
|
||||||
86
memora-api/internal/model/model.go
Normal file
86
memora-api/internal/model/model.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Word struct {
|
||||||
|
ID int64 `json:"id" gorm:"primaryKey"`
|
||||||
|
Word string `json:"word" gorm:"size:100;uniqueIndex;not null"`
|
||||||
|
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"`
|
||||||
|
PartOfSpeech string `json:"part_of_speech" gorm:"size:50"`
|
||||||
|
Definition string `json:"definition" gorm:"type:text"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Word) TableName() string {
|
||||||
|
return "words"
|
||||||
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
CorrectCount int `json:"correct_count" gorm:"default:0"`
|
||||||
|
TotalCount int `json:"total_count" gorm:"default:0"`
|
||||||
|
MasteryLevel int `json:"mastery_level" gorm:"default:0"`
|
||||||
|
LastReviewedAt *time.Time `json:"last_reviewed_at"`
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (MemoryRecord) TableName() string {
|
||||||
|
return "memory_records"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 请求响应结构
|
||||||
|
type AddWordRequest struct {
|
||||||
|
Word string `json:"word" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReviewAnswerRequest struct {
|
||||||
|
RecordID int64 `json:"record_id" binding:"required"`
|
||||||
|
Answer string `json:"answer" binding:"required"`
|
||||||
|
Mode string `json:"mode" binding:"required"` // spelling, en2cn, cn2en
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReviewResult struct {
|
||||||
|
Word *Word `json:"word"`
|
||||||
|
Correct bool `json:"correct"`
|
||||||
|
Answer string `json:"answer"`
|
||||||
|
CorrectAns string `json:"correct_ans,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有道API响应 (标准API)
|
||||||
|
type YoudaoResponse struct {
|
||||||
|
Query string `json:"query"`
|
||||||
|
Translation []string `json:"translation"`
|
||||||
|
Basic struct {
|
||||||
|
Phonetic string `json:"phonetic"`
|
||||||
|
UkPhonetic string `json:"uk-phonetic"`
|
||||||
|
UsPhonetic string `json:"us-phonetic"`
|
||||||
|
ExamType []string `json:"exam_type"`
|
||||||
|
Wfs []struct {
|
||||||
|
Wf struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"wf"`
|
||||||
|
Means []struct {
|
||||||
|
Mean struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"mean"`
|
||||||
|
} `json:"means"`
|
||||||
|
} `json:"wfs"`
|
||||||
|
} `json:"basic"`
|
||||||
|
SpeakUrl string `json:"speakUrl"`
|
||||||
|
SpeakFile string `json:"speakFile"`
|
||||||
|
Web []struct {
|
||||||
|
Value []string `json:"value"`
|
||||||
|
} `json:"web"`
|
||||||
|
ErrorCode string `json:"errorCode"`
|
||||||
|
}
|
||||||
354
memora-api/internal/service/word.go
Normal file
354
memora-api/internal/service/word.go
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"memora-api/internal/config"
|
||||||
|
"memora-api/internal/model"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WordService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWordService(db *gorm.DB) *WordService {
|
||||||
|
return &WordService{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询单词(调用有道API)
|
||||||
|
func (s *WordService) QueryWord(word string) (*model.YoudaoResponse, error) {
|
||||||
|
// 优先用 app_id,兼容 app_key
|
||||||
|
appID := config.AppConfig.Youdao.AppID
|
||||||
|
if appID == "" {
|
||||||
|
appID = config.AppConfig.Youdao.AppKey
|
||||||
|
}
|
||||||
|
appSecret := config.AppConfig.Youdao.AppSecret
|
||||||
|
|
||||||
|
// 未配置有道密钥时,先走 mock(保证流程可跑通)
|
||||||
|
if strings.TrimSpace(appID) == "" || strings.TrimSpace(appSecret) == "" {
|
||||||
|
return &model.YoudaoResponse{ErrorCode: "0"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有道 API 签名算法: sign = MD5(appKey + q + salt + appSecret)
|
||||||
|
salt := fmt.Sprintf("%d", time.Now().UnixMilli())
|
||||||
|
q := strings.ToLower(word)
|
||||||
|
rawStr := appID + q + salt + appSecret
|
||||||
|
hash := md5.Sum([]byte(rawStr))
|
||||||
|
sign := hex.EncodeToString(hash[:])
|
||||||
|
|
||||||
|
// 调用有道 API - 智能词典查询
|
||||||
|
url := fmt.Sprintf("https://openapi.youdao.com/api?q=%s&from=en&to=zh_CHS&appKey=%s&salt=%s&sign=%s",
|
||||||
|
word, appID, salt, sign)
|
||||||
|
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result model.YoudaoResponse
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug: 打印响应
|
||||||
|
fmt.Printf("[Youdao API] word=%s, errorCode=%s, translation=%v\n", word, result.ErrorCode, result.Translation)
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载音频文件
|
||||||
|
func (s *WordService) DownloadAudio(url, filePath string) error {
|
||||||
|
if url == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("download failed: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建目录
|
||||||
|
dir := filePath[:strings.LastIndex(filePath, "/")]
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Create(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(file, resp.Body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存单词到数据库
|
||||||
|
func (s *WordService) SaveWord(word string, youdaoResp *model.YoudaoResponse) (*model.Word, error) {
|
||||||
|
var existingWord model.Word
|
||||||
|
|
||||||
|
// 检查单词是否已存在
|
||||||
|
if err := s.db.Where("word = ?", word).First(&existingWord).Error; err == nil {
|
||||||
|
// 单词已存在,更新记忆记录
|
||||||
|
s.updateMemoryRecord(existingWord.ID, true)
|
||||||
|
return &existingWord, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析有道API响应
|
||||||
|
var phoneticUK, phoneticUS, partOfSpeech, definition string
|
||||||
|
|
||||||
|
// 解析音标
|
||||||
|
if youdaoResp.Basic.UkPhonetic != "" {
|
||||||
|
phoneticUK = "[" + youdaoResp.Basic.UkPhonetic + "]"
|
||||||
|
} else if youdaoResp.Basic.Phonetic != "" {
|
||||||
|
phoneticUK = "[" + youdaoResp.Basic.Phonetic + "]"
|
||||||
|
}
|
||||||
|
if youdaoResp.Basic.UsPhonetic != "" {
|
||||||
|
phoneticUS = "[" + youdaoResp.Basic.UsPhonetic + "]"
|
||||||
|
} else if youdaoResp.Basic.Phonetic != "" {
|
||||||
|
phoneticUS = "[" + youdaoResp.Basic.Phonetic + "]"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析释义 - 优先用 translation(更准确)
|
||||||
|
if len(youdaoResp.Translation) > 0 {
|
||||||
|
definition = strings.Join(youdaoResp.Translation, "; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果 basic 有 wfs,也加到释义里
|
||||||
|
if len(youdaoResp.Basic.Wfs) > 0 {
|
||||||
|
var meanings []string
|
||||||
|
for _, wf := range youdaoResp.Basic.Wfs {
|
||||||
|
if len(wf.Means) > 0 {
|
||||||
|
meanings = append(meanings, wf.Wf.Name+": "+wf.Means[0].Mean.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(meanings) > 0 {
|
||||||
|
if definition != "" {
|
||||||
|
definition += " | "
|
||||||
|
}
|
||||||
|
definition += strings.Join(meanings, "; ")
|
||||||
|
}
|
||||||
|
partOfSpeech = "basic"
|
||||||
|
}
|
||||||
|
if partOfSpeech == "" {
|
||||||
|
partOfSpeech = "n./v./adj."
|
||||||
|
}
|
||||||
|
|
||||||
|
// 音频 URL - 有道返回的 speakUrl 示例: https://dict.youdao.com/dictvoice?audio=hello
|
||||||
|
audioPath := config.AppConfig.Audio.Path
|
||||||
|
var audioUK, audioUS string
|
||||||
|
if youdaoResp.SpeakUrl != "" {
|
||||||
|
// 默认是美音,加 _uk 是英音
|
||||||
|
audioUK = strings.Replace(youdaoResp.SpeakUrl, "audio=", "audio=word_uk_", 1)
|
||||||
|
audioUS = youdaoResp.SpeakUrl
|
||||||
|
|
||||||
|
// 异步下载音频文件
|
||||||
|
go func() {
|
||||||
|
if audioPath != "" {
|
||||||
|
ukPath := fmt.Sprintf("%s/%s_uk.mp3", audioPath, word)
|
||||||
|
usPath := fmt.Sprintf("%s/%s_us.mp3", audioPath, word)
|
||||||
|
s.DownloadAudio(audioUK, ukPath)
|
||||||
|
s.DownloadAudio(audioUS, usPath)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
audioUK = fmt.Sprintf("%s/%s_uk.mp3", audioPath, word)
|
||||||
|
audioUS = fmt.Sprintf("%s/%s_us.mp3", audioPath, word)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新单词
|
||||||
|
newWord := model.Word{
|
||||||
|
Word: word,
|
||||||
|
PhoneticUK: phoneticUK,
|
||||||
|
PhoneticUS: phoneticUS,
|
||||||
|
AudioUK: audioUK,
|
||||||
|
AudioUS: audioUS,
|
||||||
|
PartOfSpeech: partOfSpeech,
|
||||||
|
Definition: definition,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.Create(&newWord).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建记忆记录 + 记一次"背过"
|
||||||
|
_ = s.createMemoryRecord(newWord.ID)
|
||||||
|
_ = s.updateMemoryRecord(newWord.ID, true)
|
||||||
|
|
||||||
|
return &newWord, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建记忆记录
|
||||||
|
func (s *WordService) createMemoryRecord(wordID int64) error {
|
||||||
|
record := model.MemoryRecord{
|
||||||
|
WordID: wordID,
|
||||||
|
UserID: 1,
|
||||||
|
CorrectCount: 0,
|
||||||
|
TotalCount: 0,
|
||||||
|
MasteryLevel: 0,
|
||||||
|
}
|
||||||
|
return s.db.Create(&record).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新记忆记录
|
||||||
|
func (s *WordService) updateMemoryRecord(wordID int64, correct bool) error {
|
||||||
|
var record model.MemoryRecord
|
||||||
|
if err := s.db.Where("word_id = ?", wordID).First(&record).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
record.TotalCount++
|
||||||
|
if correct {
|
||||||
|
record.CorrectCount++
|
||||||
|
// 计算掌握程度
|
||||||
|
record.MasteryLevel = (record.CorrectCount * 5) / (record.TotalCount + 1)
|
||||||
|
if record.MasteryLevel > 5 {
|
||||||
|
record.MasteryLevel = 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
record.LastReviewedAt = &now
|
||||||
|
// 根据掌握程度计算下次复习时间
|
||||||
|
reviewInterval := time.Hour * 24 * time.Duration(record.MasteryLevel+1)
|
||||||
|
nextReview := now.Add(reviewInterval)
|
||||||
|
record.NextReviewAt = &nextReview
|
||||||
|
|
||||||
|
return s.db.Save(&record).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取待复习单词
|
||||||
|
func (s *WordService) GetReviewWords(mode string, limit int) ([]model.MemoryRecord, error) {
|
||||||
|
var records []model.MemoryRecord
|
||||||
|
|
||||||
|
query := s.db.Preload("Word").Where("next_review_at <= ? OR next_review_at IS NULL", time.Now())
|
||||||
|
|
||||||
|
if err := query.Limit(limit).Find(&records).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有需要复习的,随机获取一些
|
||||||
|
if len(records) == 0 {
|
||||||
|
if err := s.db.Preload("Word").Limit(limit).Find(&records).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeText(s string) string {
|
||||||
|
s = strings.ToLower(strings.TrimSpace(s))
|
||||||
|
re := regexp.MustCompile(`[^\p{Han}a-z0-9]+`)
|
||||||
|
s = re.ReplaceAllString(s, "")
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsAny(def string, ans string) bool {
|
||||||
|
nDef := normalizeText(def)
|
||||||
|
nAns := normalizeText(ans)
|
||||||
|
if nDef == "" || nAns == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.Contains(nDef, nAns) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, token := range strings.Split(def, "|") {
|
||||||
|
if strings.Contains(normalizeText(token), nAns) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交复习答案
|
||||||
|
func (s *WordService) SubmitReviewAnswer(req model.ReviewAnswerRequest) (*model.ReviewResult, error) {
|
||||||
|
var record model.MemoryRecord
|
||||||
|
if err := s.db.Preload("Word").Where("id = ?", req.RecordID).First(&record).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
word := record.Word
|
||||||
|
correct := false
|
||||||
|
correctAns := ""
|
||||||
|
|
||||||
|
switch req.Mode {
|
||||||
|
case "spelling": // 读音拼单词
|
||||||
|
correct = normalizeText(req.Answer) == normalizeText(word.Word)
|
||||||
|
correctAns = word.Word
|
||||||
|
case "en2cn": // 英文写中文
|
||||||
|
correct = containsAny(word.Definition, req.Answer)
|
||||||
|
correctAns = word.Definition
|
||||||
|
case "cn2en": // 中文写英文
|
||||||
|
correct = normalizeText(req.Answer) == normalizeText(word.Word)
|
||||||
|
correctAns = word.Word
|
||||||
|
default:
|
||||||
|
correct = false
|
||||||
|
correctAns = "模式错误"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新记忆记录
|
||||||
|
s.updateMemoryRecord(record.WordID, correct)
|
||||||
|
|
||||||
|
return &model.ReviewResult{
|
||||||
|
Word: word,
|
||||||
|
Correct: correct,
|
||||||
|
Answer: req.Answer,
|
||||||
|
CorrectAns: correctAns,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有单词
|
||||||
|
func (s *WordService) GetAllWords(limit, offset int) ([]model.Word, int64, error) {
|
||||||
|
var words []model.Word
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
s.db.Model(&model.Word{}).Count(&total)
|
||||||
|
|
||||||
|
if err := s.db.Limit(limit).Offset(offset).Order("created_at DESC").Find(&words).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return words, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取记忆统计
|
||||||
|
func (s *WordService) GetStatistics() (map[string]interface{}, error) {
|
||||||
|
var totalWords int64
|
||||||
|
var masteredWords int64
|
||||||
|
var needReview int64
|
||||||
|
var todayReviewed int64
|
||||||
|
|
||||||
|
s.db.Model(&model.Word{}).Count(&totalWords)
|
||||||
|
s.db.Model(&model.MemoryRecord{}).Where("mastery_level >= 4").Count(&masteredWords)
|
||||||
|
s.db.Model(&model.MemoryRecord{}).Where("next_review_at <= ?", time.Now()).Count(&needReview)
|
||||||
|
s.db.Model(&model.MemoryRecord{}).Where("last_reviewed_at >= ?", time.Now().Format("2006-01-02")).Count(&todayReviewed)
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"total_words": totalWords,
|
||||||
|
"mastered_words": masteredWords,
|
||||||
|
"need_review": needReview,
|
||||||
|
"today_reviewed": todayReviewed,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
78
memora-api/main.go
Normal file
78
memora-api/main.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"memora-api/internal/config"
|
||||||
|
"memora-api/internal/handler"
|
||||||
|
"memora-api/internal/model"
|
||||||
|
"memora-api/internal/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// 加载配置
|
||||||
|
cfg, err := config.LoadConfig("config.yaml")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("加载配置失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 连接数据库
|
||||||
|
db, err := gorm.Open(mysql.Open(cfg.Database.DSN()), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("连接数据库失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动迁移表
|
||||||
|
if err := db.AutoMigrate(&model.Word{}, &model.MemoryRecord{}); err != nil {
|
||||||
|
log.Fatalf("数据库迁移失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化服务
|
||||||
|
wordService := service.NewWordService(db)
|
||||||
|
wordHandler := handler.NewWordHandler(wordService)
|
||||||
|
|
||||||
|
// 启动 Gin
|
||||||
|
r := gin.Default()
|
||||||
|
|
||||||
|
// CORS 中间件
|
||||||
|
r.Use(func(c *gin.Context) {
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||||
|
if c.Request.Method == "OPTIONS" {
|
||||||
|
c.AbortWithStatus(204)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 路由
|
||||||
|
api := r.Group("/api")
|
||||||
|
{
|
||||||
|
// 记忆模式
|
||||||
|
api.POST("/words", wordHandler.AddWord)
|
||||||
|
api.GET("/words", wordHandler.GetWords)
|
||||||
|
|
||||||
|
// 复习模式
|
||||||
|
api.GET("/review", wordHandler.GetReviewWords)
|
||||||
|
api.POST("/review", wordHandler.SubmitReview)
|
||||||
|
|
||||||
|
// 统计
|
||||||
|
api.GET("/stats", wordHandler.GetStatistics)
|
||||||
|
|
||||||
|
// 音频
|
||||||
|
api.GET("/audio", wordHandler.GetAudio)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动服务器
|
||||||
|
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
|
||||||
|
log.Printf("服务器启动在: %s", addr)
|
||||||
|
if err := r.Run(addr); err != nil {
|
||||||
|
log.Fatalf("启动服务器失败: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
36
memora-api/sql/init.sql
Normal file
36
memora-api/sql/init.sql
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
-- 初始化数据库
|
||||||
|
CREATE DATABASE IF NOT EXISTS memora DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
USE memora;
|
||||||
|
|
||||||
|
-- 标准单词表
|
||||||
|
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 '标准释义',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_word (word)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 记忆记录表
|
||||||
|
CREATE TABLE IF NOT EXISTS memory_records (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
word_id BIGINT NOT NULL COMMENT '关联words表',
|
||||||
|
user_id BIGINT DEFAULT 1 COMMENT '用户ID',
|
||||||
|
correct_count INT DEFAULT 0 COMMENT '正确次数',
|
||||||
|
total_count INT DEFAULT 0 COMMENT '总复习次数',
|
||||||
|
mastery_level INT DEFAULT 0 COMMENT '掌握程度 0-5',
|
||||||
|
last_reviewed_at TIMESTAMP NULL COMMENT '上次复习时间',
|
||||||
|
next_review_at TIMESTAMP NULL COMMENT '下次复习时间',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_word_id (word_id),
|
||||||
|
INDEX idx_user_id (user_id),
|
||||||
|
INDEX idx_next_review (next_review_at),
|
||||||
|
FOREIGN KEY (word_id) REFERENCES words(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
119
memora-engine/internal/engine/memory.go
Normal file
119
memora-engine/internal/engine/memory.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 记忆引擎 - 基于艾宾浩斯遗忘曲线
|
||||||
|
type MemoryEngine struct {
|
||||||
|
// 基础间隔(小时)
|
||||||
|
baseInterval float64
|
||||||
|
// 最大间隔(天)
|
||||||
|
maxInterval float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记忆级别
|
||||||
|
const (
|
||||||
|
Level0 = iota // 完全忘记
|
||||||
|
Level1
|
||||||
|
Level2
|
||||||
|
Level3
|
||||||
|
Level4
|
||||||
|
Level5 // 永久记住
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewMemoryEngine() *MemoryEngine {
|
||||||
|
return &MemoryEngine{
|
||||||
|
baseInterval: 1, // 1小时
|
||||||
|
maxInterval: 30 * 24, // 30天
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CalculateNextReview 计算下次复习时间
|
||||||
|
// correct: 回答是否正确
|
||||||
|
// currentLevel: 当前掌握程度 0-5
|
||||||
|
func (e *MemoryEngine) CalculateNextReview(correct bool, currentLevel int) time.Duration {
|
||||||
|
var interval float64
|
||||||
|
|
||||||
|
if !correct {
|
||||||
|
// 回答错误,重置到Level1,1小时后复习
|
||||||
|
interval = e.baseInterval
|
||||||
|
} else {
|
||||||
|
// 根据当前级别计算间隔
|
||||||
|
switch currentLevel {
|
||||||
|
case 0:
|
||||||
|
interval = 1 // 1小时后
|
||||||
|
case 1:
|
||||||
|
interval = 12 // 12小时后
|
||||||
|
case 2:
|
||||||
|
interval = 24 // 1天后
|
||||||
|
case 3:
|
||||||
|
interval = 72 // 3天后
|
||||||
|
case 4:
|
||||||
|
interval = 168 // 7天后
|
||||||
|
case 5:
|
||||||
|
interval = 360 // 15天后
|
||||||
|
default:
|
||||||
|
interval = e.baseInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
// 增加难度系数
|
||||||
|
interval = interval * 1.2
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制最大间隔
|
||||||
|
interval = math.Min(interval, e.maxInterval)
|
||||||
|
|
||||||
|
return time.Duration(interval) * time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMasteryLevel 计算掌握程度
|
||||||
|
// correctCount: 正确次数
|
||||||
|
// totalCount: 总次数
|
||||||
|
func (e *MemoryEngine) GetMasteryLevel(correctCount, totalCount int) int {
|
||||||
|
if totalCount == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正确率
|
||||||
|
rate := float64(correctCount) / float64(totalCount)
|
||||||
|
|
||||||
|
// 根据正确率和次数计算级别
|
||||||
|
if totalCount < 3 {
|
||||||
|
if rate >= 0.8 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if rate >= 0.9 {
|
||||||
|
return 5
|
||||||
|
} else if rate >= 0.8 {
|
||||||
|
return 4
|
||||||
|
} else if rate >= 0.7 {
|
||||||
|
return 3
|
||||||
|
} else if rate >= 0.5 {
|
||||||
|
return 2
|
||||||
|
} else {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDueForReview 是否应该复习
|
||||||
|
func (e *MemoryEngine) IsDueForReview(lastReview *time.Time, nextReview *time.Time) bool {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// 没有复习记录,需要复习
|
||||||
|
if lastReview == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有计划复习时间
|
||||||
|
if nextReview != nil {
|
||||||
|
return now.After(*nextReview)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认:超过1天没复习需要复习
|
||||||
|
return now.Sub(*lastReview) > 24*time.Hour
|
||||||
|
}
|
||||||
7
memora-web/Dockerfile
Normal file
7
memora-web/Dockerfile
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "3000"]
|
||||||
13
memora-web/index.html
Normal file
13
memora-web/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Memora 背单词</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uview-plus@3.2.0/index.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1745
memora-web/package-lock.json
generated
Normal file
1745
memora-web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
memora-web/package.json
Normal file
21
memora-web/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "memora-web",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.6.8",
|
||||||
|
"element-plus": "^2.7.2",
|
||||||
|
"vue": "^3.4.21",
|
||||||
|
"vue-router": "^4.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
|
"vite": "^5.1.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
65
memora-web/src/App.vue
Normal file
65
memora-web/src/App.vue
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<template>
|
||||||
|
<el-container class="layout">
|
||||||
|
<el-aside width="220px" class="sidebar">
|
||||||
|
<div class="logo">Memora</div>
|
||||||
|
<el-menu
|
||||||
|
:default-active="$route.path"
|
||||||
|
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="/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-main class="main">
|
||||||
|
<router-view />
|
||||||
|
</el-main>
|
||||||
|
</el-container>
|
||||||
|
</el-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
const router = useRouter()
|
||||||
|
const go = (path) => router.push(path)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.layout { min-height: 100vh; background: #f4f5fb; }
|
||||||
|
.sidebar {
|
||||||
|
background: #fff;
|
||||||
|
border-right: 1px solid #eceef3;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
height: 62px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 18px;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
.menu {
|
||||||
|
border-right: none;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
:deep(.el-menu-item.is-active) {
|
||||||
|
background: #2f7d32;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.topbar {
|
||||||
|
height: 62px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #eceef3;
|
||||||
|
}
|
||||||
|
.main {
|
||||||
|
padding: 24px 28px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
11
memora-web/src/app/index.js
Normal file
11
memora-web/src/app/index.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from '../App.vue'
|
||||||
|
import router from '../router'
|
||||||
|
import { registerPlugins } from './plugins'
|
||||||
|
|
||||||
|
export function bootstrap() {
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(router)
|
||||||
|
registerPlugins(app)
|
||||||
|
app.mount('#app')
|
||||||
|
}
|
||||||
6
memora-web/src/app/plugins.js
Normal file
6
memora-web/src/app/plugins.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
|
||||||
|
export function registerPlugins(app) {
|
||||||
|
app.use(ElementPlus)
|
||||||
|
}
|
||||||
15
memora-web/src/app/routes.js
Normal file
15
memora-web/src/app/routes.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
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 Words from '../views/Words.vue'
|
||||||
|
import Settings from '../views/Settings.vue'
|
||||||
|
|
||||||
|
export const routes = [
|
||||||
|
{ path: '/', name: 'dashboard', component: Dashboard },
|
||||||
|
{ path: '/memory', name: 'memory', component: Memory },
|
||||||
|
{ path: '/review', name: 'review', component: Review },
|
||||||
|
{ path: '/statistics', name: 'statistics', component: Statistics },
|
||||||
|
{ path: '/words', name: 'words', component: Words },
|
||||||
|
{ path: '/settings', name: 'settings', component: Settings }
|
||||||
|
]
|
||||||
3
memora-web/src/main.js
Normal file
3
memora-web/src/main.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { bootstrap } from './app'
|
||||||
|
|
||||||
|
bootstrap()
|
||||||
9
memora-web/src/router/index.js
Normal file
9
memora-web/src/router/index.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import { routes } from '../app/routes'
|
||||||
|
|
||||||
|
export const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
36
memora-web/src/services/api.js
Normal file
36
memora-web/src/services/api.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
export const api = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
timeout: 15000
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function addWord(word) {
|
||||||
|
const res = await api.post('/words', { word })
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWords({ limit = 20, offset = 0 } = {}) {
|
||||||
|
const res = await api.get('/words', { params: { limit, offset } })
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getReviewWords({ mode = 'spelling', limit = 10 } = {}) {
|
||||||
|
const res = await api.get('/review', { params: { mode, limit } })
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitReview({ recordId, answer, mode }) {
|
||||||
|
const res = await api.post('/review', { record_id: recordId, answer, mode })
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStatistics() {
|
||||||
|
const res = await api.get('/stats')
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export function audioUrl({ word, type = 'uk' }) {
|
||||||
|
const q = new URLSearchParams({ word, type })
|
||||||
|
return `/api/audio?${q.toString()}`
|
||||||
|
}
|
||||||
57
memora-web/src/views/Home.vue
Normal file
57
memora-web/src/views/Home.vue
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="title">仪表盘</div>
|
||||||
|
<div class="sub">欢迎回来,继续你的学习之旅</div>
|
||||||
|
|
||||||
|
<el-row :gutter="16" class="cards">
|
||||||
|
<el-col :span="6"><MetricCard label="今日复习数" :value="stats.today_reviewed ?? 0" /></el-col>
|
||||||
|
<el-col :span="6"><MetricCard label="待复习数" :value="stats.need_review ?? 0" /></el-col>
|
||||||
|
<el-col :span="6"><MetricCard label="已掌握" :value="stats.mastered_words ?? 0" /></el-col>
|
||||||
|
<el-col :span="6"><MetricCard label="总词汇" :value="stats.total_words ?? 0" /></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>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { defineComponent, h, onMounted, ref } from 'vue'
|
||||||
|
import { getStatistics } from '../services/api'
|
||||||
|
|
||||||
|
const stats = ref({})
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const res = await getStatistics()
|
||||||
|
stats.value = res.data ?? res
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => refresh().catch(console.error))
|
||||||
|
|
||||||
|
const MetricCard = defineComponent({
|
||||||
|
props: { label: String, value: Number },
|
||||||
|
setup(props) {
|
||||||
|
return () => h('div', { class: 'metric-card' }, [
|
||||||
|
h('div', { class: 'metric-label' }, props.label),
|
||||||
|
h('div', { class: 'metric-value' }, String(props.value ?? 0))
|
||||||
|
])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</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; }
|
||||||
|
.metric-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #eceef3;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
.metric-label { color: #6b7280; font-size: 14px; }
|
||||||
|
.metric-value { margin-top: 8px; font-size: 34px; font-weight: 700; color: #111827; }
|
||||||
|
</style>
|
||||||
75
memora-web/src/views/Memory.vue
Normal file
75
memora-web/src/views/Memory.vue
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<template>
|
||||||
|
<div class="wrap">
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<div class="h">
|
||||||
|
<div>
|
||||||
|
<div class="title">记忆模式</div>
|
||||||
|
<div class="sub">输入单词并回车,会调用后端(后端再调用有道)校验并入库</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-form @submit.prevent>
|
||||||
|
<el-form-item>
|
||||||
|
<el-input
|
||||||
|
v-model="word"
|
||||||
|
size="large"
|
||||||
|
placeholder="输入英文单词,如: example"
|
||||||
|
@keyup.enter="submit"
|
||||||
|
:disabled="loading"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="success" size="large" :loading="loading" @click="submit">确认</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-alert v-if="error" type="error" :title="error" 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>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { addWord } from '../services/api'
|
||||||
|
|
||||||
|
const word = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const saved = ref(null)
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
const w = word.value.trim()
|
||||||
|
if (!w) return
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
saved.value = null
|
||||||
|
try {
|
||||||
|
const res = await addWord(w)
|
||||||
|
// 后端建议返回 { data: Word }
|
||||||
|
saved.value = res.data ?? res
|
||||||
|
word.value = ''
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e?.response?.data?.error || e?.message || '请求失败'
|
||||||
|
} finally {
|
||||||
|
loading.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;}
|
||||||
|
</style>
|
||||||
99
memora-web/src/views/Review.vue
Normal file
99
memora-web/src/views/Review.vue
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<template>
|
||||||
|
<div class="wrap">
|
||||||
|
<el-card>
|
||||||
|
<template #header>
|
||||||
|
<div class="h">
|
||||||
|
<div class="title">复习模式</div>
|
||||||
|
<el-select v-model="mode" style="width: 220px" @change="loadOne">
|
||||||
|
<el-option label="听音拼写" value="spelling" />
|
||||||
|
<el-option label="英译中" value="en2cn" />
|
||||||
|
<el-option label="中译英" value="cn2en" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-empty v-if="!record" description="暂无可复习的单词" />
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<el-alert v-if="modeHint" type="info" :title="modeHint" show-icon style="margin-bottom:12px" />
|
||||||
|
|
||||||
|
<el-card shadow="never" style="margin-bottom:12px">
|
||||||
|
<template v-if="mode === 'spelling'">
|
||||||
|
<el-button @click="play">播放读音(uk)</el-button>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="mode === 'en2cn'">
|
||||||
|
<div class="q">{{ record.word.word }}</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="q">{{ record.word.definition }}</div>
|
||||||
|
</template>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-input v-model="answer" placeholder="输入答案并回车" @keyup.enter="submit" />
|
||||||
|
<div style="margin-top:12px; display:flex; gap:12px">
|
||||||
|
<el-button type="primary" :loading="loading" @click="submit">提交</el-button>
|
||||||
|
<el-button @click="loadOne" :disabled="loading">换一个</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-result v-if="result" :icon="result.correct ? 'success' : 'error'" :title="result.correct ? '正确' : '不对'" style="margin-top:16px">
|
||||||
|
<template #sub-title>
|
||||||
|
<div>单词:{{ result.word.word }} / 释义:{{ result.word.definition }}</div>
|
||||||
|
<div v-if="!result.correct">你的答案:{{ result.answer }};正确:{{ result.correct_ans }}</div>
|
||||||
|
</template>
|
||||||
|
</el-result>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { audioUrl, getReviewWords, submitReview } from '../services/api'
|
||||||
|
|
||||||
|
const mode = ref('spelling')
|
||||||
|
const record = ref(null) // MemoryRecord (含 word)
|
||||||
|
const answer = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const result = ref(null)
|
||||||
|
|
||||||
|
const modeHint = computed(() => {
|
||||||
|
if (mode.value === 'spelling') return '听读音,拼写单词'
|
||||||
|
if (mode.value === 'en2cn') return '看到英文,写中文意思(允许包含匹配)'
|
||||||
|
return '看到中文意思,写英文单词'
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadOne() {
|
||||||
|
result.value = null
|
||||||
|
answer.value = ''
|
||||||
|
const res = await getReviewWords({ mode: mode.value, limit: 1 })
|
||||||
|
const arr = res.data ?? res
|
||||||
|
record.value = Array.isArray(arr) && arr.length ? arr[0] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function play() {
|
||||||
|
if (!record.value?.word?.word) return
|
||||||
|
const a = new Audio(audioUrl({ word: record.value.word.word, type: 'uk' }))
|
||||||
|
a.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!record.value) return
|
||||||
|
if (!answer.value.trim()) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await submitReview({ recordId: record.value.id, answer: answer.value, mode: mode.value })
|
||||||
|
result.value = res.data ?? res
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadOne().catch(console.error)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.wrap{padding:24px;}
|
||||||
|
.h{display:flex; align-items:center; justify-content:space-between; gap:12px;}
|
||||||
|
.title{font-size:22px; font-weight:700;}
|
||||||
|
.q{font-size:24px; font-weight:700;}
|
||||||
|
</style>
|
||||||
13
memora-web/src/views/Settings.vue
Normal file
13
memora-web/src/views/Settings.vue
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<template>
|
||||||
|
<el-card>
|
||||||
|
<template #header>设置</template>
|
||||||
|
<el-descriptions :column="1" border>
|
||||||
|
<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>
|
||||||
|
</el-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
</script>
|
||||||
38
memora-web/src/views/Statistics.vue
Normal file
38
memora-web/src/views/Statistics.vue
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<template>
|
||||||
|
<div class="wrap">
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { getStatistics } from '../services/api'
|
||||||
|
|
||||||
|
const stats = ref({})
|
||||||
|
async function refresh() {
|
||||||
|
const res = await getStatistics()
|
||||||
|
stats.value = res.data ?? res
|
||||||
|
}
|
||||||
|
|
||||||
|
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;}
|
||||||
|
</style>
|
||||||
30
memora-web/src/views/Words.vue
Normal file
30
memora-web/src/views/Words.vue
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<template>
|
||||||
|
<el-card>
|
||||||
|
<template #header>单词列表</template>
|
||||||
|
<el-input v-model="kw" placeholder="搜索单词" style="max-width:280px;margin-bottom:12px" />
|
||||||
|
<el-table :data="filtered" border>
|
||||||
|
<el-table-column prop="word" label="单词" width="180" />
|
||||||
|
<el-table-column prop="part_of_speech" label="词性" width="120" />
|
||||||
|
<el-table-column prop="definition" label="释义" />
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { getWords } from '../services/api'
|
||||||
|
|
||||||
|
const kw = ref('')
|
||||||
|
const rows = ref([])
|
||||||
|
|
||||||
|
const filtered = computed(() => {
|
||||||
|
const q = kw.value.trim().toLowerCase()
|
||||||
|
if (!q) return rows.value
|
||||||
|
return rows.value.filter(r => (r.word || '').toLowerCase().includes(q))
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const res = await getWords({ limit: 200, offset: 0 })
|
||||||
|
rows.value = res.data || []
|
||||||
|
})
|
||||||
|
</script>
|
||||||
15
memora-web/vite.config.js
Normal file
15
memora-web/vite.config.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user