AIUI 智能体应用实战:在 Rokid Glasses 上做一个「谙镜读书」——扫码即得、眼镜端读书与单词练习


摘要

本文完整记录了一个可运行、可体验的 AIUI 智能体应用——谙镜读书(saomadanci) 从设计、开发到调试的全过程。它把「内容分发 + 眼镜端阅读/练习」这个高频场景做成了一款全屏应用:扫一个二维码就能把单词本或 txt 小说装进眼镜,本地查看(书 → 单元 → 单词卡)、卡片式练习、小说分页阅读全部支持,还打通了「扫码登录」这条网站 ↔ 眼镜的账号链路。项目用到的多模态设备能力非常典型:摄像头拍照 → WebP 解码 → BarcodeDetector 二维码识别 → 网络下载 → 本地持久化,以及 Rokid OpenAPI 账号确认,是 AIUI 多模态与设备能力交互的一个完整样本。

整套系统是「AIUI 眼镜端 App + PC 网站/服务端」的端到端闭环:PC 端(Vue3 + Express + MySQL)负责书库托管、上传管理、生成二维码与扫码登录,AIUI 端负责扫码即得与眼镜端阅读/练习,两端通过轻量 JSON 接口对接。本文第五章单独拆解 PC 端的实现。


目录


一、项目简介

解决什么问题: 学习/阅读内容(单词本、电子书)通常要低头、解锁、频繁翻页才能看。在通勤、排队、运动这些「碎片时间 + 腾不出手」的场景里,手机并不方便。眼镜端 AIUI 应用天然适合「抬头可见、按键即用」的轻交互。本项目把「内容获取」和「内容消费」都搬到眼镜上:

  • 内容获取:扫二维码添加书籍(二维码内容即书籍 id),或从云端在线书库按账号拉取;
  • 内容消费:单词本按 书 → 单元 → 单词卡 三级浏览,支持 TTS 朗读与自动翻页;txt 小说支持分页阅读、进度记忆与自动翻页;
  • 账号闭环:扫描网页端登录二维码,用 Rokid OpenAPI 账号一键确认网页登录。

目标用户:

  • 想用碎片时间背单词、读轻小说的学习者;
  • 想把学习内容(单词本 / 小说)按需分发给学生、团队的内容运营者(老师、组长)——他们在 PC 端上传书籍、管理书库,生成的二维码就是书籍 id,学习者扫码即得。

形态定位: 全屏页面应用(12 个页面,6 个功能入口),全部按键操作;配套一个 PC 网站 + 服务端(书库托管、上传管理、二维码分发、扫码登录),两端合起来才是完整系统。AIUI 端核心能力:

能力 说明
扫码添加 摄像头拍照 → WebP 解码 → 二维码识别 → 下载书籍 → 自动跳转书籍页
我的书籍 本地书籍列表(单词本 / 小说区分展示),书 → 单元 → 单词卡片浏览
阅读器 txt 小说分页阅读,方向键翻页,进度自动记忆,可选自动翻页
知识练习 卡片式练习:先显单词、Enter 显释义、再 Enter 下一词;支持乱序与朗读
在线添加 按账号从云端拉取书库,一键下载;已添加的书可再按一次删除
扫码登录 扫描 login:<sceneId> 二维码,确认网页端自动登录

PC 端(网站 + 服务端)负责书库托管、上传管理、二维码分发与扫码登录,详见第五章。


二、场景与用户意图

典型唤醒与使用链路

  1. 加书:老师把一本书的二维码发到群里,用户唤醒「谙镜读书」→ 首页「扫码添加」→ 对准二维码拍一张 → 识别 → 下载 → 自动跳转到新书页,全程一次 Enter。
  2. 背单词:首页「我的书籍」→ 选书 → 选单元 → 单词卡自动翻页 + 朗读,眼睛跟着走,几乎零操作。
  3. 巩固:首页「知识练习」→ 选书 → 选单元(或「全部」)→ 卡片先出单词,Enter 翻释义,自己回忆一遍再核对。
  4. 读小说:书是 txt 类型时从「我的书籍」进入阅读器,按页阅读、自动记忆进度,下次打开接着读。
  5. 分发/同步:内容运营者把书挂到在线书库,用户首页「在线添加」按账号拉取书单,一键下载;重复按 Enter 可以删除本地副本。
  6. 登录:网页端展示登录二维码,用户「扫码登录」→ 确认 → 网页自动登录,眼镜与网站账号打通。
  7. 内容生产:运营者在 PC 端登录后上传 Excel 词表 / Txt 小说、设置公开或私密,书库自动上架;书籍详情页一键生成二维码(内容 = 书籍 id),分享给任意学习者扫码即得。

智能体如何理解与承接需求

AIUI 把「应用」组织为理解 → 操作 → 反馈的闭环。本项目里:

  • 承接:所有入口收敛到首页 6 宫格;首页 onShow 每次回到首页都刷新账号状态(已登录:昵称 / 未登录),保证信息永远是最新的。
  • 理解:扫码内容被解析为两类语义——书籍 id(直接拼接下载地址)与 login:<sceneId> 登录意图(走账号确认流程),用统一状态机 + 明确校验把「扫到的是什么」讲清楚。
  • 反馈:扫码页有 READY / SCANNING / DONE / ERROR 四态与「扫码中→解析中→识别中→下载中」的分阶段文案 + 呼吸点动画;下载/登录每一步失败都给出可读原因(未识别到二维码、不是登录二维码、二维码已失效、服务端返回信息等);按键全程可操作。

三、方案设计

3.1 AIUI 智能体定义(AGENTS.md)

  • 名称:谙镜读书;版本 1.0.0;描述:扫二维码添加书籍,查看与练习知识,遵循 AIUI 技术规范。
  • 权限声明camera(扫码)、network(下载书籍/在线书库/登录接口)、audio(TTS 朗读)。
  • Skills 声明wordlist(书籍存取)、scanner(扫码识别)、practice(知识练习)。

3.2 路由结构(app.json)

12 个全屏页面,第一项 pages/index/index 为默认落地页:

路由 页面 职责
pages/index/index 首页 6 宫格功能导航 + 账号状态行
pages/wordlist/index 我的书籍 本地书籍列表,空态引导扫码
pages/wordlist/units 单元列表 某本书的单元列表
pages/wordlist/words 单词卡片 单元内逐词浏览(TTS/自动翻页)
pages/reader/index 阅读器 txt 小说分页阅读 + 进度记忆
pages/online/index 在线添加 云端书库拉取、下载、删除
pages/scan/index 扫码添加 拍二维码 → 识别书籍 id → 下载
pages/scan/login 扫码登录 拍登录二维码 → 确认网页登录
pages/practice/index 练习-选书 单词本列表(过滤小说)
pages/practice/units 练习-选单元 单元 + 「全部」
pages/practice/cards 练习-卡片 先词后释义的卡片练习
pages/settings/index 设置 朗读 / 乱序 / 自动翻页偏好

3.3 页面/卡片设计

  • 全屏页统一 Card 风格:黑底、2px 描边、12px 圆角、单色绿主题,全部走 var(--token) 主题变量并带 #40ff5e / rgba(64,255,94,…) 兜底,符合 AIUI 眼镜端单色绿设计规范。
  • 首页是 2×3 网格导航卡;列表页是「单列菜单 + scroll-view」,一屏可见 5 行(240px / 48px 步进)。
  • 单词/练习卡遵循「一个界面只讲一件事」:一张卡一个词,34px 大字展示单词,释义/例句分层排布。

3.4 状态流转

[首页 6 宫格] --Enter--> 各功能页
  我的书籍 ──> 单元 ──> 单词卡片(↑↓翻词/自动翻页) 
  知识练习 ──> 单元(+全部) ──> 卡片(Enter: 词→释义→下一词)
  阅读器   ──> 分页(↑↓) + 进度记忆 + 自动翻页
  在线添加 ──> 拉取书单 ──> Enter 下载/再次 Enter 删除
  扫码添加 ──> 拍照→解码→识别──> 下载──> 置 pending──> redirectTo 我的书籍(自动打开新书)
  扫码登录 ──> 拍照→识别 login:<sceneId>──> 取账号──> confirm──> 网页登录
  设置     ──> Enter 切换 4 个偏好项,自动翻页档位循环

在这里插入图片描述在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

关键共享状态(全部 wx 本地存储持久化):

存储键 用途
wordbooks 书籍索引(id/title/url/type/unitCount/wordCount/savedAt)
units:<bookId> 书籍单元列表
words:<bookId>:<unit> 某单元单词数组
content:<bookId> txt 小说正文
reading_progress:<bookId> 阅读器页码
current_wordbook_id 当前书籍 id
pending_wordbook_id 扫码下载后待自动打开的书 id
app_settings 朗读 / 乱序 / 自动翻页设置
local_account_id 本地兜底账号 id

3.5 能力调用关系

AIUI 页面逻辑
  ├─ wx.getStorageSync / setStorageSync / removeStorageSync —— 书籍/单元/单词/进度/设置持久化
  ├─ wx.navigateTo / navigateBack / redirectTo —— 页面路由
  ├─ wx.exitMiniProgram —— 首页返回键退出应用
  ├─ wx.media.createCameraContext + takePhoto —— 扫码拍照(必须由交互触发)
  ├─ lib/webp.js(沙箱加载 WebPJS)—— WebP 解码为灰度图
  ├─ BarcodeDetector(barcode 模块)—— 二维码内容识别
  ├─ wx.request —— 下载书籍 / 在线书库 / 登录确认 / 用户信息接口
  ├─ wx.speech.playTTS —— 单词朗读
  └─ createOpenAPI('open' 模块) —— Rokid 账号档案(account.getProfile)

3.6 端到端架构:AIUI 端 ↔ PC 端

[AIUI 眼镜端]                     [PC 网站 + 服务端]
  扫码添加 ── GET /wordlist/{id} ────────> 词表/小说 JSON
  在线添加 ── GET /api/app/wordlists ─────> 书库列表(accountId + 共享密钥)
  扫码登录 ── POST /api/auth/qr/confirm ──> 确认登录(App 侧)
  首页账号 ── POST /api/auth/user ────────> 用户信息 / 自动建档
            <── POST /api/auth/qr/create(网页生成码) + GET /api/auth/qr/status(网页轮询换 JWT)

PC 端的详细实现见第五章。


四、AIUI 实现

本节代码均来自项目源码。项目采用官方推荐的 Single File Component(.ink 单文件格式:一个文件包含 <script def>(配置)、<script setup>(逻辑)、<page>(模板)、<style>(样式)四段。

4.1 工程结构

saomadanci/                 # 谙镜读书
├── AGENTS.md               # Agent 清单(身份、权限、Skills)
├── app.json                # 12 页面路由 + 窗口配置
├── app.js                  # 应用生命周期
├── lib/
│   ├── wordlist.js         # 书籍模型:存取/拆分存储/在线拉取
│   ├── scanner.js          # 拍照 → WebP 解码 → 二维码识别 封装
│   ├── webp.js             # 纯 JS WebP 解码(扫码用)
│   ├── settings.js         # 设置读写与归一化
│   ├── auth.js             # 扫码登录 / 账号解析 / Rokid OpenAPI
│   ├── scroll.js           # 列表边沿式滚动计算
│   └── vendor/webpjs/      # webpjs 纯 JS 解码库源码
└── pages/
    ├── index/              # 首页(6 宫格)
    ├── wordlist/           # 我的书籍:列表 / 单元 / 单词卡
    ├── reader/             # 小说阅读器
    ├── online/             # 在线添加
    ├── scan/               # 扫码添加 + 扫码登录
    ├── practice/           # 知识练习:选书 / 选单元 / 卡片
    └── settings/           # 设置

4.2 全局配置:路由与入口

app.json 声明全部页面路由,首项为默认落地页;窗口标题「谙镜读书」:

{
  "pages": [
    "pages/index/index",
    "pages/wordlist/index",
    "pages/wordlist/units",
    "pages/wordlist/words",
    "pages/reader/index",
    "pages/online/index",
    "pages/scan/index",
    "pages/scan/login",
    "pages/practice/index",
    "pages/practice/units",
    "pages/practice/cards",
    "pages/settings/index"
  ],
  "window": {
    "navigationBarTitleText": "谙镜读书",
    "viewport": { "width": "device-width" }
  }
}

4.3 首页 .ink:6 宫格导航 + 账号状态(pages/index/index)

首页是典型的「功能菜单承接」页:数据绑定用 {{ }},列表用 ink:for + ink:key,焦点通过 index === focusIndex 切换高亮类:

<script type="application/json" def>
{ "navigationBarTitleText": "谙镜读书",
  "description": "应用首页,以功能菜单形式提供我的书籍、知识练习、在线添加、扫码添加、扫码登录、设置六个入口。" }
</script>

<script setup>
import wx from 'wx';
import { fetchUserInfo, getAccountInfo } from '../../lib/auth.js';

const ROUTES = {
  login: '/pages/scan/login', wordlist: '/pages/wordlist/index',
  practice: '/pages/practice/index', scan: '/pages/scan/index',
  online: '/pages/online/index', settings: '/pages/settings/index'
};

export default {
  data: {
    menuItems: [
      { key: 'wordlist', label: '我的书籍', hint: '阅读书籍或背诵单词本' },
      { key: 'practice', label: '知识练习', hint: '卡片式浏览单词与释义' },
      { key: 'online', label: '在线添加', hint: '从云端添加书籍' },
      { key: 'scan', label: '扫码添加', hint: '扫码添加新的书籍' },
      { key: 'login', label: '扫码登录', hint: '扫码确认网页登录' },
      { key: 'settings', label: '设置', hint: '朗读与练习偏好' }
    ],
    accountText: '',
    focusIndex: 0
  },
  onShow() { this.loadAccount(); },            // 每次回首页刷新账号状态
  async loadAccount() {
    const info = await getAccountInfo();
    if (!info.accountId) { this.setData({ accountText: '未登录' }); return; }
    try {
      const resp = await fetchUserInfo(info);
      if (resp.statusCode === 200 && resp.data && resp.data.user) {
        const u = resp.data.user;
        this.setData({ accountText: '已登录:' + (u.userName || u.username || u.accountId) });
      } else this.setData({ accountText: '未登录' });
    } catch (e) { this.setData({ accountText: '未登录' }); }
  },
  onKeyUp(event) {
    const code = event && event.code;
    if (code === 'Backspace') { event.preventDefault(); wx.exitMiniProgram(); return; }
    if (code === 'ArrowDown' || code === 'ArrowRight') { event.preventDefault(); this.moveFocus(1); return; }
    if (code === 'ArrowUp'   || code === 'ArrowLeft')  { event.preventDefault(); this.moveFocus(-1); return; }
    if (code === 'Enter') { event.preventDefault(); this.activate(this.data.focusIndex); return; }
  },
  moveFocus(delta) {
    // 防抖:一次滑动可能连续触发多个方向键事件,250ms 内只处理第一个
    const now = Date.now();
    if (this._lastMoveAt && now - this._lastMoveAt < 250) return;
    this._lastMoveAt = now;
    const total = this.data.menuItems.length;
    const nextIndex = (this.data.focusIndex + delta + total) % total;
    // .slice() 传新数组引用,强制整列重渲染,避免旧高亮残留
    this.setData({ focusIndex: nextIndex, menuItems: this.data.menuItems.slice() });
  },
  activate(index) {
    const item = this.data.menuItems[index];
    if (!item || !ROUTES[item.key]) return;
    wx.navigateTo({ url: ROUTES[item.key] });
  }
};
</script>

关键点:

  • 页面级按键事件 onKeyUpevent.code 分流,需要接管时显式 event.preventDefault()(宿主默认行为挂在 keyup 阶段);
  • 首页 Backspace 交还宿主前先 exitMiniProgram——在应用根部直接退出,而不是返回空白;
  • onShow 刷新账号,保证扫码登录成功后回到首页立即可见「已登录」。

4.4 扫码链路:拍照 → WebP 解码 → 二维码识别(lib/scanner.js + lib/webp.js + pages/scan)

这是本项目「多模态设备能力」的核心,也是最有含金量的部分。链路为:

takePhoto({quality:'high'}) → 判断 MIME 为 webp → decodeWebP(输出灰度)
  → new BarcodeDetector().detect({data, width, height})
  → 取 rawValue 作为内容 → (书籍id 或 login:<sceneId>)→ 后续动作

为什么需要自己解码 WebP? Rokid Glasses 相机 takePhoto 返回的是 WebP 图像,而 BarcodeDetector 需要位图灰度数据。AIUI 运行时没有现成的图片解码 API,所以项目引入了纯 JS 的 WebPJS,并做了 RIFF 容器解析与 VP8X 归一化(动画 ANIM/ANMF 直接拒绝;ALPH+VP8 / VP8L 拆包重打包为简单 WebP):

// lib/webp.js 要点
export async function decodeWebP(data, options) {
  // 1. 归一化为 Uint8Array,校验 RIFF/WebP 魔数("RIFF....WEBP")
  // 2. normalizeWebPBytes:VP8X 容器 → 拆出 VP8/VP8L(+ALPH) 重打包为简单 WebP
  // 3. new Function + with(sandbox) 加载 WebPJS 源码(规避 document/navigator 依赖)
  // 4. WebPGetFeatures → WebPDecode(MODE_ARGB)
  // 5. output:'gray' 时按 Y=0.299R+0.587G+0.114B 转单通道,喂给二维码识别
}

lib/scanner.js 把「拍照 → 解码 → 识别」收敛成一个可复用的函数,并用阶段回调驱动 UI 文案:

import BarcodeDetector from 'barcode';
import { decodeWebP } from './webp.js';

export async function toBarcodeInput(photo) {
  if (!photo || !photo.data) throw new Error('相机未返回图像数据。');
  const mimeType = String((photo && photo.mimeType) || '').toLowerCase();
  if (mimeType.includes('webp')) {
    const decoded = await decodeWebP(photo.data, { output: 'gray' });
    return { data: decoded.gray, width: decoded.width, height: decoded.height };
  }
  throw new Error('不支持的图片格式:' + (mimeType || 'unknown'));
}

export async function detectQrContent(cameraCtx, onStage) {
  if (onStage) onStage('扫码中,正在拍照...'); await sleep(500);
  const photo = await takePhoto(cameraCtx);
  if (onStage) onStage('解析中,正在解析图片...'); await sleep(500);
  const barcodeInput = await toBarcodeInput(photo);
  if (onStage) onStage('识别中,正在识别二维码...'); await sleep(500);
  const detector = new BarcodeDetector();
  const results = await detector.detect(barcodeInput);
  const qr = results && results.find((item) => item && item.rawValue);
  return qr ? extractContent(qr.rawValue) : '';
}

扫码添加页(pages/scan/index)用 isScanning 忙态挡并发,wx.redirectTo 直达书籍列表并借助 pending_wordbook_id 自动打开新书:

async scanQRCode() {
  this.setData({ status: 'SCANNING', isScanning: true, stageText: '扫码中,正在拍照...' });
  this.startLoadingDots();
  try {
    const content = await detectQrContent(this.cameraCtx, (stage) => this.setData({ stageText: stage }));
    if (!content) {
      this.setData({ status: 'ERROR', errorMessage: '未识别到二维码,请重试' });
      return;
    }
    const url = buildWordlistUrl(content);              // 二维码内容即书籍 id
    this.setData({ stageText: '下载中,正在保存书籍...' });
    const result = await fetchWordlist(url);
    const book = saveWordbook({ id: result.id, title: result.title, url, words: result.words, content: result.content });
    this.setData({ status: 'DONE' });
    setPendingWordbookId(book && book.id ? book.id : content);
    wx.redirectTo({ url: '/pages/wordlist/index' });    // 列表 onLoad 消费 pending 自动打开
  } catch (error) {
    this.setData({ status: 'ERROR', errorMessage: error && error.message ? error.message : '扫码失败' });
  } finally {
    this.setData({ isScanning: false });
  }
}

4.5 书籍模型:两种类型 + 拆分存储(lib/wordlist.js)

这是应用的「数据核心」。它统一了 excel 单词本(按单元分组的单词)与 txt 小说(纯文本正文)两类书籍的读写:

export const WORDLIST_BASE_URL = 'https://101.200.32.88/wordlist';
export function buildWordlistUrl(id) { return WORDLIST_BASE_URL + '/' + id; }

export function normalizeWords(words) {
  return words.map((item, index) => ({
    index: index + 1,
    word: /* 强转字符串 */,
    meaning: /* 强转字符串 */,
    phonetic: /* '' */, pos: /* '' */, example: /* '' */, exampleCn: /* '' */,
    unit: /* '' */
  }));
}

export function saveWordbook(wordbook) {
  if (content) { /* txt:正文存 content:<id>,元数据 unitCount:0, wordCount:正文长度 */ }
  else {
    // excel:按单元首次出现顺序分组
    // 拆分存储:units:<id> + words:<id>:<unit>,元数据不再带 words 大字段
  }
}

值得一提的三个设计:

  1. 存储分层wordbooks 只存元信息,单词/正文按 units:<id>words:<id>:<unit>content:<id> 单独存,避免列表页反复读大对象,也避免单 key 过大。
  2. 会话级内存缓存兜底booksCache / unitsCache / wordsCache / contentCache 在存储读取失败或被旧格式覆盖时兜底,防止返回导航或页面重建时列表变成空态。
  3. 删除即清理removeWordbook 会连带删除该书的单元、单词、正文、阅读进度所有 key,不留孤儿数据。

4.6 知识练习:先词后释义的卡片(pages/practice/cards.ink)

练习页是「一次只答一个词」的沉浸卡片:Enter 在「显示释义」与「下一个」之间切换,↑↓ 被显式吞掉防止误翻页:

revealOrNext() {
  if (this.data.words.length === 0) return;
  if (!this.data.revealed) {
    this.setData({ revealed: true });
    if (this.settings.ttsEnabled) {
      const current = this.data.words[this.data.currentIndex];
      if (current && current.word) wx.speech.playTTS(current.word);
    }
    return;
  }
  const nextIndex = (this.data.currentIndex + 1) % this.data.words.length;
  this.setData({ currentIndex: nextIndex, revealed: false });
}
  • 选单元页把「全部」追加到单元列表末尾;「全部」时用 getAllWords 跨单元合并。
  • 乱序练习开启时用 Fisher–Yates 洗牌(lib 内置,不修改原数组)。

4.7 阅读器:分页 + 进度记忆(pages/reader/index.ink)

txt 小说按「每行 24 字符硬折行 → 每页 12 行」切页,方向键翻页并实时写 reading_progress:<bookId>,下次打开从上次页码继续;novelFlipSeconds 开启时自动翻页、到最后一页自动停:

const PAGE_LINES = 12;
const LINE_CHARS = 24;
function splitPages(content) {
  // 先切「显示行」:按换行 + 超宽折行,保证一屏宽度内不折行
  // 再按 PAGE_LINES 分页
}

4.8 扫码登录:Rokid OpenAPI 账号确认(lib/auth.js + pages/scan/login.ink)

登录二维码内容必须是 login:<sceneId>,页面先校验前缀,再取 Rokid 云端账号(createOpenAPIopenapi.account.getProfile()),最后 POST 确认接口:

import { createOpenAPI } from 'open';   // Rokid OpenAPI

export async function getAccountProfile() {
  try {
    const openapi = await createOpenAPI();
    const profile = await openapi.account.getProfile();
    return profile && typeof profile === 'object' ? profile : null;
  } catch (e) { return null; }
}

export function confirmQrLogin({ sceneId, accountId, headIcon, userName }) {
  // POST {DEFAULT_SERVER}/api/auth/qr/confirm
  // payload: { sceneId, key: QR_LOGIN_SECRET, accountId, headIcon, userName }
}

账号解析用「Rokid 账号优先、本地兜底」:拿到 accountId 就持久化到 local_account_id;拿不到就用 'user_' + 时间戳 + 随机串 生成本地稳定 id。登录确认按 statusCode 分叉给用户可读文案(200 成功 / 403 确认失败 / 404 二维码已失效 / 其他网络异常)。

4.9 设置:偏好持久化(lib/settings.js + pages/settings/index.ink)

四个偏好项全部归一化后写入 app_settings

export const DEFAULT_SETTINGS = {
  ttsEnabled: false, shuffleEnabled: false, autoFlipSeconds: 0, novelFlipSeconds: 0
};
// getSettings / saveSettings 用 toBoolean / toNumber 做类型归一化,防脏数据

设置页 Enter 逐项切换:两个开关直接取反;两个自动翻页项在档位表(单词 0/3/5/10/15/30s,小说 0/3/5/10/15/30/45/60s)里循环步进,0 即「关」。

4.10 列表滚动:边沿式滚动对齐 5 行视口(lib/scroll.js)

列表页 scroll-view 可见高度 240px、行步进 48px(40 行高 + 8 间距),正好一屏 5 行。computeScrollTop 只在聚焦行越出视口时才滚动,且一条一条往下滚,不会跳位:

export function computeScrollTop(focusIndex, currentScrollTop) {
  const rowTop = focusIndex * ROW_PITCH;
  const rowBottom = rowTop + ROW_HEIGHT;
  if (rowTop < currentScrollTop) return rowTop;                    // 上方 → 滚上去
  if (rowBottom > currentScrollTop + VIEW_HEIGHT) return rowBottom - VIEW_HEIGHT; // 越底 → 露出该行
  return currentScrollTop;
}

4.11 运行时 API 一览

本项目实际用到的 AIUI 运行时能力(全部来自 .ink 官方运行时与官方文档,非 Web 兼容臆测):

API 场景
wx.getStorageSync / setStorageSync / removeStorageSync 书籍、单元、单词、进度、设置、账号持久化
wx.navigateTo / navigateBack / redirectTo 页面路由(压栈 / 返回 / 重定向)
wx.exitMiniProgram 首页返回键退出应用
wx.media.createCameraContext()takePhoto({quality}) 扫码拍照,返回 {data, mimeType}必须由交互触发
BarcodeDetectorimport from 'barcode')→ detect({data,width,height}) 二维码识别,返回 [{format, rawValue}]
wx.request 下载书籍 / 在线书库 / 登录确认 / 用户信息(GET/POST,dataType:'json'
wx.speech.playTTS(text) 单词朗读
createOpenAPIimport from 'open')→ openapi.account.getProfile() Rokid 云端账号档案
setInterval / clearInterval 单词/小说自动翻页定时器

五、PC 端:网站 + 服务端(xueyingyu-pc)

AIUI 端只是「消费端」;本项目还配套一个完整的 PC 网站 + 服务端xueyingyu-pc),负责内容生产、书库托管、二维码分发与扫码登录。两端通过几个轻量 JSON 接口对接,构成「生产 → 分发 → 消费 → 账号」的完整闭环。

5.1 定位与技术栈

技术
前端 Vue 3 + Vite + Element Plus + Vue Router + Axios + qrcode(响应式,适配 PC 与手机)
后端 Node.js + Express + Sequelize ORM
数据库 MySQL 8(utf8mb4)
Excel 解析 SheetJS(xlsx):中英文表头别名 + 无表头「单词 | 翻译」兜底
部署 Nginx(静态托管 + /api /wordlist /uploads 反代)+ PM2(fork 单实例,300M 内存上限自动重启)

5.2 目录结构

xueyingyu-pc/
├── client/                 # Vue 3 前端
│   └── src/
│       ├── views/          # 首页书库 / 登录注册 / 上传 / 管理(管理员)/ 扫码登录
│       ├── components/     # AppHeader / WordbookCard(书本封面)/ QrCode / ScanLoginDialog
│       ├── api/            # axios 封装(JWT 注入 + 401 跳登录)
│       └── utils/preview.js# 上传前客户端 Excel / Txt 解析预览
├── server/                 # Express 后端
│   ├── src/
│   │   ├── models/         # User / Wordbook / Word
│   │   ├── routes/         # auth(注册/登录/扫码登录)/ wordbook(上传/管理)/ wordlist(App 消费)
│   │   ├── controllers/    # 业务逻辑
│   │   ├── middleware/     # JWT 鉴权 / multer 上传(25MB 上限)
│   │   └── utils/          # excel 解析 / txt 编码识别 / id 生成 / 常量时间比较
│   ├── scripts/            # seed(建表+管理员)/ demo-data / e2e-test / qr-flow-test
│   └── uploads/covers/     # 封面图(本地存储)
├── deploy/                 # nginx.conf + ecosystem.config.js(PM2)
└── release/                # 打包发布:setup.sh 一键部署(建表 + PM2 + Nginx 自动配置)

在这里插入图片描述
在这里插入图片描述

5.3 数据契约:/wordlist/{id}(App 扫码消费的 JSON)

书籍顶层 id 就是二维码内容,App 扫码得到 id 后调用 GET {DEFAULT_SERVER}/wordlist/{id} 拉取书籍:

{
  "id": "senior-english-1",
  "title": "高中英语必修一",
  "words": [
    { "word": "confine", "meaning": "使不外出,幽禁;限制", "phonetic": "/kənˈfaɪn/",
      "pos": "vt.", "example": "Please confine your remarks.", "exampleCn": "请把发言限制住。", "unit": "Unit 1" }
  ],
  "content": null
}
  • id 规则 /^[A-Za-z0-9._-]+$/,两端一致校验;word/meaning 必填,其余字段可选(App 端缺省隐藏);
  • 同单词自动去重(dedupeWords);
  • ?preview=1(网页预览)不计下载数、txt 正文只取前 2000 字符、?limit=N 限词条数;App 下载(无参数)时 download_count +1。

5.4 核心接口一览

方法 路径 权限 说明
POST /api/auth/register / login 公开 注册 / 登录(bcrypt + JWT,7d)
GET /api/auth/me 登录 当前用户
POST /api/auth/user App 共享密钥 凭 accountId 查 / 自动建档用户信息
POST /api/auth/qr/create 公开 网页生成登录场景(sceneId + 120s TTL)
POST /api/auth/qr/confirm App 共享密钥 App 扫码确认登录
GET /api/auth/qr/status 公开 网页轮询:pending / expired / confirmed(换 JWT)
GET /api/wordlists 可选登录 网页书库列表(公开 + 本人私密 + admin 全部)
GET /api/app/wordlists App 共享密钥 App 在线书库(未注册账号也能看公开词本)
GET /wordlist/:id 公开 App 消费 JSON(下载数 +1)
GET /wordlist/:id/download 公开 网页下载 JSON 文件
POST /api/wordbooks 登录 上传(Excel / CSV / Txt + 可选封面)
PUT / DELETE /api/wordbooks/:id 本人/admin 编辑 / 删除(级联清理 words 与封面文件)

5.5 扫码登录:网站生成码 → App 确认 → 网站换 JWT

这是「多设备账号闭环」的关键,两端各负责一半:

网站侧POST /api/auth/qr/create 生成随机 sceneId 并创建 120s 有效期会话;前端 ScanLoginDialog.vueqrcode 库把 login:<sceneId> 渲染成二维码,2s 轮询 GET /api/auth/qr/status,倒计时结束自动提示过期并允许刷新:

// client/src/components/ScanLoginDialog.vue(节选)
const data = await qrApi.create();
sceneId.value = data.sceneId;
qrDataUrl.value = await QRCode.toDataURL(`login:${data.sceneId}`, { width: 320, errorCorrectionLevel: 'M' });
pollTimer = setInterval(poll, 2000);          // 2s 轮询确认状态

async function poll() {
  const data = await qrApi.status(sceneId.value);
  if (data.status === 'confirmed') {
    localStorage.setItem('token', data.token); // 确认后直接换 JWT
    localStorage.setItem('user', JSON.stringify(data.user));
  } else if (data.status === 'expired') { qrExpired.value = true; }
}

服务端侧confirmQr 用共享密钥(crypto.timingSafeEqual 常量时间比较)校验后暂存账号,qrStatus 消费会话、按 accountId upsert 用户并签发 JWT(防止重复签发):

// server/src/controllers/authController.js(节选)
async function confirmQr(req, res) {
  const { sceneId, key, accountId } = req.body || {};
  if (!secret || !key || !safeEqual(key, secret)) return res.status(403).json({ message: '密钥校验失败' });
  // 容错:App 可能把完整内容 login:xxx 当 sceneId 传,去掉前缀再查
  const realSceneId = String(sceneId).startsWith('login:') ? String(sceneId).slice(6) : String(sceneId);
  const session = qrSessions.get(realSceneId);
  if (!session || session.status !== 'pending' || Date.now() > session.expiresAt)
    return res.status(404).json({ message: '场景不存在或已过期' });
  session.status = 'confirmed';
  session.accountId = String(accountId);   // + headIcon / userName
  return res.json({ ok: true });
}

async function qrStatus(req, res) {
  // pending → 继续等;expired → 前端提示刷新;confirmed → upsert 用户 + 签发 JWT
  qrSessions.delete(sceneId);               // 消费掉,防重复签发
  const user = await upsertScanUser(session);
  return res.json({ status: 'confirmed', token: signToken(user), user: publicUser(user) });
}

登录会话用内存 Map 存储 + 30s 定期清理;部署是 PM2 fork 单实例,重启后待确认场景失效可接受。

5.6 上传与书库管理

  • 上传POST /api/wordbooks(multipart)。Excel/CSV 经 SheetJS 解析(中英文表头别名如 单词/翻译/音标…,无表头按「单词 | 翻译」两列),逐行校验 word/meaning 必填并给出行号级错误.txt 整本正文存入 content(LONGTEXT),UTF-8 优先、检测到替换符回退 GBK 解码;封面图落盘 /uploads/covers/(存相对路径,避免写死域名导致跨域加载失败)。
  • ID 策略:可手填(正则校验 + 冲突报 409)或自动生成(slugify 剔除中文 + 冲突随机后缀)——id 就是二维码内容,三处一致:id == 二维码内容 == /wordlist/{id}
  • 管理PUT 编辑(标题/简介/公开/换封面/替换文件,替换时先清旧 word 行,保证 contentwords 不同时存在);DELETE 删除(级联删 words + 封面文件);非管理员只看自己的书(按 uploaderId 匹配,扫码用户 username 为 null 也能正确匹配)。
  • 上传前客户端预览utils/preview.js 在浏览器里先解析 Excel/Txt 并预览前 10 行,格式错误直接拦在上传前。

5.7 关键实现细节(安全与性能)

关注点 做法
共享密钥防时序攻击 safeEqualcrypto.timingSafeEqual 常量时间比较(App 侧 QR_LOGIN_SECRET 与网站 .env 保持一致)
密码安全 bcrypt(10 轮)哈希;JWT 7 天过期,前端 401 统一跳登录
上传校验 multer 按「扩展名 + MIME」双保险过滤 Excel/Txt/图片,25MB 上限,错误统一转 400
列表性能 列表查询不加载 txt 全文(exclude: ['content'],只取 content_length),避免整本 LONGTEXT 拖慢列表
预览性能 预览时正文用 SQL LEFT(content, 2000) 只取开头片段
事务一致性 建书 + 批量写词 + 封面写盘用事务,失败回滚并清理封面文件
数据库连接坑 部署机用内网 127.0.0.1 连库避免 hairpin NAT 超时;不用 localhost(会被 Node 解析成 IPv6 ::1
表结构同步 sequelize.sync() 不用 alter:true,避免每次重启给 users 表追加重复索引直到 64 个上限

5.8 部署:Nginx + PM2 + 一键发布包

  • deploy/nginx.conf:静态托管前端 dist/api/wordlist/uploads 反代到后端 3000 端口,client_max_body_size 30m 支持大文件上传;
  • deploy/ecosystem.config.js:PM2 fork 单实例、autorestart、300M 内存上限自动重启;
  • release/ 发布包 + setup.sh:上传解压后一条命令完成「装依赖 → 建表 + 管理员 → PM2 启动 → 自动配置 Nginx(nginx -t 校验失败保留备份)」;
  • 上线校验:curl /api/healthcurl /wordlist/senior-english-1(App 扫码后拉取的正是这个 JSON)。

六、交互与体验

6.1 首页:6 宫格导航 + 登录状态

  • 顶部 谙镜读书 标题 + 右侧账号状态(已登录:昵称 / 未登录onShow 每次刷新);
  • 2×3 网格,每张卡片「中文标签 + 英文 hint」;焦点卡 accent 描边 + 高亮底,非焦点卡弱描边,单色绿下辨识度清晰;
  • ↑↓←→ 均可移动焦点(网格里自然换行)、Enter 进入、返回键退出应用。

6.2 列表页:一屏 5 行,边沿式跟随

我的书籍 / 单元 / 在线添加 / 练习选书共用一套「scroll-view + 40px 菜单行」模板:聚焦行左侧 > 标记 + 高亮底,右侧 hint(N 词 / 小说 / 已添加 / 下载中...)。在线添加行还有 小说私密 小标签。空态给明确引导(如「暂无书籍,请先回到首页『添加书籍』扫码」)。

6.3 单词卡片 / 练习卡片:一个界面只讲一件事

  • 单词卡:N / M 进度 + 34px 单词 + 音标 + 词性/释义 + 例句/中译;↑↓ 切词(循环),TTS 可选,自动翻页可选;
  • 练习卡:先显单词,Enter 翻出释义(并朗读),再 Enter 下一词;释义按 词性 + 释义 + 例句 + 中译 分层展开。

6.4 扫码页:一次 Enter 走完拍照→识别→下载

  • <camera> 实时取景(160×160 带绿色扫描线)+ 四态反馈:READY(对准二维码,点击镜腿识别)→ SCANNING(阶段文案 + 三点呼吸动画)→ DONE / ERROR(<error-state> 展示原因);
  • 错误原因可读:未识别到二维码,请重试 / 不是登录二维码,请扫描网页端的登录二维码 / 二维码已失效,请重新扫码 / 服务端返回信息;
  • 添加成功后 redirectTo 我的书籍并自动打开新书;登录成功后回首页立即显示 已登录

6.5 视觉一致性

全项目统一:黑底、var(--color-primary) 主绿、--radius-md 12px 圆角、2px 描边卡片、等宽字体(monospace)展示英文与数字、无 emoji、无大面积色块,符合单色绿眼镜端规范。


七、调试与验证

7.1 开发期:AIX Preview + 热更新

  • 本地用官方 aix preview --dev <项目目录> 启动文件监听服务,每次保存 .ink / WXSS / JS 自动热更新,无需刷新;
  • 在 Harness 的 AIUI Dev Console 里打开预览窗(480×352 视口,真实 Ink 浏览器运行时)边改边看。

7.2 关键调试钩子与验证点

  • 扫码链路三段解耦detectQrContent 的「拍照 / 解析 / 识别」三个阶段各带 500ms sleep 与回调,视觉上能清楚看到走到哪一步,便于定位是拍照、解码还是识别的问题;
  • 账号解析两级兜底getAccountProfile 失败返回 nullaccountId 落到 getOrCreateLocalAccountId(),保证离线/无 OpenAPI 时登录链路也能完整走通调试;
  • 真机/真实链路验证点
    • 相机 WebP 尺寸与解码耗时(ARGB→灰度)、识别率;
    • rawValue → 书籍 id / login:<sceneId> 的语义分派是否正确;
    • 下载成功后的「自动启用 + 自动打开新书」闭环(pending_wordbook_id 消费);
    • 按键节奏:忙态 Enter 是否被 isScanning 正确挡掉;Backspace 在各页是否都正确返回、在首页是否正确退出。

7.3 PC 端与两端联调验证

  • 接口自动化server/scripts/e2e-test.js 覆盖 22 项端到端接口测试(注册/登录/上传/列表/编辑/删除/词表 JSON…);qr-flow-test.js 用脚本跑通「生成码 → 确认 → 换 JWT」的扫码登录全链路;
  • App ↔ PC 联调验证点
    • 在线书库:getResolvedAccountId() 取到的 accountId 与共享密钥能拉到「公开 + 自己上传」的书单;
    • 扫码添加:curl /wordlist/<id> 与 App 端 fetchWordlist 返回结构逐字段一致(words / content);
    • 扫码登录:网页弹窗倒计时、过期刷新、App 确认后网页自动登录,已登录 状态回首页立即可见;
    • 上传链路:Excel 表头别名、Txt GBK 编码小说、封面上传后,App 端都能正确展示与阅读。

八、成果与复盘

可运行成果

  • 一个可直接在 Rokid Glasses / AIUI 预览环境运行的全屏应用(12 页 / 6 入口 / 3 权限);
  • 扫码即得:单词本与 txt 小说两种书籍类型的添加、查看、练习、阅读闭环;
  • 支持 TTS 朗读、乱序练习、单词/小说自动翻页,偏好持久化;
  • 扫码登录打通 Rokid OpenAPI 账号与网站端,已登录 状态跨页可见;
  • 阅读进度、当前书籍、待打开书籍等状态持久化,重启恢复。
  • 配套 PC 网站 + 服务端:书库托管、上传管理、二维码分发、扫码登录,/wordlist/{id} 与 App 无缝对接(详见第五章)。

遇到的问题与解法

问题 解法
相机返回 WebP,BarcodeDetector 需要位图灰度 引入纯 JS WebPJS,沙箱 new Function 加载;RIFF 容器解析 + VP8X(ANIM/ANMF/ALPH/VP8L)归一化,decodeWebP 输出灰度图
takePhoto 必须由交互触发 全部扫码由 onKeyUp 的 Enter 或按钮 bindtap 触发,忙态 isScanning 防重入
存储读取失败/旧格式覆盖后列表变空态 会话级内存缓存(books/units/words/content)兜底,返回导航不丢数据
单词本数据含脏字段(缺字段/类型不对) normalizeWords 逐字段强转并补默认值,getSettings 布尔/数字归一化
二维码语义分派(书籍 vs 登录) 登录二维码前缀校验 login:,非登录码直接给可读报错;书籍码经 buildWordlistUrl 拼接
列表滚动跳位、看不清到哪 computeScrollTop 边沿式滚动 + 固定 48px 步进 + 240px 视口,聚焦行始终可见且逐行滚动
自动翻页定时器泄漏 单词卡/阅读器都在 onHide/onUnload/返回stopAutoFlip,返回前先停表
下载书籍后回到列表却找不到新书 pending_wordbook_id 在列表 onLoad 消费并自动打开,扫码页 redirectTo 直达
登录确认失败原因不明确 confirmQrLogin 按 statusCode 分叉(200/403/404/其他),404 优先展示服务端 message
App 无 JWT,如何对接口鉴权 后端为 App 单独开放「共享密钥 + accountId」接口(/api/auth/user、/api/app/wordlists、/api/auth/qr/confirm),密钥用 timingSafeEqual 比对
二维码内容、书籍 id、下载地址三处要一致 上传可手填 id(正则校验 + 409 防冲突)或自动 slugify 生成,保证 id == 二维码内容 == /wordlist/{id}
网站公开/私密权限与 App 在线书库一致 两端共用同一套 isPublic 判定:公开全可见、登录后额外含本人私密、admin 看全部
sequelize.sync({alter:true}) 累积重复索引到 64 上限 关闭 alter,模型变更手工 ALTER;部署机用内网 127.0.0.1 连库规避 hairpin NAT / IPv6 ::1 两个坑

后续迭代计划

  1. 错词本:记录练习中「记不住」的词,进入错词优先复习;
  2. 学习进度上报:练习/阅读完成后写回服务端,支持多设备同步;
  3. 沉浸式对话卡片:把「今日书单/今日任务」做成 AIUI 对话流卡片形态,支持语音指令(如「打开上次读的书」);
  4. 词表内容指纹:用 crypto.subtle.digest('SHA-256', …) 对书籍内容做校验,防止脏数据落地;
  5. 真机联调:在 Rokid Glasses 上验证 TTS 音色、相机功耗、实拍识别率,并打磨自动翻页节奏。
  6. PC 端域名 + HTTPS:为网站配置域名与证书,DEFAULT_SERVER 切到 https,并进一步扩展多设备同步、网页端收藏等场景。

附:运行环境与提交信息

  • 运行环境:Rokid Glasses(AIUI .ink 应用),需 camera / network / audio 权限(见 AGENTS.md);
  • 技术栈(AIUI 端):.ink SFC(WXML/WXSS)、wx 运行时 API、BarcodeDetectorwx.speech.playTTScreateOpenAPI、纯 JS WebP 解码;
  • 技术栈(PC 端):Vue 3 + Vite + Element Plus(前端)、Node.js + Express + Sequelize + MySQL 8(后端)、Nginx + PM2(部署);

更多推荐