AIUI 智能体应用实战:在 Rokid Glasses 上做一个「谙镜读书」——扫码即得、眼镜端读书与单词练习
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 端(网站 + 服务端)负责书库托管、上传管理、二维码分发与扫码登录,详见第五章。
二、场景与用户意图
典型唤醒与使用链路
- 加书:老师把一本书的二维码发到群里,用户唤醒「谙镜读书」→ 首页「扫码添加」→ 对准二维码拍一张 → 识别 → 下载 → 自动跳转到新书页,全程一次 Enter。
- 背单词:首页「我的书籍」→ 选书 → 选单元 → 单词卡自动翻页 + 朗读,眼睛跟着走,几乎零操作。
- 巩固:首页「知识练习」→ 选书 → 选单元(或「全部」)→ 卡片先出单词,Enter 翻释义,自己回忆一遍再核对。
- 读小说:书是 txt 类型时从「我的书籍」进入阅读器,按页阅读、自动记忆进度,下次打开接着读。
- 分发/同步:内容运营者把书挂到在线书库,用户首页「在线添加」按账号拉取书单,一键下载;重复按 Enter 可以删除本地副本。
- 登录:网页端展示登录二维码,用户「扫码登录」→ 确认 → 网页自动登录,眼镜与网站账号打通。
- 内容生产:运营者在 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>
关键点:
- 页面级按键事件
onKeyUp按event.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 大字段
}
}
值得一提的三个设计:
- 存储分层:
wordbooks只存元信息,单词/正文按units:<id>、words:<id>:<unit>、content:<id>单独存,避免列表页反复读大对象,也避免单 key 过大。 - 会话级内存缓存兜底:
booksCache / unitsCache / wordsCache / contentCache在存储读取失败或被旧格式覆盖时兜底,防止返回导航或页面重建时列表变成空态。 - 删除即清理:
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 云端账号(createOpenAPI → openapi.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};必须由交互触发 |
BarcodeDetector(import from 'barcode')→ detect({data,width,height}) |
二维码识别,返回 [{format, rawValue}] |
wx.request |
下载书籍 / 在线书库 / 登录确认 / 用户信息(GET/POST,dataType:'json') |
wx.speech.playTTS(text) |
单词朗读 |
createOpenAPI(import 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.vue 用 qrcode 库把 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 行,保证content与words不同时存在);DELETE删除(级联删 words + 封面文件);非管理员只看自己的书(按uploaderId匹配,扫码用户username为 null 也能正确匹配)。 - 上传前客户端预览:
utils/preview.js在浏览器里先解析 Excel/Txt 并预览前 10 行,格式错误直接拦在上传前。
5.7 关键实现细节(安全与性能)
| 关注点 | 做法 |
|---|---|
| 共享密钥防时序攻击 | safeEqual 用 crypto.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/health、curl /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失败返回null,accountId落到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 两个坑 |
后续迭代计划
- 错词本:记录练习中「记不住」的词,进入错词优先复习;
- 学习进度上报:练习/阅读完成后写回服务端,支持多设备同步;
- 沉浸式对话卡片:把「今日书单/今日任务」做成 AIUI 对话流卡片形态,支持语音指令(如「打开上次读的书」);
- 词表内容指纹:用
crypto.subtle.digest('SHA-256', …)对书籍内容做校验,防止脏数据落地; - 真机联调:在 Rokid Glasses 上验证 TTS 音色、相机功耗、实拍识别率,并打磨自动翻页节奏。
- PC 端域名 + HTTPS:为网站配置域名与证书,
DEFAULT_SERVER切到 https,并进一步扩展多设备同步、网页端收藏等场景。
附:运行环境与提交信息
- 运行环境:Rokid Glasses(AIUI
.ink应用),需camera/network/audio权限(见AGENTS.md); - 技术栈(AIUI 端):
.inkSFC(WXML/WXSS)、wx运行时 API、BarcodeDetector、wx.speech.playTTS、createOpenAPI、纯 JS WebP 解码; - 技术栈(PC 端):Vue 3 + Vite + Element Plus(前端)、Node.js + Express + Sequelize + MySQL 8(后端)、Nginx + PM2(部署);
更多推荐

所有评论(0)