feat:MinIO图片上传-创建utils/minio.js(AWS V4签名PUT直传),doSubmit上传图片到MinIO并记录URL

This commit is contained in:
97694732@qq.com 2026-06-05 11:05:54 +08:00
parent 50e7fe7183
commit da500d1681
4 changed files with 379 additions and 9 deletions

9
package-lock.json generated
View File

@ -5,7 +5,8 @@
"packages": {
"": {
"dependencies": {
"blueimp-md5": "^2.19.0"
"blueimp-md5": "^2.19.0",
"js-sha256": "^0.11.1"
}
},
"node_modules/blueimp-md5": {
@ -13,6 +14,12 @@
"resolved": "https://registry.npmjs.org/blueimp-md5/-/blueimp-md5-2.19.0.tgz",
"integrity": "sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==",
"license": "MIT"
},
"node_modules/js-sha256": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.11.1.tgz",
"integrity": "sha512-o6WSo/LUvY2uC4j7mO50a2ms7E/EAdbP0swigLV+nzHKTTaYnaLIWJ02VdXrsJX0vGedDESQnLsOekr94ryfjg==",
"license": "MIT"
}
}
}

View File

@ -1,5 +1,6 @@
{
"dependencies": {
"blueimp-md5": "^2.19.0"
"blueimp-md5": "^2.19.0",
"js-sha256": "^0.11.1"
}
}

View File

@ -826,6 +826,7 @@
<script>
import { getWarehouseList, getLocationList, searchBookByIsbn } from '@/utils/api.js'
import { login as kongfzLogin, searchProducts, searchFacet } from '@/utils/kongfz.js'
import { uploadImages } from '@/utils/minio.js'
export default {
data() {
@ -1949,19 +1950,101 @@ export default {
title: '确认上传',
content: contentLines.join('\n'),
showCancel: false,
confirmText: '确定'
confirmText: '确定',
success: () => {
this.doSubmit(warehouseData)
}
})
},
doSubmit(warehouseData) {
async doSubmit(warehouseData) {
this.isSubmitting = true
uni.showLoading({ title: '上传中...' })
setTimeout(() => {
uni.showLoading({ title: '上传中...', mask: true })
try {
//
const photoList = this.currentTab === 'isbn' ? this.photoList : this.noIsbnPhotoList
const typeDir = this.currentTab === 'isbn' ? 'Isbn' : 'NoIsbn'
// MinIO
const imageUrls = await uploadImages(photoList, typeDir)
console.log('【上传】MinIO图片URL列表:', imageUrls)
uni.hideLoading()
//
const formData = {
token: uni.getStorageSync('token'),
userId: uni.getStorageSync('userId'),
warehouseId: warehouseData.warehouseId,
warehouseCode: warehouseData.warehouseCode,
locationId: warehouseData.locationId,
locationCode: warehouseData.locationCode,
conditionValue: this.currentTab === 'isbn' ? this.conditionValue : this.noIsbnConditionValue,
images: imageUrls
}
if (this.currentTab === 'isbn') {
formData.isbn = this.isbn
formData.bookName = this.bookName
formData.price = this.price
formData.stock = this.stock
formData.author = this.author
formData.publisher = this.publisher
formData.fixPrice = this.fixPrice
formData.printTime = this.printTime
} else {
formData.bookName = this.noIsbnBookName
formData.price = this.noIsbnPrice
formData.stock = this.noIsbnStock
formData.author = this.noIsbnAuthor
formData.publisher = this.noIsbnPublisher
formData.originalPrice = this.noIsbnOriginalPrice
formData.isbn = this.noIsbnIsbn || this.noIsbnUnifyIsbn
formData.printTime = this.noIsbnPrintTime
}
//
const uploadHistory = uni.getStorageSync('uploadHistory') || []
uploadHistory.unshift({
id: Date.now(),
time: new Date().toLocaleString(),
type: this.currentTab,
data: formData
})
uni.setStorageSync('uploadHistory', uploadHistory.slice(0, 100))
uni.showToast({ title: '上传成功,共' + imageUrls.length + '张图片', icon: 'success' })
//
if (this.currentTab === 'isbn') {
this.photoList = []
this.isbn = ''
this.bookName = ''
this.price = ''
this.stock = ''
this.author = ''
this.publisher = ''
this.fixPrice = ''
this.printTime = ''
} else {
this.noIsbnPhotoList = []
this.noIsbnBookName = ''
this.noIsbnPrice = ''
this.noIsbnStock = ''
this.noIsbnAuthor = ''
this.noIsbnPublisher = ''
this.noIsbnOriginalPrice = ''
this.noIsbnIsbn = ''
this.noIsbnUnifyIsbn = ''
this.noIsbnPrintTime = ''
}
} catch (e) {
uni.hideLoading()
console.error('【上传】失败:', e)
uni.showToast({ title: '上传失败: ' + (e.message || '未知错误'), icon: 'none', duration: 3000 })
} finally {
this.isSubmitting = false
uni.showToast({ title: '上传成功', icon: 'success' })
// API
}, 1500)
}
},
// ISBN - 使ISBNkongfz

279
utils/minio.js Normal file
View File

@ -0,0 +1,279 @@
/**
* MinIO 文件上传工具
* 使用 AWS Signature V4 签名通过 PUT 直传到 MinIO
*/
import sha256 from 'js-sha256'
// ====== MinIO 配置 ======
const CFG = {
endpoint: 'shxy.image.yushutx.com',
accessKey: 'minioadmin',
secretKey: 'minioadmin',
bucket: 'scan-book',
region: 'us-east-1',
protocol: 'https'
}
// ====== 工具函数 ======
function pad(n) {
return n < 10 ? '0' + n : '' + n
}
function hexToBytes(hex) {
const bytes = []
for (let i = 0; i < hex.length; i += 2) {
bytes.push(parseInt(hex.substring(i, i + 2), 16))
}
return new Uint8Array(bytes)
}
function bytesToHex(bytes) {
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
}
/**
* HMAC-SHA256 返回 Uint8Array用于签名链
*/
function hmacRaw(key, msg) {
const hexResult = sha256.hmac(key, msg)
return hexToBytes(hexResult)
}
/**
* 生成 AWS V4 签名密钥链
*/
function getSignatureKey(secretKey, dateStamp, region, service) {
let k = hmacRaw('AWS4' + secretKey, dateStamp)
k = hmacRaw(k, region)
k = hmacRaw(k, service)
return hmacRaw(k, 'aws4_request')
}
function sha256Hex(str) {
return sha256(str)
}
// ====== AWS V4 签名 ======
/**
* PUT 请求构建 Authorization 签名头
*/
function buildAuthHeader(objectKey, date) {
const dateStr = date.getFullYear() + pad(date.getMonth() + 1) + pad(date.getDate())
const amzDate = dateStr + 'T' + pad(date.getHours()) + pad(date.getMinutes()) + pad(date.getSeconds()) + 'Z'
const host = CFG.endpoint
const canonicalUri = '/' + CFG.bucket + '/' + objectKey
const signedHeadersMap = {
host: host,
'x-amz-content-sha256': 'UNSIGNED-PAYLOAD',
'x-amz-date': amzDate
}
const signedHeadersList = Object.keys(signedHeadersMap).sort()
const canonicalHeaders = signedHeadersList.map(k => k + ':' + signedHeadersMap[k] + '\n').join('')
const signedHeadersStr = signedHeadersList.join(';')
const canonicalRequest = [
'PUT',
canonicalUri,
'',
canonicalHeaders,
signedHeadersStr,
'UNSIGNED-PAYLOAD'
].join('\n')
const credentialScope = [dateStr, CFG.region, 's3', 'aws4_request'].join('/')
const stringToSign = [
'AWS4-HMAC-SHA256',
amzDate,
credentialScope,
sha256Hex(canonicalRequest)
].join('\n')
const signingKey = getSignatureKey(CFG.secretKey, dateStr, CFG.region, 's3')
const signature = bytesToHex(hmacRaw(signingKey, stringToSign))
const authHeader = 'AWS4-HMAC-SHA256 Credential=' + CFG.accessKey + '/' + credentialScope +
', SignedHeaders=' + signedHeadersStr + ', Signature=' + signature
return { authHeader, amzDate, host }
}
// ====== 文件读取(兼容 uniapp 多端) ======
/**
* 读取本地图片为 base64
*/
function readFileAsBase64(filePath) {
return new Promise((resolve, reject) => {
// 优先使用 plus.ioHBuilder 原生 App
if (typeof plus !== 'undefined' && plus.io) {
plus.io.readFile(filePath, { encoding: 'base64' }, function (data) {
resolve(data)
}, function (err) {
// 如果 plus.io 失败,回退到 XHR
console.warn('plus.io.readFile 失败,回退到 XHR:', JSON.stringify(err))
readViaXHR(filePath, resolve, reject)
})
} else {
readViaXHR(filePath, resolve, reject)
}
})
}
/**
* 通过 XMLHttpRequest 读取本地文件H5 / 开发环境
*/
function readViaXHR(filePath, resolve, reject) {
try {
const xhr = new XMLHttpRequest()
xhr.open('GET', filePath, true)
xhr.responseType = 'blob'
xhr.onload = function () {
const reader = new FileReader()
reader.onload = function () {
const base64 = reader.result.split(',')[1] || ''
resolve(base64)
}
reader.onerror = function () {
reject(new Error('FileReader 读取失败'))
}
reader.readAsDataURL(xhr.response)
}
xhr.onerror = function () {
reject(new Error('XHR 读取文件失败'))
}
xhr.send()
} catch (e) {
reject(new Error('读取文件异常: ' + e.message))
}
}
/**
* Base64 ArrayBuffer
*/
function base64ToArrayBuffer(base64) {
const binaryStr = atob(base64)
const bytes = new Uint8Array(binaryStr.length)
for (let i = 0; i < binaryStr.length; i++) {
bytes[i] = binaryStr.charCodeAt(i)
}
return bytes.buffer
}
/**
* 生成 UUID
*/
function generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = Math.random() * 16 | 0
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16)
})
}
/**
* 获取文件扩展名不含点
*/
function getFileExt(filePath) {
const m = filePath.match(/\.(\w+)$/)
return m ? m[1].toLowerCase() : 'jpg'
}
/**
* 根据扩展名获取 Content-Type
*/
function getContentType(ext) {
const map = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
bmp: 'image/bmp'
}
return map[ext] || 'image/jpeg'
}
// ====== 主上传接口 ======
/**
* 上传单张图片到 MinIO
* @param {string} filePath - 本地文件路径uni.chooseImage 返回的 tempFilePath
* @param {string} typeDir - 目录名默认 'Isbn'
* @returns {Promise<string>} 可公开访问的图片 URL
*/
export function uploadImage(filePath, typeDir = 'Isbn') {
return new Promise(function (resolve, reject) {
readFileAsBase64(filePath)
.then(function (base64) {
const arrayBuffer = base64ToArrayBuffer(base64)
// 构建对象路径:年-月-日/Isbn/uuid.ext
const now = new Date()
const datePath = now.getFullYear() + '-' + pad(now.getMonth() + 1) + '-' + pad(now.getDate())
const ext = getFileExt(filePath)
const objectKey = datePath + '/' + typeDir + '/' + generateUUID() + '.' + ext
const contentType = getContentType(ext)
// 构建 AWS V4 签名
const { authHeader, amzDate, host } = buildAuthHeader(objectKey, now)
// PUT URL
const url = CFG.protocol + '://' + host + '/' + CFG.bucket + '/' + objectKey
console.log('【MinIO上传】URL:', url)
console.log('【MinIO上传】contentType:', contentType)
console.log('【MinIO上传】contentLength:', arrayBuffer.byteLength)
// 通过 XMLHttpRequest 发送 PUT
const xhr = new XMLHttpRequest()
xhr.open('PUT', url, true)
xhr.setRequestHeader('Authorization', authHeader)
xhr.setRequestHeader('x-amz-content-sha256', 'UNSIGNED-PAYLOAD')
xhr.setRequestHeader('x-amz-date', amzDate)
xhr.setRequestHeader('Content-Type', contentType)
xhr.onload = function () {
if (xhr.status === 200) {
console.log('【MinIO上传】成功:', url)
resolve(url)
} else {
console.error('【MinIO上传】失败, HTTP:', xhr.status, xhr.responseText)
reject(new Error('上传失败: HTTP ' + xhr.status))
}
}
xhr.onerror = function () {
console.error('【MinIO上传】网络错误')
reject(new Error('上传网络错误'))
}
xhr.ontimeout = function () {
reject(new Error('上传超时'))
}
xhr.timeout = 120000
xhr.send(arrayBuffer)
})
.catch(function (err) {
reject(err)
})
})
}
/**
* 批量上传多张图片到 MinIO
* @param {string[]} filePaths - 本地文件路径数组
* @param {string} typeDir - 目录名
* @returns {Promise<string[]>} 图片 URL 数组
*/
export function uploadImages(filePaths, typeDir = 'Isbn') {
const tasks = filePaths.map(function (fp) {
return uploadImage(fp, typeDir)
})
return Promise.all(tasks)
}
export default { uploadImage, uploadImages }