/** * 统一文件上传:七牛云客户端直传 * 流程:计算 MD5 + 后缀 → 向后端取凭证 → uni.uploadFile 直传七牛 → 返回完整 CDN URL */ import { toImageUrl } from '@/request/request.js'; import { getQiniuUploadToken } from '@/request/api.js'; import { addTimestampWatermark } from '@/utils/watermark.js'; const DEFAULT_QINIU_UPLOAD_URL = 'https://upload.qiniup.com'; /** 从本地临时路径解析后缀(不含点) */ export function getFileSuffix(filePath) { if (!filePath) return ''; const clean = String(filePath).split('?')[0]; const ext = clean.split('.').pop()?.toLowerCase() || ''; // 微信部分临时文件无后缀,按图片处理 if (!ext || ext.length > 8 || clean.endsWith(ext) === false) { return 'jpg'; } return ext; } /** 计算文件 MD5(微信小程序支持;其他端回退为空字符串,需后端兼容) */ export function getFileMd5(filePath) { return new Promise((resolve) => { // #ifdef MP-WEIXIN uni.getFileInfo({ filePath, digestAlgorithm: 'md5', success: (res) => resolve((res.digest || '').toLowerCase()), fail: (err) => { console.warn('getFileMd5 fail, use empty:', err); resolve(''); } }); // #endif // #ifndef MP-WEIXIN resolve(''); // #endif }); } /** 向后端获取七牛直传凭证 */ export async function fetchQiniuUploadCredential(filePath) { const suffix = getFileSuffix(filePath); const fileMd5 = await getFileMd5(filePath); const res = await getQiniuUploadToken({ fileMd5, suffix }); return normalizeQiniuCredential(res.data); } /** * 解析后端 /frontend/attachment/qiniu/token 返回的 data * 当前约定:token、key、uploadUrl、url(完整 CDN 地址) * 例:uploadUrl=https://upload-z2.qiniup.com,url=https://oss.hexieyun.com.cn/uploads/... */ function normalizeQiniuCredential(raw) { if (!raw) { throw new Error('七牛凭证为空'); } const token = raw.token || raw.uploadToken || raw.uptoken; const key = raw.key || raw.fileKey || raw.objectKey; const uploadUrl = (raw.uploadUrl || raw.uploadHost || raw.host || DEFAULT_QINIU_UPLOAD_URL).replace(/\/$/, ''); const presetUrl = String(raw.url || raw.fileUrl || raw.fullUrl || '').trim(); let cdnOrigin = (raw.domain || raw.cdnDomain || '').replace(/\/$/, ''); if (!cdnOrigin && presetUrl) { const m = presetUrl.match(/^(https?:\/\/[^/]+)/i); if (m) cdnOrigin = m[1]; } if (cdnOrigin && !cdnOrigin.startsWith('http')) { cdnOrigin = `https://${cdnOrigin}`; } if (!token || !key) { throw new Error('七牛凭证缺少 token 或 key'); } return { token, key, uploadUrl, cdnOrigin, presetUrl }; } /** 七牛 upload 成功后的 JSON:{ hash, key },结合凭证得到最终访问 URL */ function resolveUploadedFileUrl(credential, uploadResData) { if (credential.presetUrl) { return credential.presetUrl; } let objectKey = credential.key; if (uploadResData) { try { const body = typeof uploadResData === 'string' ? JSON.parse(uploadResData) : uploadResData; if (body?.key) { objectKey = body.key; } } catch (e) { // 非 JSON 时沿用凭证里的 key } } if (credential.cdnOrigin && objectKey) { return `${credential.cdnOrigin}/${String(objectKey).replace(/^\//, '')}`; } return buildQiniuFileUrl(credential.cdnOrigin, objectKey); } /** 拼接七牛文件完整访问地址 */ export function buildQiniuFileUrl(domain, key) { if (!domain || !key) return ''; const k = String(key).replace(/^\//, ''); const d = String(domain).replace(/\/$/, ''); if (d.startsWith('http://') || d.startsWith('https://')) { return `${d}/${k}`; } return `https://${d}/${k}`; } /** * 上传单个文件到七牛云 * @param {string} filePath 本地临时路径 * @param {Object} [options] * @param {Function} [options.beforeUpload] 上传前处理(如水印),返回新的本地路径 * @returns {Promise<{url:string,key:string,filePath:string,serverPath:string}>} */ export async function uploadToCloud(filePath, options = {}) { let localPath = filePath; if (options.beforeUpload) { localPath = await options.beforeUpload(filePath); } const credential = await fetchQiniuUploadCredential(localPath); const { token, key, uploadUrl } = credential; return new Promise((resolve, reject) => { uni.uploadFile({ url: uploadUrl, filePath: localPath, name: 'file', formData: { token, key }, success: (res) => { if (res.statusCode && res.statusCode >= 400) { reject(new Error(`七牛上传失败(${res.statusCode})`)); return; } const fullUrl = resolveUploadedFileUrl(credential, res.data); if (!fullUrl) { reject(new Error('无法解析上传后的文件地址,请检查后端 url 或 CDN 配置')); return; } let respKey = key; try { const body = typeof res.data === 'string' ? JSON.parse(res.data) : res.data; if (body?.key) respKey = body.key; } catch (e) {} resolve({ url: fullUrl, key: respKey, filePath: fullUrl, serverPath: fullUrl }); }, fail: (err) => { console.error('七牛上传失败:', err); reject(err); } }); }); } /** * 表单提交用:统一为完整 URL(兼容历史相对路径) */ export function toSubmitFileUrl(filePath) { if (!filePath) return ''; const p = String(filePath); if (p.startsWith('http://') || p.startsWith('https://')) { return p; } return toImageUrl(p); } /** 将后端附件记录转为 up-upload 列表项(编辑回显) */ export function mapServerFileToUploadItem(att) { const filePath = toSubmitFileUrl(att.filePath || att.url || ''); return { url: filePath, serverPath: filePath, status: 'success', message: '', name: att.fileName || att.name || '', type: att.fileType || 'image/jpeg', size: att.fileSize || 0 }; } /** * 从 up-upload 列表项构建附件对象(提交给后端) */ export function buildAttachmentItem(file, defaults = {}) { const filePath = toSubmitFileUrl( file.serverPath || file.filePath || file.url || '' ); const fileName = file.name || (filePath ? filePath.split('/').pop()?.split('?')[0] : '') || ''; return { fileName: fileName || defaults.fileName || '', filePath, fileType: file.type || defaults.fileType || 'image/jpeg', fileSize: file.size || defaults.fileSize || 0 }; } /** * 为 up-upload 组件创建 afterRead / deletePic 处理器 * @param {import('vue').Ref} fileListRef * @param {Object} [options] * @param {Object} [options.watermark] 传入 addTimestampWatermark 的参数(不含 tempFilePath) */ export function createUploadListHandlers(fileListRef, options = {}) { const deletePic = (event) => { fileListRef.value.splice(event.index, 1); }; const afterRead = async (event) => { const lists = [].concat(event.file); let fileListLen = fileListRef.value.length; lists.forEach((item) => { fileListRef.value.push({ ...item, status: 'uploading', message: '上传中' }); }); for (let i = 0; i < lists.length; i++) { const listIndex = fileListLen; try { const beforeUpload = options.watermark ? (tempFilePath) => addTimestampWatermark({ tempFilePath, ...options.watermark }) : undefined; const result = await uploadToCloud(lists[i].url, { beforeUpload }); const item = fileListRef.value[listIndex]; fileListRef.value.splice(listIndex, 1, { ...item, status: 'success', message: '', url: result.url, serverPath: result.url }); } catch (e) { console.error('上传失败:', e); const item = fileListRef.value[listIndex]; fileListRef.value.splice(listIndex, 1, { ...item, status: 'failed', message: e?.msg || e?.message || '上传失败' }); uni.showToast({ title: e?.msg || e?.message || '上传失败', icon: 'none' }); } fileListLen++; } }; return { afterRead, deletePic }; } /** * 单文件上传(头像、证件照等),带 loading */ export function uploadSingleWithLoading(filePath, options = {}) { const loadingTitle = options.loadingTitle || '上传中...'; uni.showLoading({ title: loadingTitle, mask: true }); return uploadToCloud(filePath, options) .then((result) => { uni.hideLoading(); return result; }) .catch((err) => { uni.hideLoading(); throw err; }); } /** @deprecated 请使用 uploadToCloud;保留别名便于渐进迁移 */ export const uploadFilePromise = uploadToCloud;