551 lines
18 KiB
Vue
551 lines
18 KiB
Vue
<template>
|
||
<div class="report-view">
|
||
<!-- 加载中 -->
|
||
<div v-if="loading" class="loading-wrap">
|
||
<view class="loading-spinner"></view>
|
||
<span>加载报告中...</span>
|
||
</div>
|
||
|
||
<!-- 未找到 -->
|
||
<div v-else-if="notFound" class="not-found">
|
||
<view class="empty"><text>报告不存在或链接已失效</text></view>
|
||
</div>
|
||
|
||
<!-- 报告内容 -->
|
||
<div v-else-if="reportData" class="report-content">
|
||
<!-- 品牌头部 -->
|
||
<div class="brand-header" :style="brandHeaderSafeStyle">
|
||
<div class="header-actions">
|
||
<view class="header-btn" @click="goHome">
|
||
<AppIcon name="home" :size="15" color="#ffffff" />
|
||
</view>
|
||
<view class="header-placeholder"></view>
|
||
</div>
|
||
<div class="brand-logo">{{ reportData.store?.name || '宠伴生活馆' }}</div>
|
||
<div class="brand-sub">宠物服务,让爱更专业</div>
|
||
<div class="brand-contact">
|
||
<span v-if="reportData.store?.phone">电话:{{ reportData.store.phone }}</span>
|
||
<span v-if="reportData.store?.address">地址:{{ reportData.store.address }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 暖心问候 (C端视角) -->
|
||
<div v-if="!isStaff" class="customer-greeting">
|
||
亲爱的宠主,<span class="highlight">{{ reportData.petName }}</span> 今天的服务已完成,快来看看TA的变美记录吧!
|
||
</div>
|
||
|
||
<!-- 报告标题 -->
|
||
<div class="report-title-wrap">
|
||
<div class="report-title">服务报告</div>
|
||
<div class="report-time">{{ formatTime(reportData.appointmentTime) }}</div>
|
||
</div>
|
||
|
||
<!-- 服务信息 -->
|
||
<view class="van-cell-group service-info">
|
||
<view class="van-cell">
|
||
<view class="van-cell__title">宠物名字</view>
|
||
<view class="van-cell__value">{{ reportData.petName }}</view>
|
||
</view>
|
||
<view class="van-cell">
|
||
<view class="van-cell__title">服务项目</view>
|
||
<view class="van-cell__value">{{ reportData.serviceType }}</view>
|
||
</view>
|
||
<view class="van-cell">
|
||
<view class="van-cell__title">服务时间</view>
|
||
<view class="van-cell__value">{{ formatTime(reportData.appointmentTime) }}</view>
|
||
</view>
|
||
<view class="van-cell">
|
||
<view class="van-cell__title">服务技师</view>
|
||
<view class="van-cell__value">{{ reportData.staffName || '-' }}</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 照片对比 -->
|
||
<div class="photo-section section-card">
|
||
<div class="section-label">服务前后对比</div>
|
||
|
||
<div class="photo-group">
|
||
<div class="photo-group-title">服务前</div>
|
||
<div v-if="beforePhotos.length > 0" class="photo-grid">
|
||
<image
|
||
v-for="(img, idx) in beforePhotos"
|
||
:key="'before-'+idx"
|
||
:src="imgUrl(img)"
|
||
class="photo-image"
|
||
mode="aspectFill"
|
||
/>
|
||
</div>
|
||
<view v-else class="photo-empty">暂无照片</view>
|
||
</div>
|
||
|
||
<div class="photo-group" style="margin-top: 16px;">
|
||
<div class="photo-group-title">服务后</div>
|
||
<div v-if="afterPhotos.length > 0" class="photo-grid">
|
||
<image
|
||
v-for="(img, idx) in afterPhotos"
|
||
:key="'after-'+idx"
|
||
:src="imgUrl(img)"
|
||
class="photo-image"
|
||
mode="aspectFill"
|
||
/>
|
||
</div>
|
||
<view v-else class="photo-empty">暂无照片</view>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 备注 -->
|
||
<div class="remark-section section-card">
|
||
<div class="section-label">备注</div>
|
||
<div class="remark-content">
|
||
{{ reportData.remark || '暂无备注' }}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 操作按钮区 -->
|
||
<div class="action-section">
|
||
<!-- 店员视角 -->
|
||
<template v-if="isStaff">
|
||
<button open-type="share" class="van-button van-button--primary van-button--round van-button--block share-btn">
|
||
<AppIcon name="profile" :size="16" color="#ffffff" style="margin-right: 6px;" /> 转发给宠主
|
||
</button>
|
||
<button class="van-button van-button--block btn-ghost" @click="generatePoster">生成海报 · 保存到相册</button>
|
||
</template>
|
||
|
||
<!-- 宠主视角 -->
|
||
<template v-else>
|
||
<button class="van-button van-button--primary van-button--round van-button--block" @click="callStore">
|
||
联系门店
|
||
</button>
|
||
<button class="van-button van-button--block btn-ghost" @click="navToStore">导航去门店</button>
|
||
<button class="van-button van-button--block btn-ghost" style="margin-top: 12px;" @click="goHome">我也要预约</button>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Canvas 海报(H5 DOM / 小程序 2d) -->
|
||
<!-- #ifdef H5 -->
|
||
<canvas ref="posterCanvas" class="poster-canvas-offscreen" />
|
||
<!-- #endif -->
|
||
<!-- #ifdef MP-WEIXIN -->
|
||
<canvas
|
||
id="reportPosterCanvas"
|
||
type="2d"
|
||
class="poster-canvas-offscreen"
|
||
/>
|
||
<!-- #endif -->
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted, getCurrentInstance } from 'vue'
|
||
import { onLoad, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||
import { getReportByToken, imgUrl } from '../../api/index.js'
|
||
import { formatDateTimeYMDHM } from '../../utils/datetime.js'
|
||
import { drawReportPoster, POSTER_W, POSTER_H } from '../../utils/reportPosterDraw.js'
|
||
import AppIcon from '../../components/AppIcon.vue'
|
||
import { isLoggedIn } from '../../utils/session.js'
|
||
|
||
const loading = ref(true)
|
||
const notFound = ref(false)
|
||
const reportData = ref(null)
|
||
const posterCanvas = ref(null)
|
||
const routeToken = ref('')
|
||
|
||
const isStaff = computed(() => isLoggedIn())
|
||
|
||
const beforePhotos = computed(() => {
|
||
if (!reportData.value?.beforePhoto) return []
|
||
return reportData.value.beforePhoto.split(',').filter(Boolean)
|
||
})
|
||
|
||
const afterPhotos = computed(() => {
|
||
if (!reportData.value?.afterPhoto) return []
|
||
return reportData.value.afterPhoto.split(',').filter(Boolean)
|
||
})
|
||
|
||
/** 转发标题:店名 + 宠物 + 服务,更易产生点击欲 */
|
||
const shareTitle = computed(() => {
|
||
const r = reportData.value
|
||
if (!r) return '宠物洗护美容报告'
|
||
const store = r.store?.name || '宠伴生活馆'
|
||
const pet = r.petName || '爱宠'
|
||
const svc = r.serviceType || '洗护美容'
|
||
return `${store}|${pet}的${svc}已完成,点击查看报告`
|
||
})
|
||
|
||
/** 分享封面:优先服务后首图(微信要求可访问 https 图床) */
|
||
const shareCoverUrl = computed(() => {
|
||
if (afterPhotos.value.length > 0) return imgUrl(afterPhotos.value[0])
|
||
if (beforePhotos.value.length > 0) return imgUrl(beforePhotos.value[0])
|
||
return ''
|
||
})
|
||
const brandHeaderSafeStyle = (() => {
|
||
const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20
|
||
return `padding-top:${statusBarHeight + 10}px;`
|
||
})()
|
||
|
||
const formatTime = (time) => formatDateTimeYMDHM(time)
|
||
|
||
const loadReport = async () => {
|
||
let token = routeToken.value
|
||
// #ifdef H5
|
||
token = new URLSearchParams(window.location.search).get('token')
|
||
// #endif
|
||
if (!token) { notFound.value = true; loading.value = false; return }
|
||
|
||
uni.showLoading({ title: '加载中...' })
|
||
const res = await getReportByToken(token)
|
||
uni.hideLoading()
|
||
loading.value = false
|
||
if (res.code === 200) {
|
||
reportData.value = res.data
|
||
} else {
|
||
notFound.value = true
|
||
}
|
||
}
|
||
|
||
const goHome = () => {
|
||
uni.reLaunch({ url: '/pages/home/Home' })
|
||
}
|
||
|
||
const callStore = () => {
|
||
const phone = reportData.value?.store?.phone
|
||
if (!phone) return uni.showToast({ title: '门店未设置电话', icon: 'none' })
|
||
uni.makePhoneCall({ phoneNumber: phone })
|
||
}
|
||
|
||
const navToStore = () => {
|
||
const store = reportData.value?.store
|
||
if (!store || !store.latitude || !store.longitude) {
|
||
return uni.showToast({ title: '门店未设置导航坐标', icon: 'none' })
|
||
}
|
||
uni.openLocation({
|
||
latitude: Number(store.latitude),
|
||
longitude: Number(store.longitude),
|
||
name: store.name || '宠伴生活馆',
|
||
address: store.address || ''
|
||
})
|
||
}
|
||
|
||
onShareAppMessage(() => {
|
||
const q = routeToken.value ? `token=${encodeURIComponent(routeToken.value)}` : ''
|
||
return {
|
||
title: shareTitle.value,
|
||
path: `/pages/report-view/reportView${q ? '?' + q : ''}`,
|
||
imageUrl: shareCoverUrl.value || undefined
|
||
}
|
||
})
|
||
|
||
onShareTimeline(() => {
|
||
const q = routeToken.value ? `token=${encodeURIComponent(routeToken.value)}` : ''
|
||
return {
|
||
title: shareTitle.value,
|
||
query: q,
|
||
imageUrl: shareCoverUrl.value || undefined
|
||
}
|
||
})
|
||
|
||
// #ifdef H5
|
||
const loadImageH5 = (src) => {
|
||
return new Promise((resolve) => {
|
||
const img = new Image()
|
||
img.crossOrigin = 'anonymous'
|
||
img.onload = () => resolve(img)
|
||
img.onerror = () => resolve(null)
|
||
img.src = src
|
||
})
|
||
}
|
||
|
||
const generatePoster = async () => {
|
||
if (!reportData.value) return
|
||
const r = reportData.value
|
||
uni.showToast({ title: '正在生成海报...', icon: 'none' })
|
||
const beforeUrl = beforePhotos.value[0] ? imgUrl(beforePhotos.value[0]) : ''
|
||
const afterUrl = afterPhotos.value[0] ? imgUrl(afterPhotos.value[0]) : ''
|
||
const [beforeImg, afterImg] = await Promise.all([
|
||
beforeUrl ? loadImageH5(beforeUrl) : Promise.resolve(null),
|
||
afterUrl ? loadImageH5(afterUrl) : Promise.resolve(null)
|
||
])
|
||
const canvas = posterCanvas.value
|
||
if (!canvas) return
|
||
const ctx = canvas.getContext('2d')
|
||
canvas.width = POSTER_W
|
||
canvas.height = POSTER_H
|
||
drawReportPoster(ctx, {
|
||
storeName: r.store?.name || '',
|
||
storePhone: r.store?.phone || '',
|
||
storeAddr: r.store?.address || '',
|
||
petName: r.petName || '',
|
||
serviceType: r.serviceType || '',
|
||
timeStr: formatTime(r.appointmentTime) || '',
|
||
staffName: r.staffName || '',
|
||
remark: r.remark || '',
|
||
beforeImg,
|
||
afterImg
|
||
})
|
||
const link = document.createElement('a')
|
||
link.download = `服务报告_${r.petName || '宠物'}.png`
|
||
link.href = canvas.toDataURL('image/png')
|
||
link.click()
|
||
uni.showToast({ title: '已下载海报', icon: 'success' })
|
||
}
|
||
// #endif
|
||
|
||
// #ifdef MP-WEIXIN
|
||
function downloadToTemp(url) {
|
||
return new Promise((resolve) => {
|
||
if (!url) {
|
||
resolve(null)
|
||
return
|
||
}
|
||
uni.downloadFile({
|
||
url,
|
||
success: (res) => {
|
||
if (res.statusCode === 200) resolve(res.tempFilePath)
|
||
else resolve(null)
|
||
},
|
||
fail: () => resolve(null)
|
||
})
|
||
})
|
||
}
|
||
|
||
function loadCanvasImage(canvas, tempPath) {
|
||
return new Promise((resolve) => {
|
||
if (!tempPath) {
|
||
resolve(null)
|
||
return
|
||
}
|
||
const img = canvas.createImage()
|
||
img.onload = () => resolve(img)
|
||
img.onerror = () => resolve(null)
|
||
img.src = tempPath
|
||
})
|
||
}
|
||
|
||
const generatePoster = async () => {
|
||
if (!reportData.value) return
|
||
const r = reportData.value
|
||
uni.showLoading({ title: '生成海报中...', mask: true })
|
||
try {
|
||
const beforeUrl = beforePhotos.value[0] ? imgUrl(beforePhotos.value[0]) : ''
|
||
const afterUrl = afterPhotos.value[0] ? imgUrl(afterPhotos.value[0]) : ''
|
||
const [beforePath, afterPath] = await Promise.all([
|
||
downloadToTemp(beforeUrl),
|
||
downloadToTemp(afterUrl)
|
||
])
|
||
|
||
const inst = getCurrentInstance()
|
||
const proxy = inst?.proxy
|
||
|
||
await new Promise((resolve) => {
|
||
uni.createSelectorQuery()
|
||
.in(proxy)
|
||
.select('#reportPosterCanvas')
|
||
.fields({ node: true, size: true })
|
||
.exec(async (res) => {
|
||
const canvas = res[0]?.node
|
||
if (!canvas) {
|
||
uni.showToast({ title: '画布未就绪,请重试', icon: 'none' })
|
||
resolve()
|
||
return
|
||
}
|
||
const ctx = canvas.getContext('2d')
|
||
const dpr = Math.min(uni.getSystemInfoSync().pixelRatio || 2, 3)
|
||
canvas.width = POSTER_W * dpr
|
||
canvas.height = POSTER_H * dpr
|
||
ctx.scale(dpr, dpr)
|
||
|
||
const beforeImg = await loadCanvasImage(canvas, beforePath)
|
||
const afterImg = await loadCanvasImage(canvas, afterPath)
|
||
|
||
drawReportPoster(ctx, {
|
||
storeName: r.store?.name || '',
|
||
storePhone: r.store?.phone || '',
|
||
storeAddr: r.store?.address || '',
|
||
petName: r.petName || '',
|
||
serviceType: r.serviceType || '',
|
||
timeStr: formatTime(r.appointmentTime) || '',
|
||
staffName: r.staffName || '',
|
||
remark: r.remark || '',
|
||
beforeImg,
|
||
afterImg
|
||
})
|
||
|
||
uni.canvasToTempFilePath(
|
||
{
|
||
canvas,
|
||
fileType: 'png',
|
||
quality: 1,
|
||
destWidth: POSTER_W,
|
||
destHeight: POSTER_H,
|
||
success: (out) => {
|
||
uni.saveImageToPhotosAlbum({
|
||
filePath: out.tempFilePath,
|
||
success: () => {
|
||
uni.showToast({ title: '已保存到相册', icon: 'success' })
|
||
},
|
||
fail: (err) => {
|
||
const msg = err.errMsg || ''
|
||
if (msg.includes('auth deny') || msg.includes('authorize')) {
|
||
uni.showModal({
|
||
title: '需要相册权限',
|
||
content: '保存海报需要授权保存到相册,请在设置中开启。',
|
||
confirmText: '去设置',
|
||
success: (m) => {
|
||
if (m.confirm) uni.openSetting({})
|
||
}
|
||
})
|
||
} else {
|
||
uni.showToast({ title: '保存失败', icon: 'none' })
|
||
}
|
||
}
|
||
})
|
||
},
|
||
fail: () => {
|
||
uni.showToast({ title: '导出图片失败', icon: 'none' })
|
||
}
|
||
},
|
||
proxy
|
||
)
|
||
resolve()
|
||
})
|
||
})
|
||
} catch (_) {
|
||
uni.showToast({ title: '生成失败', icon: 'none' })
|
||
} finally {
|
||
uni.hideLoading()
|
||
}
|
||
}
|
||
// #endif
|
||
|
||
// #ifndef H5
|
||
// #ifndef MP-WEIXIN
|
||
const generatePoster = () => {
|
||
uni.showToast({ title: '请截图分享报告页', icon: 'none' })
|
||
}
|
||
// #endif
|
||
// #endif
|
||
|
||
onLoad((options) => {
|
||
routeToken.value = options?.token || ''
|
||
})
|
||
|
||
onMounted(() => loadReport())
|
||
</script>
|
||
|
||
<style scoped>
|
||
.poster-canvas-offscreen {
|
||
position: fixed;
|
||
left: -9999px;
|
||
top: 0;
|
||
width: 375px;
|
||
height: 550px;
|
||
pointer-events: none;
|
||
}
|
||
.report-view { background: #f5f7fb; min-height: 100vh; }
|
||
.loading-wrap {
|
||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||
min-height: 100vh; gap: 16px; color: #999;
|
||
}
|
||
.loading-spinner {
|
||
width: 32px; height: 32px;
|
||
border: 3px solid #e0e0e0;
|
||
border-top-color: #07c160;
|
||
border-radius: 50%;
|
||
animation: spin 0.8s linear infinite;
|
||
}
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
.not-found { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; }
|
||
.report-content { max-width: 430px; margin: 0 auto; background: #f8fafc; min-height: 100vh; box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06); }
|
||
.brand-header {
|
||
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
|
||
padding: 20px 20px 18px;
|
||
text-align: center; color: #fff;
|
||
border-radius: 0 0 16px 16px;
|
||
box-shadow: 0 8px 20px rgba(34, 197, 94, 0.24);
|
||
position: relative;
|
||
}
|
||
.header-actions {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 8px;
|
||
}
|
||
.header-btn {
|
||
width: 30px;
|
||
height: 30px;
|
||
border-radius: 999px;
|
||
border: 1px solid rgba(255, 255, 255, 0.45);
|
||
background: rgba(255, 255, 255, 0.2);
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.header-placeholder {
|
||
width: 30px;
|
||
height: 30px;
|
||
}
|
||
.brand-logo { font-size: 20px; font-weight: 700; letter-spacing: 1px; margin-bottom: 4px; }
|
||
.brand-sub { font-size: 12px; opacity: 0.7; margin-bottom: 12px; }
|
||
.brand-contact { font-size: 12px; opacity: 0.85; display: flex; justify-content: center; gap: 16px; flex-wrap: wrap; }
|
||
.report-title-wrap { text-align: center; padding: 20px 20px 14px; }
|
||
.report-title { font-size: 22px; font-weight: 700; color: #333; }
|
||
.report-time { font-size: 13px; color: #999; margin-top: 6px; }
|
||
.service-info { margin: 0 16px 12px; border-radius: 14px !important; }
|
||
.section-card {
|
||
margin: 0 16px 12px;
|
||
background: #fff;
|
||
border: 1px solid #e8edf4;
|
||
border-radius: 14px;
|
||
padding: 12px;
|
||
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.04);
|
||
}
|
||
.section-label { font-size: 15px; font-weight: 700; color: #1f2937; margin-bottom: 10px; }
|
||
.photo-group-title { font-size: 13px; color: #64748b; margin-bottom: 8px; }
|
||
.photo-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||
.photo-image {
|
||
width: 100%;
|
||
aspect-ratio: 1 / 1;
|
||
border-radius: 8px;
|
||
border: 1px solid #e5e7eb;
|
||
}
|
||
.photo-empty {
|
||
width: 100%;
|
||
height: 80px;
|
||
background: #f1f5f9;
|
||
border: 1px dashed #d1d9e6;
|
||
border-radius: 8px;
|
||
display: flex; align-items: center; justify-content: center; color: #94a3b8; font-size: 13px;
|
||
}
|
||
.remark-content { background: #f8fafc; border: 1px solid #e8edf4; border-radius: 12px; padding: 14px; font-size: 14px; color: #64748b; line-height: 1.6; min-height: 60px; }
|
||
.action-section { margin: 0 16px 24px; display: flex; flex-direction: column; gap: 12px; }
|
||
.share-btn {
|
||
margin-top: 0 !important;
|
||
box-shadow: 0 6px 14px rgba(34, 197, 94, 0.2) !important;
|
||
}
|
||
.btn-ghost {
|
||
margin-top: 0 !important;
|
||
background: #fff !important;
|
||
color: #666 !important;
|
||
border: 1px solid #e0e0e0 !important;
|
||
box-shadow: none !important;
|
||
}
|
||
.customer-greeting {
|
||
margin: -10px 16px 12px;
|
||
padding: 14px 16px;
|
||
background: #fff;
|
||
border-radius: 12px;
|
||
font-size: 14px;
|
||
color: #475569;
|
||
line-height: 1.5;
|
||
box-shadow: 0 4px 12px rgba(0,0,0,0.03);
|
||
position: relative;
|
||
z-index: 2;
|
||
}
|
||
.customer-greeting .highlight {
|
||
color: #16a34a;
|
||
font-weight: 700;
|
||
margin: 0 2px;
|
||
}
|
||
</style>
|