|
@@ -91,12 +91,13 @@
|
|
|
</div>
|
|
</div>
|
|
|
<el-descriptions :column="2" size="small" class="pet-desc" border>
|
|
<el-descriptions :column="2" size="small" class="pet-desc" border>
|
|
|
<el-descriptions-item label="宠物品种">{{ order.petBreed || '-'
|
|
<el-descriptions-item label="宠物品种">{{ order.petBreed || '-'
|
|
|
- }}</el-descriptions-item>
|
|
|
|
|
|
|
+ }}</el-descriptions-item>
|
|
|
<el-descriptions-item label="疫苗状态"><span style="color:#67c23a">{{ order.petVaccine || '-'
|
|
<el-descriptions-item label="疫苗状态"><span style="color:#67c23a">{{ order.petVaccine || '-'
|
|
|
- }}</span></el-descriptions-item>
|
|
|
|
|
|
|
+ }}</span></el-descriptions-item>
|
|
|
<el-descriptions-item label="性格特点">{{ order.petCharacter || '-' }}</el-descriptions-item>
|
|
<el-descriptions-item label="性格特点">{{ order.petCharacter || '-' }}</el-descriptions-item>
|
|
|
<el-descriptions-item label="健康状况">{{ order.petHealth || '-' }}</el-descriptions-item>
|
|
<el-descriptions-item label="健康状况">{{ order.petHealth || '-' }}</el-descriptions-item>
|
|
|
- <el-descriptions-item label="性格特点">{{ order.petPersonality || order.petCharacter || '-' }}</el-descriptions-item>
|
|
|
|
|
|
|
+ <el-descriptions-item label="性格特点">{{ order.petPersonality || order.petCharacter || '-'
|
|
|
|
|
+ }}</el-descriptions-item>
|
|
|
<el-descriptions-item label="健康状况">{{ order.petHealth || '-' }}</el-descriptions-item>
|
|
<el-descriptions-item label="健康状况">{{ order.petHealth || '-' }}</el-descriptions-item>
|
|
|
</el-descriptions>
|
|
</el-descriptions>
|
|
|
</div>
|
|
</div>
|
|
@@ -109,7 +110,7 @@
|
|
|
<div class="user-content">
|
|
<div class="user-content">
|
|
|
<div class="u-row">
|
|
<div class="u-row">
|
|
|
<el-avatar :size="40" :src="order.userAvatar">{{ (order.userName || '').charAt(0)
|
|
<el-avatar :size="40" :src="order.userAvatar">{{ (order.userName || '').charAt(0)
|
|
|
- }}</el-avatar>
|
|
|
|
|
|
|
+ }}</el-avatar>
|
|
|
<div class="u-info">
|
|
<div class="u-info">
|
|
|
<div class="nm">{{ order.userName }}</div>
|
|
<div class="nm">{{ order.userName }}</div>
|
|
|
<div class="ph">{{ order.contactPhone }}</div>
|
|
<div class="ph">{{ order.contactPhone }}</div>
|
|
@@ -142,18 +143,19 @@
|
|
|
<el-descriptions-item label="归属门店">{{ order.merchantName }}
|
|
<el-descriptions-item label="归属门店">{{ order.merchantName }}
|
|
|
({{ Number(order.platformId) === 1 ? '门店下单' : '平台代下单' }})</el-descriptions-item>
|
|
({{ Number(order.platformId) === 1 ? '门店下单' : '平台代下单' }})</el-descriptions-item>
|
|
|
<el-descriptions-item label="宠主信息">{{ order.userName }} / {{ order.contactPhone
|
|
<el-descriptions-item label="宠主信息">{{ order.userName }} / {{ order.contactPhone
|
|
|
- }}</el-descriptions-item>
|
|
|
|
|
|
|
+ }}</el-descriptions-item>
|
|
|
<el-descriptions-item label="履约佣金" label-class-name="money-label">
|
|
<el-descriptions-item label="履约佣金" label-class-name="money-label">
|
|
|
<span style="color:#f56c6c; font-weight:bold;">¥ {{ order.fulfillerFee }}</span>
|
|
<span style="color:#f56c6c; font-weight:bold;">¥ {{ order.fulfillerFee }}</span>
|
|
|
</el-descriptions-item>
|
|
</el-descriptions-item>
|
|
|
|
|
|
|
|
<el-descriptions-item label="预约时间">{{ getServiceTimeRange(order.serviceTime)
|
|
<el-descriptions-item label="预约时间">{{ getServiceTimeRange(order.serviceTime)
|
|
|
- }}</el-descriptions-item>
|
|
|
|
|
|
|
+ }}</el-descriptions-item>
|
|
|
<el-descriptions-item label="团购套餐">{{ order.groupBuyPackage || '-'
|
|
<el-descriptions-item label="团购套餐">{{ order.groupBuyPackage || '-'
|
|
|
- }}</el-descriptions-item>
|
|
|
|
|
|
|
+ }}</el-descriptions-item>
|
|
|
<el-descriptions-item label="创建时间">{{ order.createTime }}</el-descriptions-item>
|
|
<el-descriptions-item label="创建时间">{{ order.createTime }}</el-descriptions-item>
|
|
|
<el-descriptions-item label="订单佣金" label-class-name="money-label">
|
|
<el-descriptions-item label="订单佣金" label-class-name="money-label">
|
|
|
- <span style="color:#f56c6c; font-weight:bold;">¥ {{ order.orderCommission || 0 }}</span>
|
|
|
|
|
|
|
+ <span style="color:#f56c6c; font-weight:bold;">¥ {{ order.orderCommission || 0
|
|
|
|
|
+ }}</span>
|
|
|
</el-descriptions-item>
|
|
</el-descriptions-item>
|
|
|
|
|
|
|
|
<el-descriptions-item label="订单备注" :span="3">
|
|
<el-descriptions-item label="订单备注" :span="3">
|
|
@@ -173,7 +175,7 @@
|
|
|
<div class="t-row">
|
|
<div class="t-row">
|
|
|
<span class="t-k">起点</span>
|
|
<span class="t-k">起点</span>
|
|
|
<span class="t-v">{{ order.detail?.fromAddress || order.detail?.pickAddr || '-'
|
|
<span class="t-v">{{ order.detail?.fromAddress || order.detail?.pickAddr || '-'
|
|
|
- }}</span>
|
|
|
|
|
|
|
+ }}</span>
|
|
|
</div>
|
|
</div>
|
|
|
<div class="t-row">
|
|
<div class="t-row">
|
|
|
<span class="t-k">终点</span>
|
|
<span class="t-k">终点</span>
|
|
@@ -193,7 +195,7 @@
|
|
|
<div class="sec-title-bar">服务执行要求</div>
|
|
<div class="sec-title-bar">服务执行要求</div>
|
|
|
<el-descriptions :column="2" border size="default" class="custom-desc">
|
|
<el-descriptions :column="2" border size="default" class="custom-desc">
|
|
|
<el-descriptions-item label="服务地址" :span="2">{{ order.detail.area || order.address
|
|
<el-descriptions-item label="服务地址" :span="2">{{ order.detail.area || order.address
|
|
|
- }}</el-descriptions-item>
|
|
|
|
|
|
|
+ }}</el-descriptions-item>
|
|
|
</el-descriptions>
|
|
</el-descriptions>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
@@ -205,12 +207,14 @@
|
|
|
<div v-if="order.fulfillerName" class="fulfiller-card">
|
|
<div v-if="order.fulfillerName" class="fulfiller-card">
|
|
|
<div class="f-left">
|
|
<div class="f-left">
|
|
|
<el-avatar :size="60" :src="order.fulfillerAvatar">{{ order.fulfillerName.charAt(0)
|
|
<el-avatar :size="60" :src="order.fulfillerAvatar">{{ order.fulfillerName.charAt(0)
|
|
|
- }}</el-avatar>
|
|
|
|
|
|
|
+ }}</el-avatar>
|
|
|
</div>
|
|
</div>
|
|
|
<div class="f-right">
|
|
<div class="f-right">
|
|
|
<div class="f-row1">
|
|
<div class="f-row1">
|
|
|
<span class="f-name">{{ order.fulfillerName }}</span>
|
|
<span class="f-name">{{ order.fulfillerName }}</span>
|
|
|
- <el-tag size="small" type="primary" effect="plain" round>{{ order.fulfillerLevelName || '普通履约者' }}</el-tag>
|
|
|
|
|
|
|
+ <el-tag size="small" type="primary" effect="plain" round>{{
|
|
|
|
|
+ order.fulfillerLevelName ||
|
|
|
|
|
+ '普通履约者' }}</el-tag>
|
|
|
</div>
|
|
</div>
|
|
|
<div class="f-row2">
|
|
<div class="f-row2">
|
|
|
<span>联系电话:{{ order.fulfillerPhone || '138****0000' }}</span>
|
|
<span>联系电话:{{ order.fulfillerPhone || '138****0000' }}</span>
|
|
@@ -234,6 +238,13 @@
|
|
|
<!-- Tab 3: Service Progress -->
|
|
<!-- Tab 3: Service Progress -->
|
|
|
<el-tab-pane label="服务进度" name="service">
|
|
<el-tab-pane label="服务进度" name="service">
|
|
|
<div class="tab-pane-content">
|
|
<div class="tab-pane-content">
|
|
|
|
|
+ <!-- 导出流程图按钮:与订单日志导出Excel位置一致 -->
|
|
|
|
|
+ <div style="display: flex; justify-content: flex-end; margin-bottom: 15px;">
|
|
|
|
|
+ <el-button type="primary" size="small" icon="Picture"
|
|
|
|
|
+ v-hasPermi="['order:orderList:queryExportExcel']"
|
|
|
|
|
+ @click="handleExportProgressImage">导出流程图</el-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
<div v-if="serviceProgressSteps.length === 0" class="empty-progress"
|
|
<div v-if="serviceProgressSteps.length === 0" class="empty-progress"
|
|
|
style="padding:40px; text-align:center; color:#909399;">
|
|
style="padding:40px; text-align:center; color:#909399;">
|
|
|
<el-result icon="info" title="待接单" sub-title="履约者接单后将在此记录服务进度"></el-result>
|
|
<el-result icon="info" title="待接单" sub-title="履约者接单后将在此记录服务进度"></el-result>
|
|
@@ -315,6 +326,23 @@
|
|
|
</div>
|
|
</div>
|
|
|
</el-drawer>
|
|
</el-drawer>
|
|
|
|
|
|
|
|
|
|
+ <!-- 流程图预览弹窗 -->
|
|
|
|
|
+ <el-dialog v-model="processImageVisible" title="服务流程图预览" width="620px" destroy-on-close append-to-body>
|
|
|
|
|
+ <div v-loading="isProcessing" element-loading-text="正在绘制流程图...">
|
|
|
|
|
+ <div v-if="processImageUrl" style="text-align: center;">
|
|
|
|
|
+ <el-image :src="processImageUrl"
|
|
|
|
|
+ style="max-width: 100%; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1);"
|
|
|
|
|
+ :preview-src-list="[processImageUrl]" :preview-teleported="true" />
|
|
|
|
|
+ <div style="margin-top: 20px;">
|
|
|
|
|
+ <el-button type="primary" icon="Download" @click="downloadProcessImage">下载并保存图片</el-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div v-else-if="!isProcessing" style="text-align: center; color: #909399; padding: 40px;">
|
|
|
|
|
+ 生成图片失败,请稍后重试
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </el-dialog>
|
|
|
|
|
+
|
|
|
<!-- 视频播放弹窗 -->
|
|
<!-- 视频播放弹窗 -->
|
|
|
<el-dialog v-model="videoPreview.visible" title="视频播放" width="800px" append-to-body @closed="videoPreview.url = ''">
|
|
<el-dialog v-model="videoPreview.visible" title="视频播放" width="800px" append-to-body @closed="videoPreview.url = ''">
|
|
|
<div
|
|
<div
|
|
@@ -323,17 +351,107 @@
|
|
|
style="max-width: 100%; max-height: 70vh;"></video>
|
|
style="max-width: 100%; max-height: 70vh;"></video>
|
|
|
</div>
|
|
</div>
|
|
|
</el-dialog>
|
|
</el-dialog>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 隐藏截图区域(离屏渲染模式) @Author: Antigravity -->
|
|
|
|
|
+ <div style="position: fixed; left: -9999px; top: 0; pointer-events: none; background: #fff;">
|
|
|
|
|
+ <div id="order-process-capture-area"
|
|
|
|
|
+ style="width: 650px; padding: 40px; background: #fff; border-radius: 8px; box-sizing: border-box; font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;">
|
|
|
|
|
+ <!-- 报告头部 -->
|
|
|
|
|
+ <div style="margin-bottom: 30px; border-bottom: 2px solid #f0f2f5; padding-bottom: 20px;">
|
|
|
|
|
+ <div
|
|
|
|
|
+ style="font-size: 20px; font-weight: bold; color: #303133; display: flex; align-items: center; gap: 12px;">
|
|
|
|
|
+ <span
|
|
|
|
|
+ style="font-size: 14px; padding: 4px 10px; border-radius: 4px; background: #409eff; color: #fff;">{{
|
|
|
|
|
+ getStatusName(order?.status) }}</span>
|
|
|
|
|
+ <span>服务流程:{{ order?.orderNo || order?.code }}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div style="margin-top: 10px; font-size: 13px; color: #909399;">
|
|
|
|
|
+ 报告生成时间:{{ captureTime }}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 进度步骤列表 -->
|
|
|
|
|
+ <div style="padding-left: 10px;">
|
|
|
|
|
+ <div v-if="serviceProgressSteps.length === 0"
|
|
|
|
|
+ style="padding: 40px; text-align: center; color: #909399;">
|
|
|
|
|
+ 暂无服务进度记录
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div v-else>
|
|
|
|
|
+ <div v-for="(step, index) in serviceProgressSteps" :key="index"
|
|
|
|
|
+ style="position: relative; padding-bottom: 35px; padding-left: 40px;">
|
|
|
|
|
+ <!-- 垂线 -->
|
|
|
|
|
+ <div v-if="index < serviceProgressSteps.length - 1"
|
|
|
|
|
+ style="position: absolute; left: 13px; top: 13px; bottom: 0; width: 2px; background-color: #e4e7ed;">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <!-- 圆点 -->
|
|
|
|
|
+ <div :style="{
|
|
|
|
|
+ position: 'absolute', left: '0', top: '0',
|
|
|
|
|
+ width: '28px', height: '28px', borderRadius: '50%',
|
|
|
|
|
+ backgroundColor: '#fff',
|
|
|
|
|
+ border: '3px solid ' + (step.color || '#ff9900'),
|
|
|
|
|
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
|
|
|
+ zIndex: '1', color: step.color || '#ff9900', fontSize: '12px', fontWeight: 'bold'
|
|
|
|
|
+ }">●</div>
|
|
|
|
|
+ <!-- 时间 -->
|
|
|
|
|
+ <div style="font-size: 14px; color: #909399; margin-bottom: 10px; font-weight: 500;">{{
|
|
|
|
|
+ step.time
|
|
|
|
|
+ }}</div>
|
|
|
|
|
+ <!-- 进度卡片 -->
|
|
|
|
|
+ <div
|
|
|
|
|
+ style="background: #f8fcfb; border-radius: 8px; padding: 20px; border: 1px solid #ebeef5; width: calc(100% - 10px); box-sizing: border-box;">
|
|
|
|
|
+ <h4 style="margin: 0 0 12px; font-size: 17px; font-weight: bold; color: #303133;">{{
|
|
|
|
|
+ step.title
|
|
|
|
|
+ }}</h4>
|
|
|
|
|
+ <p
|
|
|
|
|
+ style="margin: 0 0 18px; color: #606266; font-size: 14px; line-height: 1.7; text-align: justify;">
|
|
|
|
|
+ {{ step.desc }}</p>
|
|
|
|
|
+ <!-- 图片展示(截图区域内用 base64 DataURL,避免 html2canvas 跨域问题) -->
|
|
|
|
|
+ <div v-if="step.media && step.media.length"
|
|
|
|
|
+ style="display: flex; gap: 12px; flex-wrap: wrap;">
|
|
|
|
|
+ <div v-for="(item, i) in step.media" :key="i">
|
|
|
|
|
+ <!-- 图片:用预加载的 base64 -->
|
|
|
|
|
+ <img v-if="item.type === 'image' && captureBase64Cache[item.url]"
|
|
|
|
|
+ :src="captureBase64Cache[item.url]"
|
|
|
|
|
+ decoding="sync"
|
|
|
|
|
+ loading="eager"
|
|
|
|
|
+ style="width: 140px; height: 140px; border-radius: 6px; border: 1px solid #e4e7ed; object-fit: cover; display: block;" />
|
|
|
|
|
+ <!-- 占位白块(当加载失败或还未加载完时) -->
|
|
|
|
|
+ <div v-else-if="item.type === 'image'"
|
|
|
|
|
+ style="width: 140px; height: 140px; border-radius: 6px; background: #f5f7fa; border: 1px solid #e4e7ed;">
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <!-- 视频: html2canvas 无法渲染 video,展示占位标识 -->
|
|
|
|
|
+ <div v-else-if="item.type === 'video'"
|
|
|
|
|
+ style="width: 140px; height: 140px; border-radius: 6px; border: 1px solid #e4e7ed; background: #1a1a2e; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 12px; flex-direction: column; gap: 6px;">
|
|
|
|
|
+ <span style="font-size: 28px;">▶</span>
|
|
|
|
|
+ <span>视频</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 页脚 -->
|
|
|
|
|
+ <div style="margin-top: 50px; text-align: center; border-top: 1px solid #f0f2f5; padding-top: 25px;">
|
|
|
|
|
+ <div style="font-size: 13px; color: #c0c4cc; font-style: italic; letter-spacing: 1px;">Powered by
|
|
|
|
|
+ 宠宝管理系统 · 官方服务证书</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
|
<script setup>
|
|
<script setup>
|
|
|
-import { ref, reactive, computed, watch, getCurrentInstance } from 'vue'
|
|
|
|
|
|
|
+import { ref, reactive, computed, watch, nextTick, getCurrentInstance } from 'vue'
|
|
|
import { ElMessage } from 'element-plus'
|
|
import { ElMessage } from 'element-plus'
|
|
|
|
|
+import html2canvas from 'html2canvas'
|
|
|
import { getPet } from '@/api/archieves/pet'
|
|
import { getPet } from '@/api/archieves/pet'
|
|
|
import { getCustomer } from '@/api/archieves/customer'
|
|
import { getCustomer } from '@/api/archieves/customer'
|
|
|
import { getSubOrderInfo } from '@/api/order/subOrder/index'
|
|
import { getSubOrderInfo } from '@/api/order/subOrder/index'
|
|
|
import { getFulfiller } from '@/api/fulfiller/fulfiller/index'
|
|
import { getFulfiller } from '@/api/fulfiller/fulfiller/index'
|
|
|
import { listSubOrderLog, exportSubOrderLogUrl } from '@/api/order/subOrderLog/index'
|
|
import { listSubOrderLog, exportSubOrderLogUrl } from '@/api/order/subOrderLog/index'
|
|
|
import { listComplaintByOrder } from '@/api/fulfiller/complaint'
|
|
import { listComplaintByOrder } from '@/api/fulfiller/complaint'
|
|
|
|
|
+import { listByIds, downloadOssBlob } from '@/api/system/oss'
|
|
|
|
|
|
|
|
const { proxy } = getCurrentInstance()
|
|
const { proxy } = getCurrentInstance()
|
|
|
|
|
|
|
@@ -389,7 +507,24 @@ const loadOrderLogs = async (order) => {
|
|
|
const list = res?.data?.data || res?.data || []
|
|
const list = res?.data?.data || res?.data || []
|
|
|
const arr = Array.isArray(list) ? list : []
|
|
const arr = Array.isArray(list) ? list : []
|
|
|
orderLogs.value = arr.filter(i => Number(i?.logType) === 0)
|
|
orderLogs.value = arr.filter(i => Number(i?.logType) === 0)
|
|
|
- fulfillerLogs.value = arr.filter(i => Number(i?.logType) === 1)
|
|
|
|
|
|
|
+
|
|
|
|
|
+ // 对履约者日志中含有 photos(ossId 串)的条目,调用 listByIds 解析出可访问的 OSS URL
|
|
|
|
|
+ const fLogs = arr.filter(i => Number(i?.logType) === 1)
|
|
|
|
|
+ await Promise.all(fLogs.map(async (item) => {
|
|
|
|
|
+ const photoIds = item?.photos
|
|
|
|
|
+ if (photoIds) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const ossRes = await listByIds(photoIds)
|
|
|
|
|
+ const ossList = ossRes?.data || []
|
|
|
|
|
+ item._resolvedUrls = ossList.map(o => ({ type: isVideo(o.url) ? 'video' : 'image', url: o.url, ossId: o.ossId }))
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ item._resolvedUrls = []
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ item._resolvedUrls = []
|
|
|
|
|
+ }
|
|
|
|
|
+ }))
|
|
|
|
|
+ fulfillerLogs.value = fLogs
|
|
|
} catch {
|
|
} catch {
|
|
|
orderLogs.value = []
|
|
orderLogs.value = []
|
|
|
fulfillerLogs.value = []
|
|
fulfillerLogs.value = []
|
|
@@ -597,13 +732,8 @@ const currentOrderSteps = computed(() => {
|
|
|
const serviceProgressSteps = computed(() => {
|
|
const serviceProgressSteps = computed(() => {
|
|
|
const list = fulfillerLogs.value || []
|
|
const list = fulfillerLogs.value || []
|
|
|
return list.map((i) => {
|
|
return list.map((i) => {
|
|
|
- // 使用 photoUrls 展示,而非 photos
|
|
|
|
|
- const rawUrls = i?.photoUrls || [];
|
|
|
|
|
- const urlList = Array.isArray(rawUrls) ? rawUrls : (typeof rawUrls === 'string' ? rawUrls.split(',').filter(Boolean) : []);
|
|
|
|
|
- const media = urlList.map(url => {
|
|
|
|
|
- const type = isVideo(url) ? 'video' : 'image';
|
|
|
|
|
- return { type, url }
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ // 使用 _resolvedUrls(通过 listByIds 解析后的 OSS 可访问 URL),而非直接使用 photoUrls
|
|
|
|
|
+ const media = Array.isArray(i?._resolvedUrls) ? i._resolvedUrls : []
|
|
|
|
|
|
|
|
return {
|
|
return {
|
|
|
title: i?.title || '--',
|
|
title: i?.title || '--',
|
|
@@ -628,6 +758,195 @@ const handleExportLogs = () => {
|
|
|
`OrderLogs_${props.order.orderNo}_${new Date().getTime()}.xlsx`
|
|
`OrderLogs_${props.order.orderNo}_${new Date().getTime()}.xlsx`
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+// =================== 服务流程图导出(仿 platform-admin/OrderList.vue handleProcessImage 实现) ===================
|
|
|
|
|
+
|
|
|
|
|
+/** 流程图预览弹窗状态 */
|
|
|
|
|
+const processImageVisible = ref(false)
|
|
|
|
|
+/** 生成的流程图 base64 DataURL */
|
|
|
|
|
+const processImageUrl = ref('')
|
|
|
|
|
+/** 是否正在截图中 */
|
|
|
|
|
+const isProcessing = ref(false)
|
|
|
|
|
+/** 截图报告时间戳(用于隐藏 DOM 渲染) */
|
|
|
|
|
+const captureTime = ref('')
|
|
|
|
|
+/**
|
|
|
|
|
+ * 截图区域的图片 base64 缓存表
|
|
|
|
|
+ * key: ossId(如果有)或图片 url(如果无 ossId)
|
|
|
|
|
+ * value: base64 DataURL
|
|
|
|
|
+ */
|
|
|
|
|
+const captureBase64Cache = ref({})
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 将图片/OSS对象转换为 base64 DataURL @Author: Antigravity
|
|
|
|
|
+ * 优先采用后端代理下载以解决跨域问题,如果无 ossId 则尝试直接 fetch
|
|
|
|
|
+ */
|
|
|
|
|
+const loadImageAsBase64 = async (item) => {
|
|
|
|
|
+ const { ossId, url } = item;
|
|
|
|
|
+ try {
|
|
|
|
|
+ let blob;
|
|
|
|
|
+ if (ossId) {
|
|
|
|
|
+ blob = await downloadOssBlob(ossId);
|
|
|
|
|
+ } else if (url) {
|
|
|
|
|
+ const response = await fetch(url);
|
|
|
|
|
+ blob = await response.blob();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (!blob || blob.size === 0) {
|
|
|
|
|
+ console.warn('[FlowChart] 下载到的 Blob 为空:', url || ossId);
|
|
|
|
|
+ return '';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ console.log(`[FlowChart] 成功获取 Blob: 尺寸=${blob.size}, 类型=${blob.type}, ID=${ossId || 'N/A'}`);
|
|
|
|
|
+
|
|
|
|
|
+ // 如果是报错返回的 JSON
|
|
|
|
|
+ if (blob.type === 'application/json') {
|
|
|
|
|
+ const text = await blob.text();
|
|
|
|
|
+ console.error('[FlowChart] 图片下载返回了错误 JSON:', text);
|
|
|
|
|
+ return '';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return new Promise((resolve) => {
|
|
|
|
|
+ const reader = new FileReader();
|
|
|
|
|
+ reader.onloadend = () => {
|
|
|
|
|
+ const result = reader.result;
|
|
|
|
|
+ // 只要是有效的 DataURL 且长度合理就通过,不再严格限制 image/ 前缀
|
|
|
|
|
+ if (typeof result === 'string' && result.length > 100) {
|
|
|
|
|
+ resolve(result);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ console.warn('[FlowChart] 生成的 Base64 长度不足或无效:', url || ossId);
|
|
|
|
|
+ resolve('');
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+ reader.onerror = () => {
|
|
|
|
|
+ console.error('[FlowChart] FileReader 读取失败');
|
|
|
|
|
+ resolve('');
|
|
|
|
|
+ };
|
|
|
|
|
+ reader.readAsDataURL(blob);
|
|
|
|
|
+ });
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error('[FlowChart] 图片加载异常:', url || ossId, err);
|
|
|
|
|
+ return '';
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 点击「导出流程图」:
|
|
|
|
|
+ * 1. 预加载所有图片为 base64(以解决 html2canvas 跨域无法捕获问题)
|
|
|
|
|
+ * 2. 更新 captureTime,等 DOM 刷新
|
|
|
|
|
+ * 3. 用 html2canvas 截取隐藏区域
|
|
|
|
|
+ * 4. 弹窗展示预览图
|
|
|
|
|
+ */
|
|
|
|
|
+const handleExportProgressImage = async () => {
|
|
|
|
|
+ if (serviceProgressSteps.value.length === 0) {
|
|
|
|
|
+ ElMessage.warning('暂无服务进度记录,无法导出流程图');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ processImageUrl.value = ''
|
|
|
|
|
+ isProcessing.value = true
|
|
|
|
|
+ processImageVisible.value = true
|
|
|
|
|
+ captureTime.value = new Date().toLocaleString()
|
|
|
|
|
+ captureBase64Cache.value = {}
|
|
|
|
|
+
|
|
|
|
|
+ // 收集所有需要预加载的图片 @Author: Antigravity
|
|
|
|
|
+ const itemsToLoad = []
|
|
|
|
|
+ for (const step of serviceProgressSteps.value) {
|
|
|
|
|
+ if (!step.media || !step.media.length) continue
|
|
|
|
|
+ for (const mediaItem of step.media) {
|
|
|
|
|
+ if (mediaItem.type === 'image' && mediaItem.url) {
|
|
|
|
|
+ // 仅收集还未加载过的
|
|
|
|
|
+ if (!captureBase64Cache.value[mediaItem.url]) {
|
|
|
|
|
+ itemsToLoad.push(mediaItem)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 并行下载所有图片为 base64 @Author: Antigravity
|
|
|
|
|
+ if (itemsToLoad.length > 0) {
|
|
|
|
|
+ console.log(`[FlowChart] 开始预加载 ${itemsToLoad.length} 张图片...`);
|
|
|
|
|
+ const results = await Promise.all(
|
|
|
|
|
+ itemsToLoad.map(async (item) => {
|
|
|
|
|
+ const b64 = await loadImageAsBase64(item);
|
|
|
|
|
+ return { url: item.url, b64 };
|
|
|
|
|
+ })
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ // 批量更新缓存,确保触发响应式
|
|
|
|
|
+ const newCache = { ...captureBase64Cache.value };
|
|
|
|
|
+ results.forEach(({ url, b64 }) => {
|
|
|
|
|
+ if (b64) newCache[url] = b64;
|
|
|
|
|
+ });
|
|
|
|
|
+ captureBase64Cache.value = newCache;
|
|
|
|
|
+ console.log('[FlowChart] 图片预加载完成,当前有效缓存数:', Object.keys(newCache).length);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 等待 Vue DOM 更新
|
|
|
|
|
+ await nextTick()
|
|
|
|
|
+
|
|
|
|
|
+ // 获取截图区域并等待内部所有图片 load 完成
|
|
|
|
|
+ const el = document.getElementById('order-process-capture-area')
|
|
|
|
|
+ if (!el) {
|
|
|
|
|
+ ElMessage.error('截图区域未找到')
|
|
|
|
|
+ isProcessing.value = false
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 显式等待所有图片加载完毕
|
|
|
|
|
+ const imgs = el.querySelectorAll('img');
|
|
|
|
|
+ if (imgs.length > 0) {
|
|
|
|
|
+ console.log(`[FlowChart] 发现区域内有 ${imgs.length} 张图片,等待浏览器解码...`);
|
|
|
|
|
+ await Promise.all(Array.from(imgs).map(img => {
|
|
|
|
|
+ if (img.complete && img.naturalWidth > 0) return Promise.resolve();
|
|
|
|
|
+ return new Promise(resolve => {
|
|
|
|
|
+ img.onload = () => {
|
|
|
|
|
+ console.log('[FlowChart] 图片加载成功:', img.src.substring(0, 50) + '...');
|
|
|
|
|
+ resolve();
|
|
|
|
|
+ };
|
|
|
|
|
+ img.onerror = () => {
|
|
|
|
|
+ console.error('[FlowChart] 图片加载失败:', img.src.substring(0, 50) + '...');
|
|
|
|
|
+ resolve();
|
|
|
|
|
+ };
|
|
|
|
|
+ // 如果超过 3 秒还没加载完,强制继续,避免卡死
|
|
|
|
|
+ setTimeout(resolve, 3000);
|
|
|
|
|
+ });
|
|
|
|
|
+ }));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 再给 300ms 缓冲时间
|
|
|
|
|
+ await new Promise(resolve => setTimeout(resolve, 300))
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const canvas = await html2canvas(el, {
|
|
|
|
|
+ scale: 2,
|
|
|
|
|
+ useCORS: true,
|
|
|
|
|
+ allowTaint: true,
|
|
|
|
|
+ backgroundColor: '#ffffff',
|
|
|
|
|
+ logging: true,
|
|
|
|
|
+ // 明确指定宽高,防止在某些浏览器下因离屏导致尺寸计算为 0
|
|
|
|
|
+ width: el.offsetWidth,
|
|
|
|
|
+ height: el.offsetHeight
|
|
|
|
|
+ })
|
|
|
|
|
+ processImageUrl.value = canvas.toDataURL('image/png')
|
|
|
|
|
+ console.log('[FlowChart] Canvas 绘制完成');
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ console.error('[FlowChart] 生成失败:', e)
|
|
|
|
|
+ ElMessage.error('流程图生成失败')
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ isProcessing.value = false
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 下载流程图到本地
|
|
|
|
|
+ */
|
|
|
|
|
+const downloadProcessImage = () => {
|
|
|
|
|
+ if (!processImageUrl.value) return
|
|
|
|
|
+ const orderNo = order.value?.orderNo || order.value?.code || 'order'
|
|
|
|
|
+ const link = document.createElement('a')
|
|
|
|
|
+ link.href = processImageUrl.value
|
|
|
|
|
+ link.download = `服务流程图_${orderNo}_${new Date().getTime()}.png`
|
|
|
|
|
+ link.click()
|
|
|
|
|
+}
|
|
|
</script>
|
|
</script>
|
|
|
|
|
|
|
|
<style scoped>
|
|
<style scoped>
|