移动端开发
这一页讲怎么开发、调试、构建和发布移动端,以及只要电脑端时怎么把它删掉。移动端有哪些页面、员工能做什么,见移动端。
概览
移动端是给员工用的手机客户端,代码在仓库根目录的 mobile/,是可选部分。它用 uni-app 开发:uni-app 是 DCloud 公司的跨平台框架,用 Vue 3 写一套代码,可以编译成微信小程序、安卓 App、iOS App 和 H5(在手机浏览器里打开的网页)。
| 平台 | 用途 |
|---|---|
| 微信小程序 | 正式发布 |
| 安卓 App、iOS App | 正式发布,用 HBuilderX(DCloud 的开发工具)打包 |
| H5 | 只用于开发调试和自动化测试,不作为正式发布的目标 |
发布前还要自己完成的事
模板的自动化测试覆盖了单元测试、H5 上的端到端测试,以及小程序的构建和包体积检查。真机上的核对、用真实的 AppID 和模板发送微信订阅消息、上架应用商店和发布小程序,模板没有做过,要按下文的发布清单完成。在 Windows 上用 HBuilderX 打包 App、上传小程序也没有验证过。
一个独立的工程
mobile/ 不在根目录的 pnpm workspace(工作区:一个仓库里的多个包一起安装、互相引用)里。它有自己的:
package.json:依赖和脚本;pnpm-workspace.yaml:pnpm 配置。和主工程一样,依赖发布满一天才能安装(minimumReleaseAge: 1440),需要运行安装脚本的包登记在allowBuilds里;pnpm-lock.yaml:锁定文件。
所以根目录的 pnpm i 不会安装移动端的依赖,根目录的锁定文件里也没有它。移动端单独用 pnpm mobile:install 安装。
主工程(apps、packages、scripts 和根目录的配置)不能引用 mobile/,只有少数几行例外,见只要电脑端:删除移动端。pnpm verify 的架构检查会检查这一点。
怎么用共享包
移动端和电脑端共用 @qiwu/shared 里的校验规则、类型、权限常量和翻译(validation.*、field.*、seed.*)。它不走 workspace 链接,而是直接用共享包的 TypeScript 源码:
// mobile/vite.config.ts(节选)
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
'@qiwu/shared': resolve(__dirname, '../packages/shared/src'),
zod: resolve(__dirname, 'node_modules/zod'),
},
},@指向mobile/src;@qiwu/shared指向共享包的源码,所以移动端不需要先构建共享包;zod指向mobile/node_modules/zod,应用和共享包只打包一份 zod。mobile/package.json里 zod 的版本要和根目录pnpm-workspace.yaml的 catalog 一致;mobile/tsconfig.json的paths也这样配置,类型检查和构建看到的是同一份代码。
共享包也要能在小程序里运行,所以 packages/shared/src 只能导入 zod 和自己的文件,packages/shared/tsconfig.json 要保持 "types": [](不能用 Node 的 process、Buffer 等)。往共享包里加代码时要注意这两点,pnpm verify 的架构检查也会检查。
版本
版本跟着 uni-app 走,全部写死精确版本,不跟主工程一起升级:
| 包 | 版本 | 说明 |
|---|---|---|
@dcloudio/*(uni-app) | 3.0.0-5020620260917001(@dcloudio/types 是 3.4.31) | 对应 HBuilderX 5.26 |
vue | 3.4.21 | uni-app 要求的版本,和电脑端不同 |
vite | 5.2.8 | uni-app 要求的版本 |
pinia | 2.2.4 | 状态管理 |
vue-i18n | 9.1.9 | 只用来保存当前语言,文字不用它的翻译函数,见国际化 |
@wot-ui/ui | 2.3.2 | 界面组件库(组件名以 wd- 开头) |
z-paging | 2.8.8 | 分页列表:下拉刷新、滚到底加载下一页 |
go-captcha-uni | 1.0.7 | 滑块验证码 |
socket.io-client | 4.8.4 | 实时推送 |
zod | 4.6.5 | 校验,和根目录 catalog 一致 |
typescript / vue-tsc | 6.0.3 / 3.3.11 | 类型检查 |
vitest / @playwright/test | 3.2.7 / 1.63.0 | 单元测试 / 端到端测试 |
packageManager 是 [email protected],要求 Node 22.22.1 及以上,和主工程相同。
根目录的检查管到哪里
pnpm verify里的翻译检查(i18n:check)也扫描mobile/src:.ts和.vue里不能写中文字面量,两种语言的翻译要一一对应;许可证检查在mobile/装过依赖时一并检查它;- 根目录的 lint 和 Prettier 格式化不处理
mobile/; pnpm ci:local在mobile/存在时,依次运行移动端的安装、verify、单元测试、H5 构建、小程序构建(含包体积检查),并在电脑端的 Playwright 之后运行移动端的端到端测试。它不构建 App。
目录结构
mobile/
├── package.json # 依赖和脚本(不在根目录的 pnpm workspace 里)
├── pnpm-workspace.yaml # 自己的 pnpm 配置:allowBuilds、minimumReleaseAge
├── pnpm-lock.yaml # 自己的锁定文件
├── vite.config.ts # 别名(@、@qiwu/shared、zod)、H5 的转发、单元测试配置
├── tsconfig.json # 应用代码的类型检查(tsconfig.node.json 管配置文件和 e2e)
├── playwright.config.ts # H5 端到端测试
├── license-exceptions.json # 移动端依赖的许可证例外
├── index.html # H5 的入口页面
├── e2e/ # Playwright 用例(mobile-*.spec.ts)
├── scripts/ # mp-size.mjs(小程序包体积检查)、audit-report.mjs(依赖安全报告)
├── unpackage/res/ # App 图标和启动图(PNG)
└── src/
├── main.ts # 入口,第一行导入 core/jitless.ts
├── App.vue # 应用生命周期(语言、主题、更新检查、实时推送)、颜色令牌和公共样式
├── pages.json # 页面登记、分包、tabBar、导航栏样式
├── manifest.json # 应用名、版本号、AppID、App 权限、图标
├── theme.json # 原生导航栏和 tab 栏的浅色、深色颜色
├── core/ # 基础层,见下文
├── api/ # 接口封装(代码生成器生成的放在这里)
├── pages/ # 主包:login(启动页)、home、approval、message、mine
├── pages-wf/ # 审批分包:start(发起)、detail(审批详情)、form(动态表单渲染器)
├── pages-biz/ # 业务分包:leave(请假)、demo/*(代码生成器示例)
├── pages-sys/ # 系统分包:inbox、bulletin、profile、password、about、wx-bind
├── locales/ # zh-CN、en-US 翻译
├── static/ # 自绘的 SVG:tab 图标、插画、空状态、标志
└── __tests__/ # vitest 单元测试分包是小程序的概念:主包在启动时下载,分包在第一次打开它里面的页面时才下载,这样主包更小、启动更快。主包只放登录页和底部的四个页签(工作台、审批、消息、我的),其他页面按领域放进 pages-wf、pages-biz、pages-sys。
dist/(构建产物)不提交到 git。
安装与运行
安装依赖
在仓库根目录执行(macOS 和 Windows 命令相同):
pnpm mobile:install它等于 pnpm -C mobile i --frozen-lockfile:按锁定文件安装,不改锁定文件。第一次运行需要联网。
根目录没有 mobile:dev 这样的命令。开发时用 pnpm -C mobile <脚本>(-C mobile 表示在 mobile 目录里运行):
| 命令 | 平台 | 产物和用法 |
|---|---|---|
pnpm -C mobile dev:h5 | H5 | 启动开发服务器,在浏览器里打开 |
pnpm -C mobile dev:mp-weixin | 微信小程序 | 编译到 mobile/dist/dev/mp-weixin,用微信开发者工具导入 |
pnpm -C mobile dev:app | App | 编译到 mobile/dist/dev/app,用 HBuilderX 导入 |
这三个命令都会一直运行,改了代码会自动重新编译,按 Ctrl+C 停止。
在浏览器里调试(H5)
日常写页面最方便的是 H5:
- 在一个终端里运行
pnpm dev,启动后端(端口 3000); - 在另一个终端里运行
pnpm -C mobile dev:h5; - 按终端打印的地址在浏览器里打开,用浏览器开发者工具切换到手机尺寸。
H5 用哈希路由,地址里带 #,比如工作台是 …/#/pages/home/index。
H5 的开发服务器把 /api、/files(本地存储的公开文件,比如头像)和 /socket.io(实时推送)转发到后端,默认是 http://127.0.0.1:3000。后端在本机的其他端口时,用环境变量 API_PROXY_TARGET 指定。只验证过指向本机的后端;指向远程的 https 后端时,实时推送可能因来源检查被拒绝,角标退回每 60 秒查询一次:
API_PROXY_TARGET=http://127.0.0.1:3100 pnpm -C mobile dev:h5$env:API_PROXY_TARGET='http://127.0.0.1:3100'
pnpm -C mobile dev:h5Windows 上用 $env: 设置的变量只在当前终端里有效,关掉终端就没了;要在同一个终端里继续做别的事,先执行 Remove-Item Env:API_PROXY_TARGET 清除它。
实时推送的转发不要加 changeOrigin
mobile/vite.config.ts 里 /socket.io 写成对象 { target, ws: true },没有加 changeOrigin。加了之后 Host 头会被改写,服务端检查来源时会把页面当成外站,拒绝连接(forbidden_origin)。
在微信开发者工具里调试小程序
小程序没有转发,要在编译时用环境变量 VITE_API_BASE 指定完整的接口地址(以 /api 结尾)。不设时请求发往相对路径 /api,小程序连不上:
VITE_API_BASE=https://api.example.com/api pnpm -C mobile dev:mp-weixin$env:VITE_API_BASE='https://api.example.com/api'
pnpm -C mobile dev:mp-weixin然后打开微信开发者工具,导入 mobile/dist/dev/mp-weixin。
Windows 上停止之后,在同一个终端里运行别的命令之前,先执行 Remove-Item Env:VITE_API_BASE 清除它。这个变量留着时,dev:h5 和 pnpm mobile:e2e 构建的 H5 也会直接请求这个地址,不再走转发。
mobile/src/manifest.json里mp-weixin.appid为空时,产物里的 AppID 是游客touristappid,可以在开发者工具里看页面,但不能上传。要调试微信登录,就填真实的 AppID,并且和服务端的WX_MP_APPID一致;manifest.json里的urlCheck: false只让开发者工具不检查合法域名。真机上的体验版和正式版一律检查;- 模板只验证过用 https 正式域名构建。用本机或局域网的 http 地址调试行不行,没有验证过,以开发者工具里实际发出的请求为准。
在手机或模拟器上调试 App
安装 HBuilderX 5.26(和
@dcloudio/*的版本对应),登录 DCloud 账号;和小程序一样,用
VITE_API_BASE指定手机能访问到的接口地址,运行pnpm -C mobile dev:app:bashVITE_API_BASE=https://api.example.com/api pnpm -C mobile dev:apppowershell$env:VITE_API_BASE='https://api.example.com/api' pnpm -C mobile dev:appWindows 上停止后同样先执行
Remove-Item Env:VITE_API_BASE;在 HBuilderX 里导入
mobile/dist/dev/app,运行到手机或模拟器。
HBuilderX 调试时用的是标准基座:DCloud 预先做好的调试用 App,运行时把你的代码装进去。标准基座在 iOS 上写死了浅色模式,跟随系统时看不到深色效果,核对深色要用云打包出来的 App。
连哪个服务端
| 平台 | 变量 | 什么时候生效 | 不设时 |
|---|---|---|---|
| H5 | API_PROXY_TARGET | 启动开发服务器或预览时,转发的目标 | http://127.0.0.1:3000 |
| 小程序、App | VITE_API_BASE | 编译时写进代码 | /api,只有 H5 能用 |
- 实时推送和头像这类文件也用
VITE_API_BASE的源(协议加域名),实时推送的地址是同一个域名下的/socket.io; VITE_API_BASE会被编译进代码,所以不能放任何密钥;mobile/里没有提交任何.env文件。uni-app 会不会读取mobile/下的.env.production.local这类文件,模板没有验证过,所以一律在命令行里设置变量,并在真机上确认请求发到了哪个地址。
基础层
mobile/src/core/ 是写页面时直接复用的代码:
| 文件 | 提供什么 |
|---|---|
request.ts | 接口请求 api、上传 upload、错误 ApiError / errorText、文件地址 assetUrl |
stores/auth.ts | 登录状态 useAuthStore、权限判断 hasPerm |
stores/counts.ts | 待办数、审批中数、未读数 useCountsStore,工作台和底部角标共用 |
realtime.ts | 实时推送的连接 |
i18n.ts | 翻译 t / tx、校验提示 fieldErrors / issueText、切换语言 setLocale |
theme.ts | 浅色和深色 useTheme、分页列表的主题 pagingTheme |
format.ts | 时间和大小的显示:formatTime、formatShort、formatSize |
pickers.ts、captcha.ts | 选人、选部门、字典、上传的逻辑;验证码 |
approvals.ts、views.ts | 审批列表、详情和操作的逻辑;自定义表单流程的移动端页面登记 |
crud.ts | 代码生成器生成的页面共用的增删改查工具 |
update.ts、wx-subscribe.ts | App 更新检查;微信订阅消息 |
jitless.ts、eio-globals.ts | 让 zod 和 socket.io 在小程序里不用 new Function |
components/ | 公共组件,见常用组件 |
请求
不要直接调用 uni.request,用 request.ts 的 api。地址不带 /api 前缀,它会自动加上:
import type { BulletinUnread, Page, WfTaskItemVo } from '@qiwu/shared'
import { api, errorText } from '@/core/request'
// GET:第二个参数是查询参数
const todo = await api.get<Page<WfTaskItemVo>>('/wf/tasks/todo', { page: 1, pageSize: 3 })
// POST、PUT、DELETE
const { unread } = await api.post<BulletinUnread>('/messaging/bulletins/feed/read-all')
// silent:出错时不弹提示、不跳转,由调用方自己处理
try {
await api.get('/wf/tasks/todo', { page: 1, pageSize: 1 }, { silent: true })
} catch (e) {
error.value = errorText(e) // 服务端翻译好的提示;没有应答时是"网络异常,请稍后重试"
}成功时返回应答里的 data,失败时抛出 ApiError(status 为 0 表示没有收到应答)。每个请求都带 X-Client-Id: mobile、Accept-Language(当前语言),能取到时区时还带 X-Timezone,超时 30 秒。
出错时的统一处理:
| 情况 | 处理 |
|---|---|
| 401 | 用 refresh 令牌刷新一次(几个请求同时 401 时只刷新一次),成功后重发原请求;刷新被拒就清除登录状态,回到登录页 |
403 A1004(必须先改密码) | 不弹提示,转到改密页 /pages-sys/password/index |
| 403、409、422、429、5xx | 弹出服务端返回的提示 |
| 网络错误(没有收到应答) | 弹出"网络异常,请稍后重试" |
| 400、404 | 不弹提示,由页面显示(表单的校验错误、"不存在") |
加了 { silent: true } 的调用以上都不做,错误交给调用方。
其他几个工具:
upload(url, filePath, formData?):用uni.uploadFile经后端上传,文件字段名是file。一般直接用QwUpload组件,见文件上传 · 大小上限。上传和普通请求不同:400、404、413(超过大小上限)、415 也会弹出提示;assetUrl('/files/…'):把服务端返回的/files/…路径变成能加载的地址(H5 走转发,小程序和 App 加上VITE_API_BASE的源),完整的https://地址原样返回。
登录状态和权限
账号密码登录和短信登录都带 clientId: 'mobile' 和 keepSignedIn: true,微信登录由服务端按同样的方式处理。建立的是移动端会话,从登录起最长 7 天,见登录与账号 · 会话时长。
- access 令牌(访问令牌)只放在内存里,应用重启后就没有了,第一个请求 401 后自动刷新;
- refresh 令牌(刷新令牌)放在 uni 的本地存储里(键
qw.auth.rt),刷新时放在请求体里发送(POST /api/auth/refresh),应答给出新的 access 令牌和换过的新 refresh 令牌。
import { hasPerm, useAuthStore } from '@/core/stores/auth'
const auth = useAuthStore()
auth.me?.user.displayName // GET /api/auth/me 的结果
hasPerm('biz.leave.create') // 传数组时有任一权限即可;超级管理员全部通过模板里按权限显示用 <QwPerm>,因为小程序不支持自定义指令(电脑端的 v-perm 在这里不能用):
<QwPerm :perm="bookPerms.create">
<wd-button type="primary" block @click="create">{{ t('crud.action.create') }}</wd-button>
</QwPerm>hasPerm 和 <QwPerm> 都只控制显示,权限以服务端为准。
/auth/me 返回的 mustChangePassword 或 passwordExpired 为真时(初始密码、密码过期),应用会一直停在改密页,改完再进入工作台。
计数和实时推送
useCountsStore() 里有 todo(待办数)、running(我发起、还在进行的流程数)、unread(未读消息数)和 load()。工作台和底部角标都读这里。
- 底部页签每次显示时(切换页签、从子页面返回、回到前台)调用一次
load(); - 应用在前台时连接实时推送,收到新待办或新消息就调用
load();被管理员强制下线时回到登录页; - 实时推送连不上时,页签显示期间每 60 秒查询一次。
连接的时机、处理哪些推送、轮询兜底的写法见在代码中推送实时消息 · 移动端(uni-app)。
国际化
文字一律用 @/core/i18n 的函数,不要用 vue-i18n 自带的 t、$t:小程序上它不替换 {name} 这样的占位符,完整版的 vue-i18n 还会用到小程序禁止的 new Function。
import { fieldErrors, t, tx } from '@/core/i18n'
t('home.hello', { name: user.displayName }) // 你好,{name}
tx(model.name) // 种子数据的名称是翻译键,管理员新建的是普通文字:是键就翻译,否则原样显示
const errors = computed(() => fieldErrors(leaveCreate, issues.value)) // 共享规则的校验提示,见下文- 移动端的文字放在
mobile/src/locales/{zh-CN,en-US}/*.json,文件名的合并规则和电脑端相同;validation.*、field.*、seed.*直接来自共享包,见在代码中使用国际化; - 启动时依次取:本机保存的选择(键
qw.locale)→ 系统语言以英文开头时用 English → 简体中文; setLocale()同时切换移动端自己的文字、wot-ui 组件自带的文字和 uni 内置的界面(弹窗、选择器),并保存在本机。在 我的 页切换语言时,还会保存到账号上(PUT /api/iam/profile/locale),服务端发的消息跟着变;- 页面标题也要翻译:在
onLoad里调用uni.setNavigationBarTitle({ title: t('…') }),pages.json里的标题只是默认值。
主题和样式
- 默认跟随系统的浅色或深色。App 和 H5 可以在 我的 → 外观 里选 跟随系统、浅色 或 深色,保存在本机(键
qw.theme);微信小程序只能跟随系统,没有这一项; - 颜色令牌(
--qw-*)的浅色值和深色值都定义在App.vue,wot-ui 的--wot-*变量都指向它们。页面里不写颜色值,只用--qw-*令牌和App.vue里的公共类(.qw-card、.qw-row、.qw-tag、.qw-btn等); - 不要用 wot-ui 的
<wd-config-provider>,它的浅色主题类会盖掉上面的映射; z-paging的主题统一用pagingTheme:在<z-paging>上绑定:default-theme-style="pagingTheme.style"、:loading-more-title-custom-style="pagingTheme.title"和:loading-more-no-more-line-custom-style="pagingTheme.line",可以照抄生成的列表页mobile/src/pages-biz/demo/book/index.vue;- 原生导航栏、tab 栏和页面底色写在
src/theme.json,pages.json用@变量引用它; src/static/下的 tab 图标和空状态插画都有浅色、深色两份,深色的文件名分别带-dark和-night;深蓝色头部用的梧桐插画(static/art/)只有一份,两种主题共用。
useTheme() 返回当前的选择 pref、是否深色 isDark、能否选择 canChoose 和设置函数 setPref。
常用组件
都在 mobile/src/core/components/,用的时候显式导入:
| 组件 | 作用 |
|---|---|
QwPageHeader | 页签页面顶部的深蓝色头部 |
QwTabBar | 自绘的底部页签栏,带角标。原生 tabBar 不能在运行时改文字(切换语言),所以四个页签页面底部都放 <QwTabBar current="…">,原生的被隐藏。它还负责没有登录时回到登录页、刷新计数和连接实时推送 |
QwPerm | 有权限才显示里面的内容 |
QwIcon | 自绘的线条图标,<QwIcon name="calendar" size="40rpx" />(rpx 是 uni-app 的长度单位,屏幕宽度固定为 750rpx) |
QwEmpty | 空状态,title、desc,可选一个按钮 action |
QwBrandMark | 栖梧标志 |
QwCaptcha | 验证码弹层(滑块或图形) |
QwUserPicker | 选人:v-model、multiple、source。source="wf"(默认)列出全部启用的用户,审批里用;source="iam" 上方是部门树,只列调用者数据范围内的用户 |
QwDeptPicker | 选部门,任一级都可以选 |
QwDictSelect | 字典下拉:code 是字典编码,值是字典项的编码。每个字典在一次运行里只取一次 |
QwUpload | 附件上传:biz-tag、limit(默认 10 个)、max-size(单位是字节,不传时跟随参数 storage.max_size_mb) |
选择组件都是一行,点开后从底部弹出。
区分平台
两种写法:
- 条件编译:uni-app 编译时按注释去掉代码,只在某个平台上保留。
#ifdef是"只在这个平台",#ifndef是"除了这个平台":
<!-- mobile/src/pages/mine/index.vue(节选):小程序没有"外观" -->
<!-- #ifndef MP-WEIXIN -->
<wd-action-sheet v-model="appearanceOpen" :actions="appearances" @select="pickAppearance" />
<!-- #endif -->// mobile/src/pages/login/index.vue(节选):只在小程序里用微信登录
onLoad(() => {
if (hasSession()) return goHome()
// #ifdef MP-WEIXIN
if (takeWxLaunch()) wxSignIn()
// #endif
})- 运行时判断:
process.env.UNI_PLATFORM在构建时被替换成'h5'、'app'或'mp-weixin',比如core/update.ts开头的if (process.env.UNI_PLATFORM !== 'app') return。
小程序的限制
写代码时要记住微信小程序的这些限制:
- 不能用
new Function和eval。所以src/main.ts的第一行导入core/jitless.ts(把 zod 设为不生成代码的模式),vue-i18n 的翻译函数不用,socket.io 的一个全局对象由vite.config.ts里的插件换掉。构建小程序后的包体积检查会查产物里有没有这类代码; - 不支持自定义指令:按权限显示用
<QwPerm>或hasPerm(); - 不支持动态组件:审批详情里的业务单据是跳转到业务页面,不是嵌进来;动态表单用自己写的渲染器(
pages-wf/form/); - 分包之间不能互相引用:每个包只能用主包和本包的代码和组件。主包里的页面(比如审批页签)也不能引用分包的代码,所以
core/views.ts里只放页面路径; - 引入新的依赖之前,确认它能在小程序里运行(不用 Node 的模块、不用
new Function),并且许可证能通过pnpm mobile:verify。
新增页面
手写一个页面
以"关于"页为例,它是最简单的页面:
<!-- mobile/src/pages-sys/about/index.vue(节选) -->
<template>
<view class="qw-detail qw-stack">
<view class="qw-about">
<QwBrandMark size="120rpx" />
<text class="qw-about__name">{{ t('common.app.name') }}</text>
<text class="qw-about__version">{{ `${t('common.version')} ${version}` }}</text>
</view>
</view>
</template>
<script setup lang="ts">
import { onLoad } from '@dcloudio/uni-app'
import QwBrandMark from '@/core/components/QwBrandMark.vue'
import { t } from '@/core/i18n'
const version = uni.getAppBaseInfo().appVersion ?? '' // 来自 manifest.json 的 versionName
onLoad(() => uni.setNavigationBarTitle({ title: t('mine.about') }))
</script>步骤:
按领域放进分包:业务页面放
mobile/src/pages-biz/<模块>/,系统和个人的页面放pages-sys/,审批的放pages-wf/。不要往主包里加页面;在
mobile/src/pages.json对应分包的pages里登记,路径不带分包名和扩展名:json{ "root": "pages-biz", "pages": [ { "path": "leave/index", "style": {} }, { "path": "leave/view", "style": {} } ] }文字写进
mobile/src/locales/zh-CN/和en-US/下的同名文件,两种语言的键一一对应;用
uni.navigateTo({ url: '/pages-biz/leave/view?id=1' })打开页面,路径以分包名开头;运行
pnpm -C mobile dev:h5看效果,再运行pnpm mobile:verify和根目录的pnpm verify(翻译检查)。
表单和校验
表单直接用共享包的 zod 规则,和电脑端、服务端是同一份(见参数校验)。不通过就不发请求,提示显示在字段下面:
// mobile/src/pages-biz/leave/index.vue(节选)
import { leaveCreate, type LeaveVo, type ValidationIssue } from '@qiwu/shared'
import { fieldErrors, t } from '@/core/i18n'
import { ApiError, api } from '@/core/request'
const issues = shallowRef<ValidationIssue[]>([])
const errors = computed(() => fieldErrors(leaveCreate, issues.value)) // { 字段名: 提示 }
async function submit() {
if (submitting.value) return
const parsed = leaveCreate.safeParse(input())
issues.value = parsed.error?.issues ?? []
if (!parsed.success) return
submitting.value = true
try {
const saved = await api.post<LeaveVo>('/biz/leaves', parsed.data)
uni.redirectTo({ url: `/pages-wf/detail/index?id=${saved.instanceId}` })
} catch (e) {
// 400(校验)和 404 显示在页面上,其他错误请求层已经弹出提示
if (e instanceof ApiError && (e.status === 400 || e.status === 404)) error.value = e.message
} finally {
submitting.value = false
}
}和电脑端一样,提交按钮在请求期间显示加载并禁用,防止重复提交。
加一个入口
页面登记后没有入口,只能通过地址打开。最常见的入口是工作台的 常用功能,在 mobile/src/pages/home/index.vue 的 SHORTCUTS 里加一项:
// mobile/src/pages/home/index.vue(节选)
const SHORTCUTS: Shortcut[] = [
{ name: 'start', icon: 'send', tone: 'brand', url: '/pages-wf/start/index' },
{
name: 'leave',
icon: 'calendar',
tone: 'success',
url: '/pages-biz/leave/index',
perm: leavePerms.create, // 有这个权限才显示
},
{ name: 'bulletin', icon: 'megaphone', tone: 'warning', url: '/pages-sys/bulletin/index' },
]name决定文字的键home.shortcut.<name>,在locales/*/home.json里加上;icon是QwIcon的图标名,tone是颜色(brand、success、warning、neutral)。
让流程在手机上发起和查看
动态表单的流程不用写代码,手机上自动能发起和查看(有些组件只读,见工作流 · 手机上的动态表单)。
业务表单的流程(自己写的业务页面,比如请假)要在 mobile/src/core/views.ts 的 MOBILE_FORMS 里登记移动端页面,否则手机上显示"请在电脑端查看",发起时提示"该流程请在电脑端发起":
// mobile/src/core/views.ts(节选)
export const MOBILE_FORMS: readonly MobileForm[] = [
{
createRoute: '/biz/leave/new', // 流程模型的"发起页路由"
viewComponent: 'biz/leave/view', // 流程模型的"单据查看组件"
view: '/pages-biz/leave/view', // 查看页,打开时带 ?id=<单据 id>&readonly=1
form: '/pages-biz/leave/index', // 新建页;带 ?id=<单据 id>&task=<任务 id> 时是退回后修改
modify: leavePerms.modify, // 退回后修改单据需要的权限
icon: 'calendar',
summary: {
// 审批详情里显示的单据摘要
url: '/biz/leaves/:id',
title: 'leave.form',
labels: 'field.biz.leave',
fields: [
{ prop: 'leaveKind', dict: 'biz.leave_kind' },
{ prop: 'startAt', time: true },
{ prop: 'endAt', time: true },
{ prop: 'days' },
{ prop: 'reason' },
],
},
},
]createRoute 和 viewComponent 要和电脑端这个流程模型的配置一致,靠它们找到对应的移动端页面。新加的查看页和新建页同样要在 pages.json 的 pages-biz 里登记。
用代码生成器生成
在 系统工具 → 代码生成 的编辑页,生成信息 页签里打开 移动端页面(每张表单独设置,默认关闭),预览、下载和命令行生成(pnpm gen write <表>)会多出这些文件:
mobile/src/api/<领域>/<模块>.ts:接口封装;mobile/src/pages-biz/<领域>/<模块>/index.vue、detail.vue、form.vue:列表、详情和表单页(只读模块没有表单页);mobile/src/locales/{zh-CN,en-US}/<领域>.<模块>.json:翻译片段(实体名和菜单名)。
只有仓库里有 mobile/src/pages.json 时才生成。生成器不改已有的文件,注册代码的最后几行就是要手工加进 pages.json 的内容。以示例的图书模块为例:
mobile/src/pages.json (subpackage "pages-biz"):
{ "path": "demo/book/index", "style": {} }
{ "path": "demo/book/detail", "style": {} }
{ "path": "demo/book/form", "style": {} }
an entry point is up to the project, e.g. a shortcut in mobile/src/pages/home/index.vue (SHORTCUTS) to /pages-biz/demo/book/index最后一行提醒你:入口要自己加,比如上文的 SHORTCUTS。
生成的页面依赖手写的 mobile/src/core/crud.ts 和 locales/*/crud.json:
// mobile/src/api/demo/book.ts(生成的)
// @generated by qw-codegen (crud)
import type { BookCreate, BookVo } from '@qiwu/shared'
import { crudApi } from '@/core/crud'
const BASE = '/demo/books'
/** /api/demo/books for the mobile pages: the standard CRUD calls. */
export const bookApi = {
...crudApi<BookVo, BookCreate>(BASE),
}crudApi 提供 page、get、create、update、remove 五个方法,对应标准的增删改查接口。
列表页用 z-paging 分页(下拉刷新、滚到底加载下一页),只有一个关键字搜索框;新增按钮包在 <QwPerm :perm="bookPerms.create"> 里。树表一层一层点进下级,主子表的子表每行一张卡片,可以添加、删除行。校验用共享包的 <模块>Create 规则,服务端会再校验一次。
生成的移动端页面没有这些,需要时自己补:密文列、关键字以外的筛选、详情里的部门名和用户名(显示编号)、子表单元格上的错误提示。页面的样子见代码生成器 · 移动端页面。
构建与打包
| 命令 | 产物 | 说明 |
|---|---|---|
pnpm mobile:build:h5 | mobile/dist/build/h5 | 只用于调试和端到端测试 |
pnpm mobile:build:mp-weixin | mobile/dist/build/mp-weixin | 构建后自动检查包体积,见下文 |
pnpm mobile:build:app | mobile/dist/build/app | App 的资源,还要用 HBuilderX 打成安装包 |
哪些是自动的、哪些要手工做:
- 自动:编译三个平台;小程序的包体积检查;
build:app把mobile/unpackage/res(图标和启动图)复制到dist/build/app/unpackage/res; - 手工:上传小程序(微信开发者工具或 miniprogram-ci);在 HBuilderX 里打包 App、准备签名证书;提交审核、上架。
小程序和 App 构建时一定要带上 VITE_API_BASE,换成你自己的接口域名:
VITE_API_BASE=https://api.example.com/api pnpm mobile:build:mp-weixin
VITE_API_BASE=https://api.example.com/api pnpm mobile:build:app$env:VITE_API_BASE='https://api.example.com/api'
try {
pnpm mobile:build:mp-weixin
if ($LASTEXITCODE -ne 0) { throw "Mini program build failed" }
pnpm mobile:build:app
if ($LASTEXITCODE -ne 0) { throw "App build failed" }
} finally {
Remove-Item Env:VITE_API_BASE
}只构建一个平台时,保留对应的那一行。
小程序包体积检查
build:mp-weixin 编译后运行 mobile/scripts/mp-size.mjs,打印每个包的大小,下面任一项不满足就失败:
- 主包和每个分包都不超过 2 MB,总共不超过 30 MB(微信的限制);
- 每个包只引用主包和本包的代码和组件;
- 产物里没有小程序不能运行的代码:
Function("return this")、require("ws")、xmlhttprequest-ssl。
src/static/ 下的文件都打进主包,所以大图片不要放在这里。
用 HBuilderX 打包 App
- 安装 HBuilderX 5.26(和
@dcloudio/*的版本对应),登录 DCloud 账号; - 在
mobile/src/manifest.json填好appid(DCloud 应用标识,可以在 HBuilderX 的 manifest 可视化编辑器里获取)、name(应用名,模板里是Qiwu)、versionName和versionCode; - 带上
VITE_API_BASE运行pnpm mobile:build:app; - 在 HBuilderX 里导入
mobile/dist/build/app,选 发行 → 原生 App-云打包。云打包由 DCloud 的服务器打出安装包(安卓的 APK、iOS 的 IPA)。也可以用 DCloud 的离线 SDK 在 Android Studio 或 Xcode 里本地打包。
App 图标和启动图是 mobile/unpackage/res/{icons,splash}/ 下的 PNG,manifest.json 按相对路径引用它们。它们不放在 src/static,否则会打进小程序的主包。换图标时,在 HBuilderX 的 manifest 可视化编辑器里用一张 1024×1024 的原图重新生成各种尺寸。
签名证书、隐私政策和上架见下文的发布清单。
微信小程序登录
员工在小程序里可以直接用微信登录,第一次要绑定员工账号。用户看到的过程和管理员的启用步骤见登录与账号 · 微信小程序登录。代码在这些地方:
| 位置 | 做什么 |
|---|---|
mobile/src/pages/login/index.vue | 微信登录的代码只在小程序里编译:本机没有保存登录状态时,每次启动先用微信静默登录一次。已绑定的进入工作台,没绑定的进入绑定页;没有开启(404)或出错时停在登录页 |
mobile/src/core/stores/auth.ts | wxLogin():uni.login 拿到临时 code,发给 POST /api/auth/wx-mp/login;wxBind():每次提交前重新静默登录,换一张新的绑定票据,再发给 POST /api/auth/wx-mp/bind |
mobile/src/pages-sys/wx-bind/index.vue | 绑定页:用账号密码或短信验证码登录一次,按钮是 绑定并登录 |
apps/server/src/modules/platform/iam/social/ | 服务端:向微信换取身份、发绑定票据、绑定 |
本地调试微信登录需要:
在
apps/server/.env.local里加上小程序的 AppID 和 AppSecret(Windows 上用记事本打开:notepad apps\server\.env.local),然后重启后端:iniWX_MP_APPID=wx开头的AppID WX_MP_SECRET=小程序的AppSecretAppSecret 只能放在这个文件里。
apps/server/.env里不要有这两个键,空值也不行,否则会盖住.env.local里的值;在 系统管理 → 参数设置 里把
auth.wx_mp.enabled(微信小程序登录)改为true。开关关闭、或者缺少 AppID 或密钥时,微信登录的接口都返回 404,登录页照常显示账号密码和短信登录;mobile/src/manifest.json的mp-weixin.appid填同一个 AppID,并带上VITE_API_BASE编译。
几点细节:
- 绑定票据有效 5 分钟,只能用一次,并且绑定了静默登录时的客户端 IP。应用每次提交绑定前都会重新取一张,所以一般不会过期;
- 退出登录后,同一次启动里不会再自动用微信登录,登录页显示账号密码和短信登录;
- 安全规则见安全基线 · 微信小程序登录。
微信订阅消息
有新的审批待办时,可以用微信的一次性订阅消息提醒审批人,默认关闭。用户怎么订阅、参数怎么配,见消息中心 · 微信订阅消息。
开发上要知道的:
- 小程序这边在
mobile/src/core/wx-subscribe.ts:loadSubscribeIds()调用GET /api/iam/profile/socials/wx-mp/subscribe,拿到可以申请的模板 ID(只在小程序、已绑定微信、开关打开、配了模板时才有);requestSubscribe(ids)弹出微信的订阅窗口,一次最多 3 个模板; requestSubscribe必须在点击事件里直接调用,前面不能有await:微信只接受由用户点击发起的订阅请求;- 服务端在站内信投递成功之后发送,不建表、不重试,订阅的次数由微信保存。实现在
apps/server/src/modules/platform/iam/social/wx-subscribe.service.ts; - 想让别的通知也发微信提醒,不用改代码:在参数
notify.wx_subscribe.templates里按通知的模板编码加一项即可。通知怎么发见消息通知; - 模板自带的自动测试用的是模拟的微信接口,真实的发送要在真机上核对,见发布清单。
App 版本更新
管理员在 系统管理 → App 版本 里发布新版本,员工的 App 启动时弹窗提示更新。字段、选择规则和两个参数见系统管理 · App 版本。
App 这边的代码在 mobile/src/core/update.ts:
App.vue的onLaunch里调用一次checkUpdate()。只有安卓和 iOS 的 App 检查;H5 不检查,小程序由微信在冷启动时自己更新;- 它把平台、资源版本(
plus.runtime.getProperty取到的当前 wgt 版本)和原生版本(plus.runtime.version)发给GET /api/settings/app-versions/latest。这个接口不用登录,每个 IP 每分钟最多 60 次,任何失败都不提示; - wgt(资源热更新包:只包含页面和脚本,不含原生代码)在 App 里下载,用
plus.runtime.install安装后自动重启。安装时force: false,版本不比当前高、或者属于别的 App 的包会被拒绝; - 整包(完整的安装包:安卓的 APK,iOS 上是 App Store 里的新版本)用
plus.runtime.openURL打开下载地址:安卓用浏览器下载安装包,iOS 打开 App Store。App 里不直接安装 APK。
发布一个 wgt
- 调高
mobile/src/manifest.json的versionName,它就是 wgt 的版本号; - 带上
VITE_API_BASE运行pnpm mobile:build:app,在 HBuilderX 里导入mobile/dist/build/app,选 发行 → 原生 App-制作移动 App 资源升级包; - 把生成的 wgt 上传到一个 https 地址;
- 在 系统管理 → App 版本 里新增一行,包类型选 资源热更新包。
改了原生层(新增模块或权限、升级 uni-app、HBuilderX 或 SDK)就必须发整包,之后的 wgt 要把 最低原生版本 填成这个整包的版本,否则旧的整包装上新的资源会出错。
整包提交应用商店审核之前,把参数 app.update.review_version(审核中的原生版本)设成提交的版本号,审核通过后清空。iOS 的热更新不能改变 App 的主要功能(App Store 的规则)。
谁能发版
有 settings.appVersion.create 或 settings.appVersion.modify 权限的人,可以给所有装了 App 的手机下发代码。只把这两个权限给负责发版的人。
App 权限
安卓:manifest.json 的 app-plus.distribute.android.permissions 只列了代码用到的权限:
| 权限 | 用途 |
|---|---|
ACCESS_NETWORK_STATE、ACCESS_WIFI_STATE | 网络状态 |
CAMERA,以及 uses-feature android.hardware.camera | 换头像时 uni.chooseImage 可以拍照 |
DCloud 模板默认带的其他权限都删掉了。没有 REQUEST_INSTALL_PACKAGES(整包走浏览器下载),也没有 READ_EXTERNAL_STORAGE;在安卓 12 及以下的手机上从相册选图是否正常,还没有在真机上验证。
iOS:app-plus.distribute.ios.privacyDescription 里写了相机(NSCameraUsageDescription)和相册(NSPhotoLibraryUsageDescription)的用途说明。缺少用途说明时,iOS 调用会闪退,审核也会被拒。说明现在只有中文,英文系统的 iPhone 上也显示中文,措辞可以直接改。
单元测试 mobile/src/__tests__/mobile-manifest.spec.ts 会检查这两份列表。新功能要用某项原生能力时:
- 在
manifest.json里加上对应的权限或用途说明; - 在同一次提交里修改这个测试;
- 在隐私政策里写明用途和什么时候申请;
- 改了权限属于改原生层,要发整包。
发布清单
每次正式发布按顺序做一遍,把这一节复制到你的发布记录里逐项确认。标"首次"的只在第一次发布、换主体或换域名时做。
1. 发布前(小程序和 App 共用)
- 首次:去掉代码生成器示例页的登记。图书、知识主题、发票三个示例模块的 9 个页面登记在
pages.json的pages-biz里,没有入口,但登记过的页面会随小程序和 App 一起上线:- 删掉
mobile/src/pages.json里demo/book、demo/topic、demo/invoice下的index、detail、form共 9 条; - 同时删掉
mobile/e2e/mobile-codegen.spec.ts,它按地址打开这些页面,页面没登记就会失败; - 示例的源码(
mobile/src/pages-biz/demo/、mobile/src/api/demo/、翻译片段)留着:pnpm gen:check-golden会把它们和生成器的结果逐字比对;
- 删掉
- 首次:换成自己的应用名和标志:
mobile/src/manifest.json的name、pages.json里globalStyle的navigationBarTitleText(默认标题Qiwu)、mobile/src/locales/{zh-CN,en-US}/common.json的app.name(页面上显示的"栖梧"和Qiwu)、login.json的motto和slogan(登录页的题字和标语)、mine.json的intro("关于"页的介绍),以及标志mobile/src/static/brand/mark-white.svg。英文的motto要保持为空:英文界面不显示题字,单元测试会检查这一点。App 图标和启动图见用 HBuilderX 打包 App; pnpm ci:local全部通过(在前两步之后运行);- 调高
manifest.json的versionName(比如1.2.0)和versionCode(只增不减的整数,比如120)。App 的更新检查和小程序上传时的版本号都用versionName;versionCode是安卓安装和应用市场判断能否升级用的; - 服务端(部署见部署):
- 接口域名已经备案(中国大陆的网站要先做 ICP 备案);HTTPS 证书支持 TLS 1.2 及以上,证书链完整(iOS 不接受自签名证书);
- 反向代理把
/socket.io/按 WebSocket 方式转发,和电脑端相同,见部署 · 反向代理; CORS_ORIGIN先不用为移动端改,真机核对时按需添加(见下文第 4 节的实时推送);- 打开需要的功能:微信登录(
auth.wx_mp.enabled,以及.env.local里的WX_MP_APPID、WX_MP_SECRET)、订阅消息(notify.wx_subscribe.*)、App 更新(app.update.enabled);
- 构建时带上
VITE_API_BASE,见构建与打包。
2. 微信小程序
首次(在微信公众平台 mp.weixin.qq.com 操作):
用企业主体注册小程序并完成小程序备案,服务类目选和实际功能一致的;
把 AppID 填进
mobile/src/manifest.json的mp-weixin.appid,也填进服务端apps/server/.env.local的WX_MP_APPID;开发管理 → 开发设置 → 服务器域名:
类型 填什么 request 合法域名 https://<接口域名>uploadFile 合法域名 https://<接口域名>(附件和头像经后端上传)socket 合法域名 wss://<接口域名>(实时推送;不配也能用,角标退回每 60 秒查询)downloadFile 合法域名 不用配,小程序不下载文件 域名只能是已备案的 https / wss 域名,不能用 IP 或 localhost;
用户隐私保护指引(设置 → 服务内容声明 → 用户隐私保护指引,也可以在提交审核时填写),按代码实际调用的隐私接口声明:
指引里的信息类型 代码里的调用 用途 选中的照片或视频信息 uni.chooseImage(个人资料页换头像)更换头像 选中的文件 uni.chooseMessageFile(QwUpload,附件上传)上传审批或单据的附件 没用到的不要勾(位置、手机号按钮、摄像头组件、通讯录、剪贴板等)。账号、姓名、手机号、头像、部门、审批内容这些你自己收集的个人信息,写进指引的补充文档或隐私政策。没有在指引里声明的隐私接口会直接调用失败。微信的规则会变,以提审页面的提示为准;
订阅消息(可选):按消息中心 · 微信订阅消息选模板、配置参数、打开开关;
用 miniprogram-ci 上传的话:在 开发管理 → 开发设置 → 小程序代码上传 下载代码上传密钥
private.<AppID>.key,放在仓库外(根目录的.gitignore已经忽略private.*.key),并开启 IP 白名单,只填上传机器的出口 IP。
每次:
- 上传,二选一:
- 微信开发者工具:导入
mobile/dist/build/mp-weixin(AppID 和manifest.json一致)→ 上传,版本号填versionName,备注写这次的变更; - miniprogram-ci(MIT 许可):命令见下文;
- 微信开发者工具:导入
- 在版本管理里把上传的开发版设为体验版 → 按第 4 节在真机上核对 → 提交审核 → 审核通过后发布。小程序必须登录才能用:提审时说明登录方式,并提供一个测试账号(用测试数据,不要用真实员工的账号)。
用 miniprogram-ci 上传时,不把它加进依赖,用 npx 临时运行,版本写死。把 AppID、版本号、说明和仓库外的密钥路径换成你自己的:
npx [email protected] upload \
--pp mobile/dist/build/mp-weixin \
--pkp <仓库外的目录>/private.<AppID>.key \
--appid <AppID> \
--uv <versionName> --ud "<变更说明>" \
-r 1 --enable-es6 truenpx miniprogram-[email protected] upload --pp mobile/dist/build/mp-weixin --pkp "C:/keys/private.APPID.key" --appid APPID --uv 1.2.0 --ud "Release notes" -r 1 --enable-es6 true
if ($LASTEXITCODE -ne 0) { throw "Mini program upload failed" }-r 是机器人编号(1–30)。Windows 上所有参数写在一行,路径和说明加引号;中文说明在 Windows PowerShell 5.1 下的编码没有验证过,建议用英文。上传本身也没有在 Windows 上执行过。
3. App(安卓、iOS)
首次:
- HBuilderX 5.26 登录 DCloud 账号;
manifest.json的appid填 DCloud 应用标识,name填应用名; - 安卓:
包名(比如
com.example.qiwu)在云打包时填写,上架后不能再改;签名证书二选一:DCloud 云端证书(本地不需要文件),或者自有证书(上架应用市场时推荐;证书丢了,同一个包名就再也发不了更新)。自有证书用 JDK 自带的
keytool生成,macOS 和 Windows 命令相同:bashkeytool -genkeypair -alias qiwu -keyalg RSA -keysize 2048 -validity 36500 -keystore qiwu.keystore证书文件和密码放在仓库外(比如密码管理器),另做备份。根目录的
.gitignore已经忽略*.keystore、*.jks;隐私政策弹窗:国内应用市场要求用户同意隐私政策之前不申请权限、不读设备信息。在
mobile/src/(和manifest.json同一个目录)放一个androidPrivacy.json,用模板模式("prompt": "template"),正文链接到隐私政策和用户协议的网页。这个文件里不能写注释,格式见 DCloud 文档"Android 平台隐私与政策提示框";
- iOS:
- 用公司的 Apple 开发者账号,在开发者后台建 App ID(Bundle ID,比如
com.example.qiwu); - 发布证书(导出成带密码的
.p12)和 App Store 类型的描述文件(.mobileprovision),云打包时上传。它们放在仓库外,.gitignore已经忽略*.p12、*.mobileprovision、*.cer; - 相机和相册的用途说明已经写好,见App 权限;
- 用公司的 Apple 开发者账号,在开发者后台建 App ID(Bundle ID,比如
- 隐私政策和用户协议:内容是你自己的法律文本,放在一个 https 网页上。登录页底部的 隐私政策、用户协议 现在打开的是"关于"页(
mobile/src/pages/login/index.vue的openAbout),发布前改成打开这两个网页。权限说明照App 权限的列表写:相机(换头像、拍照上传)、网络状态,写明用途和什么时候申请; - 上架资料:国内安卓应用市场通常要软件著作权证书、隐私政策网址和截图;App Store 要审核备注和测试账号(同样用测试数据)。
每次:
- 带上
VITE_API_BASE运行pnpm mobile:build:app→ HBuilderX 导入mobile/dist/build/app→ 发行 → 原生 App-云打包(选好证书和描述文件); - 只改了页面和脚本、没动原生层的,可以只发 wgt,见发布一个 wgt;改了原生层必须发整包;
- 整包提交商店审核前,把参数
app.update.review_version设成提交的版本号,审核通过后清空。
4. 真机核对
设备:一台安卓手机、一台 iPhone,分别跑小程序体验版和云打包的 App(不要用 HBuilder 标准基座,它在 iOS 上写死了浅色)。每台设备浅色、深色各一遍,中文、英文各一遍,可以交叉覆盖。
外观:
- 小程序右上角的胶囊按钮和自绘的深蓝色头部(登录页、页签页面)垂直对齐,不挡内容;
- 自绘的底部页签栏:文字随语言切换,图标的浅色、深色版本正确,角标正常;
- 深色:小程序和 App 都能跟随系统;App 的 我的 → 外观 三个选项立即生效,重启后保持;
- 安卓:键盘弹起时登录页换成紧凑的头部,输入框不被挡住;
- iOS:刘海和底部横条处的安全区留白正确(页签栏、底部操作栏、弹层);
- 深蓝色的页面(登录、绑定微信、页签页面)切换主题后,状态栏文字仍是白色;
- App 图标和启动图在安卓、iOS 上清晰、不变形。
功能:
- 登录:账号密码、短信、验证码(按参数
captcha.mode)、记住用户名;退出后回到登录页; - 小程序:静默登录;没绑定时进入绑定页,绑定后再打开直接进入工作台;
- 小程序绑定在 Wi‑Fi 和移动网络(4G/5G)下各做一次。每次提交前应用先重新静默登录、取一张新的绑定票据,服务端要求提交时的 IP 和取票据时相同。如果提示"绑定已失效,请重新进入小程序后再绑定",记下当时的网络,多半是这个网络下前后两次请求的 IP 不一样;
- 审批:待办列表、详情、通过、驳回、退回、转办等操作;发起请假和动态表单;附件上传(小程序从聊天记录选文件,App 选图片);
- 换头像:拍照和相册各试一次。小程序第一次调用时弹出微信的隐私授权;iOS 第一次拍照时弹出系统的相机权限提示,文字是
manifest.json里的用途说明。在一台安卓 12 及以下的手机上,换头像和审批附件各从相册选一次图; - 切换语言后,各页、页签栏和系统弹窗的语言一致;
- 实时推送:在电脑端让员工提交请假,主管手机上的待办角标和消息角标在 2 秒左右各加 1(小程序、App 分别试);切到后台再回来,能重新连接并补上计数;管理员在电脑端强制下线这个会话后,手机回到登录页并给出提示。
- 角标要等约 60 秒才变时(轮询兜底),先查 socket 合法域名和反向代理的 WebSocket 转发;
- 再查连接是不是因为来源被拒(
forbidden_origin):真机的连接可能带Origin头,它既不是本站、也不在CORS_ORIGIN里时会被拒绝。在反向代理上临时记录/socket.io/请求的Origin(nginx 用$http_origin),把看到的那个值原样加进CORS_ORIGIN(逗号分隔,只加这一个,不要放宽),然后重启服务端。注意CORS_ORIGIN同时也决定哪些网站能凭电脑端的登录 Cookie 换新令牌;
- 订阅消息(有真实的 AppID、AppSecret 和模板时):小程序 我的 页出现 新待办微信提醒,点击后微信弹窗申请订阅;之后给这个用户派一条新待办,微信"服务通知"里收到提醒,点开进入这条审批的详情;
- App 版本更新(安卓、iOS 各一遍):开关关闭时没有提示;可选的 wgt 有 稍后,立即更新 后下载、安装并自动重启,重启后不再提示这个版本;强制的 wgt 没有 稍后,下载失败(比如断网)后再次弹出;整包在安卓上打开浏览器下载、在 iOS 上打开 App Store,强制更新时从商店或浏览器返回后再次弹出;
app.update.review_version等于当前原生版本时收不到 wgt; - 小程序包体积:发布构建打印的主包不超过 2 MB(超过时构建直接失败)。
5. 发布记录
每次发布记一条:日期、versionName / versionCode、小程序上传的版本号、HBuilderX 版本、设备型号和系统版本、微信版本,以及上面每一项的结果和发现的问题。
测试与检查
| 命令 | 作用 |
|---|---|
pnpm mobile:verify | 类型检查(vue-tsc,应用代码和 Node 侧各一份配置)+ 移动端依赖的许可证检查 |
pnpm mobile:test [文件名] | vitest 单元测试 |
pnpm mobile:e2e [文件名] | Playwright 端到端测试,跑 H5 |
pnpm -C mobile audit:report | 依赖安全报告(pnpm audit):DCloud 锁定的依赖带来的告警只单独列出,不算失败;其余依赖有高危或严重告警时失败。需要联网,不在 verify 里 |
这些命令在仓库根目录执行,macOS 和 Windows 相同。另外,根目录的 pnpm verify 会检查 mobile/src 的翻译,pnpm ci:local 会运行上面的前三项,以及 H5 和小程序的构建。
许可证的例外登记在 mobile/license-exceptions.json,只对 mobile/ 有效:caniuse-lite(CC-BY-4.0)、qrcode-terminal(许可证写成了不标准的 Apache 2.0)、z-paging 2.8.8(npm 包里缺少许可证字段,它的仓库是 MIT,升级时要重新核对)。
单元测试
测试文件放在 mobile/src/__tests__/,文件名是 *.spec.ts。建议以 mobile- 开头,和仓库里其他测试文件不重名。它们在 Node 里运行,不加载 uni-app 的编译器;uni 对象由 src/__tests__/uni-stub.ts 模拟。适合测 core/ 里的逻辑,比如请求的刷新和重发、计数、动态表单的规则。
端到端测试
pnpm mobile:e2e 用 Playwright 在模拟的 Pixel 7 手机上跑 H5。它会自己构建 H5 并启动预览,再启动一个后端,所以要等一会儿才开始跑用例。它用自己的一套库、Redis 库号和端口,可以和电脑端的 Playwright 同时运行:
| 移动端 e2e | 电脑端 e2e | |
|---|---|---|
| 数据库 | qiwu_mobile_e2e | qiwu_e2e |
| Redis 库号 | 9 | 14 |
| 后端端口 | 3201 | 3200 |
| 页面端口 | 4175(H5 预览) | 4173 |
运行前要准备:
用 MySQL 的 root 账号执行下面的 SQL(Windows 在 MySQL Workbench 里,macOS 在
mysql -u root -p里),建库并授权给.env.local里的数据库账号(qiwu)。每次运行都会清空重建这个库,不要放有用的数据:sqlCREATE DATABASE qiwu_mobile_e2e CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; GRANT ALL ON qiwu_mobile_e2e.* TO 'qiwu'@'localhost'; GRANT ALL ON qiwu_mobile_e2e.* TO 'qiwu'@'127.0.0.1';后端用
apps/server/.env.local(数据库和 Redis 的账号)加上已经提交的.env.e2e,再换成上表的库、库号和端口;先在仓库根目录构建,测试用的是后端的构建产物:
bashpnpm build pnpm mobile:e2e只跑一个文件:
pnpm mobile:e2e mobile-login.spec.ts;默认用本机安装的 Microsoft Edge(Windows 自带,macOS 要自己安装)。用别的浏览器时设置环境变量
PW_CHANNEL,比如chrome。
端口被占用,或者要在两个工作目录里同时运行
在 apps/server/.env.mobile-e2e.local(不提交到 git)里改这一份的库、库号、端口和上传目录。只能写 DB_NAME(必须以 _e2e 结尾)、REDIS_DB、PORT、STORAGE_LOCAL_ROOT 和 H5 预览端口 E2E_H5_PORT,写了别的键会直接报错。Windows 上用记事本创建:notepad apps\server\.env.mobile-e2e.local。新的库同样要先建好并授权。
实时推送的测试(mobile/e2e/mobile-push.spec.ts)经 H5 预览的 /socket.io 转发连真实的后端,检查推送后 2 秒内刷新角标、强制下线后 2 秒内回到登录页、切到后台断开、回到前台后重连。
升级 uni-app
在 mobile/ 目录里用 DCloud 的官方工具统一升级 @dcloudio/*,并和本机 HBuilderX 的版本对应;vite、vue、@vue/runtime-core、@dcloudio/types 跟着它要求的精确版本走。macOS 和 Windows 命令相同:
cd mobile
npx @dcloudio/uvm@latest --manager pnpm
pnpm i
cd ..
pnpm mobile:verify
pnpm mobile:build:h5
pnpm mobile:build:mp-weixin
pnpm mobile:build:app
pnpm mobile:e2epnpm i不能出现ERR_PNPM_IGNORED_BUILDS,需要时修改mobile/pnpm-workspace.yaml的allowBuilds;mobile/pnpm-workspace.yaml的overrides把vite固定在 5.2.8,新版本要求的 vite 不同时,同时改这里;- 依赖要发布满一天才能安装(
minimumReleaseAge),刚发布的版本要等一天; - 升级 uni-app 属于改原生层,App 要发整包。
只要电脑端:删除移动端
不需要手机端时,可以把移动端整个删掉。只有第一步是必须的,其余都是清理:mobile/ 不存在时,下面列出的地方都会自动跳过,留着也没有影响。
建议先提交当前的改动,在一个新分支上删,有问题可以用 git 恢复。
1. 删除 mobile/ 和它的开发说明 docs/mobile.md:
rm -rf mobile docs/mobile.mdcmd /c rmdir /s /q mobile
Remove-Item docs\mobile.mdREADME.md 和 docs/ 下的几篇文档有指向 docs/mobile.md 的链接,删掉后可以顺手去掉这些链接。
2. 根目录 package.json: 删掉 7 个 "mobile:*" 脚本(mobile:install、mobile:verify、mobile:test、mobile:e2e、mobile:build:h5、mobile:build:mp-weixin、mobile:build:app)。
3. lint 和格式化的忽略设置:
.oxlintrc.json的ignorePatterns里删掉"mobile/**";.prettierignore删掉mobile/这一行和它上面的注释;eslint.config.mjs删掉'mobile/**',这一行。
4. 检查脚本(建议留着): scripts/ci-local.mjs、scripts/i18n-check.mjs、scripts/license-check.mjs、scripts/originality-check.mjs 里各有一个 const MOBILE = … 常量,没有 mobile/ 时用到它的步骤自动跳过。想删的话,要把用到这个常量的代码一起删干净(比如 ci-local.mjs 里 6 个移动端步骤:安装、verify、单元测试、H5 构建、小程序构建、端到端测试),只删常量会让脚本报错。
5. 代码生成器(留着): apps/server/src/modules/platform/codegen/workspace.ts 的 export const MOBILE = … 和 scripts/gen-check-golden.mjs 的 const MOBILE = … 不用改。没有 mobile/ 时,生成器不生成移动端页面,移动端页面 开关打开也不会多出文件,一致性检查也不比对移动端文件。
6. 架构检查(留着): scripts/arch/mobile-refs.mjs 检查主工程有没有引用 mobile/,和它的测试 apps/server/test/arch/arch-mobile-refs.spec.ts 要删一起删。这个测试文件同时也测"共享包只能导入 zod"的检查,不删也没有影响。
7. 检查:
pnpm i --frozen-lockfile
pnpm --filter @qiwu/shared build
pnpm verifymacOS 和 Windows 命令相同,一步成功后再执行下一步。根目录的 pnpm-lock.yaml 不会变,因为移动端从来不在根目录的锁定文件里。