petstore-frontend/src/pages/report-view/reportView.vue
2026-04-13 14:29:21 +08:00

551 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>