Browse Source

使用新的skd来实现图片和视频功能

dev-V1.10.2-temp
wangmingyong@163.com 2 months ago
parent
commit
94639abc4b
  1. 1
      zdxt-web-client/zdxt-efcode-enterprise-app/package.json
  2. 377
      zdxt-web-client/zdxt-efcode-enterprise-app/src/util/w6sMedia.js
  3. 30
      zdxt-web-client/zdxt-efcode-enterprise-app/src/views/enterprise/nos/components/NosDetailMaterialsTab.vue

1
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" "build-app:enterprise-prod": "vite build --mode enterprise-prod --outDir dist/enterprise-prod"
}, },
"dependencies": { "dependencies": {
"@w6s/sdk": "^2.0.5",
"axios": "^1.15.0", "axios": "^1.15.0",
"compressorjs": "^1.2.1", "compressorjs": "^1.2.1",
"core-js": "^3.44.0", "core-js": "^3.44.0",

377
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 sha1 from 'crypto-js/sha1'
import { siqCode } from '@/api/enterprise' import { siqCode } from '@/api/enterprise'
import { baseUrl } from '@/config/env' import { baseUrl } from '@/config/env'
@ -65,22 +65,14 @@ export const ensureW6sAuth = () => {
return w6sAuthPromise return w6sAuthPromise
} }
// 格式化当前时间为 yyyy-MM-dd HH:mm:ss // 拍照并加水印(@w6s/sdk 2.x:timeEnable/locationEnable 时 SDK 自动在 content 后拼接当前时间与定位地址)
const formatDateTime = () => { // 返回扁平结构 PhotoInfoAndMediaId: { imageURL, key, imageInfo, mediaId }
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())}`
}
// 拍照并加水印(时间戳 + 经纬度自动叠加到照片)
export const takePhotoWithWaterMark = async () => { export const takePhotoWithWaterMark = async () => {
// 如果调用方未传 content,则默认显示当前时间作为水印文字 await ensureW6sAuth()
const watermarkContent = formatDateTime();
await ensureW6sAuth();
console.log('[w6sMedia] 鉴权完成, 准备调起拍照') console.log('[w6sMedia] 鉴权完成, 准备调起拍照')
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
w6s.image.takePhotoAndAddWaterMark({ w6s.image.takePhotoAndAddWaterMark({
content: watermarkContent, content: '',
fontSize: 14, fontSize: 14,
color: '#FF5858', color: '#FF5858',
markDisable: false, markDisable: false,
@ -88,7 +80,7 @@ export const takePhotoWithWaterMark = async () => {
locationEnable: true, locationEnable: true,
success: (res) => { success: (res) => {
console.log('[w6sMedia] takePhotoAndAddWaterMark 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) resolve(res)
}, },
fail: (err) => { fail: (err) => {
@ -99,243 +91,49 @@ export const takePhotoWithWaterMark = async () => {
}) })
} }
// 将 MediaFile 从公共目录复制到应用私有目录(解决 Android 11+ 分区存储导致的 EACCES 权限问题) // 录制视频(w6s 官方 API,文档 https://open.beeworks.cn/js-sdk/video.html)
// 先通过 resolveLocalFileSystemURL(mediaFile.localURL) 拿到 FileEntry,再 copyTo dataDirectory // 返回 info: { video_path 本地路径, video_duration 时长, video_size 大小(K), video_thumbnail 首帧 base64 }
// Android 11+ SAF 模式下 File plugin 能拿到 document URI 做 copyTo,FileTransfer native 层 open() 会失败 export const startVideoRecorder = async ({
const copyMediaFileToAppDir = (mediaFile) => { duration = 0, // 最大录制时长(秒),0 不限制
return new Promise((resolve, reject) => { quality = 1, // 0 高清 / 1 一般 / 2 流畅
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 后,<input capture="camcorder"> 会直接调起摄像头录像(不弹相册/文件选择),
// 返回的 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,
front = false, front = false,
syncSystemAlbum = 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) => { return new Promise((resolve, reject) => {
if (!navigator?.device?.capture?.captureVideo) { w6s.video.startVideoRecoder({
console.error('[w6sMedia] navigator.device.capture.captureVideo 不可用') duration,
reject(new Error('当前环境不支持视频录制')) quality: String(quality),
return sync_system_album: syncSystemAlbum,
} front,
navigator.device.capture.captureVideo( success: (res) => {
async (mediaFiles) => { console.log('[w6sMedia] startVideoRecoder success 原始返回:', res)
console.log('[w6sMedia] captureVideo success, mediaFiles:', mediaFiles) const info = res?.info || {}
if (mediaFiles && mediaFiles.length > 0) { if (!info.video_path) {
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 {
reject(new Error('录制未生成视频文件')) 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) => { fail: (err) => {
console.error('[w6sMedia] captureVideo fail:', error, 'code:', error?.code, 'message:', error?.message) console.error('[w6sMedia] startVideoRecoder fail:', err)
reject(error) 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 () => { export const getLocation = async () => {
await ensureW6sAuth() await ensureW6sAuth()
@ -344,81 +142,40 @@ export const getLocation = async () => {
}) })
} }
// 上传本地文件到后端 /app-enf/common/file/upload // 上传本地文件到后端 /app-enf/common/file/upload(Cordova FileTransfer,图片/视频共用)
// 优先级:file(WebView 文件选择器返回的标准 File,FormData 直传)> FileTransfer > fetch+Blob export const uploadLocalFileToBackend = ({ fileURL, fileName = 'upload', mimeType = 'image/jpeg' }) => {
export const uploadLocalFileToBackend = async ({ console.log('[w6sMedia] uploadLocalFileToBackend 开始, 入参:', { fileURL, fileName, mimeType })
fileURL, return ensureW6sAuth().then(() => uploadViaFileTransfer({ fileURL, fileName, mimeType }))
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
}
} }
// FileTransfer 实际实现 // FileTransfer 实际实现(@w6s/sdk 2.x 的类式 API:new FileUpload(options) → .upload(params))
const uploadViaFileTransfer = ({ fileURL, fileName, mimeType }) => { const uploadViaFileTransfer = async ({ fileURL, fileName, mimeType }) => {
return new Promise((resolve, reject) => { const token = $globalStore.useCommon?.token || ''
const token = $globalStore.useCommon?.token || '' const api = `${baseUrl}/app-enf/common/file/upload`
const api = `${baseUrl}/app-enf/common/file/upload` const server = /^https?:/.test(api) ? api : `${window.location.origin}${api}`
const server = /^https?:/.test(api) ? api : `${window.location.origin}${api}` console.log('[w6sMedia] FileTransfer 上传, server:', server, 'fileURL:', fileURL)
console.log('[w6sMedia] FileTransfer 上传, server:', server, 'fileURL:', fileURL)
const opts = new window.FileUploadOptions() const opts = {
opts.fileKey = 'file' fileKey: 'file',
opts.fileName = fileName fileName,
opts.mimeType = mimeType mimeType,
opts.headers = { headers: {
Authorization: token ? `Bearer ${token}` : '', Authorization: token ? `Bearer ${token}` : '',
clientid: '9e6eb24cd4e169a98b0d2cd8034d877b', clientid: '9e6eb24cd4e169a98b0d2cd8034d877b',
} },
}
w6s.file.upload( const uploader = new w6s.file.upload(opts)
{ // upload 返回 Promise:responseCode === 200 时 resolve,否则 reject
fileURL, const res = await uploader.upload({
server, fileURL,
trustAllHosts: false, server,
success: (res) => { trustAllHosts: false,
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)
})
}) })
console.log('[w6sMedia] FileTransfer success 原始返回:', res)
try {
return JSON.parse(res.response)
} catch {
return res.response
}
} }

30
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) console.log('[NosDetail] onTakePhoto 开始, item:', item, 'fileList:', item?.fileList)
try { try {
const res = await takePhotoWithWaterMark() const res = await takePhotoWithWaterMark()
console.log('[NosDetail] 拍照返回 res:', res, 'imageURL:', res?.result.imageURL, 'key:', res?.result.key) // @w6s/sdk 2.x 返回扁平结构: { imageURL, key, imageInfo, mediaId }
const localPath = res.result.imageURL || res.result.key console.log('[NosDetail] 拍照返回 res:', res, 'imageURL:', res?.imageURL, 'key:', res?.key)
const localPath = res.imageURL || res.key
console.log('[NosDetail] 解析出的 localPath:', localPath) console.log('[NosDetail] 解析出的 localPath:', localPath)
await pushSdkFileToFileList(item, { await pushSdkFileToFileList(item, {
localPath, localPath,
fileName: `photo-${Date.now()}.jpg`, fileName: `photo-${Date.now()}.jpg`,
mimeType: res.result.mimeType || 'image/jpeg', mimeType: 'image/jpeg',
isImage: true, isImage: true,
}) })
} catch (err) { } catch (err) {
@ -453,20 +454,17 @@ const onTakePhoto = async (item) => {
} }
} }
// 录制视频 // 录制视频(w6s 官方 API)
const onRecordVideo = async (item) => { const onRecordVideo = async (item) => {
console.log('[NosDetail] onRecordVideo 开始, item:', item) console.log('[NosDetail] onRecordVideo 开始, item:', item)
try { try {
// 默认 input 方式:WebView 文件选择器 + capture 强制现场录像,返回可直接上传的 File 对象
const res = await startVideoRecorder() 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, { await pushSdkFileToFileList(item, {
localPath: res.video_path || '', localPath: res.video_path,
file: res.file || null, fileName: res.name || `video-${Date.now()}.mp4`,
fileName: res.file && /\.mp4$/i.test(res.file.name) ? res.file.name : `video-${Date.now()}.mp4`,
mimeType: res.type || 'video/mp4', mimeType: res.type || 'video/mp4',
isImage: false, isImage: false,
mediaFile: res._mediaFile || null,
}) })
} catch (err) { } catch (err) {
console.error('[NosDetail] w6s 录制失败:', err) console.error('[NosDetail] w6s 录制失败:', err)
@ -475,12 +473,10 @@ const onRecordVideo = async (item) => {
} }
// 通用:把 SDK 返回的本地文件上传后端 → 写入 fileList // 通用:把 SDK 返回的本地文件上传后端 → 写入 fileList
// file:WebView 文件选择器返回的标准 File(视频 input 方式),直接 FormData 上传 const pushSdkFileToFileList = async (item, { localPath, fileName, mimeType, isImage }) => {
// mediaFile:Cordova MediaFile(视频 cordova 方式),FileTransfer 失败时 fallback fetch+Blob console.log('[NosDetail] pushSdkFileToFileList 开始, 入参:', { localPath, fileName, mimeType, isImage })
const pushSdkFileToFileList = async (item, { localPath, fileName, mimeType, isImage, file = null, mediaFile = null }) => { if (!localPath) {
console.log('[NosDetail] pushSdkFileToFileList 开始, 入参:', { localPath, fileName, mimeType, isImage, hasFile: !!file, hasMediaFile: !!mediaFile }) console.warn('[NosDetail] pushSdkFileToFileList localPath 为空, 直接返回')
if (!localPath && !file) {
console.warn('[NosDetail] pushSdkFileToFileList localPath 和 file 均为空, 直接返回')
return return
} }
const fileObj = { const fileObj = {
@ -493,7 +489,7 @@ const pushSdkFileToFileList = async (item, { localPath, fileName, mimeType, isIm
const reactiveFile = item.fileList[item.fileList.length - 1] const reactiveFile = item.fileList[item.fileList.length - 1]
console.log('[NosDetail] 已 push 到 fileList, 当前 fileList 长度:', item.fileList.length) console.log('[NosDetail] 已 push 到 fileList, 当前 fileList 长度:', item.fileList.length)
try { try {
const res = await uploadLocalFileToBackend({ fileURL: localPath, fileName, mimeType, file, mediaFile }) const res = await uploadLocalFileToBackend({ fileURL: localPath, fileName, mimeType })
console.log('[NosDetail] 上传后端返回 res:', res) console.log('[NosDetail] 上传后端返回 res:', res)
const data = res && res.data ? res.data : res const data = res && res.data ? res.data : res
const filePath = typeof data === 'string' const filePath = typeof data === 'string'

Loading…
Cancel
Save