从零到可运行:基于 Vue3 + FastAPI + DeepSeek-V3 的 AI 英语单词学习系统全栈实战
一份真实、完整、可复现的全栈项目复盘。包含架构设计、数据库建模、AI 词库生成、前端交互、踩坑修复与实测数据。
目录
- 项目背景与目标
- 技术选型
- 项目结构
- 数据库设计
- [后端 API 设计](#后端 API 设计)
- 前端页面与交互
- [AI 词库生成:DeepSeek-V3 接入实战](#AI 词库生成:DeepSeek-V3 接入实战)
- 关键问题修复记录(踩坑实录)
- 运行与部署
- 实测数据
- 总结与后续优化方向
一、项目背景与目标
单词学习类 App 的最大痛点有两个:词库是死板的 (背来背去就那一本书)和 学习过程没有正反馈(不知道哪些词掌握了、哪些还没)。本项目尝试用全栈工程手段解决这两个问题:
- AI 动态词库 :接入大模型按难度(四级/六级/商务/托福/雅思)实时生成新词,生成的词自动累计入库并去重,词库越用越丰富;
- 学习闭环:随机出词 → 翻卡查看释义 → 标记熟练度(比较熟悉 75 / 完全掌握 100)→ 进度条与统计页实时反馈,形成"学-记-测-查"的完整闭环。
项目由用户基于 AI 导出的方案(郭震 AI 的英语单词学习系统方案)起步,经历了前端多处语法/构建错误修复、后端依赖冲突解决、AI 接口打通、词库去重策略设计等一系列工程问题,最终前后端成功运行、AI 生成功能可用。
二、技术选型
| 层级 | 技术 | 版本 | 用途 |
|---|---|---|---|
| 前端框架 | Vue 3 | ^3.5.40 | 响应式 UI |
| 构建工具 | Vite | ^8.2.0 | 开发服务器与构建 |
| 状态管理 | Pinia | ^2.3.1 | 全局学习状态(当前单词/难度/熟练度) |
| 路由 | Vue Router | ^4.5.0 | 学习/词库/统计三个页面 |
| 样式 | TailwindCSS | ^3.4.17 | 原子化 CSS,快速布局 |
| HTTP | Axios | ^1.7.9 | 前端调用后端 API |
| 后端框架 | FastAPI | 0.104.1 | 高性能异步 Web 框架 |
| ORM | SQLAlchemy | 2.0.23 | 数据库映射 |
| 数据库 | SQLite | 内置 | 零配置本地存储 |
| 数据校验 | Pydantic | 2.5.0 | 请求/响应模型校验 |
| AI 接入 | openai SDK + SiliconFlow | 3.x | 调用 DeepSeek-V3 生成单词 |
| ASGI 服务器 | Uvicorn | 0.24.0 | 后端运行 |
选型理由:前后端分离、接口清晰;Vue3 + Pinia 的 Composition API 适合中小型交互应用;FastAPI 自带 OpenAPI 文档便于调试;SQLite 免部署适合单机学习工具;AI 生成用统一 OpenAI 协议,通过 SiliconFlow 平台低成本接入 DeepSeek-V3。
三、项目结构
English-words-app/
├── backend/ # FastAPI 后端
│ ├── app/
│ │ ├── main.py # 应用入口 + 全部 API 路由
│ │ ├── database.py # SQLite 连接与 Session
│ │ ├── models.py # ORM 模型(words / learning_records)
│ │ ├── schemas.py # Pydantic 模型
│ │ ├── seeds.py # 五个难度各 12 个种子单词
│ │ └── services/
│ │ ├── ai_service.py # DeepSeek-V3 单词生成器(含备用词库)
│ │ └── word_service.py # 词库/随机取词/学习记录/统计业务逻辑
│ ├── requirements.txt
│ └── words.db # SQLite 数据库
└── frontend/ # Vue3 前端
├── index.html
├── vite.config.js
├── tailwind.config.js
├── postcss.config.js
└── src/
├── main.js
├── App.vue # 顶部导航 + 路由出口
├── style.css # Tailwind 指令入口
├── api/client.js # Axios 封装
├── stores/wordStore.js # Pinia 全局状态
└── components/
├── StudyView.vue # 学习页(核心)
├── VocabView.vue # 词库浏览页
└── StatsView.vue # 统计页
四、数据库设计
共两张表,设计上刻意保持简单:词库表负责"词是什么",学习记录表负责"你学得怎么样"。
4.1 words 词库表
| 字段 | 类型 | 说明 |
|---|---|---|
| id | Integer, PK | 主键 |
| word | String(100), unique, index | 单词本身,唯一约束是"累计去重"的数据库层保障 |
| phonetic | String(100) | 音标 |
| meaning | Text | 中文释义 |
| definition | Text | 英文定义 |
| example | Text | 英文例句 |
| example_cn | Text | 例句中文翻译 |
| difficulty | Enum(CET4/CET6/BEC/TOEFL/IELTS) | 所属难度 |
| pos | String(20) | 词性(noun/verb/adj/adv) |
| created_at | DateTime | 创建时间 |
4.2 learning_records 学习记录表
| 字段 | 类型 | 说明 |
|---|---|---|
| id | Integer, PK | 主键 |
| word_id | Integer | 关联单词(一对多:一个单词可有多次学习记录) |
| times_learned | Integer, default 0 | 学习次数 |
| last_learned | DateTime | 最近学习时间 |
| proficiency | Float, default 0 | 熟练度 0-100 |
| created_at | DateTime | 创建时间 |
为什么不需要重新设计数据库? 最初需求是"每次随机词库都要累计起来但是要去重",两张表天然满足:words.word 唯一索引 + 服务层插入前查重 = 词库累计去重;learning_records 按 word_id 独立记录熟练度 = 学习进度可追踪。后续所有功能迭代(优先未学词、排除刚看过的词、熟练度回传)都只改查询逻辑,不动表结构。
五、后端 API 设计
全部接口集中在 backend/app/main.py,CORS 已放开 5173/3000 两个开发端口。
| 方法 | 路径 | 功能 | 关键参数 |
|---|---|---|---|
| POST | /api/words/generate |
AI 生成词库(累计去重) | difficulty, count(1-100) |
| GET | /api/words |
获取指定难度词库列表 | difficulty, skip, limit |
| GET | /api/study/random |
获取随机学习单词 | difficulty, exclude_id(排除刚看过的) |
| POST | /api/study/record |
记录学习进度 | word_id, proficiency, mark_as_learned |
| GET | /api/stats |
学习统计(总词数/分难度词数) | - |
| GET | /api/health |
健康检查 | - |
5.1 核心接口:随机取词的三级优先策略
这是解决"单词总是那几个、不跟词库走"的关键逻辑:
python
@staticmethod
def get_random_word(db: Session, difficulty: DifficultyLevel, exclude_id: int = None) -> Word:
"""获取随机单词:优先未学过的词,其次未完全掌握的,最后兜底随机;可排除指定词避免连续重复"""
base = db.query(Word).filter(Word.difficulty == difficulty)
if exclude_id is not None:
base = base.filter(Word.id != exclude_id)
# 1. 从未学过(无学习记录)
unlearned = base.outerjoin(
LearningRecord, Word.id == LearningRecord.word_id
).filter(LearningRecord.id.is_(None)).all()
pool = unlearned
if not pool:
# 2. 学过但未完全掌握(proficiency < 100)
pool = base.outerjoin(
LearningRecord, Word.id == LearningRecord.word_id
).filter(
LearningRecord.id.isnot(None),
LearningRecord.proficiency < 100
).all()
if not pool:
# 3. 兜底:当前难度全部单词
pool = base.all()
if not pool:
return None
return random.choice(pool)
三层语义:先把没学过的词喂给你 → 再复习学得不熟的 → 全都掌握了才随机复习 。exclude_id 由前端传入当前单词 id,点"下一个"不会连续抽到同一词。
5.2 熟练度回传
WordResponse 增加 proficiency 字段,查询时左连学习记录取最新熟练度:
python
@staticmethod
def to_response_with_proficiency(db: Session, word: Word) -> dict:
"""将 Word 转为 WordResponse dict,并附加该词的学习熟练度"""
data = WordResponse.from_orm(word).__dict__
record = db.query(LearningRecord).filter(
LearningRecord.word_id == word.id
).first()
data["proficiency"] = record.proficiency if record else 0
return data
六、前端页面与交互
6.1 学习页(StudyView.vue)------ 核心交互

页面元素与交互逻辑:
| 元素 | 交互 | 实现 |
|---|---|---|
| 难度标签(四级/六级/商务/托福/雅思) | 点击切换难度并立即加载该难度单词 | setDifficulty() 内部调用 getRandomWord() |
| 单词卡片 | 显示单词/音标/词性;点击中文区翻转显示英文定义 | CSS 3D 翻转 |
| 熟练度进度条 | 实时反映当前词的熟练度(0/75/100) | 后端回传 proficiency |
| ⏭️ 下一个 | 随机换词,排除当前词 | exclude_id 参数 |
| 👍 比较熟悉 | 熟练度记为 75 并自动换下一个 | markAsLearned(75) |
| ✓ 完全掌握 | 熟练度记为 100 并自动换下一个 | markAsLearned(100) |
| 🤖 生成更多词库 | 调用 AI 生成 20 个新词(去重累计) | generateWords(20) |

AI 生成期间按钮进入 disabled 加载态;生成完成后随机展示一个(新生成或未学过的)单词:

6.2 词库页(VocabView.vue)

按难度切换标签,以卡片网格展示单词、音标、中文释义和英文例句,支持翻页拉取。
6.3 统计页(StatsView.vue)

展示五个难度的单词分布、总词库数与学习进度百分比(已学 / 总词库)。
6.4 全局状态(wordStore.js)
Pinia store 集中管理:currentWord、difficulty、showTranslation、learnedCount、loading、proficiencyPercentage 计算属性,以及 setDifficulty/getRandomWord/markAsLearned/generateWords/fetchStats 五个动作。切换难度的关键修复:
js
const setDifficulty = async (level) => {
difficulty.value = level
showTranslation.value = false
await getRandomWord() // 切换难度后立即加载该难度单词
}
七、AI 词库生成:DeepSeek-V3 接入实战
7.1 接入配置
通过 SiliconFlow 平台(https://api.siliconflow.cn/v1)调用 deepseek-ai/DeepSeek-V3 模型,环境变量配置在 backend/.env:
OPENAI_API_KEY=sk-xxx
OPENAI_MODEL=deepseek-ai/DeepSeek-V3
OPENAI_BASE_URL=https://api.siliconflow.cn/v1
7.2 生成逻辑(ai_service.py)
python
async def generate_word(self, difficulty: DifficultyLevel, db=None) -> dict:
# 1. 查询该难度已有单词,拼进 prompt 提示模型避开(去重的第一道防线)
existing = db.query(Word.word).filter(Word.difficulty == difficulty).all()
if existing:
exclude_words = "不要生成以下已存在的单词:" + "、".join([w[0] for w in existing][:80])
# 2. 构造 prompt,要求返回 JSON
prompt = f"""请生成一个{difficulty_prompts[difficulty]}的英语单词,返回JSON格式,包含:
{{ "word": "...", "phonetic": "...", "meaning": "...", "definition": "...",
"example": "...", "example_cn": "...", "pos": "..." }}
要求:
1. 返回格式必须是有效的JSON
4. 不要返回markdown格式,直接返回JSON
5. {exclude_words}"""
# 3. 调用 DeepSeek-V3
response = self.client.chat.completions.create(model=self.model, messages=[...], temperature=0.7)
# 4. 清理模型返回(可能用 ```json 代码块包裹),再 json.loads
content = response.choices[0].message.content.strip()
if content.startswith("```"):
content = content.strip("`")
if content.startswith("json"):
content = content[4:]
content = content.strip()
word_data = json.loads(content)
word_data["difficulty"] = difficulty
return word_data
三层去重防线:
- Prompt 层:把该难度已有单词列表告诉模型"不要生成这些";
- 应用层 :插入前再次按
word精确查重,存在则跳过; - 数据库层 :
words.word唯一索引兜底,即使并发也不会重复插入。
7.3 容错降级
- 未配置 API Key :
WordGenerator的 client 为None,服务照常启动,生成接口回退到内置备用词库; - AI 调用失败 :
_generate_fallback_word()从本地备用词列表随机返回一个,保证接口永不报错; - 返回格式异常 :先剥离 ```json 包裹再
json.loads,失败则走备用词库; - 超时 :openai client 设置
timeout=60。
八、关键问题修复记录(踩坑实录)
以下是本项目中真实遇到并解决的问题,按类型归类,供遇到同类坑的同学参考。
8.1 前端构建类
| 问题 | 根因 | 修复 |
|---|---|---|
| Vite build 失败 | App.vue 出现两个 <script setup> 块 + 残留 HelloWorld import |
合并为单个 script setup,删除残留 import |
| Vite build 失败 | StatsView.vue 有重复 script 块 |
合并去重 |
| Tailwind 样式不生效 | style.css 使用 @import 'tailwindcss/base' 非标准写法 |
改为标准 @tailwind base; @tailwind components; @tailwind utilities; 指令,并补齐 tailwind.config.js / postcss.config.js |
8.2 后端依赖与运行时类
| 问题 | 根因 | 修复 |
|---|---|---|
openai 调用报 proxies 参数错误 |
openai 1.3.5 与 httpx 版本不兼容 | 升级 openai 至 3.0.0 |
升级后报 SocketTimeoutError |
openai 3.x 与旧版 aiohttp 冲突 | 升级 aiohttp 至 3.14.3 |
| 学习记录报 None 错误 | times_learned 字段初始为 None,+= 1 崩溃 |
改为 (record.times_learned or 0) + 1 |
| 生成词失败 | 备用词库数据缺 difficulty 字段,触发 Pydantic 校验失败 |
备用词库补齐 difficulty |
| 端口被占用 | 旧进程未退出 | `lsof -ti:8000 |
8.3 功能逻辑类(本轮修复)
| 问题 | 根因 | 修复 |
|---|---|---|
| 点击四级/六级/商务标签,单词都一样 | setDifficulty 只改标签状态,没重新加载该难度单词 |
切换难度后自动 getRandomWord() |
| 词库"固定不变"、总抽到那几个词 | 随机取词不分学过与否,纯随机 | 三级优先策略:未学 → 未掌握 → 兜底 |
| "下一个"可能连续抽到同一词 | 随机无排除逻辑 | 接口新增 exclude_id,前端传当前词 id |
| 熟练度进度条一直 0% | WordResponse schema 根本没有 proficiency 字段,后端从不返回 |
schema 增加字段 + to_response_with_proficiency() 查询学习记录回传 |
| 找不到增加单词量的入口 | "生成词库"按钮只在无词时显示 | 学习页常驻"🤖 生成更多词库"按钮 |
| 生成接口重复词也报"成功生成" | 未区分新增与跳过 | 先查重再插入,返回真实新增数与跳过数 |
九、运行与部署
9.1 后端
bash
cd backend
python -m venv venv # 首次
source venv/bin/activate
pip install -r requirements.txt # 首次
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000
首次启动会自动建表;需要种子词时执行 python app/seeds.py。
9.2 前端
bash
cd frontend
npm install # 首次
npm run dev # 开发,默认 http://localhost:5173
npm run build # 生产构建
9.3 环境变量
backend/.env:
OPENAI_API_KEY=<你的 SiliconFlow Key>
OPENAI_MODEL=deepseek-ai/DeepSeek-V3
OPENAI_BASE_URL=https://api.siliconflow.cn/v1
不配置也能启动,AI 生成会走备用词库。
9.4 常见运维
bash
# 清理 8000 端口占用
lsof -ti:8000 | xargs kill -9
# 查看后端日志
tail -f backend/nohup.out # 按实际启动方式
十、实测数据
数据采集于 2026-08-13 10:11,真实运行环境。
| 指标 | 数值 |
|---|---|
| 总词库数 | 115 |
| CET4 | 58 |
| CET6 | 13 |
| BEC | 12 |
| TOEFL | 12 |
| IELTS | 20 |
| 学习记录数 | 47 |
| 已学单词数(去重) | 47 |
| AI 生成实测 | 一次点击从 103 增至 115(新增 12 个,均为去重后真实新增) |
| 随机取词 | 不同难度返回独立词库;exclude_id 生效,不连续重复 |
| 熟练度回传 | 已学词返回真实值(如 abandon=100);未学词返回 0 |
十一、总结与后续优化方向
收获
- 全栈闭环:一个需求从数据库设计到前端交互完整落地,Vue3 + FastAPI 的组合开发效率高、调试链路清晰;
- AI 接入没那么神秘 :OpenAI 协议 + 第三方平台(SiliconFlow)把模型调用简化成一次 HTTP 请求,难点在输出格式容错 和业务去重;
- "去重累计"是工程问题:单靠数据库唯一约束不够,要 prompt 提示 + 应用层查重 + 数据库约束三层配合;
- 看似简单的功能 bug 往往在数据链路断层:进度条 0% 不是前端问题,是后端 schema 根本没返回该字段------排查要顺着数据流从源头找。
可优化方向
- 间隔重复算法:引入 SM-2 算法,按熟练度与遗忘曲线安排复习节奏,已掌握的单词降权出现;
- 学习记录历史 :
learning_records增加时间维度查询,统计页展示熟练度分布趋势; - 批量生成优化:当前逐词调用大模型较慢(20 词约 30-60 秒),可改为一次 prompt 返回 5-10 个词批量入库,并加进度提示;
- 用户体系:接入登录后,学习记录按用户隔离,词库支持多人共享;
- 前端体验:单词朗读(Web Speech API)、答错重测、学习打卡等。