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 9c2f1a5f..9eb8cb14 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 @@ -98,44 +98,102 @@ const getTimeStr = () => { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}` } +// 去掉 file:// 前缀(w6s 的 toBase64 在 Android 下会自动补 file://,避免重复前缀导致解析失败) +const stripFileScheme = (p = '') => String(p).replace(/^file:\/\//, '') + +// 水印图最长边上限(像素)。原图往往 4000+,整张重新编码是耗时主因, +// 限制到 2048 可大幅缩短耗时并减小上传体积;需要更高清可上调 +const WATERMARK_MAX_SIZE = 2048 + +// 读取水印源图:优先用本地文件 URL(cdvfile:// 由 Cordova 拦截读取),无需把整张图转成 base64; +// 环境/路径不支持时回退到 SDK 的 toBase64 +const loadWatermarkSource = async (filePath) => { + const toImg = (src) => new Promise((resolve, reject) => { + const el = document.createElement('img') + el.onload = () => resolve(el) + el.onerror = () => reject(new Error('水印源图片加载失败')) + el.src = src + }) + const raw = stripFileScheme(filePath) + if (window.resolveLocalFileSystemURL) { + const fileUrl = /^(cdvfile|content):/i.test(String(filePath)) ? filePath : `file://${raw}` + try { + const entry = await new Promise((resolve, reject) => window.resolveLocalFileSystemURL(fileUrl, resolve, reject)) + return await toImg(entry.toURL()) + } catch (e) { + console.warn('[w6sMedia] 本地文件 URL 加载失败, 回退 toBase64:', e) + } + } + const evt = await w6s.image.toBase64({ filePath: raw }) + return toImg(evt?.target?.result || evt?.result || evt) +} + +// 前端自行给图片叠加时间水印,返回带水印图片的本地文件路径 +// 说明:w6s.image.takePhotoAndAddWaterMark 实测不可用(原生加水印接口无有效返回), +// 改为拍照后用 canvas 在前端叠加时间水印;用 toBlob 直接落盘,省去 base64 编解码开销 +const addTimeWaterMark = async (filePath, fileName) => { + console.log('[w6sMedia] 开始叠加时间水印, 源图:', filePath) + const img = await loadWatermarkSource(filePath) + + // 按最长边等比缩放,避免整张原图重新编码(编码是耗时主因) + const srcW = img.naturalWidth || img.width + const srcH = img.naturalHeight || img.height + const scale = Math.min(1, WATERMARK_MAX_SIZE / Math.max(srcW, srcH)) + const canvas = document.createElement('canvas') + canvas.width = Math.round(srcW * scale) + canvas.height = Math.round(srcH * scale) + const ctx = canvas.getContext('2d') + ctx.drawImage(img, 0, 0, canvas.width, canvas.height) + + const text = getTimeStr() + const fontSize = Math.max(16, Math.round(canvas.width * 0.035)) + const pad = Math.round(fontSize * 0.6) + ctx.font = `${fontSize}px sans-serif` + ctx.textAlign = 'left' + ctx.textBaseline = 'bottom' + // 描边提升浅色背景下的可读性 + ctx.lineWidth = Math.max(2, Math.round(fontSize * 0.12)) + ctx.strokeStyle = 'rgba(0, 0, 0, 0.6)' + ctx.fillStyle = '#FF5858' + const x = pad + const y = canvas.height - pad + ctx.strokeText(text, x, y) + ctx.fillText(text, x, y) + + // toBlob 直接产出二进制,避免 toDataURL 的 base64 编码 + const blob = await new Promise((resolve, reject) => { + canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('水印图片编码失败'))), 'image/jpeg', 0.9) + }) + const out = await writeBlobToLocalFile(blob, fileName) + console.log('[w6sMedia] 时间水印叠加完成, 输出:', out, '大小:', blob.size) + return out +} + // 拍照并加水印 +// 注:w6s.image.takePhotoAndAddWaterMark 不可用,已改为 w6s.image.takePicture + 前端 canvas 叠加时间水印 export const takePhotoWithWaterMark = async () => { await ensureW6sAuth() - // console.log('[w6sMedia] 鉴权完成, 准备获取定位') + console.log('[w6sMedia] 鉴权完成, 准备调起拍照') - // // 预获取定位(带缓存),第二次拍照起直接用缓存不搜星 - // let locationStr = '' - // try { - // const loc = await getCachedLocation() - // locationStr = (loc.city || '') + (loc.district || '') + (loc.street || '') - // console.log('[w6sMedia] 定位地址:', locationStr) - // } catch (e) { - // console.warn('[w6sMedia] 获取定位失败, 水印不含地址:', e) - // } + // 1. 调起原生拍照,返回 { imageURL(压缩图本地路径), key(原图本地路径), imageInfo, mediaId } + const photo = await w6s.image.takePicture({ editable: false }) + .then((res) => { + console.log('[w6sMedia] takePicture success 原始返回:', res) + const result = res?.result || res || {} + console.log('[w6sMedia] 拍照返回 imageURL:', result?.imageURL, 'key:', result?.key) + return result + }) + .catch((err) => { + console.error('[w6sMedia] takePicture fail:', err) + throw err + }) - // const content = `${getTimeStr()} ${locationStr}`.trim() - // console.log('[w6sMedia] 水印 content:', content, '准备调起拍照') + // 2. 前端叠加时间水印,产出带水印的图片本地路径 + const sourcePath = photo.imageURL || photo.key + const imageURL = await addTimeWaterMark(sourcePath, `wm-photo-${Date.now()}.jpg`) - return new Promise((resolve, reject) => { - w6s.image.takePhotoAndAddWaterMark({ - content: '', - fontSize: 14, - color: '#FF5858', - markDisable: false, - timeEnable: true, - locationEnable: false, - success: (res) => { - console.log('[w6sMedia] takePhotoAndAddWaterMark success 原始返回:', res) - const result = res?.result || res || {} - console.log('[w6sMedia] 返回字段 imageURL:', result?.imageURL, 'key:', result?.key) - resolve(result) - }, - fail: (err) => { - console.error('[w6sMedia] takePhotoAndAddWaterMark fail:', err) - reject(err) - }, - }) - }) + // 3. 返回结构与原来一致(imageURL 为带水印图片的本地路径,可直接上传) + return { ...photo, imageURL } } // 录制视频(w6s 官方 API,文档 https://open.beeworks.cn/js-sdk/video.html) @@ -195,6 +253,37 @@ export const getLocation = async () => { }) } +// 将 Blob 写入客户端缓存目录,返回可上传的本地文件路径 +// FileTransfer 只支持文件路径,canvas 产出的图片 Blob 需先落盘 +const writeBlobToLocalFile = (blob, fileName) => new Promise((resolve, reject) => { + const cordovaFile = window.cordova?.file + if (!window.resolveLocalFileSystemURL || !cordovaFile?.cacheDirectory) { + reject(new Error('当前环境不支持写入本地文件(缺少 cordova-plugin-file)')) + return + } + window.resolveLocalFileSystemURL(cordovaFile.cacheDirectory, (dirEntry) => { + dirEntry.getFile(fileName, { create: true }, (fileEntry) => { + fileEntry.createWriter((writer) => { + writer.onwriteend = () => resolve(fileEntry.toURL()) + writer.onerror = (e) => reject(e) + writer.write(blob) + }, reject) + }, reject) + }, reject) +}) + +// base64 dataURL → Blob(仅作为兼容兜底,正常流程已用 toBlob 直接产出二进制) +const dataUrlToBlob = (dataUrl) => { + const commaIdx = dataUrl.indexOf(',') + const type = (dataUrl.slice(0, commaIdx).match(/data:(.*?);/) || [])[1] || 'image/jpeg' + const bin = atob(dataUrl.slice(commaIdx + 1)) + const bytes = new Uint8Array(bin.length) + for (let i = 0; i < bin.length; i += 1) bytes[i] = bin.charCodeAt(i) + return new Blob([bytes], { type }) +} + +const writeDataUrlToFile = (dataUrl, fileName) => writeBlobToLocalFile(dataUrlToBlob(dataUrl), fileName) + // 上传本地文件到后端 /app-enf/common/file/upload(Cordova FileTransfer,图片/视频共用) export const uploadLocalFileToBackend = ({ fileURL, fileName = 'upload', mimeType = 'image/jpeg' }) => { console.log('[w6sMedia] uploadLocalFileToBackend 开始, 入参:', { fileURL, fileName, mimeType }) @@ -206,7 +295,12 @@ 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) + + // base64 dataURL(如拍照后前端叠加水印的图片)先落盘为本地临时文件 + const localFileURL = /^data:/.test(fileURL) + ? await writeDataUrlToFile(fileURL, fileName) + : fileURL + console.log('[w6sMedia] FileTransfer 上传, server:', server, 'fileURL:', localFileURL) const opts = { fileKey: 'file', @@ -221,7 +315,7 @@ const uploadViaFileTransfer = async ({ fileURL, fileName, mimeType }) => { const uploader = new w6s.file.upload(opts) // upload 返回 Promise:responseCode === 200 时 resolve,否则 reject const res = await uploader.upload({ - fileURL, + fileURL: localFileURL, server, trustAllHosts: false, }) 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 29f245d4..4ff934e2 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 @@ -85,7 +85,7 @@
- +
@@ -439,7 +439,7 @@ const onTakePhoto = async (item) => { try { const res = await takePhotoWithWaterMark() // @w6s/sdk 2.x 返回扁平结构: { imageURL, key, imageInfo, mediaId } - console.log('[NosDetail] 拍照返回 res:', res, 'imageURL:', res?.imageURL, 'key:', res?.key) + // 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, { @@ -459,12 +459,13 @@ const onRecordVideo = async (item) => { console.log('[NosDetail] onRecordVideo 开始, item:', item) try { const res = await startVideoRecorder() - console.log('[NosDetail] 录制返回 res:', res, 'video_path:', res?.video_path) + console.log('[NosDetail] 录制返回 res:', res, 'video_path:', res?.video_path, 'video_thumbnail:', res?.video_thumbnail) await pushSdkFileToFileList(item, { localPath: res.video_path, fileName: res.name || `video-${Date.now()}.mp4`, mimeType: res.type || 'video/mp4', isImage: false, + thumbnail: res.video_thumbnail, }) } catch (err) { console.error('[NosDetail] w6s 录制失败:', err) @@ -472,9 +473,16 @@ const onRecordVideo = async (item) => { } } +// w6s 录制接口返回的首帧缩略图可能是裸 base64 / dataURL / 文件路径,统一成可直接作为 poster 的地址 +const toPosterSrc = (thumb) => { + if (!thumb || typeof thumb !== 'string') return '' + if (/^(data:|https?:|file:|cdvfile:)/.test(thumb)) return thumb + return `data:image/jpeg;base64,${thumb}` +} + // 通用:把 SDK 返回的本地文件上传后端 → 写入 fileList -const pushSdkFileToFileList = async (item, { localPath, fileName, mimeType, isImage }) => { - console.log('[NosDetail] pushSdkFileToFileList 开始, 入参:', { localPath, fileName, mimeType, isImage }) +const pushSdkFileToFileList = async (item, { localPath, fileName, mimeType, isImage, thumbnail }) => { + console.log('[NosDetail] pushSdkFileToFileList 开始, 入参:', { localPath, fileName, mimeType, isImage, thumbnail }) if (!localPath) { console.warn('[NosDetail] pushSdkFileToFileList localPath 为空, 直接返回') return @@ -482,6 +490,7 @@ const pushSdkFileToFileList = async (item, { localPath, fileName, mimeType, isIm const fileObj = { url: '', isImage, + poster: toPosterSrc(thumbnail), status: 'uploading', message: '上传中...', } diff --git a/zdxt-web-client/zdxt-efcode-enterprise-app/src/views/enterprise/nos/nosCloudRecord.vue b/zdxt-web-client/zdxt-efcode-enterprise-app/src/views/enterprise/nos/nosCloudRecord.vue index 380862d3..18a4e6bd 100644 --- a/zdxt-web-client/zdxt-efcode-enterprise-app/src/views/enterprise/nos/nosCloudRecord.vue +++ b/zdxt-web-client/zdxt-efcode-enterprise-app/src/views/enterprise/nos/nosCloudRecord.vue @@ -60,7 +60,7 @@ 上传中...
-
@@ -139,6 +139,7 @@ const refreshing = ref(false); const submitting = ref(false); const hasRecord = ref(false); const cloudVideo = ref(''); +const cloudVideoPoster = ref(''); const cloudText = ref(''); const suggestion = ref(''); const cloudVideoPath = ref(''); @@ -147,6 +148,7 @@ const videoUploading = ref(false); const applyDetailData = (data) => { cloudVideo.value = data.cloudVideo ? toFullFileUrl(data.cloudVideo) : ''; + cloudVideoPoster.value = ''; cloudText.value = data.cloudText || ''; suggestion.value = data.suggestion || ''; hasRecord.value = !!(data.cloudVideo || data.cloudText || data.suggestion); @@ -186,9 +188,17 @@ const onRefresh = () => { fetchDetail(); }; +// w6s 录制接口返回的首帧缩略图可能是裸 base64 / dataURL / 文件路径,统一成可直接作为 poster 的地址 +const toPosterSrc = (thumb) => { + if (!thumb || typeof thumb !== 'string') return ''; + if (/^(data:|https?:|file:|cdvfile:)/.test(thumb)) return thumb; + return `data:image/jpeg;base64,${thumb}`; +}; + const onVideoDelete = () => { cloudVideoPath.value = ''; cloudVideo.value = ''; + cloudVideoPoster.value = ''; }; const onRecordVideo = async () => { @@ -209,6 +219,8 @@ const onRecordVideo = async () => { if (filePath) { cloudVideoPath.value = filePath; cloudVideo.value = toFullFileUrl(filePath); + // 用 SDK 返回的首帧作为封面,避免