Anki-Go 开发计划
4
2026-01-21
Anki Web 开发计划
anki 依赖PC或者移动客户端,官方anki-web适用于deck分享下载。基于个人需求设计 anki-go,核心目标为实现基于浏览器的学习功能。
一、技术架构
前端 (React)
- React 18 + TypeScript
- 状态管理: Zustand / Redux Toolkit
- UI 组件: Ant Design / shadcn
- 富文本编辑: TipTap / Milkdown (Markdown)
- 构建工具: Vite
后端 (Golang)
- Web框架: Gin / Echo
- ORM: GORM
- 认证: JWT
- 配置: Viper
数据库 (PostgreSQL/MySQL)
- users: 用户表
- decks: 牌组表
- cards: 卡片表
- reviews: 复习记录表
二、功能模块
| 模块 | 功能点 | 优先级 |
|---|---|---|
| 用户系统 | 注册、登录、JWT认证 | P0 |
| 牌组管理 | 创建、编辑、删除、列表 | P0 |
| 卡片管理 | 创建、编辑、删除、批量导入 | P0 |
| 学习系统 | 卡片展示、翻转、评分、间隔重复算法 | P0 |
| 数据统计 | 学习进度、复习热力图、正确率 | P1 |
| 导入导出 | .apkg 文件导入、CSV导入 | P1 |
| 多媒体 | 图片上传、音频支持 | P2 |
| 分享功能 | 公开牌组、牌组市场 | P2 |
三、数据库设计
-- 用户表
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
email VARCHAR(128) NOT NULL UNIQUE,
password_hash VARCHAR(256) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 牌组表
CREATE TABLE decks (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
is_public BOOLEAN DEFAULT FALSE,
card_count INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 卡片表
CREATE TABLE cards (
id BIGSERIAL PRIMARY KEY,
deck_id BIGINT NOT NULL,
front TEXT NOT NULL, -- 正面内容(支持Markdown/HTML)
back TEXT NOT NULL, -- 反面内容
tags VARCHAR(255), -- 标签,逗号分隔
ease_factor DECIMAL(5,3) DEFAULT 2.500, -- 难度系数
interval_days INT DEFAULT 0, -- 当前间隔(天)
repetitions INT DEFAULT 0, -- 连续正确次数
next_review TIMESTAMP, -- 下次复习时间
last_review TIMESTAMP, -- 上次复习时间
status SMALLINT DEFAULT 0, -- 0:新卡片 1:学习中 2:已掌握
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 复习记录表
CREATE TABLE reviews (
id BIGSERIAL PRIMARY KEY,
card_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
quality SMALLINT NOT NULL, -- 评分 1:Again 2:Hard 3:Good 4:Easy
interval_days INT, -- 本次计算的间隔
ease_factor DECIMAL(5,3), -- 本次计算后的难度系数
review_duration INT, -- 复习耗时(秒)
reviewed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 索引
CREATE INDEX idx_decks_user_id ON decks(user_id);
CREATE INDEX idx_cards_deck_id ON cards(deck_id);
CREATE INDEX idx_cards_next_review ON cards(next_review);
CREATE INDEX idx_reviews_card_id ON reviews(card_id);
CREATE INDEX idx_reviews_user_id_date ON reviews(user_id, reviewed_at);
-- 注:级联删除通过应用层代码实现,不使用数据库外键约束
四、API 设计
4.1 用户模块
| 方法 | 路径 | 描述 |
|---|---|---|
| POST | /api/auth/register | 用户注册 |
| POST | /api/auth/login | 用户登录 |
| GET | /api/auth/profile | 获取用户信息 |
| PUT | /api/auth/profile | 更新用户信息 |
4.2 牌组模块
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/decks | 获取牌组列表 |
| POST | /api/decks | 创建牌组 |
| GET | /api/decks/:id | 获取牌组详情 |
| PUT | /api/decks/:id | 更新牌组 |
| DELETE | /api/decks/:id | 删除牌组 |
| GET | /api/decks/:id/stats | 获取牌组统计 |
4.3 卡片模块
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/decks/:id/cards | 获取牌组下的卡片 |
| POST | /api/decks/:id/cards | 创建卡片 |
| PUT | /api/cards/:id | 更新卡片 |
| DELETE | /api/cards/:id | 删除卡片 |
| POST | /api/decks/:id/cards/batch | 批量导入卡片 |
4.4 学习模块
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/study/:deckId/next | 获取下一张待复习卡片 |
| POST | /api/study/:cardId/review | 提交复习结果 |
| GET | /api/study/:deckId/summary | 获取学习会话统计 |
4.5 统计模块
| 方法 | 路径 | 描述 |
|---|---|---|
| GET | /api/stats/overview | 学习概览 |
| GET | /api/stats/heatmap | 复习热力图数据 |
| GET | /api/stats/forecast | 未来复习预测 |
五、开发排期
第一阶段:基础框架(3天)
| 天数 | 任务 | 产出 |
|---|---|---|
| Day 1 | 后端项目初始化、数据库设计、用户模块 | 注册/登录/JWT认证 |
| Day 2 | 牌组CRUD、卡片CRUD | 基础数据管理API |
| Day 3 | 前端项目初始化、路由、基础布局 | React框架搭建 |
第二阶段:核心功能(5天)
| 天数 | 任务 | 产出 |
|---|---|---|
| Day 4 | SM-2算法实现、学习API | 间隔重复核心逻辑 |
| Day 5 | 前端登录注册页面 | 用户认证界面 |
| Day 6 | 前端牌组管理页面 | 牌组列表/创建/编辑 |
| Day 7 | 前端卡片管理页面 | 卡片列表/创建/编辑 |
| Day 8 | 前端学习页面(核心) | 卡片翻转/评分交互 |
第三阶段:完善体验(4天)
| 天数 | 任务 | 产出 |
|---|---|---|
| Day 9 | 富文本/Markdown编辑器集成 | 卡片内容支持格式化 |
| Day 10 | 学习统计后端 + 前端图表 | 进度可视化 |
| Day 11 | 批量导入(CSV)、导出 | 数据导入导出 |
| Day 12 | UI美化、响应式适配、Bug修复 | 产品打磨 |
第四阶段:进阶功能(可选,5天)
| 天数 | 任务 | 产出 |
|---|---|---|
| Day 13-14 | .apkg 文件解析与导入 | Anki牌组兼容 |
| Day 15 | 图片上传、媒体支持 | 多媒体卡片 |
| Day 16 | 公开牌组、分享功能 | 社区功能 |
| Day 17 | 性能优化、部署文档 | 上线准备 |
总计
| 阶段 | 工时 | 累计 |
|---|---|---|
| 基础框架 | 3天 | 3天 |
| 核心功能 | 5天 | 8天 |
| 完善体验 | 4天 | 12天 |
| 进阶功能 | 5天 | 17天 |
MVP版本:12天(约2.5周)
完整版本:17天(约3.5周)
六、重点难点分析
难点1:SM-2 间隔重复算法
难度:⭐⭐
说明: 这是Anki的核心,决定了卡片复习的时机。
实现思路:
package algorithm
import (
"math"
"time"
)
// Quality 评分等级
const (
QualityAgain = 1 // 完全忘记
QualityHard = 2 // 困难
QualityGood = 3 // 正常
QualityEasy = 4 // 简单
)
// ReviewResult 复习结果
type ReviewResult struct {
NextReview time.Time
Interval int
EaseFactor float64
Repetitions int
}
// SM2 计算下次复习时间
func SM2(easeFactor float64, interval int, repetitions int, quality int) ReviewResult {
var newInterval int
var newReps int
var newEF float64
// 根据评分判断是否记住
if quality < QualityGood {
// 忘记了,重置
newInterval = 1
newReps = 0
newEF = easeFactor
} else {
// 记住了,计算新间隔
if repetitions == 0 {
newInterval = 1
} else if repetitions == 1 {
newInterval = 6
} else {
newInterval = int(math.Round(float64(interval) * easeFactor))
}
newReps = repetitions + 1
// 调整难度系数 (EF)
// EF' = EF + (0.1 - (5-q) * (0.08 + (5-q) * 0.02))
q := float64(quality + 1) // 转换为0-5标准
newEF = easeFactor + (0.1 - (5-q)*(0.08+(5-q)*0.02))
if newEF < 1.3 {
newEF = 1.3 // 最小值限制
}
}
// Easy 评分额外奖励
if quality == QualityEasy {
newInterval = int(float64(newInterval) * 1.3)
}
return ReviewResult{
NextReview: time.Now().AddDate(0, 0, newInterval),
Interval: newInterval,
EaseFactor: newEF,
Repetitions: newReps,
}
}
难点2:学习队列管理
难度:⭐⭐⭐
说明: 一次学习会话中,需要合理安排新卡片、复习卡片、重学卡片的顺序。
当前实现:
- ✅ 采用数据库查询方案,每次获取卡片时直接查库
- ✅ 优先级:到期复习 > 新卡片(通过 SQL ORDER BY 实现)
- ✅ 实现简单,无需额外依赖,适合初期开发
后续优化方案(可配置切换):
- 方案1(当前):直接查数据库 - 适合小规模应用
- 方案2(可选):Redis 内存队列 - 批量预取,减少数据库查询,适合高并发场景
理想实现思路(内存队列版本):
package study
import (
"context"
"time"
)
// CardQueue 学习队列
type CardQueue struct {
NewCards []*Card // 新卡片
ReviewCards []*Card // 待复习卡片
RelearningCards []*Card // 重学卡片(本次会话中答错的)
}
// StudySession 学习会话
type StudySession struct {
DeckID uint64
Queue *CardQueue
DailyNewLimit int // 每日新卡片上限,默认20
DailyReviewLimit int // 每日复习上限,默认200
NewCount int // 今日已学新卡片数
ReviewCount int // 今日已复习数
}
// GetNextCard 获取下一张卡片
// 优先级:重学卡片 > 复习卡片 > 新卡片
func (s *StudySession) GetNextCard() *Card {
// 1. 优先处理重学卡片
if len(s.Queue.RelearningCards) > 0 {
card := s.Queue.RelearningCards[0]
s.Queue.RelearningCards = s.Queue.RelearningCards[1:]
return card
}
// 2. 复习卡片
if len(s.Queue.ReviewCards) > 0 && s.ReviewCount < s.DailyReviewLimit {
card := s.Queue.ReviewCards[0]
s.Queue.ReviewCards = s.Queue.ReviewCards[1:]
return card
}
// 3. 新卡片
if len(s.Queue.NewCards) > 0 && s.NewCount < s.DailyNewLimit {
card := s.Queue.NewCards[0]
s.Queue.NewCards = s.Queue.NewCards[1:]
s.NewCount++
return card
}
return nil // 学习完成
}
// OnReview 处理复习结果
func (s *StudySession) OnReview(card *Card, quality int) {
if quality < QualityGood {
// 答错,加入重学队列
s.Queue.RelearningCards = append(s.Queue.RelearningCards, card)
}
s.ReviewCount++
}
// LoadQueue 加载学习队列
func LoadQueue(ctx context.Context, deckID uint64) (*CardQueue, error) {
queue := &CardQueue{}
// 查询待复习卡片(next_review <= now)
// SELECT * FROM cards WHERE deck_id = ? AND next_review <= NOW() ORDER BY next_review
// 查询新卡片(status = 0)
// SELECT * FROM cards WHERE deck_id = ? AND status = 0 LIMIT ?
return queue, nil
}
难点3:.apkg 文件解析
难度:⭐⭐⭐⭐
说明: .apkg 是 Anki 的专有格式,本质是 zip 包含 SQLite 数据库。
实现思路:
package importer
import (
"archive/zip"
"database/sql"
"io"
"os"
"path/filepath"
_ "github.com/mattn/go-sqlite3"
)
// ApkgImporter Anki牌组导入器
type ApkgImporter struct {
tempDir string
}
// Import 导入 .apkg 文件
func (a *ApkgImporter) Import(apkgPath string) (*ImportResult, error) {
// 1. 解压 .apkg (本质是zip)
tempDir, err := os.MkdirTemp("", "anki_import_")
if err != nil {
return nil, err
}
defer os.RemoveAll(tempDir)
if err := unzip(apkgPath, tempDir); err != nil {
return nil, err
}
// 2. 打开 SQLite 数据库 (collection.anki2)
dbPath := filepath.Join(tempDir, "collection.anki2")
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, err
}
defer db.Close()
// 3. 读取牌组信息 (col 表的 decks 字段,JSON格式)
// 4. 读取笔记 (notes 表)
// 5. 读取卡片 (cards 表)
// 6. 处理媒体文件 (media 文件是JSON映射)
return parseAnkiDB(db)
}
// Anki 数据库结构
// notes 表: id, guid, mid(模板id), mod, usn, tags, flds(字段,用\x1f分隔), sfld, csum, flags, data
// cards 表: id, nid(note id), did(deck id), ord, mod, usn, type, queue, due, ivl, factor, reps, lapses, left, odue, odid, flags, data
func parseAnkiDB(db *sql.DB) (*ImportResult, error) {
result := &ImportResult{}
// 读取笔记
rows, _ := db.Query("SELECT id, flds, tags FROM notes")
for rows.Next() {
var id int64
var flds, tags string
rows.Scan(&id, &flds, &tags)
// flds 字段用 \x1f (ASCII 31) 分隔
// 第一个字段通常是正面,第二个是反面
fields := strings.Split(flds, "\x1f")
if len(fields) >= 2 {
result.Cards = append(result.Cards, &ImportedCard{
Front: fields[0],
Back: fields[1],
Tags: tags,
})
}
}
return result, nil
}
难点4:前端卡片翻转交互
难度:⭐⭐
说明: 卡片翻转动画 + 键盘快捷键 + 触摸支持
实现思路:
// components/StudyCard.tsx
import React, { useState, useEffect } from 'react';
import './StudyCard.css';
interface StudyCardProps {
front: string;
back: string;
onRate: (quality: number) => void;
}
export const StudyCard: React.FC<StudyCardProps> = ({ front, back, onRate }) => {
const [flipped, setFlipped] = useState(false);
// 键盘快捷键
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!flipped) {
if (e.code === 'Space' || e.code === 'Enter') {
setFlipped(true);
}
} else {
switch (e.code) {
case 'Digit1': onRate(1); break; // Again
case 'Digit2': onRate(2); break; // Hard
case 'Digit3': onRate(3); break; // Good
case 'Digit4': onRate(4); break; // Easy
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [flipped, onRate]);
return (
<div className="study-container">
<div className={`card ${flipped ? 'flipped' : ''}`} onClick={() => !flipped && setFlipped(true)}>
<div className="card-face card-front">
<div dangerouslySetInnerHTML={{ __html: front }} />
</div>
<div className="card-face card-back">
<div dangerouslySetInnerHTML={{ __html: back }} />
</div>
</div>
{!flipped ? (
<button className="show-answer-btn" onClick={() => setFlipped(true)}>
显示答案 (Space)
</button>
) : (
<div className="rating-buttons">
<button onClick={() => onRate(1)} className="btn-again">
重来 (1)<br/><small><1分钟</small>
</button>
<button onClick={() => onRate(2)} className="btn-hard">
困难 (2)<br/><small><6分钟</small>
</button>
<button onClick={() => onRate(3)} className="btn-good">
良好 (3)<br/><small>10分钟</small>
</button>
<button onClick={() => onRate(4)} className="btn-easy">
简单 (4)<br/><small>4天</small>
</button>
</div>
)}
</div>
);
};
/* StudyCard.css */
.card {
width: 100%;
max-width: 600px;
height: 400px;
perspective: 1000px;
cursor: pointer;
}
.card-face {
position: absolute;
width: 100%;
height: 100%;
backface-visibility: hidden;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
transition: transform 0.6s;
}
.card-front {
background: white;
}
.card-back {
background: #f8f9fa;
transform: rotateY(180deg);
}
.card.flipped .card-front {
transform: rotateY(180deg);
}
.card.flipped .card-back {
transform: rotateY(0deg);
}
.rating-buttons {
display: flex;
gap: 10px;
margin-top: 20px;
}
.rating-buttons button {
flex: 1;
padding: 12px;
border: none;
border-radius: 8px;
cursor: pointer;
}
.btn-again { background: #dc3545; color: white; }
.btn-hard { background: #fd7e14; color: white; }
.btn-good { background: #28a745; color: white; }
.btn-easy { background: #007bff; color: white; }
七、项目目录结构
anki-web/
├── backend/ # Go 后端
│ ├── cmd/
│ │ └── server/
│ │ └── main.go
│ ├── internal/
│ │ ├── config/ # 配置
│ │ ├── handler/ # HTTP handlers
│ │ ├── middleware/ # JWT等中间件
│ │ ├── model/ # 数据模型
│ │ ├── repository/ # 数据库操作
│ │ ├── service/ # 业务逻辑
│ │ │ ├── algorithm/ # SM-2算法
│ │ │ ├── study/ # 学习逻辑
│ │ │ └── importer/ # 导入功能
│ │ └── router/ # 路由
│ ├── pkg/ # 公共包
│ ├── go.mod
│ └── go.sum
│
├── frontend/ # React 前端
│ ├── src/
│ │ ├── components/ # 组件
│ │ │ ├── StudyCard/
│ │ │ ├── DeckList/
│ │ │ └── CardEditor/
│ │ ├── pages/ # 页面
│ │ │ ├── Login/
│ │ │ ├── Decks/
│ │ │ ├── Study/
│ │ │ └── Stats/
│ │ ├── services/ # API调用
│ │ ├── stores/ # 状态管理
│ │ ├── hooks/ # 自定义hooks
│ │ ├── App.tsx
│ │ └── main.tsx
│ ├── package.json
│ └── vite.config.ts
│
├── docs/ # 文档
│ └── api.md
├── docker-compose.yml
└── README.md
八、里程碑
| 里程碑 | 完成标志 | 预计时间 |
|---|---|---|
| M1: 可运行 | 能登录、创建牌组、添加卡片 | Day 5 |
| M2: 可学习 | 能进行完整的学习流程(翻卡、评分、算法生效) | Day 8 |
| M3: 可使用 | UI完善、统计功能、导入导出 | Day 12 |
| M4: 可发布 | .apkg支持、性能优化、部署完成 | Day 17 |
文档生成时间:2026-01-20
预计开发周期:12-17天