diff --git a/zdxt-web-client/zdxt-efcode-enterprise-app/package.json b/zdxt-web-client/zdxt-efcode-enterprise-app/package.json index 11aa98a5..d97167e4 100644 --- a/zdxt-web-client/zdxt-efcode-enterprise-app/package.json +++ b/zdxt-web-client/zdxt-efcode-enterprise-app/package.json @@ -14,6 +14,7 @@ "build-app:enterprise-prod": "vite build --mode enterprise-prod --outDir dist/enterprise-prod" }, "dependencies": { + "@w6s/sdk": "^2.0.5", "axios": "^1.15.0", "compressorjs": "^1.2.1", "core-js": "^3.44.0", 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 69e1fb2e..0277d587 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 @@ -1,4 +1,4 @@ -import * as w6s from 'szient-js-sdk' +import * as w6s from '@w6s/sdk' import sha1 from 'crypto-js/sha1' import { siqCode } from '@/api/enterprise' import { baseUrl } from '@/config/env' @@ -65,22 +65,14 @@ export const ensureW6sAuth = () => { return w6sAuthPromise } -// 格式化当前时间为 yyyy-MM-dd HH:mm:ss -const formatDateTime = () => { - const d = new Date() - const pad = (n) => String(n).padStart(2, '0') - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` -} - -// 拍照并加水印(时间戳 + 经纬度自动叠加到照片) +// 拍照并加水印(@w6s/sdk 2.x:timeEnable/locationEnable 时 SDK 自动在 content 后拼接当前时间与定位地址) +// 返回扁平结构 PhotoInfoAndMediaId: { imageURL, key, imageInfo, mediaId } export const takePhotoWithWaterMark = async () => { - // 如果调用方未传 content,则默认显示当前时间作为水印文字 - const watermarkContent = formatDateTime(); - await ensureW6sAuth(); + await ensureW6sAuth() console.log('[w6sMedia] 鉴权完成, 准备调起拍照') return new Promise((resolve, reject) => { w6s.image.takePhotoAndAddWaterMark({ - content: watermarkContent, + content: '', fontSize: 14, color: '#FF5858', markDisable: false, @@ -88,7 +80,7 @@ export const takePhotoWithWaterMark = async () => { locationEnable: true, success: (res) => { console.log('[w6sMedia] takePhotoAndAddWaterMark success 原始返回:', res) - console.log('[w6sMedia] 返回字段 imageURL:', res?.result.imageURL, 'key:', res?.result.key, 'imageInfo:', res?.result.imageInfo) + console.log('[w6sMedia] 返回字段 imageURL:', res?.imageURL, 'key:', res?.key, 'imageInfo:', res?.imageInfo) resolve(res) }, fail: (err) => { @@ -99,243 +91,49 @@ export const takePhotoWithWaterMark = async () => { }) } -// 将 MediaFile 从公共目录复制到应用私有目录(解决 Android 11+ 分区存储导致的 EACCES 权限问题) -// 先通过 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 - } - const sourceLocalURL = mediaFile.localURL || (mediaFile.toURL ? mediaFile.toURL() : '') - console.log('[w6sMedia] copyMediaFileToAppDir 开始, localURL:', sourceLocalURL, 'fullPath:', mediaFile.fullPath) - - window.resolveLocalFileSystemURL( - sourceLocalURL, - (sourceFileEntry) => { - 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, - safeName, - (newFileEntry) => { - const fileUrl = newFileEntry.toURL() - console.log('[w6sMedia] 文件已复制到应用私有目录, fileUrl:', fileUrl) - resolve(fileUrl) - }, - (copyErr) => { - console.error('[w6sMedia] copyTo 失败:', copyErr) - reject(copyErr) - } - ) - }, - (dataDirErr) => { - console.error('[w6sMedia] resolve dataDirectory 失败:', dataDirErr) - reject(dataDirErr) - } - ) - }, - (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 }) -} - -// 通过 WebView 文件选择器调起系统摄像头录像(推荐方式) -// 原理:Android WebView 实现 onShowFileChooser 后, 会直接调起摄像头录像(不弹相册/文件选择), -// 返回的 File 通过 content URI 授权,页面可直接读取/上传,彻底绕开 Cordova 在分区存储下的 EACCES 问题 -export const startVideoRecorderViaInput = () => { - return new Promise((resolve, reject) => { - const input = document.createElement('input') - input.type = 'file' - input.setAttribute('accept', 'video/*') - // capture 属性强制现场录制;camcorder 指定调起录像 - input.setAttribute('capture', 'camcorder') - input.style.display = 'none' - document.body.appendChild(input) - - let settled = false - const cleanup = () => { - window.removeEventListener('focus', onFocus) - input.remove() - } - const settleResolve = (payload) => { - if (settled) return - settled = true - cleanup() - resolve(payload) - } - const settleReject = (err) => { - if (settled) return - settled = true - cleanup() - reject(err) - } - - input.addEventListener('change', () => { - const file = input.files && input.files[0] - if (!file) { - settleReject(new Error('未录制视频')) - return - } - console.log('[w6sMedia] input 录像返回 File:', file.name, 'size:', file.size, 'type:', file.type) - settleResolve({ - video_path: '', - file, // 标准 File 对象,可直接 FormData 上传 - name: file.name, - size: file.size, - type: file.type || 'video/mp4', - }) - }) - // 用户取消录制时部分 WebView 会触发 cancel 事件 - input.addEventListener('cancel', () => settleReject(new Error('已取消录制'))) - // 兜底:摄像头关闭回到页面后若仍无文件,视为取消 - const onFocus = () => { - setTimeout(() => { - if (!input.files || input.files.length === 0) { - settleReject(new Error('已取消录制')) - } - }, 1500) - } - window.addEventListener('focus', onFocus) - - input.click() - }) -} - -// 录制视频(Cordova Media Capture 方式,备选) -// 注意:Android 10+ 分区存储下,公共目录文件的 FileTransfer/copyTo/FileReader 全部会 EACCES, -// 仅在设备允许旧版存储(legacy storage)时可用 -export const startVideoRecorderCordova = ({ - duration = 0, - quality = 1, +// 录制视频(w6s 官方 API,文档 https://open.beeworks.cn/js-sdk/video.html) +// 返回 info: { video_path 本地路径, video_duration 时长, video_size 大小(K), video_thumbnail 首帧 base64 } +export const startVideoRecorder = async ({ + duration = 0, // 最大录制时长(秒),0 不限制 + quality = 1, // 0 高清 / 1 一般 / 2 流畅 front = false, syncSystemAlbum = false, } = {}) => { - console.log('[w6sMedia] startVideoRecorderCordova 开始, 入参:', { duration, quality, front, syncSystemAlbum }) + console.log('[w6sMedia] startVideoRecorder 开始, 入参:', { duration, quality, front, syncSystemAlbum }) + await ensureW6sAuth() + console.log('[w6sMedia] 录制前鉴权完成, 准备调起录制') return new Promise((resolve, reject) => { - if (!navigator?.device?.capture?.captureVideo) { - console.error('[w6sMedia] navigator.device.capture.captureVideo 不可用') - reject(new Error('当前环境不支持视频录制')) - return - } - navigator.device.capture.captureVideo( - async (mediaFiles) => { - console.log('[w6sMedia] captureVideo success, mediaFiles:', mediaFiles) - if (mediaFiles && mediaFiles.length > 0) { - const file = mediaFiles[0] - try { - const appDirPath = await copyMediaFileToAppDir(file) - console.log('[w6sMedia] 视频已复制到应用私有目录:', appDirPath) - resolve({ - video_path: appDirPath, - name: file.name, - size: file.size, - type: file.type || 'video/mp4', - _mediaFile: file, - }) - } catch (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 { + w6s.video.startVideoRecoder({ + duration, + quality: String(quality), + sync_system_album: syncSystemAlbum, + front, + success: (res) => { + console.log('[w6sMedia] startVideoRecoder success 原始返回:', res) + const info = res?.info || {} + if (!info.video_path) { reject(new Error('录制未生成视频文件')) + return } + // 从路径提取文件名(可能带查询参数) + const name = (info.video_path.split('/').pop() || '').split('?')[0] || `video-${Date.now()}.mp4` + resolve({ + video_path: info.video_path, + video_duration: info.video_duration, + video_size: info.video_size, + video_thumbnail: info.video_thumbnail, + name, + type: 'video/mp4', + }) }, - (error) => { - console.error('[w6sMedia] captureVideo fail:', error, 'code:', error?.code, 'message:', error?.message) - reject(error) + fail: (err) => { + console.error('[w6sMedia] startVideoRecoder fail:', err) + reject(err) }, - duration ? { limit: 1, duration } : { limit: 1 } - ) + }) }) } -// 录制视频(对外统一入口) -// 默认 mode='input':WebView 文件选择器 + capture 强制现场录像,返回可读 File 对象(推荐) -// mode='cordova':Cordova MediaCapture + 复制私有目录(部分设备受分区存储限制不可用) -export const startVideoRecorder = ({ mode = 'input', duration = 0, quality = 1 } = {}) => { - if (mode === 'cordova') { - return startVideoRecorderCordova({ duration, quality }) - } - return startVideoRecorderViaInput() -} - // 获取定位(经纬度) export const getLocation = async () => { await ensureW6sAuth() @@ -344,81 +142,40 @@ export const getLocation = async () => { }) } -// 上传本地文件到后端 /app-enf/common/file/upload -// 优先级:file(WebView 文件选择器返回的标准 File,FormData 直传)> FileTransfer > fetch+Blob -export const uploadLocalFileToBackend = async ({ - fileURL, - fileName = 'upload', - mimeType = 'image/jpeg', - file = null, - mediaFile = null, -}) => { - console.log('[w6sMedia] uploadLocalFileToBackend 开始, 入参:', { fileURL, fileName, mimeType, hasFile: !!file, hasMediaFile: !!mediaFile }) - - // 标准 File 对象(WebView 文件选择器返回,带 content URI 授权)直接 FormData 上传,无需鉴权 - if (file) { - return uploadBlobViaFetch({ blob: file, fileName, mimeType: mimeType || file.type }) - } - - await ensureW6sAuth() - - // 优先用 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 - } +// 上传本地文件到后端 /app-enf/common/file/upload(Cordova FileTransfer,图片/视频共用) +export const uploadLocalFileToBackend = ({ fileURL, fileName = 'upload', mimeType = 'image/jpeg' }) => { + console.log('[w6sMedia] uploadLocalFileToBackend 开始, 入参:', { fileURL, fileName, mimeType }) + return ensureW6sAuth().then(() => uploadViaFileTransfer({ fileURL, fileName, mimeType })) } -// 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] FileTransfer 上传, server:', server, 'fileURL:', fileURL) +// FileTransfer 实际实现(@w6s/sdk 2.x 的类式 API:new FileUpload(options) → .upload(params)) +const uploadViaFileTransfer = async ({ fileURL, 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] FileTransfer 上传, server:', server, 'fileURL:', fileURL) - const opts = new window.FileUploadOptions() - opts.fileKey = 'file' - opts.fileName = fileName - opts.mimeType = mimeType - opts.headers = { + const opts = { + fileKey: 'file', + fileName, + mimeType, + headers: { Authorization: token ? `Bearer ${token}` : '', clientid: '9e6eb24cd4e169a98b0d2cd8034d877b', - } + }, + } - w6s.file.upload( - { - fileURL, - server, - trustAllHosts: false, - success: (res) => { - console.log('[w6sMedia] FileTransfer success 原始返回:', res) - try { - const parsed = JSON.parse(res.response) - resolve(parsed) - } catch { - resolve(res.response) - } - }, - fail: (err) => { - console.error('[w6sMedia] FileTransfer fail:', err) - reject(err) - }, - }, - opts - ).catch((err) => { - console.error('[w6sMedia] FileTransfer Promise reject:', err) - reject(err) - }) + const uploader = new w6s.file.upload(opts) + // upload 返回 Promise:responseCode === 200 时 resolve,否则 reject + const res = await uploader.upload({ + fileURL, + server, + trustAllHosts: false, }) + console.log('[w6sMedia] FileTransfer success 原始返回:', res) + try { + return JSON.parse(res.response) + } catch { + return res.response + } } 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 569fc9c8..aaea4484 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 @@ -438,13 +438,14 @@ const onTakePhoto = async (item) => { console.log('[NosDetail] onTakePhoto 开始, item:', item, 'fileList:', item?.fileList) try { const res = await takePhotoWithWaterMark() - console.log('[NosDetail] 拍照返回 res:', res, 'imageURL:', res?.result.imageURL, 'key:', res?.result.key) - const localPath = res.result.imageURL || res.result.key + // @w6s/sdk 2.x 返回扁平结构: { imageURL, key, imageInfo, mediaId } + console.log('[NosDetail] 拍照返回 res:', res, 'imageURL:', res?.imageURL, 'key:', res?.key) + const localPath = res.imageURL || res.key console.log('[NosDetail] 解析出的 localPath:', localPath) await pushSdkFileToFileList(item, { localPath, fileName: `photo-${Date.now()}.jpg`, - mimeType: res.result.mimeType || 'image/jpeg', + mimeType: 'image/jpeg', isImage: true, }) } catch (err) { @@ -453,20 +454,17 @@ const onTakePhoto = async (item) => { } } -// 录制视频 +// 录制视频(w6s 官方 API) const onRecordVideo = async (item) => { console.log('[NosDetail] onRecordVideo 开始, item:', item) try { - // 默认 input 方式:WebView 文件选择器 + capture 强制现场录像,返回可直接上传的 File 对象 const res = await startVideoRecorder() - console.log('[NosDetail] 录制返回 res:', res, 'file:', res?.file, 'video_path:', res?.video_path) + console.log('[NosDetail] 录制返回 res:', res, 'video_path:', res?.video_path) await pushSdkFileToFileList(item, { - localPath: res.video_path || '', - file: res.file || null, - fileName: res.file && /\.mp4$/i.test(res.file.name) ? res.file.name : `video-${Date.now()}.mp4`, + localPath: res.video_path, + fileName: res.name || `video-${Date.now()}.mp4`, mimeType: res.type || 'video/mp4', isImage: false, - mediaFile: res._mediaFile || null, }) } catch (err) { console.error('[NosDetail] w6s 录制失败:', err) @@ -475,12 +473,10 @@ const onRecordVideo = async (item) => { } // 通用:把 SDK 返回的本地文件上传后端 → 写入 fileList -// file:WebView 文件选择器返回的标准 File(视频 input 方式),直接 FormData 上传 -// mediaFile:Cordova MediaFile(视频 cordova 方式),FileTransfer 失败时 fallback fetch+Blob -const pushSdkFileToFileList = async (item, { localPath, fileName, mimeType, isImage, file = null, mediaFile = null }) => { - console.log('[NosDetail] pushSdkFileToFileList 开始, 入参:', { localPath, fileName, mimeType, isImage, hasFile: !!file, hasMediaFile: !!mediaFile }) - if (!localPath && !file) { - console.warn('[NosDetail] pushSdkFileToFileList localPath 和 file 均为空, 直接返回') +const pushSdkFileToFileList = async (item, { localPath, fileName, mimeType, isImage }) => { + console.log('[NosDetail] pushSdkFileToFileList 开始, 入参:', { localPath, fileName, mimeType, isImage }) + if (!localPath) { + console.warn('[NosDetail] pushSdkFileToFileList localPath 为空, 直接返回') return } const fileObj = { @@ -493,7 +489,7 @@ const pushSdkFileToFileList = async (item, { localPath, fileName, mimeType, isIm 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, file, mediaFile }) + const res = await uploadLocalFileToBackend({ fileURL: localPath, fileName, mimeType }) console.log('[NosDetail] 上传后端返回 res:', res) const data = res && res.data ? res.data : res const filePath = typeof data === 'string'