---
url: /docs/guide/uniapp/api/page-direct.md
---
# 页面直调端点（不在 `src/api/*.ts` 里的 3 条）

> 后端契约：[手册 · page-direct](/api-docs/v1/frontend/page-direct)（3 端点字段表 / curl / 错误码）。
> 为什么单列：这 3 条**没有 `src/api/*.ts` 封装**（页面里内联 `request` / `uni.uploadFile`）——
> 自动化提取器的盲区（生成器只扫 `src/api/*.ts`），也是「读法出错」的高危区。
> 本页**只写正文 1 条**（`upload`）；另 2 条已在专页写过，本页只做索引与交叉引用（不重复，避免两处维护）。

## 一、端点归属索引

| 端点 | 谁在调 | 正文在哪 |
|---|---|---|
| `POST api/v1/device` `myTodayHealth` | 首页健康卡 `src/pages/index/index.vue:124-141` | [device](./device.md) —— 块状结构读法 + `todayActivity.totalDistanceKm` 坑 |
| `POST api/v1/stats` `getTodayOverview` | 首页热量 / 连续天数卡 `src/pages/index/index.vue:120-122` | [stats](./stats.md) —— 嵌套两层读法 + **防回归块** |
| `POST api/v1/upload?type=avatar` | 个人资料页换头像 `src/pages/mine/info/personal-form.vue:281-350` | **本页正文**（下） |

> ⚠️ 前两条**共用同一个 `Promise.all`**（`loadToday()`）—— 它们的失败是**耦合**的，
> 见 [stats · 失败时的前端动作](./stats.md)「`Promise.all` 一损俱损」。

## upload（页面直调 · 头像上传）

**一句话**：个人资料页换头像 —— `uni.chooseImage` 选图 → **multipart 直传**拿 URL → **再调 `updateProfile` 落库**（两步，别只做第一步）。

**前端函数签名**

| 项 | 值 |
|---|---|
| 封装 | **页面直调**（非 `src/api/*.ts`）—— `src/pages/mine/info/personal-form.vue:282-318` `uploadAvatarFile()`；调用链 `chooseAvatar`（`:320-330`）→ `handleAvatarUpload`（`:332-350`） |
| 触发时机 | 用户点「头像」行 → `uni.chooseImage({ count: 1, sizeType: ['compressed'] })` → 选中即传；`uploading` 期间入口自锁（重复点击直接 return） |
| 鉴权 | `Bearer access` + `X-Env` —— **页面手动拼 header**（⚠️ **不走 `request.ts` 拦截器**） |
| 由此失去的能力 | **无自动 rotate** —— token 过期不会自动续期，直接失败（其余端点的 401 链在拦截器里，这条没有） |

**必需 payload 键**

| 键 | 从哪来 | 必填 |
|---|---|---|
| `type`（query） | 字面量 `'avatar'`，拼在 URL 上（`${BASE_URL}api/v1/upload?type=avatar`） | ✔ |
| `file`（multipart） | `uni.chooseImage` 的 `res.tempFilePaths[0]`；`uni.uploadFile` 的 `name` 参数**必须写 `file`** | ✔ |
| `Authorization` / `X-Env`（header） | `storage.token.get()` + `config.X_ENV` —— 手动拼（不是拦截器注入） | ✔ |

**响应读法**

```ts
// uni.uploadFile 的 res.data 是【字符串】（不是对象）—— 与 uni.request 的关键差异，必须先 parse
const body = typeof res.data === 'string' ? JSON.parse(res.data) : res.data;
if (res.statusCode === 200 && String(body?.code ?? '') === '0' && body?.data?.url) {
  const url = body.data.url;                     // 中文注解：业务字段在 body.data.url（COS/CDN 或本地 /uploads/...）
  const r = await updateProfile({ avatarUrl: url });   // 中文注解：上传 ≠ 落库 —— 落库要走 user 端点（这条经拦截器）
  form.avatarUrl = r?.data?.user?.avatarUrl || url;     // 中文注解：以后端回吐为准，取不到才用上传返回的 url
}
```

> ✅ **正确读法**：`body.data.url`（`body` = 已 `JSON.parse` 的响应体）
> ❌ **错误读法**：直接 `res.data.url` —— `res.data` 是**字符串**，取属性恒 `undefined` → 上传其实成功了、前端却判失败（`resolve` 分支进不去）。
> 与其余端点的读法**同一原则**：业务字段在 `data` 之下，只是这里的 `data` 要先 parse 出来。
> 字段表 / 约束（大小上限、MIME 白名单）/ 错误码见 [手册 · page-direct#upload](/api-docs/v1/frontend/page-direct#upload)。

**失败时的前端动作**

| 情形 | 前端动作 |
|---|---|
| 上传层失败（HTTP 非 200 / `code ≠ "0"` / 无 `url`） | `uploadAvatarFile` 内 `reject(new Error(body?.msg \|\| '头像上传失败'))`；页面 `catch` 用 `e instanceof Error` 判定 → toast `e.message` |
| 网络失败（`uni.uploadFile` 的 `fail`） | reject `new Error('网络异常，请检查网络连接')` → 同上 toast |
| **落库失败**（`updateProfile` 被拒） | 该层的失败**已由拦截器统一 toast**，`e` 是普通对象而非 `Error` → 页面 `catch` **不再重复提示**。⚠️ 此时图片**已上传成功**，只是没落库 —— 重试只需重调 `updateProfile`，不必重传文件 |
| 未取到 token | 前置 `reject(new Error('请先登录'))`（本条不发起请求） |
| 401 | **无自动 rotate** —— 需手动刷新 token 后重试（或引导重新登录）。别指望"等一下再试"能自愈 |

**⚠️ 坑位**

* **不是 action 协议**：没有 `{action, payload}` 信封 —— `type` 在 **query**、`file` 在 **multipart body**、header 要**手动拼**。
  别的端点靠 `request.ts` 托管的三件事（鉴权头 / `X-Env` / 401 rotate），这条**全得自己来**。
* **漏 `X-Env` 会静默落错库**：后端对缺头 / 非法值有 Dev 兜底 —— 上传会「成功」，但记录写进了测试库，
  线上访问图片时才暴露（与 [手册 G2](/api-docs/v1/frontend/#g2-请求头) 同一原则：X-Env 全程一致）。
* **`res.data` 是字符串**：`uni.uploadFile` 不像 `uni.request` 那样给结构化响应 —— 忘了 parse 就是「后端成功、前端报错」。
* **上传成功 ≠ 头像生效**：必须走 `updateProfile({ avatarUrl })` 落库（读法见 [user](./user.md)）；页面还顺手写了本地缓存
  （`persistLocalCache()` → `uni.setStorageSync`，下次进入表单预填用），缓存与后端不一致时**以 `res.data.user.avatarUrl` 为准**。
* **上传前先自查 token 有效性**：这条没有 rotate 兜底，过期即失败，且失败提示是通用文案（不指向"重新登录"）。
