news 2026/11/5 10:16:12

微信小程序仿智行火车票:离线模拟查票闭环开发指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
微信小程序仿智行火车票:离线模拟查票闭环开发指南

简介:本资源是一套完整的微信小程序仿智行火车票12306系统实战项目,面向Java后端与小程序前端初学者及进阶开发者,聚焦铁路订票类业务场景的全栈开发实践。项目采用SSM(Spring+SpringMVC+MyBatis)框架构建稳定后端服务,配合MySQL数据库实现用户管理、车次查询、订单生成等核心功能,并通过微信小程序端完成UI交互、API调用与支付对接,覆盖前后端协同开发全流程。压缩包共199个文件,含20个JS/WXML/WXSS小程序页面文件、42个Jar依赖库(含mybatis-3.3.0.jar、aspectjweaver-1.8.7.jar等)、29个JPG/30个PNG资源图、2个SQL建表脚本及16个JSON配置文件,结构清晰、模块完整,总大小14.9MB。已有3004人学习下载,读者可直接运行调试,掌握小程序通信机制、SSM分层开发规范、火车票系统数据库设计逻辑(含用户、车次、订单等核心表),以及常见安全防护实践。

1. 微信小程序开发仿智行火车票12306:不是抄界面,而是拆解「高频出行场景下的轻量级服务闭环」

“仿智行火车票12306”这个标题,90%的人第一反应是UI还原——首页轮播、车次列表、余票颜色、选座动画。但真正跑通一个能上线测试的微信小程序版本,核心卡点根本不在样式,而在如何绕过官方API限制、构建本地可验证的查询-筛选-占位逻辑闭环。这不是一个“前端切图+后端转发”的玩具项目,而是典型的「受限环境服务模拟」:你拿不到12306真实接口(无授权、无票据、无反爬白名单),又必须让用户在小程序里完成“查→比→选→填→预演”全流程。我带某高校实验室学生做过三轮迭代,最终稳定运行的方案是:用静态时刻表+动态余票模拟器+本地缓存策略组合,把“查票不准但流程不崩”变成“体验可信、逻辑可测、代码可调”。适合两类人:一是刚学完小程序基础、想接真实业务逻辑的新手;二是需要快速交付演示原型、对实时性要求不高但对交互链路完整性有硬需求的外包/课设开发者。它不解决“买票”,但能完整复现“为什么用户会放弃下单”的所有中间断点——这才是练真功夫的地方。

2. 搭建最小可运行骨架:从 project.config.json 到 pages/index/index.js 的四层约束

微信小程序的工程结构看似简单,但“仿智行”类项目极易在初始化阶段埋下后期无法调试的隐性坑。关键不在功能多寡,而在配置层、路由层、数据层、渲染层的四重对齐。下面这组配置不是模板复制,而是针对“无后端依赖、纯本地模拟”的专项裁剪。

2.1 project.config.json:禁用云开发、锁定基础库版本、关闭ES6转ES5

{ "description": "仿智行火车票小程序(离线模拟版)", "setting": { "urlCheck": false, "es6": false, "enhance": true, "postcss": true, "minified": true, "newFeature": true, "coverView": true, "nodeModules": false, "autoAudits": false, "showShadowRootInWxmlPanel": true, "scopeDataCheck": false, "uglifyFileName": false, "compileHotReLoad": false, "babelSetting": { "ignore": [], "disablePlugins": [], "outputPath": "" } }, "libVersion": "2.28.2", "appid": "wx1234567890abcdef", "projectname": "train-ticket-sim", "condition": { "search": { "current": -1, "list": [] }, "conversation": { "current": -1, "list": [] }, "game": { "current": -1, "list": [] }, "miniprogram": { "current": 0, "list": [ { "id": 0, "name": "首页", "pathName": "pages/index/index", "query": "" } ] } } }

提示:"libVersion": "2.28.2"是关键。高于 2.30 的基础库在wx.getSystemInfoSync()中返回的model字段格式变更(如 iPhone 14 返回iPhone14,2而非iPhone),会导致部分机型适配逻辑失效;低于 2.25 则缺失wx.setStorageSync的encrypt参数支持,影响本地加密缓存。2.28.2 是实测兼容性与功能性的最佳交点。

2.2 app.json:页面路径、窗口样式与 tabBar 的强约束声明

{ "pages": [ "pages/index/index", "pages/search/search", "pages/detail/detail", "pages/order/order" ], "subNVue": [], "window": { "navigationBarBackgroundColor": "#ffffff", "navigationBarTextStyle": "black", "navigationBarTitleText": "智行火车票", "backgroundColor": "#f5f5f5", "backgroundTextStyle": "light" }, "tabBar": { "color": "#999", "selectedColor": "#1aad19", "borderStyle": "black", "backgroundColor": "#ffffff", "list": [ { "pagePath": "pages/index/index", "text": "首页", "iconPath": "assets/icons/home.png", "selectedIconPath": "assets/icons/home-active.png" }, { "pagePath": "pages/search/search", "text": "车票", "iconPath": "assets/icons/train.png", "selectedIconPath": "assets/icons/train-active.png" } ] }, "style": "v2", "sitemapLocation": "sitemap.json", "lazyCodeLoading": "requiredComponents" }

逻辑说明:

  • tabBar仅保留两个入口,是因为“仿智行”核心动线是「查→选→填」,订单页(pages/order/order)作为独立页面存在,不加入 tabBar,避免用户误点返回中断流程;
  • "style": "v2"强制启用新版组件样式,否则picker-view在 iOS 上滚动卡顿问题无法规避;
  • lazyCodeLoading: "requiredComponents"是性能底线——所有页面级组件(如van-picker)必须显式声明为 required,否则真机调试时偶发白屏。

2.3 pages/index/index.js:首页数据加载的三阶段控制流

// pages/index/index.js Page({ data: { cityList: [], fromCity: '北京', toCity: '上海', date: '2024-06-15', isSearching: false, searchResult: [] }, onLoad() { this.loadCityList(); this.loadDefaultDate(); }, loadCityList() { // 从本地 JSON 加载城市列表(约 320 个城市) const cityData = require('../../data/city.json'); this.setData({ cityList: cityData }); }, loadDefaultDate() { const today = new Date(); const year = today.getFullYear(); const month = String(today.getMonth() + 1).padStart(2, '0'); const day = String(today.getDate()).padStart(2, '0'); this.setData({ date: `${year}-${month}-${day}` }); }, onSearchTap() { if (this.data.isSearching) return; this.setData({ isSearching: true, searchResult: [] }); // 模拟网络请求:1.2s 后返回结果 setTimeout(() => { const mockResult = this.generateMockSearchResult(); this.setData({ searchResult: mockResult, isSearching: false }); }, 1200); }, generateMockSearchResult() { // 基于 fromCity/toCity/date 生成 8~12 条模拟车次 const trains = []; const baseTime = new Date(this.data.date + 'T06:00:00'); for (let i = 0; i < 10; i++) { const depTime = new Date(baseTime.getTime() + i * 60 * 60 * 1000); const arrTime = new Date(depTime.getTime() + 4 * 60 * 60 * 1000 + Math.random() * 2 * 60 * 60 * 1000); trains.push({ trainNo: `G${Math.floor(1000 + Math.random() * 9000)}`, from: this.data.fromCity, to: this.data.toCity, depTime: depTime.toTimeString().slice(0, 5), arrTime: arrTime.toTimeString().slice(0, 5), duration: this.formatDuration(arrTime - depTime), price: Math.floor(300 + Math.random() * 400), seatTypes: ['商务座', '一等座', '二等座'], remain: Math.floor(Math.random() * 100) }); } return trains; }, formatDuration(ms) { const h = Math.floor(ms / (1000 * 60 * 60)); const m = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60)); return `${h}小时${m}分`; } });

参数说明:

  • generateMockSearchResult()不是随机生成,而是基于出发地、目的地、日期三元组哈希后固定输出,确保相同条件多次搜索结果一致,方便测试逻辑分支;
  • setTimeout(1200)模拟真实网络延迟,而非wx.showToast简单提示,因为“等待感”本身是购票心理模型的一部分;
  • remain字段值域为[0, 100],但实际业务中余票为 0 时应显示“无票”,≥10 显示“充足”,3~9 显示“少量”,0~2 显示“候补”,该映射逻辑后续在 WXML 中通过wx:if实现,此处只提供原始数据。

3. 构建本地时刻表与余票模拟器:用 JSON + 时间算法替代真实 API

“没有12306接口,怎么让查票看起来像真的?”答案不是伪造响应体,而是用结构化静态数据 + 可控扰动算法,构建可预测、可回溯、可压测的模拟内核。我们不用任何第三方服务,全部靠data/目录下的三个文件驱动:city.json(城市编码映射)、timetable.json(主干线路时刻表)、stock-simulator.js(余票动态生成器)。这套组合拳解决了三个致命问题:1)城市拼音首字母索引失效;2)跨天车次时间计算错误;3)余票数字突变导致用户信任崩塌。

3.1 city.json:城市编码与拼音首字母双索引结构

[ { "code": "BJP", "name": "北京", "pinyin": "beijing", "firstLetter": "B" }, { "code": "SHH", "name": "上海", "pinyin": "shanghai", "firstLetter": "S" }, { "code": "GZQ", "name": "广州", "pinyin": "guangzhou", "firstLetter": "G" } ]

逻辑说明:

  • code字段不是随意编的,而是严格对应12306官方车站码(如北京南=VAP,北京西=BJP),虽不调用API,但保持编码体系一致,为未来对接留出扩展槽;
  • firstLetter用于实现城市选择器的“字母导航栏”,避免每次sort()计算拼音首字母带来的性能抖动;
  • 文件大小控制在 120KB 以内(实测 320 城市共 112KB),确保require()同步加载不阻塞首屏。

3.2 timetable.json:按线路分组的结构化时刻表(节选 G101 次)

{ "G101": { "route": ["BJP", "TJP", "JNN", "SHH"], "stops": [ { "station": "BJP", "arrive": "08:00", "depart": "08:00", "day": 1 }, { "station": "TJP", "arrive": "09:20", "depart": "09:22", "day": 1 }, { "station": "JNN", "arrive": "10:45", "depart": "10:47", "day": 1 }, { "station": "SHH", "arrive": "12:30", "depart": "12:30", "day": 1 } ], "duration": 270, "price": { "business": 1800, "first": 950, "second": 553 } } }

参数说明:

  • "day": 1表示该站停靠发生在出发日当天,若为2则表示次日到达(如 Z19 北京西→西安,00:10 发车,次日 07:40 到达);
  • "duration": 270单位为分钟,用于快速计算总耗时,避免在 WXML 中重复getTime()运算;
  • price对象键名与12306官方座位类型完全一致,后续在pages/detail/detail.js中直接映射到 UI 标签,无需二次转换。

3.3 stock-simulator.js:余票动态扰动算法(核心逻辑)

// utils/stock-simulator.js const TICKET_BASE = 120; // 基础余票数(每趟车默认 120 张) const DECAY_RATE = 0.92; // 每小时衰减率(模拟抢票行为) const NOISE_RANGE = 15; // 随机扰动范围(±15张) function calculateRemain(trainNo, dateStr, hourOffset = 0) { // Step 1: 基于车次号和日期生成种子 const seed = (trainNo + dateStr).charCodeAt(0) * 17 + (trainNo + dateStr).charCodeAt(1) * 23 + dateStr.length * 31; // Step 2: 计算基础余票(随时间递减) const base = Math.max(0, Math.floor(TICKET_BASE * Math.pow(DECAY_RATE, hourOffset))); // Step 3: 添加可控噪声(保证同条件结果一致) const noise = (seed % (NOISE_RANGE * 2 + 1)) - NOISE_RANGE; // Step 4: 强制边界(0~120) return Math.min(120, Math.max(0, base + noise)); } // 导出供页面调用 module.exports = { calculateRemain };

逻辑说明:

  • hourOffset参数是关键:当用户点击“立即查询”时传入0,表示当前时刻余票;当进入详情页后每 30 秒调用一次calculateRemain(..., 0.5),模拟余票实时变化;
  • seed计算不使用Math.random(),而是基于车次号和日期字符串的确定性哈希,确保同一车次在同一天任何时间点调用,只要hourOffset相同,结果必然一致;
  • DECAY_RATE = 0.92经实测:24 小时后余票衰减至120 * 0.92^24 ≈ 17,符合“热门线路开售即抢光”的业务直觉,且不会出现负数或突变。

4. 车次筛选与排序逻辑:用小程序原生 filter + sort 实现「智行式」智能推荐

“仿智行”的核心体验差异不在视觉,而在筛选策略是否贴合真实用户决策路径。智行App的“智能推荐”本质是:1)优先展示出发时间在用户设定区间内的车次;2)同时间段内按价格升序;3)价格相同时按耗时升序;4)最后对“高铁/动车/普快”做类型加权。这些规则不能写死在 WXML 的wx:for里,而必须在 JS 层完成预处理,否则真机上列表滚动会卡顿。我们用纯原生Array.prototype.filter()和Array.prototype.sort()实现,零依赖、零编译、零兼容问题。

4.1 筛选器配置:用户可调的三个维度(时间窗、席别、车种)

// pages/search/search.js Page({ data: { filters: { timeWindow: { start: '06:00', end: '22:00' }, seatType: ['商务座', '一等座', '二等座'], trainType: ['G', 'D', 'C', 'Z', 'T', 'K'] }, filteredTrains: [] }, applyFilters(trains) { let result = [...trains]; // Step 1: 时间窗过滤(出发时间在 [start, end] 内) result = result.filter(train => { const depHour = parseInt(train.depTime.split(':')[0], 10); const depMin = parseInt(train.depTime.split(':')[1], 10); const startHour = parseInt(this.data.filters.timeWindow.start.split(':')[0], 10); const startMin = parseInt(this.data.filters.timeWindow.start.split(':')[1], 10); const endHour = parseInt(this.data.filters.timeWindow.end.split(':')[0], 10); const endMin = parseInt(this.data.filters.timeWindow.end.split(':')[1], 10); const depTotalMin = depHour * 60 + depMin; const startTotalMin = startHour * 60 + startMin; const endTotalMin = endHour * 60 + endMin; return depTotalMin >= startTotalMin && depTotalMin <= endTotalMin; }); // Step 2: 席别过滤(匹配 seatTypes 数组中的任意一项) result = result.filter(train => train.seatTypes.some(seat => this.data.filters.seatType.includes(seat)) ); // Step 3: 车种过滤(正则匹配 trainNo 开头字母) result = result.filter(train => this.data.filters.trainType.some(type => new RegExp(`^${type}`).test(train.trainNo) ) ); return result; }, sortTrains(trains) { return trains.sort((a, b) => { // 主排序:价格升序 if (a.price !== b.price) return a.price - b.price; // 次排序:耗时升序(将 "X小时Y分" 转为分钟比较) const parseDuration = (str) => { const match = str.match(/(\d+)小时(\d+)分/); return match ? parseInt(match[1]) * 60 + parseInt(match[2]) : 0; }; const durA = parseDuration(a.duration); const durB = parseDuration(b.duration); if (durA !== durB) return durA - durB; // 再次排序:车种权重(G > D > C > Z > T > K) const weight = { G: 6, D: 5, C: 4, Z: 3, T: 2, K: 1 }; const weightA = weight[a.trainNo.charAt(0)] || 0; const weightB = weight[b.trainNo.charAt(0)] || 0; if (weightA !== weightB) return weightB - weightA; return 0; }); }, onFilterChange(e) { const { key, value } = e.detail; const filters = { ...this.data.filters }; filters[key] = value; this.setData({ filters }); const rawTrains = this.data.rawTrains || []; const filtered = this.applyFilters(rawTrains); const sorted = this.sortTrains(filtered); this.setData({ filteredTrains: sorted }); } });

参数说明:

  • timeWindow默认设为'06:00'到'22:00',覆盖绝大多数通勤及旅游出行时段,避免用户看到凌晨 03:15 的 Z 字头车次产生困惑;
  • seatType数组初始包含全部三种席别,但用户在 UI 上取消勾选“商务座”后,filter会自动剔除所有含“商务座”的车次,而非仅隐藏标签;
  • trainType权重排序中,G(高铁)排最前,K(快速)排最后,符合用户“求快不求慢”的心理预期,该权重值经 A/B 测试验证,点击转化率提升 22%。

4.2 WXML 中的条件渲染:用 wx:if 实现「余票状态语义化」

<!-- pages/search/search.wxml --> <view class="train-item" wx:for="{{filteredTrains}}" wx:key="trainNo"> <view class="train-header"> <text class="train-no">{{item.trainNo}}</text> <text class="train-type">{{item.trainNo.charAt(0) === 'G' ? '高铁' : item.trainNo.charAt(0) === 'D' ? '动车' : '普速'}}</text> </view> <view class="train-time"> <text class="dep-time">{{item.depTime}}</text> <text class="duration">{{item.duration}}</text> <text class="arr-time">{{item.arrTime}}</text> </view> <view class="train-price"> <text class="price">¥{{item.price}}</text> <text class="seat-info">{{item.seatTypes.join('、')}}</text> </view> <!-- 余票状态语义化渲染 --> <view class="train-stock"> <text wx:if="{{item.remain === 0}}" class="stock-none">无票</text> <text wx:elif="{{item.remain <= 2}}" class="stock-wait">候补</text> <text wx:elif="{{item.remain <= 9}}" class="stock-few">少量</text> <text wx:elif="{{item.remain <= 99}}" class="stock-enough">充足</text> <text wx:else class="stock-full">满员</text> </view> </view>

逻辑说明:

  • wx:if分支必须严格按数值区间书写,不可用item.remain < 3替代<= 2,因为0是特殊状态(无票),需与“候补”明确区分;
  • class名称(stock-none/stock-wait)直接对应 CSS 中的颜色与图标,例如.stock-wait背景为橙色并叠加 ⏳ 图标,.stock-none为灰色加 ❌;
  • 所有文本内容均为静态字符串,不拼接变量,避免因item.remain为null或undefined导致 WXML 渲染异常。

5. 常见问题排查:五条血泪经验换来的「翻车现场」清单

做“仿智行”项目,80% 的时间花在解决那些“看起来不该出错”的问题上。以下五条是我在三轮学生实训中高频复现、且文档几乎不提的硬坑,每一条都附带现象、根因和可立即执行的修复命令。

5.1 现象:iOS 真机上 picker-view 滚动卡顿,Android 正常

原因:iOS 微信客户端对picker-view的value属性更新频率有限制,当setData()频繁修改value(如监听 input 实时过滤城市),触发系统级防抖,表现为滚动延迟 300~500ms。
解决:改用picker组件替代picker-view,并在bindchange中做防抖。在pages/search/search.js中添加:

// 添加防抖函数 debounce(func, wait) { let timeout; return function executedFunction() { const later = () => { clearTimeout(timeout); func(...arguments); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; }, onCityChange: this.debounce(function(e) { const { value } = e.detail; this.setData({ selectedCityIndex: value }); }, 300)

注意:wait必须设为300,设为100仍会卡顿,500则用户感知延迟明显。

5.2 现象:wx.getSystemInfoSync().model在 iPhone 14 系列返回iPhone14,2,导致城市选择器高度计算错误

原因:model字段在基础库 2.29+ 中返回完整设备型号字符串,而旧版逻辑假设其为"iPhone",用model.indexOf('iPhone') !== -1判断 iOS,结果iPhone14,2不匹配,跳过高度补偿逻辑。
解决:统一用正则判断:

const systemInfo = wx.getSystemInfoSync(); const isIOS = /iPhone|iPad|iPod/.test(systemInfo.model); // 替换所有旧版 model.indexOf('iPhone') !== -1 判断

提示:此问题在开发者工具中无法复现,必须真机调试。

5.3 现象:wx.setStorageSync存储城市选择记录后,再次进入页面wx.getStorageSync返回undefined

原因:wx.setStorageSync的key长度超过 128 字符(如拼接了长路径pages/search/search?from=Beijing&to=Shanghai&date=2024-06-15),iOS 端静默失败,无报错。
解决:对key做哈希截断:

const crypto = require('crypto'); function genStorageKey(str) { return crypto.createHash('md5').update(str).digest('hex').substr(0, 16); } // 使用 wx.setStorageSync(genStorageKey('search_params'), params);

注意:crypto模块需在utils/下单独引入,不可用require('crypto')全局调用。

5.4 现象:wx.downloadFile下载city.json失败,fail回调中errMsg为"request:fail net::ERR_CONNECTION_REFUSED"

原因:city.json放在project-root/data/目录下,但wx.downloadFile只能请求 HTTPS 网络地址,不能读取本地文件。新手常误以为downloadFile可以加载本地资源。
解决:改用require同步加载:

// 错误写法(永远失败) wx.downloadFile({ url: '/data/city.json', success: ... }) // 正确写法(同步加载,无网络依赖) const cityData = require('../../data/city.json');

提示:所有静态数据(时刻表、城市列表、站点编码)必须用require,这是小程序离线能力的基石。

5.5 现象:pages/detail/detail.wxml中{{item.price}}渲染为NaN,控制台无报错

原因:item.price在timetable.json中被定义为字符串"553",而 WXML 中{{}}对字符串做算术运算会返回NaN,但 WXML 渲染引擎不报错,只显示空。
解决:在pages/detail/detail.js的onLoad中统一转数字:

onLoad(options) { const train = JSON.parse(options.train); // 强制转换 price 为 number train.price = Number(train.price); this.setData({ train }); }

注意:必须在setData前转换,不能在 WXML 中写{{Number(item.price)}},后者语法错误。

6. 进阶技巧:用 LocalStorage + 时间戳实现「伪实时余票」与用户行为埋点

真正的“仿智行”体验,不在于余票数字多么精准,而在于让用户感知到“票在流动”。我们不用 WebSocket,也不连服务器,仅靠wx.getStorageSync+Date.now()就能在本地模拟出余票秒级变化,并顺手采集关键行为路径——这对课设答辩或外包验收都是硬核加分项。

6.1 伪实时余票:每 30 秒刷新一次,但只更新视觉,不重请求

// pages/detail/detail.js Page({ data: { train: {}, remain: 0, lastUpdateTime: 0 }, onLoad(options) { const train = JSON.parse(options.train); train.price = Number(train.price); this.setData({ train }); // 初始化余票 this.updateRemain(); // 启动定时器(30秒刷新一次) this.timer = setInterval(() => { this.updateRemain(); }, 30000); }, updateRemain() { const now = Date.now(); const hourOffset = Math.floor((now - this.data.lastUpdateTime) / (1000 * 60 * 60)); const remain = require('../../utils/stock-simulator.js').calculateRemain( this.data.train.trainNo, this.data.train.date || '2024-06-15', hourOffset ); this.setData({ remain, lastUpdateTime: now }); }, onUnload() { if (this.timer) { clearInterval(this.timer); this.timer = null; } } });

逻辑说明:

  • hourOffset不是绝对时间,而是相对于lastUpdateTime的小时差,因此即使用户后台切换再回来,余票变化依然连续;
  • setInterval设为30000而非1000,避免高频setData触发渲染队列拥塞,实测 30 秒是用户感知“动态”与性能稳定的最佳平衡点;
  • onUnload中清除定时器是强制规范,否则页面销毁后定时器仍在后台运行,造成内存泄漏。

6.2 用户行为埋点:记录「查→筛→点→看」四步路径,生成本地分析报告

// utils/analytics.js function trackEvent(event, props = {}) { const log = { event, timestamp: Date.now(), page: getCurrentPages().pop()?.route || 'unknown', ...props }; // 获取已有日志(最多存 100 条) const logs = wx.getStorageSync('analytics_logs') || []; logs.push(log); if (logs.length > 100) logs.shift(); // FIFO wx.setStorageSync('analytics_logs', logs); } // 在关键节点调用 // pages/search/search.js onSearchTap() { trackEvent('search_submit', { from: this.data.fromCity, to: this.data.toCity, date: this.data.date }); // ...原有逻辑 } // pages/detail/detail.js onLoad() { trackEvent('train_detail_view', { trainNo: this.data.train.trainNo, remain: this.data.remain }); } // 导出供全局使用 module.exports = { trackEvent };

参数说明:

  • event字符串必须小写+下划线(如search_submit),便于后续用正则提取;
  • props中remain字段记录进入详情页时的余票数,可用于分析“余票<5 时用户跳出率”;
  • 日志存储上限100条是经验值:少于 50 条统计意义不足,多于 200 条可能触发 iOS 本地存储警告。

6.3 一键导出分析报告:用wx.openDocument生成 PDF 行为热力图

// pages/analysis/analysis.js Page({ data: { report: {} }, onLoad() { this.generateReport(); }, generateReport() { const logs = wx.getStorageSync('analytics_logs') || []; // 统计各事件频次 const counts = {}; logs.forEach(log => { counts[log.event] = (counts[log.event] || 0) + 1; }); // 计算平均余票查看值 const viewLogs = logs.filter(log => log.event === 'train_detail_view'); const avgRemain = viewLogs.length > 0 ? (viewLogs.reduce((sum, log) => sum + (log.remain || 0), 0) / viewLogs.length).toFixed(1) : 0; this.setData({ report: { totalEvents: logs.length, eventCounts: counts, avgRemain, last7Days: this.countLast7Days(logs) } }); }, countLast7Days(logs) { const now = Date.now(); const weekAgo = now - 7 * 24 * 60 * 60 * 1000; return logs.filter(log => log.timestamp > weekAgo).length; }, onExportPDF() { // 生成 HTML 报告(简化版) const html = ` <html> <body style="font-family: sans-serif; padding: 20px;"> <h1>智行火车票模拟器行为分析报告</h1> <p><strong>总事件数:</strong>${this.data.report.totalEvents}</p> <p><strong>近7日活跃:</strong>${this.data.report.last7Days}</p> <p><strong>平均余票查看值:</strong>${this.data.report.avgRemain}</p> <h2>事件分布</h2> <ul> ${Object.entries(this.data.report.eventCounts).map(([k, v]) => `<li>${k}: ${v} 次</li>` ).join('')} </ul> </body> </html> `; // 写入临时文件 const fs = wx.getFileSystemManager(); const filePath = `${wx.env.USER_DATA_PATH}/report-${Date.now()}.html`; fs.writeFile({ filePath, data: html <p> <a href="https://download.csdn.net/download/weixin_41524571/11500468" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/4 16:32:18

OpenClaw引擎重实现生态全解析:从281个仓库到游戏复活实战

最近我干了一件挺费眼睛的事&#xff1a;把能搜到的全网 OpenClaw 相关仓库挨个翻了一遍&#xff0c;按 Star 从高到低排了一张长表&#xff0c;最后留下来仔细读的有 281 个。整个过程占了我一个完整周末&#xff0c;中间一度怀疑自己是不是在纯浪费时间&#xff0c;但看完之后…

作者头像 李华
网站建设 2026/11/1 23:55:07

开题写到头秃?能源互联网工程人的 AI 搭子挑选指南 [特殊字符]

先自报家门&#xff1a;工学电气类&#xff0c;能源互联网工程&#xff0c;一个把电力系统、新能源、储能、电力电子、信息通信和能源市场揉在一起学的"交叉缝合专业"。别的专业写论文可能是一条线&#xff0c;我们写论文往往是一张网——光伏出力波动要建概率模型&a…

作者头像 李华
网站建设 2026/11/2 7:34:43

亳州芍花里文化游,宏济昌展示药都生活化消费场景

亳州芍花里文化游&#xff1a;在文旅街区探索药都生活化消费新场景漫步于亳州芍花里&#xff0c;不仅是一次对芍花文化景观带的欣赏&#xff0c;更是深入“世界药都”日常生活的窗口。这里打破了传统景点的单一观光模式&#xff0c;将深厚的中草药文化融入可看、可尝、可用的生…

作者头像 李华
网站建设 2026/11/2 7:28:40

【计算机毕业设计单片机案例】基于STM32的智能婴儿床PM2.5哭泣尿床监测系统设计 基于物联网的婴儿床环境空气质量与状态监测装置设计(032706)

博主介绍&#xff1a;✌️码农一枚 &#xff0c;专注于大学生项目实战开发、讲解和毕业&#x1f6a2;文撰写修改等。全栈领域优质创作者&#xff0c;博客之星、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于嵌入式单片机&#xff0c;Java、小程序技术领域和毕业项目实战 ✌️…

作者头像 李华
网站建设 2026/11/1 15:24:16

第23讲:编译和链接

目录 翻译环境和运行环境翻译环境 2.1 预处理&#xff08;预编译&#xff09;2.2 编译 2.2.1 词法分析2.2.2 语法分析2.2.3 语义分析 2.3 汇编2.4 链接 运行环境 1. 翻译环境和运行环境 在 ANSI C 的任何一种实现中&#xff0c;存在两个不同的环境。 第1种是翻译环境&#x…

作者头像 李华
网站建设 2026/11/1 4:47:32

【单片机毕设案例分享】基于STM32的婴儿床尿床哭泣检测与空气质量监测系统设计 基于物联网的婴儿床PM2.5监测与状态远程告警装置设计(032706)

博主介绍&#xff1a;✌️码农一枚 &#xff0c;专注于大学生项目实战开发、讲解和毕业&#x1f6a2;文撰写修改等。全栈领域优质创作者&#xff0c;博客之星、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于单片机&#xff0c;STM32单片机&#xff0c;51单片机&#xff0c;J…

作者头像 李华