Skip to content

移动端页面开发范式 ​

适用端:移动端 | 类型:操作

这一页是动手页:给一个能直接抄的列表页骨架,并把「为什么长这样」讲清楚。移动端的页面范式与 Web 端差别很大——没有表格、没有 useTable、没有 Element Plus,取而代之的是 z-paging 分页容器 + wot-design-uni 组件 + 一套 yd-* 全局样式类 + UnoCSS 原子类。

三个必须先建立的直觉:列表页的高度是靠 flex 撑的不是靠组件自己算的、卡片自带外边距所以不能放进 flex 容器、颜色一律走令牌不写字面量。这三条踩错的后果都是"看起来对但布局坏了",且不报错。

前置阅读:工程结构与约定式路由(页面放哪才会被路由认出来、组件为什么不写 import)、请求封装与登录态(API 怎么调、业务码非 0 是 reject)。

它解决什么问题 ​

页面只写自己的业务形状,其余全部来自共享层。判断一段代码该不该写在页面里,标准就一条:换个页面还要不要再来一遍。要的,就下沉到 src/components/yudao-ui/ 或 src/style/index.scss。

代码在哪 ​

路径内容
src/style/tokens.scss🔑 设计令牌的事实来源。:root, page 下的 --brand-* 与 --wot-color-theme。与 Web 端 src/styles/var.css 同源但不是照搬(文件头 :1-15 写了 5 条移动端适配原则)
src/style/theme.ts同一套色值的 TS 侧常量 BRAND。CSS 变量在 JS 运行时 / canvas / 图表库 / pages.json 的 tabbar 配置里读不到,只能用字面量,统一从这里取
src/style/index.scss全局公共类:yd-page-container、yd-card、yd-cell-row、yd-touch、yd-navbar、yd-fab、yd-detail-body/-footer、yd-search-form-*
src/components/yudao-ui/8 个自研控件(yd-search-picker、yd-form-picker、yd-search-date-range、yd-tree-select、yd-upload、yd-upload-file、yd-upload-img、yd-upload-imgs),index.ts 里也导出了具名版本
src/components/system-select/6 个业务选择器:user-picker、user-search-picker、user-form-picker、dept-search-picker、dept-form-picker
src/components/dict-tag/字典值 → 带色标签
src/hooks/useDict.tsgetDictLabel / getDictObj / getIntDictOptions / getStrDictOptions / getBoolDictOptions / getDictOptions
src/hooks/useRequest.ts{ loading, error, data, run } 包装
src/hooks/useScroll.ts滚动位置(另有 useScroll.md 说明)
src/utils/constants/DICT_TYPE 字典类型常量
src/utils/date.tsformatDateTime 等
scripts/check-hardcoded-color.mjspnpm check:color 的实现
范例页src/pages/message/index.vue(列表页,含错误兜底)、src/pages/index/index.vue(工作台)、src/pages/user/index.vue(我的)

核心机制 ​

1. 列表页骨架:z-paging + fixed=false + flex 容器 ​

这是移动端最容易坏的一处布局。标准写法:

vue
<template>
  <view class="yd-page-container yd-page-container-paging">
    <wd-navbar title="我的消息" placeholder safe-area-inset-top fixed />
    <SearchForm @search="handleQuery" @reset="handleReset" />
    <z-paging
      ref="pagingRef" v-model="list" :fixed="false"
      class="min-h-0 flex-1" :default-page-size="10"
      :refresher-enabled="true" empty-view-text="暂无消息"
      @query="queryList"
    >
      <view class="p-24rpx"><!-- 卡片列表 --></view>
    </z-paging>
  </view>
</template>

三件套缺一不可,来源是 src/style/index.scss:36-45:

类 / 属性作用少了会怎样
.yd-page-containermin-h-screen + 画布底色 var(--brand-canvas)页面白底、短内容时下方留白
.yd-page-container-pagingh-screen flex flex-col overflow-hiddenz-paging 拿不到确定高度
z-paging 的 :fixed="false"让 z-paging 不自己 fixed 定位,改为吃父容器高度z-paging 用 position: fixed 全屏铺,盖住 navbar
class="min-h-0 flex-1"flex 子项允许被压缩到 0高度塌陷:内容多时列表撑破容器、滚动失效

min-h-0 是 flexbox 的经典陷阱(flex 子项默认 min-height: auto,不会小于内容高度),z-paging 的官方 issue 里也反复出现。这是移动端坑位里的第一条。

2. 查询回调:成功 completeByTotal,失败 complete(false) ​

z-paging 通过 @query="queryList" 把 pageNo / pageSize 推给你,你必须回调它,否则它会一直转圈。范本在 src/pages/message/index.vue:108-122:

ts
async function queryList(pageNo: number, pageSize: number) {
  try {
    const params = { ...queryParams.value, pageNo, pageSize }
    const data = await getMyNotifyMessagePage(params)
    pagingRef.value?.completeByTotal(data.list, data.total)
  } catch {
    // 失败要给用户反馈,避免"空白无反馈"被当成页面打不开
    pagingRef.value?.complete(false)
    toast.error('消息加载失败,请稍后重试')
  }
}

catch 分支不是可选的。原因见请求封装:移动端把业务码非 0 改成了 reject(http.ts:186,注释「reject 替代原本的 resolve」),所以后端返回错误时这里必然抛。不 catch 的后果是未捕获的 Promise rejection + z-paging 永久转圈,用户看到的是"页面打不开"。

这是本项目修 bug 时统一的兜底写法

移动端有一批页面加载即抛未捕获异常,根因就是这个 catch 缺失。修的时候照 message/index.vue 这个形状抄,不要各写各的。若同一形态在 5 个以上页面重复,应抽成共享 composable 而不是复制 17 份。

搜索/重置的标准接法:

ts
const queryParams = ref<Record<string, any>>({})
function handleQuery(data?: Record<string, any>) {
  queryParams.value = { ...data }
  reload()                       // 而不是 pagingRef.value?.reload()
}
function handleReset() { handleQuery() }   // 重置 = 用空条件再查一次
function reload() { pagingRef.value?.reload() }

reload() 会让 z-paging 从第 1 页重新触发 @query。

3. 卡片范式:Web 的表格降级成"标题 + 键值行 + 右上状态标签" ​

src/style/tokens.scss:11-12 把这条写成了原则:

列表用卡片不用表格:Web 的多列表格降级成「标题 + 键值行 + 右上状态标签」,横向滚动在触摸设备上是最差的交互。

对应的类(index.scss:51-121):

vue
<view class="yd-card">
  <view class="yd-card-title">
    <text>{{ item.name }}</text>
    <dict-tag :type="DICT_TYPE.SYSTEM_USER_STATUS" :value="item.status" />
  </view>
  <view class="yd-card-body">
    <view class="yd-cell-row">
      <text class="yd-cell-row__label">部门</text>
      <text class="yd-cell-row__value">{{ item.deptName }}</text>
    </view>
  </view>
</view>
类说明
.yd-cardm-24rpx p-24rpx + 表面色 + 轻描边 + 圆角 + 阴影
.yd-card--flat无阴影无描边,卡片里再套一层时用
.yd-card--flush无内边距 + overflow: hidden,包 wd-cell-group 这类自带内边距的组件
.yd-card-title / .yd-card-body标题行(justify-between)与正文
.yd-cell-row + __label / __value / __extra键值行;标签列固定 w-160rpx,相邻行自动加 border-top

.yd-card 自带 m-24rpx,不要放进 flex 容器

index.scss:49-50 的注释写明了机制:

卡片自带 24rpx 外边距:普通块级流里相邻卡片的外边距会合并成 24rpx,不要把它们放进 flex 容器(flex 不合并外边距,间距会翻倍)。

即:竖直排列的卡片间距是 24rpx(外边距折叠),一旦父级是 display: flex,折叠失效 → 间距变 48rpx。要 flex 布局就用 .yd-card--flat(m-0)自己给 gap。

4. 颜色:令牌 → CSS 变量 → UnoCSS,三层不许跳过 ​

  • uno.config.ts 的 theme.colors.primary 是 'var(--wot-color-theme,#1890ff)',所以页面里写 text-primary / bg-primary 就是品牌色,且改一处 tokens.scss 全站生效。
  • theme.ts 的 BRAND 是 TS 侧的唯一字面量出口,文件头 :2-6 说明了为什么需要它:CSS 变量在 JS 运行时、canvas、图表库、被 vite-plugin-uni-pages 编译进 pages.json 的 tabbar 配置里都读不到。TABBAR_COLOR / TABBAR_SELECTED_COLOR / TABBAR_BG_COLOR(:42-48)就是从 BRAND 取的。
  • theme.ts:6 明确要求「取值必须与 src/style/tokens.scss 保持一致(tokens.scss 是设计令牌的事实来源)」——两处要手改,没有自动同步,也没有门禁校验一致性。改主色时两边都得改。

pnpm check:color 拦的范围比你想的窄

scripts/check-hardcoded-color.mjs 只有两类规则(:34-43):

规则色值阈值
禁用#3370ff(飞书蓝,与品牌主色不同支)必须为 0
限额#1890ff(色值对,但该写成 bg-primary / var(--wot-color-theme) / BRAND.primary)baseline = 0,即新增一处就红

豁免:src/style/tokens.scss 与 src/style/theme.ts(:46-49,色值的唯一落地点);排除目录:src/uni_modules、src/lib、src/pages-bpm/components/form-create/packages、src/service(:25-30)。扫描扩展名 .vue/.ts/.tsx/.js/.scss/.css。

它不管其他任何色值。 实测 src/pages/message/index.vue 里就有 text-[#333]、text-[#999]、bg-[#f7f8f9]、text-[#323333]、text-[#777] 与 bg-red-500——全部通过门禁。所以「check:color 过了」不等于「配色合规」,它只保证品牌主色不串。要真正对齐令牌,得靠 review。

5. 字典:DICT_TYPE + getDictLabel / getIntDictOptions ​

字典数据在登录后由 dictStore.loadDictCacheWithRetry() 异步预热(store/token.ts:124-126,不 await)。页面里:

ts
import { getDictLabel, getIntDictOptions } from '@/hooks/useDict'
import { DICT_TYPE } from '@/utils/constants'

getDictLabel(DICT_TYPE.SYSTEM_NOTIFY_TEMPLATE_TYPE, item.templateType)   // 值 → 标签
getIntDictOptions(DICT_TYPE.SYSTEM_USER_STATUS)                          // 下拉选项(number 值)
getStrDictOptions(DICT_TYPE.XXX)                                         // 下拉选项(string 值)
getBoolDictOptions(DICT_TYPE.XXX)                                        // 布尔

getIntDictOptions / getStrDictOptions 的区别是值的类型,选错会导致 v-model 绑不上(后端返回 number,选项 value 是 string)。展示用 <dict-tag :type="..." :value="..." />,它顺带给标签上色。

字典是异步预热的,首屏可能拿不到

loadDictCacheWithRetry() 用 void 调用(store/token.ts:126),登录后立即跳工作台时字典可能还没回来,getDictLabel 会返回原值或空。页面若强依赖字典文案,要么自己 await dictStore.loadDictCache(),要么接受一次闪烁。

6. 组件从哪来:三条通道 ​

见工程结构。写页面时的实际影响:

你想用写法通道
wot-ui 组件<wd-navbar> <wd-cell-group> <wd-toast>easycom ^wd-(.*)
z-paging<z-paging>easycom(排除 z-paging-refresh / z-paging-load-more)
自研 yd-*<yd-search-picker> 或 <YdSearchPicker>easycom ^yd-(.*) / YudaoUiResolver
src/components/{dict-tag,system-select}<dict-tag> <user-search-picker>UniComponents autoscan
Vue / uni-app API、5 个 hookref computed uni.navigateTo useAccess()AutoImport
useToast / useDialog🔴 必须显式 import不在任何自动导入里
ts
import { useDialog } from '@wot-ui/ui/components/wd-dialog'
import { useToast } from '@wot-ui/ui/components/wd-toast'

这两个能工作的前提是 App.ku.vue 里挂了 <wd-toast /> 与 <wd-dialog /> 容器(见工程结构)。没有容器,调用不报错但什么都不显示。

7. 触控目标与字号阶梯 ​

tokens.scss:8-15 定的移动端适配原则里,两条会直接影响你写类名:

  • 触控目标 ≥ 44px(≈88rpx):可点区域套 .yd-touch(index.scss:125-129,min-height: var(--brand-touch-min))。导航栏图标按钮用 .yd-navbar__action,它靠等值负外边距把触控区撑到 ~80rpx 而不改变布局盒尺寸(:145-152)。
  • 字号阶梯 22 / 24 / 26 / 28 / 32rpx,正文 28rpx 起。UnoCSS 侧另配了 text-2xs(20rpx/28rpx)与 text-3xs(18rpx/26rpx)两档更小的(uno.config.ts 的 theme.fontSize)。

安全区:uno.config.ts 的 rules 提供了 p-safe / pt-safe / pb-safe 三个自定义规则(映射到 env(safe-area-inset-*));详情页底部固定操作条用 .yd-detail-footer,正文容器配 .yd-detail-body(padding-bottom: calc(160rpx + env(safe-area-inset-bottom)))以免最后一行被盖住。

一个列表页的完整最小样例 ​

新建 src/pages-system/foo/index.vue(src/pages-system 已在 subPackages 里,无需改 vite.config.ts):

vue
<template>
  <view class="yd-page-container yd-page-container-paging">
    <wd-navbar title="Foo 列表" placeholder safe-area-inset-top fixed />
    <z-paging ref="pagingRef" v-model="list" :fixed="false" class="min-h-0 flex-1"
      :default-page-size="10" empty-view-text="暂无数据" @query="queryList">
      <view v-for="item in list" :key="item.id" class="yd-card">
        <view class="yd-card-title">
          <text>{{ item.name }}</text>
          <dict-tag :type="DICT_TYPE.SYSTEM_USER_STATUS" :value="item.status" />
        </view>
        <view class="yd-card-body">
          <view class="yd-cell-row">
            <text class="yd-cell-row__label">创建时间</text>
            <text class="yd-cell-row__value">{{ formatDateTime(item.createTime) }}</text>
          </view>
        </view>
      </view>
    </z-paging>
  </view>
</template>

<script lang="ts" setup>
import { useToast } from '@wot-ui/ui/components/wd-toast'
import { ref } from 'vue'
import { getFooPage, type FooVO } from '@/api/system/foo'
import { DICT_TYPE } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'

definePage({ style: { navigationBarTitleText: '', navigationStyle: 'custom' } })

const toast = useToast()
const list = ref<FooVO[]>([])
const pagingRef = ref<any>()

async function queryList(pageNo: number, pageSize: number) {
  try {
    const data = await getFooPage({ pageNo, pageSize })
    pagingRef.value?.completeByTotal(data.list, data.total)
  } catch {
    pagingRef.value?.complete(false)
    toast.error('加载失败,请稍后重试')
  }
}
</script>

要点回顾:navigationStyle: 'custom' 是因为用了自己的 <wd-navbar>(否则会有两条标题栏);dict-tag / wd-navbar / z-paging 都不用 import;useToast 必须 import;catch 必须写。

怎么扩展 ​

加一个自研控件:

  1. 建 src/components/yudao-ui/yd-foo/yd-foo.vue(目录名与文件名必须同名,这是 easycom 规则 ^yd-(.*) → @/components/yudao-ui/yd-$1/yd-$1.vue 的硬要求);
  2. 需要暴露方法给父组件时,用 defineExpose 并把类型写进同目录的 types.ts,再在 src/components/yudao-ui/index.ts 里 export type { YdFooExpose }(现有 yd-form-picker / yd-search-picker 就是这么做的);
  3. 参数校验用 withDefaults(defineProps<XxxProps>(), {...}),非法值直接抛 TypeError——yd-search-picker.vue:90-101 就是范例(type 不是 radio/checkbox 就抛,allOption 配多选也抛)。宁可开发期炸掉,不要静默降级;
  4. 跑一次 dev 让 local-components.d.ts 重新生成,并把它一起提交(见工程结构)。

加一个页面私有组件:放 src/pages-xxx/<page>/components/。这个目录被 UniPages 的 exclude(vite.config.ts:101)与 UniKuRoot 的 excludePages 双重排除,不会变成路由。页面里显式 import(message/index.vue:90-91 就是这么引 DetailPopup / SearchForm 的)——页面私有组件不走 easycom。

加一个全局样式类:写进 src/style/index.scss 的对应分区(卡片 / 键值行 / 触控 / 导航栏 / 悬浮按钮 / 详情底部 / 搜索弹窗),颜色只用 var(--brand-*)。:33 的注释是硬约定:「新增页面一律用这里的类,不要在页面内再写一遍卡片/行样式与十六进制色值」。

改品牌主色:tokens.scss 的 --wot-color-theme + --brand-primary,以及 theme.ts 的 BRAND.primary(含 grad 渐变里的两个色标)。两处不同步的后果是 tabbar / 图表用旧色、页面用新色。

测试 ​

🔴 本仓无单测。这一页的范式没有自动化保护,改 index.scss 或 tokens.scss 属于全站视觉回归,必须人工过几个代表页。

bash
pnpm check:color     # 硬编码色守卫(范围见上文 warning)
pnpm type-check      # vue-tsc
pnpm lint            # eslint

人工回归的最小集合:工作台 pages/index/index(用到全部 menu-* 私有组件与网格布局)、消息 pages/message/index(z-paging + 搜索表单 + 弹窗)、我的 pages/user/index(yd-cell-row + 选择器)、任一 BPM 详情(yd-detail-footer 固定底栏 + 安全区)。

坑与注意 ​

  • min-h-0 不能省。flex-1 单独用在 z-paging 上会高度塌陷。
  • :fixed="false" 与 .yd-page-container-paging 是配对的,只改一个会更难查。
  • catch 必须写,业务码非 0 是 reject。
  • useToast / useDialog 必须显式 import,且依赖 App.ku.vue 里的容器。
  • yd-* 组件的目录名与文件名必须同名,否则 easycom 解析不到,症状是标签原样渲染成未知元素。
  • .yd-card 别放进 flex 容器(外边距不折叠 → 间距翻倍)。
  • check:color 通过 ≠ 配色合规,它只拦 #3370ff 与 #1890ff。
  • tokens.scss 与 theme.ts 要手工保持同步,无门禁。
  • index.scss:21-29 有一段被注释掉的 box-sizing 重置,注释写「这个样式有重大BUG,先去掉!!(2025-08-15)」。别顺手恢复它——uni-app 里无法用 * 选择器,这段替代方案会破坏布局。
  • index.scss:6-12 的 .test 类是模板遗留(color: red),全仓无人使用,别当成公共类。
  • 页面私有组件不走 easycom,必须显式 import;写成 <search-form /> 期望自动解析会静默失败。

→ 本节是贴着上下文的提醒;按症状查的移动端全域清单见移动端坑位,跨端清单见坑位索引。

未证实 ​

  • 暗色模式:tokens.scss:1 明确写「移动端仅亮色,暂无暗色模式」。但 store/theme.ts 与 App.ku.vue 的 wd-config-provider :theme 传的是 themeStore.theme——即 wot-ui 层面的暗色开关是接着的,只是品牌令牌没有暗色变体。开启后会发生什么未验证(大概率是 wot 组件变暗、yd-* 类仍是亮色)。
  • useScroll.ts 的实际使用面:未逐页统计有多少页面用了它,也未验证它与 z-paging 内部滚动的配合。
  • presetLegacyCompat 的必要性:uno.config.ts:55-59 注释说是为了低端安卓机与去除 oklch,但本项目只在 H5 实测过,App / 小程序端是否真的需要它、去掉会怎样,未验证。
  • src/components/system-select/ 六个选择器的差异:user-picker / user-search-picker / user-form-picker 三者的适用场景未逐个读源码确认,选用时建议先找现有页面的用例照抄。

相关页面 ​