小程序双模式(文件 / 照片)上传组件封装与解析

在小程序业务开发中,上传功能是高频场景,而部分业务需要同时支持文件上传 (如 PDF、文档)和照片上传(如图片凭证)两种模式。

基于完整的业务代码 ,从 WXML 结构、JS 逻辑、WXSS 样式三个维度,深度解析一个支持双模式切换、带上传 / 预览 / 删除、容错处理完善的小程序上传组件。该组件直接复用即可落地,适配绝大多数表单类业务(如用印申请、资料提交等)。

通过wx:if根据uploadMode切换文件 / 照片上传 UI,同时实现切换按钮、上传区域、删除 / 预览功能。

js模式切换、文件上传、照片上传、删除 / 预览、异常处理全流程逻辑,代码注释详细,直接复用。

wxml 复制代码
<view class="seal_wrapper">
  <view class="items_wrap">
    <!-- 标题栏:带必填标识 -->
    <view class="items_titles">
      <text>*</text>用印文件原件
    </view>
    <!-- 模式切换开关:文件/照片 -->
    <view class="upload-switch">
      <view class="upload-switch-item {{uploadMode === 'file' ? 'active' : ''}}" bind:tap="setUploadMode" data-mode="file">文件</view>
      <view class="upload-switch-item {{uploadMode === 'image' ? 'active' : ''}}" bind:tap="setUploadMode" data-mode="image">照片</view>
    </view>
    <!-- 上传内容区域:根据模式渲染 -->
    <view class="items_input">
      <!-- 文件上传模式 -->
      <view class="file-uploader" wx:if="{{uploadMode === 'file'}}">
        <!-- 已上传文件:显示文件名+删除按钮 -->
        <view class="file-item" wx:if="{{fileUrl}}">
          <view class="file-name">{{fileName || '已上传文件'}}</view>
          <image class="file-del" src="/img/close.png" mode="aspectFit" bind:tap="removeFile" />
        </view>
        <!-- 未上传文件:显示上传入口 -->
        <view class="file-add" bind:tap="chooseFile" wx:if="{{!fileUrl}}">
          <image class="file-add-icon" src="/img/add.png" mode="aspectFit"/>
          <view class="file-add-text">上传文件</view>
        </view>
      </view>

      <!-- 照片上传模式 -->
      <view class="images-grid" wx:if="{{uploadMode === 'image'}}">
        <!-- 已上传照片:显示图片+删除+预览 -->
        <view class="img-item" wx:if="{{images && images.length}}">
          <image class="img" src="{{images[0]}}" mode="aspectFit" bind:tap="previewImage" data-idx="0"/>
          <image class="img-del" src="/img/close.png" mode="aspectFit" bind:tap="removeImage" data-idx="0"/>
        </view>
        <!-- 未上传照片:显示上传入口 -->
        <view class="img-add" bind:tap="chooseImages">
          <image class="img-add-icon" src="/img/add.png" mode="aspectFit"/>
        </view>
      </view>
    </view>
  </view>
</view>
js 复制代码
Page({
  data: {
    // 默认选中文件上传模式
    uploadMode: 'file',
    fileUrl: '', // 已上传文件的服务端链接
    fileName: '', // 已上传文件名称
    images: [] // 已上传照片链接数组
  },

  /**
   * 切换上传模式(文件/照片)
   */
  setUploadMode(e) {
    const mode = e?.currentTarget?.dataset?.mode || ''
    // 仅允许切换到合法模式
    if (mode !== 'file' && mode !== 'image') return
    this.setData({ uploadMode: mode })
  },

  /**
   * 删除已上传文件
   */
  removeFile() {
    this.setData({
      fileUrl: '',
      fileName: ''
    })
  },

  /**
   * 选择并上传文件(文档)
   */
  chooseFile() {
    wx.chooseMessageFile({
      count: 1, // 单次上传1个文件
      type: 'file', // 类型为文件
      success: (res) => {
        const files = res?.tempFiles || []
        const file = files[0] || null
        if (!file?.path) return

        // 保存文件名称
        this.setData({ fileName: file.name || '' })

        // 上传中loading
        wx.showLoading({ title: '上传中...' })
        // 调用上传接口
        wx.uploadFile({
          filePath: file.path,
          name: 'file', // 服务端接收的文件字段名
          url: api.ImgUpload(), // 替换为你的图片上传接口
          formData: {
            // 附加参数:用户身份标识
            OpenID: wx.getStorageSync('openId'),
            MID: wx.getStorageSync('mid'),
          },
          success: (resu) => {
            // 解析服务端返回(捕获JSON解析异常)
            let data = null
            try {
              data = JSON.parse(resu.data)
            } catch (e) {
              data = null
            }
            // 上传成功:保存文件链接
            if (data && data.code === 200 && data.data) {
              this.setData({ fileUrl: data.data })
              return
            }
            // 上传失败:提示错误信息
            wx.showToast({
              title: data?.msg || '上传失败',
              icon: 'none'
            })
          },
          fail: (err) => {
            console.error('文件上传失败:', err)
            wx.showToast({ title: '上传失败', icon: 'none' })
          },
          complete: () => {
            wx.hideLoading() // 无论成功失败,关闭loading
          }
        })
      },
      fail: (err) => {
        console.error('选择文件失败:', err)
      }
    })
  },

  /**
   * 预览照片
   */
  previewImage(e) {
    const idx = e?.currentTarget?.dataset?.idx || 0
    const urls = this.data.images || []
    if (!urls.length) return
    // 调用小程序原生预览API
    wx.previewImage({
      urls,
      current: urls[idx] || urls[0]
    })
  },

  /**
   * 删除已上传照片
   */
  removeImage(e) {
    const idx = e?.currentTarget?.dataset?.idx || 0
    const urls = [...(this.data.images || [])] // 浅拷贝避免直接修改原数据
    if (!urls.length) return
    urls.splice(idx, 1) // 删除对应索引的图片
    this.setData({ images: urls })
  },

  /**
   * 选择并上传照片
   */
  chooseImages() {
    wx.chooseMedia({
      count: 1, // 单次上传1张照片
      mediaType: ['image'], // 仅选择图片
      sourceType: ['album', 'camera'], // 支持相册/相机
      success: (res) => {
        const files = res?.tempFiles || []
        const tempFilePath = files[0]?.tempFilePath || ''
        if (!tempFilePath) return

        wx.showLoading({ title: '上传中...' })
        wx.uploadFile({
          filePath: tempFilePath,
          name: 'file',
          url: api.ImgUpload(), // 替换为你的图片上传接口
          formData: {
            OpenID: wx.getStorageSync('openId'),
            MID: wx.getStorageSync('mid'),
          },
          success: (resu) => {
            let data = null
            try {
              data = JSON.parse(resu.data)
            } catch (e) {
              data = null
            }
            if (data && data.code === 200 && data.data) {
              // 照片仅支持单张,直接覆盖数组
              this.setData({ images: [data.data] })
              return
            }
            wx.showToast({
              title: data?.msg || '上传失败',
              icon: 'none'
            })
          },
          fail: (err) => {
            console.error('照片上传失败:', err)
            wx.showToast({ title: '上传失败', icon: 'none' })
          },
          complete: () => {
            wx.hideLoading()
          }
        })
      },
      fail: (err) => {
        console.error('选择照片失败:', err)
      }
    })
  }
})
css 复制代码
/* 外层容器:避免样式污染 */
.seal_wrapper .items_wrap {
  width: 100%;
  padding: 30rpx 0;
  border-bottom: 1rpx solid #f0f0f0;
}
/* 最后一项去掉下边框 */
.seal_wrapper .items_wrap:last-child {
  border-bottom: none;
}
/* 标题样式 */
.seal_wrapper .items_titles {
  font-size: 32rpx;
  margin-bottom: 20rpx;
  color: #222;
}
/* 必填红色星号 */
.seal_wrapper .items_titles text {
  color: #ff4d4f;
  margin-right: 8rpx;
}
/* 输入/上传区域 */
.seal_wrapper .items_input {
  width: 100%;
}
/* 模式切换容器 */
.seal_wrapper .upload-switch{
  display: flex;
  gap: 16rpx;
  margin: 8rpx 0 16rpx;
}
/* 切换项样式 */
.seal_wrapper .upload-switch-item{
  padding: 10rpx 26rpx;
  border-radius: 999rpx;
  background: #f3f4f6;
  color: #666;
  font-size: 26rpx;
  line-height: 1.4;
}
/* 选中态样式 */
.seal_wrapper .upload-switch-item.active{
  background: #e8f1ff;
  color: #2f7cff;
  font-weight: 700;
}
/* 文件上传容器 */
.seal_wrapper .file-uploader{
  width: 100%;
}
/* 未上传文件:虚线边框+居中 */
.seal_wrapper .file-add{
  width: 100%;
  height: 96rpx;
  border-radius: 16rpx;
  border: 2rpx dashed #d8d8d8;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 14rpx;
}
/* 上传图标 */
.seal_wrapper .file-add-icon{
  width: 44rpx;
  height: 44rpx;
  opacity: 0.7;
}
/* 上传文字 */
.seal_wrapper .file-add-text{
  font-size: 28rpx;
  color: #666;
}
/* 已上传文件:背景色+弹性布局 */
.seal_wrapper .file-item{
  width: 100%;
  min-height: 96rpx;
  border-radius: 16rpx;
  background: #f9fafb;
  padding: 18rpx;
  box-sizing: border-box;
  display: flex;
  align-items: center;
  justify-content: space-between;
}
/* 文件名:超出省略 */
.seal_wrapper .file-name{
  flex: 1;
  min-width: 0;
  font-size: 28rpx;
  color: #333;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  padding-right: 16rpx;
}
/* 删除按钮 */
.seal_wrapper .file-del{
  width: 36rpx;
  height: 36rpx;
  background: rgba(0,0,0,0.3);
  border-radius: 18rpx;
  padding: 4rpx;
  box-sizing: border-box;
}
/* 照片网格布局 */
.seal_wrapper .images-grid{
  display: flex;
  flex-wrap: wrap;
  gap: 18rpx;
}
/* 已上传照片容器 */
.seal_wrapper .img-item{
  width: 160rpx;
  height: 160rpx;
  border-radius: 16rpx;
  overflow: hidden;
  position: relative;
}
/* 照片 */
.seal_wrapper .img{
  width: 100%;
  height: 100%;
}
/* 照片删除按钮 */
.seal_wrapper .img-del{
  position: absolute;
  top: 6rpx;
  right: 6rpx;
  width: 36rpx;
  height: 36rpx;
  background: rgba(0,0,0,0.3);
  border-radius: 18rpx;
  padding: 4rpx;
  box-sizing: border-box;
}
/* 未上传照片:虚线边框+居中 */
.seal_wrapper .img-add{
  width: 160rpx;
  height: 160rpx;
  border-radius: 16rpx;
  border: 2rpx dashed #d8d8d8;
  display: flex;
  align-items: center;
  justify-content: center;
}
/* 上传图标 */
.seal_wrapper .img-add-icon{
  width: 54rpx;
  height: 54rpx;
  opacity: 0.7;
}
相关推荐
jay神5 小时前
基于 YOLOv8 的轴承缺陷检测(Web 版)
前端·人工智能·yolo
jay神5 小时前
基于 YOLOv8 的森林火灾检测系统(Web 版)
前端·yolo
code2cat5 小时前
HTML基础篇之第一张网页:从index.html到浏览器里的任务清单
前端·html
miss6 小时前
AI 改完不敢上线?OpenSpec 规约驱动开发实战
前端·代码规范·前端工程化
AiMan7 小时前
腾讯ima还是不可以导出左侧所有对话,只能单条导出,方法不对吗
前端
去伪存真7 小时前
并发量一高就装死?Agent 全链路“防猝死”手册
前端·agent
Moment7 小时前
AI 编程时代,聊聊我的 Mac mini 双屏桌面
前端·后端
AiMan7 小时前
先确认能不能一次把Microsofat Copilot所有内容打包一个文件导出
前端
总要学点什么7 小时前
第97篇:三维数据加载-城市建筑白模自定义着色器——让一条光带沿高度扫上去,快慢却由帧率说了算
前端·gis·webgl·cesium·着色器·空间分析·白模
上海懒图科技8 小时前
工厂厂区电子地图定位导航:厂区人车精准定位,安全合规双保障
前端·arcgis·vue