移动端页面开发范式
适用端:移动端 | 类型:操作
这一页是动手页:给一个能直接抄的列表页骨架,并把「为什么长这样」讲清楚。移动端的页面范式与 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.ts | getDictLabel / 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.ts | formatDateTime 等 |
scripts/check-hardcoded-color.mjs | pnpm check:color 的实现 |
| 范例页 | src/pages/message/index.vue(列表页,含错误兜底)、src/pages/index/index.vue(工作台)、src/pages/user/index.vue(我的) |
核心机制
1. 列表页骨架:z-paging + fixed=false + flex 容器
这是移动端最容易坏的一处布局。标准写法:
<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-container | min-h-screen + 画布底色 var(--brand-canvas) | 页面白底、短内容时下方留白 |
.yd-page-container-paging | h-screen flex flex-col overflow-hidden | z-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:
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 份。
搜索/重置的标准接法:
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):
<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-card | m-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)。页面里:
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 个 hook | ref computed uni.navigateTo useAccess() | AutoImport |
useToast / useDialog | 🔴 必须显式 import | 不在任何自动导入里 |
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):
<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 必须写。
怎么扩展
加一个自研控件:
- 建
src/components/yudao-ui/yd-foo/yd-foo.vue(目录名与文件名必须同名,这是 easycom 规则^yd-(.*)→@/components/yudao-ui/yd-$1/yd-$1.vue的硬要求); - 需要暴露方法给父组件时,用
defineExpose并把类型写进同目录的types.ts,再在src/components/yudao-ui/index.ts里export type { YdFooExpose }(现有yd-form-picker/yd-search-picker就是这么做的); - 参数校验用
withDefaults(defineProps<XxxProps>(), {...}),非法值直接抛TypeError——yd-search-picker.vue:90-101就是范例(type不是radio/checkbox就抛,allOption配多选也抛)。宁可开发期炸掉,不要静默降级; - 跑一次 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 属于全站视觉回归,必须人工过几个代表页。
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三者的适用场景未逐个读源码确认,选用时建议先找现有页面的用例照抄。
相关页面
- 工程结构与约定式路由 —— 页面放哪、三条自动导入通道、生成物口径
- 请求封装与登录态 —— API 调用签名、reject 语义、
useAccess - 在线表单在移动端 ——
yd-form-picker等控件在表单运行时里的另一重身份 - 服务端驱动导航:system_nav_node 全链路 —— 页面写好后如何出现在工作台
- 移动端坑位
- 通用组件与 hooks —— Web 端的对应页,可对照两端的组件体系差异
