petstore-frontend/src/pages/report-view/reportView.vue
2026-04-19 15:44:49 +08:00

1071 lines
30 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">
<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>