十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于uni-app的旅游小程序开发:协同过滤推荐、Echarts可视化与腾讯地图实战

基于uni-app的旅游小程序开发:协同过滤推荐、Echarts可视化与腾讯地图实战 1. 项目背景与核心概念1.1 这个项目到底解决了什么问题很多同学在选题做旅游类毕业设计或者个人项目时都会遇到同一个困惑做一个简单的旅游信息展示小程序并不难难的是“如何让项目有亮点”。如果只是罗列景点和攻略技术含量确实不够。本文要实现的旅游小程序重点是围绕“三个关键词”展开协同过滤算法根据用户的历史行为为当前用户推荐他可能感兴趣的景点。Echarts 图形化分析用图表展示景点热度、游客分布、浏览趋势让数据变得直观。腾讯地图 API在小程序中展示景点位置、周边酒店和餐饮支持地图选点与导航能力。三者叠加之后这个小程序就不只是一个信息展示工具而是一个带有个性化推荐、数据可视化、LBS 能力的完整闭环项目。无论是用于课程设计、毕业设计还是作为个人作品集都具备较强的完整性。1.2 技术栈与分工层次技术选型负责内容前端框架uni-app一套代码编译到微信小程序、H5、App状态与路由Vuex uni API用户状态、跳转、本地缓存推荐模块协同过滤算法Java/Node 后端基于用户相似度计算推荐结果可视化Echarts饼图、柱状图、地图数据展示地图能力腾讯地图 SDK定位、POI 搜索、地图组件展示后端接口Spring Boot / Node.js用户、景点、评分、推荐接口这里有一点需要说明uniapp 本身是前端框架协同过滤算法的核心逻辑通常放在后端服务中完成前端负责把用户行为数据提交给后端然后消费推荐结果接口。本文会分别给出前后端的实现思路与核心代码。1.3 适用读者正在做旅游类小程序课题的学生。想给简历增加“个性化推荐 可视化 LBS”亮点的开发者。对 uni-app 多端开发、Echarts 集成、地图 SDK 接入感兴趣的读者。想从“增删改查”往“有算法、有图表、有地图”方向进阶的开发新手。阅读本文前建议你具备基本的Vue语法基础和JavaScript基础了解小程序开发的基本页面生命周期。如果没有也问题不大文中所有关键代码都会逐步解释。2. 环境准备与项目初始化2.1 开发环境说明在开始之前我们需要准备好一套本地开发环境。以下是以常见环境为例的说明具体版本需要根据你自己的项目实际情况调整工具说明HBuilderXuni-app 官方推荐的 IDE支持可视化创建项目和直接运行到微信开发者工具微信开发者工具用于预览和调试小程序端需要注册小程序 AppIDNode.js如果使用 Vue CLI 创建项目或者后端用 Node 开发JDK 1.8如果后端使用 Spring Boot 实现协同过滤推荐接口MySQL 5.7存放用户、景点、评分行为数据如果不方便使用 HBuilderX也可以使用 Vue CLI 方式创建 uni-app 项目但本文的演示以 HBuilderX 创建的项目结构为例因为这种结构在导入微信开发者工具时最简单直接。2.2 创建 uni-app 项目打开 HBuilderX点击“文件 - 新建 - 项目”选择“uni-app”模板填写项目名称比如travel-miniapp模板选择“默认模板”。创建后的基础目录结构如下travel-miniapp ├── pages │ ├── index │ │ └── index.vue │ └── ... ├── static ├── App.vue ├── main.js ├── manifest.json ├── pages.json └── uni.scss如果你使用的是 Vue CLI 方式项目结构会多出src目录本质上是一样的。下面我们规划一下整体页面和模块pages ├── index // 首页景点推荐列表 Banner ├── recommend // 推荐结果页协同过滤算法结果展示 ├── map // 地图页腾讯地图展示景点与周边 ├── charts // 数据可视化页Echarts 图表分析 ├── detail // 景点详情页景点信息 评分行为上报 └── mine // 个人中心用户信息 行为记录2.3 配置小程序 AppID 与基础设置在manifest.json中选择“微信小程序”配置项填入你自己的AppID。如果还没有小程序账号可以先使用测试号或者不填 AppID用微信开发者工具的游客模式预览。{ mp-weixin: { appid: 你的小程序AppID, setting: { urlCheck: false, es6: true, minified: true }, usingComponents: true } }这里说明一下urlCheck如果在开发阶段设置为false可以避免小程序校验合法域名方便本地调试接口。但上线前必须改为true并在小程序后台配置合法请求域名。3. 协同过滤推荐算法实现3.1 协同过滤算法基本原理协同过滤Collaborative Filtering是目前最经典的推荐算法之一它的核心思想是如果两个用户对某些物品的评分相似那么这两个用户对其他物品的评分也可能相似。在旅游场景中可以理解为用户 A 喜欢“故宫”“颐和园”“长城”。用户 B 喜欢“故宫”“颐和园”。那么系统会认为用户 A 和用户 B 兴趣相似从而把“长城”推荐给用户 B。协同过滤主要分两类分类思路适用场景基于用户的协同过滤UserCF找到相似用户推荐他们喜欢的物品用户数量较少时效果好基于物品的协同过滤ItemCF找到相似物品推荐用户未看过的相似物品物品数量较少时效果好旅游景点场景下景点数量一般不会特别多而用户数量可能较多因此很多旅游推荐系统会选择基于物品的协同过滤ItemCF。不过本文为了便于讲解先以 UserCF 为切入点因为它更直观代码也容易理解。3.2 用户-景点评分模型设计算法需要一个核心数据模型用户对景点的评分。在真实项目中评分可能来自用户主动评价也可能来自用户点击、收藏、转发等行为转化。我们先用数据库表来表达这个模型CREATE TABLE user_rating ( id bigint NOT NULL AUTO_INCREMENT, user_id bigint NOT NULL COMMENT 用户ID, scenic_id bigint NOT NULL COMMENT 景点ID, rating double NOT NULL COMMENT 评分1-5分, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_scenic (user_id, scenic_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;假设我们已经有这样一份评分数据用户故宫颐和园长城西湖九寨沟A545?2B4535?C54435现在想预测用户 A 对“西湖”的评分从而决定是否把西湖推荐给 A。3.3 基于用户的协同过滤核心代码这里先用 Java 给出一个可运行的工具类。它实现的功能是构建用户评分矩阵。计算两个用户之间的余弦相似度。找出与当前用户最相似的 K 个用户。根据相似用户对目标景点的评分加权预测当前用户对景点的评分。// 文件路径src/main/java/com/example/travel/recommend/UserCF.java package com.example.travel.recommend; import java.util.*; /** * 基于用户的协同过滤推荐工具类 */ public class UserCF { /** * 计算两个用户的余弦相似度 * 余弦相似度公式cos (a·b) / (|a| * |b|) */ public static double cosineSimilarity(MapString, Double user1, MapString, Double user2) { SetString commonItems new HashSet(user1.keySet()); commonItems.retainAll(user2.keySet()); if (commonItems.isEmpty()) { return 0.0; } double dotProduct 0.0; double norm1 0.0; double norm2 0.0; for (String item : commonItems) { dotProduct user1.get(item) * user2.get(item); } for (Double value : user1.values()) { norm1 Math.pow(value, 2); } for (Double value : user2.values()) { norm2 Math.pow(value, 2); } if (norm1 0 || norm2 0) { return 0.0; } return dotProduct / (Math.sqrt(norm1) * Math.sqrt(norm2)); } /** * 为指定用户推荐景点 * * param userRatings 所有用户的评分数据 * param targetUserId 目标用户ID * param k 选择最相似的K个用户 * param topN 返回推荐景点个数 */ public static ListString recommend(ListMapString, Object userRatings, String targetUserId, int k, int topN) { MapString, MapString, Double ratingMatrix new HashMap(); for (MapString, Object record : userRatings) { String userId String.valueOf(record.get(userId)); String scenicId String.valueOf(record.get(scenicId)); double rating ((Number) record.get(rating)).doubleValue(); ratingMatrix .computeIfAbsent(userId, key - new HashMap()) .put(scenicId, rating); } MapString, Double targetUserRatings ratingMatrix.get(targetUserId); if (targetUserRatings null) { return Collections.emptyList(); } // 1. 计算目标用户与其他所有用户的相似度 MapString, Double similarityMap new HashMap(); for (String userId : ratingMatrix.keySet()) { if (userId.equals(targetUserId)) { continue; } double similarity cosineSimilarity(targetUserRatings, ratingMatrix.get(userId)); if (similarity 0) { similarityMap.put(userId, similarity); } } // 2. 找出最相似的K个用户 ListMap.EntryString, Double sortedUsers new ArrayList(similarityMap.entrySet()); sortedUsers.sort((a, b) - Double.compare(b.getValue(), a.getValue())); ListMap.EntryString, Double topKUsers sortedUsers.subList( 0, Math.min(k, sortedUsers.size())); // 3. 对目标用户未评分过的景点做加权评分预测 MapString, Double predictScores new HashMap(); MapString, Double weightSum new HashMap(); for (Map.EntryString, Double simEntry : topKUsers) { String otherUserId simEntry.getKey(); double similarity simEntry.getValue(); MapString, Double otherRatings ratingMatrix.get(otherUserId); for (Map.EntryString, Double ratingEntry : otherRatings.entrySet()) { String scenicId ratingEntry.getKey(); double rating ratingEntry.getValue(); // 跳过用户已经评分过的景点 if (targetUserRatings.containsKey(scenicId)) { continue; } predictScores.merge(scenicId, similarity * rating, Double::sum); weightSum.merge(scenicId, similarity, Double::sum); } } // 4. 按加权分数降序排列返回推荐列表 ListMap.EntryString, Double resultList new ArrayList(); for (Map.EntryString, Double entry : predictScores.entrySet()) { double finalScore entry.getValue() / weightSum.get(entry.getKey()); resultList.add(new AbstractMap.SimpleEntry(entry.getKey(), finalScore)); } resultList.sort((a, b) - Double.compare(b.getValue(), a.getValue())); ListString recommendList new ArrayList(); for (int i 0; i Math.min(topN, resultList.size()); i) { recommendList.add(resultList.get(i).getKey()); } return recommendList; } }这段代码的关键点有三个cosineSimilarity方法用来计算两个用户评分向量的余弦夹角值越接近 1 表示两个用户越相似。在预测用户对未知景点评分时不是简单取平均而是根据相似用户的相似度做加权相似度越高的用户他的评分对预测结果影响越大。topN控制最终返回的推荐数量一般是 5 或 10。3.4 推荐接口与前端调用后端把UserCF封装成一个 HTTP 接口前端通过uni.request获取推荐结果。// 文件路径src/main/java/com/example/travel/controller/RecommendController.java RestController RequestMapping(/recommend) public class RecommendController { Autowired private RatingService ratingService; GetMapping(/list) public ResultListScenic recommend( RequestParam(userId) String userId, RequestParam(value topN, defaultValue 5) int topN) { // 1. 查询所有用户的评分记录 ListMapString, Object allRatings ratingService.findAllRatings(); // 2. 调用协同过滤算法 ListString scenicIds UserCF.recommend(allRatings, userId, 5, topN); // 3. 根据景点ID集合查询景点详情 ListScenic scenicList scenicService.listByIds(scenicIds); return Result.success(scenicList); } }uniapp 前端请求推荐接口并渲染列表// 文件路径pages/recommend/recommend.vue 中的核心方法 methods: { async loadRecommend() { const userId uni.getStorageSync(userId) || defaultUser; const res await new Promise((resolve) { uni.request({ url: http://localhost:8080/recommend/list, data: { userId, topN: 5 }, success: (data) resolve(data.data), fail: () resolve({ code: 500, data: [] }) }); }); if (res.code 200) { this.scenicList res.data; } else { uni.showToast({ title: 推荐加载失败, icon: none }); } } }这里使用的http://localhost:8080是本地开发地址。如果在微信开发者工具中调试需要把本机 IP 写进去并且确保小程序后台勾选了“不校验合法域名”。真正上线时必须替换成 HTTPS 域名。4. 腾讯地图 API 接入4.1 申请开发者 Key打开“腾讯位置服务”官网注册登录后进入控制台创建一个应用并申请 Key。记得在小程序配置中开启“微信小程序”服务并填写你的小程序 AppID否则在小程序端调用会报invalid key错误。申请完成后你会得到类似下面的配置信息开发者密钥XXXXX-XXXXX-XXXXX-XXXXX在项目中新建一个配置文件统一管理地图相关参数// 文件路径config/map.js export default { // 微信小程序 JavaScript SDK 使用的 Key miniProgramKey: 你的腾讯地图小程序Key, // WebService API 使用的 Key webServiceKey: 你的腾讯位置服务WebServiceKey }4.2 使用 map 组件展示景点位置微信小程序原生提供了map组件。在 uniapp 中我们可以直接使用同一个标签template view classmap-page map idmap :latitudelatitude :longitudelongitude :markersmarkers :scale12 markertaphandleMarkerTap /map /view /templatemarkers数据格式是数组每个标记项的核心字段包括字段说明id标记点 ID点击事件中可以用来区分景点latitude纬度longitude经度title标记点名称callout气泡内容展示景点名和评分iconPath标记图标路径示例代码export default { data() { return { latitude: 39.908823, longitude: 116.397470, markers: [ { id: 1, latitude: 39.908823, longitude: 116.397470, title: 故宫, callout: { content: 故宫\n评分4.8, display: ALWAYS, fontSize: 14, borderRadius: 8, padding: 8, bgColor: #ffffff, color: #333333 } }, { id: 2, latitude: 39.999600, longitude: 116.276700, title: 颐和园, callout: { content: 颐和园\n评分4.7, display: ALWAYS, fontSize: 14, borderRadius: 8, padding: 8, bgColor: #ffffff, color: #333333 } } ] } }, methods: { handleMarkerTap(e) { const markerId e.detail.markerId; uni.showToast({ title: 点击了景点ID${markerId}, icon: none }); } } }4.3 使用腾讯地图 SDK 完成搜索与逆地址解析地图组件解决的是“展示”问题而“搜索周边酒店”“根据经纬度获取地址名称”这些能力需要腾讯位置服务的小程序 SDK。首先下载qqmap-wx-jssdk.min.js放入项目的utils目录然后在页面中引入并实例化// 文件路径pages/map/map.vue import QQMapWX from /utils/qqmap-wx-jssdk.min.js; export default { data() { return { qqMapObj: null } }, onLoad() { // 实例化腾讯地图SDK this.qqMapObj new QQMapWX({ key: 你的腾讯地图小程序Key }); this.loadCurrentLocation(); }, methods: { // 获取当前定位 loadCurrentLocation() { uni.getLocation({ type: gcj02, success: (res) { const { latitude, longitude } res; this.latitude latitude; this.longitude longitude; this.searchNearby(酒店, latitude, longitude); }, fail: () { uni.showToast({ title: 定位失败请检查权限, icon: none }); } }); }, // 搜索周边POI searchNearby(keyword, latitude, longitude) { this.qqMapObj.search({ keyword: keyword, location: { latitude: latitude, longitude: longitude }, page_size: 10, success: (res) { const pois res.data.map((item) ({ id: item.id, latitude: item.location.lat, longitude: item.location.lng, title: item.title, address: item.address })); // 把搜索到的POI追加到地图标记中 this.markers this.markers.concat(pois); this.poiList res.data; }, fail: (err) { console.error(err); } }); }, // 逆地址解析 reverseGeocoder(latitude, longitude) { this.qqMapObj.reverseGeocoder({ location: { latitude: latitude, longitude: longitude }, success: (res) { // res.result.formatted_addresses.recommend 为推荐地址描述 const recommend res.result.formatted_addresses?.recommend || res.result.address; uni.showToast({ title: recommend, icon: none }); } }); } } }使用腾讯地图 SDK 时有几个容易踩的坑提前说明必须先在腾讯位置服务后台关联小程序 AppID否则会报错。小程序端调uni.getLocation需要在manifest.json的微信小程序配置里声明requiredPrivateInfos为[getLocation]。gcj02是国测局坐标系前端展示和调用腾讯地图时都需要用它不要使用wgs84的原始 GPS 坐标直接叠加否则地图上会有偏移。5. Echarts 图形化分析5.1 在 uni-app 中引入 Echarts在 uniapp 中使用 Echarts最推荐的方式是renderjs。这个方案的原理是把 Echarts 运行在视图层View 层而 Vue 实例的逻辑层通过prop把数据传给视图层再由 renderjs 中的 Echarts 实例完成渲染。这样既能利用 Echarts 丰富的能力又不会阻塞小程序逻辑层。第一步下载echarts.min.js文件放到项目的common目录下。第二步创建一个封装好的图表组件。这里以饼图为例!-- 文件路径components/uni-echarts/uni-echarts.vue -- template view classecharts-container view v-if!isCanvas :idchartId :propoption :change:propecharts.update classecharts-view /view /view /template script moduleecharts langrenderjs import * as echarts from /common/echarts.min.js; export default { data() { return { chart: null }; }, mounted() { this.init(); }, methods: { // 初始化图表 init() { const dom document.getElementById(this.chartId); if (!dom) return; this.chart echarts.init(dom); if (this.option) { this.chart.setOption(this.option); } }, // 当父组件传入的 option 改变时触发 update(newOption, oldOption) { if (!this.chart) { this.init(); } if (this.chart) { this.chart.setOption(newOption || {}); } } } }; /script script export default { name: UniEcharts, props: { chartId: { type: String, default: chart }, option: { type: Object, default: () ({}) } } }; /script style scoped .echarts-container, .echarts-view { width: 100%; height: 100%; min-height: 300rpx; } /style第三步在页面中引用这个组件传入 Echarts 的option配置。template view classcharts-page view classchart-box uni-echarts chart-idhotPie :optionpieOption/uni-echarts /view /view /template script import UniEcharts from /components/uni-echarts/uni-echarts.vue; export default { components: { UniEcharts }, data() { return { pieOption: {} } }, onLoad() { this.loadChartData(); }, methods: { loadChartData() { // 模拟从后端获取统计结果 const res { code: 200, data: [ { name: 故宫, value: 1200 }, { name: 颐和园, value: 980 }, { name: 长城, value: 1500 }, { name: 西湖, value: 720 } ] }; this.pieOption { title: { text: 景点热度占比, left: center }, tooltip: { trigger: item, formatter: {a} br/{b} : {c} ({d}%) }, legend: { orient: vertical, left: left }, series: [ { name: 景点热度, type: pie, radius: 60%, data: res.data, emphasis: { itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: rgba(0, 0, 0, 0.5) } } } ] }; } } } /script5.2 景点热度柱状图示例饼图适合展示占比柱状图更适合展示排名和变化趋势。下面这段代码展示各个景区的月度游客量对比methods: { buildBarOption(chartData) { this.barOption { title: { text: 各景点访问量趋势, left: center }, tooltip: { trigger: axis }, legend: { data: [本月访问量, 上月访问量], bottom: 0 }, xAxis: { type: category, data: chartData.scenicNames }, yAxis: { type: value }, series: [ { name: 本月访问量, type: bar, barWidth: 20, data: chartData.monthValues, itemStyle: { color: #409EFF } }, { name: 上月访问量, type: bar, barWidth: 20, data: chartData.lastMonthValues, itemStyle: { color: #67C23A } } ] }; } }注意在 renderjs 中echarts.init 必须等待 DOM 存在后再执行所以组件里加了一层判断。如果你的项目在 H5 端运行document 是天然存在的在小程序端只有在微信开发者工具中开启“ES6 转 ES5”并支持 renderjs 的情况下才能正常工作。真机预览时如果图表不显示优先检查组件的 chartId 是否唯一以及 echarts.min.js 文件有没有正确放到 common 目录下。5.3 使用地图数据源时的注意事项如果你的可视化需求不止饼图和柱状图还想在 Echarts 里渲染“省市区旅游热度地图”需要额外引入地图 GeoJSON 数据文件。这个数据需要从第三方地图数据源获取然后以registerMap方式注册到 Echarts 中。import mapJson from /common/china.json; echarts.registerMap(china, mapJson); this.mapOption { visualMap: { min: 0, max: 1000, left: left, top: bottom, text: [高, 低], inRange: { color: [#e0f3f8, #abd9e9, #74add1, #4575b4, #313695] } }, series: [ { type: map, map: china, roam: false, label: { show: true }, data: [ { name: 北京市, value: 800 }, { name: 上海市, value: 650 }, { name: 广东省, value: 920 } ] } ] };需要提醒的是echarts 的map系列在小程序端一旦数据量变大渲染性能会受到明显影响。如果只是课程设计或项目演示建议只在中国地图级别做统计展示如果是生产项目可以考虑后端返回聚合后的省市级汇总数据避免前端加载过大的 JSON 文件。6. 完整案例串联6.1 首页推荐流实现首页不只是展示景点列表还要把推荐结果融合进来。这里我们设计成首次进入时展示热门景点如果用户已经有评分行为则在推荐区域展示协同过滤算法的结果。!-- 文件路径pages/index/index.vue -- template view classindex-page view classbanner swiper :indicator-dotstrue :autoplaytrue :interval3000 circular swiper-item v-foritem in banners :keyitem.id image :srcitem.imageUrl modeaspectFill/image /swiper-item /swiper /view view classsection view classsection-title text classtitle-text猜你喜欢/text text classtitle-more clickgoRecommend更多推荐/text /view view classscenic-card v-forscenic in recommendList :keyscenic.id clickgoDetail(scenic.id) image :srcscenic.cover modeaspectFill/image view classscenic-info text classscenic-name{{ scenic.name }}/text text classscenic-desc{{ scenic.description }}/text view classscenic-bottom text classscore评分 {{ scenic.score }}/text text classaddress{{ scenic.address }}/text /view /view /view /view /view /template首页初始化时先从本地存储读取用户 ID然后调用推荐接口。如果推荐结果为空则退回热门景点接口。export default { data() { return { banners: [], recommendList: [] } }, onShow() { this.loadBanner(); this.loadRecommend(); }, methods: { loadBanner() { // 从后端获取 Banner 数据 }, async loadRecommend() { const userId uni.getStorageSync(userId) || defaultUser; const res await uni.request({ url: http://localhost:8080/recommend/list, data: { userId, topN: 3 } }); if (res.data.code 200 res.data.data.length 0) { this.recommendList res.data.data; } else { this.loadHotScenic(); } }, async loadHotScenic() { const res await uni.request({ url: http://localhost:8080/scenic/hot, data: { limit: 3 } }); this.recommendList res.data.data || []; }, goRecommend() { uni.switchTab({ url: /pages/recommend/recommend }); }, goDetail(id) { uni.navigateTo({ url: /pages/detail/detail?id${id} }); } } }6.2 上报用户评分行为协同过滤算法能否生效依赖评分数据的积累。因此在景点详情页要提供“评分”功能用户打分后通过接口上报到后端。!-- 文件路径pages/detail/detail.vue -- template view classdetail-page image :srcscenic.cover classdetail-cover modeaspectFill/image view classdetail-content text classdetail-name{{ scenic.name }}/text text classdetail-desc{{ scenic.description }}/text view classrating-section text我来评分/text view classstar-list view classstar-item v-forstar in 5 :keystar clickselectStar(star) text :classstar currentRating ? active : ★/text /view /view text classrating-value{{ currentRating }} 分/text /view button typeprimary clicksubmitRating提交评分/button /view /view /templateexport default { data() { return { scenicId: , scenic: {}, currentRating: 0 } }, onLoad(options) { // uniapp 中获取路由参数options.id this.scenicId options.id; this.loadScenicDetail(); }, methods: { selectStar(star) { this.currentRating star; }, async submitRating() { if (this.currentRating 0) { uni.showToast({ title: 请先选择评分, icon: none }); return; } const userId uni.getStorageSync(userId) || defaultUser; const res await uni.request({ url: http://localhost:8080/rating/submit, method: POST, data: { userId, scenicId: this.scenicId, rating: this.currentRating } }); if (res.data.code 200) { uni.showToast({ title: 评分成功 }); } } } }6.3 数据统计页数据统计页把 Echarts 的分析结果放在一起支持切换饼图和柱状图。页面底部可以放一个腾讯地图的入口让用户可以继续查看地理维度上的数据。template view classcharts-page view classchart-tabs view classtab-item :class{ active: currentTab pie } clickswitchTab(pie) 热度占比/view view classtab-item :class{ active: currentTab bar } clickswitchTab(bar) 访问趋势/view /view view classchart-box uni-echarts v-ifcurrentTab pie chart-idpieChart :optionpieOption /uni-echarts uni-echarts v-ifcurrentTab bar chart-idbarChart :optionbarOption /uni-echarts /view view classmap-entry clickgoMap text查看景点分布地图/text /view /view /template这里有一个组件复用的细节如果你同时显示多个 Echarts 组件必须保证每个组件的chartId唯一否则 renderjs 的document.getElementById会拿到同一个 DOM 节点导致图表互相覆盖。7. 常见问题与排查思路在做这个项目的过程中最容易出现问题的主要集中在三个模块地图 Key、Echarts 渲染、推荐接口数据。问题现象常见原因解决思路小程序地图显示空白AppID 与地图 Key 未绑定在腾讯位置服务后台重新绑定小程序 AppIDgetLocation失败manifest.json 未声明隐私接口在mp-weixin配置中添加requiredPrivateInfos定位地址偏移严重坐标系混用统一使用gcj02坐标系Echarts 图表不显示echarts.min.js 路径错误或 chartId 重复检查common目录文件确保 chartId 全局唯一饼图在 H5 正常但小程序空白renderjs 在小程序兼容性问题升级 HBuilderX 到较新版本或者改用 webview 方式推荐接口返回空列表用户评分数据太少先造一批测试评分数据保证每个用户至少评分 3-5 个景点开发时接口访问失败小程序合法域名校验未关闭开发阶段在微信开发者工具中勾选“不校验合法域名”真机图表和地图同时卡顿页面数据量过大图表数据做聚合地图 marker 数量控制在 50 个以内下面单独展开说一个高频问题的排查过程现象在小程序端打开图表页面屏幕完全空白没有报错。排查步骤先在 H5 端运行项目看图表是否正常。如果 H5 正常说明 Echarts 本体和 option 配置没有问题。再用微信开发者工具运行打开 Console 面板看是否有 renderjs 模块加载失败的错误。确认echarts.min.js文件确实在common目录下并且路径写的是/common/echarts.min.js。检查页面中是否存在多个chartId相同的 Echarts 组件。如果以上都没问题把 HBuilderX 和微信开发者工具都升级到最新版本再重新编译。8. 工程化与最佳实践8.1 数据安全与合规旅游小程序涉及用户位置和浏览行为数据必须注意收集用户评分行为前要在隐私协议中明确说明用途。用户位置信息只在需要地图功能时采集不要做全量上传和存储。后端接口必须做用户鉴权推荐接口不能暴露给未登录用户无限制调用。涉及用户数据的删除、导出等操作需要在测试环境验证完毕后再上线。8.2 推荐系统的可演进方向本文中的协同过滤是最基础的版本真正放到线上需要考虑几个问题问题建议方案冷启动新用户没有评分记录用热门景点作为兜底推荐积累评分后再个性化数据稀疏引入物品内容特征从协同过滤过渡到混合推荐计算性能定期离线计算相似度矩阵缓存到 Redis在线阶段直接读结果结果多样性对推荐列表做去重、打散、按类别均衡8.3 uni-app 项目结构建议到项目后期建议把公共逻辑抽取出来避免页面里到处写重复的uni.request代码。可以按下面的方式组织src ├── api // 接口层 │ ├── recommend.js │ ├── scenic.js │ └── rating.js ├── components // 公共组件 │ ├── uni-echarts/ │ └── scenic-card.vue ├── common // echarts 等第三方库文件 ├── config │ └── map.js ├── utils │ ├── request.js │ └── auth.js ├── pages └── store例如统一封装request.js后接口调用会简洁很多// 文件路径utils/request.js const BASE_URL http://localhost:8080; export function request({ url, method GET, data {} }) { return new Promise((resolve, reject) { uni.request({ url: BASE_URL url, method, data, header: { Content-Type: application/json, Authorization: uni.getStorageSync(token) || }, success: (res) { if (res.statusCode 200) { resolve(res.data); } else { uni.showToast({ title: 请求失败, icon: none }); reject(res); } }, fail: (err) reject(err) }); }); }8.4 从开发到上线的几个关键动作当项目完成开发准备打包上线或用于演示时下面几个动作建议提前完成配置生产环境接口地址把BASE_URL从本地 IP 改为 HTTPS 域名。申请并配置合法域名在微信公众平台后台把后端接口域名、腾讯地图相关域名加入 request 合法域名。安卓签名与打包如果需要在 Android 端运行用 HBuilderX 的“发行 - 原生App-云打包”功能打包前配置好manifest.json中的 App 图标和打包证书。隐私协议和用户协议小程序在审核阶段会要求填写隐私保护指引其中要明确列出“地理位置”和“用户存储信息”的用途。性能优化图片资源全部走 CDN不要塞在小程序包里。列表页做分页不要一次性返回几百条数据。Echarts 统计页的图表数据提前聚合好减少前端计算量。8.5 真机调试注意事项在真机上运行小程序时有两类问题最容易暴露第一定位权限弹窗。微信开发者工具里一切正常但真机一打开就白屏或崩溃很可能是manifest.json缺少权限声明。需要检查以下片段mp-weixin: { permission: { scope.userLocation: { desc: 获取你的位置信息用于为你推荐周边景点 } }, requiredPrivateInfos: [getLocation] }第二renderjs 的兼容性。个别安卓机型对 ES6 新语法支持不完整如果 Echarts 页面在部分安卓机上空白需要把 HBuilderX 项目里的“ES6 转 ES5”选项打开同时确认微信开发者工具的基础库版本不要太低。8.6 测试数据准备为了让协同过滤算法有直观的推荐效果建议在后端准备一份模拟评分数据例如 10 个用户对 20 个景点各打了 3-5 分。数据要保证有一定的区分度也就是说用户之间的兴趣偏好要存在差异不能所有人打分都一样。这样才能在测试时看出“不同用户看到的推荐结果不同”。可以用下面的 SQL 快速插入一批测试数据INSERT INTO user_rating (user_id, scenic_id, rating) VALUES (u001, 1, 5), (u001, 2, 4), (u001, 3, 5), (u002, 1, 4), (u002, 2, 5), (u002, 4, 3), (u003, 1, 5), (u003, 3, 5), (u003, 5, 4);数据插入完成后分别用u001、u002、u003调用推荐接口你会看到每个人都有一份不同的推荐列表这就是协同过滤算法产生的个性化差异。写到这里“基于 uniapp 的旅游小程序”三个核心模块都已经完成推荐算法能根据历史行为产出个性化结果Echarts 把统计数据变成可视化图表腾讯地图负责地理维度的展示和搜索。这个项目再往下扩展可以加入游记社区、语音导览、门票预订等功能。如果你正好在开发类似的选题建议先把本文的推荐、图表、地图三个闭环跑通再根据实际需求继续丰富业务模块。有问题也欢迎在评论区留言我会按你遇到的真实报错给出排查思路。
返回列表