---
url: /docs/api-docs/v1/oauth/uniapp-dev-guide.md
---
# uniapp 前端开发指南（OAuth 登录链落地手册）

> 📍 来源：OAuth 文档 `uniapp-dev-guide.html`
> 定位：从零把登录链接进 uniapp 工程的**施工手册** —— 按工程阶段组织，代码可整段拷贝
> API 字段细节看 [9 Action 字段表](/api-docs/v1/oauth/actions)，SDK 方法签名看 [SDK 参考](/api-docs/v1/oauth/sdk)，本文不重复
> 所有代码已对齐 SDK 2026-09-11 审查版真签名

> ⚠️ **归属与分工边界（2026-09-20 划定 — 防止三处同讲一件事后互相漂移）**
>
> | 问题 | 去哪看 |
> |---|---|
> | **字段表 / 错误码码值 / curl / HTTP 形态** | [前端联调 API 手册](/api-docs/v1/frontend/)（**契约权威** — 本文与 guide 都不得复述） |
> | **调用视角**（哪个页面何时调、响应怎么读、失败时前端做什么） | [guide/uniapp/api/oauth](/guide/uniapp/api/oauth) |
> | **从零施工**（拷 SDK / 基座 / manifest / 真机调试清单） | **本文** |
>
> ⚠️ **§2.2「业务请求的 401 单飞」与手册 G4/G5、guide/api/oauth 三处讲的是同一件事** —— 分工如下：
> **实现细节（拦截器怎么写、单飞并发语义）以本文为准**；**错误码分档与处置（哪个码强登出/哪个码只 toast）一律以手册为准**。
> 三者若出现不一致，**以手册为准**并回来修本文。
>
> 📦 **原 §7「平台差异坑速查表」已并入** [guide/uniapp 铁律](/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 铁律](/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 字段表](/api-docs/v1/oauth/actions)
* 存储与安全义务 → [安全规范](/api-docs/v1/oauth/security)
* 联调用例 → [Cookbook](/internal-wiki/oauth-deep-dive/)
* 遇到问题先查 [错误码矩阵](/api-docs/v1/oauth/error-codes)
