petstore-frontend/src/views/ReportView.vue
2026-04-18 09:19:46 +08:00

413 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>
<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>
<!-- 报告标题 -->
<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-grid">
<image
v-if="reportData.beforePhoto"
:src="imgUrl(reportData.beforePhoto)"
class="photo-image"
mode="aspectFill"
/>
<view v-else class="photo-empty">暂无照片</view>
<image
v-if="reportData.afterPhoto"
:src="imgUrl(reportData.afterPhoto)"
class="photo-image"
mode="aspectFill"
/>
<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>
<!-- 下次服务提醒(留资钩子 A -->
<ReminderCard
:token="reportData.reportToken || routeToken"
:pet-name="reportData.petName"
/>
<!-- 生成海报按钮 -->
<div class="action-section">
<button class="van-button van-button--primary van-button--round van-button--block" @click="generatePoster">生成图片分享朋友圈</button>
</div>
</div>
<!-- Canvas海报隐藏 H5 -->
<!-- #ifdef H5 -->
<canvas ref="posterCanvas" style="position:fixed;top:-9999px;left:-9999px;" />
<!-- #endif -->
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getReportByToken, imgUrl } from '../api/index.js'
import { formatDateTimeYMDHM } from '../utils/datetime.js'
import AppIcon from '../components/AppIcon.vue'
import ReminderCard from '../components/report/ReminderCard.vue'
import { navigateTo } from '../utils/globalState.js'
const loading = ref(true)
const notFound = ref(false)
const reportData = ref(null)
const posterCanvas = ref(null)
const routeToken = ref('')
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 = () => {
navigateTo('home')
}
// #ifdef H5
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 generatePoster = async () => {
if (!reportData.value) return
uni.showToast({ title: '正在生成海报...', icon: 'none' })
const r = reportData.value
const canvas = posterCanvas.value
const ctx = canvas.getContext('2d')
canvas.width = 750
canvas.height = 1100
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, 750, 1100)
const gradient = ctx.createLinearGradient(0, 0, 750, 300)
gradient.addColorStop(0, '#07c160')
gradient.addColorStop(1, '#10b76f')
ctx.fillStyle = gradient
ctx.fillRect(0, 0, 750, 300)
const storeName = r.store?.name || '宠伴生活馆'
const storePhone = r.store?.phone || ''
const storeAddr = r.store?.address || ''
ctx.fillStyle = '#ffffff'
ctx.font = 'bold 36px sans-serif'
ctx.textAlign = 'center'
ctx.fillText(storeName, 375, 70)
ctx.font = '20px sans-serif'
ctx.globalAlpha = 0.7
ctx.fillText('宠物服务,让爱更专业', 375, 105)
ctx.globalAlpha = 1
if (storePhone || storeAddr) {
ctx.font = '18px sans-serif'
ctx.globalAlpha = 0.85
const contactLine = [storePhone, storeAddr].filter(Boolean).join(' | ')
ctx.fillText(contactLine, 375, 138)
ctx.globalAlpha = 1
}
ctx.fillStyle = '#333333'
ctx.font = 'bold 36px sans-serif'
ctx.fillText('服务报告', 375, 220)
ctx.fillStyle = '#f8f6f3'
ctx.beginPath()
roundRect(ctx, 40, 260, 670, 220, 20)
ctx.fill()
const infoItems = [
['宠物名字', r.petName || '-'],
['服务项目', r.serviceType || '-'],
['服务时间', formatTime(r.appointmentTime) || '-'],
['服务技师', r.staffName || '-']
]
let y = 310
ctx.textAlign = 'left'
infoItems.forEach(([label, val]) => {
ctx.fillStyle = '#999999'
ctx.font = '22px sans-serif'
ctx.fillText(label, 80, y)
ctx.fillStyle = '#333333'
ctx.font = 'bold 24px sans-serif'
ctx.fillText(val, 220, y)
y += 48
})
ctx.fillStyle = '#f8f6f3'
ctx.beginPath()
roundRect(ctx, 40, 500, 670, 360, 20)
ctx.fill()
ctx.fillStyle = '#333333'
ctx.font = 'bold 24px sans-serif'
ctx.textAlign = 'center'
ctx.fillText('服务前后对比', 375, 545)
const imgY = 575
const imgH = 260
const imgW = 300
ctx.fillStyle = '#e0e0e0'
ctx.beginPath()
roundRect(ctx, 60, imgY, imgW, imgH, 16)
ctx.fill()
ctx.fillStyle = '#999999'
ctx.font = '20px sans-serif'
ctx.fillText('服务前', 210, imgY + imgH/2)
ctx.fillStyle = '#e0e0e0'
ctx.beginPath()
roundRect(ctx, 390, imgY, imgW, imgH, 16)
ctx.fill()
ctx.fillStyle = '#999999'
ctx.fillText('服务后', 540, imgY + imgH/2)
if (r.remark) {
ctx.fillStyle = '#f8f6f3'
ctx.beginPath()
roundRect(ctx, 40, 880, 670, 100, 20)
ctx.fill()
ctx.fillStyle = '#666666'
ctx.font = '22px sans-serif'
ctx.textAlign = 'left'
const remark = r.remark
if (remark.length > 30) {
ctx.fillText(remark.substring(0, 30), 70, 920)
ctx.fillText(remark.substring(30), 70, 955)
} else {
ctx.fillText(remark, 70, 930)
}
}
ctx.fillStyle = '#07c160'
ctx.font = 'bold 22px sans-serif'
ctx.textAlign = 'center'
ctx.fillText(`${storeName}`, 375, 1050)
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) {
ctx.save()
ctx.beginPath()
roundRect(ctx, 60, imgY, imgW, imgH, 16)
ctx.clip()
ctx.drawImage(beforeImg, 60, imgY, imgW, imgH)
ctx.restore()
}
if (afterImg) {
ctx.save()
ctx.beginPath()
roundRect(ctx, 390, imgY, imgW, imgH, 16)
ctx.clip()
ctx.drawImage(afterImg, 390, imgY, imgW, imgH)
ctx.restore()
}
const link = document.createElement('a')
link.download = `服务报告_${r.petName || '宠物'}.png`
link.href = canvas.toDataURL('image/png')
link.click()
}
// #endif
// #ifndef H5
const generatePoster = () => {
uni.showToast({ title: '请在微信中长按保存海报', icon: 'none' })
}
// #endif
function roundRect(ctx, x, y, w, h, r) {
ctx.beginPath()
ctx.moveTo(x + r, y)
ctx.lineTo(x + w - r, y)
ctx.quadraticCurveTo(x + w, y, x + w, y + r)
ctx.lineTo(x + w, y + h - r)
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h)
ctx.lineTo(x + r, y + h)
ctx.quadraticCurveTo(x, y + h, x, y + h - r)
ctx.lineTo(x, y + r)
ctx.quadraticCurveTo(x, y, x + r, y)
ctx.closePath()
}
onLoad((options) => {
routeToken.value = options?.token || ''
})
onMounted(() => loadReport())
</script>
<style scoped>
.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-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.photo-image {
width: 100%;
aspect-ratio: 1 / 1;
border-radius: 12px;
border: 1px solid #e5e7eb;
}
.photo-empty {
width: 100%;
aspect-ratio: 1 / 1;
background: #f1f5f9;
border: 1px dashed #d1d9e6;
border-radius: 12px;
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; }
</style>