NOTE欢迎来到“从零搭建AI小程序全栈实战”系列文章!
此为本系列的第四篇文章,你可以点击跳转到第一篇文章在系列文章中快速跳转。
一、这篇文章将要做的
1.1 回顾
-
上一篇我们完成了后端工程化加固,现在 Spring Boot 有统一响应、异常处理、接口文档、数据校验
-
当前状态:后端接口全部就绪,
/api/images/upload-and-recognize能接收图片并返回识别结果
1.2 目标
-
写一个微信小程序,把后端的能力”装进手机”
-
四个里程碑:
-
小程序开发环境搭建 + 拍照页面
-
调用后端接口,打通上传→识别→展示
-
历史记录列表页
-
体验优化(图片压缩、加载动画、底部导航)
-
二、创建第一个小程序项目
2.1 注册小程序账号与下载工具
注册(如果还没有)
-
打开 微信公众平台,点击“立即注册”,选择“小程序”。
-
用未注册过公众平台的邮箱注册,按提示完成主体类型选择(个人即可)。
-
注册完成后,在「开发」→「开发管理」→「开发设置」页面,记下你的 AppID(后面要用)。
下载开发者工具
-
打开 微信开发者工具下载,下载 Windows 64 位稳定版。
-
安装后打开,用刚刚注册的小程序绑定的微信扫码登录。
2.2 创建项目
-
点击「+」创建项目。
-
填入:
-
项目名称:
ai-mini-app -
目录:选一个空文件夹
-
AppID:粘贴你在后台记下的 AppID
-
开发模式:小程序
-
模板选择:「JavaScript 基础模板」(不要选 TypeScript,先降低复杂度)
-
-
点击确定,你会看到默认模板生成的项目结构:
├── pages/│ └── index/ 首页│ ├── index.js│ ├── index.wxml│ ├── index.wxss│ └── index.json├── app.js 全局逻辑├── app.json 全局配置├── app.wxss 全局样式└── project.config.json2.3 认识四个核心文件
| 文件类型 | 作用 | 类比 |
|---|---|---|
.wxml | 页面结构,写 HTML 类似的标签 | 相当于网页的 HTML |
.wxss | 页面样式,写 CSS | 相当于网页的 CSS |
.js | 页面逻辑,处理数据、发请求 | 相当于网页的 JavaScript |
.json | 页面配置(如标题、导航栏颜色) | 微信特有的配置 |
一个页面由这四个文件组成,名字必须完全相同。
2.4 改造首页
改写 pages/index/index.wxml(清空原有内容,写入):
<view class="container"> <!-- 标题 --> <view class="title">AI图像识别</view>
<!-- 图片展示区(拍照后显示在这里) --> <view class="image-area"> <image wx:if="{{imageUrl}}" src="{{imageUrl}}" mode="aspectFit" class="preview-image"/> <view wx:else class="placeholder"> <text>请点击下方按钮拍照</text> </view> </view>
<!-- 拍照按钮 --> <button class="camera-btn" bindtap="takePhoto"> 📷 拍照识别 </button>
<!-- 识别结果区 --> <view wx:if="{{result}}" class="result-area"> <text class="result-title">识别结果:</text> <text class="result-text">{{result}}</text> </view></view>改写 pages/index/index.wxss(样式):
.container { padding: 40rpx; display: flex; flex-direction: column; align-items: center;}.title { font-size: 40rpx; font-weight: bold; margin-bottom: 40rpx;}.image-area { width: 100%; height: 400rpx; background: #f5f5f5; border-radius: 20rpx; display: flex; align-items: center; justify-content: center; margin-bottom: 30rpx;}.preview-image { width: 100%; height: 100%; border-radius: 20rpx;}.placeholder { color: #999; font-size: 28rpx;}.camera-btn { width: 80%; background: #07c160; color: white; font-size: 32rpx; margin-bottom: 30rpx;}.result-area { width: 100%; padding: 30rpx; background: #fff; border-radius: 20rpx; box-shadow: 0 2rpx 10rpx rgba(0,0,0,0.1);}.result-title { font-size: 30rpx; font-weight: bold; display: block; margin-bottom: 10rpx;}.result-text { font-size: 28rpx; color: #333;}改写 pages/index/index.js:
Page({ data: { imageUrl: '', // 拍照后的图片临时路径 result: '' // 识别结果文本 },
// 拍照方法 takePhoto() { // 调用微信相机 wx.chooseMedia({ count: 1, mediaType: ['image'], sourceType: ['camera'], // 只允许拍照 success: (res) => { const tempFilePath = res.tempFiles[0].tempFilePath this.setData({ imageUrl: tempFilePath }) // 拍照后直接提示(后续连接后端) wx.showToast({ title: '拍照成功!', icon: 'success' }) }, fail: (err) => { console.error('拍照失败', err) wx.showToast({ title: '拍照失败', icon: 'error' }) } }) }})2.5 预览效果
在开发者工具顶部点击「预览」→ 手机微信扫码,在真机上点击按钮看能否调起相机,并显示图片。
如果工具里没法调相机,先点「编译」确认页面显示正常即可。
三、发起HTTP请求连接后端
3.1 配置服务器域名
在微信公众平台后台 →「开发」→「开发管理」→「开发设置」→「服务器域名」:
-
request 合法域名:添加
https://你的域名或先用https://你的服务器IP(如果只有 IP,临时设置为https://12.34.56.78) -
uploadFile 合法域名:同样添加你的服务器地址
开发阶段绕过域名校验:在开发者工具右上角「详情」→「本地设置」→ 勾选「不校验合法域名、TLS 证书等」,否则本地 HTTP 请求发不出去。
3.2 封装网络请求工具
为了方便管理,统一封装请求。创建 utils/api.js:
// 你的服务器地址(阶段1部署的 Spring Boot)const BASE_URL = 'http://你的服务器IP:8080'
function request(url, method = 'GET', data = {}) { return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + url, method: method, data: data, header: { 'Content-Type': 'application/json' }, success: (res) => { if (res.statusCode === 200) { resolve(res.data) } else { reject(res.data) } }, fail: (err) => { reject(err) } }) })}
// 上传图片专用function uploadImage(filePath) { return new Promise((resolve, reject) => { wx.uploadFile({ url: BASE_URL + '/api/images/upload-and-recognize', filePath: filePath, name: 'file', success: (res) => { try { const data = JSON.parse(res.data) if (data.code === 200) { resolve(data.data) } else { reject(data) } } catch(e) { reject({ message: '解析失败' }) } }, fail: (err) => reject(err) }) })}
module.exports = { request, uploadImage }3.3 在页面中调用后端
改造 pages/index/index.js,引入 api 模块,拍照后自动上传:
const api = require('../../utils/api')
Page({ data: { imageUrl: '', result: '' },
takePhoto() { wx.chooseMedia({ count: 1, mediaType: ['image'], sourceType: ['camera'], success: (res) => { const tempFilePath = res.tempFiles[0].tempFilePath this.setData({ imageUrl: tempFilePath })
// 显示上传中 wx.showLoading({ title: '识别中...' })
// 调用后端 AI 识别接口 api.uploadImage(tempFilePath) .then(record => { wx.hideLoading() this.setData({ result: record.recognitionResult || '识别完成' }) wx.showToast({ title: '识别成功!', icon: 'success' }) }) .catch(err => { wx.hideLoading() console.error('识别失败', err) wx.showToast({ title: '识别失败,请重试', icon: 'error' }) }) }, fail: (err) => { console.error('拍照失败', err) } }) }})3.4 验证全链路
-
确保服务器上 jar 在跑,笔记本上 Flask API + frp 也在跑。
-
小程序拍照 → 显示 loading → 返回结果(目前是 fake_prediction,但证明链路通)。
-
现在是 小程序 → Spring Boot → 笔记本 AI → 返回结果 的全链路打通时刻!
四、历史记录列表页
4.1 创建 history 页面
在 pages 文件夹上右键 → 新建文件夹 history,然后新建 Page(选四个文件)。
修改以下文件
history.wxml :
<view class="container"> <view class="title">历史记录</view>
<view wx:if="{{list.length === 0}}" class="empty">暂无记录</view>
<view wx:for="{{list}}" wx:key="id" class="card"> <image src="{{serverUrl}}{{item.imageUrl}}" mode="aspectFill" class="card-image"/> <view class="card-info"> <text class="card-name">{{item.originalFilename}}</text> <text class="card-result">{{item.recognitionResult || '未识别'}}</text> <text class="card-time">{{item.createTime}}</text> </view> </view></view>history.js :
const api = require('../../utils/api')
Page({ data: { list: [], serverUrl: 'http://你的服务器IP:8080' // 拼图片完整路径 },
onShow() { this.loadHistory() },
loadHistory() { wx.showLoading({ title: '加载中' }) api.request('/api/images', 'GET') .then(res => { wx.hideLoading() if (res.code === 200) { this.setData({ list: res.data.reverse() }) // 最新的在前 } }) .catch(err => { wx.hideLoading() console.error(err) }) }})history.wxss :
.container { padding: 20rpx; }.title { font-size: 36rpx; font-weight: bold; padding: 20rpx; }.empty { text-align: center; color: #999; margin-top: 100rpx; }.card { display: flex; background: white; border-radius: 16rpx; padding: 20rpx; margin-bottom: 20rpx; box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.08); }.card-image { width: 150rpx; height: 150rpx; border-radius: 12rpx; }.card-info { flex: 1; margin-left: 20rpx; display: flex; flex-direction: column; justify-content: space-around; }.card-name { font-size: 28rpx; font-weight: bold; }.card-result { font-size: 24rpx; color: #07c160; }.card-time { font-size: 22rpx; color: #999; }4.2 注册页面
在 app.json 的 pages 数组中注册:
"pages": [ "pages/index/index", "pages/history/history"]4.3 添加底部导航栏
在 app.json 中添加 tabBar:
"tabBar": { "color": "#999", "selectedColor": "#07c160", "list": [ { "pagePath": "pages/index/index", "text": "拍照", "iconPath": "images/camera.png", "selectedIconPath": "images/camera-active.png" }, { "pagePath": "pages/history/history", "text": "记录", "iconPath": "images/history.png", "selectedIconPath": "images/history-active.png" } ]}五、图片压缩
5.0 为什么要压缩
手机拍照一张图通常 2-5 MB,服务器带宽小(2核2G),直接上传可能要 5-10 秒。压缩到 100KB 以内,秒传。
5.1 改造 index.js
实现思路
在 takePhoto 拍照成功后,不直接上传,而是先调用 wx.compressImage 压缩,压缩后的临时文件再上传。
完整改造 pages/index/index.js
const api = require('../../utils/api')
Page({ data: { imageUrl: '', // 压缩后的用于展示 originalUrl: '', // 原始图(如果需要预览大图) result: '', uploading: false // 上传中状态,防重复点击 },
takePhoto() { // 防重复点击 if (this.data.uploading) return
wx.chooseMedia({ count: 1, mediaType: ['image'], sourceType: ['camera'], success: (res) => { const originalPath = res.tempFiles[0].tempFilePath
// 先显示原图(给用户即时反馈) this.setData({ imageUrl: originalPath, originalUrl: originalPath, result: '' // 清空旧结果 })
// 压缩图片 this.compressAndUpload(originalPath) }, fail: (err) => { console.error('拍照失败', err) wx.showToast({ title: '拍照失败', icon: 'error' }) } }) },
// 压缩后上传 compressAndUpload(filePath) { wx.showLoading({ title: '压缩中...' })
wx.compressImage({ src: filePath, quality: 60, // 60%质量,肉眼几乎看不出差别 success: (compressRes) => { wx.hideLoading() // 用压缩后的图片展示(加载更快) this.setData({ imageUrl: compressRes.tempFilePath }) // 上传并识别 this.uploadAndRecognize(compressRes.tempFilePath) }, fail: () => { // 压缩失败直接用原图上传 wx.hideLoading() this.uploadAndRecognize(filePath) } }) },
// 上传到后端识别 uploadAndRecognize(filePath) { this.setData({ uploading: true }) wx.showLoading({ title: '识别中...', mask: true })
api.uploadImage(filePath) .then(record => { wx.hideLoading() this.setData({ result: record.recognitionResult || '识别完成', uploading: false }) wx.showToast({ title: '识别成功!', icon: 'success' }) }) .catch(err => { wx.hideLoading() this.setData({ uploading: false }) console.error('识别失败', err) // 显示重试选项(任务3详讲) this.showRetryDialog(filePath) }) },
showRetryDialog(filePath) { wx.showModal({ title: '识别失败', content: '网络似乎不太好,要重试吗?', confirmText: '重试', cancelText: '取消', success: (res) => { if (res.confirm) { this.uploadAndRecognize(filePath) } } }) }})关键点说明
-
quality: 60:平衡清晰度与大小,可调,80/60/40 根据实际压缩结果选。 -
mask: true:loading 时阻止用户点其他按钮。 -
uploading状态:防止用户狂点按钮造成重复上传。
六、前端系统优化
6.1 加载状态与空状态完善
6.1.1 历史记录页优化 pages/history/history.js
const api = require('../../utils/api')
Page({ data: { list: [], serverUrl: 'http://你的服务器IP:8080', loading: true, // 首次加载状态 error: false, // 是否加载失败 errorMsg: '' },
onShow() { this.loadHistory() },
loadHistory() { this.setData({ loading: true, error: false })
api.request('/api/images', 'GET') .then(res => { this.setData({ loading: false }) if (res.code === 200) { this.setData({ list: res.data.reverse() }) if (res.data.length === 0) { // 空状态由 wxml 处理 } } }) .catch(err => { console.error('加载历史失败', err) this.setData({ loading: false, error: true, errorMsg: '加载失败,请检查网络' }) }) },
// 下拉刷新 onPullDownRefresh() { this.loadHistory() wx.stopPullDownRefresh() },
// 重试按钮 retry() { this.loadHistory() }})6.1.2 历史记录页模板优化pages/history/history.wxml
<view class="container"> <view class="title">历史记录</view>
<!-- 加载中 --> <view wx:if="{{loading}}" class="status-box"> <text>加载中...</text> </view>
<!-- 加载失败 --> <view wx:elif="{{error}}" class="status-box"> <text class="error-text">{{errorMsg}}</text> <button class="retry-btn" bindtap="retry">重新加载</button> </view>
<!-- 空状态 --> <view wx:elif="{{list.length === 0}}" class="status-box"> <text class="empty-icon">📷</text> <text class="empty-text">还没有识别记录</text> <text class="empty-hint">去首页拍张照片试试吧</text> </view>
<!-- 列表 --> <view wx:else> <view wx:for="{{list}}" wx:key="id" class="card" bindtap="previewImage" data-url="{{serverUrl}}{{item.imageUrl}}"> <image src="{{serverUrl}}{{item.imageUrl}}" mode="aspectFill" class="card-image"/> <view class="card-info"> <text class="card-name">{{item.originalFilename}}</text> <text class="card-result">{{item.recognitionResult || '未识别'}}</text> <text class="card-time">{{item.createTime}}</text> </view> <text class="arrow">></text> </view> </view></view>6.1.3 历史记录页样式补充
/* 状态占位 */.status-box { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 100rpx 0;}.empty-icon { font-size: 80rpx; margin-bottom: 20rpx; }.empty-text { font-size: 28rpx; color: #666; }.empty-hint { font-size: 24rpx; color: #999; margin-top: 10rpx; }.error-text { color: #e74c3c; font-size: 28rpx; margin-bottom: 20rpx; }.retry-btn { background: #07c160; color: white; font-size: 26rpx; padding: 10rpx 40rpx; border-radius: 40rpx;}.arrow { color: #ccc; font-size: 28rpx; align-self: center; }6.2 错误处理
完善 utils/api.js,加入超时和统一错误提示:
const BASE_URL = 'http://你的服务器IP:8080'
// 通用请求function request(url, method = 'GET', data = {}, showError = true) { return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + url, method: method, data: data, timeout: 10000, // 10秒超时 header: { 'Content-Type': 'application/json' }, success: (res) => { if (res.statusCode === 200) { resolve(res.data) } else { if (showError) wx.showToast({ title: '服务器错误', icon: 'none' }) reject(res.data) } }, fail: (err) => { if (showError) { wx.showToast({ title: '网络连接失败', icon: 'none' }) } reject(err) } }) })}
// 上传图片function uploadImage(filePath) { return new Promise((resolve, reject) => { wx.uploadFile({ url: BASE_URL + '/api/images/upload-and-recognize', filePath: filePath, name: 'file', timeout: 30000, // 上传给30秒 success: (res) => { try { const data = JSON.parse(res.data) if (data.code === 200) { resolve(data.data) } else { wx.showToast({ title: data.message || '识别失败', icon: 'none' }) reject(data) } } catch(e) { reject({ message: '数据解析失败' }) } }, fail: (err) => { wx.showToast({ title: '上传失败', icon: 'none' }) reject(err) } }) })}
module.exports = { request, uploadImage }6.3 完善UI细节
6.3.1 图片预览
在 history.wxml 中,卡片已经绑定了 bindtap="previewImage"。现在在 history.js 中加入方法:
// 在 Page({}) 内部添加previewImage(e) { const url = e.currentTarget.dataset.url wx.previewImage({ urls: [url], // 图片地址数组,可以多张左右滑动 current: url // 当前显示哪张 })}首页也想预览的话,在 index.wxml 的 <image> 标签上加 bindtap="previewImage",index.js 中同样加:
previewImage() { wx.previewImage({ urls: [this.data.originalUrl || this.data.imageUrl], })}6.3.2 统一色调
选定一个主色,比如微信绿 #07c160,所有按钮、高亮用这个色。在 app.wxss 中定义全局变量:
page { --primary: #07c160; --danger: #e74c3c; --text: #333; --text-light: #999; --bg: #f5f5f5; background-color: var(--bg); font-size: 28rpx; color: var(--text);}6.3.3 首页美化与细节交互
/* 全面美化版 index.wxss */.container { padding: 30rpx; display: flex; flex-direction: column; align-items: center; min-height: 100vh;}
.title { font-size: 42rpx; font-weight: bold; color: var(--primary); margin: 40rpx 0 30rpx; letter-spacing: 2rpx;}
.image-area { width: 100%; height: 450rpx; background: white; border-radius: 24rpx; box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.06); display: flex; align-items: center; justify-content: center; margin-bottom: 40rpx; overflow: hidden;}
.preview-image { width: 100%; height: 100%; border-radius: 24rpx;}
.placeholder { text-align: center; color: #bbb;}
.placeholder-icon { font-size: 80rpx; display: block; margin-bottom: 16rpx;}
.placeholder-text { font-size: 28rpx;}
.camera-btn { width: 80%; height: 88rpx; background: linear-gradient(135deg, #07c160, #06ad56); color: white; font-size: 34rpx; font-weight: bold; border-radius: 44rpx; box-shadow: 0 8rpx 20rpx rgba(7, 193, 96, 0.3); display: flex; align-items: center; justify-content: center; margin-bottom: 40rpx;}
.camera-btn:active { opacity: 0.8; transform: scale(0.98);}
.result-box { width: 100%; background: white; border-radius: 24rpx; padding: 30rpx; box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.06);}
.result-label { font-size: 26rpx; color: #999; margin-bottom: 12rpx;}
.result-text { font-size: 32rpx; font-weight: bold; color: var(--primary); word-break: break-all;}对应更新 index.wxml 的占位区:
<view wx:else class="placeholder"> <text class="placeholder-icon">📷</text> <text class="placeholder-text">点击下方按钮拍照</text></view>以及结果区:
<view wx:if="{{result}}" class="result-box"> <text class="result-label">🔍 识别结果</text> <text class="result-text">{{result}}</text></view>6.3.4 底部导航图标生成
用 emoji 代替图标文件,或者去 iconfont 下载免费图标(48x48 px)。
如果 app.json 中没图标,可以暂时注释掉 iconPath 和 selectedIconPath,只用文字:
"tabBar": { "color": "#999", "selectedColor": "#07c160", "list": [ { "pagePath": "pages/index/index", "text": "📷 拍照" }, { "pagePath": "pages/history/history", "text": "📋 记录" } ]}七、验收清单
-
小程序点击”拍照识别”能调起相机
-
拍照后图片显示在页面上,并上传到后端
-
后端返回识别结果,页面显示
-
历史记录页能展示所有识别过的记录
-
底部导航栏正常切换