feat:MinIO图片上传-创建utils/minio.js(AWS V4签名PUT直传),doSubmit上传图片到MinIO并记录URL
This commit is contained in:
parent
50e7fe7183
commit
da500d1681
9
package-lock.json
generated
9
package-lock.json
generated
@ -5,7 +5,8 @@
|
|||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"blueimp-md5": "^2.19.0"
|
"blueimp-md5": "^2.19.0",
|
||||||
|
"js-sha256": "^0.11.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/blueimp-md5": {
|
"node_modules/blueimp-md5": {
|
||||||
@ -13,6 +14,12 @@
|
|||||||
"resolved": "https://registry.npmjs.org/blueimp-md5/-/blueimp-md5-2.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/blueimp-md5/-/blueimp-md5-2.19.0.tgz",
|
||||||
"integrity": "sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==",
|
"integrity": "sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w==",
|
||||||
"license": "MIT"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"blueimp-md5": "^2.19.0"
|
"blueimp-md5": "^2.19.0",
|
||||||
|
"js-sha256": "^0.11.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -826,6 +826,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { getWarehouseList, getLocationList, searchBookByIsbn } from '@/utils/api.js'
|
import { getWarehouseList, getLocationList, searchBookByIsbn } from '@/utils/api.js'
|
||||||
import { login as kongfzLogin, searchProducts, searchFacet } from '@/utils/kongfz.js'
|
import { login as kongfzLogin, searchProducts, searchFacet } from '@/utils/kongfz.js'
|
||||||
|
import { uploadImages } from '@/utils/minio.js'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
@ -1949,19 +1950,101 @@ export default {
|
|||||||
title: '确认上传',
|
title: '确认上传',
|
||||||
content: contentLines.join('\n'),
|
content: contentLines.join('\n'),
|
||||||
showCancel: false,
|
showCancel: false,
|
||||||
confirmText: '确定'
|
confirmText: '确定',
|
||||||
|
success: () => {
|
||||||
|
this.doSubmit(warehouseData)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
doSubmit(warehouseData) {
|
async doSubmit(warehouseData) {
|
||||||
this.isSubmitting = true
|
this.isSubmitting = true
|
||||||
uni.showLoading({ title: '上传中...' })
|
uni.showLoading({ title: '上传中...', mask: true })
|
||||||
setTimeout(() => {
|
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()
|
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
|
this.isSubmitting = false
|
||||||
uni.showToast({ title: '上传成功', icon: 'success' })
|
}
|
||||||
// 清空表单待后续对接真实API
|
|
||||||
}, 1500)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// 无ISBN - 书名搜索(使用与ISBN相同的kongfz接口)
|
// 无ISBN - 书名搜索(使用与ISBN相同的kongfz接口)
|
||||||
|
|||||||
279
utils/minio.js
Normal file
279
utils/minio.js
Normal 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.io(HBuilder 原生 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 }
|
||||||
Loading…
Reference in New Issue
Block a user