Explorar o código

docs: add miniprogram header capsule safe-area implementation plan.

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong hai 1 mes
pai
achega
0599b7a3a9

+ 494 - 0
docs/superpowers/plans/2026-09-12-miniprogram-header-capsule-safe-area.md

@@ -0,0 +1,494 @@
+# 小程序顶部栏胶囊安全区适配 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 让所有 `navigationStyle: custom` 页面的业务顶栏避开微信状态栏与右上角胶囊(方案 B:加高 + 右侧预留)。
+
+**Architecture:** 复用 `utils/safeArea.js` 量纲;新增纯函数 `buildSafeAreaCssVarsStyle` 供顶栏根节点 `:style` 注入 CSS 变量;`.db-header` / `.bx-page .topbar` / `.mp-header` 统一消费 `--sq-*`。微信端不依赖 `document.documentElement`。
+
+**Tech Stack:** uni-app Vue3、现有 SCSS 主题(`db-theme` / `bx-theme` / `mp-shell`)、`getMenuButtonBoundingClientRect`
+
+**Spec:** `docs/superpowers/specs/2026-09-12-miniprogram-header-capsule-safe-area-design.md`
+
+---
+
+## 文件结构
+
+| 路径 | 职责 |
+|------|------|
+| `miniprogram/utils/safeArea.js` | 量纲计算 + `buildSafeAreaCssVarsStyle` |
+| `miniprogram/utils/__tests__/safeArea.style.test.mjs` | 纯函数 Node 单测(无 uni 运行时依赖的 style 映射) |
+| `miniprogram/styles/db-theme.scss` | `.db-header` 方案 B 几何 |
+| `miniprogram/styles/bx-theme.scss` | `.topbar` 对齐同一变量 |
+| `miniprogram/styles/mp-shell.scss` | `.mp-header*` 用 safe-top + capsule-right |
+| `miniprogram/styles/index.scss` | 确认 `.sq-hero-header` 变量路径一致(注释/兜底) |
+| `miniprogram/components/app-safe-header.vue` | 改用 `buildSafeAreaCssVarsStyle`(供后续新页) |
+| `miniprogram/pages/db/**/*.vue` | `:style="safeAreaStyle"` + `__nav` 结构 |
+| `miniprogram/pages/bx/**/*.vue` + `components/bx-ui/bx-wf-config-page.vue` | topbar 绑定 style |
+| `miniprogram/pages/{workbench,home,todo}/*.vue` 等 | mp-header 绑定 style |
+| `docs/miniprogram-ui-spec-sanqiang.md` | §3.2 补充胶囊 SHALL |
+
+---
+
+### Task 1: `buildSafeAreaCssVarsStyle` + 单测
+
+**Files:**
+- Modify: `miniprogram/utils/safeArea.js`
+- Create: `miniprogram/utils/__tests__/safeArea.style.test.mjs`
+
+- [ ] **Step 1: 写失败单测(先测 style 映射)**
+
+创建 `miniprogram/utils/__tests__/safeArea.style.test.mjs`:
+
+```js
+import assert from 'node:assert/strict'
+import { buildSafeAreaCssVarsStyle } from '../safeArea.js'
+
+const style = buildSafeAreaCssVarsStyle({
+  topSafeHeight: 44,
+  navContentHeight: 40,
+  headerHeight: 84,
+  capsuleRightSafeWidth: 96,
+})
+
+assert.equal(style['--sq-safe-top'], '44px')
+assert.equal(style['--sq-nav-height'], '40px')
+assert.equal(style['--sq-header-height'], '84px')
+assert.equal(style['--sq-capsule-right-safe'], '96px')
+console.log('ok')
+```
+
+- [ ] **Step 2: 运行确认失败**
+
+Run:
+
+```bash
+cd miniprogram && node utils/__tests__/safeArea.style.test.mjs
+```
+
+Expected: `ERR_MODULE_NOT_FOUND` 或 `buildSafeAreaCssVarsStyle is not a function`
+
+- [ ] **Step 3: 实现函数**
+
+在 `miniprogram/utils/safeArea.js` 末尾追加:
+
+```js
+export function buildSafeAreaCssVarsStyle(metrics) {
+  const m = metrics || getGlobalSafeArea()
+  return {
+    '--sq-safe-top': `${m.topSafeHeight}px`,
+    '--sq-nav-height': `${m.navContentHeight}px`,
+    '--sq-header-height': `${m.headerHeight}px`,
+    '--sq-capsule-right-safe': `${m.capsuleRightSafeWidth}px`,
+  }
+}
+```
+
+确保文件可作为 ESM 被 Node 导入:若当前无 `"type":"module"`,测试改为动态 `createRequire` 不可行时,把测试改为内联复制映射断言并在注释中要求实现与之一致;**优先**给 `safeArea.js` 保持现有导出方式(uni 打包为普通 JS),测试文件用:
+
+```js
+import { createRequire } from 'node:module'
+const require = createRequire(import.meta.url)
+
+// 若 safeArea 依赖 uni,则只测纯映射:复制最小实现到测试或拆出 safeAreaCss.js
+```
+
+**推荐落地(避免 uni 依赖拖垮 Node 测试):**
+
+新建 `miniprogram/utils/safeAreaCss.js`(纯函数,无 `uni`):
+
+```js
+export function buildSafeAreaCssVarsStyle(metrics) {
+  return {
+    '--sq-safe-top': `${metrics.topSafeHeight}px`,
+    '--sq-nav-height': `${metrics.navContentHeight}px`,
+    '--sq-header-height': `${metrics.headerHeight}px`,
+    '--sq-capsule-right-safe': `${metrics.capsuleRightSafeWidth}px`,
+  }
+}
+```
+
+`safeArea.js` 改为:
+
+```js
+import { buildSafeAreaCssVarsStyle } from './safeAreaCss.js'
+export { buildSafeAreaCssVarsStyle }
+
+export function getSafeAreaStyle() {
+  return buildSafeAreaCssVarsStyle(getGlobalSafeArea())
+}
+```
+
+单测只 import `safeAreaCss.js`。
+
+- [ ] **Step 4: 再跑单测**
+
+```bash
+cd miniprogram && node utils/__tests__/safeArea.style.test.mjs
+```
+
+Expected: 打印 `ok`,exit 0
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add miniprogram/utils/safeAreaCss.js miniprogram/utils/safeArea.js miniprogram/utils/__tests__/safeArea.style.test.mjs
+git commit -m "feat(miniprogram): add safe-area CSS vars style builder"
+```
+
+---
+
+### Task 2: 地磅 `.db-header` 样式(方案 B)
+
+**Files:**
+- Modify: `miniprogram/styles/db-theme.scss`(约 L10–36)
+
+- [ ] **Step 1: 替换 `.db-header` 块为:**
+
+```scss
+.db-header {
+  position: sticky;
+  top: 0;
+  z-index: 50;
+  background: #fff;
+  border-bottom: 1px solid #eee;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
+  height: var(--sq-header-height, calc(44px + 44px));
+  padding-top: var(--sq-safe-top, 44px);
+  box-sizing: border-box;
+}
+
+.db-header__nav {
+  height: var(--sq-nav-height, 44px);
+  display: grid;
+  grid-template-columns: auto minmax(0, 1fr) var(--sq-capsule-right-safe, 96px);
+  align-items: center;
+  padding: 0 12px 0 8px;
+  box-sizing: border-box;
+}
+
+.db-header__left {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  min-width: 0;
+}
+
+.db-header__back {
+  font-size: 20px;
+  color: #333;
+  padding: 4px 8px;
+}
+
+.db-header__title {
+  font-size: 17px;
+  font-weight: 700;
+  color: #111827;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.db-header__user {
+  font-size: 12px;
+  color: #6b7280;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  text-align: right;
+  padding-right: 4px;
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add miniprogram/styles/db-theme.scss
+git commit -m "style(miniprogram): adapt db-header for WeChat capsule safe area"
+```
+
+---
+
+### Task 3: 地磅页面挂载变量 + `__nav` 结构
+
+**Files:**
+- Modify:
+  - `miniprogram/pages/db/dashboard/index.vue`
+  - `miniprogram/pages/db/plans/list.vue`
+  - `miniprogram/pages/db/plans/detail.vue`
+  - `miniprogram/pages/db/inventory/list.vue`
+  - `miniprogram/pages/db/inventory/detail.vue`
+  - `miniprogram/pages/db/weighing/list.vue`
+  - `miniprogram/pages/db/weighing/detail.vue`
+
+- [ ] **Step 1: 每个文件 script 增加**
+
+```js
+import { getSafeAreaStyle } from '@/utils/safeArea'
+const safeAreaStyle = getSafeAreaStyle()
+```
+
+(若页面已是 `<script setup>`,直接按上;否则在 `data`/`setup` 暴露 `safeAreaStyle`。)
+
+- [ ] **Step 2: 模板统一改结构**
+
+仪表盘示例(有用户名):
+
+```html
+<view class="db-header" :style="safeAreaStyle">
+  <view class="db-header__nav">
+    <view class="db-header__left">
+      <text class="db-header__back" @click="goWorkbench">←</text>
+      <text class="db-header__title">仪表盘</text>
+    </view>
+    <text class="db-header__user">{{ username }}</text>
+    <view /><!-- 胶囊占位列 -->
+  </view>
+</view>
+```
+
+列表/详情(无用户名):
+
+```html
+<view class="db-header" :style="safeAreaStyle">
+  <view class="db-header__nav">
+    <view class="db-header__left">
+      <text class="db-header__back" @click="goBack">←</text>
+      <text class="db-header__title">{{ title }}</text>
+    </view>
+    <view />
+    <view />
+  </view>
+</view>
+```
+
+删除 `dashboard/index.vue` 中 scoped 重复的 `.db-header__user`(已迁入 theme),避免冲突。
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add miniprogram/pages/db/
+git commit -m "fix(miniprogram): wire db pages to capsule safe-area header"
+```
+
+---
+
+### Task 4: 报销 `.topbar` 样式 + 页面绑定
+
+**Files:**
+- Modify: `miniprogram/styles/bx-theme.scss`(`.bx-page .topbar` 约 L74–148)
+- Modify 所有含 `<view class="topbar">` 的文件:
+  - `miniprogram/pages/bx/advance/{list,apply,detail}.vue`
+  - `miniprogram/pages/bx/reimburse/{list,apply,detail}.vue`
+  - `miniprogram/pages/bx/mine/list.vue`
+  - `miniprogram/pages/bx/all/list.vue`
+  - `miniprogram/pages/bx/invoice/{mine,admin}.vue`
+  - `miniprogram/pages/bx/admin/expense-item.vue`
+  - `miniprogram/components/bx-ui/bx-wf-config-page.vue`
+
+- [ ] **Step 1: 替换 topbar 高度规则**
+
+将:
+
+```scss
+height: calc(58px + env(safe-area-inset-top, 0px));
+padding: env(safe-area-inset-top, 0px) 12px 0;
+```
+
+改为:
+
+```scss
+.bx-page .topbar {
+  position: sticky;
+  top: 0;
+  z-index: 50;
+  display: grid;
+  grid-template-columns: 1fr auto var(--sq-capsule-right-safe, 96px);
+  align-items: center;
+  height: var(--sq-header-height, calc(44px + 44px));
+  padding: var(--sq-safe-top, 44px) 12px 0;
+  box-sizing: border-box;
+  background: rgba(255, 255, 255, 0.92);
+  box-shadow: 0 1px 0 rgba(217, 232, 226, 0.85), 0 8px 20px rgba(15, 23, 42, 0.06);
+}
+
+.bx-page .topbar::before {
+  height: var(--sq-header-height, calc(44px + 44px));
+  /* 其余渐变背景保持不变 */
+}
+```
+
+标题仍 `justify-self: center`;`topbar-right` 作为中间/右侧业务槽时不得伸入最后一列——最后一列留给胶囊(grid 第三列为空即可;若现结构是 `1fr auto 1fr`,改为 `1fr auto var(--sq-capsule-right-safe)`,左侧放返回,中间标题,右侧列空)。
+
+具体 grid:
+
+```scss
+grid-template-columns: minmax(0, 1fr) auto var(--sq-capsule-right-safe, 96px);
+```
+
+`topbar-title` 放第二列居中;`topbar-right` 若需对称,可并入第一列或改为不占用第三列。
+
+- [ ] **Step 2: 每个 topbar 根节点**
+
+```html
+<view class="topbar" :style="safeAreaStyle">
+```
+
+script:
+
+```js
+import { getSafeAreaStyle } from '@/utils/safeArea'
+const safeAreaStyle = getSafeAreaStyle()
+```
+
+- [ ] **Step 3: 修正 `expense-item.vue` 列表高度计算**
+
+将:
+
+```js
+const topbar = 44 + (sys.statusBarHeight || 0)
+```
+
+改为:
+
+```js
+import { getGlobalSafeArea } from '@/utils/safeArea'
+const topbar = getGlobalSafeArea().headerHeight
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add miniprogram/styles/bx-theme.scss miniprogram/pages/bx/ miniprogram/components/bx-ui/bx-wf-config-page.vue
+git commit -m "fix(miniprogram): adapt bx topbar for WeChat capsule safe area"
+```
+
+---
+
+### Task 5: 壳层 `.mp-header`(工作台/首页/待办/占位)
+
+**Files:**
+- Modify: `miniprogram/styles/mp-shell.scss`(`.mp-header` 及 `--home` / `--workbench`)
+- Modify:
+  - `miniprogram/pages/workbench/workbench.vue`
+  - `miniprogram/pages/home/home.vue`
+  - `miniprogram/pages/todo/todo.vue`
+  - `miniprogram/pages/common/biz-placeholder.vue`
+  - `miniprogram/components/bx-ui/bx-placeholder-page.vue`
+- Modify: `miniprogram/components/app-safe-header.vue`(改用 `buildSafeAreaCssVarsStyle`)
+
+- [ ] **Step 1: 更新 mp-shell 头部 padding**
+
+```scss
+.mp-header {
+  background: var(--mp-header-gradient);
+  color: #fff;
+  padding:
+    calc(28rpx + var(--sq-safe-top, env(safe-area-inset-top, 0px)))
+    calc(32rpx + var(--sq-capsule-right-safe, 96px))
+    32rpx
+    32rpx;
+  border-bottom-left-radius: var(--mp-radius-xl);
+  border-bottom-right-radius: var(--mp-radius-xl);
+  box-shadow: 0 16rpx 40rpx rgba(10, 159, 109, 0.12);
+  box-sizing: border-box;
+}
+
+.mp-header--home {
+  padding-top: calc(40rpx + var(--sq-safe-top, env(safe-area-inset-top, 0px)));
+  padding-bottom: 48rpx;
+}
+
+.mp-header--workbench {
+  /* 去掉魔法 40px 双叠加;仅用 safe-top + 内容间距 */
+  padding-top: calc(48rpx + var(--sq-safe-top, env(safe-area-inset-top, 0px)));
+  padding-bottom: 56rpx;
+  min-height: calc(200rpx + var(--sq-safe-top, 0px));
+  box-sizing: border-box;
+}
+```
+
+工作台 `mp-header__row` 已有左右分栏;右侧用户栈靠 `padding-right` 的 capsule 预留避开胶囊,**不要**再把退出按钮画进胶囊区。
+
+- [ ] **Step 2: 各页 `:style="safeAreaStyle"` + `getSafeAreaStyle()`**
+
+```html
+<view class="mp-header mp-header--workbench" :style="safeAreaStyle">
+```
+
+- [ ] **Step 3: `app-safe-header.vue` 改用**
+
+```js
+import { getGlobalSafeArea, buildSafeAreaCssVarsStyle } from '@/utils/safeArea'
+const headerStyle = computed(() => ({
+  ...buildSafeAreaCssVarsStyle(getGlobalSafeArea()),
+  '--sq-header-background': props.background,
+}))
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add miniprogram/styles/mp-shell.scss miniprogram/pages/workbench/workbench.vue miniprogram/pages/home/home.vue miniprogram/pages/todo/todo.vue miniprogram/pages/common/biz-placeholder.vue miniprogram/components/bx-ui/bx-placeholder-page.vue miniprogram/components/app-safe-header.vue
+git commit -m "fix(miniprogram): adapt mp-header for WeChat capsule safe area"
+```
+
+---
+
+### Task 6: 规范文档 + 手工验收清单
+
+**Files:**
+- Modify: `docs/miniprogram-ui-spec-sanqiang.md` §3.2
+
+- [ ] **Step 1: 在 §3.2 追加**
+
+```markdown
+#### 自定义导航与微信胶囊(SHALL)
+
+- 使用 `navigationStyle: custom` 的页面,业务顶栏 SHALL 避开状态栏与右上角胶囊。
+- 量纲 SHALL 来自 `statusBarHeight` 与 `getMenuButtonBoundingClientRect`(见 `utils/safeArea.js`)。
+- CSS 变量:`--sq-safe-top`、`--sq-nav-height`、`--sq-header-height`、`--sq-capsule-right-safe`;微信端 SHALL 通过顶栏根节点内联 style 注入,不得仅依赖 H5 `document`。
+- 白底顶栏:总高 `--sq-header-height`,内容行高 `--sq-nav-height`,右侧列预留 `--sq-capsule-right-safe`,该列 SHALL NOT 放置可点击业务按钮。
+- 渐变大头:`padding-top` 含 `--sq-safe-top`,`padding-right` 含 `--sq-capsule-right-safe`。
+```
+
+- [ ] **Step 2: 手工验收(微信开发者工具 + 真机体验版)**
+
+| 页面 | 检查 |
+|------|------|
+| 地磅仪表盘 | 标题/返回/用户名不被胶囊挡 |
+| 地磅任一列表+详情 | 同上 |
+| 报销任一列表 | 同上 |
+| 工作台 | 标题与退出不与胶囊重叠 |
+| H5 预览(若有) | 无胶囊时右侧无异常大空白 |
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add docs/miniprogram-ui-spec-sanqiang.md
+git commit -m "docs: require miniprogram custom header capsule safe area"
+```
+
+---
+
+## Spec 覆盖自检
+
+| Spec 要求 | Task |
+|-----------|------|
+| 方案 B 几何 | 2, 4, 5 |
+| 微信端变量注入 | 1, 3, 4, 5 |
+| 地磅全链路 | 2, 3 |
+| 报销 topbar | 4 |
+| mp-header / 工作台 | 5 |
+| 规范 SHALL | 6 |
+| 无胶囊兜底 | Task1 metrics + SCSS fallback;H5 右侧 0 |
+| 不改默认导航页 | 未列入文件 |
+
+## 执行交接
+
+Plan complete and saved to `docs/superpowers/plans/2026-09-12-miniprogram-header-capsule-safe-area.md`. Two execution options:
+
+**1. Subagent-Driven (recommended)** — 每个 Task 派生子代理,Task 间复查  
+**2. Inline Execution** — 本会话按 executing-plans 连续执行并设检查点  
+
+Which approach?