简介:基于uni-app与Vue框架开发的《露营》App完整项目源码包,面向需要学习移动端与后台管理开发的初级、中级开发者。项目在HBuilder X平台下实现,分为用户前端和管理后台:前端覆盖首页、露营信息、露营教程、个人中心等模块,后台支持类型管理、用户管理、露营信息管理、订单信息管理及系统管理,并均可完成增删改查操作。压缩包共1015个文件,其中216个vue页面组件、139个js脚本、111个java接口类构成核心逻辑,配合231个png及162个svg处理界面视觉,整体大小约17.75MB,文件结构清晰,便于按模块查阅。已有199人学习浏览,适合作为课程设计、毕业设计或入门实战项目参考,有助于快速掌握uni-app跨端开发与后台协同的完整流程。
1. 露营这件小事,为什么用uniapp+Vue来写App
露营是这两年最具体的周末场景之一:找营地、查装备、约好友、看天气,每个需求单拎出来都不算复杂,但要把它们串成一个能用的 App,涉及页面路由、地图定位、多端打包一整套工程决策。用原生安卓写一遍,再给微信小程序重写一遍,成本直接翻倍,这正是 uniapp + Vue 组合存在的理由。
在这套技术栈里,Vue 负责组件化和状态组织,uniapp 把微信小程序、H5、安卓和 iOS 的差异收敛到同一套 API 后面。这篇文不是某个现成项目的说明书,而是顺着《露营》这类工具型 App 最常见的落地路径,把工程结构、核心功能、打包上架和真机验证讲清楚,哪怕你手上只有一个打包好的 zip 工程,也能拿它当排查地图用。
2. 露营App的Vue3页面骨架与pages.json路由配置
露营类 App 的页面通常不会太多:营地列表、营地详情、装备清单,再加一个个人中心。页面少反而要求把路由和目录一次定清楚,后面加“我的足迹”“路书”这类功能时,不用回头挪结构。这一章先把工程的骨架立住,再落到 Vue3 组合式 API 的页面写法上,顺带把 Vue2 老工程迁移时最容易踩的三个差异说透。
2.1 先分清 pages.json、manifest.json 和 static 的职责
uniapp 工程里,pages.json 管路由和导航栏配置,manifest.json 管应用级配置,static 目录放静态资源。新手最常犯的错是把页面跳转逻辑放在某个全局变量里,或者在 pages.json 里写业务字段。下表是这几个地方的职责边界,照着划分就不会乱:
| 文件/目录 | 职责 | 常见误用 |
|---|---|---|
| src/pages/ | 页面组件 | 把业务组件也塞进 pages 目录,导致每个分包多编译一份 |
| src/static/ | 图片、字体、本地 JSON | 用字符串动态拼路径,小程序端编译后找不到资源 |
| pages.json | 路由、tabBar、导航栏、分包 | 在 style 里写 uniapp 不支持的字段,真机静默失效 |
| manifest.json | appid、模块权限、隐私声明 | 改完不重新打包,以为热重载会带新配置 |
| uni.scss | 全局 scss 变量 | 页面里散落重复颜色值,换主题时逐个改 |
pages.json 是路由的唯一入口,下面的配置可以直接抄进工程:
{ "pages": [ { "path": "pages/index/index", "style": { "navigationBarTitleText": "营地列表" } }, { "path": "pages/checklist/checklist", "style": { "navigationBarTitleText": "装备清单" } }, { "path": "pages/detail/detail", "style": { "navigationBarTitleText": "营地详情" } } ], "globalStyle": { "navigationBarTextStyle": "white", "navigationBarBackgroundColor": "#3A6B35", "backgroundColor": "#F6F7F8" }, "tabBar": { "color": "#8C8C8C", "selectedColor": "#3A6B35", "list": [ { "pagePath": "pages/index/index", "text": "找营地" }, { "pagePath": "pages/checklist/checklist", "text": "装备" } ] } }pages 数组里第一个页面就是冷启动首页;tabBar 的 pagePath 必须同时存在于 pages 数组,否则编译阶段直接报错。navigationBarBackgroundColor 只接受十六进制色值,写成 rgb() 在安卓端会不生效。tabBar 列表建议控制在 2 到 5 项,超过 5 项在部分安卓机型上会挤压文字区域。
2.2 用Vue3 script setup写营地列表,uniapp路由传参一次说清
页面骨架定好后,列表页是最有代表性的写法:请求数据、搜索过滤、点击跳详情。下面这段覆盖了 uniapp 里 Vue3 组合式 API 的主要用法:
<template> <view class="camp-list"> <input v-model="keyword" placeholder="搜索营地名称" class="search" /> <view v-for="site in filteredList" :key="site.id" class="camp-item" @click="goDetail(site.id)" > <image class="cover" :src="site.cover" mode="aspectFill" /> <view class="body"> <text class="name">{{ site.name }}</text> <text class="price">¥{{ site.price }}/晚</text> </view> </view> <view v-if="loading" class="loading">加载中...</view> </view> </template> <script setup> import { ref, computed } from 'vue' import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app' const list = ref([]) const keyword = ref('') const loading = ref(false) const filteredList = computed(() => keyword.value ? list.value.filter((item) => item.name.includes(keyword.value)) : list.value ) onLoad(async () => { loading.value = true try { const res = await uni.request({ url: 'https://api.camp.example.com/sites', method: 'GET', data: { page: 1, size: 20 } }) list.value = res.data.list } catch (e) { uni.showToast({ title: '营地列表拉取失败', icon: 'none' }) } finally { loading.value = false } }) onPullDownRefresh(async () => { await loadSites() uni.stopPullDownRefresh() }) const goDetail = (id) => { uni.navigateTo({ url: `/pages/detail/detail?id=${id}` }) } </script>onLoad 是 uniapp 的页面生命周期,来源是 @dcloudio/uni-app 而不是 vue 包;页面里同时存在 Vue 自身生命周期时,两者按各自语义执行,不要混写。filteredList 用 computed 缓存过滤结果,避免每次渲染都重算全量数组。onPullDownRefresh 要生效,必须在 pages.json 对应页面的 style 里加 "enablePullDownRefresh": true。跳详情用 navigateTo 拼接查询参数,详情页在 onLoad(options) 里取 options.id,这就是 uniapp 形态的路由参数传递——vue-router 的 query 在这里变成 URL 查询串,传对象时要先 JSON.stringify,接收端再 parse。
2.3 从Vue2迁到Vue3:main.js、全局属性和过滤器的差异
很多露营小程序的存量工程还是 Vue2 写法。uniapp 对 Vue3 的支持已经稳定,迁移时不必重写所有页面,把入口、全局属性和过滤器处理好就能跑通。三处改动的对照如下:
// Vue2 时代的 main.js import Vue from 'vue' import App from './App.vue' Vue.config.productionTip = false Vue.prototype.$toast = (msg) => uni.showToast({ title: msg }) new Vue({ render: (h) => h(App) }).$mount('#app')// Vue3 时代的 main.js import { createSSRApp } from 'vue' import App from './App.vue' export function createApp() { const app = createSSRApp(App) app.config.globalProperties.$toast = (msg) => uni.showToast({ title: msg }) return { app } }uniapp 的 Vue3 入口要求导出 createApp,内部用 createSSRApp 保证 App 端渲染正常,这个函数名不能换成 createApp 后直接挂载。Vue2 的 prototype 挂载改为 globalProperties,页面里通过 getCurrentInstance().appContext.config.globalProperties 取用。Vue3 移除了 filters,价格格式化这类逻辑改成 computed 或独立函数,模板里直接调用即可。迁移后如果遇到样式错位,优先检查是否用了 Vue2 时代的深度选择器 /deep/,统一改成 :deep() 语法。
3. 露营核心功能落地:uniapp定位、本地存储与请求API的实战写法
露营 App 的差异化在“现场感”:营地在地图上的位置、出发前要带的装备、目的地天气。这三块分别对应定位、本地存储和网络请求,也是 uniapp 跨端差异最明显的地方。只要有户外使用场景,就不能假设手机永远在线,所以这一章的代码都按弱网和权限受限的情况做了兜底。
3.1 map组件和uni.getLocation的坐标陷阱
地图页是露营 App 的入口级功能。uniapp 内置 map 组件在微信小程序和 App 端都能直接用,但坐标体系有一个必须处理的细节:定位类型要选 gcj02,也就是高德和腾讯地图使用的国测局坐标。如果拿到 wgs84 原始坐标直接丢给 map 组件,在真实户外会偏移几十米,室内看不出来,一到空旷营地就露馅。
<template> <map id="campMap" class="map" :latitude="center.lat" :longitude="center.lng" :markers="markers" :scale="12" @markertap="onMarkerTap" /> </template> <script setup> import { reactive, ref } from 'vue' import { onLoad } from '@dcloudio/uni-app' const center = reactive({ lat: 39.9042, lng: 116.4074 }) const markers = ref([]) const onMarkerTap = (e) => { const site = markers.value.find((m) => m.id === e.detail.markerId) if (site) uni.navigateTo({ url: `/pages/detail/detail?id=${site.id}` }) } onLoad(async () => { try { const loc = await uni.getLocation({ type: 'gcj02', isHighAccuracy: true, highAccuracyExpireTime: 4000 }) center.lat = loc.latitude center.lng = loc.longitude const res = await uni.request({ url: 'https://api.camp.example.com/nearby', data: { lat: center.lat, lng: center.lng, radius: 20000 } }) markers.value = res.data.map((s) => ({ id: s.id, latitude: s.lat, longitude: s.lng, title: s.name, iconPath: '/static/marker.png', width: 28, height: 34 })) } catch (e) { uni.showToast({ title: '定位失败,请检查权限', icon: 'none' }) } }) </script>isHighAccuracy 和高精度超时只在部分安卓机型生效,低端机可能直接走普通定位,失败时要用上一次缓存的坐标做降级,而不是弹窗打断用户。markertap 事件的 detail.markerId 是数字,和构造 markers 时写入的 id 对应,不要拿数组索引去匹配。H5 端的 map 组件支持不完整,常见做法是用平台判断:小程序和 App 走内置 map,H5 引入腾讯地图 JS SDK 单独渲染。
提示:微信小程序端必须在 manifest.json 的 mp-weixin 节点里声明 requiredPrivateInfos: ["getLocation"],否则真机一进来就报 getLocation:fail the api need to be declared,这个报错直接搜错误文案就能定位到问题。
3.2 装备清单用uni.setStorageSync实现离线可用
营地的信号盲区是真实痛点,装备清单必须离线可用。uniapp 的本地存储 API 在四端行为一致,同步接口在小数据量场景下性能足够,不需要引入额外的存储库:
<template> <view class="gear"> <input v-model="draft" placeholder="输入装备,例如:防潮垫" @confirm="addItem" /> <view v-for="item in items" :key="item.id" class="row" @click="toggle(item)" > <text :class="{ done: item.done }">{{ item.name }}</text> </view> </view> </template> <script setup> import { ref, watch } from 'vue' import { onShow } from '@dcloudio/uni-app' const STORAGE_KEY = 'camp_gear' const items = ref(uni.getStorageSync(STORAGE_KEY) || []) const draft = ref('') watch( items, (val) => { uni.setStorageSync(STORAGE_KEY, val) }, { deep: true } ) const addItem = () => { const name = draft.value.trim() if (!name) return items.value.push({ id: Date.now(), name, done: false }) draft.value = '' } const toggle = (item) => { item.done = !item.done } onShow(() => { items.value = uni.getStorageSync(STORAGE_KEY) || [] }) </script>watch 深监听数组变化后同步写入存储,比每次增删手动调 setStorageSync 少写出错分支。onShow 里重新读取,是为了处理从其他页面返回后的数据同步,避免多页面共用一份清单时看到旧数据。storage 适合放 JSON 小对象,图片、音频这类资源不要往里塞,Android 端单 key 超过 1MB 后读写延迟会明显上升。如果清单数据还要多人协作,再把读写逻辑抽进 pinia store,用 defineStore 管理,本地存储只做兜底缓存。
| 维度 | 本地存储 | pinia | 适用场景 |
|---|---|---|---|
| 读取速度 | 磁盘级 | 内存级 | 单人装备清单用本地存储足够 |
| 跨页面共享 | 需自己监听同步 | 天然响应式 | 多页面同改一份数据用 pinia |
| 持久化 | 默认持久 | 不持久 | pinia 需要搭配 persist 插件再落盘 |
3.3 天气请求的timeout与缓存兜底
露营计划页里通常会放一张天气卡片,常见做法是接和风天气或高德天气这类接口。第三方接口在弱网下最容易超时,uni.request 的 timeout 默认 60000 毫秒,对户外场景太长了,必须显式调小:
function fetchWeather(lat, lng) { return uni.request({ url: 'https://api.camp.example.com/weather', data: { lat, lng, key: WEATHER_KEY }, timeout: 8000, method: 'GET' }) } async function safeLoadWeather() { try { const res = await fetchWeather(center.lat, center.lng) return res.data.now } catch (e) { return { text: '晴', temp: '--' } } }timeout 参数对大多数平台生效,但微信小程序的基础库对超时支持有差异,真机弱网测试时如果发现超时不生效,就用 Promise.race 包一层做兜底。天气数据变化频率低,拉取成功后缓存 15 分钟,避免每次进页面都发请求。WEATHER_KEY 这类密钥放前端会被直接抓包看到,常见做法是让服务端代理天气请求,小程序端只传坐标,由后端拼 key。
4. manifest配置、uniapp多端打包与3个高频坑
功能写完,打包和上架才是真正的分岔路。uniapp 打包微信小程序、H5 和安卓 App 走的是三条链路,manifest.json 是这三条链路的统一入口。这一章把配置、命令和三个高频报错一起讲清楚,打包阶段少走弯路。
4.1 manifest.json里的定位权限与隐私声明
manifest.json 在 HBuilderX 里可以可视化编辑,但多人协作的仓库里应该以源码为准。定位权限和隐私声明是打包前必须确认的两项:
{ "name": "露营", "appid": "__UNI__CAMP001", "mp-weixin": { "appid": "wx你的小程序appid", "requiredPrivateInfos": ["getLocation", "chooseLocation"], "permission": { "scope.userLocation": { "desc": "用于在营地地图上显示你的位置" } } }, "app-plus": { "distribute": { "android": { "permissions": [ "<uses-permission android:name=\"android.permission.ACCESS_FINE_LOCATION\"/>", "<uses-permission android:name=\"android.permission.ACCESS_COARSE_LOCATION\"/>" ] } } } }mp-weixin 的 requiredPrivateInfos 是微信收紧隐私接口后必须补的声明,2023 年后更新过一轮规则,真机报 getLocation 相关错误时,第一件事就是回这里检查,而不是反复改代码。app-plus 的 permissions 是安卓权限列表,云打包时 HBuilderX 会合并这些声明,不要在页面里动态申请未声明的权限。改完 manifest 必须重新打包,热重载不会带上新配置。
注意:appid 属于应用标识,提交到公开仓库前确认是否要脱敏。HBuilderX 的测试包用公共证书即可,上架安卓应用市场必须换成自己的 keystore 签名。
4.2 微信小程序、H5和安卓的打包链路与产物
用 vue-cli 或 vite 方式创建的 uniapp 工程,可以用命令行出包,对 CI 友好:
# 安装依赖,注意 node 版本要和工程要求匹配 npm install # 微信小程序,产物在 dist/build/mp-weixin npm run build:mp-weixin # H5,产物在 dist/build/h5 npm run build:h5微信小程序产物用微信开发者工具导入 dist/build/mp-weixin 目录,上传前在详情里确认 appid 正确。H5 产物是纯静态文件,可以部署到任意静态服务器,接口跨域需要在服务端配置 CORS。安卓 App 的常见做法是 HBuilderX 菜单栏“发行 -> 原生App-云打包”,不需要本地安卓环境;测试包选公共证书,上架时必须用自己生成的 keystore,并保持各市场包名一致。
| 目标端 | 命令/入口 | 产物位置 | 最常见的失败原因 |
|---|---|---|---|
| 微信小程序 | npm run build:mp-weixin | dist/build/mp-weixin | 没填小程序 appid,编译后页面空白 |
| H5 | npm run build:h5 | dist/build/h5 | 接口跨域,需后端加 CORS 头 |
| Android | HBuilderX 云打包 | apk/aab | 证书过期或权限声明冲突 |
vue2 老工程迁到 vue3 后首次打包,容易遇到 HBuilderX 版本过老导致编译中断,Vue3 模板需要 HBuilderX 3.2.5 以上的版本,升级前先备份工程。
4.3 打包后布局异常和app is not defined的排查
打包后的报错和开发期不完全一样,下面这几个是社区里出现频率最高的:
| 报错或现象 | 常见根因 | 处理办法 |
|---|---|---|
| 打包后样式错位、字体大小不一致 | px 和 rpx 混用,或字体文件没走 static 目录 | 统一用 rpx,字体放 /static/fonts 并在 App.vue 引入 |
| app is not defined | 在微信小程序端引用了 App 全局对象或 window | 改用 uni.getApp(),或把逻辑放进 APP-PLUS 条件编译 |
| 修改刚进入的加载页面不生效 | 改的是 pages.json 的 globalStyle,冷启动页是原生层 | App 端换启动图去 manifest 的启动图配置,H5 端改 index.html |
| 微信小程序定位失败 | requiredPrivateInfos 未声明 | 补声明后在开发者工具重新编译 |
条件编译是解决这类平台差异的核心手段,写法如下:
// #ifdef APP-PLUS console.log('只在 App 端编译执行') // #endif // #ifdef MP-WEIXIN console.log('只在微信小程序端编译执行') // #endif条件编译按平台注释在编译期剔除代码,不是运行时判断,写在 template、script、style 里都生效。布局异常还有一个高发点:iPhone 底部被安全区横条遮挡,tabBar 页面要加 padding-bottom: constant(safe-area-inset-bottom),否则列表最后一项会被顶住。
5. 用uniapp自定义分享和扫码把露营闭环搭完
露营 App 真正的闭环不是看营地,而是“创建活动、分享好友、到场签到”。这一章把 uniapp 的自定义分享和扫码串起来,这也是上架前最值得真机验证的两个能力。
<script setup> import { ref } from 'vue' import { onShareAppMessage, onLoad } from '@dcloudio/uni-app' const activityId = ref('') onLoad((options) => { activityId.value = options.id || '' }) // 自定义分享:好友点进来直接落在活动页 onShareAppMessage(() => ({ title: '一起露营,地点我已选好', path: `/pages/activity/activity?id=${activityId.value}` })) // 现场签到:扫场地二维码核销 const scanCheckIn = () => { uni.scanCode({ onlyFromCamera: false, scanType: ['qrCode'], success: (res) => { const code = res.result uni.request({ url: 'https://api.camp.example.com/checkin', method: 'POST', data: { code } }) } }) } </script>onShareAppMessage 在微信小程序端生效,返回的 path 必须带完整页面路径和参数,漏了 id 会导致好友打开后看不到对应活动。H5 端要接各平台的分享 SDK,常见做法是页面右上角引导复制链接,不依赖微信的分享回调。uni.scanCode 的 res.result 是二维码原始内容,建议把营地 id 编码进 URL,签到接口只收 code,前端扫码组件不需要引入三方库。
把这个页面在真机上按四项自测:户外关 Wi-Fi 跑定位,marker 与实际位置偏差控制在 30 米内;微信开发者工具里点右上角转发,确认好友打开后能落到活动页;打印二维码贴到营位,扫码后接口能返回核销成功;杀掉进程重新冷启动,加载页之后 2 秒内能进首页。如果场地不允许贴二维码,就把签到码改成口令输入框,uni.scanCode 换成 input 的 confirm 事件,核销接口不用改。
本文还有配套的精品资源,点击获取