4 changed files with 490 additions and 1 deletions
@ -0,0 +1,291 @@ |
|||||
|
# 上传功能按环境拆分:本地用 van-uploader,生产强制现场拍摄/录制 |
||||
|
|
||||
|
## Context(背景) |
||||
|
|
||||
|
[NosDetailMaterialsTab.vue](file:///d:/Enforcementcode/EnforcementCode-2.0/zdxt-web-client/zdxt-efcode-enterprise-app/src/views/enterprise/nos/components/NosDetailMaterialsTab.vue) 第 48-63 行使用单个 `van-uploader` 同时处理图片/视频/PDF。生产环境(beeworks 容器/深 i 企)内合规要求:**线上不能用相册的图片和视频,必须是现场拍摄和录制**,且**拍照必须带水印与经纬度**。 |
||||
|
|
||||
|
按环境拆分上传入口: |
||||
|
|
||||
|
- **开发环境(dev)**:保留现有 `van-uploader` 不变(支持图片/视频/PDF)。 |
||||
|
- **生产环境(prod)**:只允许两种方式,均来自设备现场采集: |
||||
|
1. **拍照** → [`w6s.image.takePhotoAndAddWaterMark`](https://open.beeworks.cn/js-sdk/image.html#%E5%9B%BE%E7%89%87%E6%B7%BB%E5%8A%A0%E6%B0%B4%E5%8D%B0)(`timeEnable: true, locationEnable: true` 自动叠加时间戳与经纬度水印) |
||||
|
2. **录制视频** → [`w6s.video.startVideoRecoder`](https://open.beeworks.cn/js-sdk/video.html#%E8%A7%86%E9%A2%91%E5%BD%95%E5%88%B6) |
||||
|
3. 上传到后端 → [`w6s.file.upload`](https://open.beeworks.cn/js-sdk/file.html#%E6%96%87%E4%BB%B6%E4%B8%8A%E4%BC%A0)(原生 FileTransfer,直接传本地路径,无需 base64 转换) |
||||
|
4. 经纬度获取(可选) → [`w6s.location.getLocation`](https://open.beeworks.cn/js-sdk/location.html) |
||||
|
|
||||
|
> **关键约束**:生产环境**不提供**相册选择、文件选择器入口,避免用户上传已有图片/视频。PDF 上传在生产环境是否保留待用户确认(见开放问题 1)。 |
||||
|
|
||||
|
## 关键复用点 |
||||
|
|
||||
|
- **环境判断**:[src/config/env.js](file:///d:/Enforcementcode/EnforcementCode-2.0/zdxt-web-client/zdxt-efcode-enterprise-app/src/config/env.js) 的 `appEnv`,生产值为 `'prod'`。 |
||||
|
- **w6s SDK 导入**:`import * as w6s from 'szient-js-sdk'`(参考 [qrCode.vue#L42](file:///d:/Enforcementcode/EnforcementCode-2.0/zdxt-web-client/zdxt-efcode-enterprise-app/src/views/enterprise/qr/qrCode.vue#L42))。鉴权已在 [enterpriseHome.vue#L295-L344](file:///d:/Enforcementcode/EnforcementCode-2.0/zdxt-web-client/zdxt-efcode-enterprise-app/src/views/enterprise/home/enterpriseHome.vue#L295-L344) 完成。 |
||||
|
- **后端上传地址**:[src/util/fileUpload.js](file:///d:/Enforcementcode/EnforcementCode-2.0/zdxt-web-client/zdxt-efcode-enterprise-app/src/util/fileUpload.js) 中 `/app-enf/common/file/upload`,完整 URL 为 `${baseUrl}/app-enf/common/file/upload`。 |
||||
|
- **认证头**:[src/http/http.js#L26-L31](file:///d:/Enforcementcode/EnforcementCode-2.0/zdxt-web-client/zdxt-efcode-enterprise-app/src/http/http.js#L26-L31) 拦截器加 `Authorization: Bearer + token` 与 `clientid: 9e6eb24cd4e169a98b0d2cd8034d877b`,`w6s.file.upload` 需手动传入。 |
||||
|
- **URL 拼接**:复用 [src/util/fileUrl.js](file:///d:/Enforcementcode/EnforcementCode-2.0/zdxt-web-client/zdxt-efcode-enterprise-app/src/util/fileUrl.js) 的 `toFullFileUrl`。 |
||||
|
- **全局状态**:`$globalStore.useCommon`(取 `token`、`enterpriseName`)。 |
||||
|
|
||||
|
## 实施步骤 |
||||
|
|
||||
|
### 步骤 1:新建 `src/util/w6sMedia.js` |
||||
|
|
||||
|
Promise 化封装 4 个方法: |
||||
|
|
||||
|
```js |
||||
|
import * as w6s from 'szient-js-sdk' |
||||
|
import { baseUrl } from '@/config/env' |
||||
|
import { $globalStore } from '@/common/globalStore' |
||||
|
|
||||
|
// 1. 拍照并加水印(时间戳 + 经纬度自动叠加) |
||||
|
export const takePhotoWithWaterMark = ({ |
||||
|
content = '', |
||||
|
fontSize = 14, |
||||
|
color = '#FF5858', |
||||
|
timeEnable = true, |
||||
|
locationEnable = true, |
||||
|
} = {}) => new Promise((resolve, reject) => { |
||||
|
w6s.image.takePhotoAndAddWaterMark({ |
||||
|
content, fontSize, color, |
||||
|
markDisable: false, |
||||
|
timeEnable, locationEnable, |
||||
|
success: resolve, |
||||
|
fail: reject, |
||||
|
}) |
||||
|
}) |
||||
|
|
||||
|
// 2. 录制视频 |
||||
|
export const startVideoRecorder = ({ |
||||
|
duration = 60, |
||||
|
quality = 1, // 0 高清 / 1 一般 / 2 流畅 |
||||
|
front = false, |
||||
|
syncSystemAlbum = false, |
||||
|
} = {}) => new Promise((resolve, reject) => { |
||||
|
w6s.video.startVideoRecoder({ |
||||
|
duration, |
||||
|
quality, |
||||
|
front, |
||||
|
sync_system_album: syncSystemAlbum, |
||||
|
success: resolve, |
||||
|
fail: reject, |
||||
|
}) |
||||
|
}) |
||||
|
|
||||
|
// 3. 获取定位(可选,用于单独保存经纬度元数据) |
||||
|
export const getLocation = () => new Promise((resolve, reject) => { |
||||
|
w6s.location.getLocation({ success: resolve, fail: reject }) |
||||
|
}) |
||||
|
|
||||
|
// 4. 上传本地文件到后端 /app-enf/common/file/upload |
||||
|
export const uploadLocalFileToBackend = ({ |
||||
|
fileURL, // SDK 返回的本地路径 |
||||
|
fileName = 'upload', |
||||
|
mimeType = 'image/jpeg', |
||||
|
}) => new Promise((resolve, reject) => { |
||||
|
const token = $globalStore.useCommon.token || '' |
||||
|
// w6s.file.upload 需要完整 URL |
||||
|
const api = `${baseUrl}/app-enf/common/file/upload` |
||||
|
const server = /^https?:/.test(api) ? api : `${window.location.origin}${api}` |
||||
|
|
||||
|
const opts = new window.FileUploadOptions() |
||||
|
opts.fileKey = 'file' |
||||
|
opts.fileName = fileName |
||||
|
opts.mimeType = mimeType |
||||
|
opts.headers = { |
||||
|
'Authorization': token ? `Bearer ${token}` : '', |
||||
|
'clientid': '9e6eb24cd4e169a98b0d2cd8034d877b', |
||||
|
} |
||||
|
const fileIns = new w6s.file.upload(opts) |
||||
|
fileIns.upload({ |
||||
|
fileURL, |
||||
|
server, |
||||
|
trustAllHosts: false, |
||||
|
success: (res) => { |
||||
|
// Cordova FileTransfer 返回 res.response 为字符串 |
||||
|
try { |
||||
|
const parsed = typeof res.response === 'string' ? JSON.parse(res.response) : res.response |
||||
|
resolve(parsed) |
||||
|
} catch (e) { |
||||
|
resolve(res.response) |
||||
|
} |
||||
|
}, |
||||
|
fail: reject, |
||||
|
}) |
||||
|
}) |
||||
|
``` |
||||
|
|
||||
|
### 步骤 2:修改 `NosDetailMaterialsTab.vue` |
||||
|
|
||||
|
#### 2.1 新增引入 |
||||
|
|
||||
|
```js |
||||
|
import { appEnv } from '@/config/env' |
||||
|
import { |
||||
|
takePhotoWithWaterMark, |
||||
|
startVideoRecorder, |
||||
|
uploadLocalFileToBackend, |
||||
|
} from '@/util/w6sMedia' |
||||
|
|
||||
|
const isProd = computed(() => appEnv === 'prod') |
||||
|
const showUploadAction = ref(false) |
||||
|
const activeUploadItem = ref(null) |
||||
|
const uploadActions = [ |
||||
|
{ name: '拍照(带水印与经纬度)' }, |
||||
|
{ name: '录制视频' }, |
||||
|
] |
||||
|
``` |
||||
|
|
||||
|
#### 2.2 模板改造(替换第 47-64 行) |
||||
|
|
||||
|
```html |
||||
|
<div class="material-media-edit" v-else> |
||||
|
<!-- 开发环境:原 van-uploader 不变 --> |
||||
|
<van-uploader |
||||
|
v-if="!isProd" |
||||
|
v-model="item.fileList" |
||||
|
multiple |
||||
|
:max-count="20" |
||||
|
:max-size="200 * 1024 * 1024" |
||||
|
accept="image/*,video/*,.pdf" |
||||
|
:after-read="onAfterReadImage" |
||||
|
:before-read="onBeforeRead" |
||||
|
@oversize="onOversize" |
||||
|
> |
||||
|
<template #default> |
||||
|
<div class="uploader-custom-btn"> |
||||
|
<van-icon name="photograph" size="24" color="#dcdee0" /> |
||||
|
</div> |
||||
|
</template> |
||||
|
</van-uploader> |
||||
|
|
||||
|
<!-- 生产环境:自定义上传按钮 --> |
||||
|
<div v-else class="uploader-custom-btn" @click="onOpenUploadAction(item)"> |
||||
|
<van-icon name="photograph" size="24" color="#dcdee0" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<!-- 与现有 van-popup 同级 --> |
||||
|
<van-action-sheet |
||||
|
v-model:show="showUploadAction" |
||||
|
:actions="uploadActions" |
||||
|
cancel-text="取消" |
||||
|
close-on-click-action |
||||
|
@select="onSelectUploadAction" |
||||
|
/> |
||||
|
``` |
||||
|
|
||||
|
#### 2.3 脚本逻辑 |
||||
|
|
||||
|
```js |
||||
|
const onOpenUploadAction = (item) => { |
||||
|
if ((item.fileList?.length || 0) >= 20) { |
||||
|
showToast('已达到最多 20 个文件上限') |
||||
|
return |
||||
|
} |
||||
|
activeUploadItem.value = item |
||||
|
showUploadAction.value = true |
||||
|
} |
||||
|
|
||||
|
const onSelectUploadAction = ({ name }) => { |
||||
|
const item = activeUploadItem.value |
||||
|
if (!item) return |
||||
|
if (name === '拍照(带水印与经纬度)') onTakePhoto(item) |
||||
|
else if (name === '录制视频') onRecordVideo(item) |
||||
|
} |
||||
|
|
||||
|
// 拍照带水印 |
||||
|
const onTakePhoto = async (item) => { |
||||
|
try { |
||||
|
const watermarkContent = $globalStore.useCommon.enterpriseName || '深圳市行政执法监督码' |
||||
|
const res = await takePhotoWithWaterMark({ content: watermarkContent }) |
||||
|
await pushSdkFileToFileList(item, { |
||||
|
localPath: res.imageURL || res.key, |
||||
|
fileName: `photo-${Date.now()}.jpg`, |
||||
|
mimeType: 'image/jpeg', |
||||
|
isImage: true, |
||||
|
}) |
||||
|
} catch (err) { |
||||
|
console.error('w6s 拍照失败', err) |
||||
|
showToast('拍照失败或已取消') |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 录制视频 |
||||
|
const onRecordVideo = async (item) => { |
||||
|
try { |
||||
|
const res = await startVideoRecorder({ duration: 60, quality: 1 }) |
||||
|
// res.info.video_path 是本地视频路径 |
||||
|
const info = res.info || res |
||||
|
await pushSdkFileToFileList(item, { |
||||
|
localPath: info.video_path, |
||||
|
fileName: `video-${Date.now()}.mp4`, |
||||
|
mimeType: 'video/mp4', |
||||
|
isImage: false, |
||||
|
}) |
||||
|
} catch (err) { |
||||
|
console.error('w6s 录制失败', err) |
||||
|
showToast('录制失败或已取消') |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 通用:把本地文件上传后端 → 写入 fileList |
||||
|
const pushSdkFileToFileList = async (item, { localPath, fileName, mimeType, isImage }) => { |
||||
|
if (!localPath) return |
||||
|
const fileObj = { |
||||
|
url: '', |
||||
|
isImage, |
||||
|
status: 'uploading', |
||||
|
message: '上传中...', |
||||
|
} |
||||
|
item.fileList.push(fileObj) |
||||
|
try { |
||||
|
const res = await uploadLocalFileToBackend({ fileURL: localPath, fileName, mimeType }) |
||||
|
const data = res && res.data ? res.data : res |
||||
|
const filePath = typeof data === 'string' |
||||
|
? data |
||||
|
: (data && (data.data || data.url || data.fileUrl || data.path)) || '' |
||||
|
if (filePath) { |
||||
|
fileObj.url = toFullFileUrl(filePath) |
||||
|
fileObj.status = 'done' |
||||
|
fileObj.message = '' |
||||
|
} else { |
||||
|
fileObj.status = 'failed' |
||||
|
fileObj.message = '上传失败' |
||||
|
} |
||||
|
} catch (err) { |
||||
|
console.error('w6s 上传后端失败', err) |
||||
|
fileObj.status = 'failed' |
||||
|
fileObj.message = '上传失败' |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
> 注意:`$globalStore` 在该组件需确认已注入。若未注入,可改为 `import { $globalStore } from '@/common/globalStore'`(与 [http.js#L3](file:///d:/Enforcementcode/EnforcementCode-2.0/zdxt-web-client/zdxt-efcode-enterprise-app/src/http/http.js#L3) 一致)。 |
||||
|
|
||||
|
## 待用户确认的开放问题 |
||||
|
|
||||
|
1. **PDF 上传在生产如何处理?** 你强调"线上不能用相册的图片和视频,必须现场拍摄/录制"。PDF 是文档,无法现场采集。请选择: |
||||
|
- (A) 生产环境**不支持 PDF**,只允许拍照和录制视频。 |
||||
|
- (B) 生产环境**保留 PDF**,用 `w6s.file.chooseFiles`(文件选择器)入口,作为 action-sheet 第 3 项。 |
||||
|
2. **水印文案**:默认"企业名称 || '深圳市行政执法监督码'",是否需要加入其他信息(如执法人员姓名、时间)? |
||||
|
3. **视频录制时长上限**:默认 60 秒,是否需要调整(例如 30 秒 / 120 秒)? |
||||
|
4. **视频清晰度**:默认 `quality: 1`(一般),可选 0 高清 / 2 流畅。考虑到 200MB 上限,建议保持一般。 |
||||
|
5. **经纬度是否单独存储**:`takePhotoAndAddWaterMark` 的 `locationEnable: true` 已把经纬度叠加到照片水印。是否还需要在 `item.fileList` 元数据中单独保存经纬度(用于后端记录)?若需要,会在 `onTakePhoto` 中额外调 `getLocation()` 并保存。 |
||||
|
6. **文件上限**:现模板文案是"最多 30 个文件"但 `max-count` 为 20,本计划沿用 20。是否需要统一? |
||||
|
|
||||
|
## 验证方式 |
||||
|
|
||||
|
1. **开发环境**(`VITE_APP_ENV=dev`): |
||||
|
- 进入 NOS 详情材料 Tab 编辑态,确认仍显示原 van-uploader,可选图片/视频/PDF,上传后端成功。 |
||||
|
2. **生产环境**(`VITE_APP_ENV=prod`,beeworks 容器内): |
||||
|
- 编辑态下点击上传按钮,弹出 action-sheet:拍照(带水印与经纬度)/ 录制视频 / 取消。 |
||||
|
- **拍照**:调起原生相机,拍完照片带时间戳+经纬度水印,自动上传后端,卡片显示缩略图。 |
||||
|
- **录制视频**:调起原生视频录制,录完自动上传后端,卡片显示视频缩略图(可点击预览)。 |
||||
|
- **无相册/文件选择入口**,确认不会出现从相册选择图片或视频的选项。 |
||||
|
- 文件数达 20 时点击按钮提示上限。 |
||||
|
3. 检查后端 `/app-enf/common/file/upload` 收到的 `file` 字段非空,返回 URL 正确写入 `item.fileList`,`status: 'done'`。 |
||||
|
4. 预览/删除等既有功能不受影响。 |
||||
|
|
||||
|
## 涉及文件 |
||||
|
|
||||
|
- 新增:`src/util/w6sMedia.js` |
||||
|
- 修改:`src/views/enterprise/nos/components/NosDetailMaterialsTab.vue`(模板 47-64 行 + script 新增方法) |
||||
|
- 复用:`src/config/env.js`、`src/util/fileUrl.js`、`src/common/globalStore.js`、`szient-js-sdk` |
||||
@ -0,0 +1,83 @@ |
|||||
|
import * as w6s from 'szient-js-sdk' |
||||
|
import { baseUrl } from '@/config/env' |
||||
|
import { $globalStore } from '@/common/globalStore' |
||||
|
|
||||
|
// 拍照并加水印(时间戳 + 经纬度自动叠加到照片)
|
||||
|
export const takePhotoWithWaterMark = ({ |
||||
|
content = '', |
||||
|
fontSize = 14, |
||||
|
color = '#FF5858', |
||||
|
timeEnable = true, |
||||
|
locationEnable = true, |
||||
|
} = {}) => new Promise((resolve, reject) => { |
||||
|
w6s.image.takePhotoAndAddWaterMark({ |
||||
|
content, |
||||
|
fontSize, |
||||
|
color, |
||||
|
markDisable: false, |
||||
|
timeEnable, |
||||
|
locationEnable, |
||||
|
success: resolve, |
||||
|
fail: reject, |
||||
|
}) |
||||
|
}) |
||||
|
|
||||
|
// 录制视频
|
||||
|
export const startVideoRecorder = ({ |
||||
|
duration = 60, |
||||
|
quality = 1, // 0 高清 / 1 一般 / 2 流畅
|
||||
|
front = false, |
||||
|
syncSystemAlbum = false, |
||||
|
} = {}) => new Promise((resolve, reject) => { |
||||
|
w6s.video.startVideoRecoder({ |
||||
|
duration, |
||||
|
quality, |
||||
|
front, |
||||
|
sync_system_album: syncSystemAlbum, |
||||
|
success: resolve, |
||||
|
fail: reject, |
||||
|
}) |
||||
|
}) |
||||
|
|
||||
|
// 获取定位(经纬度)
|
||||
|
export const getLocation = () => new Promise((resolve, reject) => { |
||||
|
w6s.location.getLocation({ success: resolve, fail: reject }) |
||||
|
}) |
||||
|
|
||||
|
// 上传本地文件到后端 /app-enf/common/file/upload
|
||||
|
// 使用 w6s.file.upload(Cordova FileTransfer),直接传本地路径,无需 base64 转换
|
||||
|
export const uploadLocalFileToBackend = ({ |
||||
|
fileURL, |
||||
|
fileName = 'upload', |
||||
|
mimeType = 'image/jpeg', |
||||
|
}) => new Promise((resolve, reject) => { |
||||
|
const token = $globalStore.useCommon?.token || '' |
||||
|
// w6s.file.upload 需要完整 URL
|
||||
|
const api = `${baseUrl}/app-enf/common/file/upload` |
||||
|
const server = /^https?:/.test(api) ? api : `${window.location.origin}${api}` |
||||
|
|
||||
|
const opts = new window.FileUploadOptions() |
||||
|
opts.fileKey = 'file' |
||||
|
opts.fileName = fileName |
||||
|
opts.mimeType = mimeType |
||||
|
opts.headers = { |
||||
|
Authorization: token ? `Bearer ${token}` : '', |
||||
|
clientid: '9e6eb24cd4e169a98b0d2cd8034d877b', |
||||
|
} |
||||
|
const fileIns = new w6s.file.upload(opts) |
||||
|
fileIns.upload({ |
||||
|
fileURL, |
||||
|
server, |
||||
|
trustAllHosts: false, |
||||
|
success: (res) => { |
||||
|
// Cordova FileTransfer 返回 res.response 为字符串
|
||||
|
try { |
||||
|
const parsed = typeof res.response === 'string' ? JSON.parse(res.response) : res.response |
||||
|
resolve(parsed) |
||||
|
} catch (e) { |
||||
|
resolve(res.response) |
||||
|
} |
||||
|
}, |
||||
|
fail: reject, |
||||
|
}) |
||||
|
}) |
||||
Loading…
Reference in new issue