petstore-frontend/src/pages/report-view/reportView.vue

1378 lines
37 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="state-wrap">
<view class="state-spinner"></view>
<text class="state-text">加载中</text>
</view>
<view v-else-if="notFound" class="state-wrap">
<text class="state-emoji">🐾</text>
<text class="state-text">报告不存在或链接已失效</text>
</view>
<view v-else-if="reportData" class="report-content">
<!-- 顶部品牌 -->
<view class="brand-bar nav-gradient" :style="brandBarSafe">
<view class="brand-bar-inner">
<view class="brand-home" @click="goBackOrHome">
<AppIcon name="back" :size="16" color="#ffffff" />
</view>
<text class="brand-name">{{ reportData.store?.name || '宠伴生活馆' }}</text>
<view style="width:32px"></view>
</view>
</view>
<!-- 主视觉照片对比 -->
<view class="hero-stage">
<view class="hero-photos">
<view class="hero-before">
<image
v-if="beforePhotos.length > 0"
:src="imgUrl(beforePhotos[0])"
class="hero-img"
mode="aspectFill"
@click="previewPhotos('before', 0)"
/>
<view v-else class="hero-empty">
<text class="hero-empty-text">暂无</text>
</view>
<view v-if="beforePhotos.length > 0" class="hero-seq hero-seq-before">
<text>1 / {{ beforePhotos.length }}</text>
</view>
<view class="hero-tag tag-before">Before</view>
</view>
<view class="hero-after">
<image
v-if="afterPhotos.length > 0"
:src="imgUrl(afterPhotos[0])"
class="hero-img"
mode="aspectFill"
@click="previewPhotos('after', 0)"
/>
<view v-else class="hero-empty">
<text class="hero-empty-text">暂无</text>
</view>
<view v-if="afterPhotos.length > 0" class="hero-seq hero-seq-after">
<text>1 / {{ afterPhotos.length }}</text>
</view>
<view class="hero-tag tag-after">After</view>
</view>
</view>
<view class="hero-divider">
<text class="hero-divider-text">前后对比</text>
</view>
</view>
<view class="hero-meta">
<view class="hero-meta-chip hero-meta-chip-before">
<text>服务前 {{ beforePhotos.length }} 张</text>
</view>
<view class="hero-meta-chip hero-meta-chip-after">
<text>服务后 {{ afterPhotos.length }} 张</text>
</view>
</view>
<view v-if="beforePhotos.length > 0 || afterPhotos.length > 0" class="hero-tip">
<text>点击图片查看大图</text>
</view>
<view v-if="beforePhotos.length > 0 || afterPhotos.length > 0" class="hero-actions">
<view
v-if="beforePhotos.length > 0"
class="hero-action-chip hero-action-chip-before"
@click="previewPhotos('before', 0)"
>
<text>查看服务前全部照片</text>
</view>
<view
v-if="afterPhotos.length > 0"
class="hero-action-chip hero-action-chip-after"
@click="previewPhotos('after', 0)"
>
<text>查看服务后全部照片</text>
</view>
</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="duringMedia.length > 0" class="section-gap">
<view class="during-section">
<view class="during-title">
<view class="during-title-dot"></view>
<text>服务过程中</text>
</view>
<!-- 照片 -->
<view v-if="duringPhotoItems.length > 0" class="during-group">
<view class="during-group-label">照片 {{ duringPhotoItems.length }} 张</view>
<view class="during-grid">
<view
v-for="(url, idx) in duringPhotoItems"
:key="'dp-' + idx"
class="during-thumb"
@click="previewPhotos('during-photo', idx)"
>
<image :src="imgUrl(url)" class="during-thumb-img" mode="aspectFill" />
</view>
</view>
</view>
<!-- 视频 -->
<view v-if="duringVideoItems.length > 0" class="during-group">
<view class="during-group-label">视频 {{ duringVideoItems.length }} 个</view>
<view class="during-grid">
<view
v-for="(url, idx) in duringVideoItems"
:key="'dv-' + idx"
class="during-thumb during-thumb--video"
@click="playVideo(imgUrl(url))"
>
<video
:src="imgUrl(url)"
class="during-thumb-img"
:show-center-play-btn="true"
:show-play-btn="true"
objectFit="cover"
:enable-progress-gesture="false"
/>
</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 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 class="info-row" v-if="reportData.staffName">
<text class="info-key">技师</text>
<text class="info-val">{{ reportData.staffName }}</text>
</view>
</view>
<!-- 备注 -->
<view v-if="reportData.remark" class="remark-card">
<text class="remark-label">备注</text>
<text class="remark-text">{{ reportData.remark }}</text>
</view>
<!-- 服务回顾短片:素材智能剪辑(后端可扩展接入生成式 AI 视频) -->
<view v-if="hasHighlightSource" class="highlight-card">
<view class="highlight-head">
<text class="highlight-title">服务回顾短片</text>
<text class="highlight-badge">智能剪辑</text>
</view>
<text class="highlight-desc">
按「服务前 → 过程 → 服务后」顺序,将照片与短视频自动合成竖屏 9:16 成片15 秒或 30 秒)。门店服务器需已安装 ffmpeg若需大模型生成式成片可在后端接入对应云 API。
</text>
<view v-if="highlightStatus === 'processing'" class="highlight-status highlight-status--busy">
<text>短片生成中,请稍候…</text>
</view>
<view v-else-if="highlightStatus === 'failed'" class="highlight-status highlight-status--err">
<text>{{ reportData.highlightVideoError || '生成失败' }}</text>
</view>
<video
v-if="highlightReady"
class="highlight-player"
:src="imgUrl(reportData.highlightVideoUrl)"
controls
:show-center-play-btn="true"
objectFit="contain"
/>
<view v-if="canTriggerHighlight" class="highlight-actions">
<button
class="highlight-btn"
:disabled="highlightStatus === 'processing'"
@click="startHighlight(15)"
>
生成 15 秒
</button>
<button
class="highlight-btn highlight-btn--secondary"
:disabled="highlightStatus === 'processing'"
@click="startHighlight(30)"
>
生成 30 秒
</button>
</view>
</view>
<!-- 操作区 -->
<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>
<TestimonialModal
v-if="isStaff"
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, onUnmounted, getCurrentInstance, nextTick } from 'vue'
import { onLoad, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { getReportByToken, imgUrl, postReportHighlightStart, submitReportTestimonial } from '../../api/index.js'
import { formatDateTimeYMDHM } from '../../utils/datetime.js'
import { drawReportPoster, POSTER_W, computePosterLogicalHeight } from '../../utils/reportPosterDraw.js'
import AppIcon from '../../components/AppIcon.vue'
import TestimonialModal from '../../components/report/TestimonialModal.vue'
import { getUserSession, isLoggedIn } from '../../utils/session.js'
const loading = ref(true)
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 highlightStatus = computed(() => reportData.value?.highlightVideoStatus || '')
const highlightReady = computed(
() => highlightStatus.value === 'done' && !!reportData.value?.highlightVideoUrl
)
/** 仅店长/员工在登录态下可发起合成(宠主仅查看已生成短片) */
const canTriggerHighlight = computed(() => {
const u = getUserSession()
const role = u?.role
return !!(u?.id && (role === 'boss' || role === 'staff'))
})
let highlightPollTimer = null
const stopHighlightPoll = () => {
if (highlightPollTimer != null) {
clearInterval(highlightPollTimer)
highlightPollTimer = null
}
}
const getRouteToken = () => {
let token = routeToken.value
// #ifdef H5
token = new URLSearchParams(window.location.search).get('token') || token
// #endif
return token || ''
}
const maybeStartHighlightPoll = () => {
stopHighlightPoll()
if (reportData.value?.highlightVideoStatus !== 'processing') return
highlightPollTimer = setInterval(async () => {
const t = getRouteToken()
if (!t) {
stopHighlightPoll()
return
}
const res = await getReportByToken(t)
if (res.code === 200 && res.data) {
reportData.value = res.data
if (res.data.highlightVideoStatus !== 'processing') {
stopHighlightPoll()
if (res.data.highlightVideoStatus === 'done') {
uni.showToast({ title: '短片已生成', icon: 'success' })
}
}
}
}, 2500)
}
const startHighlight = async (durationSec) => {
const u = getUserSession()
if (!reportData.value?.id || !u?.id) {
uni.showToast({ title: '请先登录门店账号', icon: 'none' })
return
}
uni.showLoading({ title: '提交中' })
const res = await postReportHighlightStart({
reportId: reportData.value.id,
durationSec,
operatorUserId: u.id,
role: u.role || 'staff'
})
uni.hideLoading()
if (res.code === 200) {
uni.showToast({ title: res.message || '已开始生成', icon: 'none' })
reportData.value = { ...reportData.value, highlightVideoStatus: 'processing' }
maybeStartHighlightPoll()
} else if (res.code === 503) {
uni.showModal({
title: '暂无法生成',
content: res.message || '服务器未配置 ffmpeg',
showCancel: false
})
} else {
uni.showToast({ title: res.message || '发起失败', icon: 'none' })
}
}
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 shareTitle = computed(() => {
const r = reportData.value
if (!r) return '洗护前后对比'
const store = r.store?.name || '宠伴生活馆'
const pet = r.petName || '爱宠'
return `${pet}的洗护蜕变|${store}`
})
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 brandBarSafe = (() => {
const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20
return `padding-top:${statusBarHeight + 8}px;`
})()
const formatTime = (time) => formatDateTimeYMDHM(time)
const loadReport = async () => {
const token = getRouteToken()
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
maybeStartHighlightPoll()
} else {
notFound.value = true
}
}
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
})
}
const playVideo = (url) => {
if (!url) return
uni.navigateTo({
url: `/pages/video-player/videoPlayer?url=${encodeURIComponent(url)}`
})
}
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())
onUnmounted(() => stopHighlightPoll())
</script>
<style scoped>
.report-view {
background: #fafaf8;
min-height: 100vh;
}
.poster-offscreen {
position: fixed;
left: -9999px;
top: 0;
width: 375px;
height: 610px;
pointer-events: none;
}
/* 加载/空状态 */
.state-wrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
gap: 12px;
}
.state-spinner {
width: 28px; height: 28px;
border: 2.5px solid #ebebea;
border-top-color: #2db96d;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.state-text { font-size: 14px; color: #999993; }
.state-emoji { font-size: 36px; }
/* 内容 */
.report-content {
max-width: 430px;
margin: 0 auto;
background: #fff;
min-height: 100vh;
}
/* 品牌栏 */
.brand-bar {
background: #2db96d;
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-home {
width: 32px; height: 32px;
border-radius: 10px;
background: rgba(255,255,255,0.12);
display: flex;
align-items: center;
justify-content: center;
}
.brand-name {
font-size: 16px;
font-weight: 700;
color: #fff;
letter-spacing: 0.5px;
}
/* 主视觉:照片对比 */
.hero-stage {
position: relative;
margin: 12px 16px 0;
border-radius: 18px;
overflow: hidden;
background: #f5f5f2;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.hero-photos {
display: flex;
height: 280px;
overflow: hidden;
}
.hero-before, .hero-after {
flex: 1;
position: relative;
overflow: hidden;
}
.hero-before { border-right: 1.5px solid #fff; }
.hero-after { border-left: 1.5px solid #fff; }
.hero-img {
width: 100%;
height: 100%;
display: block;
}
.hero-empty {
width: 100%;
height: 100%;
background: #f0f0ee;
display: flex;
align-items: center;
justify-content: center;
}
.hero-empty-text {
font-size: 13px;
color: #bbbbb5;
}
.hero-seq {
position: absolute;
top: 10px;
right: 10px;
min-width: 44px;
height: 24px;
padding: 0 8px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
z-index: 2;
}
.hero-seq-before {
background: rgba(245, 145, 62, 0.9);
color: #fff;
}
.hero-seq-after {
background: rgba(45, 185, 109, 0.9);
color: #fff;
}
.hero-tag {
position: absolute;
bottom: 10px;
left: 10px;
padding: 3px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.tag-before {
background: rgba(245, 145, 62, 0.9);
color: #fff;
}
.tag-after {
background: rgba(45, 185, 109, 0.9);
color: #fff;
}
.hero-divider {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 3;
pointer-events: none;
}
.hero-divider-text {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 58px;
height: 28px;
padding: 0 12px;
border-radius: 999px;
background: rgba(26, 26, 26, 0.82);
color: #fff;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.5px;
}
/* 照片信息与补充分组 */
.hero-meta {
display: flex;
gap: 8px;
padding: 10px 16px 0;
}
.hero-tip {
padding: 8px 16px 0;
text-align: center;
}
.hero-tip text {
font-size: 12px;
color: #bbbbb5;
letter-spacing: 0.2px;
}
.hero-actions {
display: flex;
gap: 8px;
padding: 10px 16px 0;
}
.hero-action-chip {
flex: 1;
min-width: 0;
height: 40px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.2px;
}
.hero-action-chip-before {
background: #fff7f0;
color: #f5913e;
border: 1px solid rgba(245, 145, 62, 0.18);
}
.hero-action-chip-after {
background: #f3fbf6;
color: #2db96d;
border: 1px solid rgba(45, 185, 109, 0.18);
}
.hero-meta-chip {
flex: 1;
min-width: 0;
height: 36px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
}
.hero-meta-chip-before {
background: #fef5ec;
color: #f5913e;
}
.hero-meta-chip-after {
background: #eaf8f0;
color: #2db96d;
}
.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: #999993;
}
.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: 16px;
padding: 20px;
background: #fafaf8;
border-radius: 14px;
}
.info-title {
font-size: 13px;
font-weight: 700;
color: #999993;
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: #999993;
}
.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: #999993;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 8px;
}
.remark-text {
font-size: 14px;
color: #666660;
line-height: 1.6;
}
/* 服务回顾短片 */
.highlight-card {
margin: 0 16px 16px;
padding: 16px;
background: linear-gradient(145deg, #f3fff9 0%, #eef8ff 100%);
border-radius: 14px;
border: 1px solid #dcefe3;
}
.highlight-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.highlight-title {
font-size: 16px;
font-weight: 700;
color: #14532d;
}
.highlight-badge {
font-size: 11px;
font-weight: 700;
color: #0f766e;
background: rgba(45, 185, 109, 0.15);
padding: 3px 10px;
border-radius: 999px;
}
.highlight-desc {
display: block;
font-size: 12px;
color: #4b5563;
line-height: 1.55;
margin-bottom: 12px;
}
.highlight-status {
padding: 10px 12px;
border-radius: 10px;
font-size: 13px;
margin-bottom: 10px;
}
.highlight-status--busy {
background: rgba(45, 185, 109, 0.12);
color: #166534;
}
.highlight-status--err {
background: rgba(239, 68, 68, 0.1);
color: #b91c1c;
}
.highlight-player {
width: 100%;
max-height: 420px;
border-radius: 12px;
background: #000;
margin-bottom: 12px;
}
.highlight-actions {
display: flex;
gap: 10px;
}
.highlight-btn {
flex: 1;
font-size: 14px;
font-weight: 600;
padding: 10px 8px;
border-radius: 10px;
border: none;
background: #2db96d;
color: #fff;
}
.highlight-btn--secondary {
background: #0d9488;
}
.highlight-btn[disabled] {
opacity: 0.55;
}
/* 操作按钮 */
.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;
}
/* 过程中区块 */
.section-gap {
padding: 0 16px;
}
.during-section {
background: #fff;
border-radius: 16px;
padding: 18px 18px 4px;
}
.during-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 14px;
}
.during-title-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #7c5ce9;
}
.during-group {
margin-bottom: 14px;
}
.during-group-label {
font-size: 12px;
font-weight: 600;
color: #999993;
margin-bottom: 8px;
}
.during-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.during-thumb {
position: relative;
aspect-ratio: 1;
border-radius: 10px;
overflow: hidden;
background: #f0f0ee;
}
.during-thumb-img {
width: 100%;
height: 100%;
display: block;
}
</style>