From 36a6366da801edc364bdb2452f6c4b76f4713f0b Mon Sep 17 00:00:00 2001 From: "wangmingyong@163.com" Date: Thu, 27 Aug 2026 15:13:58 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A7=86=E9=A2=91=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/util/w6sMedia.js | 170 ++++++++++++------ .../nos/components/NosDetailMaterialsTab.vue | 11 +- 2 files changed, 121 insertions(+), 60 deletions(-) diff --git a/zdxt-web-client/zdxt-efcode-enterprise-app/src/util/w6sMedia.js b/zdxt-web-client/zdxt-efcode-enterprise-app/src/util/w6sMedia.js index 5e083d69..746b8389 100644 --- a/zdxt-web-client/zdxt-efcode-enterprise-app/src/util/w6sMedia.js +++ b/zdxt-web-client/zdxt-efcode-enterprise-app/src/util/w6sMedia.js @@ -100,80 +100,120 @@ export const takePhotoWithWaterMark = async () => { } // 将 MediaFile 从公共目录复制到应用私有目录(解决 Android 11+ 分区存储导致的 EACCES 权限问题) -// 直接使用 MediaFile.file() 获取 FileEntry,避免 resolveLocalFileSystemURL 在公共目录路径上的限制 +// 先通过 resolveLocalFileSystemURL(mediaFile.localURL) 拿到 FileEntry,再 copyTo dataDirectory +// Android 11+ SAF 模式下 File plugin 能拿到 document URI 做 copyTo,FileTransfer native 层 open() 会失败 const copyMediaFileToAppDir = (mediaFile) => { return new Promise((resolve, reject) => { if (!window.cordova?.file?.dataDirectory) { reject(new Error('cordova.file.dataDirectory 不可用')) return } - console.log('[w6sMedia] copyMediaFileToAppDir 开始, localURL:', mediaFile.localURL, 'fullPath:', mediaFile.fullPath) + const sourceLocalURL = mediaFile.localURL || (mediaFile.toURL ? mediaFile.toURL() : '') + console.log('[w6sMedia] copyMediaFileToAppDir 开始, localURL:', sourceLocalURL, 'fullPath:', mediaFile.fullPath) - // 通过 MediaFile.file() 获取 FileEntry(比 resolveLocalFileSystemURL 更可靠) - mediaFile.file( + window.resolveLocalFileSystemURL( + sourceLocalURL, (sourceFileEntry) => { - console.log('[w6sMedia] 获取源 FileEntry 成功:', sourceFileEntry.fullPath) + console.log('[w6sMedia] 源 FileEntry 获取成功:', sourceFileEntry.fullPath) window.resolveLocalFileSystemURL( window.cordova.file.dataDirectory, (dataDirEntry) => { console.log('[w6sMedia] 应用私有目录:', dataDirEntry.fullPath) + // 构造不冲突的目标文件名(加时间戳后缀) + const baseName = mediaFile.name || 'video' + const dotIdx = baseName.lastIndexOf('.') + const safeName = dotIdx > 0 + ? `${baseName.slice(0, dotIdx)}_${Date.now()}${baseName.slice(dotIdx)}` + : `${baseName}_${Date.now()}` sourceFileEntry.copyTo( dataDirEntry, - mediaFile.name, + safeName, (newFileEntry) => { const fileUrl = newFileEntry.toURL() console.log('[w6sMedia] 文件已复制到应用私有目录, fileUrl:', fileUrl) resolve(fileUrl) }, (copyErr) => { - // 目标文件已存在时,先删除再复制 - console.warn('[w6sMedia] copyTo 可能目标已存在,尝试移除后重试:', copyErr) - window.resolveLocalFileSystemURL( - window.cordova.file.dataDirectory + mediaFile.name, - (existingEntry) => { - existingEntry.remove( - () => { - sourceFileEntry.copyTo( - dataDirEntry, - mediaFile.name, - (newFileEntry) => { - const fileUrl = newFileEntry.toURL() - console.log('[w6sMedia] 重试复制成功, fileUrl:', fileUrl) - resolve(fileUrl) - }, - reject - ) - }, - () => { - // 无法删除旧文件,直接 resolve 原始 localURL - console.warn('[w6sMedia] 无法删除旧文件,返回原始 localURL') - resolve(mediaFile.localURL) - } - ) - }, - () => { - // 旧文件不存在,直接报错 - reject(copyErr) - } - ) + console.error('[w6sMedia] copyTo 失败:', copyErr) + reject(copyErr) } ) }, - reject + (dataDirErr) => { + console.error('[w6sMedia] resolve dataDirectory 失败:', dataDirErr) + reject(dataDirErr) + } ) }, - (fileErr) => { - console.error('[w6sMedia] MediaFile.file() 失败:', fileErr) - reject(fileErr) + (sourceErr) => { + console.error('[w6sMedia] resolve source localURL 失败:', sourceErr) + reject(sourceErr) } ) }) } +// 将 Cordova File API 的 File 对象读成 Blob +// Cordova File 实现了 Blob 接口,可用 FileReader 读取 +const cordovaFileToBlob = (cordovaFile) => { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + const blob = new Blob([reader.result], { type: cordovaFile.type || 'application/octet-stream' }) + resolve(blob) + } + reader.onerror = () => reject(reader.error) + reader.readAsArrayBuffer(cordovaFile) + }) +} + +// 通过 fetch + FormData 上传 Blob(绕开 Cordova FileTransfer native 层 EACCES 问题) +const uploadBlobViaFetch = async ({ blob, fileName, mimeType }) => { + const token = $globalStore.useCommon?.token || '' + const api = `${baseUrl}/app-enf/common/file/upload` + const server = /^https?:/.test(api) ? api : `${window.location.origin}${api}` + console.log('[w6sMedia] uploadBlobViaFetch 开始, server:', server, 'blob.size:', blob.size) + + const formData = new FormData() + formData.append('file', blob, fileName) + + const resp = await fetch(server, { + method: 'POST', + headers: { + Authorization: token ? `Bearer ${token}` : '', + clientid: '9e6eb24cd4e169a98b0d2cd8034d877b', + }, + body: formData, + }) + const text = await resp.text() + console.log('[w6sMedia] uploadBlobViaFetch response:', text) + try { + return JSON.parse(text) + } catch { + return text + } +} + +// 通过 resolveLocalFileSystemURL 获取 FileEntry → 读成 Blob → fetch 上传 +// 用于视频等大文件,绕开 Cordova FileTransfer 在 Android 11+ 上的 EACCES 问题 +const uploadMediaFileViaBlob = async ({ mediaFile, fileName, mimeType }) => { + const localURL = mediaFile.localURL || (mediaFile.toURL ? mediaFile.toURL() : '') + console.log('[w6sMedia] uploadMediaFileViaBlob 开始, localURL:', localURL) + const fileEntry = await new Promise((resolve, reject) => { + window.resolveLocalFileSystemURL(localURL, resolve, reject) + }) + const cordovaFile = await new Promise((resolve, reject) => { + fileEntry.file(resolve, reject) + }) + console.log('[w6sMedia] 读取 Cordova File 成功, size:', cordovaFile.size, 'type:', cordovaFile.type) + const blob = await cordovaFileToBlob(cordovaFile) + return uploadBlobViaFetch({ blob, fileName, mimeType }) +} + // 录制视频(使用 Cordova 原生 Media Capture API) export const startVideoRecorder = async ({ duration = 0, - quality = 1, // 0 高清 / 1 一般 / 2 流畅 + quality = 1, front = false, syncSystemAlbum = false, } = {}) => { @@ -192,8 +232,9 @@ export const startVideoRecorder = async ({ if (mediaFiles && mediaFiles.length > 0) { const file = mediaFiles[0] console.log('[w6sMedia] 视频文件: fullPath=', file.fullPath, 'localURL=', file.localURL, 'name=', file.name, 'size=', file.size) + // 优先把文件复制到应用私有目录,后续上传时传复制后的路径 + // 如果复制失败,fallback 返回原始 localURL,让上传端再尝试 fetch+Blob 方案 try { - // 尝试将 MediaFile 复制到应用私有目录,获得可访问的 file:// URL const appDirPath = await copyMediaFileToAppDir(file) console.log('[w6sMedia] 视频已复制到应用私有目录:', appDirPath) resolve({ @@ -201,14 +242,16 @@ export const startVideoRecorder = async ({ name: file.name, size: file.size, type: file.type || 'video/mp4', + _mediaFile: file, // 附带原始 MediaFile,供 fetch+Blob 上传兜底 }) } catch (copyErr) { - console.warn('[w6sMedia] 复制视频失败,尝试使用 localURL:', copyErr) + console.warn('[w6sMedia] 复制视频到应用私有目录失败,返回原始 MediaFile 作兜底:', copyErr) resolve({ video_path: file.localURL, name: file.name, size: file.size, type: file.type || 'video/mp4', + _mediaFile: file, }) } } else { @@ -233,20 +276,41 @@ export const getLocation = async () => { } // 上传本地文件到后端 /app-enf/common/file/upload -// 使用 w6s.file.upload(Cordova FileTransfer),直接传本地路径,无需 base64 转换 +// 默认使用 w6s.file.upload(Cordova FileTransfer) +// 如果传入 mediaFile(视频场景),FileTransfer 失败(如 EACCES)时自动 fallback 到 fetch+Blob 方案 export const uploadLocalFileToBackend = async ({ fileURL, fileName = 'upload', mimeType = 'image/jpeg', + mediaFile = null, }) => { - console.log('[w6sMedia] uploadLocalFileToBackend 开始, 入参:', { fileURL, fileName, mimeType }) + console.log('[w6sMedia] uploadLocalFileToBackend 开始, 入参:', { fileURL, fileName, mimeType, hasMediaFile: !!mediaFile }) await ensureW6sAuth() - console.log('[w6sMedia] 上传前鉴权完成') + + // 优先用 FileTransfer(图片已验证能成功) + try { + return await uploadViaFileTransfer({ fileURL, fileName, mimeType }) + } catch (err) { + console.warn('[w6sMedia] FileTransfer 上传失败:', err?.exception || err?.message || err) + // 如果有原始 MediaFile(视频场景),且错误是 EACCES 权限问题,自动 fallback fetch+Blob + if (mediaFile) { + const errMsg = String(err?.exception || err?.message || err || '') + if (errMsg.includes('EACCES') || errMsg.includes('open failed') || err?.code === 1) { + console.log('[w6sMedia] 检测到 EACCES 权限错误,fallback 到 fetch+Blob 方案') + return uploadMediaFileViaBlob({ mediaFile, fileName, mimeType }) + } + } + throw err + } +} + +// FileTransfer 实际实现 +const uploadViaFileTransfer = ({ fileURL, fileName, mimeType }) => { return new Promise((resolve, reject) => { const token = $globalStore.useCommon?.token || '' const api = `${baseUrl}/app-enf/common/file/upload` const server = /^https?:/.test(api) ? api : `${window.location.origin}${api}` - console.log('[w6sMedia] 上传目标 server:', server, 'token 存在:', !!token) + console.log('[w6sMedia] FileTransfer 上传, server:', server, 'fileURL:', fileURL) const opts = new window.FileUploadOptions() opts.fileKey = 'file' @@ -256,7 +320,6 @@ export const uploadLocalFileToBackend = async ({ Authorization: token ? `Bearer ${token}` : '', clientid: '9e6eb24cd4e169a98b0d2cd8034d877b', } - console.log('[w6sMedia] FileUploadOptions 构造完成:', { fileKey: opts.fileKey, fileName: opts.fileName, mimeType: opts.mimeType }) w6s.file.upload( { @@ -264,25 +327,22 @@ export const uploadLocalFileToBackend = async ({ server, trustAllHosts: false, success: (res) => { - console.log('[w6sMedia] file.upload success 原始返回:', res) + console.log('[w6sMedia] FileTransfer success 原始返回:', res) try { const parsed = JSON.parse(res.response) - console.log('[w6sMedia] 解析后的上传响应:', parsed) resolve(parsed) - } catch (e) { - console.warn('[w6sMedia] 响应 JSON 解析失败, 返回原始 response:', res.response) + } catch { resolve(res.response) } }, fail: (err) => { - console.error('[w6sMedia] file.upload fail:', err) + console.error('[w6sMedia] FileTransfer fail:', err) reject(err) }, }, opts ).catch((err) => { - // w6s.file.upload 在 responseCode !== 200 时会 reject 但不调用 fail 回调 - console.error('[w6sMedia] w6s.file.upload Promise reject:', err) + console.error('[w6sMedia] FileTransfer Promise reject:', err) reject(err) }) }) diff --git a/zdxt-web-client/zdxt-efcode-enterprise-app/src/views/enterprise/nos/components/NosDetailMaterialsTab.vue b/zdxt-web-client/zdxt-efcode-enterprise-app/src/views/enterprise/nos/components/NosDetailMaterialsTab.vue index 17b520b8..83cf3689 100644 --- a/zdxt-web-client/zdxt-efcode-enterprise-app/src/views/enterprise/nos/components/NosDetailMaterialsTab.vue +++ b/zdxt-web-client/zdxt-efcode-enterprise-app/src/views/enterprise/nos/components/NosDetailMaterialsTab.vue @@ -457,13 +457,14 @@ const onTakePhoto = async (item) => { const onRecordVideo = async (item) => { console.log('[NosDetail] onRecordVideo 开始, item:', item) try { - const res = await startVideoRecorder({ duration: 60, quality: 1 }) + const res = await startVideoRecorder({ duration: 0, quality: 1 }) console.log('[NosDetail] 录制返回 res:', res, 'video_path:', res?.video_path) await pushSdkFileToFileList(item, { localPath: res.video_path, fileName: `video-${Date.now()}.mp4`, mimeType: res.type || 'video/mp4', isImage: false, + mediaFile: res._mediaFile, }) } catch (err) { console.error('[NosDetail] w6s 录制失败:', err) @@ -472,8 +473,9 @@ const onRecordVideo = async (item) => { } // 通用:把 SDK 返回的本地文件上传后端 → 写入 fileList -const pushSdkFileToFileList = async (item, { localPath, fileName, mimeType, isImage }) => { - console.log('[NosDetail] pushSdkFileToFileList 开始, 入参:', { localPath, fileName, mimeType, isImage }) +// mediaFile 可选:视频场景传入原始 MediaFile,FileTransfer 失败时 fallback fetch+Blob +const pushSdkFileToFileList = async (item, { localPath, fileName, mimeType, isImage, mediaFile = null }) => { + console.log('[NosDetail] pushSdkFileToFileList 开始, 入参:', { localPath, fileName, mimeType, isImage, hasMediaFile: !!mediaFile }) if (!localPath) { console.warn('[NosDetail] pushSdkFileToFileList localPath 为空, 直接返回') return @@ -485,11 +487,10 @@ const pushSdkFileToFileList = async (item, { localPath, fileName, mimeType, isIm message: '上传中...', } item.fileList.push(fileObj) - // 取数组中的响应式引用,避免后续直接修改原始普通对象导致视图不更新 const reactiveFile = item.fileList[item.fileList.length - 1] console.log('[NosDetail] 已 push 到 fileList, 当前 fileList 长度:', item.fileList.length) try { - const res = await uploadLocalFileToBackend({ fileURL: localPath, fileName, mimeType }) + const res = await uploadLocalFileToBackend({ fileURL: localPath, fileName, mimeType, mediaFile }) console.log('[NosDetail] 上传后端返回 res:', res) const data = res && res.data ? res.data : res const filePath = typeof data === 'string'