3407 字
17 分钟
从零搭建AI小程序全栈实战(三):微信小程序前端
NOTE

欢迎来到“从零搭建AI小程序全栈实战”系列文章!
此为本系列的第四篇文章,你可以点击跳转到第一篇文章在系列文章中快速跳转。

一、这篇文章将要做的#

1.1 回顾#

  • 上一篇我们完成了后端工程化加固,现在 Spring Boot 有统一响应、异常处理、接口文档、数据校验

  • 当前状态:后端接口全部就绪,/api/images/upload-and-recognize 能接收图片并返回识别结果

1.2 目标#

  • 写一个微信小程序,把后端的能力”装进手机”

  • 四个里程碑:

    1. 小程序开发环境搭建 + 拍照页面

    2. 调用后端接口,打通上传→识别→展示

    3. 历史记录列表页

    4. 体验优化(图片压缩、加载动画、底部导航)

二、创建第一个小程序项目#

2.1 注册小程序账号与下载工具#

注册(如果还没有)#

  1. 打开 微信公众平台,点击“立即注册”,选择“小程序”。

  2. 用未注册过公众平台的邮箱注册,按提示完成主体类型选择(个人即可)。

  3. 注册完成后,在「开发」→「开发管理」→「开发设置」页面,记下你的 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.json

2.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.jsonpages 数组中注册:

"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 中没图标,可以暂时注释掉 iconPathselectedIconPath,只用文字:

"tabBar": {
"color": "#999",
"selectedColor": "#07c160",
"list": [
{
"pagePath": "pages/index/index",
"text": "📷 拍照"
},
{
"pagePath": "pages/history/history",
"text": "📋 记录"
}
]
}

七、验收清单#

  • 小程序点击”拍照识别”能调起相机

  • 拍照后图片显示在页面上,并上传到后端

  • 后端返回识别结果,页面显示

  • 历史记录页能展示所有识别过的记录

  • 底部导航栏正常切换

从零搭建AI小程序全栈实战(三):微信小程序前端
https://47.113.107.125:80/posts/ai-miniapp/03-微信小程序前端/
作者
TerryC
发布于
2026-01-04
许可协议
CC BY-NC-SA 4.0