UniApp + Vue3:实战封装一个通用图片上传模块(含业务页面完整示例)
技术栈:UniApp + Vue3 + Composition API
场景:微信小程序端「鉴酒订单」9 张图片上传 + 表单提交
最近在做一个鉴酒小程序项目,需要用户在下单前上传多张酒瓶图片(顶部、底部、防伪细节、正反面等),并配合表单一起提交。
这个模块一开始写得非常“糙”:每个页面都自己写 chooseImage + uploadFile,逻辑重复、难改难维护。
后来干脆把上传逻辑彻底封装成一个 composable:
- 页面只负责业务和 UI;
- 上传、错误处理、接口调用全交给 composable 统一管理。
这篇文章就是对这个模块的一次完整复盘。
一、业务背景:为什么要抽离图片上传?
实际需求大致是这样:
- 用户下单前必须上传 6 张必填图片 + 3 张选填图片;
- 图片上传完成后,需要拿到 OSS URL 再组装成后端需要的
cdImgs数组; - 同时还要填写获取渠道、鉴定师、鉴定模式、生产日期等字段;
- 最终统一调用「新增订单」接口。
如果不封装,页面里会充斥着大量这样的代码:
uni.chooseImage({
count: 1,
success(res) {
const filePath = res.tempFilePaths[0]
uni.uploadFile({
url: 'xxx',
filePath,
name: 'file',
header: { 'X-Token': token },
success(uploadRes) { ... },
fail(err) { ... }
})
}
})
这样的代码在多个页面反复出现,有几个明显问题:
- 逻辑重复,改一处要到处找;
- 错误处理风格不统一;
- 容易出现某个页面漏传 token、漏处理 code 的情况;
- 页面代码看起来非常“厚重”,不利于阅读和维护。
所以我决定:把上传逻辑整体收走,做成一个通用图片上传模块。
二、统一上传接口:uploadFile API 封装
先在 api/user.js 中封装一个统一的上传接口。
因为是 UniApp 项目,小程序端必须使用 uni.uploadFile,不能用 axios:
export const uploadFile = (filePath) => {
return new Promise((resolve, reject) => {
uni.uploadFile({
url: '这里填自己的请求路径',
filePath,
name: 'file',
header: {
"X-Token": uni.getStorageSync('token') || '',
},
success: (res) => {
const data = JSON.parse(res.data)
resolve(data)
},
fail: (err) => reject(err)
})
})
}
这里做了几件事:
- 统一上传地址:以后如果改成别的 OSS,只需要改这一处;
- 自动携带 token:从本地
uni.getStorageSync('token')取; - 统一解析 JSON:
res.data是字符串,统一JSON.parse一次; - 返回一个 Promise,方便在
async/await里使用。
三、抽离核心上传逻辑:useImageUpload composable
然后在 composables/useImageUpload.js 中写一个通用的上传逻辑:
// -------------------------------------------
// 图片上传封装(通用版)
// -------------------------------------------
import { ref } from 'vue'
import { uploadFile } from '@/api/user.js'
export function useImageUpload() {
// 后端返回的图片 URL
const tupianUrl = ref('')
/**
* 上传单张图片文件,返回图片 URL
* @param {string} wenjianLujing - 本地临时路径(chooseImage 返回)
* @returns {string|null}
*/
const shangchuanTupian = async (wenjianLujing) => {
try {
const res = await uploadFile(wenjianLujing)
if (res.code === 200) {
tupianUrl.value = res.data // 保存到响应式变量
return res.data // 同时 return 给调用方使用
} else {
console.error('后端返回错误:', res)
uni.showToast({ title: '图片上传失败', icon: 'none' })
return null
}
} catch (err) {
console.error('上传时发生异常:', err)
uni.showToast({ title: '上传异常,请重试', icon: 'none' })
return null
}
}
return {
tupianUrl,
shangchuanTupian
}
}
这里的设计思路
-
返回
tupianUrl:可以在某些场景下做双向绑定展示; -
返回
shangchuanTupian函数:页面只关心“给你一个临时路径,换回一个 URL”; -
composable 内部统一处理:
- 调用上传 API;
- 处理
code === 200的逻辑; - 错误弹窗提示。
四、业务页面:cateDetail.vue 中使用 composable
接下来就是最关键的一步:在实际业务页面中使用这个上传模块。
场景是「新增鉴酒订单」,需要上传 9 张图片,用 3×3 网格展示。
1. 定义图片位置信息配置
// 必填 + 选填图片位置信息
const imgTypes = [
// 必填
{ key: 'top', label: '顶部', urlName: '顶部', required: true },
{ key: 'bottom', label: '底部', urlName: '底部', required: true },
{ key: 'head_teeth', label: '头部齿', urlName: '头部齿', required: true },
{ key: 'tooth', label: '头齿', urlName: '头齿', required: true },
{ key: 'detail_code1', label: '细节喷码(一)', urlName: '细节喷码', required: true },
{ key: 'detail_code2', label: '细节喷码(二)', urlName: '细节喷码', required: true },
// 选填
{ key: 'anti_fake', label: '防伪标签细节', urlName: '防伪标签细节', required: false },
{ key: 'front', label: '正面', urlName: '正面', required: false },
{ key: 'back', label: '背面', urlName: '背面', required: false }
]
每一项都包含三类信息:
key:在form.cdImgs中使用的字段label:UI 上显示的文案urlName:传给后端的固定描述required:是否必填
这让后面的校验和组装参数非常简单。
2. 表单数据结构
const form = ref({
cdImgs: {
top: '',
bottom: '',
head_teeth: '',
tooth: '',
detail_code1: '',
detail_code2: '',
anti_fake: '',
front: '',
back: ''
},
dataDic: '', // 获取渠道
remark: '', // 购买渠道 + dataDic
windTime: '', // YYYY-MM-DD
expertId: '', // 鉴定师 id
expertName: '', // 显示用
expertMode: '', // 1 单人,0 双人
expertModeLabel: '' // 显示用
})
表单里的 cdImgs 与 imgTypes 一一对应。
3. 使用 composable 上传图片
在 <script setup> 里引入:
import { useImageUpload } from '@/composables/useImgeUpload.js'
const { shangchuanTupian } = useImageUpload()
然后封装选择 + 上传逻辑:
const chooseImage = (key) => {
uni.chooseImage({
count: 1,
success: async (res) => {
const filePath = res.tempFilePaths[0]
uni.showLoading({ title: '上传中...' })
const url = await shangchuanTupian(filePath)
uni.hideLoading()
if (url) {
form.value.cdImgs[key] = url
} else {
uni.showToast({ title: '上传失败', icon: 'none' })
}
},
fail: () => {
uni.showToast({ title: '选择图片失败', icon: 'none' })
}
})
}
这个函数非常干净:
- 让用户选一张图片;
- 把临时路径丢给 composable 处理;
- 如果成功,填入
form.cdImgs[key]; - 如果失败,统一 toast 提示。
4. 模板中的 3×3 图片网格
<view class="img-group">
<view class="img-item" v-for="item in imgTypes" :key="item.key">
<view class="upload-btn" @click="chooseImage(item.key)">
<text v-if="!form.cdImgs[item.key]">点击上传</text>
<image
v-else
:src="form.cdImgs[item.key]"
class="preview"
></image>
</view>
<view class="img-label">
<text>{{ item.label }}</text>
<text v-if="item.required" class="required">*</text>
</view>
</view>
</view>
配合简单的样式,就能实现一个规整的图片网格上传区:
.img-group {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20rpx;
margin-right: 15rpx;
}
.img-item {
background: #fff;
padding: 20rpx;
border-radius: 12rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
align-items: center;
}
.upload-btn {
width: 200rpx;
height: 200rpx;
background: #f5f5f5;
border-radius: 12rpx;
display: flex;
justify-content: center;
align-items: center;
}
.preview {
width: 200rpx;
height: 200rpx;
border-radius: 12rpx;
}
五、生成提交参数:把 9 张图片转换成后端需要的结构
后端要求的 cdImgs 字段格式:
[
{
"required": true,
"url": "https://xxx.jpg",
"urlName": "顶部"
},
...
]
可以直接通过 imgTypes + form.cdImgs 合成:
const imgs = form.value.cdImgs
const cdImgsArr = imgTypes
.filter(item => imgs[item.key]) // 只保留用户实际上传的项
.map(item => ({
required: item.required,
url: imgs[item.key],
urlName: item.urlName
}))
这一块用配置驱动,避免写一堆 if/else。
六、提交订单:完整流程串起来
import { insertOrder, GetqueryrankingDeta } from '@/api/home.js'
const submitOrder = async () => {
const imgs = form.value.cdImgs
// 1. 校验必填图片
for (let item of imgTypes) {
if (item.required && !imgs[item.key]) {
return uni.showToast({ title: `请上传:${item.label}`, icon: 'none' })
}
}
// 2. 校验日期格式:YYYY-MM-DD
const dateReg = /^\d{4}-\d{2}-\d{2}$/
if (!dateReg.test(form.value.windTime)) {
return uni.showToast({ title: '生产日期格式不正确:YYYY-MM-DD', icon: 'none' })
}
// 3. 渠道校验
if (!channels.includes(form.value.dataDic)) {
return uni.showToast({ title: '请选择正确的获取渠道', icon: 'none' })
}
// 4. 鉴定师 / 模式校验
if (!form.value.expertId) {
return uni.showToast({ title: '请选择鉴定师', icon: 'none' })
}
if (form.value.expertMode === '') {
return uni.showToast({ title: '请选择鉴定模式', icon: 'none' })
}
// 5. 组装 cdImgs 数组
const cdImgsArr = imgTypes
.filter(item => imgs[item.key])
.map(item => ({
required: item.required,
url: imgs[item.key],
urlName: item.urlName
}))
// 从本地取“鉴定者 id”
const identifyIdFromStorage = uni.getStorageSync('id') || '8297'
// 6. 组装最终参数
const params = {
cId: form.value.cId, // 分类 id(路由传入)
cdImgs: cdImgsArr,
cdPrice: '10', // 固定价格
cdVideos: [], // 视频先传空
dataDic: form.value.dataDic,
expertId: form.value.expertId,
expertMode: form.value.expertMode, // 1 单人,0 双人
identifyId: identifyIdFromStorage,
remark: form.value.remark,
windTime: form.value.windTime
}
uni.showLoading({ title: '提交中...' })
try {
const res = await insertOrder(params)
uni.hideLoading()
if (res.code === 200) {
uni.showToast({ title: '提交成功', icon: 'success' })
// 订单页是 tabBar 页面,需要 switchTab 跳转
setTimeout(() => {
uni.switchTab({
url: '/pages/dingdan/dingdan'
})
}, 500)
} else {
uni.showToast({ title: `提交失败:${res.message || ''}`, icon: 'none' })
}
} catch (e) {
uni.hideLoading()
console.log(e)
uni.showToast({ title: '请求异常', icon: 'none' })
}
}
七、整体结构回顾
整个图片上传 + 下单页面,可以分成三层:
-
API 层:
uploadFile、insertOrder等- 负责和后端交互,统一 URL / header / code 判断;
-
通用能力层:
useImageUpload- 负责整个上传流程;
- 页面只调用一个函数
shangchuanTupian(filePath);
-
业务页面层:
cateDetail.vue- 负责表单字段、UI 布局;
- 使用配置驱动 9 张图片与字段映射;
- 处理各种业务校验与最终提交。
这种结构的好处是:各司其职,上传逻辑与业务逻辑完全解耦,后面如果项目新增别的需要上传图片的页面,复用的成本非常低。
八、项目复盘:踩过的坑与收获
做完这个模块之后,有几个印象比较深的点:
-
小程序端不能直接用 axios 上传文件
微信小程序环境不支持 axios 的fetch/adapter,上传文件必须用uni.uploadFile;- 所以统一在 API 层封装
uploadFile,不要把小程序特性散落在业务页面。
-
composable 非常适合做“跨页面通用能力”
- 像图片上传、节流防抖、权限校验等,都可以往这方向抽一抽;
- 页面代码会变得更清爽,阅读时只需要关注业务主线。
-
配置化可以减少大量重复代码
- 9 张图片如果用 9 套模板 + 9 个字段手写,维护起来很痛苦;
- 用
imgTypes做配置,一处改名、增减图片位置非常方便。
-
错误处理一定要统一
- 上传失败 / 请求异常 / 校验不通过都要有明确的 toast 提示;
- 这样用户体验会好很多,自己调试也更轻松。
如果你也在用 UniApp + Vue3 做小程序项目,可以按这个思路,把所有零散的上传逻辑逐步收拢到 composable 和 API 层里,让页面只负责“业务编排”和“交互呈现”。
整个过程改完之后,项目的可维护性和心情都会好很多。 如果我的文章帮助到了你,可以给我点一个小小的赞,不胜感激