Compare commits

..
33 Commits
Author SHA1 Message Date
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
zlf e7bb5a23be CRM_26-07-09#story#8922,抢单链接页面更改及 BI 埋点新增需求 2026-06-30 10:39:41 +08:00
zlf da844d0e24 CRM_26-06-30#story#8869,救援 APP、中道神行小程序、跑圈小板运输小程序和小板运输APP端上传损伤照片自动生成推送链接需求 2026-06-30 10:01:07 +08:00
zlf 1d7b200f76 环境救援照片页面修改 2026-06-29 14:22:01 +08:00
zlf ddb3817d6e CRM_26-06-30#story#8869,救援 APP、中道神行小程序、跑圈小板运输小程序和小板运输APP端上传损伤照片自动生成推送链接需求 2026-06-26 16:52:40 +08:00
zlf 2ff9e7684f CRM_26-06-30#story#8858,关于“我的车辆”中行驶证主页照片识别出的“车牌号”和“车架号”的校验需求 2026-06-25 16:09:11 +08:00
zlf c7327de2e5 CRM_26-06-30#story#8858,关于“我的车辆”中行驶证主页照片识别出的“车牌号”和“车架号”的校验需求 2026-06-25 15:31:21 +08:00
zlf 9c324fbff3 CRM_26-06-30#story#8858,关于“我的车辆”中行驶证主页照片识别出的“车牌号”和“车架号”的校验需求 2026-06-23 16:45:20 +08:00
zlf 69469ed61f CRM_26-04-22#story#8495,潜在供应商H5页面提交信息,信息审核,提交补充信息与退回理由的优化与修复(跳转详情修改2) 2026-06-22 16:25:31 +08:00
zlf 3a880c0f34 CRM_26-04-22#story#8495,潜在供应商H5页面提交信息,信息审核,提交补充信息与退回理由的优化与修复(车辆信息跳转路由) 2026-06-22 10:02:57 +08:00
zxl 798a2a60c9 CRM_26-06-16#story#8836,小板跳转APP的h5链接增加操作指引 2026-06-17 11:51:41 +08:00
zxl 0ad22527a1 CRM_26-06-16#story#8836,小板跳转APP的h5链接增加操作指引 2026-06-17 11:47:59 +08:00
zxl f30afdd1e9 CRM_26-06-16#story#8836,小板跳转APP的h5链接增加操作指引 2026-06-17 11:43:19 +08:00
20 changed files with 2665 additions and 63 deletions
+3 -2
View File
@@ -1,8 +1,7 @@
.DS_Store
node_modules
/dist
/.kaifa
/.summaries
# local env files
.env.local
@@ -18,6 +17,8 @@ pnpm-debug.log*
# Editor directories and files
.idea
.vscode
.claude
.kimi-code
*.suo
*.ntvs*
*.njsproj
+2 -2
View File
@@ -31,14 +31,14 @@
}
</script>
<script type="text/javascript" src="https://webapi.amap.com/maps?v=2.0&key=2560bbf04daef66c810c5e6a97e8c508&plugin=AMap.AutoComplete,AMap.PlaceSearch,AMap.Geolocation,AMap.Geocoder,AMap.Marker,AMap.Driving"></script>
<!-- <script src="https://unpkg.com/vconsole@latest/dist/vconsole.min.js"></script>-->
<script src="https://unpkg.com/vconsole@latest/dist/vconsole.min.js"></script>
<script>
window.selfLink = window.location.href;
window.selfSearch = window.location.search;
</script>
<script>
// VConsole 默认会挂载到 `window.VConsole` 上
// let vConsole = new window.VConsole();
let vConsole = new window.VConsole();
(function (doc, win) {
// console.log("==window.location.pathname===",window.location.pathname)
let docEl = doc.documentElement
+14
View File
@@ -0,0 +1,14 @@
import request from '@/utils/http'
/**
* 查询 H5 损伤照片
* @param {Object} data - { userOrderId, taskOrderId }
*/
export function getDamagePhotos(data) {
return request({
url: '/api/h5-interface/damagePhotos',
method: 'POST',
contentType: 'application/json',
data
})
}
+10
View File
@@ -0,0 +1,10 @@
import request from '@/utils/http'
export function getBeforeDispatchImage(data) {
return request({
url: '/supplierAppV2/dispatchApp/order/getBeforeDispatchImage',
method: 'POST',
contentType: 'application/json',
data
})
}
+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
})
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 643 KiB

After

Width:  |  Height:  |  Size: 1.2 MiB

+646
View File
@@ -0,0 +1,646 @@
<template>
<div class="cameraGuide" v-if="visible">
<!-- ===== 拍摄引导页 ===== -->
<div class="cameraPage" v-show="step==='guide'">
<video ref="video" class="cameraVideo" autoplay playsinline muted></video>
<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">
<!-- 主页模板标题 + 印章 -->
<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">
<svg viewBox="0 0 140 70" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 46 L20 26 Q22 20 30 19 L88 16 Q96 16 102 22 L116 34 Q126 36 128 42 L128 48 Q128 52 124 52 L116 52"></path>
<path d="M14 46 L14 50 Q14 52 18 52 L30 52"></path>
<circle cx="42" cy="52" r="9"></circle>
<circle cx="102" cy="52" r="9"></circle>
<path d="M51 52 L93 52"></path>
<path d="M58 19 L60 33 L34 34"></path>
<path d="M92 18 L94 32 L114 33"></path>
</svg>
</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
}
},
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 预览确认
stream: 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 = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' },
audio: false
})
// 等待期间组件已被关闭,立即释放
if (!this.visible) {
stream.getTracks().forEach(t => t.stop())
return
}
this.stream = stream
await this.$nextTick()
const video = this.$refs.video
if (video) {
video.srcObject = stream
video.play().catch(() => {})
}
} catch (e) {
// 权限拒绝或相机不可用 → 通知父组件降级为系统选择器
console.error('getUserMedia error:', e)
this.$emit('fallback')
}
},
stopCamera() {
if (this.stream) {
this.stream.getTracks().forEach(t => t.stop())
this.stream = null
}
},
takePhoto() {
const video = this.$refs.video
if (!video || !video.videoWidth) {
this.$toast('相机尚未就绪,请稍候')
return
}
const canvas = document.createElement('canvas')
canvas.width = video.videoWidth
canvas.height = video.videoHeight
canvas.getContext('2d').drawImage(video, 0, 0)
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;
padding-bottom: 100px;
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%;
max-width: 340px;
padding-top: 69.93%; /* 宽高比约 1.43:1 */
flex-shrink: 0;
filter: drop-shadow(0 4px 16px rgba(20, 30, 50, .12));
}
.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>
+33 -1
View File
@@ -43,6 +43,22 @@ const routes = [
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',
name: 'toDoList',
@@ -164,6 +180,22 @@ const routes = [
title: '查看发票'
}
},
{
path: '/damagePhotos',
name: 'damagePhotoList',
component: () => import('@/views/damagePhoto/damagePhotoList.vue'),
meta: {
title: '损伤照片'
}
},
{
path: '/environmentPhotos',
name: 'environmentPhotoList',
component: () => import('@/views/environmentPhoto/environmentPhotoList.vue'),
meta: {
title: '环境照片'
}
},
{
path: "/vehicleManage",
name: 'vehicleManage',
@@ -337,7 +369,7 @@ const routes = [
name: 'goToApp',
component:()=>import('@/views/goToApp/goToApp.vue'),
meta: {
title:'中道小板运输'
title:'小板运输司机端'
}
},
...kpiRouter,
+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 }
}
+312
View File
@@ -0,0 +1,312 @@
<template>
<div class="wrap damage-photo-wrap">
<div class="navBar">
<van-nav-bar
title="损伤照片"
left-arrow
left-arrow-color="#FFFFFF"
:border="false"
:fixed="true"
:safe-area-inset-top="true"
@click-left="h5GoBack"
/>
</div>
<div class="content-wrap" v-if="loaded">
<!-- 案件信息 -->
<div class="info-card">
<div class="info-item">
<span class="info-label">案件号</span>
<span class="info-value">{{ info.caseNo || '-' }}</span>
</div>
<div class="info-item">
<span class="info-label">车辆标识</span>
<span class="info-value">{{ info.vehicleIdentity || '-' }}</span>
</div>
<div class="info-item">
<span class="info-label">起止路线</span>
<span class="info-value">{{ info.route || '-' }}</span>
</div>
</div>
<!-- 照片列表 -->
<div class="photo-list" v-if="photoList.length">
<div class="photo-card" v-for="(item, index) in photoList" :key="index">
<div class="photo-title">
<span>{{ item.title || '-' }}</span>
<span
v-if="item.replaced || (item.historyList && item.historyList.length)"
class="replaced-tag"
@click="showHistory(item)"
>被替换</span>
</div>
<div class="photo-image" @click="previewCurrent(item.currentUrl)">
<van-image
:src="item.currentUrl"
fit="cover"
width="100%"
height="160"
:error-icon="defaultImg"
error-icon-size="48"
/>
</div>
</div>
</div>
<!-- 空态 -->
<div class="empty-state" v-else>
<van-empty description="暂无损伤照片" />
</div>
</div>
<!-- 历史照片弹框 -->
<van-popup
v-model="historyShow"
position="bottom"
round
closeable
:style="{ height: '70%' }"
@closed="currentHistory = []"
>
<div class="history-popup">
<div class="history-title">历史照片</div>
<div class="history-list" v-if="currentHistory.length">
<div
class="history-item"
v-for="(item, index) in currentHistory"
:key="index"
@click="previewHistory(item.url)"
>
<van-image
:src="item.url"
fit="cover"
width="100%"
height="120"
:error-icon="defaultImg"
error-icon-size="48"
/>
<div class="history-time" v-if="item.replaceTime">替换时间{{ item.replaceTime }}</div>
</div>
</div>
<div class="history-empty" v-else>
<van-empty description="暂无历史照片" />
</div>
</div>
</van-popup>
</div>
</template>
<script>
import { getDamagePhotos } from '@/api/damagePhoto'
import { myMixins } from '@/utils/myMixins'
import { ImagePreview, Toast } from 'vant'
export default {
name: 'damagePhotoList',
mixins: [myMixins],
data() {
return {
loaded: false,
info: {
caseNo: '',
vehicleIdentity: '',
route: ''
},
photoList: [],
historyShow: false,
currentHistory: [],
defaultImg: require('@/assets/empty.png')
}
},
async mounted() {
await this.fetchDamagePhotos()
},
methods: {
async fetchDamagePhotos() {
const userOrderId = this.$route.query.userOrderId
const taskOrderId = this.$route.query.taskOrderId
if (!userOrderId || !taskOrderId) {
Toast('缺少必要参数')
this.loaded = true
return
}
Toast.loading({
message: '加载中...',
forbidClick: true,
duration: 0
})
try {
const res = await getDamagePhotos({
userOrderId: Number(userOrderId),
taskOrderId: Number(taskOrderId)
})
this.info = {
caseNo: res.data?.caseNo || '',
vehicleIdentity: res.data?.vehicleIdentity || '',
route: res.data?.route || ''
}
this.photoList = Array.isArray(res.data?.photos) ? res.data.photos : []
} finally {
Toast.clear()
this.loaded = true
}
},
previewCurrent(url) {
if (!url) return
ImagePreview({
images: [url],
startPosition: 0,
closeable: true
})
},
showHistory(item) {
this.currentHistory = Array.isArray(item.historyList) ? item.historyList : []
this.historyShow = true
},
previewHistory(url) {
if (!url) return
ImagePreview({
images: [url],
startPosition: 0,
closeable: true
})
}
}
}
</script>
<style scoped lang="scss">
@import "@/styles/mixin.scss";
@import "@/styles/common.scss";
.wrap {
@include wh(100%, 100%);
background-color: #F4F5F7;
overflow-y: auto;
}
.navBar {
height: 46px;
::v-deep .van-nav-bar--fixed {
z-index: 9999 !important;
}
}
.content-wrap {
padding: 10px 15px 20px;
}
.info-card {
background: #fff;
border-radius: 8px;
padding: 15px;
margin-bottom: 10px;
.info-item {
display: flex;
line-height: 24px;
font-size: 13px;
&:not(:last-child) {
margin-bottom: 8px;
}
.info-label {
color: rgba(0, 0, 0, 0.5);
width: 70px;
flex-shrink: 0;
}
.info-value {
color: #323643;
flex: 1;
word-break: break-all;
}
}
}
.photo-list {
.photo-card {
background: #fff;
border-radius: 8px;
padding: 12px 12px 15px;
margin-bottom: 10px;
.photo-title {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 14px;
font-weight: bold;
color: #323643;
margin-bottom: 10px;
.replaced-tag {
font-size: 12px;
font-weight: normal;
color: #fff;
background-color: #ee0a24;
padding: 2px 8px;
border-radius: 4px;
}
}
.photo-image {
width: 100%;
border-radius: 4px;
overflow: hidden;
}
}
}
.empty-state {
margin-top: 30px;
}
.history-popup {
height: 100%;
display: flex;
flex-direction: column;
.history-title {
font-size: 16px;
font-weight: bold;
color: #323643;
text-align: center;
padding: 15px 0;
border-bottom: 1px solid #E9E9EA;
}
.history-list {
flex: 1;
overflow-y: auto;
padding: 15px;
.history-item {
margin-bottom: 15px;
border-radius: 4px;
overflow: hidden;
.history-time {
font-size: 12px;
color: rgba(0, 0, 0, 0.5);
margin-top: 6px;
}
}
}
.history-empty {
flex: 1;
}
}
</style>
<style>
/* 确保图片预览及其遮罩层级高于底部弹框 */
.van-image-preview {
z-index: 10000000003 !important;
}
.van-overlay.van-image-preview__overlay {
z-index: 10000000002 !important;
}
</style>
@@ -0,0 +1,118 @@
<template>
<div class="wrap environment-photo-wrap">
<div v-if="loaded && photoList.length === 0" class="empty-tip">暂无环境照片</div>
<div v-for="(item, index) in photoList" :key="index" class="photo-item">
<div class="media-wrap">
<video
v-if="item.tag && item.tag.includes('video')"
:src="item.imageUrl"
controls
playsinline
class="media"
/>
<img
v-else-if="item.tag && item.tag.includes('img')"
:src="item.imageUrl"
class="media"
@click="previewImage(index)"
/>
</div>
<p v-if="item.title" class="photo-name">{{ item.title }}</p>
</div>
</div>
</template>
<script>
import { Toast, ImagePreview } from 'vant'
import { getBeforeDispatchImage } from '@/api/environmentPhoto'
export default {
name: 'EnvironmentPhotoList',
data() {
return {
photoList: [],
loaded: false
}
},
async mounted() {
await this.fetchPhotos()
},
methods: {
async fetchPhotos() {
const userOrderId = this.$route.query.userOrderId
if (!userOrderId) {
Toast('缺少必要参数')
this.loaded = true
return
}
Toast.loading({
message: '加载中...',
forbidClick: true,
duration: 0
})
try {
const res = await getBeforeDispatchImage({ userOrderId: Number(userOrderId) })
this.photoList = Array.isArray(res.data) ? res.data : []
} finally {
Toast.clear()
this.loaded = true
}
},
previewImage(index) {
const urls = this.photoList
.filter(item => item.tag && item.tag.includes('img'))
.map(item => item.imageUrl)
ImagePreview({ images: urls, startPosition: index })
}
}
}
</script>
<style scoped>.wrap {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #F4F5F7;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.environment-photo-wrap {
padding: 12px;
}
.empty-tip {
text-align: center;
color: #999;
margin-top: 60px;
font-size: 14px;
}
.photo-item {
background: #fff;
border-radius: 6px;
margin-bottom: 12px;
overflow: hidden;
}
.media-wrap {
position: relative;
width: 100%;
padding-top: 56.25%;
}
.photo-item img,
.photo-item video {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.photo-name {
font-size: 13px;
color: #333;
padding: 8px 10px;
margin: 0;
}
</style>
+17 -3
View File
@@ -3,8 +3,7 @@
<div class="content">
<div class="goToBtn" @click="goAppHandler">立即打开</div>
<div class="content_tip">
<div class="font_cls">如未安装请点击下载</div>
<div class="font_cls">如已安装未自动跳转 <span @click="downLoadHandler">立即下载</span></div>
<div class="font_cls">如未安装请点击 <span @click="downLoadHandler">立即下载</span></div>
</div>
</div>
@@ -17,7 +16,7 @@
</template>
<script>
import { getNewVersion } from "@/api/order"
import { getNewVersion, getLog } from "@/api/order"
export default {
name: "goToApp",
data() {
@@ -26,6 +25,11 @@ export default {
}
},
mounted() {
getLog({
source: 'App下载页',
event: '页面浏览',
page: '/h5/supplier/dispatch/goToApp'
})
this.showGuide = this.isWeixin()
},
methods: {
@@ -37,9 +41,19 @@ export default {
this.showGuide = false
},
goAppHandler() {
getLog({
source: 'App下载页',
event: '直接打开',
page: '/h5/supplier/dispatch/goToApp'
})
window.location.href = "rvdriver://page/pagesLogin/phoneLogin?source=h5Link";
},
async downLoadHandler() {
getLog({
source: 'App下载页',
event: '立即下载',
page: '/h5/supplier/dispatch/goToApp'
});
getNewVersion({
appType: 5
}).then((res) => {
+5 -2
View File
@@ -41,6 +41,7 @@
const cached = localStorage.getItem('supplierAddForm');
if (cached) {
this.formPayload = JSON.parse(cached);
this.wechatId = this.formPayload.wechatId;
this.name = this.formPayload.name;
}
if (this.name) {
@@ -70,8 +71,10 @@
},
async nextStep() {
if (!this.clickFlag) return;
let res = await getContactQrCodeResult({ configId: this.configId });
this.wechatId = res?.data?.wechatId;
if(!this.wechatId) {
let res = await getContactQrCodeResult({ configId: this.configId, name: this.name });
this.wechatId = res?.data?.wechatId;
}
if (!this.wechatId) {
Toast('请先添加企微再继续');
return;
+1
View File
@@ -312,6 +312,7 @@
accountUrl: this.licensePhoto,
service: this.$refs.tree.getCheckedKeys().join(','),
serviceAreaCode: checkArr.join(','),
wechatId: this.wechatId,
};
localStorage.setItem('supplierAddForm', JSON.stringify(payload));
this.goPage('addWechat', { potentialFlag: 1 });
+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>
+347 -51
View File
@@ -17,45 +17,54 @@
<img class="startImg" src="@/assets/start.png" />
<span>行驶证首页</span>
</div>
<van-uploader
v-model="vehicleLicenseFrontList"
:after-read="vehicleLicenseFrontHandler"
@oversize="onOversize"
:max-size="5 * 1024 * 1024"
max-count="1"
:preview-size="54"
accept="image "
/>
<div class="uploadSlotWrap">
<van-uploader
ref="licenseFrontUploader"
v-model="vehicleLicenseFrontList"
:after-read="vehicleLicenseFrontHandler"
@delete="vehicleLicenseFrontDelete"
max-count="1"
:preview-size="54"
accept="image/*"
/>
<div class="slotClickMask" @click="onLicenseSlotClick('front')"></div>
</div>
</div>
<div class="itemContent">
<div class="titleType">
<img class="startImg" src="@/assets/start.png" />
<span>行驶证副页</span>
</div>
<van-uploader
v-model="vehicleLicenseBackList"
:after-read="vehicleLicenseBackHandler"
@oversize="onOversize"
:max-size="5 * 1024 * 1024"
max-count="1"
:preview-size="54"
accept="image "
/>
<div class="uploadSlotWrap">
<van-uploader
ref="licenseBackUploader"
v-model="vehicleLicenseBackList"
:after-read="vehicleLicenseBackHandler"
@delete="vehicleLicenseBackDelete"
max-count="1"
:preview-size="54"
accept="image/*"
/>
<div class="slotClickMask" @click="onLicenseSlotClick('back')"></div>
</div>
</div>
<div class="itemContent">
<div class="titleType">
<img class="startImg" src="@/assets/start.png" />
<span>行驶证车辆照片页</span>
</div>
<van-uploader
v-model="vehicleLicenseCarPhotoList"
:after-read="vehicleLicenseCarHandler"
@oversize="onOversize"
:max-size="5 * 1024 * 1024"
max-count="1"
:preview-size="54"
accept="image "
/>
<div class="uploadSlotWrap">
<van-uploader
ref="licenseCarUploader"
v-model="vehicleLicenseCarPhotoList"
:after-read="vehicleLicenseCarHandler"
@delete="vehicleLicenseCarPhotoDelete"
max-count="1"
:preview-size="54"
accept="image/*"
/>
<div class="slotClickMask" @click="onLicenseSlotClick('car')"></div>
</div>
</div>
<div class="itemContent">
<div class="titleType">
@@ -65,11 +74,10 @@
<van-uploader
v-model="vehicleFrontPhotoList"
:after-read="vehicleFrontPhotoHandler"
@oversize="onOversize"
:max-size="5 * 1024 * 1024"
@delete="vehicleFrontPhotoDelete"
max-count="1"
:preview-size="54"
accept="image "
accept="image/*"
/>
</div>
@@ -104,6 +112,7 @@
value-key="name"
class="elSelect"
collapse-tags="collapse-tags"
:popper-append-to-body="false"
placeholder="请选择" style="width: 55%"
>
<el-option
@@ -445,6 +454,14 @@
</div>
</van-popup>
<camera-guide
:visible="cameraGuideVisible"
:type="cameraGuideType"
@confirm="onCameraConfirm"
@cancel="onCameraCancel"
@fallback="onCameraFallback"
/>
</div>
</template>
@@ -457,6 +474,7 @@ import {vehicleTypeList,saveVehicle,getInfoById,supplierServiceTree, uploadImage
userOperationPermissions,saveSupplierApproval} from "@/api/mine"
import TwoCommonBtn from "@/components/twoBtnCommon.vue"
import CellGroup from "@/components/cellGroup.vue";
import CameraGuide from "@/components/CameraGuide.vue";
export default {
name: "vehicleAdd",
mixins:[myMixins],
@@ -492,6 +510,8 @@ export default {
oldSupplierServiceList:[],
show:false,
imageUrl: require('@/assets/arr_right.png'),
cameraGuideVisible: false, // 拍摄引导弹层显示
cameraGuideType: 'front', // 当前拍摄槽位 front/back/car
vehicleLicenseFrontList: [],
vehicleLicenseBackList: [],
vehicleLicenseCarPhotoList: [],
@@ -798,33 +818,288 @@ export default {
onOversize() {
this.$toast(`文件大小不能超过5M`)
},
// 将 base64 dataURL 转为 Blob,用于 APP WebView 只返回 content 的场景
dataURLToBlob(dataURL) {
const arr = dataURL.split(',');
const mime = arr[0].match(/:(.*?);/)[1];
const bstr = atob(arr[1]);
let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new Blob([u8arr], { type: mime });
},
blobToDataURL(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => resolve(e.target.result);
reader.onerror = () => reject(new Error('读取压缩图片失败'));
reader.readAsDataURL(blob);
});
},
compressImage(file, maxSize = 5 * 1024 * 1024) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const originWidth = img.width;
const originHeight = img.height;
const tryQuality = (quality = 0.9) => {
canvas.width = originWidth;
canvas.height = originHeight;
ctx.clearRect(0, 0, originWidth, originHeight);
ctx.drawImage(img, 0, 0, originWidth, originHeight);
canvas.toBlob((blob) => {
if (!blob) return reject(new Error('压缩失败'));
if (blob.size <= maxSize || quality <= 0.1) {
if (blob.size <= maxSize) {
resolve(blob);
} else {
tryResolution();
}
return;
}
tryQuality(quality - 0.1);
}, 'image/jpeg', quality);
};
const tryResolution = (scale = 0.9, quality = 0.8) => {
const width = Math.floor(originWidth * scale);
const height = Math.floor(originHeight * scale);
canvas.width = width;
canvas.height = height;
ctx.clearRect(0, 0, width, height);
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob((blob) => {
if (!blob) return reject(new Error('压缩失败'));
if (blob.size <= maxSize || scale <= 0.1) {
if (blob.size <= maxSize) {
resolve(blob);
} else {
reject(new Error('超过5M'));
}
return;
}
tryResolution(scale - 0.1, quality);
}, 'image/jpeg', quality);
};
tryQuality();
};
img.onerror = () => reject(new Error('图片加载失败'));
img.src = e.target.result;
};
reader.onerror = () => reject(new Error('文件读取失败'));
reader.readAsDataURL(file);
});
},
async compressFileItem(fileItem, maxSize = 5 * 1024 * 1024) {
let rawFile = fileItem?.file;
const rawContent = fileItem?.content;
// 打印可序列化的基础信息,VConsole 中对象会显示为空,但 name/size/type 能看到
console.log('rawFile info:', {
name: rawFile?.name,
size: rawFile?.size,
type: rawFile?.type,
constructor: rawFile?.constructor?.name
});
console.log('rawContent prefix:', typeof rawContent === 'string' ? rawContent.slice(0, 60) : rawContent);
// 部分 APP WebView 返回的文件对象异常或为空,但 content 是 base64,此时用 content 兜底
if ((!rawFile || !(rawFile instanceof File) || rawFile.size === 0) &&
typeof rawContent === 'string' && rawContent.startsWith('data:')) {
try {
const blob = this.dataURLToBlob(rawContent);
const ext = blob.type === 'image/png' ? 'png' : 'jpg';
rawFile = new File([blob], `upload_${Date.now()}.${ext}`, { type: blob.type || 'image/jpeg' });
console.log('fallback file from content, size:', rawFile.size);
} catch (e) {
console.error('content to file error:', e);
throw new Error('文件解析失败,请重新上传');
}
}
if (!rawFile) {
throw new Error('未获取到文件,请重新上传');
}
if (rawFile.size <= maxSize) return rawFile;
const blob = await this.compressImage(rawFile, maxSize);
if (blob.size > maxSize) {
throw new Error('超过5M');
}
const fileName = (rawFile.name || 'image').replace(/\.[^.]+$/, '') + '.jpg';
const compressedFile = new File([blob], fileName, { type: 'image/jpeg' });
fileItem.file = compressedFile;
fileItem.size = compressedFile.size;
fileItem.content = await this.blobToDataURL(blob);
const mb1 = (compressedFile.size / 1024 / 1024).toFixed(2);
console.log(`压缩后为:${mb1}M`)
return compressedFile;
},
isCameraSupported() { // 是否支持 getUserMedia(不支持则降级系统选择器)
return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)
},
onLicenseSlotClick(type) { // 行驶证三槽位点击拦截:支持相机进引导页,不支持降级原系统选择器
if (this.isCameraSupported()) {
this.cameraGuideType = type
this.cameraGuideVisible = true
} else {
this.triggerNativeUpload(type)
}
},
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 链路
this.cameraGuideVisible = false
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)
},
onCameraCancel() { // 返回放弃
this.cameraGuideVisible = false
},
onCameraFallback() { // 权限拒绝/相机不可用:弹框询问,重试或降级系统选择器
this.cameraGuideVisible = false
Dialog.confirm({
title: '提示',
message: '相机权限未开启,无法使用拍摄引导。是否重新尝试开启相机?',
confirmButtonText: '重新尝试',
cancelButtonText: '从相册选择',
confirmButtonColor: '#354D93',
}).then(() => {
// 重新打开拍摄引导(等弹层关闭状态先刷新,确保 visible 变化被组件感知)
this.$nextTick(() => {
this.cameraGuideVisible = true
})
}).catch(() => {
// 降级为系统选择器
this.triggerNativeUpload(this.cameraGuideType)
})
},
async vehicleLicenseFrontHandler(file) { // 上传 行驶证首页
const formData = new FormData();
formData.append("file" , file.file);
let res = await uploadImage(formData)
this.vehicleLicenseFront = res.data;
await this.vehicleOcrHandler();
console.log('file',file)
try {
const uploadFile = await this.compressFileItem(file);
console.log('uploadFile',uploadFile)
if (uploadFile.size > 5 * 1024 * 1024) {
this.onOversize();
return;
}
const formData = new FormData();
formData.append("file" , uploadFile);
let res = await uploadImage(formData)
this.vehicleLicenseFront = res.data;
await this.vehicleOcrHandler();
} catch (e) {
console.error('upload compress error:', e);
if (e && e.message === '超过5M') {
this.onOversize();
} else {
this.$toast(e && e.message ? e.message : '上传失败,请重试');
}
}
},
async vehicleLicenseBackHandler(file) { // 上传 行驶证副页
this.vehicleLicenseBackOcrFlag = true;
const formData = new FormData();
formData.append("file" , file.file);
let res = await uploadImage(formData);
this.vehicleLicenseBack = res.data;
await this.vehicleBackOcrHandler();
try {
this.vehicleLicenseBackOcrFlag = true;
const uploadFile = await this.compressFileItem(file);
if (uploadFile.size > 5 * 1024 * 1024) {
this.onOversize();
return;
}
const formData = new FormData();
formData.append("file" , uploadFile);
let res = await uploadImage(formData);
this.vehicleLicenseBack = res.data;
await this.vehicleBackOcrHandler();
} catch (e) {
console.error('upload compress error:', e);
if (e && e.message === '超过5M') {
this.onOversize();
} else {
this.$toast(e && e.message ? e.message : '上传失败,请重试');
}
}
},
async vehicleLicenseCarHandler(file) { // 上传 行驶证车辆照片
const formData = new FormData();
formData.append("file" , file.file);
let res = await uploadImage(formData);
this.vehicleLicenseCarPhoto = res.data;
try {
const uploadFile = await this.compressFileItem(file);
if (uploadFile.size > 5 * 1024 * 1024) {
this.onOversize();
return;
}
const formData = new FormData();
formData.append("file" , uploadFile);
let res = await uploadImage(formData);
this.vehicleLicenseCarPhoto = res.data;
} catch (e) {
console.error('upload compress error:', e);
if (e && e.message === '超过5M') {
this.onOversize();
} else {
this.$toast(e && e.message ? e.message : '上传失败,请重试');
}
}
},
async vehicleFrontPhotoHandler(file) { // 上传 车头照
const formData = new FormData();
formData.append("file" , file.file);
let res = await uploadImage(formData);
this.vehicleFrontPhoto = res.data;
await this.ocrCarFrontHandler()
try {
const uploadFile = await this.compressFileItem(file);
if (uploadFile.size > 5 * 1024 * 1024) {
this.onOversize();
return;
}
const formData = new FormData();
formData.append("file" , uploadFile);
let res = await uploadImage(formData);
this.vehicleFrontPhoto = res.data;
await this.ocrCarFrontHandler()
} catch (e) {
console.error('upload compress error:', e);
if (e && e.message === '超过5M') {
this.onOversize();
} else {
this.$toast(e && e.message ? e.message : '上传失败,请重试');
}
}
},
vehicleLicenseFrontDelete() { // 删除 行驶证首页
this.vehicleLicenseFront = '';
},
vehicleLicenseBackDelete() { // 删除 行驶证副页
this.vehicleLicenseBack = '';
this.vehicleLicenseBackOcrFlag = false;
},
vehicleLicenseCarPhotoDelete() { // 删除 行驶证车辆照片
this.vehicleLicenseCarPhoto = '';
},
vehicleFrontPhotoDelete() { // 删除 车头照
this.vehicleFrontPhoto = '';
},
clickPreview(){
console.log(' this.insurancePicturePhoto', this.insurancePicturePhoto)
@@ -1398,7 +1673,8 @@ export default {
},
components:{
CellGroup,
TwoCommonBtn
TwoCommonBtn,
CameraGuide
}
}
</script>
@@ -1412,6 +1688,23 @@ export default {
<style scoped lang="scss">
@import "@/styles/mixin.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{
@include fontWeightSize(bold,13px);
color: #323643;
@@ -1424,6 +1717,9 @@ export default {
}
.navBar{
margin-bottom: 46px;
::v-deep .van-nav-bar--fixed {
z-index: 9999 !important;
}
}
.wrap{
@include wh(100%,100%);
+72
View File
@@ -96,8 +96,32 @@
<div class="item" v-if="queryType == 9 || queryType ==11 || queryType ==12 || queryType ==5">
<span class="leftTitle fontColor">工单照片:</span><span class="rightContent">
<span class="driverPoiBtn" @click="checkPhoto">查看照片</span>
<span class="driverPoiBtn" v-if="orderDetailInfo.beforeDispatchImages && orderDetailInfo.beforeDispatchImages.length > 0" @click="environmentPhoto">环境照片</span>
</span>
</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 class="serviceInfo common" v-if="orderDetailInfo.batteryNo">
@@ -153,6 +177,16 @@ export default {
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() {
// 获取当前 URL
const urlParams = new URLSearchParams(window.location.search);
@@ -235,6 +269,12 @@ export default {
window.android.sendMessage("orderPhoto,"+this.userOrderId+","+this.orderCode+","+this.taskOrderId+","+isAllowImage);
}
},
environmentPhoto() {
this.$router.push({
path: '/environmentPhotos',
query: { userOrderId: this.userOrderId }
})
},
initMap() {
this.map = new AMap.Map('container',{
zoom:14,//级别(缩放比例 3-20 )
@@ -411,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{
@include fontWeightSize(bold,14px);
@include colorOpa(#323643,0.6);
+45 -2
View File
@@ -35,9 +35,17 @@
<div class="item">
<div class="left opaCol"><span class="star" style="opacity: 0">*</span><span>角色</span></div>
<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>
<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="left" :class="{'opaCol':type!=2}"><span class="star">*</span><span>电话1</span></div>
<div class="right">
@@ -83,6 +91,8 @@
import {myMixins} from "@/utils/myMixins"
import {getConfirmPerson,getConfirmStatus,getQrCode,confirmAddWechat,confirm,submitConfirm} from "@/api/authentication";
import { Dialog } from 'vant';
// 带关系角色(存储格式:角色名-关系)
const RELATION_ROLES = ['其他', '法人关系人', '实控人关系人', '合伙人']
export default {
name: "personList",
mixins:[myMixins],
@@ -163,11 +173,26 @@ export default {
// this.type=2
let res=await getConfirmPerson({supplierId:this.supplierId})
if(res.data && res.data.length>0){
this.personList=res.data
this.personList=res.data.map(item => this.buildRoleDisplay(item))
}else{
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(){//确认已添加微信按钮
Dialog.confirm({
message:'请确认相关人员是否已添加该二维码。',
@@ -238,6 +263,18 @@ export default {
this.$toast('电话号码格式不正确')
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})
this.$toast('操作成功')
if(this.isWebFunc()){
@@ -407,6 +444,12 @@ export default {
color: #323643;
opacity: .65;
}
.item .left.relationLeft{
width: auto;
min-width: 66px;
white-space: nowrap;
padding-right: 4px;
}
.star{
font-weight: bold;
font-size: 13px;