Files
shanxia2.0/docs/superpowers/plans/2026-09-12-miniprogram-header-capsule-safe-area.md
T

15 KiB
Raw Blame History

小程序顶部栏胶囊安全区适配 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:

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:

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 末尾追加:

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),测试文件用:

import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)

// 若 safeArea 依赖 uni,则只测纯映射:复制最小实现到测试或拆出 safeAreaCss.js

推荐落地(避免 uni 依赖拖垮 Node 测试):

新建 miniprogram/utils/safeAreaCss.js(纯函数,无 uni):

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 改为:

import { buildSafeAreaCssVarsStyle } from './safeAreaCss.js'
export { buildSafeAreaCssVarsStyle }

export function getSafeAreaStyle() {
  return buildSafeAreaCssVarsStyle(getGlobalSafeArea())
}

单测只 import safeAreaCss.js。

  • Step 4: 再跑单测
cd miniprogram && node utils/__tests__/safeArea.style.test.mjs

Expected: 打印 ok,exit 0

  • Step 5: Commit
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 块为:

.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
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 增加

import { getSafeAreaStyle } from '@/utils/safeArea'
const safeAreaStyle = getSafeAreaStyle()

(若页面已是 <script setup>,直接按上;否则在 data/setup 暴露 safeAreaStyle。)

  • Step 2: 模板统一改结构

仪表盘示例(有用户名):

<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>

列表/详情(无用户名):

<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
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 高度规则

将:

height: calc(58px + env(safe-area-inset-top, 0px));
padding: env(safe-area-inset-top, 0px) 12px 0;

改为:

.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:

grid-template-columns: minmax(0, 1fr) auto var(--sq-capsule-right-safe, 96px);

topbar-title 放第二列居中;topbar-right 若需对称,可并入第一列或改为不占用第三列。

  • Step 2: 每个 topbar 根节点
<view class="topbar" :style="safeAreaStyle">

script:

import { getSafeAreaStyle } from '@/utils/safeArea'
const safeAreaStyle = getSafeAreaStyle()
  • Step 3: 修正 expense-item.vue 列表高度计算

将:

const topbar = 44 + (sys.statusBarHeight || 0)

改为:

import { getGlobalSafeArea } from '@/utils/safeArea'
const topbar = getGlobalSafeArea().headerHeight
  • Step 4: Commit
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

.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()
<view class="mp-header mp-header--workbench" :style="safeAreaStyle">
  • Step 3: app-safe-header.vue 改用
import { getGlobalSafeArea, buildSafeAreaCssVarsStyle } from '@/utils/safeArea'
const headerStyle = computed(() => ({
  ...buildSafeAreaCssVarsStyle(getGlobalSafeArea()),
  '--sq-header-background': props.background,
}))
  • Step 4: Commit
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 追加

#### 自定义导航与微信胶囊(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
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?