1071 lines
30 KiB
Vue
1071 lines
30 KiB
Vue
<template>
|
||
<view class="report-view">
|
||
<AppPageState
|
||
class="report-top-state"
|
||
:loading="loading"
|
||
:error="loadError"
|
||
:empty="!loading && !loadError && notFound"
|
||
empty-title="报告不存在或链接已失效"
|
||
empty-hint="请向门店索取最新分享链接"
|
||
empty-emoji="🐾"
|
||
:show-retry="!!loadError"
|
||
@retry="loadReport"
|
||
>
|
||
<view v-if="reportData" class="report-content">
|
||
<!-- 顶部品牌(与 H5 对齐:Logo + 店名 + 服务时间) -->
|
||
<view class="brand-bar nav-gradient" :style="brandBarSafe">
|
||
<view class="brand-bar-inner brand-bar-inner--row">
|
||
<view class="brand-home" @click="goBackOrHome">
|
||
<AppIcon name="back" :size="16" color="#ffffff" />
|
||
</view>
|
||
<view class="brand-main">
|
||
<image
|
||
v-if="storeLogoUrl"
|
||
class="brand-logo-img"
|
||
:src="storeLogoUrl"
|
||
mode="aspectFit"
|
||
/>
|
||
<view class="brand-text-col">
|
||
<text class="brand-name-line">{{ reportData.store?.name || '宠小它' }}</text>
|
||
<text class="brand-sub-line">服务报告 · {{ formatTime(reportData.appointmentTime) }}</text>
|
||
</view>
|
||
</view>
|
||
<view class="brand-spacer"></view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="report-privacy-bar">
|
||
<text class="report-privacy-bar-text">本页含宠物服务影像,请勿公开转发或发群</text>
|
||
</view>
|
||
|
||
<!-- 技师情感卡片(与 H5 对齐) -->
|
||
<view v-if="reportData.staffName || staffAvatarDisplay" class="staff-card-mp">
|
||
<image
|
||
v-if="staffAvatarDisplay"
|
||
class="staff-avatar-mp"
|
||
:src="staffAvatarDisplay"
|
||
mode="aspectFill"
|
||
/>
|
||
<view v-else class="staff-avatar-mp staff-avatar-mp--ph">
|
||
<text class="staff-avatar-letter-mp">{{ staffInitial }}</text>
|
||
</view>
|
||
<view class="staff-copy-mp">
|
||
<text class="staff-sign-mp">本次由 {{ reportData.staffName || '技师' }} 为您服务</text>
|
||
<text class="staff-subline-mp">感谢信任,愿毛孩子健康可爱每一天</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 服务信息(不含技师行) -->
|
||
<view class="info-card">
|
||
<view class="info-title">服务详情</view>
|
||
<view class="info-row">
|
||
<text class="info-key">宠物</text>
|
||
<text class="info-val">{{ reportData.petName }}</text>
|
||
</view>
|
||
<view class="info-row">
|
||
<text class="info-key">项目</text>
|
||
<text class="info-val">{{ reportData.serviceType }}</text>
|
||
</view>
|
||
<view class="info-row">
|
||
<text class="info-key">时间</text>
|
||
<text class="info-val">{{ formatTime(reportData.appointmentTime) }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 前后对比滑块 -->
|
||
<view class="compare-block">
|
||
<text class="compare-h2">服务前后对比</text>
|
||
<BeforeAfterSlider :before-src="beforeMainUrl" :after-src="afterMainUrl" />
|
||
<view
|
||
v-if="beforePhotos.length > 1 || afterPhotos.length > 1"
|
||
class="compare-more-hint"
|
||
>
|
||
<text>滑动对比主图;更多张数见下方补充相册</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 过程素材九宫格(与 H5 对齐) -->
|
||
<view v-if="duringListForGrid.length" class="during-grid-wrap">
|
||
<DuringMediaGrid :items="duringListForGrid" />
|
||
</view>
|
||
|
||
<!-- 补充照片:前后分组,不混排 -->
|
||
<view v-if="beforeMorePhotos.length > 0 || afterMorePhotos.length > 0" class="photo-groups">
|
||
<view v-if="beforeMorePhotos.length > 0" class="photo-group-card">
|
||
<view class="photo-group-head">
|
||
<text class="photo-group-title">服务前补充</text>
|
||
<text class="photo-group-count">{{ beforeMorePhotos.length }} 张</text>
|
||
</view>
|
||
<view class="photo-grid-extra">
|
||
<view
|
||
v-for="(img, idx) in beforeMorePhotos"
|
||
:key="'before-more-' + idx"
|
||
class="photo-grid-extra-item"
|
||
@click="previewPhotos('before', idx + 1)"
|
||
>
|
||
<image
|
||
:src="imgUrl(img)"
|
||
class="photo-grid-extra-img"
|
||
mode="aspectFill"
|
||
/>
|
||
<view class="photo-grid-extra-badge">
|
||
<text>{{ idx + 2 }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="afterMorePhotos.length > 0" class="photo-group-card">
|
||
<view class="photo-group-head">
|
||
<text class="photo-group-title">服务后补充</text>
|
||
<text class="photo-group-count">{{ afterMorePhotos.length }} 张</text>
|
||
</view>
|
||
<view class="photo-grid-extra">
|
||
<view
|
||
v-for="(img, idx) in afterMorePhotos"
|
||
:key="'after-more-' + idx"
|
||
class="photo-grid-extra-item"
|
||
@click="previewPhotos('after', idx + 1)"
|
||
>
|
||
<image
|
||
:src="imgUrl(img)"
|
||
class="photo-grid-extra-img"
|
||
mode="aspectFill"
|
||
/>
|
||
<view class="photo-grid-extra-badge photo-grid-extra-badge-after">
|
||
<text>{{ idx + 2 }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 宠主问候 -->
|
||
<view v-if="!isStaff" class="greeting-card">
|
||
<text class="greeting-text">
|
||
<text class="greeting-pet">{{ reportData.petName }}</text> 的服务已完成,看看TA的变化吧 ✨
|
||
</text>
|
||
</view>
|
||
|
||
<!-- 备注 -->
|
||
<view v-if="reportData.remark" class="remark-card">
|
||
<text class="remark-label">备注</text>
|
||
<text class="remark-text">{{ reportData.remark }}</text>
|
||
</view>
|
||
|
||
<!-- 留资 + 微信绑定闭环(S3→S4):下次服务提醒,仅宠主可见 -->
|
||
<ReminderCard
|
||
v-if="reportData && !isStaff && reminderToken"
|
||
:token="reminderToken"
|
||
:pet-name="reportData.petName || ''"
|
||
/>
|
||
|
||
<!-- 服务回顾短片:编排方式 + 可变时长(见 ReportHighlightBlock) -->
|
||
<ReportHighlightBlock v-if="reportData && hasHighlightSource" v-model="reportData" />
|
||
|
||
<!-- 操作区 -->
|
||
<view class="actions">
|
||
<template v-if="isStaff">
|
||
<button open-type="share" class="app-btn-main app-btn-primary">
|
||
转发给宠主
|
||
</button>
|
||
<button class="app-btn-main app-btn-soft" @click="openPosterModal">
|
||
保存海报到相册
|
||
</button>
|
||
</template>
|
||
<template v-else>
|
||
<button class="app-btn-main app-btn-primary" @click="callStore">
|
||
联系门店
|
||
</button>
|
||
<button v-if="reportData.store?.latitude" class="app-btn-main app-btn-soft" @click="navToStore">
|
||
导航去门店
|
||
</button>
|
||
<button class="app-btn-main app-btn-outline action-link-btn" @click="goHome">
|
||
我也要预约
|
||
</button>
|
||
</template>
|
||
</view>
|
||
|
||
<!-- 底部品牌 -->
|
||
<view class="footer-brand">
|
||
<text class="footer-line"></text>
|
||
<text class="footer-name">{{ reportData.store?.name || '宠小它' }}</text>
|
||
<text class="footer-sub">用心宠小它,温暖伴一生</text>
|
||
</view>
|
||
</view>
|
||
</AppPageState>
|
||
|
||
<TestimonialModal
|
||
v-if="isStaff && reportData"
|
||
v-model="posterModalOpen"
|
||
:pet-name="reportData?.petName"
|
||
@skip="onTestimonialSkip"
|
||
@confirm="onTestimonialConfirm"
|
||
/>
|
||
|
||
<!-- #ifdef H5 -->
|
||
<canvas ref="posterCanvas" class="poster-offscreen" />
|
||
<!-- #endif -->
|
||
<!-- #ifdef MP-WEIXIN -->
|
||
<canvas id="reportPosterCanvas" type="2d" class="poster-offscreen" />
|
||
<!-- #endif -->
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted, getCurrentInstance, nextTick } from 'vue'
|
||
import { onLoad, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||
import { getReportByToken, imgUrl, submitReportTestimonial } from '../../api/index.js'
|
||
import { trackReportOpen, getReportByTokenWithTimeout } from '../../utils/reportOpenTrack.js'
|
||
import { formatDateTimeYMDHM } from '../../utils/datetime.js'
|
||
import { drawReportPoster, POSTER_W, computePosterLogicalHeight } from '../../utils/reportPosterDraw.js'
|
||
import AppIcon from '../../components/AppIcon.vue'
|
||
import AppPageState from '../../components/AppPageState.vue'
|
||
import { messageFromApi, NETWORK_ERROR_MSG } from '../../utils/apiResult.js'
|
||
import BeforeAfterSlider from '../../components/report/BeforeAfterSlider.vue'
|
||
import DuringMediaGrid from '../../components/report/DuringMediaGrid.vue'
|
||
import TestimonialModal from '../../components/report/TestimonialModal.vue'
|
||
import ReminderCard from '../../components/report/ReminderCard.vue'
|
||
import ReportHighlightBlock from '../../components/report/ReportHighlightBlock.vue'
|
||
import { isLoggedIn } from '../../utils/session.js'
|
||
import { buildReportShareTitle, buildReportShareCoverUrl } from '../../utils/reportShare.js'
|
||
|
||
const loading = ref(true)
|
||
const loadError = ref(null)
|
||
const notFound = ref(false)
|
||
const reportData = ref(null)
|
||
const posterCanvas = ref(null)
|
||
const posterModalOpen = ref(false)
|
||
const routeToken = ref('')
|
||
const isStaff = computed(() => isLoggedIn())
|
||
|
||
const hasHighlightSource = computed(() => {
|
||
const r = reportData.value
|
||
if (!r) return false
|
||
const b = r.beforePhotos?.length || 0
|
||
const a = r.afterPhotos?.length || 0
|
||
const d = r.duringMedia?.length || 0
|
||
return b + a + d > 0
|
||
})
|
||
|
||
const getRouteToken = () => {
|
||
let token = routeToken.value
|
||
// #ifdef H5
|
||
token = new URLSearchParams(window.location.search).get('token') || token
|
||
// #endif
|
||
return token || ''
|
||
}
|
||
|
||
/** 报告 token:接口字段优先,兼容 H5 地址栏 */
|
||
const reminderToken = computed(() => reportData.value?.reportToken || getRouteToken())
|
||
|
||
const beforePhotos = computed(() => {
|
||
if (!reportData.value?.beforePhotos) return []
|
||
// 支持新旧两种格式:旧版是 string[],新版是 {url, mediaType}[]
|
||
const raw = reportData.value.beforePhotos
|
||
if (raw.length === 0) return []
|
||
if (typeof raw[0] === 'string') return raw
|
||
return raw.map(item => item.url)
|
||
})
|
||
const afterPhotos = computed(() => {
|
||
if (!reportData.value?.afterPhotos) return []
|
||
const raw = reportData.value.afterPhotos
|
||
if (raw.length === 0) return []
|
||
if (typeof raw[0] === 'string') return raw
|
||
return raw.map(item => item.url)
|
||
})
|
||
const duringMedia = computed(() => {
|
||
return reportData.value?.duringMedia || []
|
||
})
|
||
const duringPhotoItems = computed(() => duringMedia.value.filter(i => i.mediaType !== 'video').map(i => i.url))
|
||
const duringVideoItems = computed(() => duringMedia.value.filter(i => i.mediaType === 'video').map(i => i.url))
|
||
const beforePhotoUrls = computed(() => beforePhotos.value.map((item) => imgUrl(item)))
|
||
const afterPhotoUrls = computed(() => afterPhotos.value.map((item) => imgUrl(item)))
|
||
const beforeMorePhotos = computed(() => beforePhotos.value.slice(1))
|
||
const afterMorePhotos = computed(() => afterPhotos.value.slice(1))
|
||
|
||
const beforeMainUrl = computed(() => {
|
||
if (!beforePhotos.value.length) return ''
|
||
return imgUrl(beforePhotos.value[0])
|
||
})
|
||
const afterMainUrl = computed(() => {
|
||
if (!afterPhotos.value.length) return ''
|
||
return imgUrl(afterPhotos.value[0])
|
||
})
|
||
const duringListForGrid = computed(() =>
|
||
duringMedia.value.map((m) => ({
|
||
url: imgUrl(m.url),
|
||
mediaType: m.mediaType === 'video' ? 'video' : 'photo'
|
||
}))
|
||
)
|
||
const storeLogoUrl = computed(() => {
|
||
const logo = reportData.value?.store?.logo
|
||
return logo ? imgUrl(logo) : ''
|
||
})
|
||
const staffAvatarDisplay = computed(() => {
|
||
const a = reportData.value?.staffAvatar
|
||
return a ? imgUrl(a) : ''
|
||
})
|
||
const staffInitial = computed(() => {
|
||
const n = (reportData.value?.staffName || '?').trim()
|
||
return n.slice(0, 1)
|
||
})
|
||
|
||
const shareTitle = computed(() => buildReportShareTitle(reportData.value))
|
||
|
||
const shareCoverUrl = computed(() =>
|
||
buildReportShareCoverUrl(reportData.value, {
|
||
afterFirst: afterPhotos.value[0],
|
||
beforeFirst: beforePhotos.value[0],
|
||
storeLogo: reportData.value?.store?.logo
|
||
})
|
||
)
|
||
|
||
const brandBarSafe = (() => {
|
||
const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20
|
||
return `padding-top:${statusBarHeight + 8}px;`
|
||
})()
|
||
|
||
const formatTime = (time) => formatDateTimeYMDHM(time)
|
||
|
||
const loadReport = async () => {
|
||
loadError.value = null
|
||
notFound.value = false
|
||
const token = getRouteToken()
|
||
if (!token) {
|
||
notFound.value = true
|
||
reportData.value = null
|
||
loading.value = false
|
||
return
|
||
}
|
||
loading.value = true
|
||
try {
|
||
const res = await getReportByTokenWithTimeout(getReportByToken, token)
|
||
if (res && res.code === 200 && res.data) {
|
||
reportData.value = res.data
|
||
trackReportOpen(token)
|
||
} else if (res && res.code === 404) {
|
||
reportData.value = null
|
||
notFound.value = true
|
||
} else {
|
||
reportData.value = null
|
||
loadError.value = messageFromApi(res, '加载失败')
|
||
}
|
||
} catch (e) {
|
||
reportData.value = null
|
||
loadError.value =
|
||
e && e.message === 'TIMEOUT' ? '加载超时,请检查网络后重试' : NETWORK_ERROR_MSG
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
const goHome = () => uni.reLaunch({ url: '/pages/home/Home' })
|
||
|
||
const goBackOrHome = () => {
|
||
try {
|
||
const pages = getCurrentPages()
|
||
if (pages.length > 1) {
|
||
uni.navigateBack()
|
||
return
|
||
}
|
||
} catch (_) {
|
||
// ignore and fallback to home
|
||
}
|
||
goHome()
|
||
}
|
||
|
||
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 || ''
|
||
})
|
||
}
|
||
|
||
const previewPhotos = (group, index = 0) => {
|
||
let urls
|
||
if (group === 'before') urls = beforePhotoUrls.value
|
||
else if (group === 'after') urls = afterPhotoUrls.value
|
||
else if (group === 'during-photo') urls = duringPhotoItems.value.map(imgUrl)
|
||
else if (group === 'during-video') urls = duringVideoItems.value.map(imgUrl)
|
||
if (!urls || !urls.length) return
|
||
const current = urls[Math.max(0, Math.min(index, urls.length - 1))]
|
||
uni.previewImage({
|
||
urls,
|
||
current
|
||
})
|
||
}
|
||
|
||
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
|
||
}
|
||
})
|
||
|
||
const openPosterModal = () => {
|
||
if (!reportData.value) return
|
||
posterModalOpen.value = true
|
||
}
|
||
|
||
const onTestimonialSkip = () => {
|
||
posterModalOpen.value = false
|
||
// #ifdef H5
|
||
runGeneratePosterH5('')
|
||
// #endif
|
||
// #ifdef MP-WEIXIN
|
||
runGeneratePosterMp('')
|
||
// #endif
|
||
// #ifndef H5
|
||
// #ifndef MP-WEIXIN
|
||
uni.showToast({ title: '请截图分享报告页', icon: 'none' })
|
||
// #endif
|
||
// #endif
|
||
}
|
||
|
||
const onTestimonialConfirm = async (text) => {
|
||
posterModalOpen.value = false
|
||
const t = (text || '').trim()
|
||
const tok = getRouteToken()
|
||
if (t && tok) {
|
||
try {
|
||
const res = await submitReportTestimonial(tok, t, true)
|
||
if (res.code !== 200) {
|
||
uni.showToast({ title: res.message || '寄语未保存,仍将生成海报', icon: 'none' })
|
||
}
|
||
} catch (e) {
|
||
uni.showToast({ title: '寄语未保存,仍将生成海报', icon: 'none' })
|
||
}
|
||
}
|
||
// #ifdef H5
|
||
await runGeneratePosterH5(t)
|
||
// #endif
|
||
// #ifdef MP-WEIXIN
|
||
await runGeneratePosterMp(t)
|
||
// #endif
|
||
// #ifndef H5
|
||
// #ifndef MP-WEIXIN
|
||
uni.showToast({ title: '当前环境无法导出海报,可截图分享', icon: 'none' })
|
||
// #endif
|
||
// #endif
|
||
}
|
||
|
||
// #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 runGeneratePosterH5 = async (testimonial) => {
|
||
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')
|
||
const posterH = computePosterLogicalHeight(false, testimonial || '')
|
||
canvas.width = POSTER_W
|
||
canvas.height = posterH
|
||
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 || '',
|
||
beforeCount: beforePhotos.value.length,
|
||
afterCount: afterPhotos.value.length,
|
||
beforeImg,
|
||
afterImg,
|
||
testimonial: testimonial || ''
|
||
})
|
||
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 && res.tempFilePath) resolve(res.tempFilePath)
|
||
else resolve(null)
|
||
},
|
||
fail: () => resolve(null)
|
||
})
|
||
})
|
||
}
|
||
|
||
function queryPosterCanvasNode() {
|
||
return new Promise((resolve) => {
|
||
const inst = getCurrentInstance()
|
||
const proxy = inst?.proxy
|
||
const pick = (res) => (res && res[0] && res[0].node ? res[0].node : null)
|
||
const fallback = () => {
|
||
uni.createSelectorQuery()
|
||
.select('#reportPosterCanvas')
|
||
.fields({ node: true, size: true })
|
||
.exec((res2) => resolve(pick(res2)))
|
||
}
|
||
if (proxy) {
|
||
uni.createSelectorQuery()
|
||
.in(proxy)
|
||
.select('#reportPosterCanvas')
|
||
.fields({ node: true, size: true })
|
||
.exec((res) => {
|
||
const n = pick(res)
|
||
if (n) resolve(n)
|
||
else fallback()
|
||
})
|
||
} else {
|
||
fallback()
|
||
}
|
||
})
|
||
}
|
||
|
||
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 runGeneratePosterMp = async (testimonial) => {
|
||
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)
|
||
])
|
||
|
||
await nextTick()
|
||
await new Promise((res) => setTimeout(res, 100))
|
||
|
||
let canvas = await queryPosterCanvasNode()
|
||
if (!canvas) {
|
||
await new Promise((res) => setTimeout(res, 200))
|
||
canvas = await new Promise((resolve) => {
|
||
uni.createSelectorQuery()
|
||
.select('#reportPosterCanvas')
|
||
.fields({ node: true, size: true })
|
||
.exec((res2) => resolve(res2[0]?.node || null))
|
||
})
|
||
}
|
||
if (!canvas) {
|
||
uni.showToast({ title: '画布未就绪,请稍后重试', icon: 'none' })
|
||
return
|
||
}
|
||
|
||
const inst = getCurrentInstance()
|
||
const proxy = inst?.proxy
|
||
|
||
const ctx = canvas.getContext('2d')
|
||
if (!ctx) {
|
||
uni.showToast({ title: '画布上下文异常', icon: 'none' })
|
||
return
|
||
}
|
||
let dpr = 2
|
||
try {
|
||
const win = typeof uni.getWindowInfo === 'function' ? uni.getWindowInfo() : null
|
||
if (win && win.pixelRatio != null) dpr = Math.min(win.pixelRatio, 3)
|
||
else dpr = Math.min(uni.getSystemInfoSync().pixelRatio || 2, 3)
|
||
} catch (_) { dpr = 2 }
|
||
const posterH = computePosterLogicalHeight(false, testimonial || '')
|
||
canvas.width = POSTER_W * dpr
|
||
canvas.height = posterH * dpr
|
||
ctx.scale(dpr, dpr)
|
||
|
||
const beforeImg = await loadCanvasImage(canvas, beforePath)
|
||
const afterImg = await loadCanvasImage(canvas, afterPath)
|
||
|
||
try {
|
||
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 || '',
|
||
beforeCount: beforePhotos.value.length,
|
||
afterCount: afterPhotos.value.length,
|
||
beforeImg,
|
||
afterImg,
|
||
testimonial: testimonial || ''
|
||
})
|
||
} catch (drawErr) {
|
||
console.error(drawErr)
|
||
uni.showToast({ title: '绘制失败,请重试', icon: 'none' })
|
||
return
|
||
}
|
||
|
||
const tempPath = await new Promise((resolve, reject) => {
|
||
const opts = {
|
||
canvas,
|
||
fileType: 'png',
|
||
quality: 1,
|
||
destWidth: POSTER_W,
|
||
destHeight: posterH,
|
||
success: (out) => resolve(out.tempFilePath),
|
||
fail: (e) => reject(e)
|
||
}
|
||
if (proxy) uni.canvasToTempFilePath(opts, proxy)
|
||
else uni.canvasToTempFilePath(opts)
|
||
})
|
||
|
||
await new Promise((resolve) => {
|
||
uni.saveImageToPhotosAlbum({
|
||
filePath: tempPath,
|
||
success: () => {
|
||
uni.showToast({ title: '已保存到相册', icon: 'success' })
|
||
resolve()
|
||
},
|
||
fail: (err) => {
|
||
const msg = err.errMsg || ''
|
||
if (msg.includes('auth deny') || msg.includes('authorize') || msg.includes('auth')) {
|
||
uni.showModal({
|
||
title: '需要相册权限',
|
||
content: '保存海报需要授权保存到相册,请在设置中开启。',
|
||
confirmText: '去设置',
|
||
success: (m) => { if (m.confirm) uni.openSetting({}) }
|
||
})
|
||
} else {
|
||
uni.showToast({ title: msg.length < 60 ? msg : '保存失败', icon: 'none' })
|
||
}
|
||
resolve()
|
||
}
|
||
})
|
||
})
|
||
} catch (e) {
|
||
const em = e && e.errMsg ? String(e.errMsg) : ''
|
||
if (em.includes('canvasToTempFilePath')) {
|
||
uni.showToast({ title: '导出图片失败,请重试', icon: 'none' })
|
||
} else {
|
||
uni.showToast({ title: '生成失败', icon: 'none' })
|
||
}
|
||
} finally {
|
||
uni.hideLoading()
|
||
}
|
||
}
|
||
// #endif
|
||
|
||
onLoad((options) => {
|
||
routeToken.value = options?.token || ''
|
||
})
|
||
onMounted(() => loadReport())
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* 小程序 WXSS 禁止 @import 外链字体;手写感用系统楷体栈,H5 可在页面级引入马善政体 */
|
||
|
||
.report-view {
|
||
background: #fafaf8;
|
||
min-height: 100vh;
|
||
}
|
||
.poster-offscreen {
|
||
position: fixed;
|
||
left: -9999px;
|
||
top: 0;
|
||
width: 375px;
|
||
height: 610px;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.report-top-state { min-height: 48vh; }
|
||
|
||
/* 内容 */
|
||
.report-content {
|
||
max-width: 430px;
|
||
margin: 0 auto;
|
||
background: #fff;
|
||
min-height: 100vh;
|
||
}
|
||
|
||
/* 品牌栏(含 Logo,与 H5 对齐) */
|
||
.brand-bar {
|
||
background: var(--c-brand);
|
||
padding: 12px 16px 14px;
|
||
border-bottom-left-radius: 14px;
|
||
border-bottom-right-radius: 14px;
|
||
}
|
||
.brand-bar-inner {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
.brand-bar-inner--row {
|
||
align-items: center;
|
||
}
|
||
.brand-home {
|
||
width: 32px; height: 32px;
|
||
border-radius: 10px;
|
||
background: rgba(255,255,255,0.12);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
}
|
||
.brand-main {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 10px;
|
||
padding: 0 8px;
|
||
}
|
||
.brand-logo-img {
|
||
width: 44px;
|
||
height: 44px;
|
||
border-radius: 12px;
|
||
background: rgba(255, 255, 255, 0.95);
|
||
border: 2px solid rgba(255, 255, 255, 0.45);
|
||
flex-shrink: 0;
|
||
}
|
||
.brand-text-col {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
min-width: 0;
|
||
text-align: left;
|
||
}
|
||
.brand-name-line {
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
color: #fff;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
.brand-sub-line {
|
||
font-size: 11px;
|
||
color: rgba(255, 255, 255, 0.88);
|
||
line-height: 1.3;
|
||
}
|
||
.brand-spacer {
|
||
width: 32px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* 技师卡片 */
|
||
.staff-card-mp {
|
||
margin: 12px 16px 0;
|
||
padding: 14px 14px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
background: linear-gradient(120deg, #fffbeb 0%, #ffffff 55%, var(--c-brand-surface-soft) 100%);
|
||
border-radius: 14px;
|
||
border: 1px solid #fde68a;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||
}
|
||
.staff-avatar-mp {
|
||
width: 52px;
|
||
height: 52px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
border: 3px solid rgba(45, 185, 109, 0.35);
|
||
}
|
||
.staff-avatar-mp--ph {
|
||
background: linear-gradient(145deg, var(--c-brand-soft), var(--c-brand-mid));
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.staff-avatar-letter-mp {
|
||
font-size: 20px;
|
||
font-weight: 800;
|
||
color: #fff;
|
||
}
|
||
.staff-copy-mp {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
.staff-sign-mp {
|
||
font-family: "KaiTi", "STKaiti", "Songti SC", "PingFang SC", serif;
|
||
font-size: 22px;
|
||
color: #422006;
|
||
line-height: 1.35;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
.staff-subline-mp {
|
||
font-size: 11px;
|
||
color: #78716c;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
/* 前后对比滑块区块 */
|
||
.compare-block {
|
||
margin: 12px 16px 0;
|
||
padding: 14px 12px 12px;
|
||
background: #fff;
|
||
border-radius: 14px;
|
||
border: 1px solid #f0f0ee;
|
||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||
}
|
||
.compare-h2 {
|
||
display: block;
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
color: #1a1a1a;
|
||
margin-bottom: 10px;
|
||
}
|
||
.compare-more-hint {
|
||
margin-top: 10px;
|
||
text-align: center;
|
||
}
|
||
.compare-more-hint text {
|
||
font-size: 11px;
|
||
color: #bbbbb5;
|
||
}
|
||
|
||
.during-grid-wrap {
|
||
margin: 12px 16px 0;
|
||
padding: 12px 10px 10px;
|
||
background: #fff;
|
||
border-radius: 14px;
|
||
border: 1px solid #f0f0ee;
|
||
}
|
||
|
||
.photo-groups {
|
||
padding: 12px 16px 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
.photo-group-card {
|
||
background: #fafaf8;
|
||
border-radius: 14px;
|
||
padding: 14px;
|
||
}
|
||
.photo-group-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 10px;
|
||
}
|
||
.photo-group-title {
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
color: #1a1a1a;
|
||
}
|
||
.photo-group-count {
|
||
font-size: 12px;
|
||
color: var(--c-text-3);
|
||
}
|
||
.photo-grid-extra {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 8px;
|
||
}
|
||
.photo-grid-extra-item {
|
||
position: relative;
|
||
}
|
||
.photo-grid-extra-img {
|
||
width: 100%;
|
||
aspect-ratio: 1 / 1;
|
||
border-radius: 10px;
|
||
background: #f0f0ee;
|
||
display: block;
|
||
}
|
||
.photo-grid-extra-badge {
|
||
position: absolute;
|
||
top: 6px;
|
||
right: 6px;
|
||
min-width: 20px;
|
||
height: 20px;
|
||
padding: 0 6px;
|
||
border-radius: 999px;
|
||
background: rgba(245, 145, 62, 0.92);
|
||
color: #fff;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
}
|
||
.photo-grid-extra-badge-after {
|
||
background: rgba(45, 185, 109, 0.92);
|
||
}
|
||
|
||
/* 问候 */
|
||
.greeting-card {
|
||
margin: 16px 16px 0;
|
||
padding: 14px 16px;
|
||
background: #fef9f0;
|
||
border-radius: 12px;
|
||
}
|
||
.greeting-text {
|
||
font-size: 14px;
|
||
color: #666660;
|
||
line-height: 1.6;
|
||
}
|
||
.greeting-pet {
|
||
font-weight: 700;
|
||
color: #1a1a1a;
|
||
}
|
||
|
||
/* 信息卡 */
|
||
.info-card {
|
||
margin: 12px 16px 0;
|
||
padding: 20px;
|
||
background: #fafaf8;
|
||
border-radius: 14px;
|
||
}
|
||
.info-title {
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
color: var(--c-text-3);
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
margin-bottom: 14px;
|
||
}
|
||
.info-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 8px 0;
|
||
}
|
||
.info-row + .info-row {
|
||
border-top: 1px solid #f0f0ee;
|
||
}
|
||
.info-key {
|
||
font-size: 14px;
|
||
color: var(--c-text-3);
|
||
}
|
||
.info-val {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: #1a1a1a;
|
||
}
|
||
|
||
/* 备注 */
|
||
.remark-card {
|
||
margin: 0 16px 16px;
|
||
padding: 16px;
|
||
background: #fafaf8;
|
||
border-radius: 14px;
|
||
}
|
||
.remark-label {
|
||
display: block;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
color: var(--c-text-3);
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
margin-bottom: 8px;
|
||
}
|
||
.remark-text {
|
||
font-size: 14px;
|
||
color: #666660;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
/* 操作按钮 */
|
||
.actions {
|
||
padding: 0 16px 16px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
.action-link-btn {
|
||
height: 42px !important;
|
||
min-height: 42px !important;
|
||
line-height: 42px !important;
|
||
font-weight: 600 !important;
|
||
}
|
||
|
||
/* 底部品牌 */
|
||
.footer-brand {
|
||
padding: 24px 16px 40px;
|
||
text-align: center;
|
||
}
|
||
.footer-line {
|
||
display: block;
|
||
width: 24px;
|
||
height: 2px;
|
||
background: #ebebea;
|
||
margin: 0 auto 12px;
|
||
border-radius: 1px;
|
||
}
|
||
.footer-name {
|
||
display: block;
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
color: #1a1a1a;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
.footer-sub {
|
||
display: block;
|
||
font-size: 11px;
|
||
color: #bbbbb5;
|
||
margin-top: 4px;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.report-privacy-bar {
|
||
margin: 0 16px 12px;
|
||
padding: 10px 12px;
|
||
background: #fffbeb;
|
||
border: 1px solid #fde68a;
|
||
border-radius: 10px;
|
||
}
|
||
.report-privacy-bar-text {
|
||
font-size: 12px;
|
||
color: #92400e;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
</style>
|