后台管理系统搭好了,客户催着要移动端。一个人,两周时间,一套代码覆盖 App + 小程序 + H5——这篇文章把从脚手架到第一个业务页面的全过程讲清楚。
一、为什么移动端要上 uni-app
后台系统上线第二周,销售团队就找过来了:「能不能在手机上查客户、录拜访记录?我们天天在外面跑,电脑不在身边。」
需求很明确:CRM 移动端,能看客户、记拜访、跟商机、提工单。
方案对比:
| uni-app + Vue3 |
选 uni-app 的原因很简单:
技术栈一致:后台是 Vue3 + Element Plus,移动端也用 Vue3,心智负担为零 一套代码多端覆盖:先上微信小程序,后面要 App 和 H5,不用重写 生态够用:uni-ui 组件库 + uView Plus,大部分场景不用自己造轮子 若依社区有 RuoYi-App 参考:认证流程可以直接复用
独立开发者的效率法则:能用一套代码解决的问题,绝对不写第二套。
二、项目初始化:从脚手架到跑起来
2.1 创建项目
我用的是 HBuilderX 创建项目,选 uni-app → Vue3 版本:
项目结构(精简后):├── pages/ # 页面目录│ ├── index/ # 首页(工作台)│ ├── customer/ # 客户模块│ │ ├── list.vue # 客户列表│ │ └── detail.vue # 客户详情│ ├── opportunity/ # 商机模块│ ├── visit/ # 拜访记录│ └── workorder/ # 工单模块├── api/ # 接口层│ ├── request.js # 请求封装│ └── modules/ # 按模块拆分│ ├── customer.js│ └── auth.js├── store/ # Pinia 状态管理│ ├── user.js│ └── app.js├── components/ # 公共组件└── static/ # 静态资源2.2 第一个关键决策:路由模式
uni-app 的路由和 Vue Router 不一样——它用的是页面栈,所有页面平铺在 pages.json 里:
{"pages": [ {"path": "pages/index/index","style": {"navigationBarTitleText": "工作台" } }, {"path": "pages/customer/list","style": {"navigationBarTitleText": "客户列表" } }, {"path": "pages/customer/detail","style": {"navigationBarTitleText": "客户详情" } } ],"tabBar": {"list": [ { "pagePath": "pages/index/index", "text": "工作台", "iconPath": "static/tab/home.png" }, { "pagePath": "pages/customer/list", "text": "客户", "iconPath": "static/tab/customer.png" }, { "pagePath": "pages/visit/list", "text": "拜访", "iconPath": "static/tab/visit.png" }, { "pagePath": "pages/mine/index", "text": "我的", "iconPath": "static/tab/mine.png" } ] }}页面跳转用 uni-app API,不用 router.push:
// 跳转到客户详情uni.navigateTo({ url: `/pages/customer/detail?id=${customerId}` })// Tab 页切换uni.switchTab({ url: '/pages/customer/list' })三、请求封装:把后端对接干净
这一步是整个移动端的基石。uni-app 里不能用 axios(它依赖浏览器环境),得用 uni.request 封装。
3.1 基础封装
// api/request.jsconst BASE_URL = 'https://api.yourdomain.com'// Token 存储(兼容多端)const getToken = () => {// #ifdef H5return localStorage.getItem('token')// #endif// #ifndef H5return uni.getStorageSync('token')// #endif}const request = (options) => {returnnewPromise((resolve, reject) => { uni.request({url: BASE_URL + options.url,method: options.method || 'GET',data: options.data,header: {'Authorization': `Bearer ${getToken()}`,'Content-Type': 'application/json' },success: (res) => {const { code, data, msg } = res.dataif (code === 200) { resolve(data) } elseif (code === 401) {// Token 过期 → 清空登录态 → 跳转登录页 uni.removeStorageSync('token') uni.reLaunch({ url: '/pages/login/index' }) reject(newError('登录已过期')) } else { uni.showToast({ title: msg || '请求失败', icon: 'none' }) reject(newError(msg)) } },fail: (err) => { uni.showToast({ title: '网络异常', icon: 'none' }) reject(err) } }) })}exportdefault request3.2 按模块拆分接口
// api/modules/customer.jsimport request from'../request'// 客户列表(分页 + 搜索)exportconst listCustomer = (params) => {return request({url: '/crm/customer/list',method: 'GET',data: params })}// 客户详情exportconst getCustomerDetail = (id) => {return request({url: `/crm/customer/${id}`,method: 'GET' })}// 新增客户exportconst addCustomer = (data) => {return request({url: '/crm/customer',method: 'POST', data })}关键点:Token 存储用条件编译
#ifdef H5隔离——H5 用 localStorage,App/小程序用uni.getStorageSync。混用会踩坑,后面聊。
四、客户列表页:第一个业务页面
这是 CRM 移动端入口页面,也是用户用得最多的页面。设计要点:搜索 + 列表 + 上拉加载更多。
<template> <view class="customer-page"> <!-- 搜索栏 --> <view class="search-bar"> <uni-search-bar v-model="keyword" placeholder="搜索客户名称/联系人" @confirm="handleSearch" @clear="handleSearch" /> </view> <!-- 客户列表 --> <view class="customer-list"> <view v-for="item in list" :key="item.id" class="customer-card" @click="goDetail(item.id)" > <view class="card-header"> <text class="name">{{ item.name }}</text> <uni-tag :text="industryMap[item.industry] || item.industry" :type="item.status === 'ACTIVE' ? 'success' : 'default'" size="small" /> </view> <view class="card-body"> <text class="info">联系人:{{ item.contactName }}</text> <text class="info">电话:{{ item.contactPhone }}</text> </view> <view class="card-footer"> <text class="time">{{ item.lastVisitTime || '暂无拜访记录' }}</text> </view> </view> </view> <!-- 加载更多 --> <uni-load-more :status="loadStatus" /> <!-- 新建按钮 --> <view class="fab" @click="goAdd"> <uni-icons type="plus" size="24" color="#fff" /> </view> </view></template><script setup>import { ref } from 'vue'import { onReachBottom } from '@dcloudio/uni-app'import { listCustomer } from '@/api/modules/customer'const keyword = ref('')const list = ref([])const page = ref(1)const loadStatus = ref('more')const industryMap = { 'PRINTING': '印刷', 'PACKAGING': '包装', 'PUBLISHING': '出版'}const fetchData = async (reset = false) => { if (reset) page.value = 1 loadStatus.value = 'loading' try { const { rows, total } = await listCustomer({ pageNum: page.value, pageSize: 10, name: keyword.value }) list.value = reset ? rows : [...list.value, ...rows] loadStatus.value = list.value.length >= total ? 'noMore' : 'more' } catch { loadStatus.value = 'more' }}const handleSearch = () => fetchData(true)onReachBottom(() => { if (loadStatus.value === 'noMore') return page.value++ fetchData()})const goDetail = (id) => uni.navigateTo({ url: `/pages/customer/detail?id=${id}` })const goAdd = () => uni.navigateTo({ url: '/pages/customer/add' })</script><style lang="scss" scoped>.customer-page { min-height: 100vh; background: #f5f6fa; padding-bottom: 20rpx;}.search-bar { padding: 20rpx; background: #fff;}.customer-card { margin: 20rpx 24rpx; padding: 24rpx; background: #fff; border-radius: 12rpx; box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04); .card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16rpx; .name { font-size: 32rpx; font-weight: 600; color: #1a1a1a; } } .card-body { display: flex; gap: 32rpx; .info { font-size: 26rpx; color: #666; } } .card-footer { margin-top: 16rpx; .time { font-size: 22rpx; color: #999; } }}.fab { position: fixed; right: 40rpx; bottom: 120rpx; width: 96rpx; height: 96rpx; background: #2979ff; border-radius: 50%; display: flex; align-items: center; justify-content: center; box-shadow: 0 4rpx 16rpx rgba(41, 121, 255, 0.4);}</style>4.1 上拉加载更多
uni-app 提供了 onReachBottom 生命周期,触发时机等同于触底。核心逻辑:
onReachBottom(() => {if (loadStatus.value === 'noMore' || loadStatus.value === 'loading') return page.value++ fetchData()})uni-load-more 组件自动展示「加载中 / 没有更多了」状态,比手写简单很多。
五、客户详情页:表单与数据联动
<template> <view class="detail-page"> <view class="section"> <view class="section-title">基本信息</view> <uni-forms :model="form" label-width="160rpx"> <uni-forms-item label="客户名称" name="name"> <uni-easyinput v-model="form.name" placeholder="请输入客户名称" /> </uni-forms-item> <uni-forms-item label="所属行业" name="industry"> <uni-data-checkbox v-model="form.industry" :localdata="industryOptions" /> </uni-forms-item> <uni-forms-item label="联系人" name="contactName"> <uni-easyinput v-model="form.contactName" placeholder="请输入联系人" /> </uni-forms-item> <uni-forms-item label="联系电话" name="contactPhone"> <uni-easyinput v-model="form.contactPhone" type="number" placeholder="请输入电话" /> </uni-forms-item> </uni-forms> </view> <view class="section"> <view class="section-title">最近拜访记录</view> <view v-if="visits.length > 0"> <view v-for="v in visits" :key="v.id" class="visit-item"> <text class="visit-date">{{ v.visitDate }}</text> <text class="visit-summary">{{ v.summary }}</text> </view> </view> <uni-section v-else title="暂无拜访记录" type="line" /> </view> <view class="bottom-btn"> <button type="primary" @click="handleSave">保存</button> </view> </view></template><script setup>import { ref, onMounted } from 'vue'import { onLoad } from '@dcloudio/uni-app'import { getCustomerDetail, updateCustomer } from '@/api/modules/customer'const id = ref('')const form = ref({})const visits = ref([])const industryOptions = [ { text: '印刷', value: 'PRINTING' }, { text: '包装', value: 'PACKAGING' }, { text: '出版', value: 'PUBLISHING' }]onLoad((options) => { id.value = options.id fetchDetail()})const fetchDetail = async () => { const data = await getCustomerDetail(id.value) form.value = data visits.value = data.visitRecords || []}const handleSave = async () => { await updateCustomer(id.value, form.value) uni.showToast({ title: '保存成功', icon: 'success' })}</script>六、三个最容易踩的坑
| API 不兼容 Web | uni.getStorageSync 但 H5 下被安全策略拦截 | #ifdef H5 / #ifndef H5 隔离 | |
| 页面栈溢出 | navigateTo,详情→编辑用 redirectTo | ||
| uni-forms 校验失效 | uni-easyinputname 和 rules 的 name 不匹配 | name 字段一致性 |
6.1 条件编译详解
这是 uni-app 最核心的特性,也是新手最容易翻车的地方:
// ✅ 正确:按平台隔离 API// #ifdef H5localStorage.setItem('token', val)// #endif// #ifdef MP-WEIXINwx.setStorageSync('token', val)// #endif// #ifdef APP-PLUSplus.storage.setItem('token', val)// #endif// ❌ 错误:直接在公共代码用 Web APIwindow.location.href = '/login'// 小程序直接报错原则很简单:任何依赖特定平台 API 的代码,都用条件编译包起来。
6.2 页面栈管理
uni-app 的页面栈最多 10 层,不注意很容易爆:
uni.navigateTo | ||
uni.redirectTo | ||
uni.reLaunch | ||
uni.switchTab |
七、当前的页面结构
我的 CRM 移动端目前覆盖了四个 Tab:
┌──────────────────────────────────────────┐│ Tab 1: 工作台 ││ 今日待办 + 数据概览 + 快捷入口 │├──────────────────────────────────────────┤│ Tab 2: 客户列表 ││ 客户搜索/筛选/详情/新增/编辑 │├──────────────────────────────────────────┤│ Tab 3: 拜访记录 ││ 拜访列表/签到/写跟进/拍照上传 │├──────────────────────────────────────────┤│ Tab 4: 我的 ││ 个人信息/设置/关于/退出登录 │└──────────────────────────────────────────┘目前对接的后端接口约 20 个,覆盖了销售日常使用最频繁的场景。后续会增加商机管理和工单模块。
八、下一步计划
这篇文章覆盖了从脚手架到客户列表、客户详情的完整流程。接下来的几篇文章会深入:
Pinia 模块化状态管理:用户信息、全局配置、Token 刷新 跨平台适配:一套代码兼容 App / 小程序 / H5 / 鸿蒙的具体策略 移动端 CRUD 最佳实践:表单校验、图片上传、列表性能优化 与 Spring Boot 后端对接:请求重试、离线缓存、消息推送
如果你也在做类似的移动端项目,希望这个系列能帮你少踩坑。
📌 关于我
我是一名全栈开发者,目前在深圳创业,专注于印刷包装行业的数字化系统建设。
技术栈:Java / Spring Boot / Vue3 / uni-app / MySQL / Redis

长按识别二维码,关注「MqCode」
🔧 全栈开发实战 | 📦 MES产品设计 | 💡 独立开发者思考
如果觉得有用,欢迎关注、点赞、在看 👇
本文首发于微信公众号「MqCode」,欢迎自由转载。
夜雨聆风