petstore-frontend/src/pages/report-view/reportView.vue
2026-04-16 23:23:15 +08:00

1111 lines
29 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>
<!-- 操作区 -->
<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="generatePoster">
保存海报到相册
</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>
<!-- #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 } from '../../api/index.js'
import { formatDateTimeYMDHM } from '../../utils/datetime.js'
import { drawReportPoster, POSTER_W, POSTER_H } from '../../utils/reportPosterDraw.js'
import AppIcon from '../../components/AppIcon.vue'
import { isLoggedIn } from '../../utils/session.js'
const loading = ref(true)
const notFound = ref(false)
const reportData = ref(null)
const posterCanvas = ref(null)
const routeToken = ref('')
const isStaff = computed(() => isLoggedIn())
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 || '爱宠'
const svc = r.serviceType || '洗护美容'
return `${store}${pet}${svc}报告`
})
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 () => {
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 = () => 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
}
})
// #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 generatePoster = async () => {
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')
canvas.width = POSTER_W
canvas.height = POSTER_H
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
})
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 generatePoster = async () => {
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 }
canvas.width = POSTER_W * dpr
canvas.height = POSTER_H * 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
})
} 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: POSTER_H,
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
// #ifndef H5
// #ifndef MP-WEIXIN
const generatePoster = () => {
uni.showToast({ title: '请截图分享报告页', icon: 'none' })
}
// #endif
// #endif
onLoad((options) => { routeToken.value = options?.token || '' })
onMounted(() => loadReport())
</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;
}
/* 操作按钮 */
.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>