Skip to content

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 只收 literal true,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. 下一步 ​