Appearance
uniapp 前端开发指南(OAuth 登录链落地手册)
📍 来源:OAuth 文档
uniapp-dev-guide.html定位:从零把登录链接进 uniapp 工程的施工手册 —— 按工程阶段组织,代码可整段拷贝 API 字段细节看 9 Action 字段表,SDK 方法签名看 SDK 参考,本文不重复 所有代码已对齐 SDK 2026-09-11 审查版真签名
⚠️ 归属与分工边界(2026-09-20 划定 — 防止三处同讲一件事后互相漂移)
问题 去哪看 字段表 / 错误码码值 / curl / HTTP 形态 前端联调 API 手册(契约权威 — 本文与 guide 都不得复述) 调用视角(哪个页面何时调、响应怎么读、失败时前端做什么) guide/uniapp/api/oauth 从零施工(拷 SDK / 基座 / manifest / 真机调试清单) 本文 ⚠️ §2.2「业务请求的 401 单飞」与手册 G4/G5、guide/api/oauth 三处讲的是同一件事 —— 分工如下: 实现细节(拦截器怎么写、单飞并发语义)以本文为准;错误码分档与处置(哪个码强登出/哪个码只 toast)一律以手册为准。 三者若出现不一致,以手册为准并回来修本文。
📦 原 §7「平台差异坑速查表」已并入 guide/uniapp 铁律(App/H5/小程序 五坑一张表 — 避免同一坑三处各写一份)。 本文 §3.2 的 rich-text/SVG 结论保留(属施工细节,非平台差异清单)。
1. 工程准备
1.1 工程形态
| 形态 | 适用 | 说明 |
|---|---|---|
| HBuilderX 可视化工程 | 团队主用 HBuilderX、需要云打包 | 直接新建 uni-app 项目;本文默认此形态 |
| CLI 工程(vue3+vite) | 想要 npm 生态/自定义构建 | npx degit dcloudio/uni-preset-vue#vite-ts;__DEV__ 注入需自行配置 |
本 SDK 兼容两者:client.ts 只依赖 uni.request/getStorageSync/setStorageSync,无第三方 UI/状态库。
1.2 拷入 SDK
bash
cp -r QM-APP/apps/mp-official/utils/oauth <工程>/utils/oauth
cp QM-APP/apps/mp-official/utils/oauth-demo.vue <工程>/pages/oauth-demo.vue # 可选:先跑 demo 再拆pages.json 注册 demo 页验证链路:
json
{ "path": "pages/oauth-demo", "style": { "navigationBarTitleText": "登录演示" } }1.3 BASE_URL 环境切换
SDK 内置切换(__DEV__ === true → http://localhost:3003,否则生产)。
HBuilderX 工程:manifest.json → 源码视图 → 自定义节点注入 __DEV__;或直接构造时覆盖:
typescript
// utils/env.ts
const isDev = process.env.NODE_ENV === 'development';
export const BASE_URL = isDev ? 'http://localhost:3000' : 'https://api.qingmulife.cn';
// new OAuthHttpClient({ baseUrl: BASE_URL }) // 显式覆盖最直观,推荐⚠️
https://qingmulife.cn是官网域名。API 域名是https://api.qingmulife.cn。
2. 网络层封装
2.1 H5 跨域(dev)
vite/webpack dev server 加代理,只影响 H5 调试:
typescript
// vite.config.ts
server: { proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true } } }App 端无跨域概念;生产包必须 HTTPS(manifest 里不要保留 http 明文白名单)。
2.2 业务请求的 401 单飞(核心)
登录后所有业务 API 走统一包装:
typescript
import { authFetch } from '@/utils/oauth/interceptor';
// 业务 API 示例:内部自动处理 401 → rotate → 重放一次
export function getTodayOverview() {
return authFetch((access) => request(`/api/v1/stats`, { action: 'getTodayOverview', token: access }));
}规则复习:
- rotate 失败 → 拦截器已清会话并跳登录
- 业务层拿到
null直接 return,不要自行重试
3. 登录页实现(核心章)
3.1 页面状态机
idle ──载入──→ captchaReady ──点「获取验证码」──→ sending ──成功──→ counting(60s) ──→ idle
│ 任何失败 │ 图形码失败自动刷新 │ 倒计时结束
└→ captchaReady(重新拉码) ←───────┘
counting(60s) ──点「登录」──→ loggingIn ──成功──→ 按 onboardingDone 分流主页/向导
└→ captchaReady + 保留短信码输入⚠️ V2 实况(2026-09-17,先跑通阶段):登录响应暂以
user.isNewUser(注册当次 true)驱动分流 — true → 弹「完善基础资料」弹窗(向导 step1,提交updateBasicProfile);false → 主页。onboardingDone真分流随 user 模块落库阶段接回。
3.2 图形验证码渲染 —— ⚠️ 最大平台坑
uniapp App 端 rich-text 不支持 SVG 标签(官方组件限制)。方案矩阵:
| 方案 | 兼容性 | 结论 |
|---|---|---|
rich-text 直接塞 svg 字符串 | ❌ App 端不渲染 | 禁用 |
svg → base64 dataURL → <image> | ✅ Android 5+/iOS 12+(现代设备全覆盖) | 推荐 |
web-view 内嵌 HTML 渲染 | ✅ 全端 | 兜底(重,通信麻烦) |
| mp-html / uparse 插件 | ✅ | 已用插件生态可选 |
✅ 首选(2026-09-11 起):服务端直出 PNG —— captcha 响应新增 png 字段(data:image/png;base64 dataURL),<image :src="png"> 全端直显:
typescript
const r = await client.getCaptcha();
captchaId.value = r.captchaId;
captchaSrc.value = r.png; // PNG dataURL,App/H5 直显或直接用组件包 utils/oauth/captcha-image.vue(已内置 PNG 优先 + 点击刷新):
html
<captcha-image v-model:captchaId="captchaId" @error="showError" />3.3 60s 倒计时(与限流对齐)
typescript
const countdown = ref(0);
let timer: number | undefined;
function startCooldown(sec = 60) {
countdown.value = sec;
timer = setInterval(() => {
if (--countdown.value <= 0) clearInterval(timer);
}, 1000);
}
onUnmounted(() => clearInterval(timer));- 倒计时期间按钮
:disabled="countdown > 0",文案{n}s 后重发 - 收到
40101005且 msg 含「60 秒」→ 重置倒计时 60s(服务端为准)
3.4 协议勾选(前端拦截 40101008)
html
<checkbox :checked="agreed" @tap="agreed = !agreed" />
<text>我已阅读并同意 <navigator url="/pages/agreement/index">《用户协议》</navigator> 与 <navigator url="/pages/privacy/index">《隐私政策》</navigator></text>
<button :disabled="!agreed || countdown > 0 || loggingIn" @tap="onLogin">登录</button>- 未勾选禁用按钮(服务端
agreementAccepted只收 literaltrue,40101008兜底) - 协议正文可内嵌
https://api.qingmulife.cn/agreement与/privacy(web-view 加载生产已部署页面)
3.5 错误处理统一出口
typescript
import { toClientMessage, OAuthErrorCode } from '@/utils/oauth/errors';
function showError(err: unknown) {
const m = toClientMessage(err);
if (m.oauthCode === OAuthErrorCode.CAPTCHA_WRONG
|| m.oauthCode === OAuthErrorCode.CAPTCHA_EXPIRED) {
loadCaptcha(); // 图形码失败 → 自动刷新,不打断输入
}
uni.showToast({ title: m.advice, icon: 'none' });
}4. 会话管理
4.1 冷启动恢复(App.vue)
typescript
// App.vue onLaunch
import { TokenStorage } from '@/utils/oauth/storage';
import { rotateOnce } from '@/utils/oauth/interceptor';
onLaunch(async () => {
const refresh = TokenStorage.getRefreshToken();
if (!refresh) return goLogin(); // 从未登录
const ok = await rotateOnce(); // 续期(失败内部已清会话跳登录)
if (ok) {
const user = TokenStorage.getUser();
// V2 实况:onboardingDone 未随 rotate 返回 → 冷启动一律 goHome();
// 弹窗仅在「注册当次」触发(isNewUser — 见 §3.1 注)。落库阶段接回真分流。
user?.onboardingDone ? goHome() : goOnboarding();
}
});4.2 路由守卫
uniapp 无全局路由守卫,用 uni.addInterceptor 拦 navigateTo/switchTab/relaunch:
typescript
const needAuthPages = ['/pages/home/index', '/pages/profile/index'];
['navigateTo', 'switchTab', 'redirectTo'].forEach((api) =>
uni.addInterceptor(api, {
invoke(args) {
if (needAuthPages.some((p) => args.url.startsWith(p)) && !TokenStorage.getAccessToken()) {
uni.reLaunch({ url: '/pages/login/index' });
return false;
}
return true;
},
}),
);4.3 主动续期
登录/rotate 响应带 token_expiring。SDK 已封装 onTokenExpiring 回调:
typescript
client.onTokenExpiring = () => setTimeout(() => rotateOnce(), 2000); // 空闲 2s 后静默续5. 微信登录(App 端)
5.1 manifest 配置(一次性)
manifest.json → App 模块配置 → OAuth(登录鉴权) → 勾选「微信登录」:
| 项 | 值 |
|---|---|
| appid | 微信开放平台(open.weixin.qq.com)移动应用 AppID —— 不是公众平台 |
| Universal Links(iOS) | https://你的域名/uni/,需与开放平台后台一致 + 服务器放 apple-app-site-association |
5.2 调用链
typescript
uni.login({
provider: 'weixin',
success: async ({ code }) => {
// 拿到微信授权码 → 走后端 callback(unionid 自动合并账号)
const r = await client.wechatCallback({
code,
source: 'app',
agreementAccepted: true,
});
TokenStorage.saveTokens(r, r.user);
},
});5.3 ⚠️ 必须自定义基座
微信登录(OAuth 模块)与 KeyStore 加密都依赖原生能力 —— HBuilderX 标准基座测不了:
HBuilderX → 运行 → 运行到手机或模拟器 → 制作自定义调试基座 → 云打包(勾微信登录模块)
→ 真机运行选「自定义基座」5.4 微信登录常见错误
| 现象 | 原因 |
|---|---|
| 「universal link 校验不通过」(iOS) | 开放平台 URL 与 manifest 不一致 / 服务器缺 AASA 文件 |
后端返回 40101010 | 服务端微信凭据未配置 / 微信侧故障 |
后端返回 40101007 | 该微信 openid 已绑定其他账号 |
6. 真机调试与打包检查清单
| 能力 | 标准基座 | 自定义基座 | 说明 |
|---|---|---|---|
| 手机号登录全链 | ✅ | ✅ | 纯 JS 层 |
| KeyStore 加密存储 | ⚠️ 可能无原生桥 → 自动降级 XOR | ✅ | 降级有 console.warn |
| 微信登录 | ❌ | ✅ | 原生 SDK |
生产打包前自检:
- [ ] manifest 无 http 明文白名单残留(dev 专用项)
- [ ] BASE_URL 走生产域名(grep 确认无 localhost)
- [ ] clientId 稳定性:多入口 App 用同一持久化 clientId(SDK 默认行为)—— 换 clientId 会被视为新设备
- [ ] 协议页两个 URL 可访问(
/agreement/privacy) - [ ] 真机全流程:注册 → 登录 → 杀 App 冷启动恢复 → 退出登录
6.1 ⚠️ 施工铁律:失败路径也要刷新列表(2026-09-21 真机实锤)
症状:设备列表点「解除连接」→ 弹出「绑定不存在」→ 列表纹丝不动,仍显示「已连接」。 用户再点一次,还是同一个错 —— 幽灵状态没有任何机制能消除它。
根因:这是极常见的写法陷阱 —— 把刷新放在 await 之后:
js
// ❌ 错:抛错即跳过 load()
try {
await unbindVendor(vendor);
uni.showToast({ title: '已解除连接', icon: 'success' });
load(); // ← 只有成功路径会执行
} finally {
busy.value = '';
}为什么错:这类接口的失败往往恰恰说明本地状态是陈旧的 (409 冲突 / 404 不存在 / 403 无权限 —— 都意味着「你看到的不是真的」)。 失败时不刷新 = 把用户的错误认知锁死在屏幕上。
正解:把 load() 放进 finally:
js
// ✅ 对:无论成败都回到服务端真值
try {
await unbindVendor(vendor);
uni.showToast({ title: '已解除连接', icon: 'success' });
} finally {
busy.value = '';
load(); // ⭐ 失败时正好把幽灵状态纠正掉
}⚠️ 不要为此自造错误文案:utils/request.ts 在业务码非 0 时已统一 toast(body.msg)。 前端再弹一次会盖掉后端更准确的那句(后弹的赢)—— 这正是 DBG-009 里 smsLogin 那条 catch 坏掉的方式。错误提示的所有权在 request 层,文案的事实源在后端。
这条与具体接口无关 —— 凡「动作类接口 + 列表页」的组合都适用 (解绑 / 删除 / 取消报名 / 退出群组…)。判据:这个动作失败后,用户看到的还是真的吗?
7. 平台差异坑速查表
📦 已并入 guide/uniapp 铁律(2026-09-20)—— 五坑一张表(rich-text/SVG · storage 同步语义 · 跨域 · http 明文 · cookie) 现由 guide 统一维护。本文不复述:同一张表放两处,改一处忘一处就是下一次漂移。
本链路与平台无关的关键点仍以本文为准:token 走 header(不依赖 cookie)· 生产包必须 HTTPS(manifest 不留 http 白名单,见 §1.3 / §2.1)。
8. 下一步
- API 字段与错误分支 → 9 Action 字段表
- 存储与安全义务 → 安全规范
- 联调用例 → Cookbook
- 遇到问题先查 错误码矩阵