5. 页面开发范式
一句话定位
一个移动端页面 = pages.json 注册一条 + 一个 .vue 文件(template / script setup lang="ts" / style scoped lang="scss"),数据来自 api_all,交互走 uni.xxx。
页面骨架
pages/xxx/xxx.vue
├─ <template> 只能用 uni-app 组件:view / text / image / input / button / scroll-view / swiper / picker …
│ 自定义组件:import Xxx from '@/pages/component/xxx.vue'
├─ <script lang="ts" setup> import { reactive, ref, onMounted } from 'vue';import { useXxxApi } from '@/api_all/xxx'
│ ├─ const state = reactive({ ... }) // 全部状态收敛到一个 reactive
│ ├─ onMounted(() => load()) // 页面加载
│ ├─ onPullDownRefresh(() => reload()) // 下拉刷新(需在 pages.json 开启 enablePullDownRefresh)
│ └─ onReachBottom(() => loadMore()) // 上拉加载
└─ <style lang="scss" scoped> 直接用 $uni-color-primary 等变量,尺寸用 rpx一、在 pages.json 注册页面
{
"pages": [
{ "path": "pages/login/login", "style": { "navigationStyle": "custom" } }, // pages[0] = 启动页
{
"path": "pages/sysUser/index", // ← 新增页面
"style": {
"navigationBarTitleText": "用户列表",
"enablePullDownRefresh": true, // 开启下拉刷新
"onReachBottomDistance": 50 // 上拉触底距离(rpx)
}
}
],
"globalStyle": {
"navigationBarTextStyle": "white",
"navigationBarBackgroundColor": "#11559c",
"navigationBarTitleText": "Admin.NET"
},
"easycom": { "autoscan": true },
"tabBar": {
"custom": true,
"list": [
{ "pagePath": "pages/home/home" },
{ "pagePath": "pages/mine/mine" }
]
}
}规则:
- 未注册的页面跳转直接失败;路径不带
.vue后缀。 - tabBar 页(
pages/home/home、pages/mine/mine)只能用uni.switchTab打开,且不能带参数。 enablePullDownRefresh默认false(pages/feedback/feedback里显式写了false),需要下拉就必须显式true。- 单页样式写在
style里,globalStyle是兜底。 easycom.autoscan: true已开启,但本仓库的pages/component/*仍按显式 import 使用,保持一致即可。
当前仓库已注册页面(App/pages.json):
| 页面 | 说明 | 关键 style |
|---|---|---|
pages/login/login | 启动页(未登录首入) | navigationStyle: custom |
pages/home/home | 首页(tabBar) | 标题 Admin.NET 开发框架 |
pages/mine/mine | 个人中心(tabBar) | 标题 个人中心 |
pages/password/password | 修改密码 | 标题 修改密码 |
pages/about/about | 关于我们 | 深色导航条 #23212F |
pages/feedback/feedback | 意见反馈 | enablePullDownRefresh: false |
二、页面生命周期
uni-app 页面除 Vue 自身的 onMounted / onUnmounted 外,还有一套页面级钩子(从 @dcloudio/uni-app 引入):
onLoad(options) 页面加载,options = 上一个页面传来的参数 → 只执行一次,适合初始化 + 拉详情
↓
onShow 页面显示(每次切回来都触发) → 适合刷新列表、恢复定时器
↓
onReady 首次渲染完成 → 适合操作 DOM / 创建地图等原生组件
↓
onHide 页面隐藏(navigateTo 到下一页也会触发)
↓
onUnload 页面卸载(redirectTo / reLaunch / navigateBack)
附加钩子(在 pages.json 开启后生效)
onPullDownRefresh 下拉刷新 ← 需 enablePullDownRefresh: true
onReachBottom 上拉触底 ← 需 onReachBottomDistance
onPageScroll 页面滚动
onShareAppMessage 分享(小程序)<script lang="ts" setup>
import { reactive } from 'vue'
import { onLoad, onShow, onReady, onHide, onUnload, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
const state = reactive({ id: 0, detail: {} as any, timer: null as any })
onLoad((options: any) => {
state.id = Number(options?.id ?? 0) // ★ 参数只在 onLoad 的 options 里
load()
})
onShow(() => { /* 从下一页返回、切 tab 回来都会走这里 */ })
onReady(() => { /* 渲染完成 */ })
onHide(() => { /* 暂停轮询、暂停播放器 */ })
onUnload(() => { if (state.timer) clearInterval(state.timer) }) // ★ 定时器必须清
onPullDownRefresh(() => load())
onReachBottom(() => loadMore())
</script>`onLoad` 与 `onMounted` 怎么选
需要参数用 onLoad(options);只做「拉数据、注册事件」用 onMounted 也行(仓库现有页面都是 onMounted)。卸载清理用 onUnmounted 或 onUnload 均可,login.vue 用的是 onUnmounted(() => clearInterval(timer))。
三、列表页(分页 + 下拉刷新 + 上拉加载)
<template>
<view class="pd-lr-20">
<view class="cardBox mg-t-20" v-for="(item, i) in state.list" :key="i" @click="goDetail(item.id)">
<view class="row">
<text class="name">{{ item.realName }}</text>
<view class="tag info">{{ item.account }}</view>
</view>
<view class="sub">{{ item.orgName }} · {{ item.createTime }}</view>
</view>
<view class="empty" v-if="!state.list.length && !state.loading">暂无数据</view>
<view class="empty" v-else-if="state.finished">没有更多了</view>
</view>
</template>
<script lang="ts" setup>
import { reactive } from 'vue'
import { onMounted, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import { useXxxApi } from '@/api_all/xxx'
const api = useXxxApi()
const state = reactive({
query: { page: 1, pageSize: 10, keyword: '' },
list: [] as any[],
total: 0,
loading: false,
finished: false
})
// 加载(append = true 时追加,用于上拉)
const load = async (append = false) => {
if (state.loading || state.finished) return
state.loading = true
uni.showLoading({ title: '加载中' })
try {
const res: any = await api.page(state.query, false, false) // 自行 catch
const items = res.result?.items ?? []
state.total = res.result?.total ?? 0
state.list = append ? state.list.concat(items) : items
state.finished = state.list.length >= state.total // 与后端分页参数对接
} catch (e: any) {
uni.showToast({ title: e?.errMsg || '加载失败', icon: 'none' })
} finally {
state.loading = false
uni.hideLoading()
uni.stopPullDownRefresh() // 下拉动画必须手动关闭
}
}
// 下拉刷新:重置页码后重新加载
const reload = () => { state.query.page = 1; state.finished = false; load(false) }
// 上拉加载:页码 +1 后追加
const loadMore = () => {
if (state.list.length >= state.total) { state.finished = true; return }
state.query.page += 1
load(true)
}
const goDetail = (id: number) => uni.navigateTo({ url: `/pages/xxx/detail?id=${id}` })
onMounted(() => load())
onPullDownRefresh(() => reload())
onReachBottom(() => loadMore())
</script>
<style lang="scss" scoped>
.pd-lr-20 { padding: 0 20rpx; }
.mg-t-20 { margin-top: 16rpx; }
.cardBox { padding: 20rpx; background: #fff; border-radius: 15rpx; box-sizing: border-box; }
.row { display: flex; align-items: baseline; justify-content: space-between; }
.name { font-size: 30rpx; font-weight: bold; color: $uni-text-color; }
.sub { margin-top: 12rpx; font-size: 26rpx; color: $uni-text-color-grey; }
.tag { display: inline-block; border: 1px solid #cee3f9; background: #cee3f9; color: $uni-color-primary; border-radius: 8rpx; padding: 4rpx 10rpx; font-size: 24rpx; }
.empty { text-align: center; color: $uni-text-color-grey; font-size: 26rpx; padding: 40rpx 0; } // 空态/到底
</style>与后端分页的对接要点:
| 前端 | 后端 |
|---|---|
query.page(从 1 开始) | BasePageInput.Page,[Range(1, int.MaxValue)],默认 1 |
query.pageSize / query.field / query.order | BasePageInput.PageSize(默认 20);Field / Order 为排序字段与方向(如 CreateTime / desc) |
res.result.items / res.result.total | SqlSugarPagedList.Items / Total(total 用于判断是否还有下一页) |
仓库里最接近的现成例子是 pages/component/sysLogOp.vue(首页内嵌的操作日志卡片流):
<script lang="ts" setup>
import { onMounted, reactive } from 'vue'
import { usesysLogOpApi } from '@/api_all/sysLogOp'
const state = reactive({
theme: uni.getStorageSync('theme'),
requestBody: { page: 0, pageSize: 10 },
logData: {} as any
})
const logOpApi = usesysLogOpApi()
onMounted(() => {
logOpApi.page(state.requestBody).then((res: any) => {
state.logData = res.result.items // ★ 默认自动捕获异常并 toast
})
})
</script>四、表单页(新增/编辑/改密)
<template>
<view class="pd-lr-20">
<view class="label"><text class="icon">🔒</text> 原密码</view>
<input class="input mg-t-20" password v-model="state.form.passwordOld" placeholder="请输入原密码" />
<view class="label mg-t-20"><text class="icon">🔒</text> 新密码</view>
<input class="input mg-t-20" password v-model="state.form.passwordNew" placeholder="6-15 位字母或数字" @blur="checkPwd" />
<view :class="['tip', state.valid ? '' : 'red']">{{ state.tip }}</view>
<button class="btn-primary mg-t-20" @click="submit">提交</button>
</view>
</template>
<script lang="ts" setup>
import { reactive } from 'vue'
import { sm2 } from 'sm-crypto'
import { env } from '@/utils/.env.js'
import { useappAuthApi } from '@/api_all/appAuth'
import { PhoneVal } from '@/utils/validate'
const appAuthApi = useappAuthApi()
const state = reactive({ form: { passwordOld: '', passwordNew: '' }, valid: false, tip: '' })
const checkPwd = () => { // 前端先校验,减少一次无效请求
state.valid = state.form.passwordNew.length >= 6 && state.form.passwordNew.length <= 15
state.tip = state.valid ? '' : '请输入 6-15 位,字母或数字'
}
const submit = async () => {
checkPwd()
if (!state.valid) return uni.showToast({ title: state.tip, icon: 'none' })
// 密码国密 SM2 加密后再提交(模式 1)
const data = {
passwordOld: sm2.doEncrypt(state.form.passwordOld, env.smKey, 1),
passwordNew: sm2.doEncrypt(state.form.passwordNew, env.smKey, 1)
}
try {
const res: any = await appAuthApi.changePwd(data, false, false)
if (res.code == 200) {
uni.showToast({ title: '修改成功', icon: 'none' })
setTimeout(() => uni.navigateBack(), 1000)
}
} catch (e: any) {
uni.showToast({ title: e?.errMsg || '提交失败', icon: 'none' })
}
}
</script>
<style lang="scss" scoped>
.pd-lr-20 { padding: 0 16rpx; }
.mg-t-20 { margin-top: 20rpx; }
.label { display: flex; align-items: baseline; font-size: 30rpx; color: #666; }
.icon { font-size: 35rpx; margin-right: 5px; }
.input { border: 1px solid #eee; border-radius: 8rpx; padding: 20rpx; }
.tip { font-size: 24rpx; color: $uni-text-color-grey; padding: 12rpx 0; }
.red { color: $uni-color-error; }
.btn-primary { color: #fff; width: 90%; border-radius: 30px; background: $uni-color-primary; height: 90rpx; line-height: 90rpx; } // 主按钮
</style>校验工具:utils/validate.js 提供 PhoneVal(手机号正则)、IDcardVal(身份证)、judgementIdCard(解析性别/年龄/生日)。提交前用 PhoneVal.test(v) 先行校验,比请求后再报错体验更好。
真实页面 pages/password/password.vue 的写法要点:两个输入框分别 @blur 校验长度与「两次是否一致」,只有 state.isTrue 为真才允许提交,提交成功后 setTimeout(() => uni.navigateBack(), 1000)。
五、详情页
<script lang="ts" setup>
import { reactive } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { useXxxApi } from '@/api_all/xxx'
const api = useXxxApi()
const state = reactive({ id: 0, detail: {} as any })
onLoad(async (options: any) => { // detail(id) → GET /api/xxx/detail?id=xx
state.id = Number(options.id)
const res: any = await api.detail(state.id, false, false)
state.detail = res.result ?? {}
})
</script>六、页面传参与回传
1. 正向传参(navigateTo + URL query)
// 列表页
uni.navigateTo({ url: `/pages/xxx/detail?id=${item.id}&title=${encodeURIComponent(item.title)}` })// 详情页
import { onLoad } from '@dcloudio/uni-app'
onLoad((options: any) => {
const id = Number(options.id)
const title = decodeURIComponent(options.title ?? '')
uni.setNavigationBarTitle({ title }) // 动态改标题
})2. 回传(三种可选)
// ① 全局事件(最简单,本仓库 mitt 之外可用 uni 自带)
// 详情页
uni.$emit('listRefresh', { id: 1 })
// 列表页 onMounted / onLoad
uni.$on('listRefresh', (data: any) => load())
// 列表页 onUnmounted / onUnload
uni.$off('listRefresh')
// ② 返回上一页后调用上一页实例(H5 / App 常用)
const pages = getCurrentPages()
const prev: any = pages[pages.length - 2]
prev?.reload?.() // 上一页把 reload 暴露出来即可
// ③ 用 storage 传值(tabBar 页不能带参时的唯一办法)
uni.setStorageSync('detailId', item.id)
uni.switchTab({ url: '/pages/home/home' })事件要反注册
uni.$on 注册的回调必须在 onUnload / onUnmounted 里 uni.$off 掉,否则页面反复进出会重复触发。
七、项目内组件(pages/component/,显式 import)
<script lang="ts" setup>
import TabBar from '@/pages/component/tabbar.vue' // 自定义底部导航,:current-page 0=首页 1=我的
import SysLogOp from '@/pages/component/sysLogOp.vue' // 操作日志卡片流
import BusinessOverview from '@/pages/component/BusinessOverview.vue' // 经营概况
import OrderOverview from '@/pages/component/OrderOverview.vue' // 订单概况
</script>
<template>
<BusinessOverview /> <OrderOverview /> <SysLogOp />
<TabBar :current-page="0" />
</template>写自己的组件(pages/component/xxx.vue)模板:
<template>
<view class="panel">
<view class="panel-title">{{ title }}</view>
<slot /> <!-- 允许外部塞内容 -->
</view>
</template>
<script lang="ts" setup>
const props = defineProps({
title: { type: String, default: '' },
currentPage: { type: Number, default: 0 }
})
const emit = defineEmits(['change'])
const onClick = () => emit('change', props.currentPage)
</script>
<style lang="scss" scoped>
.panel { background: #fff; border-radius: 15rpx; padding: 20rpx; }
.panel-title { font-size: 30rpx; font-weight: bold; color: $uni-text-color; }
</style>tabbar.vue 的关键约定(每个 tabBar 页面都要渲染它):
<script lang="ts" setup>
const props = defineProps({ currentPage: Number })
const state = reactive({
theme: uni.getStorageSync('theme'),
currentItem: 0,
tabbarList: [
{ id: 0, path: '/pages/home/home', icon: 'home', text: '首页', centerItem: false },
{ id: 1, path: '/pages/mine/mine', icon: 'account', text: '我的', centerItem: false }
]
})
onMounted(() => {
state.currentItem = props.currentPage
uni.hideTabBar() // ★ 自定义 tabBar 必须隐藏原生 tabBar
})
const changeItem = (item: any) => uni.switchTab({ url: item.path })
</script>八、样式与单位规范
// App.vue 里的全局样式(仓库真实代码)
page {
margin: 0;
padding: 0;
font-size: 28rpx; // 基准字号
background: #f5f5f5; // 页面底色
}| 约定 | 说明 |
|---|---|
尺寸用 rpx | 750rpx = 屏宽;字号 24–32rpx,间距 16/20rpx,卡片圆角 15rpx |
| 主题色用变量 | uni.scss 提供 $uni-color-primary: #11559c、$uni-color-success、$uni-color-warning、$uni-color-error、$uni-text-color、$uni-text-color-grey、$uni-bg-color、$uni-border-color |
必须写 lang="scss" | 不写则 $uni-* 变量不生效 |
建议 scoped | 避免样式外泄;需要覆盖子组件样式时用 :deep() |
| 常用原子类 | .pd-lr-20、.mg-t-20、.cardBox、.tag、.btn-primary(各页面各自定义,非全局) |
// 覆盖子组件(如内置 input 占位符)
:deep(.uni-input-placeholder) { color: #aeaeae; }九、常用组件与 API
内置组件
| 组件 | 用途 | 仓库用例 |
|---|---|---|
view / text | 基础容器与文本(div/span 的替代) | 全部页面 |
image | 图片,mode 控制裁剪 | login.vue 背景、home.vue 轮播 |
swiper / swiper-item | 轮播(indicator-dots、circular) | home.vue |
input / textarea / button | 输入与按钮 | login.vue、feedback.vue、password.vue |
picker | 日期、地区、多列选择 | OrderOverview.vue(mode="date") |
scroll-view | 局部滚动区域 | 列表页可选 |
block | 不渲染的包裹元素,配合 v-for | sysLogOp.vue |
uni.xxx 常用 API
// 交互
uni.showToast({ title: '提示', icon: 'none', duration: 2000 })
uni.showLoading({ title: '加载中' }) / uni.hideLoading()
uni.showModal({ title: '提示', content: '是否退出系统?', success: (e) => { if (e.confirm) ... } })
uni.showActionSheet({ itemList: ['编辑', '删除'], success: ... })
// 路由(入栈最多 10 层;tabBar 页只能 switchTab 且不能带参)
uni.navigateTo({ url: '/pages/password/password' }) / uni.navigateBack({ delta: 1 })
uni.redirectTo({ url: '/pages/login/login' }) // 替换当前页
uni.reLaunch({ url: '/pages/login/login' }) // 关闭全部重开(退出登录用)
uni.switchTab({ url: '/pages/home/home' }) // tabBar 页
// 存储
uni.setStorageSync('theme', '#11559c') / uni.getStorageSync('current-user')
uni.removeStorageSync('access-token') / uni.clearStorageSync()
// 设备与业务
uni.makePhoneCall({ phoneNumber: '18012345678' }) // login.vue 的联系电话
uni.hideTabBar() // 自定义 tabBar 时必须调用
uni.setNavigationBarTitle({ title: 'xxx' }) // 动态改标题
uni.uploadFile({ url, filePath, name: 'file', formData, header })
uni.downloadFile({ url, header }) / uni.openDocument({ filePath }) / uni.previewImage({ urls: [...] })十、权限控制
main.js 已通过 registerAuthDirectives(app) 注册 v-auth 系列指令,数据取自本地 current-user.apis:
<button v-auth="'xxx:add'">新增</button> <!-- 单个权限 -->
<button v-auths="['xxx:update','xxx:delete']">操作</button> <!-- 满足其一 -->
<view v-auth-all="['a','b']">需同时具备</view>
<view v-auth-else="'xxx:add'">无权限提示</view> <!-- 与上一个兄弟节点相反 -->指令用 el.style.display 控制显示(不是移除 DOM),并且 mounted / updated 都会重新判定,所以权限刷新后能自动生效(详见第 3 篇)。
十一、完整可复制:列表 → 详情 → 新增 三页闭环
<!-- pages/sysUser/index.vue(列表) -->
<template>
<view class="pd-lr-20">
<input class="search" v-model="state.query.keyword" placeholder="搜索姓名/账号" @confirm="reload" />
<view class="cardBox mg-t-20" v-for="(item, i) in state.list" :key="i" @click="goDetail(item.id)">
<view class="row">
<text class="name">{{ item.realName }}</text>
<view class="tag info">{{ item.account }}</view>
</view>
<view class="sub">{{ item.orgName }} · {{ item.createTime }}</view>
</view>
<view class="empty" v-if="!state.list.length && !state.loading">暂无数据</view>
<view class="empty" v-else-if="state.finished">没有更多了</view>
<view class="fab" v-auth="'sysUser:add'" @click="goAdd">+</view>
<TabBar :current-page="1" />
</view>
</template>
<script lang="ts" setup>
import { reactive } from 'vue'
import { onMounted, onShow, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
import TabBar from '@/pages/component/tabbar.vue'
import { usesysUserApi } from '@/api_all/sysUser'
const api = usesysUserApi()
const state = reactive({
query: { page: 1, pageSize: 10, keyword: '' },
list: [] as any[], total: 0, loading: false, finished: false
})
const load = async (append = false) => {
if (state.loading || state.finished) return
state.loading = true
uni.showLoading({ title: '加载中' })
try {
const res: any = await api.page(state.query, false, false)
const items = res.result?.items ?? []
state.total = res.result?.total ?? 0
state.list = append ? state.list.concat(items) : items
state.finished = state.list.length >= state.total
} catch (e: any) {
uni.showToast({ title: e?.errMsg || '加载失败', icon: 'none' })
} finally {
state.loading = false
uni.hideLoading()
uni.stopPullDownRefresh()
}
}
const reload = () => { state.query.page = 1; state.finished = false; load(false) }
const loadMore = () => {
if (state.list.length >= state.total) { state.finished = true; return }
state.query.page += 1
load(true)
}
const goDetail = (id: number) => uni.navigateTo({ url: `/pages/sysUser/detail?id=${id}` })
const goAdd = () => uni.navigateTo({ url: '/pages/sysUser/edit' })
onMounted(() => load())
onShow(() => { /* 从编辑页返回时刷新 */ })
onPullDownRefresh(() => reload())
onReachBottom(() => loadMore())
</script><!-- pages/sysUser/edit.vue(新增/编辑共用) -->
<script lang="ts" setup>
import { reactive } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { usesysUserApi } from '@/api_all/sysUser'
import { PhoneVal } from '@/utils/validate'
const api = usesysUserApi()
const state = reactive({ id: 0, form: { account: '', realName: '', phone: '' } as any })
onLoad(async (opts: any) => {
state.id = Number(opts?.id ?? 0)
if (!state.id) return
const res: any = await api.detail(state.id, false, false)
state.form = res.result ?? {}
})
const submit = async () => {
if (state.form.phone && !PhoneVal.test(state.form.phone))
return uni.showToast({ title: '手机号格式不正确', icon: 'none' })
try {
const res: any = state.id
? await api.update({ ...state.form, id: state.id }, false, false)
: await api.add(state.form, false, false)
if (res.code == 200) {
uni.$emit('listRefresh') // 通知列表刷新
uni.showToast({ title: '保存成功', icon: 'none' })
setTimeout(() => uni.navigateBack(), 800)
}
} catch (e: any) {
uni.showToast({ title: e?.errMsg || '保存失败', icon: 'none' })
}
}
</script>步骤清单
- 建目录
pages/模块名/页面名.vue,并在pages.json的pages数组加一条(含navigationBarTitleText;需要下拉加enablePullDownRefresh: true)。 api_all里准备好接口(见第 4 篇),页面import { useXxxApi }。const state = reactive({...})收敛状态;onMounted/onLoad拉数据。- 列表页接
page/pageSize与result.items/total,配onPullDownRefresh/onReachBottom。 - 表单页做前端校验 + SM2 加密,提交后
uni.navigateBack();涉及权限的按钮加v-auth。 - 渲染
TabBar组件(tabBar 页),用uni.$emit/uni.$on做跨页刷新。 - 真机跑一遍:下拉动画是否关闭、触底是否重复请求、返回是否刷新列表、小程序端标签是否都换成了
view/text。
⚠️ 注意事项与常见坑
- 下拉刷新动画不会自动结束,必须在数据回来后调
uni.stopPullDownRefresh(),否则一直转圈。 - 上拉加载要加并发锁(
state.loading),否则触底瞬间会连发多页请求导致重复数据;page从 1 开始(后端BasePageInput.Page有[Range(1, int.MaxValue)]),仓库sysLogOp.vue里写page: 0属历史写法。 - 小程序端不支持
div/span/br:login.vue、about.vue、mine.vue、password.vue里有少量这类标签,H5 正常但小程序端可能渲染异常,建议统一改成view/text。 uni.switchTab不能传参:tabBar 页之间用 storage 传值;页面栈最多 10 层,长流程用redirectTo/reLaunch收敛。- 自定义 tabBar 记得
uni.hideTabBar():tabbar.vue已在onMounted调用,每个用到它的页面都要渲染该组件并传:current-page。 - 图片路径:
home.vue里/static/uview/swiper/swiper2.png在仓库中不存在(真实资源在/static/swiper/);<style>不写lang="scss"时$uni-*变量不生效。 - 定时器必须清:
login.vue的验证码倒计时用onUnmounted(() => clearInterval(timer)),忘记清理会在页面销毁后继续跑。 options里的值都是字符串:onLoad(options)拿到的id要Number()转换,undefined要兜底(Number(opts?.id ?? 0))。api.xxx(data, false, false)的后两个参数:第二个是cancel(取消同名请求),第三个是autoCatchException(false= 自己 catch)。默认值是自动 toast + 吞掉异常,页面需要自定义提示时必须显式传false, false。- 真机访问不到
localhost:utils/.env.js的baseUrl调试真机时改成本机局域网 IP。
小结
记住三件事:先在 pages.json 注册;状态全部收敛进 reactive;列表一定对接 page/pageSize + items/total 并配好下拉/上拉的开关与锁。照 sysLogOp.vue(列表)、password.vue(表单)、about.vue(纯展示)三个现有页面抄,就能覆盖 80% 的移动端页面;跨页刷新用 uni.$emit/$on,权限用 v-auth,复杂交互优先查 uni.xxx 而不是引第三方库。