背景
女儿上幼儿园之前,我跟老婆说得很坚定:幼儿园阶段不卷,快乐教育。 结果话说得很轻松,真正到上学看到其他同学也报了,没忍住给她报了英语外教班。
上课本身倒没什么问题,真正让我头疼的是课后复习。 每次上完课,老师都会在群里发当天的学习内容,有单词、有句型、有字母操,还有课堂视频和家庭作业。比如:
📆【今日学习内容】 LG1
————————————
👩🏫教学目标
一、复习Student book—《Classroom》
1. 重点词汇+句型
window 🪟
door 🚪
desk 桌子
chair 🪑
book 📖
二、学习《Super ABC》P12 Letter Cc
❤️学习Letter Cc 字母操
❤️学习关于字母c的单词
1️⃣cat 猫🐱
2️⃣cap 帽子🧢
3️⃣cake 蛋糕🍰
4️⃣car 汽车🚗
❤️相关句型学习
三、课前口语对话
四、书写练习
————————————
🌞【家庭作业】:
1️⃣口语作业💬
朗读《Super ABC》P12关于字母c的单词
————————————
[Sun]【今日课堂小结】
今天课堂宝贝们都很活跃👏 请回家之后多听音频磨耳朵进行复习哟!❤️请各位宝贝一定要完成作业噢💕💕
或者:
📆【今日学习内容】
————————————
👩🏫教学目标
1.Free Talk:课前问答
2.复习字母A所学内容
3.❤️学习字母B相关内容
—字母操B的学习
—初步识别字母B的样子
—关于字母b的重点单词学习
1️⃣bee 蜜蜂🐝
2️⃣ball 球
3️⃣balloon 气球🎈
4️⃣bear 熊🐻
拓展词汇:box 📦 bike 🚴
—句型学习:B is for __
—句型理解性输入:
1.I see a bee. (我看见一只蜜蜂。)
2. I have a ball. (我有一个球。)
3. The balloon is big. (气球很大。)
4. The bear is brown. (熊是棕色的。)
4.书写练习
————————————
🌞【家庭作业】:
口语作业💬
听音频 朗读《Super ABC book》P8
————————————
[Sun]【今日课堂小结】
今天课堂宝贝们都很活跃👏 请回家之后多听音频磨耳朵进行复习哟!❤️请各位宝贝一定要完成作业噢💕💕
老师整理得其实已经很详细了,但它更像一份“课堂记录”,并不是一个适合 4 岁孩子反复练习的产品。
我最开始的复习方式很朴素:找到老师发的视频,点开,再拉回到要复习的位置,播一遍,停下来,跟读,再继续。
一两次还好,次数多了就发现效率很低。
孩子的注意力本来就短,家长还在群聊里翻视频、找进度条,她已经跑去玩别的了。有时候只是想复习 cat、cap、cake、car 四个单词,却要把整段课堂视频重新播一遍。
后来我又试着用欧路词典、百词斩之类的 App 录入生词,希望让它们自动轮播。
百词斩 自定义的单词库,学习时,内容太多不聚焦
欧陆词典 要么只有文字, 要么虽然还有视频,但是内容过于复杂
这些 App 本身都很好,但放到我的场景里,总差一点:
-
有些课堂单词没有合适的图例;
-
有些解释是写给成年人看的,对幼儿来说太复杂;
-
词典更关心音标、词性和释义,我只想让孩子看到一个清楚的画面;
-
学习路径是产品提前设计好的,很难完全贴合老师当天教的内容;
-
页面上的操作太多,孩子容易点到不需要的地方。
说到底,我要的并不是一套庞大的英语课程。
我只想把老师今天教的几个单词和句子,整理成一组能看、能听、能自动播放的复习卡片。
这个需求很小,小到以前我大概不会专门为它写一个 App。
但 AI 时代有一个很有意思的变化:以前这种“只服务一个家庭”的需求,投入产出比太低;现在我可以用 AI 快速把想法做成原型,再用自己的前端经验把它慢慢修成真正顺手的工具。
所以,之前给自己做完英语学习 App 之后,我又做了第二个版本。
这一次不是写给自己,而是写给女儿。
需求
我没有一上来就想做打卡、积分、排行榜,也没有打算造一个儿童版百词斩。
我先把需求压到最小。
老师每次发来的内容虽然很长,但真正需要反复复习的核心只有两类:
单词 + 句子
像“教学目标”“课堂小结”“书写练习”“请各位宝贝完成作业”这些内容,对家长有用,却不应该出现在孩子的复习卡片里。
因此,这个 App 最核心的流程被我整理成了:
老师发来课堂内容
↓
提取英文单词和句子
↓
补充中文、Emoji 或图片
↓
逐张播放并朗读
↓
记录当天复习进度
在这个核心流程之上,我又补了几个实际使用时绕不开的要求。
1. 内容必须按上课日期组织
孩子每节课学的内容不同。今天是 Letter B,下一节可能是 Letter C,再下一节又换成水果。
所以内容不能混成一个越来越长的单词本,而要按日期创建“复习日”。切换日期后,继续上次的复习进度。
2. 一张卡片只做一件事
每张卡片只显示一个字母、单词或句子,保留英文、中文、图片和发音。
孩子不用理解复杂操作,只要看当前内容、听发音,再点“下一个”。
3. 打开就能听,最好还能自动轮播
复习时不应该每个单词都让家长点一次播放。
页面切换到新卡片后自动朗读,也可以开启学习模式:朗读当前内容,停顿几秒,再自动跳到下一张。
4. 图片要简单、直观,而且允许替换
对幼儿来说,bear 对应一只熊,比一长串中文解释更有效。
系统自带 Emoji 是最省事的方案:不需要下载、不需要存储、加载也快。cat 用 🐱,bee 用 🐝,chair 用 🪑,大部分常见单词都能直接找到。
但 Emoji 不可能覆盖所有内容。有些单词没有对应图标,有些图标也不够准确,所以还要支持联网查图和本地上传。
5. 家里不同设备要看到同一份内容
如果数据只放在浏览器 localStorage,换一台手机就没有了。爸爸录好的内容,妈妈的手机也看不到。
因此它最终不能只是一个纯静态页面,还需要一个很轻量的云端存储。
实现
技术栈没有刻意求新,还是我最熟悉的一套:
Vue 3
Vite
TypeScript
Element Plus
Supabase
GitHub Pages
这类个人工具,技术选型最重要的不是“高级”,而是我能不能很快改、改完敢不敢继续用。
1. 把老师原文和生成内容分开
添加复习日时,我保留了两份内容:
teacherText:老师发来的完整课堂原文,只作为以后核对的依据;generatedContent:真正用于生成卡片的单词和句子。
这一步很重要。
如果直接拿整段群消息去渲染,就会把 LG1、Super ABC、教学目标和家庭作业一起塞进题库。与其追求一个“百分百理解所有老师文案”的万能解析器,不如让 AI 先整理出核心内容,再给家长一个可以人工确认的输入框。
例如 Letter B 这节课,最后进入应用的内容可以简化成:
Words
bee 蜜蜂🐝
ball 球
balloon 气球🎈
bear 熊🐻
box 盒子📦
bike 自行车🚲
Sentences
B is for __
I see a bee.
I have a ball.
The balloon is big.
The bear is brown.
应用会按行解析,自动判断是单词还是句子,并去掉重复项:
export function parseReviewText(rawText: string) {
let currentCategory: ReviewItemCategory | undefined;
const parsed = rawText.split(/\r?\n/).flatMap((line) => {
const heading = cleanLine(line).replace(/[::]$/g, "").trim();
if (/^(words?|单词)$/i.test(heading)) {
currentCategory = "word";
return [];
}
if (/^(sentences?|句子)$/i.test(heading)) {
currentCategory = "sentence";
return [];
}
return parseReviewLine(line).map((item) => ({
...item,
category: currentCategory || item.category
}));
});
return assignContextualEmojis(dedupeReviewItems(parsed));
}
如果只想从老师原文里拿单词,也可以直接提取所有英文单词,去重后再编辑:
function extractEnglishWords(sourceText: string) {
const matches = sourceText.match(/[A-Za-z]+(?:['’-][A-Za-z]+)*/g) || [];
const seenWords = new Set<string>();
return matches.reduce<string[]>((words, match) => {
const normalizedWord = match.toLocaleLowerCase();
if (!normalizedWord || seenWords.has(normalizedWord)) return words;
seenWords.add(normalizedWord);
words.push(normalizedWord);
return words;
}, []);
}
这里 AI 并没有被塞进每一次运行流程里。它更像开发和整理阶段的助手:帮我把老师的长文本收敛成结构化内容;真正播放时,应用使用的仍然是简单、稳定、可人工修改的数据。
2. 中文翻译、Emoji 和句子自动补齐
家长只输入英文也可以。
应用会优先尝试浏览器内置翻译能力,不支持时再调用在线翻译服务,并把结果缓存在本地,避免同一个单词反复请求。
Emoji 的策略也尽量简单:
- 老师原文里本来有 Emoji,优先保留;
- 本地映射表里有对应单词,直接匹配;
- 句子中包含已经出现过的单词,就复用这个单词的 Emoji;
- 实在找不到,再显示通用占位图标。
比如:
function buildReviewItem(english: string, chinese = "") {
const explicitEmoji = extractReviewEmoji(chinese);
return {
id: createReviewId("item"),
english,
chinese,
category: inferCategory(english),
emoji: explicitEmoji || pickEmoji(english, chinese)
};
}
为什么先用系统 Emoji,而不是一开始就为每个单词联网找图?
因为“能稳定显示”比“图片特别漂亮”更重要。
Emoji 不受图片防盗链、接口限流和网络波动影响。对于 apple、cat、car 这种幼儿词汇,它已经足够清楚。
3. Emoji 不够用时,再联网查图
遇到 Emoji 表达不准确的内容,卡片上可以直接打开图片选择器。
应用内接入了 Openverse 和 Wikimedia Commons,输入英文关键词后返回几张候选图。如果 Openverse 请求失败,会自动尝试 Wikimedia。
async function fetchReferenceImages(item, cursor, source) {
if (source === "openverse") {
try {
return await fetchOpenverseReferenceImages(item.english, cursor);
} catch {
return await fetchCommonsReferenceImages(item.english, cursor);
}
}
return fetchCommonsReferenceImages(item.english, cursor);
}
如果开放图库还是找不到合适的图片,也可以跳到百度图片、搜狗图片等外部来源搜索,复制图片地址回来。
再不行,就本地上传。
本地图片不会原封不动地传上去。页面会先用 Canvas 把最长边压到 720px,再转成 WebP,控制体积后上传到 Supabase Storage:
const maxDimension = 720;
const scale = Math.min(
1,
maxDimension / Math.max(image.naturalWidth, image.naturalHeight)
);
context.drawImage(image, 0, 0, width, height);
const result = canvas.toDataURL("image/webp", 0.72);
这套图片策略其实就是三层兜底:
系统 Emoji
↓ 不合适
Openverse / Wikimedia 联网查图
↓ 还不合适
本地上传到 Supabase Storage
不追求一次做到完美,但保证家长总有办法给孩子换成一张看得懂的图。
4. 发音不单独购买服务,先用浏览器能力
英语朗读使用的是浏览器自带的 speechSynthesis。
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = "en-US";
utterance.pitch = 1.08;
utterance.rate = 0.78;
window.speechSynthesis.speak(utterance);
我把语速调得比默认值慢一点,更适合孩子跟读。
为了防止某些浏览器没有正常触发结束事件,还加了一个兜底计时器。这样自动学习模式不会永远卡在某一张卡片上。
这个方案的声音未必像专业外教音频那么自然,但它免费、调用简单,而且对“重复听当天的几个单词”已经够用。
5. 本地优先,再同步云端
复习内容和学习进度会先写进 localStorage。
这么做是因为家庭学习工具最怕网络一抖,整个页面就不能用了。本地缓存至少保证已经录入的内容还能继续复习。
云端同步则负责解决多设备共享。
我没有把 Supabase 的 service role key 放到前端,而是让所有请求经过 Supabase Edge Function:
Vue 页面
↓
Supabase Edge Function
├── PostgreSQL:保存复习内容与版本号
└── Storage:保存本地上传的参考图片
读取可以公开进行,写入则要输入家庭编辑码。
同时,每份云端数据都有一个 revision。保存时前端会带上自己读取到的版本号;如果云端已经被另一台设备改过,接口返回 409,提示先同步最新内容,避免后一次保存直接覆盖前一次修改。
对于只有家里几个人使用的小工具,这套权限并不复杂,却刚好够用。
部署上的坑
一开始我想把前端和数据接口都放在 Cloudflare。
前端用 Cloudflare Pages,数据放 D1,再通过 Pages Functions 提供读取和保存接口。方案看起来很完整,而且都有免费额度。
我甚至已经把表结构、D1 binding 和 REVIEW_EDIT_CODE 都接好了。
但实际调试时,问题不是代码跑不通,而是服务经常无法正常访问。部署后的页面、接口和控制台在我的网络环境里都不够稳定,调一个很小的问题,也要反复确认到底是代码错了,还是网络没有连上。
个人项目最消耗人的往往不是难题,而是这种不确定性。
最后我没有继续硬扛,直接把数据层迁到了 Supabase:
-
D1 换成 PostgreSQL;
-
Pages Functions 换成 Supabase Edge Function;
-
参考图片放进 Supabase Storage;
-
数据表开启 RLS,前端不能直接访问;
-
写操作继续使用家庭编辑码;
-
保留
revision做乐观锁,避免多设备覆盖。
前端本身仍然是 Vite 静态构建,所以没有必要和数据服务绑在一起。我把页面交给 GitHub Pages,通过 GitHub Actions 在每次推送 main 后自动构建:
- name: Build
run: pnpm build:github
env:
VITE_REVIEW_SYNC_URL: ${{ vars.VITE_REVIEW_SYNC_URL }}
- name: Deploy to GitHub Pages
uses: actions/deploy-pages@v4
这里还有一个容易漏掉的点:GitHub Pages 部署在仓库子路径下,Vite 的 base 需要对应仓库名。
const base =
process.env.VITE_DEPLOY_TARGET === "github-pages"
? "/vancy-learn-english/"
: "/";
最终架构变成了:
GitHub Pages:部署 Vue 静态页面
Supabase PostgreSQL:保存复习内容
Supabase Storage:保存上传图片
Supabase Edge Function:统一读写入口
页面和数据分开以后,反而更清楚。
需要说明的是,GitHub、Supabase、Openverse、Wikimedia 这些服务在国内网络环境下都可能有访问波动。我自己在初始化项目、部署函数和排查接口时,需要准备稳定的网络环境。
这也是为什么应用始终保留 localStorage:云端同步失败时,本机缓存还能继续用,不能因为一个免费服务暂时连不上,就耽误当天复习。
效果
最终做出来的东西,比我最开始想的更简单。
整体效果
设置
转化内容
设置自定义图片
学习模式
打开页面后,孩子看到的是当前复习日的一张大卡片。中间是 Emoji 或参考图片,下面是英文和中文。卡片切换时自动朗读,也可以进入学习模式,让它按照设置的间隔自动轮播。
家长侧的操作则集中在“复习设置”里:
-
按日期切换老师当天的学习内容;
-
粘贴老师原文,提取单词并整理句子;
-
新增、编辑和删除复习日;
-
调整自动播放的间隔;
-
给不合适的单词更换 Emoji 或参考图;
-
同步云端内容,让家里其他设备直接使用。
以前复习 Letter B,要先翻群聊、找视频、拖进度条,再一个个暂停。
现在只要打开当天的复习日:
bee → ball → balloon → bear
单词播完,再接着听:
I see a bee.
I have a ball.
The balloon is big.
The bear is brown.
它没有复杂的学习体系,也没有让人上瘾的积分设计。
但它把一次原本有点费家长的复习,变成了孩子愿意自己点几下的过程。
这对我来说就够了。
总结
做完这个 App,我越来越觉得,好的软件不一定是功能最多的软件。
对商业产品来说,我们会关心用户规模、增长和通用性;但对个人产品来说,价值可以非常具体:它只要替家里省掉每天十分钟重复、低效又容易烦躁的操作,就已经有存在的意义。
这次真正解决问题的,也不是 Vue、Supabase 或 AI 里的某一个技术点。
而是先把痛点说清楚:
我不需要另一套英语课程。
我只需要把老师今天教的单词和句子,变成孩子愿意反复听的卡片。
需求一旦收敛,后面的实现自然就简单了。
AI 把“做一个只服务自己家人的软件”的成本降了下来,而前端经验让我可以继续把这个原型修到顺手、稳定、能长期使用。
在线地址:mjsong07.github.io/vancy-learn…
“Perfection is achieved, not when there is nothing more to add, but when there is nothing left to take away.” — Antoine de Saint-Exupéry
完美并非无可增加,而是无可删减。