Compare commits

..
Author SHA1 Message Date
zlf 74736897ca 去掉企微验证 2026-08-17 10:17:46 +08:00
zlf 945b60403a CRM_26-08-13#story#9187,服务商新增车辆规则调整 2026-08-13 15:14:24 +08:00
zlf d92f28a03e CRM_26-08-13#story#9204,关于优化“我的车辆”中“牌照”分类的需求 2026-08-12 14:37:18 +08:00
zlf e04be176fa CRM_26-08-13#story#9187,服务商新增车辆规则调整 2026-08-12 13:41:26 +08:00
zlf a6be6d36e0 CRM_26-08-13#story#9203,关于优化“我的车辆”中“车辆类别”的需求 2026-08-11 14:47:19 +08:00
zlf 7df67ee75a CRM_26-08-13#story#9201,新增调度APP通过复制快速新增车辆的功能 2026-08-07 14:41:27 +08:00
zlf 137a1b943e CRM_26-08-13#story#9201,新增调度APP通过复制快速新增车辆的功能 2026-08-07 14:27:05 +08:00
zlf 9af26f9b32 CRM_26-08-05#story#9126,车辆管理、服务商管理系统需求(清除数据) 2026-08-06 09:58:58 +08:00
zlf 4b71881ceb CRM_26-08-05#story#9126,车辆管理、服务商管理系统需求(去除 log) 2026-08-05 20:24:15 +08:00
zlf dd9d8f453e CRM_26-08-05#story#9126,车辆管理、服务商管理系统需求 2026-08-05 20:21:20 +08:00
zlf 1b323a4bfa CRM_26-08-05#story#9126,车辆管理、服务商管理系统需求 2026-08-05 19:04:02 +08:00
zlf 2dd5f41a3b CRM_26-08-05#story#9126,车辆管理、服务商管理系统需求 2026-08-05 18:43:21 +08:00
zlf cd2aaeef32 CRM_26-08-05#story#9126,车辆管理、服务商管理系统需求 2026-08-05 18:37:22 +08:00
zlf c9f5296e3c CRM_26-08-05#story#9126,车辆管理、服务商管理系统需求 2026-08-05 09:44:28 +08:00
zlf 68fc21992b CRM_26-08-05#story#9126,车辆管理、服务商管理系统需求 2026-08-04 19:29:34 +08:00
zlf e28c8bb6f1 CRM_26-08-05#story#9126,车辆管理、服务商管理系统需求 2026-08-04 19:15:28 +08:00
zlf 3976d086ba CRM_26-08-05#story#9126,车辆管理、服务商管理系统需求 2026-08-04 19:05:59 +08:00
zlf f6dc8ea98b CRM_26-08-05#story#9126,车辆管理、服务商管理系统需求 2026-08-04 18:58:15 +08:00
zlf ae77c69fe9 CRM_26-08-05#story#9126,车辆管理、服务商管理系统需求 2026-08-04 17:57:26 +08:00
zlf 1f0d537c80 CRM_26-08-05#story#9126,车辆管理、服务商管理系统需求 2026-08-04 17:45:47 +08:00
zlf cad1bcc505 CRM_26-08-05#story#9126,车辆管理、服务商管理系统需求( 添加 log ) 2026-08-04 17:29:19 +08:00
zlf 614bb875eb CRM_26-08-05#story#9126,车辆管理、服务商管理系统需求 2026-08-04 17:18:11 +08:00
zlf c404da3520 CRM_26-08-05#story#9126,车辆管理、服务商管理系统需求 2026-08-04 17:11:00 +08:00
zlf f5fab0dd16 CRM_26-08-05#story#9126,车辆管理、服务商管理系统需求 2026-08-04 16:34:05 +08:00
zlf 74ff93863e CRM_26-08-05#story#9126,车辆管理、服务商管理系统需求 2026-08-04 16:12:41 +08:00
zlf 18851a2070 潜在服务商获取添加企微结果添加入参 name 2026-08-04 14:56:31 +08:00
zlf 8cfffb7704 CRM_26-08-05#story#9126,车辆管理、服务商管理系统需求 2026-08-04 14:50:36 +08:00
zlf 9fcdd55af2 临时去除 wechatId 2026-07-31 10:35:41 +08:00
zxl e42b86e05f 忽略文件添加 2026-07-30 17:28:27 +08:00
zxl 4277989def CRM_26-07-21#story#9039,服务商在调度端App与系统中能变更基础信息及对应区域经理审核功能增加 2026-07-27 11:26:48 +08:00
zxl 4888d4964b CRM_26-07-30#story#9040,平安聚合案件失败后在调度端App中显示被聚合师傅的人员与车辆信息,以及未接单原因 2026-07-22 16:28:01 +08:00
zxl 5cb0f8f44d CRM_26-07-21#story#9039,服务商在调度端App与系统中能变更基础信息及对应区域经理审核功能增加 2026-07-22 15:08:14 +08:00
zxl eb696e4409 CRM_26-07-21#story#9039,服务商在调度端App与系统中能变更基础信息及对应区域经理审核功能增加 2026-07-22 13:47:58 +08:00
zxl a5b07e3f40 CRM_26-07-21#story#9039,服务商在调度端App与系统中能变更基础信息及对应区域经理审核功能增加 2026-07-22 13:17:26 +08:00
zlf b95f325b3e 潜在服务商修改 2026-07-20 15:30:40 +08:00
zxl 1001244f38 CRM_26-07-21#story#9039,服务商在调度端App与系统中能变更基础信息及对应区域经理审核功能增加 2026-07-17 09:38:34 +08:00
zxl fb65bf728b 去除VConsole 2026-07-16 09:43:01 +08:00
zxl 257d7fd4c6 CRM_26-07-21#story#8995,调度app上传行驶证自动压缩 2026-07-15 16:43:34 +08:00
zxl 6c0db0163a 添加VConsole 2026-07-15 16:14:05 +08:00
zxl fa53771e59 添加VConsole 2026-07-15 16:07:38 +08:00
zxl e71976ba5f 添加VConsole 2026-07-15 16:02:10 +08:00
zxl fd080aa39b CRM_26-07-21#story#8995,调度app上传行驶证自动压缩 2026-07-13 13:55:28 +08:00
18 changed files with 6596 additions and 71 deletions
+2
View File
@@ -17,6 +17,8 @@ pnpm-debug.log*
# Editor directories and files # Editor directories and files
.idea .idea
.vscode .vscode
.claude
.kimi-code
*.suo *.suo
*.ntvs* *.ntvs*
*.njsproj *.njsproj
@@ -0,0 +1,501 @@
# 调度App H5行驶证拍摄取景引导框 — 需求文档
---
## 需求文档摘要
**一句话概括**:在调度App H5车辆管理页(`vehicleAdd.vue`)的行驶证上传流程中,拦截现有 `van-uploader` 的点击事件,先进入自定义拍摄引导页(WebRTC取景+浅色背景+灰色边框引导框+蓝色角标+Hero提示卡+取景要点卡+预览确认),拍摄确认后将照片传回现有的压缩→上传→OCR流程,实现拍摄环节的引导增强。
**核心要点**
| 维度 | 内容 |
| --- | --- |
| **背景** | 调度App H5车辆管理页行驶证上传使用 `van-uploader` 直接调起系统选择器,无拍摄规范提示,导致卡套反光、证件歪斜、文字模糊等问题频发 |
| **目标** | 拦截行驶证槽位点击,进入拍摄引导页(浅色背景+相机预览+引导框+Hero提示卡+取景要点卡),拍摄后预览确认,确认后回到现有压缩→上传→OCR流程 |
| **现有流程** | `van-uploader` 点击 → 系统选择器 → `after-read` 回调 → `compressFileItem` 压缩 → `uploadImage` 上传 → `ocrHandler` OCR识别 |
| **改造方案** | 行驶证槽位点击 → **拍摄引导页(新增)** → 预览确认(新增)→ `compressFileItem` 压缩 → `uploadImage` 上传 → `ocrHandler` OCR识别 |
| **界面改动** | 新增拍摄引导页组件 + 预览确认页组件;改造 `vehicleAdd.vue` 中行驶证三个槽位的点击逻辑 |
| **数据规则** | 无新增字段;引导框样式与文案为前端静态资源;后端接口完全不变 |
| **原型** | [调度App-H5行驶证拍摄取景引导原型.html](https://sinoassist.yuque.com/attachments/yuque/0/2026/html/34748803/1785205633529-2dd10b98-a855-4a6b-b2a1-894fc14e3ca3.html) |
**技术要点**
+ 后端:无需变更,复用现有 `POST /order/uploadImage``POST /agg-api/tencent-ocr/unifiedOCR`
+ 前端:`supplier-dispatch-h5` 项目 `vehicleAdd.vue` 改造 + 新增拍摄引导组件
+ 数据库:无需变更
---
## 一、基本信息
| 项目 | 内容 |
| --- | --- |
| **需求名称** | 调度App H5行驶证拍摄取景引导框 |
| **提出人** | |
| **产品负责人** | 宋志领 |
| **所属模块** | 调度App H5 - 车辆管理(`vehicleAdd.vue`- 行驶证上传 |
| **需求类型** | 优化 |
| **目标版本** | |
| **紧急程度** | 中 |
## 二、需求背景
### 2.1 现有流程
调度App H5车辆管理页面(`https://crm1.sino-assist.com/h5/supplier/dispatch/vehicleManage`)中,用户在录入或修改车辆信息时,需要上传行驶证主页、副页、车辆照片页。
当前上传流程基于 Vant `van-uploader` 组件实现:
```plain
用户点击 van-uploader 上传槽位
系统调起相册/相机选择器(accept="image/*"
after-read 回调触发(vehicleLicenseFrontHandler / vehicleLicenseBackHandler / vehicleLicenseCarHandler
compressFileItem() 前端压缩(目标 < 5MBJPEG 格式)
uploadImage() 上传 → POST /order/uploadImagemultipart/form-data
ocrHandler() OCR 识别 → POST /agg-api/tencent-ocr/unifiedOCR
回填识别结果到表单字段(车牌号、车架号、所有人等)
```
**现有代码位置**
| 文件 | 关键方法/组件 |
| --- | --- |
| `supplier-dispatch-h5/src/views/index/vehicleAdd.vue` | `van-uploader` 组件(行驶证三个槽位) |
| `supplier-dispatch-h5/src/views/index/vehicleAdd.vue` | `vehicleLicenseFrontHandler()` — 行驶证主页上传 |
| `supplier-dispatch-h5/src/views/index/vehicleAdd.vue` | `vehicleLicenseBackHandler()` — 行驶证副页上传 |
| `supplier-dispatch-h5/src/views/index/vehicleAdd.vue` | `vehicleLicenseCarHandler()` — 行驶证车辆照片上传 |
| `supplier-dispatch-h5/src/views/index/vehicleAdd.vue` | `compressFileItem()` — 图片压缩 |
| `supplier-dispatch-h5/src/api/mine.js` | `uploadImage()` — 上传接口 |
| `supplier-dispatch-h5/src/api/mine.js` | `ocrHandler()` — OCR 识别接口 |
### 2.2 问题分析
当前流程中,用户点击上传槽位后直接调起系统相册/相机选择器,**没有任何拍摄规范提示**。实际采集到的照片普遍存在以下问题:
1. **卡套/保护膜未取出**:证件装在透明卡套或覆膜中拍摄,塑料/玻璃表面反光,遮盖号牌、车架号等关键字段,导致OCR无法识别。
2. **摆放不规整**:歪斜、弯曲、四角缺角、未对准画面中央,导致自动识别定位失败或字段错位。
3. **反光与阴影**:在强光或灯光直射下拍摄,出现高光斑、阴影遮挡文字,造成文字模糊。
上述问题直接造成行驶证OCR识别失败、审核驳回率升高,服务商需反复重传,拉长车辆录入周期。
### 2.3 改造方向
参考业界成熟的证件拍摄方案(如银行卡、身份证识别),在拍摄预览界面上叠加半透明取景引导框,配合文字提示,能从源头规范用户拍摄行为,显著提升照片合格率。
**改造原则**:仅改造拍摄环节(系统选择器 → 自定义拍摄引导页),后续的压缩→上传→OCR流程完全保持不变。
---
## 三、需求目标
1. 用户点击行驶证任一上传槽位时,**不再直接调起系统选择器**,而是进入自定义拍摄引导页。
2. 拍摄引导页在相机预览画面上**自动叠加半透明取景引导框**,框的形状和比例匹配行驶证实际尺寸。
3. 引导框区域显示明确的文字提示:「请将行驶证从卡套内取出后拍摄,避免反光」「请将证件对准框内,确保边缘完整、文字清晰」。
4. 引导框**适配不同手机屏幕尺寸**,在主流移动端设备上显示正常。
5. 用户点击拍摄后,引导框消失,进入**照片预览确认步骤**,支持重拍或确认使用。
6. 用户确认使用后,照片**传回现有的 **`compressFileItem`** → **`uploadImage`** → **`ocrHandler`** 流程**,后续逻辑完全不变。
7. 车头照、保单等非行驶证槽位**保持现有 **`van-uploader`** 直接调起选择器的逻辑**,不受影响。
---
## 四、功能说明
### 功能点 1:拦截行驶证槽位点击,进入拍摄引导页
**功能概述**
| 项目 | 内容 |
| --- | --- |
| **功能名称** | 行驶证槽位点击拦截 |
| **涉及页面** | `vehicleAdd.vue` — 行驶证主页、副页、车辆照片页的 `van-uploader` 组件 |
| **触发条件** | 用户在车辆管理页点击行驶证任一上传槽位(首次上传或重新上传)时触发 |
| **处理逻辑** | 拦截 `van-uploader` 的默认点击行为(调起系统选择器),改为打开拍摄引导页组件 |
| **输出结果** | 进入拍摄引导页,展示相机预览 + 引导框 |
**改造前后对比**
| 步骤 | 改造前(现有流程) | 改造后 |
| --- | --- | --- |
| 1 | 点击 `van-uploader` 槽位 | 点击 `van-uploader` 槽位 |
| 2 | 系统调起相册/相机选择器 | **拦截点击 → 进入拍摄引导页(新增)** |
| 3 | 选择/拍摄照片 | 相机预览 + 引导框 + 文字提示 |
| 4 | `after-read` 回调 | 点击拍摄 → **预览确认页(新增)** |
| 5 | `compressFileItem` 压缩 | 确认使用 → **构造 file 对象,触发 **`after-read`** 回调** |
| 6 | `uploadImage` 上传 | `compressFileItem` 压缩(**不变** |
| 7 | `ocrHandler` OCR 识别 | `uploadImage` 上传(**不变** |
| 8 | 回填表单 | `ocrHandler` OCR 识别(**不变**)→ 回填表单 |
**关键改造点**:拍摄引导页确认使用后,需将 Canvas 捕获的图片 Blob 构造为 `van-uploader``after-read` 回调所期望的 `{ file: File, content: dataURL }` 格式,从而无缝衔接现有的压缩→上传→OCR流程。
---
### 功能点 2:拍摄引导页 — 相机预览 + 引导框
**功能概述**
| 项目 | 内容 |
| --- | --- |
| **功能名称** | 拍摄取景引导框 |
| **涉及页面** | 新增拍摄引导页组件 |
| **触发条件** | 从行驶证槽位点击进入 |
| **处理逻辑** | 使用 WebRTC `getUserMedia` 获取摄像头视频流,在 `video` 元素上全屏展示;叠加浅色背景层(`#F5F5F5`+ 中央镂空引导框(灰色实线边框 `#C2C8D2` + 蓝色 L 形角标 `#354D93` + 入场渐显动画)+ 顶部 Hero 提示卡(白底+蓝色图标)+ 取景要点图标卡(do/don't)+ 底部白色拍摄栏 |
| **输出结果** | 用户在规范引导下完成拍摄 |
**界面设计**(拍摄引导页示意):
```plain
┌─────────────────────────────────────────┐
│ ← 拍摄行驶证主页 │ ← 顶部导航栏(深蓝 #2C395F)
├─────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────┐ │ ← Hero 提示卡(白底+蓝色图标)
│ │ [📁] 请将行驶证从卡套内取出后拍摄 │ │
│ │ 避免反光,确保文字清晰 │ │
│ └───────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────┐ │
│ │└── ──┘ │ │ ← 蓝色 L 形角标(#354D93
│ │ 中华人民共和国机动车行驶证 │ │ ← 证件标题轮廓(半透明蓝色)
│ │ │ │
│ │ │ │ ← 页面其余区域空白
│ │ │ │
│ │ ▢ 印章 │ │ ← 印章位置轮廓(红色方形)
│ │└── ──┘ │ │
│ └───────────────────────────────────┘ │ ← 灰色实线边框(#C2C8D2)
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ ← 取景要点图标卡(白底)
│ │ [✓] │ │ [✓] │ │ [✗] │ │
│ │ 边缘完整 │ │ 文字清晰 │ │ 避免反光 │ │
│ └─────────┘ └─────────┘ └─────────┘ │
│ │
├─────────────────────────────────────────┤
│ ┌──────────┐ │
│ │ ◉ 拍摄 │ │ ← 拍摄按钮(蓝色 #354D93)
│ └──────────┘ │ ← 底部白色拍摄栏
└─────────────────────────────────────────┘
```
> 说明:全屏相机预览(`video` 元素)作为底层背景,整体页面背景为浅色(`#F5F5F5`),营造明亮的拍摄环境。屏幕中央为镂空透明区域(匹配行驶证宽高比约 1.43:1),镂空区域外围为浅色背景(区别于镂空区域的相机预览画面)。镂空区域外圈增加灰色实线边框(`#C2C8D2`,3px,带入场渐显动画),四角显示蓝色 L 形角标(`#354D93`,带入场渐显动画),辅助用户对齐证件边缘。**引导框上方设置 Hero 提示卡**(白底圆角卡片+蓝色图标+主标题"请将行驶证从卡套内取出后拍摄"+副标题"避免反光,确保文字清晰"),**引导框下方设置取景要点图标卡**(3 个白底小卡片,分别用 ✓/✗ 图标标注"边缘完整""文字清晰""避免反光")。**引导框内叠加极简证件模板轮廓**(半透明蓝色,透明度约 20%-35%):主页仅展示顶部一行标题"中华人民共和国机动车行驶证"和左下角红色方形印章,其余区域空白;副页仅展示右下角条形码图案(长度与真实条形码一致)及数字编号,其余区域空白。底部为白色拍摄栏,居中放置蓝色圆形拍摄按钮。
>
**操作按钮**
| 按钮 | 触发条件 | 操作结果 |
| --- | --- | --- |
| 拍摄 | 点击 | 通过 Canvas 捕获当前 `video` 帧,生成图片 Blob,进入预览确认页 |
| 返回(左上角) | 点击 | 关闭相机流,退出拍摄引导页,返回车辆管理页,不上传照片 |
| 点击背景区(非引导框内) | 点击 | 无响应,保持拍摄引导页(防止误触) |
---
### 功能点 3:三页差异化引导
**功能概述**
| 项目 | 内容 |
| --- | --- |
| **功能名称** | 分页面差异化引导 |
| **涉及页面** | 行驶证主页、副页、车辆照片页拍摄引导页 |
| **触发条件** | 按用户点击的具体槽位进入对应拍摄引导页 |
| **处理逻辑** | 三页共用相同的引导框比例和通用提示文案;差异体现在页面标题和**引导框内的证件模板轮廓** |
| **输出结果** | 用户明确知道当前拍摄的是行驶证的哪一页,并能按模板轮廓对齐证件内容 |
**各页引导差异**
| 页面 | 对应槽位 | 对应 Handler | 页面标题 | 模板轮廓内容 |
| --- | --- | --- | --- | --- |
| 行驶证主页 | `vehicleLicenseFrontList` | `vehicleLicenseFrontHandler` | 拍摄行驶证主页 | 仅展示:顶部一行标题"中华人民共和国机动车行驶证" + 左下红色方形印章 |
| 行驶证副页 | `vehicleLicenseBackList` | `vehicleLicenseBackHandler` | 拍摄行驶证副页 | 仅展示:右下角条形码图案(长度与真实条形码一致)+ 条形码数字编号 |
| 行驶证车辆照片 | `vehicleLicenseCarPhotoList` | `vehicleLicenseCarHandler` | 拍摄行驶证车辆照片 | "车辆照片"标题 + 车辆SVG轮廓(车身/车窗/车轮),引导居中拍摄 |
> **说明**:引导框内的证件模板轮廓为半透明叠层(透明度约 20%-35%),仅作视觉对齐辅助,不遮挡相机预览画面。模板轮廓使用 CSS/SVG 静态实现,不依赖后端数据。三种模板通过 `renderLicenseTemplate(type)` 函数根据槽位类型动态切换。
>
---
### 功能点 4:照片预览确认
**功能概述**
| 项目 | 内容 |
| --- | --- |
| **功能名称** | 照片预览确认 |
| **涉及页面** | 拍摄完成后进入的预览确认页 |
| **触发条件** | 用户在拍摄引导页点击"拍摄"按钮后触发 |
| **处理逻辑** | 展示刚拍摄的照片,提供"重拍"和"确认使用"两个操作 |
| **输出结果** | 确认使用 → 照片传回 `van-uploader``after-read` 回调,走现有压缩→上传→OCR流程;重拍则返回拍摄引导页 |
**界面设计**(预览确认页示意):
```plain
┌─────────────────────────────────────────┐
│ ← 返回 │
├─────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────┐ │
│ │ │ │
│ │ 刚拍摄的照片预览 │ │
│ │ (全宽自适应展示) │ │
│ │ │ │
│ └─────────────────────────────┘ │
│ │
│ │
│ ┌──────────┐ ┌──────────┐ │
│ │ 重拍 │ │ 确认使用 │ │
│ └──────────┘ └──────────┘ │
│ │
└─────────────────────────────────────────┘
```
**操作按钮**
| 按钮 | 触发条件 | 操作结果 |
| --- | --- | --- |
| 重拍 | 点击 | 返回拍摄引导页,重新进入相机预览 |
| 确认使用 | 点击 | 将照片 Blob 构造为 `{ file: File, content: dataURL }` 格式,调用对应槽位的 `after-read` Handler(如 `vehicleLicenseFrontHandler`),进入现有压缩→上传→OCR流程 |
| 返回 | 点击 | 关闭相机流,返回车辆管理页,不保存照片 |
**数据衔接说明**
确认使用后,需构造以下对象传给现有的 Handler 方法:
```plain
{
file: new File([blob], `vehicle_license_${type}_${Date.now()}.jpg`, { type: 'image/jpeg' }),
content: dataURL // base64 格式,供 van-uploader 预览使用
}
```
传入后,现有的 `compressFileItem()``uploadImage()``ocrHandler()` 链路完全不变。
---
## 五、业务规则
### 规则 1:触发范围
引导框仅对 `vehicleAdd.vue` 中标题含"行驶证"的三个 `van-uploader` 槽位生效(主页、副页、车辆照片);**车头照**(`vehicleFrontPhotoList`)、**保单**`insurancePictureFiles`)等其他槽位不进入拍摄引导页,保持现有 `van-uploader` 直接调起选择器的交互。
### 规则 2:引导框比例
引导框的宽高比固定为行驶证实物比例(约 1.43:1,横向),在不同屏幕尺寸下按屏幕宽度自适应缩放(宽度约为屏幕宽度的 80%),左右留白均匀,确保用户无需过度调整手机位置即可将证件放入框内。
### 规则 3:后续流程不变
引导框仅做"拍摄环节的视觉辅助",**不改变原上传与OCR识别流程**。用户确认使用后,照片传入现有的 `compressFileItem``uploadImage``ocrHandler` 链路,识别失败仍按现有方式提示重传。
### 规则 4:屏幕适配
引导框在竖屏模式下以横向矩形展示(行驶证为横向证件)。若用户将手机横置,引导框应自适应旋转为纵向矩形以匹配证件方向。优先保证竖屏体验,横屏为可选适配。
### 规则 5:降级策略
若设备不支持 WebRTC `getUserMedia`(部分低版本 Android WebView 或非 HTTPS 环境),**降级为现有的 **`van-uploader`** 直接调起系统选择器**,不阻断用户上传流程。降级时引导框不展示,用户走原有流程。
---
## 六、边界与异常场景
| 场景编号 | 场景描述 | 预期处理 |
| --- | --- | --- |
| 1 | 用户未对准引导框就点击拍摄 | 系统不拦截,正常拍摄并进入预览;用户在预览页可点击"重拍"重新对准 |
| 2 | 相机权限未授权 | 拍摄引导页加载前检测权限;未授权时提示"请允许使用相机权限",并提供"从相册选择"降级入口 |
| 3 | 弱光/暗光环境拍摄 | 引导框正常展示;若设备支持,可自动开启屏幕补光(或提示用户到光线充足处拍摄) |
| 4 | 用户旋转手机(横竖屏切换) | 引导框自适应旋转,保持与行驶证方向一致;拍摄按钮和提示文字跟随屏幕方向调整位置 |
| 5 | 小屏幕手机(如 iPhone SE) | 引导框按比例缩小,提示文字自动换行,确保所有元素在可视区域内完整展示 |
| 6 | 大屏幕手机(如 iPhone Pro Max) | 引导框按比例放大,不超过屏幕安全区域,保持舒适的单手操作距离 |
| 7 | 设备不支持 getUserMedia | 自动降级为 `van-uploader` 直接调起系统选择器,不展示引导框 |
| 8 | iOS 微信内置浏览器权限受限 | 检测到微信环境且 getUserMedia 不可用时,降级为系统选择器 |
| 9 | 拍摄后未进入预览(相机被中断) | 检测到相机关闭/中断事件,自动返回车辆管理页,不上传照片 |
| 10 | 非行驶证槽位(如车头照)点击 | 不进入拍摄引导页,维持现有 `van-uploader` 直接调起选择器的交互 |
| 11 | 修改车辆时重新上传行驶证 | 同样进入拍摄引导页,拍摄确认后替换原照片,走现有压缩→上传→OCR流程 |
---
## 七、影响范围
### 前端
| 文件 | 变更类型 | 变更内容 |
| --- | --- | --- |
| `supplier-dispatch-h5/src/views/index/vehicleAdd.vue` | **改造** | 行驶证三个 `van-uploader` 槽位(`vehicleLicenseFrontList`/`vehicleLicenseBackList`/`vehicleLicenseCarPhotoList`)增加点击拦截逻辑:点击时打开拍摄引导页组件,而非直接调起系统选择器;拍摄确认后将照片传回现有的 `vehicleLicenseFrontHandler`/`vehicleLicenseBackHandler`/`vehicleLicenseCarHandler` |
| `supplier-dispatch-h5/src/views/index/vehicleAdd.vue` | **改造** | 车头照(`vehicleFrontPhotoList`)、保单(`insurancePictureFiles`)槽位**不变**,保持现有 `van-uploader` 直接调起选择器的逻辑 |
| `supplier-dispatch-h5/src/components/CameraGuide.vue` | **新增** | 拍摄引导页组件:WebRTC 相机预览 + 浅色背景层(`#F5F5F5`+ 镂空引导框 + 灰色实线边框(`#C2C8D2`,入场渐显动画) + 四角蓝色 L 形角标(`#354D93`,入场渐显动画) + Hero 提示卡(白底+蓝色图标) + 取景要点图标卡(do/don't + **证件模板轮廓叠层**(主页/副页/车辆照片三种模板动态切换) + 底部白色拍摄栏(蓝色圆形拍摄按钮) |
| `supplier-dispatch-h5/src/components/CameraGuide.vue` | **新增** | 预览确认页:照片展示 + 重拍按钮 + 确认使用按钮 |
| `supplier-dispatch-h5/src/api/mine.js` | **不变** | `uploadImage()``ocrHandler()` 完全复用 |
| `supplier-dispatch-h5/src/utils/` | **不变** | `compressFileItem()` 等工具方法完全复用 |
### 后端
| 文件 | 变更内容 |
| --- | --- |
| 无 | 无需变更,复用现有 `POST /order/uploadImage``POST /agg-api/tencent-ocr/unifiedOCR` |
### 数据库
| 表名 | 变更内容 |
| --- | --- |
| 无 | 无需变更 |
---
## 八、验收标准
### 8.1 拍摄引导页展示
- [ ] 点击行驶证主页/副页/车辆照片任一上传槽位,进入拍摄引导页(非直接调起系统选择器)
- [ ] 拍摄引导页展示全屏相机预览背景(`video` 元素实时画面)
- [ ] 屏幕中央显示镂空引导框,框的宽高比约 1.43:1(横向)
- [ ] 引导框外围为浅色背景(`#F5F5F5`),与镂空区域的相机预览画面形成对比
- [ ] 引导框外圈显示灰色实线边框(`#C2C8D2`3px,带入场渐显动画)
- [ ] 引导框四角显示蓝色 L 形定位角标(`#354D93`,带入场渐显动画)
- [ ] 引导框上方显示 Hero 提示卡(白底圆角卡片+蓝色图标),主标题"请将行驶证从卡套内取出后拍摄",副标题"避免反光,确保文字清晰"
- [ ] 引导框下方显示取景要点图标卡(3 个白底小卡片):边缘完整(✓ 蓝色)、文字清晰(✓ 蓝色)、避免反光(✗ 红色)
- [ ] 页面底部显示白色拍摄栏,居中放置蓝色圆形拍摄按钮(`#354D93`),按钮样式醒目、易于点击
- [ ] 页面标题根据槽位类型显示对应文字("拍摄行驶证主页"/"拍摄行驶证副页"/"拍摄行驶证车辆照片"
- [ ] 引导框内显示证件模板轮廓(半透明,不遮挡相机预览)
- [ ] 主页模板包含:顶部一行标题"中华人民共和国机动车行驶证" + 左下红色方形印章,其余区域空白
- [ ] 副页模板包含:右下角条形码图案(长度与真实条形码一致)+ 条形码数字编号,其余区域空白
- [ ] 车辆照片模板包含:车辆SVG轮廓(车身/车窗/车轮)
- [ ] 切换不同槽位时,模板轮廓自动切换为对应内容
### 8.2 预览确认页展示
- [ ] 点击"拍摄"后进入预览确认页,展示刚拍摄的照片
- [ ] 预览页提供"重拍"按钮,点击后返回拍摄引导页
- [ ] 预览页提供"确认使用"按钮,点击后照片传入现有的 `after-read` Handler
- [ ] 预览页左上角提供"返回"按钮,点击后放弃当前照片,返回车辆管理页
### 8.3 现有流程衔接
- [ ] 确认使用后,照片经 `compressFileItem()` 压缩(目标 < 5MB
- [ ] 压缩后经 `uploadImage()` 上传到服务器(`POST /order/uploadImage`
- [ ] 上传后经 `ocrHandler()` 进行 OCR 识别(`POST /agg-api/tencent-ocr/unifiedOCR`
- [ ] OCR 识别结果正确回填到表单字段(车牌号、车架号、所有人等)
- [ ] 上传槽位显示拍摄的照片缩略图
### 8.4 非行驶证槽位不受影响
- [ ] 点击车头照上传槽位,保持现有 `van-uploader` 直接调起系统选择器的逻辑
- [ ] 点击保单上传槽位,保持现有 `van-uploader` 直接调起系统选择器的逻辑
- [ ] 车头照上传后的 `ocrCarFrontHandler()` OCR 识别正常
### 8.5 屏幕适配
- [ ] 在 iPhone 各型号(SE/标准版/Plus/Pro Max)上引导框比例正确、文字完整显示
- [ ] 在 Android 主流机型(华为/小米/OPPO/vivo 等)上引导框比例正确、文字完整显示
- [ ] 竖屏模式下引导框为横向矩形,横屏模式下自适应为纵向矩形
### 8.6 降级场景
- [ ] 设备不支持 `getUserMedia` 时,降级为 `van-uploader` 直接调起系统选择器
- [ ] 降级后压缩→上传→OCR流程正常
- [ ] iOS 微信内置浏览器权限受限时,降级为系统选择器
---
## 九、非功能要求
+ **性能**:拍摄引导页从点击槽位到展示引导框的耗时 < 500ms;拍摄按钮响应 < 200msCanvas 捕获帧 < 100ms。
+ **兼容性**:支持 iOS Safari 12+、Android Chrome 80+、微信/企业微信内置浏览器;需兼容 WebView 环境。
+ **适配性**:支持屏幕宽度 320px - 430px 的主流手机;引导框和文字在不同尺寸下自适应,不出现截断或重叠。
+ **安全性**:引导内容为纯前端静态展示,不涉及用户照片上传前的任何数据采集与存储;相机流在页面关闭时立即释放。
+ **降级方案**:若设备不支持 WebRTC/getUserMedia,降级为现有的 `van-uploader` 直接调起系统选择器,不阻断用户上传流程。
---
## 流程图
### 改造后完整流程
```plain
用户在 vehicleAdd.vue 点击上传槽位
┌─────────────────────┐
│ 是否为行驶证三个 │
│ 槽位之一? │
│ (主页/副页/车辆照片)│
└─────────────────────┘
│ │
否 是
│ ▼
│ ┌─────────────────────┐
│ │ 检测设备是否支持 │
│ │ getUserMedia
│ └─────────────────────┘
│ │ │
│ 否 是
│ │ │
│ ▼ ▼
│ 降级: 进入拍摄引导页
│ van-uploader WebRTC相机预览 +
│ 直接调起 浅色背景 + 引导框 +
│ 系统选择器 Hero卡 + 要点卡 + 角标)
│ │ │
│ │ ┌──────┼──────┐
│ │ ▼ ▼ ▼
│ │ 返回 拍摄 点击
│ │ 车辆 按钮 背景
│ │ 管理页 │ (无响应)
│ │ ▼
│ │ ┌──────────────┐
│ │ │ 照片预览确认 │
│ │ │ 页面 │
│ │ └──────────────┘
│ │ │
│ │ ┌─────┼─────┐
│ │ ▼ ▼ ▼
│ │ 重拍 确认 返回
│ │ │ 使用 车辆
│ │ │ │ 管理页
│ │ │ ▼
│ │ │ 构造 { file, content }
│ │ │ 传入 after-read Handler
│ │ │ │
▼ ▼ ▼ ▼
┌─────────────────────────┐
│ 现有流程(完全不变) │
│ compressFileItem() 压缩 │
│ │ │
│ ▼ │
│ uploadImage() 上传 │
│ POST /order/uploadImage │
│ │ │
│ ▼ │
│ ocrHandler() OCR识别 │
│ POST /agg-api/ │
│ tencent-ocr/unifiedOCR │
│ │ │
│ ▼ │
│ 回填表单字段 │
│ (车牌号/车架号/所有人等) │
└─────────────────────────┘
```
---
## 影响范围说明
| 维度 | 影响说明 |
| --- | --- |
| **功能** | 仅新增行驶证拍摄前的取景引导层,不改变车辆管理页其他功能和OCR识别流程 |
| **数据** | 无新增业务数据;不修改现有上传接口的入参和出参 |
| **接口** | 无接口变更,复用现有 `POST /order/uploadImage``POST /agg-api/tencent-ocr/unifiedOCR` |
| **配置** | 无变更 |
| **用户习惯** | 用户上传行驶证时多了一步引导页+预览确认,但流程轻量,拍摄确认后无缝衔接现有压缩→上传→OCR流程,整体体验提升 |
## 测试关注点
| 测试类型 | 关注点 |
| --- | --- |
| **功能测试** | 三页槽位均正确进入拍摄引导页;引导框比例正确;提示文字完整显示;拍摄后进入预览确认;确认后正确回填到 `van-uploader` 并触发现有 Handler |
| **流程衔接测试** | 确认使用后 `compressFileItem` 压缩正常;`uploadImage` 上传成功;`ocrHandler` OCR 识别结果正确回填表单;行驶证主页 OCR 回填车牌号/车架号/所有人等;行驶证副页 OCR 回填档案编号/核载人数等 |
| **兼容测试** | iPhone SE/标准版/Plus/Pro Max;华为/小米/OPPO/vivo 主流机型;微信/企业微信内置浏览器;Safari/Chrome |
| **回归测试** | 车头照等非行驶证槽位保持原有直接调选择器逻辑;车辆管理页其他功能不受影响;OCR 识别与审核流程正常;修改车辆时重新上传行驶证正常 |
| **降级测试** | 不支持 `getUserMedia` 的设备降级为 `van-uploader` 系统选择器;降级后压缩→上传→OCR 流程正常 |
| **适配测试** | 竖屏/横屏切换;320px 窄屏与 430px 宽屏的显示效果;文字不截断、按钮可点击 |
File diff suppressed because one or more lines are too long
+8
View File
@@ -18,6 +18,14 @@ export function saveVehicle(data){
data data
}) })
} }
//车辆属性预判(行驶证OCR识别出车辆所有人后调用,命中服务商信息则锁定为自有车辆)
export function preJudgeVirtualVehicle(data){
return request({
url:'/supplierAppV2/dispatchApp/user/preJudgeVirtualVehicle',
method:'POST',
data
})
}
//提交审批车辆信息 //提交审批车辆信息
export function saveSupplierApproval(data){ export function saveSupplierApproval(data){
return request({ return request({
+43
View File
@@ -0,0 +1,43 @@
import request from '@/utils/http'
/**
* 查询当前服务商分时段电话列表和审批状态
* @param {Object} params
* @param {number} params.supplierId 服务商 ID
*/
export function getSupplierPhoneList(params) {
return request({
url: '/supplierAppV2/dispatchApp/supplierPhone/list',
method: 'GET',
params
})
}
/**
* 查询当前服务商电话时段变更审批状态
* @param {Object} params
* @param {number} params.supplierId 服务商 ID
*/
export function getSupplierPhoneApprovalStatus(params) {
return request({
url: '/supplierAppV2/dispatchApp/supplierPhone/approvalStatus',
method: 'GET',
params
})
}
/**
* 提交服务商电话时段变更审批
* @param {Object} data
* @param {number} data.supplierId 服务商 ID
* @param {string} [data.modifyReason] 修改原因
* @param {Array} data.phoneList 电话时段草稿列表
*/
export function submitSupplierPhoneApproval(data) {
return request({
url: '/supplierAppV2/dispatchApp/supplierPhone/submit',
method: 'POST',
contentType: 'application/json',
data
})
}
+706
View File
@@ -0,0 +1,706 @@
<template>
<div class="cameraGuide" v-if="visible">
<!-- ===== 拍摄引导页 ===== -->
<div class="cameraPage" v-show="step==='guide'">
<div class="cameraNav">
<van-icon name="arrow-left" class="backBtn" @click="handleClose" />
<span class="navTitle">{{ navTitle }}</span>
</div>
<div class="guideLayer">
<!-- 顶部主指令卡 -->
<div class="guideHero">
<div class="guideHeroIcon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="5" width="18" height="14" rx="2"></rect>
<path d="M3 9h18"></path>
<circle cx="8" cy="13.5" r="1.6"></circle>
<path d="M11 12.5h7M11 15h5"></path>
</svg>
</div>
<div class="guideHeroText">
<div class="guideHeroTitle">{{ heroTitle }}</div>
<div class="guideHeroSub">从卡套内拿出避免反光</div>
</div>
</div>
<!-- 取景框 -->
<div class="guideFrameOuter">
<span class="corner tl"></span>
<span class="corner tr"></span>
<span class="corner bl"></span>
<span class="corner br"></span>
<div class="guideFrameBorder"></div>
<div class="guideHole">
<!-- 相机画面仅在取景框内显示 -->
<video ref="video" class="cameraVideo" autoplay playsinline webkit-playsinline muted></video>
<!-- 主页模板标题 + 印章 -->
<div v-if="type==='front'" class="licenseTemplate ltFrontPage">
<div class="ltInner">
<div class="ltTitle">
<div class="ltTitleLine">中华人民共和国机动车行驶证</div>
</div>
<div class="ltSeal">印章</div>
</div>
</div>
<!-- 副页模板条形码 + 编号 -->
<div v-else-if="type==='back'" class="licenseTemplate ltBackPage">
<div class="ltInner">
<div class="ltBarcodeArea">
<div class="ltBarcodeStrip">
<div v-for="(bar, i) in barcodeBars" :key="i" class="ltBar" :class="bar"></div>
</div>
<div class="ltBarcodeNum">3701001234567</div>
</div>
</div>
</div>
<!-- 车辆照片模板标题 + 车辆轮廓 -->
<div v-else class="licenseTemplate ltCarPage">
<div class="ltInner">
<div class="ltTitle">
<div class="ltTitleLine">车辆照片</div>
</div>
<div class="ltCarOutline">
</div>
</div>
</div>
</div>
</div>
<!-- 底部取景要点 -->
<div class="guideTips">
<div class="tipItem ok">
<span class="tipIco">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 8V4h4M16 4h4v4M20 16v4h-4M8 20H4v-4"></path>
</svg>
</span>
<span class="tipLabel">四角入框</span>
</div>
<div class="tipItem ok">
<span class="tipIco">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 12h16M7 7h10M7 17h10"></path>
</svg>
</span>
<span class="tipLabel">平放正向</span>
</div>
<div class="tipItem warn">
<span class="tipIco">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"></circle>
<path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M19 5l-2 2M5 19l2-2"></path>
</svg>
</span>
<span class="tipLabel">避免反光</span>
</div>
</div>
</div>
<div class="shootBar">
<div class="shootBtn" @click="takePhoto"></div>
<div class="shootLabel">点击拍照</div>
</div>
</div>
<!-- ===== 预览确认页 ===== -->
<div class="previewPage" v-show="step==='preview'">
<div class="cameraNav">
<van-icon name="arrow-left" class="backBtn" @click="handlePreviewBack" />
<span class="navTitle">照片预览</span>
</div>
<div class="previewImgWrap">
<img class="previewImg" :src="previewDataURL" alt="预览" />
</div>
<div class="previewBtns">
<button class="previewBtn btnRetake" @click="retake">重拍</button>
<button class="previewBtn btnConfirm" @click="confirmPhoto">确认使用</button>
</div>
</div>
</div>
</template>
<script>
// 拍摄引导组件:WebRTC 取景 + 引导框 + 分页面证件模板 + 预览确认
// props: type(front/back/car), visible
// emit: confirm({blob, dataURL})、cancel、fallback(权限拒绝/相机不可用,父组件降级系统选择器)
export default {
name: 'CameraGuide',
props: {
type: {
type: String,
default: 'front'
},
visible: {
type: Boolean,
default: false
},
// 父组件前置探测时已拿到的相机流,传入则直接复用(iOS 上避免二次 getUserMedia 黑屏)
stream: {
type: Object,
default: null
}
},
data() {
const heights = ['tall', 'short', 'medium wide', 'tall', 'short', 'medium', 'tall wide', 'short',
'medium', 'tall', 'short wide', 'medium', 'tall', 'short', 'medium wide', 'tall',
'short', 'medium', 'tall wide', 'short', 'medium', 'tall', 'short wide', 'medium',
'tall', 'short', 'medium wide', 'tall', 'short', 'medium', 'tall wide', 'short',
'medium', 'tall', 'short wide', 'medium', 'tall', 'short', 'medium wide', 'tall']
return {
step: 'guide', // guide 拍摄引导 | preview 预览确认
activeStream: null,
previewBlob: null,
previewDataURL: '',
barcodeBars: heights
}
},
computed: {
navTitle() {
return { front: '拍摄行驶证主页', back: '拍摄行驶证副页', car: '拍摄行驶证车辆照片' }[this.type] || '拍摄行驶证'
},
heroTitle() {
return { front: '请将主页对准取景框', back: '请将副页对准取景框', car: '请将车辆对准取景框' }[this.type] || '请将证件对准取景框'
}
},
watch: {
visible(val) {
if (val) {
this.step = 'guide'
this.previewBlob = null
this.previewDataURL = ''
this.$nextTick(() => this.startCamera())
} else {
this.stopCamera()
}
}
},
beforeDestroy() {
this.stopCamera()
},
methods: {
async startCamera() {
try {
// 优先复用父组件探测时拿到的流,未传入才自行申请
const stream = this.stream || await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' },
audio: false
})
// 等待期间组件已被关闭或销毁,立即释放
if (!this.visible || this._isDestroyed || this._isBeingDestroyed) {
stream.getTracks().forEach(t => t.stop())
return
}
this.activeStream = stream
console.log('[CameraGuide] stream acquired, 来源:', this.stream ? '父组件复用' : '组件自申请', 'active:', stream.active,
'tracks:', stream.getTracks().map(t => `${t.kind}/${t.readyState}/enabled:${t.enabled}`))
await this.$nextTick()
const video = this.$refs.video
if (video) {
// iOS 自动播放加固:muted/playsinline 属性与特性同时设置,元数据就绪后再 play
video.muted = true
video.setAttribute('playsinline', 'true')
video.setAttribute('webkit-playsinline', 'true')
video.srcObject = stream
const logVideoState = (tag) => {
console.log(`[CameraGuide] ${tag}:`,
'paused:', video.paused,
'readyState:', video.readyState,
'currentTime:', video.currentTime,
'videoWidth:', video.videoWidth,
'videoHeight:', video.videoHeight,
'streamActive:', stream.active,
'muted:', video.muted)
}
const playVideo = () => {
video.play()
.then(() => logVideoState('play resolved'))
.catch(e => {
console.warn('[CameraGuide] video play error:', e && (e.name || e.message || e))
logVideoState('play rejected')
})
}
video.onloadedmetadata = () => {
logVideoState('loadedmetadata')
playVideo()
}
video.onplaying = () => logVideoState('playing')
video.onerror = () => {
console.warn('[CameraGuide] video element error:', video.error && `${video.error.code}/${video.error.message}`)
}
if (video.readyState >= 1) {
playVideo()
}
// 2 秒后再看一次:currentTime 是否增长(判断是真在播还是黑屏死流)
setTimeout(() => logVideoState('after 2s'), 2000)
}
} catch (e) {
// 权限拒绝或相机不可用 → 通知父组件降级为系统选择器
console.warn('getUserMedia error:', e && (e.name || e.message || e))
this.$emit('fallback', e)
}
},
stopCamera() {
if (this.activeStream) {
this.activeStream.getTracks().forEach(t => t.stop())
this.activeStream = null
}
},
takePhoto() {
const video = this.$refs.video
if (!video || !video.videoWidth) {
this.$toast('相机尚未就绪,请稍候')
return
}
// 按取景框比例(约 1.43:1)从视频中心裁剪,与框内可视区域(object-fit: cover)保持一致
const frameAspect = 1.43
const vw = video.videoWidth
const vh = video.videoHeight
let sw, sh
if (vw / vh > frameAspect) {
sh = vh
sw = Math.round(vh * frameAspect)
} else {
sw = vw
sh = Math.round(vw / frameAspect)
}
const sx = Math.round((vw - sw) / 2)
const sy = Math.round((vh - sh) / 2)
const canvas = document.createElement('canvas')
canvas.width = sw
canvas.height = sh
canvas.getContext('2d').drawImage(video, sx, sy, sw, sh, 0, 0, sw, sh)
const dataURL = canvas.toDataURL('image/jpeg', 0.92)
canvas.toBlob(blob => {
if (!blob) {
this.$toast('拍摄失败,请重试')
return
}
this.previewBlob = blob
this.previewDataURL = dataURL
this.step = 'preview'
}, 'image/jpeg', 0.92)
},
retake() {
// 重拍回到引导页,相机流保持
this.step = 'guide'
},
confirmPhoto() {
this.$emit('confirm', { blob: this.previewBlob, dataURL: this.previewDataURL })
},
handleClose() {
this.$emit('cancel')
},
handlePreviewBack() {
// 预览页返回 = 放弃拍摄,关闭并释放相机流
this.$emit('cancel')
}
}
}
</script>
<style scoped lang="scss">
.cameraGuide {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 3000;
background: #F5F5F5;
}
.cameraPage {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(160deg, #F7F9FC 0%, #EEF1F6 50%, #E6EAF1 100%);
overflow: hidden;
}
.cameraVideo {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 1;
}
.cameraNav {
position: relative;
z-index: 10;
display: flex;
align-items: center;
height: 52px;
padding-left: 14px;
padding-top: env(safe-area-inset-top);
background: #2C395F;
color: #fff;
.backBtn {
font-size: 22px;
width: 36px;
flex-shrink: 0;
}
.navTitle {
flex: 1;
text-align: left;
font-size: 18px;
font-weight: bolder;
letter-spacing: .5px;
padding-left: 4px;
}
}
.guideLayer {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 5;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 80px;
/* 避让底部拍照栏(约 142px 高),防止小屏下提示被遮挡 */
padding-bottom: 150px;
padding-bottom: calc(150px + env(safe-area-inset-bottom));
pointer-events: none;
}
.guideHero {
display: flex;
align-items: center;
gap: 12px;
background: #FFFFFF;
border: 1px solid #E9E9EA;
border-radius: 6px;
padding: 12px 15px;
margin-bottom: 24px;
max-width: 322px;
width: calc(100% - 48px);
box-sizing: border-box;
box-shadow: 0 2px 10px rgba(20, 30, 50, .08);
.guideHeroIcon {
width: 42px;
height: 42px;
flex-shrink: 0;
border-radius: 6px;
background: rgba(53, 77, 147, .10);
display: flex;
align-items: center;
justify-content: center;
color: #354D93;
svg {
width: 23px;
height: 23px;
}
}
.guideHeroTitle {
color: #222;
font-size: 16px;
font-weight: 700;
line-height: 1.3;
letter-spacing: .3px;
}
.guideHeroSub {
color: #5B606E;
font-size: 12.5px;
margin-top: 4px;
line-height: 1.4;
}
}
/* 取景框:宽约为屏宽 80%,宽高比约 1.43:1;小屏按可视高度自适应缩小,避免遮挡上下元素 */
.guideFrameOuter {
position: relative;
width: 80%;
/* 纵向可用高度 ≈ 100vh - (导航80 + 拍照栏150 + 提示卡92 + 底部提示92) ≈ 100vh - 420px
换算成宽度(×1.43)并限制在 170~340px 之间,大屏表现与原 80%/340px 一致 */
width: clamp(170px, min(80%, calc((100vh - 420px) * 1.43)), 340px);
width: clamp(170px, min(80%, calc((100dvh - 420px) * 1.43)), 340px);
/* 宽高比约 1.43:1(与拍照裁剪比例一致);不用 padding-top 百分比——它相对父级宽度,无法跟随自适应宽度 */
aspect-ratio: 1.43 / 1;
flex-shrink: 0;
/* 不用 filter: drop-shadowiOS 上父级 filter 会导致 video 合成层不绘制(黑屏) */
box-shadow: 0 4px 16px rgba(20, 30, 50, .12);
}
/* 兼容不支持 aspect-ratio 的旧浏览器:padding-top 相对父级宽度,80% / 1.43 ≈ 55.94% */
@supports not (aspect-ratio: 1 / 1) {
.guideFrameOuter {
padding-top: 55.94%;
}
}
.corner {
position: absolute;
width: 30px;
height: 30px;
border-color: #354D93;
border-style: solid;
z-index: 10;
animation: cornerFadeIn .45s ease both;
&.tl { top: -4px; left: -4px; border-width: 4px 0 0 4px; border-radius: 4px 0 0 0; }
&.tr { top: -4px; right: -4px; border-width: 4px 4px 0 0; border-radius: 0 4px 0 0; }
&.bl { bottom: -4px; left: -4px; border-width: 0 0 4px 4px; border-radius: 0 0 0 4px; }
&.br { bottom: -4px; right: -4px; border-width: 0 4px 4px 0; border-radius: 0 0 4px 0; }
}
@keyframes cornerFadeIn {
from { opacity: 0; transform: scale(1.15); }
to { opacity: 1; transform: scale(1); }
}
.guideFrameBorder {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border: 3px solid #C2C8D2;
border-radius: 8px;
pointer-events: none;
z-index: 9;
}
.guideHole {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 8px;
overflow: hidden;
}
/* 证件模板叠层(仅视觉辅助,不拦截事件) */
.licenseTemplate {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 6;
border: 1.5px solid rgba(53, 77, 147, .55);
border-radius: 4px;
overflow: hidden;
pointer-events: none;
background: rgba(53, 77, 147, .06);
}
.ltInner {
position: absolute;
top: 5px;
left: 5px;
right: 5px;
bottom: 5px;
display: flex;
flex-direction: column;
}
.ltTitle {
text-align: center;
margin-bottom: 4px;
}
.ltTitleLine {
font-size: 10px;
color: rgba(53, 77, 147, .9);
font-weight: bold;
letter-spacing: 1px;
white-space: nowrap;
}
.ltSeal {
position: absolute;
left: 10px;
bottom: 10px;
width: 34px;
height: 34px;
border: 1.5px dashed rgba(255, 85, 59, .85);
border-radius: 2px;
display: flex;
align-items: center;
justify-content: center;
font-size: 5.5px;
color: rgba(255, 85, 59, .85);
}
.ltFrontPage .ltInner {
justify-content: flex-start;
}
.ltBackPage .ltInner {
justify-content: flex-end;
}
.ltBarcodeArea {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
padding-bottom: 6px;
padding-right: 8px;
}
.ltBarcodeStrip {
display: flex;
align-items: flex-end;
height: 28px;
gap: 1px;
.ltBar {
background: rgba(53, 77, 147, .6);
width: 1.5px;
border-radius: 0.5px;
&.wide { width: 2.8px; }
&.short { height: 50%; }
&.medium { height: 70%; }
&.tall { height: 100%; }
}
}
.ltBarcodeNum {
font-size: 8px;
color: rgba(53, 77, 147, .9);
letter-spacing: 2px;
font-family: 'Courier New', monospace;
}
.ltCarPage .ltCarOutline {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
svg {
width: 60%;
opacity: .6;
color: #354D93;
}
}
.guideTips {
display: flex;
gap: 10px;
margin-top: 22px;
padding: 0 14px;
max-width: 340px;
width: 100%;
box-sizing: border-box;
}
.tipItem {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 7px;
background: #FFFFFF;
border-radius: 6px;
padding: 11px 4px 9px;
border: 1px solid #E9E9EA;
box-shadow: 0 2px 8px rgba(20, 30, 50, .06);
.tipIco {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
svg {
width: 17px;
height: 17px;
}
}
&.ok .tipIco {
background: rgba(53, 77, 147, .10);
color: #354D93;
}
&.warn .tipIco {
background: rgba(255, 85, 59, .12);
color: #FF553B;
}
.tipLabel {
color: #5B606E;
font-size: 11.5px;
line-height: 1.3;
text-align: center;
}
}
.shootBar {
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
padding-top: 12px;
padding-bottom: calc(32px + env(safe-area-inset-bottom));
background: #FFFFFF;
border-top: 1px solid #E9E9EA;
}
.shootBtn {
width: 72px;
height: 72px;
border-radius: 50%;
border: 4px solid #354D93;
background: #FFFFFF;
position: relative;
box-shadow: 0 0 0 2px rgba(53, 77, 147, .22), 0 6px 16px rgba(20, 30, 50, .18);
&:active {
transform: scale(.92);
}
&::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 58px;
height: 58px;
border-radius: 50%;
background: #354D93;
}
}
.shootLabel {
color: #5B606E;
font-size: 12px;
margin-top: 10px;
letter-spacing: 1px;
}
/* ===== 预览确认页 ===== */
.previewPage {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #F5F5F5;
display: flex;
flex-direction: column;
z-index: 20;
}
.previewImgWrap {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
overflow: hidden;
}
.previewImg {
max-width: 100%;
max-height: 100%;
border-radius: 8px;
object-fit: contain;
}
.previewBtns {
display: flex;
gap: 16px;
padding: 0 20px 32px;
padding-bottom: calc(32px + env(safe-area-inset-bottom));
}
.previewBtn {
flex: 1;
height: 48px;
border-radius: 24px;
border: none;
font-size: 16px;
font-weight: 600;
&:active {
opacity: .8;
}
}
.btnRetake {
background: #FFFFFF;
color: #354D93;
border: 1px solid #354D93;
}
.btnConfirm {
background: #354D93;
color: #fff;
}
</style>
+136
View File
@@ -0,0 +1,136 @@
<template>
<div class="step-bar">
<div
v-for="(step, index) in steps"
:key="index"
class="step-item"
:class="getStepClass(index)"
>
<div class="step-circle" @click="onStepClick(index)">
<span v-if="getStatus(index) === 'finish'" class="step-check"></span>
<span v-else>{{ index + 1 }}</span>
</div>
<div class="step-label">{{ step }}</div>
<div v-if="index < steps.length - 1" class="step-line" :class="{'step-line-done': getStatus(index) === 'finish'}"></div>
</div>
</div>
</template>
<script>
export default {
name: 'StepBar',
props: {
current: {
type: Number,
default: 1
},
steps: {
type: Array,
default: () => ['基本信息', '添加企微', '车辆信息', '驾驶员信息']
}
},
methods: {
getStatus(index) {
const stepNum = index + 1;
if (stepNum < this.current) return 'finish';
if (stepNum === this.current) return 'process';
return 'wait';
},
getStepClass(index) {
return `step-${this.getStatus(index)}`;
},
onStepClick(index) {
const stepNum = index + 1;
if (this.getStatus(index) === 'finish') {
this.$emit('step-click', stepNum);
}
}
}
}
</script>
<style scoped lang="scss">
.step-bar {
display: flex;
align-items: flex-start;
justify-content: center;
padding: 14px 10px 10px;
background: #fff;
position: relative;
}
.step-item {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
position: relative;
}
.step-circle {
width: 26px;
height: 26px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: bold;
z-index: 1;
position: relative;
}
.step-finish .step-circle {
background: #0E76F4;
color: #fff;
cursor: pointer;
}
.step-process .step-circle {
background: #0E76F4;
color: #fff;
box-shadow: 0 0 0 3px rgba(14, 118, 244, 0.2);
}
.step-wait .step-circle {
background: #e0e0e0;
color: #999;
}
.step-label {
font-size: 11px;
margin-top: 5px;
text-align: center;
white-space: nowrap;
}
.step-finish .step-label {
color: #0E76F4;
}
.step-process .step-label {
color: #0E76F4;
font-weight: bold;
}
.step-wait .step-label {
color: #999;
}
.step-line {
position: absolute;
top: 13px;
left: calc(50% + 13px);
right: calc(-50% + 13px);
height: 2px;
background: #e0e0e0;
}
.step-line-done {
background: #0E76F4;
}
.step-check {
font-size: 14px;
}
</style>
+26 -1
View File
@@ -43,6 +43,22 @@ const routes = [
title: '信息查看', title: '信息查看',
} }
}, },
{
path: '/supplierTimePhone',
name: 'supplierTimePhone',
component: () => import('@/views/index/supplierTimePhone'),
meta:{
title: '供应商时段电话',
}
},
{
path: '/supplierTimePhoneEdit',
name: 'supplierTimePhoneEdit',
component: () => import('@/views/index/supplierTimePhoneEdit'),
meta:{
title: '编辑时段电话',
}
},
{ {
path: '/toDoList', path: '/toDoList',
name: 'toDoList', name: 'toDoList',
@@ -193,7 +209,16 @@ const routes = [
name: 'vehicleAdd', name: 'vehicleAdd',
component: () => import('@/views/index/vehicleAdd'), component: () => import('@/views/index/vehicleAdd'),
meta: { meta: {
title: '车辆新增或修改' title: '车辆新增或修改',
cache: true // keep-alive 缓存:跳转拍摄页返回时保留表单,再次从其他页面进入时由 activated 重新初始化
}
},
{
path: "/cameraGuide",
name: 'cameraGuide',
component: () => import('@/views/index/cameraGuide'),
meta: {
title: '拍摄行驶证'
} }
}, },
{ {
+22
View File
@@ -0,0 +1,22 @@
// 拍摄页与表单页之间的内存中转:blob 无法走路由 query,返回时由表单页取出并清空
export default {
namespaced: true,
state: {
result: null, // 拍照确认结果 { type, blob, dataURL }
fallback: null // 相机不可用降级信息 { type, error }
},
mutations: {
setResult(state, payload) {
state.result = payload
state.fallback = null
},
setFallback(state, payload) {
state.fallback = payload
state.result = null
},
clear(state) {
state.result = null
state.fallback = null
}
}
}
+51
View File
@@ -0,0 +1,51 @@
/**
* 校验电话号码
* 支持:大陆手机号、大陆固话、400电话、港澳手机号
* @param {string} phone
* @returns {boolean}
*/
export function isValidPhone(phone) {
if (!phone) return false
// 大陆手机号
const mainlandMobile = /^1[3-9]\d{9}$/
// 大陆固话:区号(可选) + 7~8位号码
const mainlandLandline = /^(0\d{2,3}-?)\d{7,8}$/
// 400电话
const phone400 = /^400-?\d{3}-?\d{4}$/
// 港澳手机号(8位,首位5/6/8/9)
const hkMacaoMobile = /^[5689]\d{7}$/
// 港澳固话
const hkMacaoLandline = /^(\d{4}-?)?\d{4}$/
return (
mainlandMobile.test(phone) ||
mainlandLandline.test(phone) ||
phone400.test(phone) ||
hkMacaoMobile.test(phone) ||
hkMacaoLandline.test(phone)
)
}
/**
* 校验时段格式 HH:mm 及起止关系
* @param {string} startTime
* @param {string} endTime
* @returns {{valid: boolean, message?: string}}
*/
export function validateTimeRange(startTime, endTime) {
const timeRegex = /^([0-1]\d|2[0-3]):([0-5]\d)$/
if (!startTime || !endTime) {
return { valid: false, message: '请填写完整的起止时段' }
}
if (!timeRegex.test(startTime) || !timeRegex.test(endTime)) {
return { valid: false, message: '时段格式不正确,应为 00:00 ~ 23:59' }
}
const [startH, startM] = startTime.split(':').map(Number)
const [endH, endM] = endTime.split(':').map(Number)
const start = startH * 60 + startM
const end = endH * 60 + endM
if (start > end) {
return { valid: false, message: '开始时间不得晚于结束时间' }
}
return { valid: true }
}
+2 -2
View File
@@ -72,10 +72,10 @@
async nextStep() { async nextStep() {
if (!this.clickFlag) return; if (!this.clickFlag) return;
if(!this.wechatId) { if(!this.wechatId) {
let res = await getContactQrCodeResult({ configId: this.configId }); let res = await getContactQrCodeResult({ configId: this.configId, name: this.name });
this.wechatId = res?.data?.wechatId; this.wechatId = res?.data?.wechatId;
} }
/* if (!this.wechatId) { /*if (!this.wechatId) {
Toast('请先添加企微再继续'); Toast('请先添加企微再继续');
return; return;
}*/ }*/
+47
View File
@@ -0,0 +1,47 @@
<template>
<!-- 独立拍摄页 vehicleAdd 跳转进入query.type = front/back/car
确认/取消/降级后 $router.back() 返回 vehicleAdd -->
<camera-guide
:visible="show"
:type="type"
@confirm="onConfirm"
@cancel="goBack"
@fallback="onFallback"
/>
</template>
<script>
import CameraGuide from "@/components/CameraGuide.vue";
export default {
name: 'cameraGuidePage',
components: { CameraGuide },
data() {
return {
show: false // 挂载后置 true,触发组件 visible 侦听器启动相机
}
},
computed: {
type() {
const t = this.$route.query.type
return ['front', 'back', 'car'].includes(t) ? t : 'front'
}
},
mounted() {
this.show = true
},
methods: {
onConfirm({ blob, dataURL }) { // 拍照确认:结果存入 store,返回 vehicleAdd 消费
this.$store.commit('camera/setResult', { type: this.type, blob, dataURL })
this.$router.back()
},
onFallback(e) { // 相机不可用:降级信息存入 store,返回 vehicleAdd 走系统选择器
this.$store.commit('camera/setFallback', { type: this.type, error: e })
this.$router.back()
},
goBack() {
this.$router.back()
}
}
}
</script>
+417
View File
@@ -0,0 +1,417 @@
<template>
<div class="wrap">
<van-nav-bar
title="供应商时段电话"
left-arrow
@click-left="onNavBack"
fixed
placeholder
class="nav-bar"
/>
<div class="content">
<!-- 审批状态提示条 -->
<div v-if="inApproval" class="status-tip status-tip--pending">{{approvalStateName }}</div>
<div v-else-if="rejected" class="status-tip status-tip--danger">
<div class="status-tip__title">审批被驳回</div>
<div v-if="approvalRemark" class="status-tip__desc">{{ approvalRemark }}</div>
</div>
<!-- 空态 -->
<div v-if="!phoneList.length" class="empty-wrap">
<div class="empty-text">暂无分时段电话点击新增时段电话</div>
</div>
<!-- 时段卡片列表 -->
<div v-else class="card-list">
<div v-for="(item, index) in phoneList" :key="index" class="time-card" @click="goEdit(index)">
<div class="card-head">
<span class="period">{{ item.startTime }} {{ item.endTime }}</span>
<div class="ops" @click.stop v-if="!inApproval">
<span class="op-edit" @click="goEdit(index)">编辑</span>
<span class="op-del" @click="deleteItem(index)">删除</span>
</div>
</div>
<div class="divider"></div>
<div class="phone-rows">
<div v-for="phone in visiblePhones(item)" :key="phone.index" class="phone-row">
<span class="phone-icon"></span>
<span class="phone-num">{{ phone.num }}</span>
<span v-if="phone.desc" class="tag">{{ phone.desc }}</span>
<span v-if="phone.wx" class="tag tag-wx">:{{ phone.wx }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 底部操作栏 -->
<div class="footer">
<button v-if="!inApproval" class="btn btn-add" @click="goAdd"> 新增时段</button>
<button
class="btn btn-submit"
:disabled="inApproval || !phoneList.length"
@click="onSubmitClick"
>
{{ inApproval ? '审核中' : '提交审批' }}
</button>
</div>
</div>
</template>
<script>
import { getSupplierPhoneList, submitSupplierPhoneApproval } from '@/api/supplierPhone'
import { myMixins } from '@/utils/myMixins'
import { Dialog, Toast } from 'vant'
const DRAFT_KEY_PREFIX = 'supplierTimePhoneDraft_'
function getSupplierIdFromToken() {
const token = localStorage.getItem('token')
if (!token) return null
try {
const payload = JSON.parse(atob(token.split('.')[1]))
return payload.supplierId || null
} catch (e) {
return null
}
}
function getDraftKey(supplierId) {
return `${DRAFT_KEY_PREFIX}${supplierId}`
}
export default {
name: 'supplierTimePhone',
mixins: [myMixins],
data() {
return {
supplierId: null,
supplierName: '',
approvalId: null,
approvalState: null,
approvalStateName: '',
inApproval: false,
rejected: false,
approvalRemark: '',
phoneList: []
}
},
mounted() {
const urlParams = new URLSearchParams(window.location.search);
this.supplierId = urlParams.get('supplierId') || this.$route.query.supplierId || getSupplierIdFromToken()
if (!this.supplierId) {
Toast('未获取到服务商信息')
return
}
this.loadData()
},
methods: {
async loadData() {
Toast.loading({ message: '加载中...', forbidClick: true, duration: 0 })
try {
const result = await getSupplierPhoneList({ supplierId: this.supplierId })
const res=result?.data || ''
this.supplierName = res?.supplierName || ''
this.approvalId = res?.approvalId || null
this.approvalState = res?.approvalState || null
this.approvalStateName = res?.approvalStateName || ''
this.inApproval = !!res?.inApproval
this.rejected = !!res?.rejected
this.approvalRemark = res?.approvalRemark || ''
const cached = this.getCachedDraft()
if (cached && Array.isArray(cached.phoneList)) {
this.phoneList = cached.phoneList
} else {
this.phoneList = res?.phoneList || []
this.saveDraft()
}
} catch (e) {
console.error(e)
} finally {
Toast.clear()
}
},
visiblePhones(item) {
const list = []
for (let i = 1; i <= 3; i++) {
const num = item['phone' + i]
if (num) {
list.push({
index: i,
num,
desc: item['phone' + i + 'Desc'],
wx: item['phone' + i + 'WechatId']
})
}
}
return list
},
getCachedDraft() {
try {
const raw = localStorage.getItem(getDraftKey(this.supplierId))
return raw ? JSON.parse(raw) : null
} catch (e) {
return null
}
},
saveDraft() {
const draft = {
approvalId: this.approvalId,
approvalState: this.approvalState,
phoneList: this.phoneList
}
localStorage.setItem(getDraftKey(this.supplierId), JSON.stringify(draft))
},
onNavBack() {
localStorage.removeItem(getDraftKey(this.supplierId))
this.goBack()
},
goAdd() {
this.saveDraft()
this.goPage('supplierTimePhoneEdit', { supplierId: this.supplierId, index: -1 })
},
goEdit(index) {
this.saveDraft()
this.goPage('supplierTimePhoneEdit', { supplierId: this.supplierId, index })
},
deleteItem(index) {
Dialog.confirm({
title: '提示',
message: '确定删除该时段电话?'
}).then(() => {
this.phoneList.splice(index, 1)
this.saveDraft()
}).catch(() => {})
},
// 将 HH:mm 转换为分钟数,兼容中英文冒号
timeToMinutes(timeStr) {
if (!timeStr) return null
const normalized = String(timeStr).trim().replace(//g, ':')
const [h, m] = normalized.split(':').map(Number)
if (isNaN(h) || isNaN(m)) return null
return h * 60 + m
},
// 校验时间段:重叠、00:00 开始、23:59 结束、遗漏(允许 1 分钟误差)
validateTimePeriods(list) {
const periods = list.map(item => ({
start: this.timeToMinutes(item.startTime),
end: this.timeToMinutes(item.endTime)
}))
if (periods.some(p => p.start === null || p.end === null)) {
return '存在格式不正确的时间段'
}
periods.sort((a, b) => a.start - b.start)
if (periods[0].start !== 0) {
return '时间段未从 00:00 开始!'
}
if (periods[periods.length - 1].end !== 23 * 60 + 59) {
return '时间段未到 23:59 结束!'
}
for (let i = 1; i < periods.length; i++) {
if (periods[i].start < periods[i - 1].end) {
return '时间段重叠'
}
if (periods[i].start - periods[i - 1].end > 1) {
return '时间段有遗漏'
}
}
return null
},
async onSubmitClick() {
if (this.inApproval) return
if (!this.phoneList.length) {
Toast('请先添加至少一个时段电话')
return
}
if (!this.phoneList.every(item => item.phone1)) {
Toast('每条时段电话1均必填')
return
}
const timeError = this.validateTimePeriods(this.phoneList)
if (timeError) {
Toast(timeError)
return
}
Toast.loading({ message: '提交中...', forbidClick: true, duration: 0 })
try {
await submitSupplierPhoneApproval({
supplierId: this.supplierId,
modifyReason: '',
phoneList: this.phoneList
})
Toast.success('提交成功')
localStorage.removeItem(getDraftKey(this.supplierId))
this.loadData()
} catch (e) {
Toast(e?.message || '提交失败,请重试')
} finally {
Toast.clear()
}
}
}
}
</script>
<style scoped lang="scss">
@import "@/styles/mixin.scss";
@import "@/styles/common.scss";
.wrap {
@include wh(100%, 100%);
box-sizing: border-box;
background: #f4f5f7;
display: flex;
flex-direction: column;
}
::v-deep .nav-bar {
.van-nav-bar {
background: #2b6cff;
}
.van-nav-bar__title,
.van-nav-bar__arrow {
color: #fff;
}
}
.content {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.status-tip {
border-radius: 10px;
padding: 10px 12px;
font-size: 13px;
line-height: 1.5;
margin-bottom: 12px;
&--pending {
background: #eef3ff;
color: #2b6cff;
}
&--danger {
background: #fff2f0;
color: #ff4d4f;
}
&__title {
font-weight: 600;
}
&__desc {
margin-top: 4px;
font-size: 12px;
}
}
.empty-wrap {
text-align: center;
padding: 60px 0;
.empty-text {
font-size: 13px;
color: #999;
}
}
.card-list {
.time-card {
background: #fff;
border-radius: 14px;
padding: 12px 14px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.period {
font-size: 15px;
font-weight: 700;
color: #1a1a1a;
background: #eef3ff;
padding: 4px 10px;
border-radius: 8px;
}
.ops {
font-size: 13px;
color: #2b6cff;
span {
margin-left: 14px;
cursor: pointer;
}
.op-del {
color: #ff4d4f;
}
}
.divider {
height: 1px;
background: #f0f0f0;
margin: 10px 0;
}
.phone-row {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 0;
font-size: 13.5px;
}
.phone-icon {
color: #2b6cff;
font-size: 14px;
}
.phone-num {
font-weight: 600;
color: #1a1a1a;
letter-spacing: 0.3px;
}
.tag {
font-size: 11px;
padding: 2px 7px;
border-radius: 6px;
background: #f2f3f5;
color: #666;
}
.tag-wx {
background: #e8f8ee;
color: #1a8a4a;
}
}
.footer {
padding: 10px 12px;
background: #fff;
border-top: 1px solid #eee;
flex-shrink: 0;
display: flex;
gap: 10px;
.btn {
flex: 1;
height: 46px;
border-radius: 12px;
border: none;
font-size: 15px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
.btn-add {
flex: 0 0 44%;
background: #fff;
color: #2b6cff;
border: 1.5px solid #2b6cff;
}
.btn-submit {
background: #2b6cff;
color: #fff;
&:disabled {
background: #b9c8ee;
cursor: not-allowed;
}
}
}
</style>
+393
View File
@@ -0,0 +1,393 @@
<template>
<div class="wrap">
<van-nav-bar
:title="isEdit ? '编辑时段电话' : '新增时段电话'"
left-arrow
@click-left="h5GoBack"
fixed
placeholder
class="nav-bar"
/>
<div class="content">
<div class="field">
<label class="field-label">时段开始 结束格式 00:00 ~ 23:59</label>
<div class="period-row">
<input
type="text"
readonly
:value="form.startTime"
placeholder="开始时间"
@click="openTimePicker('startTime')"
/>
<span class="sep"></span>
<input
type="text"
readonly
:value="form.endTime"
placeholder="结束时间"
@click="openTimePicker('endTime')"
/>
</div>
<div v-if="periodErr" class="err-msg">{{ periodErr }}</div>
</div>
<div class="phone-block" v-for="n in 3" :key="n">
<div class="phone-block__head">
<span>电话 {{ n }}{{ n === 1 ? '(必填)' : '(选填)' }}</span>
</div>
<input
type="text"
v-model="form['phone' + n]"
:placeholder="'电话号码'"
/>
<div class="sub-grid">
<input
type="text"
v-model="form['phone' + n + 'Desc']"
placeholder="备注/负责人"
/>
<input
type="text"
v-model="form['phone' + n + 'WechatId']"
placeholder="微信号"
/>
</div>
<div v-if="phoneErr[n - 1]" class="err-msg">{{ phoneErr[n - 1] }}</div>
</div>
</div>
<div class="footer">
<button class="btn btn-save" @click="save">保存</button>
</div>
<van-popup v-model="showTimePicker" round position="bottom">
<van-datetime-picker
v-if="showTimePicker"
v-model="pickerValue"
type="time"
:formatter="timeFormatter"
@confirm="onTimeConfirm"
@cancel="showTimePicker = false"
/>
</van-popup>
</div>
</template>
<script>
import { myMixins } from '@/utils/myMixins'
import { isValidPhone, validateTimeRange } from '@/utils/validate'
import { Toast } from 'vant'
const DRAFT_KEY_PREFIX = 'supplierTimePhoneDraft_'
function getSupplierIdFromToken() {
const token = localStorage.getItem('token')
if (!token) return null
try {
const payload = JSON.parse(atob(token.split('.')[1]))
return payload.supplierId || null
} catch (e) {
return null
}
}
function getDraftKey(supplierId) {
return `${DRAFT_KEY_PREFIX}${supplierId}`
}
function emptyRecord() {
return {
startTime: '',
endTime: '',
phone1: '',
phone1Desc: '',
phone1WechatId: '',
phone2: '',
phone2Desc: '',
phone2WechatId: '',
phone3: '',
phone3Desc: '',
phone3WechatId: ''
}
}
export default {
name: 'supplierTimePhoneEdit',
mixins: [myMixins],
data() {
return {
supplierId: null,
index: -1,
form: emptyRecord(),
periodErr: '',
phoneErr: ['', '', ''],
showTimePicker: false,
pickerField: 'startTime',
pickerValue: ''
}
},
computed: {
isEdit() {
return this.index >= 0
}
},
mounted() {
this.supplierId = this.$route.query.supplierId || getSupplierIdFromToken()
this.index = Number(this.$route.query.index)
if (Number.isNaN(this.index)) {
this.index = -1
}
if (this.isEdit) {
this.loadRecord()
}
},
methods: {
validate() {
this.periodErr = ''
this.phoneErr = ['', '', '']
let valid = true
const timeRes = validateTimeRange(this.form.startTime, this.form.endTime)
if (!timeRes.valid) {
this.periodErr = timeRes.message
valid = false
}
if (!this.form.phone1) {
this.phoneErr[0] = '电话 1 为必填项'
valid = false
} else if (!isValidPhone(this.form.phone1)) {
this.phoneErr[0] = '电话 1 格式不正确'
valid = false
} else if (!this.form.phone1Desc) {
this.phoneErr[0] = '电话 1 备注为必填项'
valid = false
}
if (this.form.phone2) {
if (!isValidPhone(this.form.phone2)) {
this.phoneErr[1] = '电话 2 格式不正确'
valid = false
} else if (!this.form.phone2Desc) {
this.phoneErr[1] = '电话 2 备注为必填项'
valid = false
}
}
if (this.form.phone3) {
if (!isValidPhone(this.form.phone3)) {
this.phoneErr[2] = '电话 3 格式不正确'
valid = false
} else if (!this.form.phone3Desc) {
this.phoneErr[2] = '电话 3 备注为必填项'
valid = false
}
}
return valid
},
loadRecord() {
const key = getDraftKey(this.supplierId)
const raw = localStorage.getItem(key)
if (!raw) {
Toast('未找到时段数据')
this.h5GoBack()
return
}
try {
const draft = JSON.parse(raw)
const list = draft.phoneList || []
if (this.index >= 0 && this.index < list.length) {
this.form = { ...list[this.index] }
} else {
Toast('时段数据不存在')
this.h5GoBack()
}
} catch (e) {
console.error(e)
Toast('数据读取失败')
this.h5GoBack()
}
},
save() {
if (!this.validate()) return
try {
const key = getDraftKey(this.supplierId)
const draft = JSON.parse(localStorage.getItem(key) || '{}')
const list = draft.phoneList || []
const record = {
startTime: this.form.startTime,
endTime: this.form.endTime,
phone1: this.form.phone1,
phone1Desc: this.form.phone1Desc,
phone1WechatId: this.form.phone1WechatId,
phone2: this.form.phone2,
phone2Desc: this.form.phone2Desc,
phone2WechatId: this.form.phone2WechatId,
phone3: this.form.phone3,
phone3Desc: this.form.phone3Desc,
phone3WechatId: this.form.phone3WechatId
}
if (this.isEdit) {
list[this.index] = record
} else {
list.push(record)
}
draft.phoneList = list
localStorage.setItem(key, JSON.stringify(draft))
Toast('已保存')
this.h5GoBack()
} catch (e) {
Toast('保存失败')
console.error(e)
}
},
openTimePicker(field) {
this.pickerField = field
this.pickerValue = this.form[field] || '00:00'
this.showTimePicker = true
},
onTimeConfirm(value) {
this.form[this.pickerField] = value
this.showTimePicker = false
},
timeFormatter(type, value) {
return value.toString().padStart(2, '0')
}
}
}
</script>
<style scoped lang="scss">
@import "@/styles/mixin.scss";
@import "@/styles/common.scss";
.wrap {
@include wh(100%, 100%);
box-sizing: border-box;
background: #f4f5f7;
display: flex;
flex-direction: column;
}
::v-deep .nav-bar {
.van-nav-bar {
background: #2b6cff;
}
.van-nav-bar__title,
.van-nav-bar__arrow {
color: #fff;
}
}
.content {
flex: 1;
overflow-y: auto;
padding: 14px;
}
.field {
margin-bottom: 16px;
}
.field-label {
display: block;
font-size: 13px;
color: #333;
font-weight: 600;
margin-bottom: 7px;
}
.period-row {
display: flex;
align-items: center;
gap: 10px;
input {
flex: 1;
}
.sep {
color: #999;
}
}
input[type='text'] {
width: 100%;
height: 42px;
border: 1px solid #dfe1e6;
border-radius: 10px;
padding: 0 12px;
font-size: 14px;
color: #1a1a1a;
background: #fafbfc;
outline: none;
box-sizing: border-box;
&:focus {
border-color: #2b6cff;
background: #fff;
}
&[readonly] {
cursor: pointer;
}
}
.phone-block {
border: 1px solid #eef0f3;
border-radius: 12px;
padding: 12px;
margin-bottom: 12px;
background: #fcfcfd;
&__head {
display: flex;
justify-content: space-between;
font-size: 13px;
color: #2b6cff;
font-weight: 600;
margin-bottom: 8px;
}
.sub-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 8px;
}
}
.err-msg {
color: #ff4d4f;
font-size: 12px;
margin-top: 6px;
}
.footer {
padding: 10px 12px;
background: #fff;
border-top: 1px solid #eee;
flex-shrink: 0;
display: flex;
flex-direction: row;
gap: 10px;
.btn {
flex: 1;
height: 46px;
border-radius: 12px;
border: none;
font-size: 15px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
}
.btn-save {
background: #2b6cff;
color: #fff;
}
.btn-cancel {
background: #f2f3f5;
color: #666;
}
.btn-del {
background: #fff;
color: #ff4d4f;
border: 1.5px solid #ffccc7;
}
}
</style>
+317 -65
View File
@@ -17,42 +17,54 @@
<img class="startImg" src="@/assets/start.png" /> <img class="startImg" src="@/assets/start.png" />
<span>行驶证首页</span> <span>行驶证首页</span>
</div> </div>
<van-uploader <div class="uploadSlotWrap">
v-model="vehicleLicenseFrontList" <van-uploader
:after-read="vehicleLicenseFrontHandler" ref="licenseFrontUploader"
@delete="vehicleLicenseFrontDelete" v-model="vehicleLicenseFrontList"
max-count="1" :after-read="vehicleLicenseFrontHandler"
:preview-size="54" @delete="vehicleLicenseFrontDelete"
accept="image/*" max-count="1"
/> :preview-size="54"
accept="image/*"
/>
<div class="slotClickMask" @click="onLicenseSlotClick('front')"></div>
</div>
</div> </div>
<div class="itemContent"> <div class="itemContent">
<div class="titleType"> <div class="titleType">
<img class="startImg" src="@/assets/start.png" /> <img class="startImg" src="@/assets/start.png" />
<span>行驶证副页</span> <span>行驶证副页</span>
</div> </div>
<van-uploader <div class="uploadSlotWrap">
v-model="vehicleLicenseBackList" <van-uploader
:after-read="vehicleLicenseBackHandler" ref="licenseBackUploader"
@delete="vehicleLicenseBackDelete" v-model="vehicleLicenseBackList"
max-count="1" :after-read="vehicleLicenseBackHandler"
:preview-size="54" @delete="vehicleLicenseBackDelete"
accept="image/*" max-count="1"
/> :preview-size="54"
accept="image/*"
/>
<div class="slotClickMask" @click="onLicenseSlotClick('back')"></div>
</div>
</div> </div>
<div class="itemContent"> <div class="itemContent">
<div class="titleType"> <div class="titleType">
<img class="startImg" src="@/assets/start.png" /> <img class="startImg" src="@/assets/start.png" />
<span>行驶证车辆照片页</span> <span>行驶证车辆照片页</span>
</div> </div>
<van-uploader <div class="uploadSlotWrap">
v-model="vehicleLicenseCarPhotoList" <van-uploader
:after-read="vehicleLicenseCarHandler" ref="licenseCarUploader"
@delete="vehicleLicenseCarPhotoDelete" v-model="vehicleLicenseCarPhotoList"
max-count="1" :after-read="vehicleLicenseCarHandler"
:preview-size="54" @delete="vehicleLicenseCarPhotoDelete"
accept="image/*" max-count="1"
/> :preview-size="54"
accept="image/*"
/>
<div class="slotClickMask" @click="onLicenseSlotClick('car')"></div>
</div>
</div> </div>
<div class="itemContent"> <div class="itemContent">
<div class="titleType"> <div class="titleType">
@@ -119,23 +131,30 @@
<img class="startImg" src="@/assets/start.png" /> <img class="startImg" src="@/assets/start.png" />
<span>车辆属性</span> <span>车辆属性</span>
</div> </div>
<el-select <div class="virtualVehicleField">
v-model="virtualVehicle" <el-select
value-key="name" v-model="virtualVehicle"
class="elSelect" value-key="name"
collapse-tags="collapse-tags" class="elSelect"
:disabled="vehicleInfoDisabled" collapse-tags="collapse-tags"
placeholder="请选择" style="width: 55%" :disabled="vehicleInfoDisabled || virtualVehicleLocked"
> placeholder="请选择" style="width: 55%"
<el-option
v-for="item in virtualVehicleOptions"
:key="item.name"
:label="item.name"
:value="item.value"
> >
</el-option> <el-option
</el-select> v-for="item in virtualVehicleOptions"
:key="item.name"
:label="item.name"
:value="item.value"
:disabled="item.value == 1 && !virtualVehicleLocked"
>
</el-option>
</el-select>
<span class="matchRoleArrow" :class="{expanded: matchRoleExpand}"
@click="matchRoleExpand = !matchRoleExpand"></span>
</div>
</div>
<div v-if="matchRoleExpand" class="matchRolePanel">
匹配角色{{ virtualVehicleMatchRole || '' }}
</div> </div>
<div class="lineBot"></div> <div class="lineBot"></div>
<div class="itemContent"> <div class="itemContent">
@@ -447,11 +466,11 @@
<script> <script>
// uploadImage // uploadImage
import {Dialog} from "vant"; import {Dialog, ImagePreview} from "vant";
import {formatDate1} from "@/utils/common" import {formatDate1} from "@/utils/common"
import {myMixins} from "@/utils/myMixins" import {myMixins} from "@/utils/myMixins"
import {vehicleTypeList,saveVehicle,getInfoById,supplierServiceTree, uploadImage, ocrHandler, import {vehicleTypeList,saveVehicle,getInfoById,supplierServiceTree, uploadImage, ocrHandler,
userOperationPermissions,saveSupplierApproval} from "@/api/mine" userOperationPermissions,saveSupplierApproval,preJudgeVirtualVehicle as preJudgeVirtualVehicleApi} from "@/api/mine"
import TwoCommonBtn from "@/components/twoBtnCommon.vue" import TwoCommonBtn from "@/components/twoBtnCommon.vue"
import CellGroup from "@/components/cellGroup.vue"; import CellGroup from "@/components/cellGroup.vue";
export default { export default {
@@ -489,6 +508,7 @@ export default {
oldSupplierServiceList:[], oldSupplierServiceList:[],
show:false, show:false,
imageUrl: require('@/assets/arr_right.png'), imageUrl: require('@/assets/arr_right.png'),
cameraGuideType: 'front', // 当前拍摄槽位 front/back/car
vehicleLicenseFrontList: [], vehicleLicenseFrontList: [],
vehicleLicenseBackList: [], vehicleLicenseBackList: [],
vehicleLicenseCarPhotoList: [], vehicleLicenseCarPhotoList: [],
@@ -502,6 +522,9 @@ export default {
vehicleLicenseInfo: {}, vehicleLicenseInfo: {},
vehicleLicenseBackOcrFlag: false, // 行驶证副页 修改时默认不需要 ocr识别 vehicleLicenseBackOcrFlag: false, // 行驶证副页 修改时默认不需要 ocr识别
isMultiple: false, // 是否支持多选 isMultiple: false, // 是否支持多选
virtualVehicleLocked: false, // 车辆属性锁定(OCR所有人命中服务商信息时锁定为自有车辆)
virtualVehicleMatchRole: '', // 车辆属性自动判定命中的匹配角色
matchRoleExpand: false, // 匹配角色面板展开状态
vehicleLicense:'',//牌照 vehicleLicense:'',//牌照
vehicleFrontLicensePlate:'',//车头照车牌号码 vehicleFrontLicensePlate:'',//车头照车牌号码
insuranceCorp: '', insuranceCorp: '',
@@ -514,7 +537,7 @@ export default {
name: '一般平板车', name: '一般平板车',
value: 2 value: 2
},{ },{
name: '落地平板车', name: '落地平板车',
value: 3 value: 3
},{ },{
name: '地库车', name: '地库车',
@@ -538,6 +561,9 @@ export default {
{ {
name:'箱式拖车', name:'箱式拖车',
value: 10 }, value: 10 },
{
name:'斜落一体平板车',
value: 11 },
], ],
vehicleLicenseOptions: [{ vehicleLicenseOptions: [{
@@ -547,7 +573,7 @@ export default {
name: '黄牌', name: '黄牌',
value: 2 value: 2
}, { }, {
name: '新能源', name: '绿牌',
value: 3 value: 3
}, { }, {
name: '临牌', name: '临牌',
@@ -555,6 +581,9 @@ export default {
}, { }, {
name: '其他', name: '其他',
value: 5 value: 5
}, {
name: '黄绿牌',
value: 6
}], }],
virtualVehicleOptions: [{ virtualVehicleOptions: [{
name: '自有车辆', name: '自有车辆',
@@ -661,6 +690,7 @@ export default {
this.vehicleTypes[7].disabled=true this.vehicleTypes[7].disabled=true
this.vehicleTypes[8].disabled=true this.vehicleTypes[8].disabled=true
this.vehicleTypes[9].disabled=true this.vehicleTypes[9].disabled=true
this.vehicleTypes[10].disabled=true
}else{ }else{
this.isMultiple = false this.isMultiple = false
this.vehicleTypes[2].disabled=false this.vehicleTypes[2].disabled=false
@@ -669,6 +699,7 @@ export default {
this.vehicleTypes[7].disabled=false this.vehicleTypes[7].disabled=false
this.vehicleTypes[8].disabled=false this.vehicleTypes[8].disabled=false
this.vehicleTypes[9].disabled=false this.vehicleTypes[9].disabled=false
this.vehicleTypes[10].disabled=false
} }
if (newVal == 1 || newVal == 7 || newVal == 9){ if (newVal == 1 || newVal == 7 || newVal == 9){
let arr = [] let arr = []
@@ -685,30 +716,89 @@ export default {
}, },
}, },
async mounted() { async mounted() {
this.id=this.$route.params?.id await this.initPage()
this.approvalForm.supplierId=this.$route.params?.supplierId this.hasInited = true // 标记首次初始化完成(keep-alive 下 activated 据此判断再次进入需重新初始化)
const urlParams = new URLSearchParams(window.location.search); },
const rawId = this.$route.query.supplierId || urlParams.get('supplierId'); activated() {
this.supplierId = rawId ? Number(rawId) : ''; // 从拍摄页返回:取回拍照结果/降级信息,保留表单不重新初始化
await this.getSupplierServiceTree(); const cam = this.$store.state.camera
await this.getTypeList(); if (cam.result || cam.fallback) {
if( this.id){ const { result, fallback } = cam
await this.vehicleInfo() this.$store.commit('camera/clear')
}else{ if (result) {
this.originData={ this.cameraGuideType = result.type
vehicleType:'', this.onCameraConfirm(result)
vehicleStatus:'', } else {
virtualVehicle:'', this.cameraGuideType = fallback.type
serviceIds:[], this.handleCameraFail(fallback.error)
hasLiabilityInsurance:'',
insuranceCorp:'',
liabilityInsuranceStartTime:'',
liabilityInsuranceEndTime:'',
insurancePicturePhoto:''
} }
return
}
// 从车辆管理等其他页面再次进入(keep-alive 缓存复用):按当前路由重新初始化
if (this.hasInited) {
this.initPage()
} }
}, },
methods:{ methods:{
async initPage() {
// 整体重置为初始 data(keep-alive 缓存复用时清空上一次的新增/修改数据,含 watcher 改过的 vehicleTypes.disabled),再按当前模式初始化
Object.assign(this.$data, this.$options.data.call(this, this))
this.id=this.$route.params?.id
this.approvalForm.supplierId=this.$route.params?.supplierId
const urlParams = new URLSearchParams(window.location.search);
const rawId = this.$route.query.supplierId || urlParams.get('supplierId');
this.supplierId = rawId ? Number(rawId) : '';
await this.getSupplierServiceTree();
await this.getTypeList();
if( this.id){
await this.vehicleInfo()
}else{
// 新增:清空 el-tree 勾选状态(缓存复用时可能残留上一辆车的服务种类勾选)
this.$nextTick(() => {
this.$refs.tree && this.$refs.tree.setCheckedKeys([])
})
this.originData={
vehicleType:'',
vehicleStatus:'',
virtualVehicle:'',
serviceIds:[],
hasLiabilityInsurance:'',
insuranceCorp:'',
liabilityInsuranceStartTime:'',
liabilityInsuranceEndTime:'',
insurancePicturePhoto:''
}
// 复制新增:从 query 预填类别/属性/服务种类(id 为空,提交仍走新增分支)
this.prefillFromQuery()
}
},
// 复制新增:从 query 解析并回填车辆类别/属性/服务种类 3 项,不调任何接口;
// 字段缺失或为空时等价普通新增兜底;其余字段(照片/OCR/责任险/保单/车辆状态)保持重置后的空值
// 注意:SPA 内 $router.push 跳转时 $route.query 保留原始类型(copyFlag 为数字 1),
// 整页加载/刷新时则为字符串 "1",此处统一 String() 兼容两种来源
prefillFromQuery() {
const urlParams = new URLSearchParams(window.location.search);
const copyFlag = this.$route.query.copyFlag || urlParams.get('copyFlag');
if (String(copyFlag) !== '1') {
return
}
const vehicleType = this.$route.query.vehicleType || urlParams.get('vehicleType');
const virtualVehicle = this.$route.query.virtualVehicle || urlParams.get('virtualVehicle');
const serviceIds = this.$route.query.serviceIds || urlParams.get('serviceIds');
if (vehicleType) {
this.selectedOption = String(vehicleType).split(',').map(Number)
}
if (virtualVehicle) {
this.virtualVehicle = Number(virtualVehicle)
}
// selectedOption 赋值触发 watch(类别 disabled 重置、小修车过滤拖车服务节点、setDefault),
// 服务树勾选必须在 watch 过滤之后执行,$nextTick 回调注册顺序保证清空勾选 → watch 过滤 → 预填勾选
this.$nextTick(() => {
if (serviceIds) {
this.$refs.tree && this.$refs.tree.setCheckedKeys(String(serviceIds).split(',').map(Number))
}
})
},
showDateHandler() { showDateHandler() {
if( this.id ) { // 修改 if( this.id ) { // 修改
if( this.permissonList.includes('hasInsuranceAudit') ) { if( this.permissonList.includes('hasInsuranceAudit') ) {
@@ -780,12 +870,15 @@ export default {
} }
}, },
getVehicleLicense(color) { getVehicleLicense(color) {
color = color || ''
if( color == '蓝' ) { if( color == '蓝' ) {
this.vehicleLicense = 1 this.vehicleLicense = 1
} else if(color == '黄') { } else if(color == '黄') {
this.vehicleLicense = 2 this.vehicleLicense = 2
} else if(color == '临牌') { } else if(color == '临牌') {
this.vehicleLicense = 4 this.vehicleLicense = 4
} else if(color.includes('黄绿')) {
this.vehicleLicense = 6
} else if(color.includes('绿')) { } else if(color.includes('绿')) {
this.vehicleLicense = 3 this.vehicleLicense = 3
} else { } else {
@@ -920,6 +1013,85 @@ export default {
console.log(`压缩后为:${mb1}M`) console.log(`压缩后为:${mb1}M`)
return compressedFile; return compressedFile;
}, },
isCameraSupported() { // 是否支持 getUserMedia(不支持则降级系统选择器)
return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia) && window.isSecureContext !== false
},
onLicenseSlotClick(type) { // 行驶证三槽位点击:已有图片→预览,无图片→跳拍摄页(不支持相机则降级系统选择器)
this.cameraGuideType = type
const listMap = {
front: 'vehicleLicenseFrontList',
back: 'vehicleLicenseBackList',
car: 'vehicleLicenseCarPhotoList'
}
const item = (this[listMap[type]] || [])[0]
const src = item && (item.url || item.content)
if (src) {
// 已有图片:预览(删除按钮在拦截层之上,可删除后重拍)
ImagePreview({ images: [src], closeable: true })
return
}
// 无图片:拍照
if (!this.isCameraSupported()) {
// 不支持 getUserMedia(如 iOS 微信、非 HTTPS、老 WebView):直接调系统选择器
this.triggerNativeUpload(type)
return
}
this.$router.push({ name: 'cameraGuide', query: { type } })
},
isCameraPermissionError(e) { // 是否为相机权限类错误
return !!(e && (e.name === 'NotAllowedError' || e.name === 'SecurityError' || e.name === 'PermissionDeniedError'))
},
handleCameraFail(e) { // 相机不可用统一处理:权限类弹确认框,其他异常提示后由用户点击触发相册(保证点击手势有效)
if (this.isCameraPermissionError(e)) {
Dialog.confirm({
title: '提示',
message: '相机权限未开启,无法使用拍摄引导。是否重新尝试开启相机?',
confirmButtonText: '重新尝试',
cancelButtonText: '从相册选择',
confirmButtonColor: '#354D93',
}).then(() => {
// 重新走前置探测,探测通过才打开拍摄引导
this.onLicenseSlotClick(this.cameraGuideType)
}).catch(() => {
this.triggerNativeUpload(this.cameraGuideType)
})
} else {
Dialog.alert({
title: '提示',
message: '当前设备无法调用相机拍摄,请从相册选择照片上传。',
confirmButtonText: '从相册选择',
confirmButtonColor: '#354D93',
}).then(() => {
this.triggerNativeUpload(this.cameraGuideType)
})
}
},
triggerNativeUpload(type) { // 主动触发对应 uploader 的原生文件选择
const refMap = { front: 'licenseFrontUploader', back: 'licenseBackUploader', car: 'licenseCarUploader' }
this.$nextTick(() => {
const uploader = this.$refs[refMap[type]]
const input = uploader && uploader.$el && uploader.$el.querySelector('.van-uploader__input')
if (input) input.click()
})
},
onCameraConfirm({ blob, dataURL }) { // 拍摄确认:构造 {file, content} 走现有压缩→上传→OCR 链路
const type = this.cameraGuideType
const file = new File([blob], `vehicle_license_${type}_${Date.now()}.jpg`, { type: 'image/jpeg' })
const fileItem = { file, content: dataURL }
const handlerMap = {
front: 'vehicleLicenseFrontHandler',
back: 'vehicleLicenseBackHandler',
car: 'vehicleLicenseCarHandler'
}
const listMap = {
front: 'vehicleLicenseFrontList',
back: 'vehicleLicenseBackList',
car: 'vehicleLicenseCarPhotoList'
}
// 写回 uploader 的 v-model 列表,使槽位缩略图回显
this[listMap[type]] = [fileItem]
this[handlerMap[type]](fileItem)
},
async vehicleLicenseFrontHandler(file) { // 上传 行驶证首页 async vehicleLicenseFrontHandler(file) { // 上传 行驶证首页
console.log('file',file) console.log('file',file)
try { try {
@@ -1040,7 +1212,7 @@ export default {
cardSide: 'FRONT' cardSide: 'FRONT'
}); });
this.vehicleFrontLicensePlate=res.data?.number this.vehicleFrontLicensePlate=res.data?.number
this.getVehicleLicense(res.data.color) this.getVehicleLicense(res.data?.color)
}, },
async vehicleOcrHandler() { // 行驶证首页 ocr 识别 async vehicleOcrHandler() { // 行驶证首页 ocr 识别
this.vehicleLicenseInfo.licensePlateNumber = ''; this.vehicleLicenseInfo.licensePlateNumber = '';
@@ -1065,6 +1237,8 @@ export default {
this.vehicleLicenseInfo.licensePlateNumber = frontInfo?.plateNo; this.vehicleLicenseInfo.licensePlateNumber = frontInfo?.plateNo;
this.vehicleLicenseInfo.vehicleType = frontInfo?.vehicleType; this.vehicleLicenseInfo.vehicleType = frontInfo?.vehicleType;
this.vehicleLicenseInfo.owner = frontInfo?.owner; this.vehicleLicenseInfo.owner = frontInfo?.owner;
// 行驶证所有人识别成功后,预判车辆属性(命中服务商信息则锁定为自有车辆)
this.preJudgeVirtualVehicle();
this.vehicleLicenseInfo.address = frontInfo?.address; this.vehicleLicenseInfo.address = frontInfo?.address;
this.vehicleLicenseInfo.useNature = frontInfo?.useCharacter; this.vehicleLicenseInfo.useNature = frontInfo?.useCharacter;
this.vehicleLicenseInfo.model = frontInfo?.model; this.vehicleLicenseInfo.model = frontInfo?.model;
@@ -1089,6 +1263,33 @@ export default {
} }
return true return true
}, },
// 车辆属性预判:行驶证所有人与车辆归属服务商的名称/法人/关联人员比对,命中自有则锁定选择器
async preJudgeVirtualVehicle() {
const wasLocked = this.virtualVehicleLocked;
this.virtualVehicleLocked = false;
this.virtualVehicleMatchRole = '';
// 之前命中自有自动填入的值,本次未命中/识别失败则清空(自有车辆不可手动选),由操作人员手动选择 2/3/4
if (wasLocked && this.virtualVehicle === 1) {
this.virtualVehicle = '';
}
const owner = (this.vehicleLicenseInfo.owner || '').trim();
if (!owner) {
return;
}
try {
const res = await preJudgeVirtualVehicleApi({
owner,
supplierId: this.supplierId || undefined // 车辆归属服务商ID,自录场景为空由后端取当前用户服务商
});
if (res?.data?.locked) {
this.virtualVehicleLocked = true;
this.virtualVehicleMatchRole = res.data.matchRole || '';
this.virtualVehicle = 1;
}
} catch (e) {
// 预判失败不影响保存,选择器保持可编辑
}
},
async vehicleBackOcrHandler() { // 行驶证副页 ocr 识别 async vehicleBackOcrHandler() { // 行驶证副页 ocr 识别
this.vehicleLicenseInfo.recordNumber = ''; this.vehicleLicenseInfo.recordNumber = '';
this.vehicleLicenseInfo.passengerCapacity = ''; this.vehicleLicenseInfo.passengerCapacity = '';
@@ -1167,6 +1368,9 @@ export default {
}); });
this.auditStatus = result.auditStatus; this.auditStatus = result.auditStatus;
this.virtualVehicle = result.virtualVehicle; this.virtualVehicle = result.virtualVehicle;
// 车辆属性溯源:编辑场景初始化匹配角色,已是自有车辆且命中角色时锁定选择器
this.virtualVehicleMatchRole = result.virtualVehicleMatchRole || '';
this.virtualVehicleLocked = !!(result.virtualVehicleMatchRole && result.virtualVehicle === 1);
this.$refs.tree.setCheckedKeys(result.serviceIds) this.$refs.tree.setCheckedKeys(result.serviceIds)
this.vehicleStatus = result.vehicleStatus this.vehicleStatus = result.vehicleStatus
this.vehicleLicenseFront = result.vehicleLicenseFront; this.vehicleLicenseFront = result.vehicleLicenseFront;
@@ -1605,6 +1809,23 @@ export default {
<style scoped lang="scss"> <style scoped lang="scss">
@import "@/styles/mixin.scss"; @import "@/styles/mixin.scss";
@import "@/styles/common.scss"; @import "@/styles/common.scss";
/* 行驶证三槽位透明点击拦截层 */
.uploadSlotWrap{
position: relative;
display: inline-block;
.slotClickMask{
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10;
}
/* 已上传图片的删除按钮置于拦截层之上,保证可删除重传 */
::v-deep .van-uploader__preview-delete{
z-index: 11;
}
}
.mySelect{ .mySelect{
@include fontWeightSize(bold,13px); @include fontWeightSize(bold,13px);
color: #323643; color: #323643;
@@ -1721,6 +1942,37 @@ export default {
pointer-events: none; pointer-events: none;
opacity: 0.6; opacity: 0.6;
} }
/* 车辆属性-匹配角色溯源 */
.virtualVehicleField {
display: flex;
align-items: center;
justify-content: flex-end;
.matchRoleArrow {
margin-left: 6px;
cursor: pointer;
color: #909399;
user-select: none;
display: inline-block;
font-size: 22px;
line-height: 1;
padding: 4px;
transition: transform .2s;
&.expanded {
transform: rotate(180deg);
color: #354D93;
}
}
}
.matchRolePanel {
margin: 0 25px 6px 0;
padding: 4px 10px;
font-size: 12px;
color: #606266;
background: #f4f4f5;
border-radius: 4px;
line-height: 1.6;
text-align: right;
}
.pop_wrap { .pop_wrap {
width: 340px; width: 340px;
box-sizing: border-box; box-sizing: border-box;
+24 -1
View File
@@ -82,7 +82,10 @@
<div class="codeLeft">{{item.plateNumber}} / {{item.vehicleTypeString}} <div class="codeLeft">{{item.plateNumber}} / {{item.vehicleTypeString}}
<span class="ml10" :class="{'insuranceSuccess': item.vehicleStatus == 1, 'insuranceDanger': item.vehicleStatus == 2 ,'insuranceGray': !item.vehicleStatus }">{{ item.vehicleStatus == 1 ? '启用' :( item.vehicleStatus == 2 ? '停用' : ( item.vehicleStatus == 12 ? '否-服务商停用' : '无状态')) }} </span> <span class="ml10" :class="{'insuranceSuccess': item.vehicleStatus == 1, 'insuranceDanger': item.vehicleStatus == 2 ,'insuranceGray': !item.vehicleStatus }">{{ item.vehicleStatus == 1 ? '启用' :( item.vehicleStatus == 2 ? '停用' : ( item.vehicleStatus == 12 ? '否-服务商停用' : '无状态')) }} </span>
</div> </div>
<div class="codeRight" v-if="item.vehicleStatus == 1" @click.stop="updateStatus(item)">停用</div> <div class="btnWrap">
<div class="codeRight" v-if="permissonList.includes('vehicleAddBtn') || supplierId" @click.stop="copyAddVehicle(item)">复制新增</div>
<div class="codeRight" v-if="item.vehicleStatus == 1" @click.stop="updateStatus(item)">停用</div>
</div>
</div> </div>
<div class="juhe flex-between"> <div class="juhe flex-between">
<span class="zdJuhe">核验认证</span> <span class="zdJuhe">核验认证</span>
@@ -360,6 +363,19 @@ export default {
// on cancel // on cancel
}); });
}, },
copyAddVehicle(item){//复制新增:把源车的类别/属性/服务种类通过 query 带入新增页(后端未返回新字段时为空,新增页按普通新增兜底)
const query = {
copyFlag: 1,
vehicleType: item.vehicleType || '',
virtualVehicle: item.virtualVehicle || '',
serviceIds: (item.serviceIds || []).join(','),
}
if (this.supplierId) {//潜在服务商流程,与「添加」按钮保持一致
query.supplierId = this.supplierId
query.potentialFlag = this.potentialFlag
}
this.goPage('vehicleAdd', query)
},
updateVehicle(item){//修改 updateVehicle(item){//修改
if( this.permissonList.includes('vehicleModifyBtn') ) { if( this.permissonList.includes('vehicleModifyBtn') ) {
this.$router.push({ this.$router.push({
@@ -445,6 +461,13 @@ export default {
.codeLeft{ .codeLeft{
@include fontWeightSize(bold,14px) @include fontWeightSize(bold,14px)
} }
.btnWrap{
display: flex;
align-items: center;
.codeRight + .codeRight{
margin-left: 8px;
}
}
.codeRight{ .codeRight{
padding: 2px 8px; padding: 2px 8px;
border: 1px solid #DDDDDD; border: 1px solid #DDDDDD;
+65
View File
@@ -99,6 +99,29 @@
<span class="driverPoiBtn" v-if="orderDetailInfo.beforeDispatchImages && orderDetailInfo.beforeDispatchImages.length > 0" @click="environmentPhoto">环境照片</span> <span class="driverPoiBtn" v-if="orderDetailInfo.beforeDispatchImages && orderDetailInfo.beforeDispatchImages.length > 0" @click="environmentPhoto">环境照片</span>
</span> </span>
</div> </div>
<div v-if="orderDetailInfo.firstAggregateFailInfo" class="aggregate-fail-card">
<div class="aggregate-fail-card__title"> 首次聚合未接单</div>
<div class="aggregate-fail-card__row">
<span class="aggregate-fail-card__label">师傅姓名</span>
<span class="aggregate-fail-card__value">{{ orderDetailInfo.firstAggregateFailInfo.driverName || '—' }}</span>
</div>
<div class="aggregate-fail-card__row">
<span class="aggregate-fail-card__label">师傅手机号</span>
<span class="aggregate-fail-card__value">{{ orderDetailInfo.firstAggregateFailInfo.driverPhone || '—' }}</span>
</div>
<div class="aggregate-fail-card__row">
<span class="aggregate-fail-card__label">车辆</span>
<span class="aggregate-fail-card__value">{{ aggregateFailVehicle }}</span>
</div>
<div class="aggregate-fail-card__row">
<span class="aggregate-fail-card__label">失败原因</span>
<span class="aggregate-fail-card__value aggregate-fail-card__value--warning">{{ orderDetailInfo.firstAggregateFailInfo.failReason || '—' }}</span>
</div>
<div class="aggregate-fail-card__row">
<span class="aggregate-fail-card__label">聚合时间</span>
<span class="aggregate-fail-card__value">{{ orderDetailInfo.firstAggregateFailInfo.dispatchTime || '—' }}</span>
</div>
</div>
</div> </div>
</div> </div>
<div class="serviceInfo common" v-if="orderDetailInfo.batteryNo"> <div class="serviceInfo common" v-if="orderDetailInfo.batteryNo">
@@ -154,6 +177,16 @@ export default {
carTypeList: [] carTypeList: []
} }
}, },
computed:{
aggregateFailVehicle(){
const info = this.orderDetailInfo.firstAggregateFailInfo
if(!info) return '—'
const plate = info.licensePlate || ''
const type = info.vehicleTypeName || ''
if(plate && type) return `${plate}${type}`
return plate || type || '—'
}
},
async mounted() { async mounted() {
// 获取当前 URL // 获取当前 URL
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
@@ -418,6 +451,38 @@ export default {
} }
} }
} }
.aggregate-fail-card{
margin-top: 12px;
padding: 12px;
background: #FFF7F0;
border: 1px solid #FFBB96;
border-radius: 6px;
&__title{
@include fontWeightSize(bold,13px);
color: #FA541C;
margin-bottom: 8px;
}
&__row{
display: flex;
align-items: baseline;
line-height: 22px;
}
&__label{
width: 78px;
flex-shrink: 0;
@include fontWeightSize(400,12px);
color: #000000;
opacity: .65;
}
&__value{
flex: 1;
@include fontWeightSize(bold,12px);
color: #000000;
&--warning{
color: #FA541C;
}
}
}
.title{ .title{
@include fontWeightSize(bold,14px); @include fontWeightSize(bold,14px);
@include colorOpa(#323643,0.6); @include colorOpa(#323643,0.6);
+45 -2
View File
@@ -35,9 +35,17 @@
<div class="item"> <div class="item">
<div class="left opaCol"><span class="star" style="opacity: 0">*</span><span>角色</span></div> <div class="left opaCol"><span class="star" style="opacity: 0">*</span><span>角色</span></div>
<div class="right" :class="{'opaCol':type==2}"> <div class="right" :class="{'opaCol':type==2}">
<van-field :input-align="type==2 ? 'right' : 'left'" readonly v-model="item.roleName" placeholder="请输入" /> <van-field :input-align="type==2 ? 'right' : 'left'" readonly v-model="item.roleDisplay" placeholder="请输入" />
</div> </div>
</div> </div>
<template v-if="item.hasRelationRole">
<div class="item" v-for="(row, rIndex) in item.relationRows" :key="'relation-' + rIndex">
<div class="left relationLeft" :class="{'opaCol':type!=2}"><span class="star" :style="{opacity: type==2 ? 1 : 0}">*</span><span>{{ item.relationRows.length > 1 ? row.base : '关系' }}</span></div>
<div class="right">
<van-field :input-align="type==2 ? 'right' : 'left'" :readonly="type!=2" v-model="row.relation" placeholder="请输入关系" />
</div>
</div>
</template>
<div class="item"> <div class="item">
<div class="left" :class="{'opaCol':type!=2}"><span class="star">*</span><span>电话1</span></div> <div class="left" :class="{'opaCol':type!=2}"><span class="star">*</span><span>电话1</span></div>
<div class="right"> <div class="right">
@@ -83,6 +91,8 @@
import {myMixins} from "@/utils/myMixins" import {myMixins} from "@/utils/myMixins"
import {getConfirmPerson,getConfirmStatus,getQrCode,confirmAddWechat,confirm,submitConfirm} from "@/api/authentication"; import {getConfirmPerson,getConfirmStatus,getQrCode,confirmAddWechat,confirm,submitConfirm} from "@/api/authentication";
import { Dialog } from 'vant'; import { Dialog } from 'vant';
// 带关系角色(存储格式:角色名-关系)
const RELATION_ROLES = ['其他', '法人关系人', '实控人关系人', '合伙人']
export default { export default {
name: "personList", name: "personList",
mixins:[myMixins], mixins:[myMixins],
@@ -163,11 +173,26 @@ export default {
// this.type=2 // this.type=2
let res=await getConfirmPerson({supplierId:this.supplierId}) let res=await getConfirmPerson({supplierId:this.supplierId})
if(res.data && res.data.length>0){ if(res.data && res.data.length>0){
this.personList=res.data this.personList=res.data.map(item => this.buildRoleDisplay(item))
}else{ }else{
this.personList=[] this.personList=[]
} }
}, },
// 拆分带关系角色:roleName 按逗号拆项,识别 `角色名-关系`,按第一个 '-' 拆出基础角色与关系
buildRoleDisplay(item){
const parts = (item.roleName || '').split(',').filter(Boolean).map(raw => {
const dashIndex = raw.indexOf('-')
if (dashIndex > -1 && RELATION_ROLES.includes(raw.substring(0, dashIndex))) {
return { raw, base: raw.substring(0, dashIndex), relation: raw.substring(dashIndex + 1) }
}
return { raw, base: raw, relation: null }
})
item.roleParts = parts
item.roleDisplay = parts.map(p => p.relation != null ? p.base : p.raw).join(',')
item.relationRows = parts.filter(p => p.relation != null)
item.hasRelationRole = item.relationRows.length > 0
return item
},
async wechatConfirm(){//确认已添加微信按钮 async wechatConfirm(){//确认已添加微信按钮
Dialog.confirm({ Dialog.confirm({
message:'请确认相关人员是否已添加该二维码。', message:'请确认相关人员是否已添加该二维码。',
@@ -238,6 +263,18 @@ export default {
this.$toast('电话号码格式不正确') this.$toast('电话号码格式不正确')
return return
} }
// 带关系角色:关系必填校验,并将 roleDisplay + relation 重新拼接为 `角色名-关系` 写回 roleName
for (const item of this.personList) {
if (item.hasRelationRole) {
for (const row of item.relationRows) {
if (!row.relation || !String(row.relation).trim()) {
this.$toast(`请填写${row.base}关系`)
return
}
}
item.roleName = item.roleParts.map(p => p.relation != null ? `${p.base}-${String(p.relation).trim()}` : p.raw).join(',')
}
}
await submitConfirm({supplierId:this.supplierId,infos:this.personList}) await submitConfirm({supplierId:this.supplierId,infos:this.personList})
this.$toast('操作成功') this.$toast('操作成功')
if(this.isWebFunc()){ if(this.isWebFunc()){
@@ -407,6 +444,12 @@ export default {
color: #323643; color: #323643;
opacity: .65; opacity: .65;
} }
.item .left.relationLeft{
width: auto;
min-width: 66px;
white-space: nowrap;
padding-right: 4px;
}
.star{ .star{
font-weight: bold; font-weight: bold;
font-size: 13px; font-size: 13px;