petstore-frontend/pages/report-view/reportView.vue
2026-04-12 22:57:48 +08:00

417 lines
12 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>
<view class="report-view">
<!-- 加载中 -->
<view v-if="loading" class="loading-wrap">
<view class="loading-inner">
<view class="loading-spinner"></view>
<text class="loading-text">加载报告中...</text>
</view>
</view>
<!-- 未找到 -->
<view v-else-if="notFound" class="not-found">
<text class="not-found-icon">🔍</text>
<text class="not-found-text">报告不存在或链接已失效</text>
</view>
<!-- 报告内容 -->
<view v-else-if="reportData" class="report-content">
<!-- 品牌头部 -->
<view class="brand-header">
<view class="brand-logo">{{ reportData.store?.name || '宠伴生活馆' }}</view>
<view class="brand-sub">宠物服务,让爱更专业</view>
<view class="brand-contact" v-if="reportData.store?.phone || reportData.store?.address">
<text v-if="reportData.store?.phone">📞 {{ reportData.store.phone }}</text>
<text v-if="reportData.store?.address">📍 {{ reportData.store.address }}</text>
</view>
</view>
<!-- 报告标题 -->
<view class="report-title-wrap">
<view class="report-title">服务报告</view>
<view class="report-time">{{ formatTime(reportData.appointmentTime) }}</view>
</view>
<!-- 服务信息 -->
<view class="service-info">
<view class="info-row">
<text class="info-label">宠物名字</text>
<text class="info-val">{{ reportData.petName || '-' }}</text>
</view>
<view class="info-row">
<text class="info-label">服务项目</text>
<text class="info-val">{{ reportData.serviceType || '-' }}</text>
</view>
<view class="info-row">
<text class="info-label">服务时间</text>
<text class="info-val">{{ formatTime(reportData.appointmentTime) }}</text>
</view>
<view class="info-row">
<text class="info-label">服务技师</text>
<text class="info-val">{{ reportData.staffName || '-' }}</text>
</view>
</view>
<!-- 照片对比 -->
<view class="photo-section">
<view class="section-label">服务前后对比</view>
<view class="photo-grid">
<view class="photo-cell">
<text class="photo-label">服务前</text>
<image
v-if="reportData.beforePhoto"
:src="imgUrl(reportData.beforePhoto)"
class="photo-img"
mode="aspectFill"
/>
<view v-else class="photo-empty">暂无照片</view>
</view>
<view class="photo-cell">
<text class="photo-label">服务后</text>
<image
v-if="reportData.afterPhoto"
:src="imgUrl(reportData.afterPhoto)"
class="photo-img"
mode="aspectFill"
/>
<view v-else class="photo-empty">暂无照片</view>
</view>
</view>
</view>
<!-- 备注 -->
<view v-if="reportData.remark" class="remark-section">
<view class="section-label">备注</view>
<view class="remark-content">{{ reportData.remark }}</view>
</view>
<!-- 生成海报按钮 -->
<view class="action-section">
<view class="btn-share" @click="generatePoster">📸 生成图片分享朋友圈</view>
<view class="btn-book" @click="goHome">我要预约</view>
</view>
</view>
<!-- Canvas海报隐藏 -->
<canvas canvas-id="posterCanvas" id="posterCanvas" class="poster-canvas" />
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getReportByToken, imgUrl } from '../../utils/api.js'
const loading = ref(true)
const notFound = ref(false)
const reportData = ref(null)
const formatTime = (time) => {
if (!time) return ''
const d = new Date(time)
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`
}
const loadReport = async () => {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const token = currentPage?.options?.token || ''
if (!token) {
notFound.value = true
loading.value = false
return
}
const res = await getReportByToken(token)
loading.value = false
if (res.code === 200) {
reportData.value = res.data
} else {
notFound.value = true
}
}
const loadImage = (src) => {
return new Promise((resolve) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => resolve(img)
img.onerror = () => resolve(null)
img.src = src
})
}
const goHome = () => {
uni.switchTab({ url: '/pages/home/home' })
}
const generatePoster = async () => {
if (!reportData.value) return
uni.showLoading({ title: '生成中...' })
const r = reportData.value
const canvas = uni.createCanvasContext('posterCanvas')
canvas.width = 750
canvas.height = 1100
// 背景
canvas.setFillStyle('#ffffff')
canvas.fillRect(0, 0, 750, 1100)
// 顶部绿色
canvas.setFillStyle('#07c160')
canvas.fillRect(0, 0, 750, 300)
const storeName = r.store?.name || '宠伴生活馆'
// Logo
canvas.setFillStyle('#ffffff')
canvas.setFontSize(36)
canvas.setTextAlign('center')
canvas.fillText(storeName, 375, 70)
canvas.setFontSize(20)
canvas.setGlobalAlpha(0.7)
canvas.fillText('宠物服务,让爱更专业', 375, 105)
canvas.setGlobalAlpha(1)
// 联系信息
const storePhone = r.store?.phone || ''
const storeAddr = r.store?.address || ''
if (storePhone || storeAddr) {
canvas.setFontSize(18)
canvas.setGlobalAlpha(0.85)
const contactLine = [storePhone, storeAddr].filter(Boolean).join(' | ')
canvas.fillText(contactLine, 375, 138)
canvas.setGlobalAlpha(1)
}
// 报告标题
canvas.setFillStyle('#333333')
canvas.setFontSize(36)
canvas.fillText('服务报告', 375, 220)
// 服务信息卡片
canvas.setFillStyle('#f8f6f3')
roundRect(canvas, 40, 260, 670, 220, 20)
canvas.fill()
const infoItems = [
['宠物名字', r.petName || '-'],
['服务项目', r.serviceType || '-'],
['服务时间', formatTime(r.appointmentTime) || '-'],
['服务技师', r.staffName || '-']
]
let y = 310
canvas.setTextAlign('left')
infoItems.forEach(([label, val]) => {
canvas.setFillStyle('#999999')
canvas.setFontSize(22)
canvas.fillText(label, 80, y)
canvas.setFillStyle('#333333')
canvas.setFontSize(24)
canvas.fillText(val, 220, y)
y += 48
})
// 照片区域
canvas.setFillStyle('#f8f6f3')
roundRect(canvas, 40, 500, 670, 360, 20)
canvas.fill()
canvas.setFillStyle('#333333')
canvas.setFontSize(24)
canvas.setTextAlign('center')
canvas.fillText('服务前后对比', 375, 545)
const imgY = 575
const imgH = 260
const imgW = 300
// 服务前占位
canvas.setFillStyle('#e0e0e0')
roundRect(canvas, 60, imgY, imgW, imgH, 16)
canvas.fill()
canvas.setFillStyle('#999999')
canvas.setFontSize(20)
canvas.fillText('服务前', 210, imgY + imgH/2)
// 服务后占位
canvas.setFillStyle('#e0e0e0')
roundRect(canvas, 390, imgY, imgW, imgH, 16)
canvas.fill()
canvas.setFillStyle('#999999')
canvas.fillText('服务后', 540, imgY + imgH/2)
// 备注
if (r.remark) {
canvas.setFillStyle('#f8f6f3')
roundRect(canvas, 40, 880, 670, 100, 20)
canvas.fill()
canvas.setFillStyle('#666666')
canvas.setFontSize(22)
canvas.setTextAlign('left')
const remark = r.remark
if (remark.length > 30) {
canvas.fillText(remark.substring(0, 30), 70, 920)
canvas.fillText(remark.substring(30), 70, 955)
} else {
canvas.fillText(remark, 70, 930)
}
}
// 底部Logo
canvas.setFillStyle('#07c160')
canvas.setFontSize(22)
canvas.setTextAlign('center')
canvas.fillText(`${storeName}`, 375, 1050)
canvas.draw()
// 异步绘制真实照片
const beforeImgSrc = r.beforePhoto ? imgUrl(r.beforePhoto) : null
const afterImgSrc = r.afterPhoto ? imgUrl(r.afterPhoto) : null
const [beforeImg, afterImg] = await Promise.all([
beforeImgSrc ? loadImage(beforeImgSrc) : Promise.resolve(null),
afterImgSrc ? loadImage(afterImgSrc) : Promise.resolve(null)
])
if (beforeImg) {
canvas.drawImage(beforeImg, 60, imgY, imgW, imgH)
canvas.draw()
}
if (afterImg) {
canvas.drawImage(afterImg, 390, imgY, imgW, imgH)
canvas.draw()
}
uni.hideLoading()
uni.showToast({ title: '海报已生成,请截图分享', icon: 'none', duration: 3000 })
}
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath()
ctx.moveTo(x + r, y)
ctx.lineTo(x + w - r, y)
ctx.arcTo(x + w, y, x + w, y + r, r)
ctx.lineTo(x + w, y + h - r)
ctx.arcTo(x + w, y + h, x + w - r, y + h, r)
ctx.lineTo(x + r, y + h)
ctx.arcTo(x, y + h, x, y + h - r, r)
ctx.lineTo(x, y + r)
ctx.arcTo(x, y, x + r, y, r)
ctx.closePath()
}
onMounted(() => loadReport())
</script>
<style scoped>
.report-view { background: #f8f6f3; min-height: 100vh; }
.loading-wrap {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.loading-inner { display: flex; flex-direction: column; align-items: center; gap: 16px; }
.loading-spinner {
width: 36px;
height: 36px;
border: 3px solid #07c160;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading-text { font-size: 14px; color: #999; }
.not-found { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; gap: 12px; }
.not-found-icon { font-size: 60px; }
.not-found-text { font-size: 15px; color: #999; }
.report-content { max-width: 430px; margin: 0 auto; background: #fff; min-height: 100vh; }
.brand-header {
background: linear-gradient(135deg, #07c160 0%, #10b76f 100%);
padding: 24px 24px 20px;
text-align: center;
color: #fff;
}
.brand-logo { font-size: 20px; font-weight: 700; letter-spacing: 2px; 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: 24px 24px 16px; }
.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 16px;
background: #f8f6f3;
border-radius: 12px;
padding: 4px 16px;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
}
.info-row:last-child { border-bottom: none; }
.info-label { font-size: 14px; color: #999; }
.info-val { font-size: 14px; color: #333; font-weight: 500; }
.photo-section { margin: 0 24px 24px; }
.section-label { font-size: 15px; font-weight: 600; color: #333; margin-bottom: 12px; }
.photo-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.photo-cell { display: flex; flex-direction: column; gap: 8px; }
.photo-label { font-size: 13px; color: #999; text-align: center; }
.photo-img { width: 100%; height: 160px; border-radius: 12px; object-fit: cover; }
.photo-empty {
width: 100%;
height: 160px;
background: #f0f0f0;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: #ccc;
font-size: 13px;
}
.remark-section { margin: 0 24px 24px; }
.remark-content { background: #f9f9f9; border-radius: 12px; padding: 16px; font-size: 14px; color: #666; line-height: 1.6; min-height: 60px; }
.action-section { margin: 0 24px 24px; }
.btn-share {
width: 100%;
height: 46px;
background: #07c160;
color: #fff;
border-radius: 23px;
font-size: 16px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12px;
}
.btn-book {
width: 100%;
height: 46px;
background: #fff;
color: #07c160;
border: 1px solid #07c160;
border-radius: 23px;
font-size: 16px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
.poster-canvas {
position: fixed;
top: -9999px;
left: -9999px;
width: 750px;
height: 1100px;
}
</style>