PDA 安卓设备上传多张图片

Vue3 + uni-app 混合项目

需求:浏览器和PDA一次提交记录接口兼容上传多张图片,浏览器是正常的,但是pda和安卓设备上上传多张图时,最后一张图覆盖掉了第一张图,最终只能上传一张。

javascript 复制代码
const submitUpload = async () => {
    if (fileList.value.length === 0) {
        uni.showToast({ title: '请选择图片', icon: 'none' })
        return
    }
    submitLoading.value = true

    try {
        const fd = new FormData();

        // ✅ 正确:追加真正的 File 对象
        fileList.value.forEach((item, index) => {
            if (item.file) {
                console.log(item)
                fd.append('fileImgList', item.file); // 只传 file!
            }
        });

        // 打印看看(现在一定是真正的 File)
        console.log("真实图片列表:", fd.getAll("fileImgList"));

        const res = await uni.request({
            url: `http://${getBaseUrl()}/api/FirstLevelRecord/Add`,
            method: "POST",
            header: {
                "Content-Type": "multipart/form-data",
                Authorization: uni.getStorageSync("token") || ""
            },
            data: fd,
        });

        uni.showToast({ title: "上传成功" });
        closeUpload();
        page.value = 1;
        noMore.value = false;
        getList();
    } catch (e) {
        console.error("失败", e);
        uni.showToast({ title: "上传失败", icon: "none" });
    } finally {
        submitLoading.value = false;
    }
};

尝试别的方法:各种报错

javascript 复制代码
const submitUpload = async () => {
    if (fileList.value.length === 0) {
        uni.showToast({ title: '请选择图片', icon: 'none' })
        return
    }
    submitLoading.value = true

    try {
        // 把所有图片转 base64
        const base64List = []

        for (const item of fileList.value) {
            const base64 = await new Promise((resolve) => {
                uni.getFileSystemManager().readFile({
                    filePath: item.path,
                    encoding: 'base64',
                    success: (res) => {
                        resolve('data:image/jpeg;base64,' + res.data)
                    }
                })
            })
            base64List.push(base64)
        }

        // 一次性发给后端!!!只发一次请求!!!
        const res = await uni.request({
            url: `http://${getBaseUrl()}/api/FirstLevelRecord/Add`,
            method: 'POST',
            header: {
                Authorization: uni.getStorageSync('token') || '',
                'Content-Type': 'application/json'
            },
            data: {
                images: base64List  // 图片base64数组
            }
        })

        uni.showToast({ title: '上传成功' })
        closeUpload()
        page.value = 1
        noMore.value = false
        getList()

    } catch (e) {
        console.error(e)
        uni.showToast({ title: '上传失败', icon: 'none' })
    } finally {
        submitLoading.value = false
    }
}

最终解决:安装插件市场的image-tools的pathToBase64

javascript 复制代码
<template>
    <nav-custom title="一级保养记录"></nav-custom>

    <!-- 上传按钮 -->
    <view class="top-btn">
        <button type="primary" @click="openUpload">上传一级保养记录</button>
    </view>

    <!-- 卡片列表 -->
    <view class="list-box">
        <uni-card :isFull="false" v-for="(item, index) in tableData" :key="index" class="card-item">
            <view class="card-content">
                <view class="row">点检日期:{{ item.inspectionDate }}</view>
                <view class="row">操作员:{{ item.operator }}</view>

                <view class="img-box" v-if="item.fileUrlList && item.fileUrlList.length">
                    <image v-for="(url, idx) in item.fileUrlList" :key="idx" :src="getImgUrl(url)" class="img"
                        mode="aspectFill" @click="previewImage(item.fileUrlList, idx)" />
                </view>
                <view v-else class="no-img">暂无图片</view>
            </view>
        </uni-card>

        <!-- 加载提示 -->
        <view class="loading" v-if="loading">
            加载中...
        </view>
        <view class="no-more" v-if="noMore">
            没有更多数据了
        </view>
    </view>

    <!-- 上传弹窗 -->
    <uni-popup ref="uploadPopup" type="center" :mask-click="true">
        <view class="popup-box">
            <view class="popup-title">上传保养记录</view>
            <view class="upload-box">
                <button type="default" @click="chooseImage">选择/拍照图片</button>
                <view class="preview-list" v-if="fileList.length">
                    <image v-for="(item, idx) in fileList" :key="idx" :src="item.path" class="preview-img"></image>
                </view>
            </view>
            <view class="popup-footer">
                <button @click="closeUpload" plain>取消</button>
                <button type="primary" :loading="submitLoading" @click="submitUpload">
                    提交
                </button>
            </view>
        </view>
    </uni-popup>
</template>

<script setup>
import { ref, reactive, onMounted } from 'vue'
import { onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
import {
    useProjectStore
} from '@/store'
import { getBaseUrl } from '@/api/request'
import CustomFormData from '@/common/FormData'
import { pathToBase64 } from '@/uni_modules/mmmm-image-tools/index.js'
const project = useProjectStore().project
const api = project.rest.otherStandAlone
const server = 'http://' + getBaseUrl() + ''
// 分页核心
const tableData = ref([])         // 列表数据
const loading = ref(false)        // 加载中
const noMore = ref(false)         // 没有更多
const page = ref(1)               // 当前页
const size = ref(10)               // 每页条数(PDA用10条最合适)

// 上传
const uploadPopup = ref(null)
const fileList = ref([])
const submitLoading = ref(false)

// 获取列表(支持分页)
const getList = async () => {
    if (noMore.value || loading.value) return
    loading.value = true

    try {
        const res = await api.getFirstLevelRecordList({
            page: page.value,
            size: size.value
        })

        const data = res.data || {}
        const items = data.items || []
        const total = data.totalElements || 0

        if (page.value === 1) {
            tableData.value = items
        } else {
            tableData.value = [...tableData.value, ...items]
        }

        if (items.length < size.value) {
            noMore.value = true
        }

    } catch (e) {
        uni.showToast({ title: '加载失败', icon: 'none' })
    } finally {
        loading.value = false
        // ✅ 关闭下拉刷新圈圈!!!
        uni.stopPullDownRefresh()
    }
}

// 上拉加载更多
onReachBottom(() => {
    if (noMore.value || loading.value) return
    page.value++
    getList()
})


// 下拉刷新
onPullDownRefresh(() => {
    // 重置为第一页
    page.value = 1
    noMore.value = false
    getList() // 刷新数据
})

// 初始化(第一页)
onMounted(() => {
    getList()
})

// 图片预览
const getImgUrl = (url) => server + url
const previewImage = (list, index) => {
    const urls = list.map(it => getImgUrl(it))
    uni.previewImage({ current: index, urls })
}

// 上传相关
const openUpload = () => uploadPopup.value.open()
const closeUpload = () => {
    uploadPopup.value.close()
    fileList.value = []
}

const onFileChange = (e) => fileList.value = e.tempFiles
const onFileRemove = (e) => fileList.value = e.tempFiles

const chooseImage = () => {
    uni.chooseImage({
        count: 5,           // 最多选5张
        sizeType: ['compressed'],
        sourceType: ['album', 'camera'], // ✅ 同时开启 相册 + 拍照
        success: (res) => {
            fileList.value = res.tempFiles.map(it => ({ path: it.path, file: it }))
        }
    })
}


const submitUpload = async () => {
    if (fileList.value.length === 0) {
        uni.showToast({ title: '请选择图片', icon: 'none' })
        return
    }

    submitLoading.value = true

    try {
        const form = new CustomFormData()
        const token = uni.getStorageSync('token') || ''

        // 循环所有图片 → 转 base64 → 转 ArrayBuffer
        for (let i = 0; i < fileList.value.length; i++) {
            const path = fileList.value[i].path

            // 1. 路径转 base64(插件)
            const base64Data = await pathToBase64(path)

            // 2. 去掉头,转 ArrayBuffer
            const base64 = base64Data.split(',')[1]
            const arrayBuffer = uni.base64ToArrayBuffer(base64)

            // 3. 加入表单(多张不会覆盖!)
            form.files.push({
                name: 'fileImgList',
                buffer: arrayBuffer,
                fileName: `maintain_${Date.now()}_${i}.jpg`
            })
        }

        // 发送请求
        const data = form.getData()
        const uploadRes = await uni.request({
            url: `http://${getBaseUrl()}/api/FirstLevelRecord/Add`,
            method: 'POST',
            data: data.buffer,
            header: {
                'Content-Type': data.contentType,
                'Authorization': token
            }
        })

        if (uploadRes.statusCode === 200) {
            uni.showToast({ title: '上传成功' })
            closeUpload()
            page.value = 1
            noMore.value = false
            getList()
        } else {
            uni.showToast({ title: '上传失败', icon: 'none' })
        }

    } catch (e) {
        console.error('上传失败', e)
        uni.showToast({ title: '上传失败', icon: 'none' })
    } finally {
        submitLoading.value = false
    }
}
</script>

<style lang="scss" scoped>
.top-btn {
    padding: 20rpx;
    text-align: right;
}

.list-box {
    padding: 0 20rpx;
}

.card-item {
    margin-bottom: 20rpx;
}

.card-content {
    .row {
        font-size: 28rpx;
        line-height: 44rpx;
    }

    .img-box {
        display: flex;
        flex-wrap: wrap;
        gap: 10rpx;
        margin-top: 10rpx;
    }

    .img {
        width: 100rpx;
        height: 100rpx;
        border-radius: 8rpx;
    }
}

.loading {
    text-align: center;
    padding: 20rpx;
    color: #666;
}

.no-more {
    text-align: center;
    padding: 20rpx;
    color: #999;
}

.popup-box {
    width: 600rpx;
    max-height: 70vh;
    overflow-y: auto;
    background: #fff;
    border-radius: 16rpx;
    padding: 30rpx;
}

.popup-title {
    font-size: 32rpx;
    font-weight: bold;
    text-align: center;
    margin-bottom: 30rpx;
}

.upload-box {
    margin: 20rpx 0;
}

.preview-list {
    display: flex;
    flex-wrap: wrap;
    gap: 10rpx;
    margin-top: 15rpx;
}

.preview-img {
    width: 100rpx;
    height: 100rpx;
    border-radius: 8rpx;
}

.popup-footer {
    display: flex;
    justify-content: flex-end;
    gap: 20rpx;
    margin-top: 30rpx;
}
</style>

FormData.ts

javascript 复制代码
import mimeMap from './mimeMap'

interface CommonFormData {
  fileManager: any
  data: any
  files: any

  append(name: string, value: any): boolean
  appendFile(name: string, path: any, fileName?: any): boolean
  getData(): any
}

class CustomFormData implements CommonFormData {
  fileManager: any
  data: any
  files: any

  constructor() {
    // this.fileManager = uni.getFileSystemManager()
     this.fileManager = null
    this.data = {}
    this.files = []
  }

  append(name: string, value: any) {
    this.data[name] = value
    return true
  }


async appendFile(name: string, path: string): Promise<void> {
  return new Promise((resolve, reject) => {
    // 全平台最兼容:XMLHttpRequest 老浏览器/PDA 100%支持
    const xhr = new XMLHttpRequest();
    xhr.open('GET', path);
    xhr.responseType = 'arraybuffer';

    xhr.onload = () => {
      const buffer = xhr.response;

      // 安全文件名(后端不报错)
      let fileName;
      if (path.startsWith('blob:')) {
        fileName = `image_${Date.now()}.jpg`;
      } else {
        fileName = path.split('/').pop() || `image_${Date.now()}`;
        if (!fileName.includes('.')) fileName += '.jpg';
      }

      this.files.push({ name, buffer, fileName });
      resolve();
    };

    xhr.onerror = reject;
    xhr.send();
  });
}
  getData() {
    return convert(this.data, this.files)
  }
}

function getFileNameFromPath(path: any) {
  let idx = path.lastIndexOf('/')
  return path.substr(idx + 1)
}

function convert(data: any, files: any) {
  let boundaryKey = 'unimpFormBoundary' + randString() // 数据分割符,一般是随机的字符串
  let boundary = '--' + boundaryKey
  let endBoundary = boundary + '--'

  let postArray: any = []
  //拼接参数
  if (data && Object.prototype.toString.call(data) == '[object Object]') {
    for (let key in data) {
      postArray = postArray.concat(formDataArray(boundary, key, data[key]))
    }
  }
  //拼接文件
  if (files && Object.prototype.toString.call(files) == '[object Array]') {
    for (let i in files) {
      let file = files[i]
      postArray = postArray.concat(formDataArray(boundary, file.name, file.buffer, file.fileName))
    }
  }

  //结尾
  let endBoundaryArray = []
  endBoundaryArray.push(...endBoundary.toUtf8Bytes())
  postArray = postArray.concat(endBoundaryArray)

  return {
    contentType: 'multipart/form-data; boundary=' + boundaryKey,
    buffer: new Uint8Array(postArray).buffer,
  }
}

// 获取随机字符串
function randString() {
  var result = ''
  var chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
  for (var i = 17; i > 0; --i) result += chars[Math.floor(Math.random() * chars.length)]
  return result
}

function formDataArray(boundary: any, name: string, value: any, fileName?: string) {
  let dataString: any = ''
  let isFile = !!fileName

  dataString += boundary + '\r\n'
  dataString += 'Content-Disposition: form-data; name="' + name + '"'
  if (isFile) {
    dataString += '; filename="' + fileName + '"' + '\r\n'
    dataString += 'Content-Type: ' + getFileMime(fileName || '') + '\r\n\r\n'
  } else {
    dataString += '\r\n\r\n'
    dataString += value
  }

  var dataArray = []
  dataArray.push(...dataString.toUtf8Bytes())

  if (isFile) {
    let fileArray = new Uint8Array(value)
    dataArray = dataArray.concat(Array.prototype.slice.call(fileArray))
  }
  dataArray.push(...'\r'.toUtf8Bytes())
  dataArray.push(...'\n'.toUtf8Bytes())

  return dataArray
}

function getFileMime(fileName: string) {
  let idx = fileName.lastIndexOf('.')
  let index: string = fileName.substr(idx)
  let mime = mimeMap[index]
  return mime ? mime : 'application/octet-stream'
}

function stringToUtf8(string: string) {
  let encoder = new TextEncoder()
  return encoder.encode(string)
}

String.prototype.toUtf8Bytes = function () {
  var str: any = this
  var bytes = []
  for (var i = 0; i < str.length; i++) {
    bytes.push(...str.utf8CodeAt(i))
    if (str.codePointAt(i) > 0xffff) {
      i++
    }
  }
  return bytes
}

String.prototype.utf8CodeAt = function (i: any) {
  var str = this
  var out = [],
    p = 0
  var c = str.charCodeAt(i)
  if (c < 128) {
    out[p++] = c
  } else if (c < 2048) {
    out[p++] = (c >> 6) | 192
    out[p++] = (c & 63) | 128
  } else if ((c & 0xfc00) == 0xd800 && i + 1 < str.length && (str.charCodeAt(i + 1) & 0xfc00) == 0xdc00) {
    // Surrogate Pair
    c = 0x10000 + ((c & 0x03ff) << 10) + (str.charCodeAt(++i) & 0x03ff)
    out[p++] = (c >> 18) | 240
    out[p++] = ((c >> 12) & 63) | 128
    out[p++] = ((c >> 6) & 63) | 128
    out[p++] = (c & 63) | 128
  } else {
    out[p++] = (c >> 12) | 224
    out[p++] = ((c >> 6) & 63) | 128
    out[p++] = (c & 63) | 128
  }
  return out
}

export default CustomFormData

mimeMap.ts

javascript 复制代码
const mimeMap: Record<string, string> = {
  ".jpg": "image/jpeg",
  ".jpeg": "image/jpeg",
  ".png": "image/png",
  ".gif": "image/gif",
  ".bmp": "image/bmp"
};

export default mimeMap;
相关推荐
zb200641203 小时前
Laravel6.x新特性全解析
android
掰头战士3 小时前
深入了解JS原型及原型继承链机制
javascript
贵州数擎科技有限公司3 小时前
霓虹沙尘暴的 Three.js 实现
前端·webgl
一只叁木Meow3 小时前
电商 SKU 选择器:用算法实现优雅的用户交互
前端·javascript·算法
plainGeekDev3 小时前
Kotlin核心:空安全都搞不明白,还敢说熟练Kotlin?
android·面试·kotlin
笔优站长3 小时前
vue-sign-canvas v2 重构复盘:从 Vue 2 签名板到 Vue 3 + TypeScript 组件库
前端·vue.js
Aolith3 小时前
事件驱动设计:我如何为校园论坛实现消息通知功能
前端·vue.js
代码煮茶3 小时前
Vue3 Mock 数据实战 | 用 Mockjs + vite-plugin-mock 搭建前端独立开发环境
javascript·vue.js
JieE2123 小时前
反转链表:从双指针到递归,吃透链表反转的核心逻辑
javascript·算法