我用 Next.js 16 + Supabase 从零做了个「背单词」H5 应用

一个能真机上背单词、带英/美发音、进度云端同步的移动端 H5。从数据建模到上线踩坑,全部记录。

一、为什么做这个

市面上的背单词 App 要么太贵,要么广告满天飞,要么数据不透明。作为一个程序员,最直接的想法就是:自己写一个

我的需求很简单:

  • 手机浏览器打开就能用(H5,不用装 App)
  • 单词书能自己维护,数据可控
  • 学完一个词自动记住进度,下次接着背
  • 点击小喇叭能听到英式/美式发音
  • 界面要好看,不能是上个时代的土味 UI

于是就有了这个项目:一个 后台管理系统 + H5 学习端 的一体化应用。本文讲 H5 学习端是怎么做的。

技术栈

选型
框架 Next.js 16.3.3(App Router)
语言/UI React 19 + TypeScript + Tailwind CSS v4
组件库 shadcn/ui(base-nova 主题,基于 Base UI)
ORM Drizzle ORM 0.45
数据库 Supabase(PostgreSQL,直连 5432)
鉴权 scrypt 密码哈希 + httpOnly Cookie 会话

二、数据怎么设计:一张 JSON 就能装下整个单词

单词的「知识点」特别多:词性、中文释义、英/美音标、英/美发音、例句、短语、同根词、近义词、记忆方法......如果全部拆成关系表,会非常痛苦。

我采用了业界常见的做法:词书(books)+ 单词(words)两张表,单词的丰富内容全部塞进一个 content JSON 字段

bash 复制代码
books 单词书(目录)
├── id            uuid
├── book_id       可读标识,如 PEPXiaoXue3_1 / CET6_2
├── title         书名(小学三年级 / 六级词汇)
├── word_count    单词总数
└── cover_url     封面图

words 单词(内容)
├── id            bigint
├── wordRank      在书内的序号(第 1 个、第 2 个......)
├── headWord      单词本身
└── content       json ------ 释义/音标/发音/例句/短语/近义词......全在这里

words.content 的 JSON 结构长这样(来自有道词库,清洗后入库):

json 复制代码
{
  "word": {
    "wordId": "CET6_2_123",
    "wordHead": "trade",
    "content": {
      "trans": [{ "pos": "n.", "tranCn": "贸易;交易", "tranOther": "trade" }],
      "ukphone": "treɪd",
      "usphone": "treɪd",
      "ukspeech": "trade&type=1",
      "usspeech": "trade&type=2",
      "sentence": {
        "sentences": [
          { "sContent": "They trade goods with each other.", "sCn": "他们互相交换商品。" }
        ]
      },
      "relWord": { "rels": [{ "pos": "n.", "words": [{ "hwd": "trading", "tran": "贸易" }] }] },
      "syno": { "synos": [{ "pos": "vt.", "hwds": [{ "w": "exchange" }], "tran": "交换" }] },
      "remMethod": { "val": "tra(交易)+ de → 交易" }
    }
  }
}

关键点:书和词的关联没有外键words.content->'word'->>'wordId' 长成 {book_id}_{rank},所以按 LIKE 'PEPXiaoXue3_1\_%' 前缀匹配就能把某一本书的全部单词捞出来。这个设计让词库的导入变得非常灵活------任何格式的第三方词库,只要清洗成这个 JSON 结构就能用。

三、学一个单词,前端页面长什么样

H5 端我按「首页 → 学习页 → 我的」三个页面来组织,底部两个 Tab(首页 / 我的)。

3.1 首页:问候 + 最近背 + 最近学 + 全部单词书

首页是 Server Component(app/h5/page.tsx),直接调用数据层函数取数,不需要额外 API:

tsx 复制代码
export default async function H5HomePage() {
  const session = await getH5Session();
  const books = await getWordBooks();
  const recent = session ? await getProgressListWithBooks(session.id, 3) : [];
  const recentWords = session ? await getRecentWords(session.id, 10) : [];
  return <HomeView session={session} books={books} recent={recent} recentWords={recentWords} />;
}

页面上依次是:

  • 渐变问候 hero:按当前时间问候(早上好/下午好/晚上好),显示「在学 N 本」「累计 N 个单词」
  • 最近背单词:横向滚动的小卡片,展示刚背过的单词 + 中文释义,点击跳详情
  • 最近学习:正在学的单词书卡片,带进度条
  • 全部单词书:两列卡片网格,每张卡显示封面、书名、词数、学习进度

未登录用户点单词书 → 跳登录弹窗;已登录 → 直接进学习页。

3.2 学习页:详情内联 + 吸底操作栏

学习页是客户端组件,最核心的交互是:

  1. 顶部:退出按钮 + 书名 + 「已学完」徽标
  2. 进度条第 N / M 个 · P%,渐变进度条实时更新
  3. 单词详情:直接内联展示(不用跳转卡片页),渐变词卡 + 英/美发音小喇叭 + 释义/例句/短语/同根/近义/记忆方法
  4. 底部吸底操作栏上一个(描边按钮)/ 下一个(渐变主按钮),点了「下一个」就上报进度
tsx 复制代码
async function handleNext() {
  const res = await fetch(`/api/h5/me/progress/${encodeURIComponent(book.bookId)}`, {
    method: "PUT",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ lastWordRank: word.wordRank }),
  });
  if (res.status === 401) {
    router.push(`/h5/me?book=${book.bookId}&openLogin=1`);
    return;
  }
  if (isLast) { setFinished(true); return; }
  setIndex(i => i + 1);
}

进度上报的语义是:学到第 N 个,last_word_rank = N,下次从 N+1 开始。存的是「学到的位置」而不是「已掌握的单词数」,这样续背特别自然。

3.3 我的:账号 + 学习进度列表

我的页展示当前账号(头像、昵称、邮箱)、退出登录按钮,以及每本书的学习进度列表。未登录点「去登录」弹出登录/注册弹窗(登录注册共用,注册时角色固定为 learner)。

四、几个值得讲的技术点

4.1 有道发音:库里存的不是完整 URL

词库清洗后,ukspeech/usspeech 字段存的是 trade&type=1 这样的发音标识type=1 英音、type=2 美音),不是完整链接。真正播放时要拼上有道 dictvoice 的前缀:

ts 复制代码
const DICTVOICE_BASE = "https://dict.youdao.com/dictvoice?audio=";

export function getUkSpeechUrl(c) {
  const t = c?.word?.content?.ukspeech;
  return t ? DICTVOICE_BASE + t : null;
}

前端播放用的是纯客户端组件 + HTMLAudioElement

tsx 复制代码
async function handlePlay() {
  const audio = new Audio(url);
  audio.onended = () => setPlaying(false);
  audio.onerror = () => { setPlaying(false); setError(true); };
  await audio.play();
}

跨域音频只要播放 、不读流,new Audio(url).play() 不需要 CORS 配合,浏览器直接放。这里我踩过一个小坑:audio.play() 返回的是一个 Promise,在某些浏览器(比如 iOS Safari)必须处理它的 rejection,否则会报 Unhandled Promise Rejection。所以我包了 try/catch,失败时把图标切成 ✕ 提示用户。

4.2 JSON 安全取值:所有字段都可能不存在

因为 content 是外来词库数据,每个字段都可能缺失 。我用了一组纯函数来安全取值,缺失返回 null,渲染层再决定整块隐藏:

ts 复制代码
function inner(c) {
  return c?.word?.content ?? null;
}

export function getTranCn(c) {       // 卡片只用第一个中文释义
  return inner(c)?.trans?.[0]?.tranCn ?? null;
}

export function getSentences(c) {    // 例句
  return inner(c)?.sentence?.sentences ?? null;
}

export function getRelWords(c) {     // 同根词
  return inner(c)?.relWord?.rels ?? null;
}

export function getRemMethod(c) {    // 记忆方法
  return inner(c)?.remMethod?.val ?? null;
}

对应的详情页组件里,每一块都是 xxx?.length ? <Section>...</Section> : null------字段没有就整块隐藏,绝不渲染空壳。

4.3 LIKE 前缀匹配:_ 是个坑

前面说书和词靠 content->'word'->>'wordId' LIKE 'PEPXiaoXue3_1\_%' 关联。但 book_id 里的下划线 _ 恰好是 LIKE 的通配符(匹配任意单个字符),不转义会把别的书也匹配进来。必须手动转义:

ts 复制代码
const escaped = bookId.replace(/[\\%_]/g, (m) => "\\" + m);
const rows = await db
  .select({ ... })
  .from(words)
  .where(sql`${words.content}->'word'->>'wordId' LIKE ${escaped + "_%"}`)
  .orderBy(words.wordRank);

如果某天 book_id 里还出现 %,同样要转义------正则里一起处理了。

4.4 UPSERT 进度:唯一键 + onConflictDoUpdate

每个用户 × 每本书一行进度,用 user_id + book_id 做唯一索引。保存进度用 Drizzle 的 onConflictDoUpdate,一条 SQL 搞定「有则更新、无则插入」:

ts 复制代码
const rows = await db
  .insert(learningProgress)
  .values({ userId, bookId, lastWordRank, updatedAt: now })
  .onConflictDoUpdate({
    target: [learningProgress.userId, learningProgress.bookId],
    set: { lastWordRank, updatedAt: now },
  })
  .returning();

首页的「最近学习」按 updated_at 倒序取前 3 条,再 INNER JOIN books 带出书名和总词数,进度百分比 = lastWordRank / wordCount

4.5 MOCK 开关:本地开发不连库

连 Supabase 需要网络和权限,做 UI 时反复开关太麻烦。我加了一个 MOCK=1 环境变量开关,所有数据层函数都先判断 isMock(),是就走内存假数据:

ts 复制代码
export async function getWordBooks() {
  if (isMock()) return mockGetWordBooks();
  return db.select().from(books).orderBy(desc(books.createdAt));
}

Mock 数据里内置了 20 个 PEP 词 + 40 个 CET6 词,进度存在内存数组里(还做了按 bookId:rank 去重,防止上一个/下一个来回翻产生重复记录)。这样本地 MOCK=1 pnpm start 就能完整跑一遍流程,不用连数据库。

五、移动端 UI 的小心思

5.1 主题隔离:H5 用 indigo 高级紫,不动后台

后台是默认主题,H5 想单独用一套 indigo→violet 的渐变高级感。做法是在 H5 根布局包一层 theme-h5 class,用后代选择器覆盖 shadcn 的 CSS 变量:

css 复制代码
.theme-h5 {
  --primary: oklch(0.511 0.262 276.9);   /* indigo */
  --radius: 1rem;
}

因为 shadcn 的工具类都是在使用处 引用 var(--primary) 的,所以后代覆盖能天然级联生效,后台完全不受影响。

5.2 一个配色 + 圆角 + 渐变,撑起整页质感

  • 词卡和 hero 用 bg-gradient-to-br from-indigo-500 via-indigo-500 to-violet-500,叠加两个 blur-2xl 的白色圆点做高光
  • 所有卡片 rounded-2xl + border-indigo-100/70 + 轻投影
  • 底部操作栏 sticky bottom-0 + backdrop-blur,滑到哪都能点
  • Tab 高亮用 indigo 胶囊背景 + 顶部渐变指示条

5.3 桌面预览:限宽居中

移动端页面在桌面浏览器打开会太宽,布局里用 mx-auto w-full max-w-md 把内容限制成手机宽度,居中显示,方便调试预览。

六、踩坑记录

  1. Next 16 的 params/searchParams 变成了 Promise :动态路由 [bookId] 页面里必须 await params,不是解构直接用。客户端 useSearchParams 还要包 <Suspense>,否则构建报错。我的做法是尽量在 Server Component 里读数据,把搜索参数留在服务端解析。

  2. audio.play() 必须 catch :iOS Safari 上如果返回的 Promise 没被处理,会报 Unhandled Promise Rejection,而且点击后立刻再点会中断上次播放 。用 try/catch 包住,onended/onerror 双路重置状态。

  3. postgres-js 的 SQL 是标签模板 :Drizzle 生成的查询里拼条件要小心,LIKE 通配符转义要自己做(见 4.3)。另外 JSON 操作符 ->> 在 drizzle 里要显式写 sql 模板,别指望它自动处理。

  4. 错误信息被 Drizzle 包装 :数据库的唯一约束冲突(SQLSTATE 23505)会包成 DrizzleQueryError,真正 code 在 err.cause.code 上,判断时要两层都取。

七、效果与后续计划

现在能完整跑通的闭环是:注册/登录 → 首页选书 → 背单词(发音 + 详情 + 进度条)→ 自动上报进度 → 首页「最近学习」实时显示 → 我的页查看全部进度

接下来的方向:

  • 艾宾浩斯遗忘曲线复习提醒
  • 背单词统计图表(连续打卡天数)
  • 单词本收藏 + 生词本
  • 更多词库导入(考研、雅思、托福)

数据与词库的清洗流程也很值得单独写一篇:怎么把高星的第三方词库仓库,清洗成可以入库的 content JSON 结构。如果这篇有收获,点个赞关注,下一篇见。


项目源码:Next.js 16 + Supabase + Drizzle + Tailwind v4 + shadcn/ui,H5 学习端 + 后台管理端双端一体。

相关推荐
boooooooom31 分钟前
手把手做一个图 RAG 烹饪问答系统:Neo4j + Milvus + LLM 的工程实践
前端·javascript·后端
染指11103 小时前
103.RAG-LLamaIndex后端rag问答-聊天接口
前端·javascript·vue.js·人工智能
单线程_0111 小时前
从案例分析 Vue3 Tokenizer+Parser 源码三
前端·javascript·vue.js
小磊哥er13 小时前
深入解构Claude Code - 第 8 篇 · 数据放哪、钱怎么算
javascript·ai编程
kyriewen15 小时前
我装了30多个Skill,给AI安排了8个岗位
前端·javascript·ai编程
默_笙17 小时前
🔥 让 AI 帮我写完整个 Next.js 博客:框架就是 AI 的"上下文 buff"
前端·javascript
喵本喵叁肆20 小时前
06-M6-部门过滤与综合研判-从问答机到研判助手
前端·javascript·jquery
এ慕ོ冬℘゜20 小时前
JavaScript学习心得:从只会语法,到真正会写业务逻辑
开发语言·javascript·ecmascript