fix: H5海报Canvas按devicePixelRatio缩放

This commit is contained in:
MaDaLei 2026-04-18 17:34:23 +08:00
parent d93ed027c7
commit 582092f467
20 changed files with 1969 additions and 676 deletions

View File

@ -89,7 +89,7 @@ npm run dev:h5
| 页面 | 路径 | 说明 |
|------|------|------|
| 登录 | /pages/login/login | Tab切换员工登录/老板登录/注册老板/注册员工 |
| 首页 | /pages/home/home | 预约列表 Tab确认/进行中/已完成)+ 新建预约 |
| 首页 | /pages/home/home | 预约列表 Tab开始/进行中/已完成)+ 新建预约 |
| 洗美报告 | /pages/report/report | 填写报告 + 提交后显示链接+二维码 |
| 报告页 | /pages/report-view/reportView | 独立访问的报告页token=xxx |
| 我的 | /pages/mine/mine | 个人中心,老板有额外菜单 |

View File

@ -4,6 +4,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>服务报告 - 宠伴生活馆</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Ma+Shan+Zheng&display=swap" rel="stylesheet" />
</head>
<body>
<div id="app"></div>

View File

@ -43,8 +43,13 @@ export const sendSms = (phone) => post('/sms/send', { phone })
// 登录
export const login = (phone, code) => post('/user/login', { phone, code })
/** 微信小程序getPhoneNumber 返回的 detail.code */
export const wxPhoneLogin = (phoneCode) => post('/user/wx-phone-login', { phoneCode })
/**
* 微信小程序getPhoneNumber phoneCode + uni.login codeloginCode一并提交服务端换手机号并沉淀 openid
* @param {string} phoneCode getPhoneNumber 回调 detail.code
* @param {string} [loginCode] uni.login 返回的 code phoneCode 不同
*/
export const wxPhoneLogin = (phoneCode, loginCode) =>
post('/user/wx-phone-login', { phoneCode, loginCode: loginCode || '' })
// 注册老板
export const registerBoss = (data) => post('/user/register-boss', data)
@ -59,6 +64,10 @@ export const updatePet = (data) => put('/pet/update', data)
export const deletePet = (id, operatorUserId, role) =>
del(`/pet/delete?id=${id}&operatorUserId=${operatorUserId}&role=${encodeURIComponent(role)}`)
/** 按宠物:预约 + 报告时间线(需 operatorUserId + role 鉴权) */
export const getPetHistory = (petId, operatorUserId, role) =>
get('/pet/history', { petId, operatorUserId, role })
// 预约列表
export const getAppointmentList = (userId, storeId, options = {}) =>
get('/appointment/list', { userId, storeId, ...options })
@ -95,9 +104,13 @@ export const getReportList = (params) => get('/report/list', params)
// 报告页:下次服务建议日期
export const getReportSuggestion = (token) => get(`/report/${encodeURIComponent(token)}/suggestion`)
// 报告页:宠主留资(下次提醒)
export const submitReportReminder = (token, phone) =>
post(`/report/${encodeURIComponent(token)}/reminder`, { phone, consent: true })
// 报告页:宠主留资(下次提醒);小程序可传 loginCodeuni.login沉淀 openid/unionid
export const submitReportReminder = (token, phone, loginCode) =>
post(`/report/${encodeURIComponent(token)}/reminder`, {
phone,
consent: true,
loginCode: loginCode || ''
})
// 报告页:一键退订
export const unsubscribeReportReminder = (unsubscribeToken) =>

View File

@ -0,0 +1,146 @@
<template>
<view class="aps">
<view v-if="loading" class="aps-panel aps-loading">
<view class="aps-spinner" />
<text class="aps-line">{{ loadingText }}</text>
</view>
<view v-else-if="error" class="aps-panel aps-fail">
<text class="aps-icon">!</text>
<text class="aps-title">{{ errorTitle }}</text>
<text class="aps-desc">{{ error }}</text>
<button
v-if="showRetry"
class="aps-retry"
hover-class="aps-retry--hover"
@click="$emit('retry')"
>
{{ retryText }}
</button>
</view>
<view v-else-if="empty" class="aps-panel aps-empty">
<text class="aps-emoji">{{ emptyEmoji }}</text>
<text class="aps-title">{{ emptyTitle }}</text>
<text v-if="emptyHint" class="aps-hint">{{ emptyHint }}</text>
</view>
<slot v-else />
</view>
</template>
<script setup>
defineProps({
loading: { type: Boolean, default: false },
/** 非空时显示失败态(优先于 empty */
error: { type: String, default: null },
empty: { type: Boolean, default: false },
loadingText: { type: String, default: '加载中…' },
errorTitle: { type: String, default: '加载失败' },
retryText: { type: String, default: '点击重试' },
showRetry: { type: Boolean, default: true },
emptyTitle: { type: String, default: '暂无数据' },
emptyHint: { type: String, default: '' },
emptyEmoji: { type: String, default: '📋' }
})
defineEmits(['retry'])
</script>
<style scoped>
.aps {
width: 100%;
min-height: 0;
}
.aps-panel {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 24px 48px;
min-height: 220px;
box-sizing: border-box;
}
.aps-loading .aps-spinner {
width: 36px;
height: 36px;
border: 3px solid #e8edf4;
border-top-color: #2db96d;
border-radius: 50%;
animation: aps-spin 0.75s linear infinite;
}
.aps-line {
margin-top: 14px;
font-size: 14px;
color: #64748b;
}
.aps-fail .aps-icon {
width: 44px;
height: 44px;
line-height: 44px;
text-align: center;
border-radius: 50%;
background: #fef2f2;
color: #dc2626;
font-size: 22px;
font-weight: 800;
}
.aps-title {
margin-top: 12px;
font-size: 16px;
font-weight: 700;
color: #1e293b;
}
.aps-desc {
margin-top: 6px;
font-size: 13px;
color: #64748b;
text-align: center;
line-height: 1.5;
max-width: 280px;
}
.aps-retry {
margin-top: 18px;
padding: 0 28px;
height: 40px;
line-height: 40px;
font-size: 15px;
font-weight: 600;
color: #fff;
background: linear-gradient(135deg, #3dd68c, #2db96d);
border-radius: 999px;
border: none;
}
.aps-retry--hover {
opacity: 0.92;
}
.aps-empty .aps-emoji {
font-size: 40px;
line-height: 1.2;
margin-bottom: 4px;
}
.aps-empty .aps-hint {
margin-top: 6px;
font-size: 12px;
color: #94a3b8;
text-align: center;
line-height: 1.5;
max-width: 260px;
}
@keyframes aps-spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@ -0,0 +1,243 @@
<template>
<view class="ba-root">
<view v-if="!beforeSrc && !afterSrc" class="ba-empty">暂无对比照片</view>
<view v-else-if="!beforeSrc || !afterSrc" class="ba-single">
<image v-if="beforeSrc || afterSrc" class="ba-img" :src="beforeSrc || afterSrc" mode="aspectFill" />
</view>
<view
v-else
class="ba-wrap"
ref="wrapRef"
@touchstart.stop="onTouchStart"
@touchmove.stop.prevent="onTouchMove"
@mousedown.prevent="onMouseDown"
>
<!-- 洗后底层铺满 -->
<image class="ba-layer ba-after" :src="afterSrc" mode="aspectFill" />
<!-- 洗前左侧裁剪区宽度 = posPct%内层图宽度 = 相对裁剪区的 10000/pos%避免小程序 image clip-path 不稳定 -->
<view class="ba-before-clip" :style="{ width: posPct + '%' }">
<image class="ba-before-img" :src="beforeSrc" mode="aspectFill" :style="beforeImgStretchStyle" />
</view>
<view class="ba-divider" :style="{ left: posPct + '%' }">
<view class="ba-handle">
<text class="ba-arrows"> </text>
</view>
</view>
<view class="ba-tags">
<text class="ba-tag ba-tag-before">洗前</text>
<text class="ba-tag ba-tag-after">洗后</text>
</view>
<text class="ba-hint">拖动对比</text>
</view>
</view>
</template>
<script setup>
import { ref, computed, getCurrentInstance, nextTick, onMounted } from 'vue'
const props = defineProps({
beforeSrc: { type: String, default: '' },
afterSrc: { type: String, default: '' }
})
const posPct = ref(50)
const wrapRef = ref(null)
/** 小程序端 getBoundingClientRect 不可用,用查询缓存 */
const mpRect = ref(null)
const beforeImgStretchStyle = computed(() => {
const p = Math.max(3, Math.min(97, posPct.value))
return {
width: (10000 / p).toFixed(4) + '%',
height: '100%'
}
})
function clamp(n, a, b) {
return Math.min(b, Math.max(a, n))
}
function getWrapRect() {
const el = wrapRef.value
if (!el) return null
const dom = el.$el ?? el
return dom.getBoundingClientRect?.() || null
}
function refreshMpRect() {
// #ifndef H5
const proxy = getCurrentInstance()?.proxy
if (!proxy) return
uni.createSelectorQuery()
.in(proxy)
.select('.ba-wrap')
.boundingClientRect((rect) => {
if (rect && rect.width) mpRect.value = rect
})
.exec()
// #endif
}
function setFromClientX(clientX) {
let rect = getWrapRect()
if ((!rect || !rect.width) && mpRect.value) rect = mpRect.value
if (!rect || !rect.width) return
const x = clientX - rect.left
posPct.value = clamp(Math.round((x / rect.width) * 1000) / 10, 3, 97)
}
function onTouchStart() {
refreshMpRect()
}
function onTouchMove(e) {
if (!props.beforeSrc || !props.afterSrc) return
const t = e.touches?.[0]
if (t) setFromClientX(t.clientX)
}
onMounted(() => {
nextTick(() => refreshMpRect())
})
function onMouseDown(e) {
if (!props.beforeSrc || !props.afterSrc) return
setFromClientX(e.clientX)
if (typeof window === 'undefined') return
const move = (ev) => setFromClientX(ev.clientX)
const up = () => {
window.removeEventListener('mousemove', move)
window.removeEventListener('mouseup', up)
}
window.addEventListener('mousemove', move)
window.addEventListener('mouseup', up)
}
</script>
<style scoped>
.ba-root { width: 100%; }
.ba-empty {
aspect-ratio: 1;
border-radius: 14px;
background: #f1f5f9;
border: 1px dashed #cbd5e1;
display: flex;
align-items: center;
justify-content: center;
color: #94a3b8;
font-size: 14px;
}
.ba-single {
width: 100%;
aspect-ratio: 1;
border-radius: 14px;
overflow: hidden;
border: 1px solid #e5e7eb;
}
.ba-img { width: 100%; height: 100%; display: block; }
.ba-wrap {
position: relative;
width: 100%;
aspect-ratio: 1;
border-radius: 14px;
overflow: hidden;
border: 1px solid #e5e7eb;
touch-action: none;
user-select: none;
-webkit-user-select: none;
cursor: ew-resize;
}
.ba-layer {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
display: block;
}
.ba-after { z-index: 1; }
.ba-before-clip {
position: absolute;
left: 0;
top: 0;
height: 100%;
overflow: hidden;
z-index: 2;
border-right: 1px solid rgba(255, 255, 255, 0.85);
}
.ba-before-img {
position: absolute;
left: 0;
top: 0;
display: block;
object-fit: cover;
}
.ba-divider {
position: absolute;
top: 0;
bottom: 0;
width: 0;
z-index: 3;
transform: translateX(-50%);
pointer-events: none;
}
.ba-handle {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.2);
border: 2px solid #16a34a;
display: flex;
align-items: center;
justify-content: center;
pointer-events: auto;
cursor: ew-resize;
}
.ba-arrows {
font-size: 14px;
font-weight: 700;
color: #15803d;
letter-spacing: -2px;
}
.ba-tags {
position: absolute;
bottom: 10px;
left: 0;
right: 0;
z-index: 4;
display: flex;
justify-content: space-between;
padding: 0 10px;
pointer-events: none;
}
.ba-tag {
font-size: 11px;
font-weight: 700;
padding: 4px 10px;
border-radius: 8px;
color: #fff;
}
.ba-tag-before { background: rgba(245, 158, 11, 0.92); }
.ba-tag-after { background: rgba(22, 163, 74, 0.92); }
.ba-hint {
position: absolute;
top: 8px;
left: 50%;
transform: translateX(-50%);
z-index: 4;
font-size: 11px;
color: rgba(255, 255, 255, 0.95);
background: rgba(15, 23, 42, 0.35);
padding: 3px 10px;
border-radius: 999px;
pointer-events: none;
}
</style>

View File

@ -0,0 +1,194 @@
<template>
<view v-if="items.length" class="dm-root">
<view class="dm-title-row">
<text class="dm-title">服务过程</text>
<text class="dm-sub">{{ items.length }} 段素材</text>
</view>
<view class="dm-grid">
<view
v-for="(it, idx) in displayItems"
:key="idx"
class="dm-cell"
@click="onCell(it, idx)"
>
<image
v-if="it.mediaType !== 'video'"
class="dm-thumb"
:src="it.url"
mode="aspectFill"
/>
<view v-else class="dm-video-wrap">
<view class="dm-video-ph"></view>
<view class="dm-play">
<text class="dm-play-icon"></text>
</view>
</view>
</view>
</view>
<!-- H5全屏遮罩播放小程序原生 video fixed 遮罩内易触发渲染层异常改跳转播放页 -->
<!-- #ifdef H5 -->
<view v-if="videoPreviewUrl" class="dm-video-overlay" @click="closeVideo">
<view class="dm-video-box" @click.stop>
<video
class="dm-video"
:src="videoPreviewUrl"
controls
autoplay
/>
<text class="dm-close">关闭</text>
</view>
</view>
<!-- #endif -->
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
items: {
type: Array,
default: () => []
}
})
const videoPreviewUrl = ref('')
const displayItems = computed(() => props.items.slice(0, 9))
const imageUrls = computed(() =>
displayItems.value.filter((i) => i.mediaType !== 'video').map((i) => i.url)
)
function onCell(it, idx) {
if (it.mediaType === 'video') {
// #ifdef H5
videoPreviewUrl.value = it.url
// #endif
// #ifndef H5
uni.navigateTo({
url: `/pages/video-player/videoPlayer?url=${encodeURIComponent(it.url)}`
})
// #endif
return
}
const urls = imageUrls.value
if (!urls.length) return
const current = it.url
uni.previewImage({
urls,
current
})
}
function closeVideo() {
videoPreviewUrl.value = ''
}
</script>
<style scoped>
.dm-root {
margin-top: 2px;
}
.dm-title-row {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 10px;
}
.dm-title {
font-size: 15px;
font-weight: 700;
color: #1f2937;
}
.dm-sub {
font-size: 12px;
color: #94a3b8;
}
.dm-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
}
.dm-cell {
aspect-ratio: 1;
border-radius: 10px;
overflow: hidden;
background: #f1f5f9;
border: 1px solid #e8edf4;
}
.dm-thumb {
width: 100%;
height: 100%;
display: block;
}
.dm-video-wrap {
position: relative;
width: 100%;
height: 100%;
}
.dm-video-ph {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
background: linear-gradient(145deg, #1e293b 0%, #334155 100%);
}
.dm-play {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(15, 23, 42, 0.25);
}
.dm-play-icon {
width: 36px;
height: 36px;
line-height: 36px;
text-align: center;
border-radius: 50%;
background: rgba(255, 255, 255, 0.95);
color: #16a34a;
font-size: 14px;
padding-left: 3px;
}
.dm-video-overlay {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 10000;
background: rgba(15, 23, 42, 0.75);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
box-sizing: border-box;
}
.dm-video-box {
width: 100%;
max-width: 400px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.dm-video {
width: 100%;
max-height: 70vh;
border-radius: 12px;
background: #000;
}
.dm-close {
color: #fff;
font-size: 14px;
padding: 8px 20px;
}
</style>

View File

@ -126,12 +126,26 @@ async function loadSuggestion() {
}
}
const fetchMiniLoginCode = () =>
new Promise((resolve) => {
// #ifdef MP-WEIXIN
uni.login({
success: (res) => resolve((res && res.code) || ''),
fail: () => resolve('')
})
// #endif
// #ifndef MP-WEIXIN
resolve('')
// #endif
})
async function onSubmit() {
if (!canSubmit.value) return
errorMsg.value = ''
submitting.value = true
try {
const res = await submitReportReminder(props.token, phone.value)
const loginCode = await fetchMiniLoginCode()
const res = await submitReportReminder(props.token, phone.value, loginCode)
if (res && res.code === 200 && res.data && res.data.ok) {
savedDate.value = res.data.remindDate || suggestion.value.remindDate
submitted.value = true

View File

@ -13,6 +13,7 @@
{"path": "pages/mine/MyReports"},
{"path": "pages/mine/MyOrders"},
{"path": "pages/mine/MyPets"},
{"path": "pages/mine/PetHistory"},
{"path": "pages/mine/Profile"},
{"path": "pages/video-player/videoPlayer"},
{"path": "pages/report-view/reportView"},

View File

@ -6,9 +6,18 @@
<view class="nav-placeholder"></view>
</view>
<view v-if="loading" class="page-section loading-hint"><text>加载中</text></view>
<view v-else-if="detail" class="page-section detail-body">
<AppPageState
class="detail-state"
:loading="loading"
:error="loadError"
:empty="!loading && !loadError && !detail"
empty-title="未找到该预约"
empty-hint="可能已取消或编号有误"
empty-emoji="🔍"
:show-retry="!!loadError"
@retry="retryLoad"
>
<view v-if="detail" class="page-section detail-body">
<view class="detail-card">
<view class="detail-row head-row">
<view class="pet-line">
@ -42,8 +51,7 @@
</view>
</view>
</view>
<view v-else class="page-section empty"><text>未找到该预约</text></view>
</AppPageState>
</div>
</template>
@ -51,12 +59,16 @@
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import AppIcon from '../../components/AppIcon.vue'
import AppPageState from '../../components/AppPageState.vue'
import { messageFromApi, NETWORK_ERROR_MSG } from '../../utils/apiResult.js'
import { getAppointmentDetail } from '../../api/index.js'
import { formatDateTimeCN } from '../../utils/datetime.js'
import { getAppointmentStatusText, getAppointmentTagClass } from '../../utils/appointment.js'
const loading = ref(true)
const loadError = ref(null)
const detail = ref(null)
const routeId = ref('')
const navSafeStyle = (() => {
const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20
@ -78,23 +90,38 @@ const tagClass = (status) => getAppointmentTagClass(status)
const goBack = () => uni.navigateBack()
const load = async (id) => {
loadError.value = null
if (!id) {
detail.value = null
loading.value = false
return
}
loading.value = true
try {
const res = await getAppointmentDetail(Number(id))
loading.value = false
if (res.code === 200 && res.data) {
if (res && res.code === 200 && res.data) {
detail.value = res.data
} else {
detail.value = null
uni.showToast({ title: res.message || '加载失败', icon: 'none' })
if (res && res.code === 404) {
loadError.value = null
} else {
loadError.value = messageFromApi(res, '加载失败')
}
}
} catch (e) {
detail.value = null
loadError.value = NETWORK_ERROR_MSG
} finally {
loading.value = false
}
}
const retryLoad = () => load(routeId.value)
onLoad((options) => {
load(options.id)
routeId.value = options.id || ''
load(routeId.value)
})
</script>

View File

@ -11,12 +11,37 @@
<text class="c-hero-sub">查看预约进度点击右下角加号新建预约</text>
</view>
<view class="tabs-wrap">
<view class="home-dash">
<view
v-for="card in dashCards"
:key="card.key"
class="dash-card"
:class="{ 'dash-card--active': activeDashKey === card.key }"
@click="onDashCardClick(card.key)"
>
<text class="dash-card-value">{{ card.value }}</text>
<text class="dash-card-label">{{ card.label }}</text>
<text class="dash-card-hint">{{ card.hint }}</text>
</view>
</view>
<view v-if="listMode === 'today'" class="list-mode-bar">
<text class="list-mode-back" @click="exitListMode"> 返回</text>
<text class="list-mode-title">今日日程</text>
<text class="list-mode-placeholder"></text>
</view>
<view v-else-if="listMode === 'week'" class="list-mode-bar list-mode-bar--muted">
<text class="list-mode-back" @click="exitListMode"> 返回</text>
<text class="list-mode-title">本周已完成</text>
<text class="list-mode-placeholder"></text>
</view>
<view v-else class="tabs-wrap">
<view
v-for="tab in statusTabs"
:key="tab.name"
:class="['tab', { 'tab-active': currentStatus === tab.name }]"
@click="currentStatus = tab.name"
@click="selectTab(tab.name)"
>
<text class="tab-text">{{ tab.title }}</text>
<view
@ -30,9 +55,18 @@
</view>
</view>
<AppPageState
class="home-list-state"
:loading="appointmentsLoading"
:error="appointmentsError"
:empty="showListPlaceholder"
:empty-title="listEmptyTitle"
:empty-hint="listEmptyHint"
@retry="fetchAppointments"
>
<view class="order-list">
<view
v-for="item in filteredOrders"
v-for="item in ordersForList"
:key="item.id"
class="order-card"
@click="goAppointmentDetail(item)"
@ -49,8 +83,8 @@
<button class="app-btn-sm app-btn-outline" @click.stop="cancelService(item)">取消预约</button>
</view>
</view>
<view v-if="filteredOrders.length === 0" class="empty"><text>暂无预约</text></view>
</view>
</AppPageState>
<view class="fab-appt" @click="openCustCreateAppt" hover-class="fab-appt--hover">
<text class="fab-appt-icon">+</text>
@ -63,13 +97,38 @@
<text class="nav-title">{{ storeInfo.name || '宠伴生活馆' }}</text>
</view>
<view class="home-dash">
<view
v-for="card in dashCards"
:key="card.key"
class="dash-card"
:class="{ 'dash-card--active': activeDashKey === card.key }"
@click="onDashCardClick(card.key)"
>
<text class="dash-card-value">{{ card.value }}</text>
<text class="dash-card-label">{{ card.label }}</text>
<text class="dash-card-hint">{{ card.hint }}</text>
</view>
</view>
<view v-if="listMode === 'today'" class="list-mode-bar">
<text class="list-mode-back" @click="exitListMode"> 返回</text>
<text class="list-mode-title">今日日程</text>
<text class="list-mode-placeholder"></text>
</view>
<view v-else-if="listMode === 'week'" class="list-mode-bar list-mode-bar--muted">
<text class="list-mode-back" @click="exitListMode"> 返回</text>
<text class="list-mode-title">本周已完成</text>
<text class="list-mode-placeholder"></text>
</view>
<!-- 状态 Tab -->
<view class="tabs-wrap">
<view v-else class="tabs-wrap">
<view
v-for="tab in statusTabs"
:key="tab.name"
:class="['tab', { 'tab-active': currentStatus === tab.name }]"
@click="currentStatus = tab.name"
@click="selectTab(tab.name)"
>
<text class="tab-text">{{ tab.title }}</text>
<view
@ -84,8 +143,17 @@
</view>
<!-- 列表 -->
<AppPageState
class="home-list-state"
:loading="appointmentsLoading"
:error="appointmentsError"
:empty="showListPlaceholder"
:empty-title="listEmptyTitle"
:empty-hint="listEmptyHint"
@retry="fetchAppointments"
>
<view class="order-list">
<view v-for="item in filteredOrders" :key="item.id" class="order-card" @click="goAppointmentDetail(item)">
<view v-for="item in ordersForList" :key="item.id" class="order-card" @click="goAppointmentDetail(item)">
<view class="order-top">
<view class="order-pet">
<text class="order-pet-name">{{ item.petName }}</text>
@ -104,8 +172,8 @@
</view>
</view>
</view>
<view v-if="filteredOrders.length === 0" class="empty"><text>暂无数据</text></view>
</view>
</AppPageState>
<view class="fab-appt" @click="openStaffNewApptDialog" hover-class="fab-appt--hover">
<text class="fab-appt-icon">+</text>
@ -195,12 +263,36 @@ import {
getAppointmentAvailableSlots
} from '../../api/index.js'
import TabBar from '../../components/TabBar.vue'
import AppPageState from '../../components/AppPageState.vue'
import { messageFromApi, NETWORK_ERROR_MSG } from '../../utils/apiResult.js'
import { getUserSession, getStoreSession } from '../../utils/session.js'
import { useNavigator } from '../../composables/useNavigator.js'
import { formatDateTimeCN } from '../../utils/datetime.js'
import { getAppointmentStatusText } from '../../utils/appointment.js'
import { normalizeToHalfHour, ymdLocal, formatHalfHourSlotRange } from '../../utils/halfHourTime.js'
/** 预约时间转本地日历 YYYY-MM-DD */
function apptYmd(iso) {
if (!iso) return ''
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ''
return ymdLocal(d)
}
/** 预约是否落在本周(周一至周日,本地时区) */
function isApptInThisWeek(iso) {
if (!iso) return false
const t = new Date(iso).getTime()
if (Number.isNaN(t)) return false
const now = new Date()
const day = now.getDay()
const monOff = day === 0 ? -6 : 1 - day
const mon = new Date(now.getFullYear(), now.getMonth(), now.getDate() + monOff)
mon.setHours(0, 0, 0, 0)
const nextMon = new Date(mon.getTime() + 7 * 24 * 60 * 60 * 1000)
return t >= mon.getTime() && t < nextMon.getTime()
}
const userInfo = getUserSession()
const storeInfo = getStoreSession()
const { goPage } = useNavigator()
@ -217,12 +309,41 @@ const navSafeStyle = (() => {
const currentStatus = ref(userInfo.role === 'customer' ? 'new' : 'doing')
const statusTabs = [
{ title: '待确认', name: 'new' },
{ title: '待开始', name: 'new' },
{ title: '进行中', name: 'doing' },
{ title: '已完成', name: 'done' }
]
const orders = ref([])
const appointmentsLoading = ref(false)
const appointmentsError = ref(null)
/** tab | today | week聚合视图week 仅本周已完成 */
const listMode = ref('tab')
const activeDashKey = ref(null)
const todayYmd = computed(() => ymdLocal())
const homeStats = computed(() => {
const list = orders.value
const today = todayYmd.value
return {
todayCount: list.filter((o) => apptYmd(o.appointmentTime) === today && o.status !== 'cancel').length,
doingCount: list.filter((o) => o.status === 'doing').length,
newCount: list.filter((o) => o.status === 'new').length,
weekDoneCount: list.filter((o) => o.status === 'done' && isApptInThisWeek(o.appointmentTime)).length
}
})
const dashCards = computed(() => {
const s = homeStats.value
return [
{ key: 'today', value: s.todayCount, label: '今日到店', hint: '已排期' },
{ key: 'doing', value: s.doingCount, label: '进行中', hint: '服务中' },
{ key: 'new', value: s.newCount, label: '待开始', hint: '开剪前' },
{ key: 'weekDone', value: s.weekDoneCount, label: '本周完成', hint: '已完成单' }
]
})
const newApptCount = computed(() => orders.value.filter(o => o.status === 'new').length)
const doingApptCount = computed(() => orders.value.filter(o => o.status === 'doing').length)
const serviceTypes = ref([])
@ -269,6 +390,65 @@ const filteredOrders = computed(() => orders.value.filter(o => {
return true
}))
const ordersForList = computed(() => {
if (listMode.value === 'today') {
const t = todayYmd.value
return orders.value
.filter((o) => apptYmd(o.appointmentTime) === t && o.status !== 'cancel')
.slice()
.sort((a, b) => new Date(a.appointmentTime) - new Date(b.appointmentTime))
}
if (listMode.value === 'week') {
return orders.value
.filter((o) => o.status === 'done' && isApptInThisWeek(o.appointmentTime))
.slice()
.sort((a, b) => new Date(b.appointmentTime) - new Date(a.appointmentTime))
}
return filteredOrders.value
})
const selectTab = (name) => {
currentStatus.value = name
listMode.value = 'tab'
activeDashKey.value = null
}
const onDashCardClick = (key) => {
activeDashKey.value = key
if (key === 'today') {
listMode.value = 'today'
return
}
if (key === 'weekDone') {
listMode.value = 'week'
return
}
listMode.value = 'tab'
if (key === 'doing') currentStatus.value = 'doing'
else if (key === 'new') currentStatus.value = 'new'
}
const exitListMode = () => {
listMode.value = 'tab'
activeDashKey.value = null
}
const showListPlaceholder = computed(
() => !appointmentsLoading.value && !appointmentsError.value && ordersForList.value.length === 0
)
const listEmptyTitle = computed(() => {
if (listMode.value === 'today') return '今日暂无预约'
if (listMode.value === 'week') return '本周暂无已完成'
return userInfo.role === 'customer' ? '暂无预约' : '暂无数据'
})
const listEmptyHint = computed(() =>
listMode.value === 'today' || listMode.value === 'week'
? '可返回列表切换日期或 Tab'
: '切换上方状态或新建预约'
)
const loadStaffAvailableSlots = async () => {
if (!storeInfo?.id) {
staffAvailableTimes.value = []
@ -337,11 +517,17 @@ const onStaffApptTimeChange = (e) => {
}
const fetchAppointments = async () => {
appointmentsError.value = null
if (userInfo.role === 'customer') {
if (!userInfo.id) return
if (!userInfo.id) {
appointmentsLoading.value = false
return
}
appointmentsLoading.value = true
try {
const res = await getAppointmentList(userInfo.id)
if (res.code === 200) {
orders.value = res.data.map(appt => ({
if (res && res.code === 200) {
orders.value = res.data.map((appt) => ({
id: appt.id,
title: appt.serviceType || '洗澡美容预约',
desc: `${appt.petType || ''} - ${appt.petName || ''}`,
@ -353,13 +539,25 @@ const fetchAppointments = async () => {
serviceType: appt.serviceType,
appointmentTime: appt.appointmentTime
}))
} else {
appointmentsError.value = messageFromApi(res, '加载失败')
}
} catch (e) {
appointmentsError.value = NETWORK_ERROR_MSG
} finally {
appointmentsLoading.value = false
}
return
}
if (!storeInfo.id) return
if (!storeInfo.id) {
appointmentsLoading.value = false
return
}
appointmentsLoading.value = true
try {
const res = await getAppointmentList(null, storeInfo.id, { page: 1, pageSize: 50 })
if (res.code === 200) {
orders.value = res.data.map(appt => ({
if (res && res.code === 200) {
orders.value = res.data.map((appt) => ({
id: appt.id,
title: appt.serviceType || '洗澡美容预约',
desc: `${appt.petType || ''} - ${appt.petName || ''}`,
@ -371,8 +569,13 @@ const fetchAppointments = async () => {
serviceType: appt.serviceType,
appointmentTime: appt.appointmentTime
}))
const newCount = orders.value.filter(o => o.status === 'new').length
if (newCount > 0) uni.showToast({ title: `${newCount} 个待确认`, icon: 'none' })
} else {
appointmentsError.value = messageFromApi(res, '加载失败')
}
} catch (e) {
appointmentsError.value = NETWORK_ERROR_MSG
} finally {
appointmentsLoading.value = false
}
}
@ -555,6 +758,81 @@ onShow(() => {
}
.home-remark-textarea { min-height: 80px; line-height: 1.5; }
/* 首页数据看板 */
.home-dash {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin: 0 16px 12px;
}
.dash-card {
background: #fff;
border-radius: 14px;
padding: 12px 10px 10px;
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.06);
border: 1.5px solid transparent;
}
.dash-card--active {
border-color: #2db96d;
background: linear-gradient(145deg, #f0fdf4 0%, #ffffff 55%);
}
.dash-card-value {
display: block;
font-size: 26px;
font-weight: 800;
color: #1a1a1a;
letter-spacing: -0.5px;
line-height: 1.1;
}
.dash-card-label {
display: block;
margin-top: 4px;
font-size: 13px;
font-weight: 700;
color: #3f3f3a;
}
.dash-card-hint {
display: block;
margin-top: 2px;
font-size: 11px;
color: #a8a89f;
}
.list-mode-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0 16px 8px;
padding: 10px 12px;
background: #ecfdf5;
border-radius: 12px;
border: 1px solid #bbf7d0;
}
.list-mode-back {
font-size: 15px;
font-weight: 600;
color: #15803d;
padding: 4px 0;
}
.list-mode-title {
font-size: 15px;
font-weight: 700;
color: #166534;
}
.list-mode-placeholder {
width: 56px;
}
.list-mode-bar--muted {
background: #f8fafc;
border-color: #e2e8f0;
}
.list-mode-bar--muted .list-mode-title {
color: #334155;
}
.list-mode-bar--muted .list-mode-back {
color: #64748b;
}
/* B端 Tab */
.tabs-wrap {
display: flex;

View File

@ -160,6 +160,20 @@ const backToLoginForm = () => { view.value = 'login' }
const showToast = (msg) => uni.showToast({ title: msg, icon: 'none' })
/** 微信小程序 wx.login用于服务端 jscode2session 换 openid与手机号授权 code 无关) */
const fetchWxLoginCode = () =>
new Promise((resolve) => {
// #ifdef MP-WEIXIN
uni.login({
success: (res) => resolve((res && res.code) || ''),
fail: () => resolve('')
})
// #endif
// #ifndef MP-WEIXIN
resolve('')
// #endif
})
const handleSendSms = async () => {
if (!loginForm.phone || loginForm.phone.length !== 11) return showToast('请输入正确的手机号')
const res = await sendSms(loginForm.phone)
@ -253,7 +267,8 @@ const onWxGetPhoneNumber = async (e) => {
}
loginLoading.value = true
try {
const res = await wxPhoneLogin(code)
const loginCode = await fetchWxLoginCode()
const res = await wxPhoneLogin(code, loginCode)
if (res.code === 200) {
setUserSession(res.data.user)
setStoreSession(res.data.store)
@ -277,7 +292,8 @@ const onWxGetPhoneNumberBoss = async (e) => {
if (!code) { showToast('未获取到授权,请手动输入'); return }
loginLoading.value = true
try {
const res = await wxPhoneLogin(code)
const loginCode = await fetchWxLoginCode()
const res = await wxPhoneLogin(code, loginCode)
const phone = getWxLoginPhone(res)
if (phone) {
bossForm.phone = phone
@ -302,7 +318,8 @@ const onWxGetPhoneNumberStaff = async (e) => {
if (!code) { showToast('未获取到授权,请手动输入'); return }
loginLoading.value = true
try {
const res = await wxPhoneLogin(code)
const loginCode = await fetchWxLoginCode()
const res = await wxPhoneLogin(code, loginCode)
const phone = getWxLoginPhone(res)
if (phone) {
staffForm.phone = phone

View File

@ -37,16 +37,17 @@
</view>
<!-- 列表 -->
<view v-if="loading" class="state-wrap">
<view class="spinner"></view>
<text class="state-text">加载中</text>
</view>
<view v-else-if="list.length === 0" class="empty">
<text>{{ tab === 'pending' ? '暂无待回访,今天可以喘口气' : '还没有宠主留资' }}</text>
</view>
<view v-else class="list">
<AppPageState
class="leads-state"
:loading="loading"
:error="loadError"
:empty="!loading && !loadError && list.length === 0"
:empty-title="emptyTitle"
:empty-hint="emptyHint"
empty-emoji="📝"
@retry="loadList"
>
<view class="list">
<view
v-for="item in list"
:key="item.id"
@ -75,6 +76,14 @@
<text class="row-label">手机号</text>
<text class="row-value phone">{{ item.phone }}</text>
</view>
<view v-if="item.wechatOpenid" class="lead-row">
<text class="row-label">openid</text>
<text class="row-value phone">{{ item.wechatOpenid }}</text>
</view>
<view v-if="item.wechatUnionid" class="lead-row">
<text class="row-label">unionid</text>
<text class="row-value phone">{{ item.wechatUnionid }}</text>
</view>
<view class="lead-row">
<text class="row-label">留资时间</text>
<text class="row-value">{{ formatDateTime(item.createTime) }}</text>
@ -91,6 +100,7 @@
</view>
</view>
</view>
</AppPageState>
</view>
</template>
@ -98,6 +108,8 @@
import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import AppIcon from '../../components/AppIcon.vue'
import AppPageState from '../../components/AppPageState.vue'
import { messageFromApi, NETWORK_ERROR_MSG } from '../../utils/apiResult.js'
import { getReportLeads } from '../../api/index.js'
import { getStoreSession, getUserSession } from '../../utils/session.js'
import { formatDateTimeYMDHM } from '../../utils/datetime.js'
@ -107,7 +119,15 @@ const storeInfo = getStoreSession()
const tab = ref('pending')
const loading = ref(false)
const loadError = ref(null)
const list = ref([])
const emptyTitle = computed(() =>
tab.value === 'pending' ? '暂无待回访' : '还没有宠主留资'
)
const emptyHint = computed(() =>
tab.value === 'pending' ? '今天可以喘口气,或切换「全部留资」' : '宠主在报告页留资后将出现在这里'
)
const pendingCount = ref(0)
const navSafeStyle = (() => {
@ -129,6 +149,7 @@ const switchTab = (t) => {
const loadList = async () => {
if (!storeInfo.id) return
loadError.value = null
loading.value = true
try {
const res = await getReportLeads(storeInfo.id, tab.value)
@ -137,7 +158,11 @@ const loadList = async () => {
if (tab.value === 'pending') {
pendingCount.value = list.value.length
}
} else {
loadError.value = messageFromApi(res, '加载失败')
}
} catch (e) {
loadError.value = NETWORK_ERROR_MSG
} finally {
loading.value = false
}
@ -306,20 +331,6 @@ onShow(() => {
color: #16a34a;
}
.state-wrap { padding: 40px 16px; text-align: center; color: #94a3b8; font-size: 13px; }
.spinner {
width: 26px; height: 26px;
margin: 0 auto 8px;
border: 3px solid #e2e8f0;
border-top-color: #16a34a;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.state-text { font-size: 13px; color: #94a3b8; }
.empty { padding: 40px 16px; text-align: center; color: #94a3b8; font-size: 13px; }
.list { padding: 0 16px; }
.lead-card {

View File

@ -10,7 +10,7 @@
<view class="page-section orders-hero">
<view class="hero-title">服务进度一目了然</view>
<view v-if="userInfo.role === 'customer'" class="hero-sub">查看您的预约状态随时掌握服务进度</view>
<view v-else class="hero-sub">按状态查看订单确认可快速开始服务进行中可直接填写报告</view>
<view v-else class="hero-sub">按状态查看订单开始可点开始服务进行中可填写报告</view>
</view>
<!-- 状态 Tab -->
@ -25,6 +25,15 @@
<!-- 订单列表 -->
<view class="page-section section-gap">
<AppPageState
:loading="loading"
:error="loadError"
:empty="!loading && !loadError && filteredOrders.length === 0"
empty-title="暂无符合条件的订单"
empty-hint="切换上方状态或返回首页查看"
empty-emoji="📋"
@retry="fetchOrders"
>
<div v-for="item in filteredOrders" :key="item.id" class="order-item" @click="goAppointmentDetail(item)">
<div class="order-head">
<div class="order-title">{{ item.title }}</div>
@ -53,15 +62,16 @@
</div>
</div>
</div>
</AppPageState>
</view>
<view v-if="filteredOrders.length === 0" class="empty"><text>暂无数据</text></view>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import AppIcon from '../../components/AppIcon.vue'
import AppPageState from '../../components/AppPageState.vue'
import { messageFromApi, NETWORK_ERROR_MSG } from '../../utils/apiResult.js'
import { navigateTo } from '../../utils/globalState.js'
import { getAppointmentList, startAppointment, cancelAppointment } from '../../api/index.js'
import { getUserSession } from '../../utils/session.js'
@ -93,9 +103,11 @@ const navSafeStyle = (() => {
const currentStatus = ref('new')
const orders = ref([])
const loading = ref(true)
const loadError = ref(null)
const statusTabs = [
{ title: '待确认', name: 'new' },
{ title: '待开始', name: 'new' },
{ title: '进行中', name: 'doing' },
{ title: '已完成', name: 'done' }
]
@ -112,10 +124,16 @@ const statusTagBg = (status) => {
}
const fetchOrders = async () => {
if (!currentUserId) return
if (!currentUserId) {
loading.value = false
return
}
loadError.value = null
loading.value = true
try {
const res = await getAppointmentList(currentUserId, null, { page: 1, pageSize: 50 })
if (res.code === 200) {
orders.value = res.data.map(appt => ({
if (res && res.code === 200) {
orders.value = res.data.map((appt) => ({
id: appt.id,
title: appt.serviceType || '洗澡美容预约',
desc: `${appt.petType || ''} - ${appt.petName || ''}`,
@ -127,6 +145,13 @@ const fetchOrders = async () => {
serviceType: appt.serviceType,
appointmentTime: appt.appointmentTime
}))
} else {
loadError.value = messageFromApi(res, '加载失败')
}
} catch (e) {
loadError.value = NETWORK_ERROR_MSG
} finally {
loading.value = false
}
}

View File

@ -11,9 +11,16 @@
<text class="hero-sub">{{ heroSub }}</text>
</view>
<view v-if="loading" class="loading-hint"><text>加载中</text></view>
<view v-else class="pet-list">
<AppPageState
:loading="loading"
:error="loadError"
:empty="!loading && !loadError && petList.length === 0"
empty-title="暂无宠物档案"
empty-hint="点击下方 + 添加宠物信息"
empty-emoji="🐾"
@retry="loadPets"
>
<view class="pet-list">
<template v-for="p in petList" :key="p.id">
<view
v-if="isCustomer"
@ -37,6 +44,7 @@
<text class="pet-name">{{ p.name }}</text>
<text class="pet-meta">{{ p.petType }}{{ p.breed ? ' · ' + p.breed : '' }}</text>
<text v-if="p.remark" class="pet-remark">{{ p.remark }}</text>
<text class="pet-history-link" @click.stop="openHistory(p)">服务记录 </text>
</view>
</view>
</view>
@ -51,13 +59,13 @@
<text class="pet-name">{{ p.name }}</text>
<text class="pet-meta">{{ p.petType }}{{ p.breed ? ' · ' + p.breed : '' }}</text>
<text v-if="p.remark" class="pet-remark">{{ p.remark }}</text>
<text class="pet-history-link" @click.stop="openHistory(p)">服务记录 </text>
</view>
</view>
</view>
</template>
</view>
<view v-if="!loading && petList.length === 0" class="empty"><text>暂无宠物档案</text></view>
</AppPageState>
<view
v-if="isCustomer"
@ -117,6 +125,8 @@
import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import AppIcon from '../../components/AppIcon.vue'
import AppPageState from '../../components/AppPageState.vue'
import { messageFromApi, NETWORK_ERROR_MSG } from '../../utils/apiResult.js'
import {
getPetList,
createPet,
@ -149,6 +159,7 @@ const navSafeStyle = (() => {
})()
const loading = ref(true)
const loadError = ref(null)
const petList = ref([])
const swipedId = ref(null)
const touchStartX = ref(0)
@ -173,7 +184,9 @@ const petTypes = [
]
const loadPets = async () => {
loadError.value = null
loading.value = true
try {
let res
if (isCustomer.value) {
res = await getPetList({ ownerUserId: userInfo.id })
@ -185,8 +198,16 @@ const loadPets = async () => {
}
res = await getPetList({ storeId: storeInfo.id })
}
if (res && res.code === 200) {
petList.value = res.data || []
} else {
loadError.value = messageFromApi(res, '加载失败')
}
} catch (e) {
loadError.value = NETWORK_ERROR_MSG
} finally {
loading.value = false
if (res.code === 200) petList.value = res.data || []
}
}
const goBack = () => {
@ -195,6 +216,12 @@ const goBack = () => {
else navigateTo('mine')
}
const openHistory = (p) => {
if (!p?.id) return
const name = encodeURIComponent(p.name || '')
uni.navigateTo({ url: `/pages/mine/PetHistory?petId=${p.id}&name=${name}` })
}
const openCreate = () => {
editingId.value = null
form.value = { name: '', petType: '', breed: '', remark: '', avatar: '', avatarDisplay: '' }
@ -393,7 +420,6 @@ onShow(() => loadPets())
line-height: 1.45;
}
.loading-hint { text-align: center; padding: 24px; color: #94a3b8; font-size: 14px; }
.pet-list { padding: 0 16px 80px; }
/* 与服务类型 / 员工管理一致:左滑露出删除 */
@ -467,6 +493,13 @@ onShow(() => loadPets())
.pet-name { font-size: 16px; font-weight: 700; color: #1a1a1a; display: block; }
.pet-meta { font-size: 12px; color: #999993; display: block; margin-top: 4px; }
.pet-remark { font-size: 12px; color: #666660; display: block; margin-top: 6px; }
.pet-history-link {
display: block;
margin-top: 8px;
font-size: 13px;
font-weight: 600;
color: #2db96d;
}
.fab-pet {
position: fixed;

View File

@ -9,11 +9,20 @@
<view class="page-section reports-hero">
<view class="hero-title">服务成果回顾</view>
<view class="hero-sub">已生成 <text class="hero-count">{{ reportList.length }}</text> 份报告点击卡片可查看详情并分享</view>
<view class="hero-sub">已生成 <text class="hero-count">{{ loading ? '…' : reportList.length }}</text> 份报告点击卡片可查看详情并分享</view>
</view>
<!-- 网格相册 -->
<div v-if="reportList.length > 0" class="page-section section-gap gallery-grid">
<AppPageState
class="reports-state"
:loading="loading"
:error="loadError"
:empty="!loading && !loadError && reportList.length === 0"
empty-title="暂无报告"
empty-hint="完成服务并填写报告后将显示在这里"
empty-emoji="📄"
@retry="loadReports"
>
<div class="page-section section-gap gallery-grid">
<div
v-for="r in reportList"
:key="r.id"
@ -42,8 +51,7 @@
</div>
</div>
</div>
<view v-if="!loading && reportList.length === 0" class="empty"><text>暂无报告</text></view>
</AppPageState>
<!-- 仅商家/员工可新建报告客户仅查看列表 -->
<view
@ -63,6 +71,8 @@
import { ref, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import AppIcon from '../../components/AppIcon.vue'
import AppPageState from '../../components/AppPageState.vue'
import { messageFromApi, NETWORK_ERROR_MSG } from '../../utils/apiResult.js'
import TabBar from '../../components/TabBar.vue'
import { useNavigator } from '../../composables/useNavigator.js'
import { getReportList, imgUrl, API_ORIGIN } from '../../api/index.js'
@ -83,17 +93,28 @@ const navSafeStyle = (() => {
}
return `padding-top:${statusBarHeight}px;height:${navHeight}px;`
})()
const loading = ref(false)
const loading = ref(true)
const loadError = ref(null)
const reportList = ref([])
const loadReports = async () => {
loadError.value = null
loading.value = true
try {
const params = userInfo.role === 'boss'
? { storeId: storeInfo.id, page: 1, pageSize: 50 }
: { userId: userInfo.id, page: 1, pageSize: 50 }
const res = await getReportList(params)
if (res && res.code === 200) {
reportList.value = res.data || []
} else {
loadError.value = messageFromApi(res, '加载失败')
}
} catch (e) {
loadError.value = NETWORK_ERROR_MSG
} finally {
loading.value = false
if (res.code === 200) reportList.value = res.data
}
}
const openFillReport = () => {

View File

@ -0,0 +1,272 @@
<template>
<view class="page-shell pet-history-page">
<view class="ph-nav nav-gradient" :style="navSafeStyle">
<view class="nav-back" @click="goBack"><AppIcon name="back" :size="18" color="#ffffff" /></view>
<text class="nav-title">服务记录</text>
<view class="nav-placeholder"></view>
</view>
<AppPageState
:loading="loading"
:error="loadError"
:empty="!loading && !loadError && items.length === 0"
empty-title="暂无服务记录"
empty-hint="新建预约时选择该宠物,门店即可在此沉淀到店与报告;历史未关联宠物的订单不会显示。"
empty-emoji="📒"
@retry="loadHistory"
>
<view v-if="petName" class="ph-hero">
<text class="ph-pet-name">{{ petName }}</text>
<text class="ph-summary">{{ summaryLine }}</text>
</view>
<view class="ph-timeline">
<view v-for="(it, idx) in items" :key="it.appointmentId || idx" class="ph-item">
<view class="ph-dot-line">
<view class="ph-dot" />
<view v-if="idx < items.length - 1" class="ph-line" />
</view>
<view class="ph-card">
<view class="ph-card-head">
<text class="ph-time">{{ formatTime(it.appointmentTime) }}</text>
<view :class="['ph-tag', `ph-tag--${it.status}`]">{{ statusText(it.status) }}</view>
</view>
<text class="ph-svc">{{ it.serviceType || '服务' }}</text>
<text v-if="it.storeName" class="ph-store">{{ it.storeName }}</text>
<view v-if="it.reportToken" class="ph-actions">
<text class="ph-link" @click="openReport(it.reportToken)">查看报告 </text>
</view>
</view>
</view>
</view>
</AppPageState>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import AppIcon from '../../components/AppIcon.vue'
import AppPageState from '../../components/AppPageState.vue'
import { getPetHistory, API_ORIGIN } from '../../api/index.js'
import { getUserSession } from '../../utils/session.js'
import { formatDateTimeCN } from '../../utils/datetime.js'
import { getAppointmentStatusText } from '../../utils/appointment.js'
import { messageFromApi, NETWORK_ERROR_MSG } from '../../utils/apiResult.js'
const userInfo = getUserSession()
const petId = ref(null)
const loading = ref(true)
const loadError = ref(null)
const petName = ref('')
const summary = ref({ totalAppointments: 0, completedCount: 0, cancelledCount: 0 })
const items = ref([])
const navSafeStyle = (() => {
const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20
let navHeight = statusBarHeight + 44
const menuRect = uni.getMenuButtonBoundingClientRect?.()
if (menuRect && menuRect.top && menuRect.height) {
const verticalGap = Math.max(menuRect.top - statusBarHeight, 4)
navHeight = statusBarHeight + verticalGap * 2 + menuRect.height
}
return `padding-top:${statusBarHeight}px;height:${navHeight}px;`
})()
const summaryLine = computed(() => {
const s = summary.value
const t = s.totalAppointments || 0
const d = s.completedCount || 0
return `累计 ${t} 次预约 · 已完成 ${d}`
})
const statusText = (s) => getAppointmentStatusText(s)
const formatTime = (t) => {
if (!t) return '—'
return formatDateTimeCN(t)
}
const goBack = () => uni.navigateBack()
const loadHistory = async () => {
if (!petId.value || !userInfo?.id) {
loadError.value = '参数错误'
loading.value = false
return
}
loadError.value = null
loading.value = true
try {
const res = await getPetHistory(petId.value, userInfo.id, userInfo.role || 'customer')
if (res && res.code === 200 && res.data) {
const d = res.data
petName.value = d.pet?.name || petName.value || '爱宠'
summary.value = d.summary || summary.value
items.value = Array.isArray(d.items) ? d.items : []
} else {
loadError.value = messageFromApi(res, '加载失败')
}
} catch (e) {
loadError.value = NETWORK_ERROR_MSG
} finally {
loading.value = false
}
}
const openReport = (token) => {
if (!token) return
// #ifdef H5
const origin =
typeof window !== 'undefined' ? window.location.origin : API_ORIGIN.replace(/\/api$/, '')
window.location.href = `${origin}/report.html?token=${encodeURIComponent(token)}`
// #endif
// #ifndef H5
uni.navigateTo({ url: `/pages/report-view/reportView?token=${encodeURIComponent(token)}` })
// #endif
}
onLoad((options) => {
petId.value = options.petId ? Number(options.petId) : null
if (options.name) {
petName.value = decodeURIComponent(options.name)
}
loadHistory()
})
</script>
<style scoped>
.pet-history-page {
min-height: 100vh;
background: #fafaf8;
padding-bottom: env(safe-area-inset-bottom);
}
.ph-nav {
padding: 14px 16px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 10;
}
.nav-back {
width: 32px;
}
.nav-title {
font-size: 18px;
font-weight: 700;
color: #fff;
}
.nav-placeholder {
width: 32px;
}
.ph-hero {
padding: 16px 16px 8px;
}
.ph-pet-name {
display: block;
font-size: 22px;
font-weight: 800;
color: #1a1a1a;
}
.ph-summary {
display: block;
margin-top: 6px;
font-size: 13px;
color: #64748b;
}
.ph-timeline {
padding: 8px 16px 24px;
}
.ph-item {
display: flex;
gap: 12px;
}
.ph-dot-line {
width: 18px;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
}
.ph-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #2db96d;
margin-top: 8px;
}
.ph-line {
flex: 1;
width: 2px;
margin-top: 4px;
background: linear-gradient(180deg, #bbf7d0, #e8edf4);
min-height: 24px;
}
.ph-card {
flex: 1;
background: #fff;
border-radius: 14px;
padding: 12px 14px;
margin-bottom: 10px;
border: 1px solid #e8edf4;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
}
.ph-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.ph-time {
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
.ph-tag {
font-size: 11px;
font-weight: 700;
padding: 2px 8px;
border-radius: 6px;
}
.ph-tag--new {
background: #fef5ec;
color: #f5913e;
}
.ph-tag--doing {
background: #ecfdf5;
color: #16a34a;
}
.ph-tag--done {
background: #f1f5f9;
color: #64748b;
}
.ph-tag--cancel {
background: #f1f5f9;
color: #94a3b8;
}
.ph-svc {
display: block;
margin-top: 6px;
font-size: 15px;
font-weight: 600;
color: #334155;
}
.ph-store {
display: block;
margin-top: 4px;
font-size: 12px;
color: #94a3b8;
}
.ph-actions {
margin-top: 10px;
}
.ph-link {
font-size: 14px;
font-weight: 600;
color: #2db96d;
}
</style>

View File

@ -1,94 +1,88 @@
<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">
<!-- 顶部品牌 -->
<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">
<view class="brand-bar-inner brand-bar-inner--row">
<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 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="hero-stage">
<view class="hero-photos">
<view class="hero-before">
<!-- 技师情感卡片 H5 对齐 -->
<view v-if="reportData.staffName || staffAvatarDisplay" class="staff-card-mp">
<image
v-if="beforePhotos.length > 0"
:src="imgUrl(beforePhotos[0])"
class="hero-img"
v-if="staffAvatarDisplay"
class="staff-avatar-mp"
:src="staffAvatarDisplay"
mode="aspectFill"
@click="previewPhotos('before', 0)"
/>
<view v-else class="hero-empty">
<text class="hero-empty-text">暂无</text>
<view v-else class="staff-avatar-mp staff-avatar-mp--ph">
<text class="staff-avatar-letter-mp">{{ staffInitial }}</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 class="staff-copy-mp">
<text class="staff-sign-mp">本次由 {{ reportData.staffName || '技师' }} 为您服务</text>
<text class="staff-subline-mp">感谢信任愿毛孩子健康可爱每一天</text>
</view>
</view>
<view class="hero-meta">
<view class="hero-meta-chip hero-meta-chip-before">
<text>服务前 {{ beforePhotos.length }} </text>
<!-- 服务信息不含技师行 -->
<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="hero-meta-chip hero-meta-chip-after">
<text>服务后 {{ afterPhotos.length }} </text>
<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 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 class="compare-block">
<text class="compare-h2">服务前后对比</text>
<BeforeAfterSlider :before-src="beforeMainUrl" :after-src="afterMainUrl" />
<view
v-if="beforePhotos.length > 0"
class="hero-action-chip hero-action-chip-before"
@click="previewPhotos('before', 0)"
v-if="beforePhotos.length > 1 || afterPhotos.length > 1"
class="compare-more-hint"
>
<text>查看服务前全部照片</text>
<text>滑动对比主图更多张数见下方补充相册</text>
</view>
<view
v-if="afterPhotos.length > 0"
class="hero-action-chip hero-action-chip-after"
@click="previewPhotos('after', 0)"
>
<text>查看服务后全部照片</text>
</view>
<!-- 过程素材九宫格 H5 对齐 -->
<view v-if="duringListForGrid.length" class="during-grid-wrap">
<DuringMediaGrid :items="duringListForGrid" />
</view>
<!-- 补充照片前后分组不混排 -->
@ -142,53 +136,6 @@
</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">
@ -196,27 +143,6 @@
</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>
@ -230,7 +156,7 @@
<text class="highlight-badge">智能剪辑</text>
</view>
<text class="highlight-desc">
服务前 过程 服务后顺序将照片与短视频自动合成竖屏 9:16 成片15 秒或 30 门店服务器需已安装 ffmpeg若需大模型生成式成片可在后端接入对应云 API
服务前 过程 服务后整理素材再将照片与小视频穿插编排类相册成片图片带轻微推拉镜头竖屏 9:16可选 BGM总时长 15 秒或 30 服务器需 ffmpeg建议安装 ffprobe配乐请将商用授权文件配置在后端 HIGHLIGHT_BGM_PATH
</text>
<view v-if="highlightStatus === 'processing'" class="highlight-status highlight-status--busy">
<text>短片生成中请稍候</text>
@ -294,9 +220,10 @@
<text class="footer-sub">宠物服务 · 让爱更专业</text>
</view>
</view>
</AppPageState>
<TestimonialModal
v-if="isStaff"
v-if="isStaff && reportData"
v-model="posterModalOpen"
:pet-name="reportData?.petName"
@skip="onTestimonialSkip"
@ -319,10 +246,15 @@ import { getReportByToken, imgUrl, postReportHighlightStart, submitReportTestimo
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 { getUserSession, isLoggedIn } from '../../utils/session.js'
const loading = ref(true)
const loadError = ref(null)
const notFound = ref(false)
const reportData = ref(null)
const posterCanvas = ref(null)
@ -446,6 +378,33 @@ const afterPhotoUrls = computed(() => afterPhotos.value.map((item) => imgUrl(ite
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(() => {
const r = reportData.value
if (!r) return '洗护前后对比'
@ -468,21 +427,33 @@ const brandBarSafe = (() => {
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
}
uni.showLoading({ title: '加载中' })
loading.value = true
try {
const res = await getReportByToken(token)
uni.hideLoading()
loading.value = false
if (res.code === 200) {
if (res && res.code === 200 && res.data) {
reportData.value = res.data
maybeStartHighlightPoll()
} else {
} 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 = NETWORK_ERROR_MSG
} finally {
loading.value = false
}
}
@ -534,13 +505,6 @@ const previewPhotos = (group, index = 0) => {
})
}
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 {
@ -840,6 +804,8 @@ onUnmounted(() => stopHighlightPoll())
</script>
<style scoped>
/* 小程序 WXSS 禁止 @import 外链字体手写感用系统楷体栈H5 可在页面级引入马善政体 */
.report-view {
background: #fafaf8;
min-height: 100vh;
@ -853,25 +819,7 @@ onUnmounted(() => stopHighlightPoll())
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-top-state { min-height: 48vh; }
/* 内容 */
.report-content {
@ -881,7 +829,7 @@ onUnmounted(() => stopHighlightPoll())
min-height: 100vh;
}
/* 品牌栏 */
/* 品牌栏(含 Logo与 H5 对齐) */
.brand-bar {
background: #2db96d;
padding: 12px 16px 14px;
@ -893,6 +841,9 @@ onUnmounted(() => stopHighlightPoll())
align-items: center;
justify-content: space-between;
}
.brand-bar-inner--row {
align-items: center;
}
.brand-home {
width: 32px; height: 32px;
border-radius: 10px;
@ -900,178 +851,131 @@ onUnmounted(() => stopHighlightPoll())
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.brand-name {
.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;
}
/* 主视觉:照片对比 */
.hero-stage {
position: relative;
/* 技师卡片 */
.staff-card-mp {
margin: 12px 16px 0;
border-radius: 18px;
overflow: hidden;
background: #f5f5f2;
padding: 14px 14px;
display: flex;
align-items: center;
gap: 12px;
background: linear-gradient(120deg, #fffbeb 0%, #ffffff 55%, #f0fdf4 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, #86efac, #22c55e);
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);
}
.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%;
.compare-h2 {
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-size: 15px;
font-weight: 700;
z-index: 2;
color: #1a1a1a;
margin-bottom: 10px;
}
.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;
.compare-more-hint {
margin-top: 10px;
text-align: center;
}
.hero-tip text {
font-size: 12px;
.compare-more-hint text {
font-size: 11px;
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;
.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;
@ -1152,7 +1056,7 @@ onUnmounted(() => stopHighlightPoll())
/* 信息卡 */
.info-card {
margin: 16px;
margin: 12px 16px 0;
padding: 20px;
background: #fafaf8;
border-radius: 14px;
@ -1324,54 +1228,4 @@ onUnmounted(() => stopHighlightPoll())
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>

11
src/utils/apiResult.js Normal file
View File

@ -0,0 +1,11 @@
/**
* 统一从接口响应中取错误文案含网络失败时 res 为空
*/
export function messageFromApi(res, fallback = '请求失败') {
if (res == null || typeof res !== 'object') return fallback
const m = res.message
if (typeof m === 'string' && m.trim()) return m.trim()
return fallback
}
export const NETWORK_ERROR_MSG = '网络异常,请稍后再试'

View File

@ -1,5 +1,5 @@
export const APPOINTMENT_STATUS_TEXT = {
new: '待确认',
new: '待开始',
doing: '服务中',
done: '已完成',
cancel: '已取消'

View File

@ -1,18 +1,17 @@
<template>
<div class="report-view">
<!-- 加载中 -->
<div v-if="loading" class="loading-wrap">
<view class="loading-spinner"></view>
<span>加载报告中...</span>
</div>
<!-- 未找到 -->
<div v-else-if="notFound" class="not-found">
<view class="empty"><text>报告不存在或链接已失效</text></view>
</div>
<!-- 报告内容 -->
<div v-else-if="reportData" class="report-content">
<AppPageState
class="report-h5-state"
:loading="loading"
:error="loadError"
:empty="!loading && !loadError && notFound"
empty-title="报告不存在或链接已失效"
empty-hint="请向门店索取最新分享链接"
empty-emoji="🐾"
:show-retry="!!loadError"
@retry="loadReport"
>
<div v-if="reportData" class="report-content">
<!-- 品牌头部 -->
<div class="brand-header" :style="brandHeaderSafeStyle">
<div class="header-actions">
@ -21,8 +20,18 @@
</view>
<view class="header-placeholder"></view>
</div>
<div class="brand-row" :class="{ 'brand-row--solo': !storeLogoUrl }">
<image
v-if="storeLogoUrl"
class="brand-logo-img"
:src="storeLogoUrl"
mode="aspectFit"
/>
<div class="brand-text">
<div class="brand-logo">{{ reportData.store?.name || '宠伴生活馆' }}</div>
<div class="brand-sub">宠物服务让爱更专业</div>
</div>
</div>
<div class="brand-contact">
<span v-if="reportData.store?.phone">电话{{ reportData.store.phone }}</span>
<span v-if="reportData.store?.address">地址{{ reportData.store.address }}</span>
@ -35,7 +44,24 @@
<div class="report-time">{{ formatTime(reportData.appointmentTime) }}</div>
</div>
<!-- 服务信息 -->
<!-- 技师情感化卡片 -->
<div v-if="reportData.staffName || staffAvatarDisplay" class="staff-card section-card">
<image
v-if="staffAvatarDisplay"
class="staff-avatar"
:src="staffAvatarDisplay"
mode="aspectFill"
/>
<view v-else class="staff-avatar staff-avatar--ph">
<text class="staff-avatar-letter">{{ staffInitial }}</text>
</view>
<view class="staff-copy">
<text class="staff-sign">本次由 {{ reportData.staffName || '技师' }} 为您服务</text>
<text class="staff-subline">感谢信任愿毛孩子健康可爱每一天</text>
</view>
</div>
<!-- 服务信息不含技师行已在上方卡片展示 -->
<view class="van-cell-group service-info">
<view class="van-cell">
<view class="van-cell__title">宠物名字</view>
@ -49,31 +75,17 @@
<view class="van-cell__title">服务时间</view>
<view class="van-cell__value">{{ formatTime(reportData.appointmentTime) }}</view>
</view>
<view class="van-cell">
<view class="van-cell__title">服务技师</view>
<view class="van-cell__value">{{ reportData.staffName || '-' }}</view>
</view>
</view>
<!-- 照片对比 -->
<div class="photo-section section-card">
<!-- 前后对比滑块 -->
<div class="photo-section section-card section-card--hero">
<div class="section-label">服务前后对比</div>
<div class="photo-grid">
<image
v-if="reportData.beforePhoto"
:src="imgUrl(reportData.beforePhoto)"
class="photo-image"
mode="aspectFill"
/>
<view v-else class="photo-empty">暂无照片</view>
<image
v-if="reportData.afterPhoto"
:src="imgUrl(reportData.afterPhoto)"
class="photo-image"
mode="aspectFill"
/>
<view v-else class="photo-empty">暂无照片</view>
<BeforeAfterSlider :before-src="beforeMainUrl" :after-src="afterMainUrl" />
</div>
<!-- 过程素材九宫格 -->
<div v-if="duringList.length" class="section-card section-card--during">
<DuringMediaGrid :items="duringList" />
</div>
<!-- 备注 -->
@ -95,6 +107,7 @@
<button class="van-button van-button--primary van-button--round van-button--block" @click="openPosterModal">生成图片分享朋友圈</button>
</div>
</div>
</AppPageState>
<!-- #ifdef H5 -->
<TestimonialModal
@ -105,7 +118,6 @@
/>
<!-- #endif -->
<!-- Canvas海报隐藏 H5 -->
<!-- #ifdef H5 -->
<canvas ref="posterCanvas" style="position:fixed;top:-9999px;left:-9999px;" />
<!-- #endif -->
@ -113,19 +125,24 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getReportByToken, imgUrl, submitReportTestimonial } from '../api/index.js'
import { drawReportPoster, POSTER_W, computePosterLogicalHeight } from '../utils/reportPosterDraw.js'
import { formatDateTimeYMDHM } from '../utils/datetime.js'
import AppIcon from '../components/AppIcon.vue'
import AppPageState from '../components/AppPageState.vue'
import { messageFromApi, NETWORK_ERROR_MSG } from '../utils/apiResult.js'
import ReminderCard from '../components/report/ReminderCard.vue'
import BeforeAfterSlider from '../components/report/BeforeAfterSlider.vue'
import DuringMediaGrid from '../components/report/DuringMediaGrid.vue'
// #ifdef H5
import TestimonialModal from '../components/report/TestimonialModal.vue'
// #endif
import { navigateTo } from '../utils/globalState.js'
const loading = ref(true)
const loadError = ref(null)
const notFound = ref(false)
const reportData = ref(null)
const posterCanvas = ref(null)
@ -138,21 +155,84 @@ const brandHeaderSafeStyle = (() => {
const formatTime = (time) => formatDateTimeYMDHM(time)
function firstMediaUrl(photos, legacy) {
if (photos && photos.length) {
const x = photos[0]
return typeof x === 'string' ? x : x?.url
}
return legacy || ''
}
const beforeMainUrl = computed(() => {
const r = reportData.value
if (!r) return ''
const raw = firstMediaUrl(r.beforePhotos, r.beforePhoto)
return raw ? imgUrl(raw) : ''
})
const afterMainUrl = computed(() => {
const r = reportData.value
if (!r) return ''
const raw = firstMediaUrl(r.afterPhotos, r.afterPhoto)
return raw ? imgUrl(raw) : ''
})
const duringList = computed(() => {
const r = reportData.value
if (!r?.duringMedia?.length) return []
return r.duringMedia.map((m) => ({
url: imgUrl(m.url || m),
mediaType: m.mediaType === 'video' ? 'video' : 'photo'
}))
})
const staffAvatarDisplay = computed(() => {
const r = reportData.value
if (!r?.staffAvatar) return ''
return imgUrl(r.staffAvatar)
})
const staffInitial = computed(() => {
const n = (reportData.value?.staffName || '?').trim()
return n.slice(0, 1)
})
const storeLogoUrl = computed(() => {
const logo = reportData.value?.store?.logo
return logo ? imgUrl(logo) : ''
})
const loadReport = async () => {
loadError.value = null
notFound.value = false
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 {
if (!token) {
notFound.value = true
reportData.value = null
loading.value = false
return
}
loading.value = true
try {
const res = await getReportByToken(token)
if (res && res.code === 200 && res.data) {
reportData.value = res.data
} 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 = NETWORK_ERROR_MSG
} finally {
loading.value = false
}
}
@ -213,9 +293,11 @@ const runGeneratePoster = async (testimonial) => {
beforeUrl ? loadImage(beforeUrl) : Promise.resolve(null),
afterUrl ? loadImage(afterUrl) : Promise.resolve(null)
])
const dpr = Math.min(window.devicePixelRatio || 1, 3)
const posterH = computePosterLogicalHeight(false, testimonial || '')
canvas.width = POSTER_W
canvas.height = posterH
canvas.width = POSTER_W * dpr
canvas.height = posterH * dpr
ctx.scale(dpr, dpr)
drawReportPoster(ctx, {
storeName: r.store?.name || '',
petName: r.petName || '',
@ -246,20 +328,10 @@ onMounted(() => loadReport())
</script>
<style scoped>
/* 手写标题H5 由 report.html 引入马善政体后,可匹配 "Ma Shan Zheng";此处栈兼容无网络字体场景 */
.report-view { background: #f5f7fb; min-height: 100vh; }
.loading-wrap {
display: flex; flex-direction: column; align-items: center; justify-content: center;
min-height: 100vh; gap: 16px; color: #999;
}
.loading-spinner {
width: 32px; height: 32px;
border: 3px solid #e0e0e0;
border-top-color: #07c160;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.not-found { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; }
.report-h5-state { min-height: 50vh; }
.report-content { max-width: 430px; margin: 0 auto; background: #f8fafc; min-height: 100vh; box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06); }
.brand-header {
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
@ -289,12 +361,79 @@ onMounted(() => loadReport())
width: 30px;
height: 30px;
}
.brand-row {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 8px;
}
.brand-row--solo .brand-text {
text-align: center;
}
.brand-logo-img {
width: 48px;
height: 48px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.95);
border: 2px solid rgba(255, 255, 255, 0.5);
flex-shrink: 0;
}
.brand-text { text-align: left; min-width: 0; }
.brand-logo { font-size: 20px; font-weight: 700; letter-spacing: 1px; margin-bottom: 4px; }
.brand-sub { font-size: 12px; opacity: 0.7; margin-bottom: 12px; }
.brand-sub { font-size: 12px; opacity: 0.7; }
.brand-contact { font-size: 12px; opacity: 0.85; display: flex; justify-content: center; gap: 16px; flex-wrap: wrap; }
.report-title-wrap { text-align: center; padding: 20px 20px 14px; }
.report-title { font-size: 22px; font-weight: 700; color: #333; }
.report-time { font-size: 13px; color: #999; margin-top: 6px; }
.staff-card {
display: flex;
align-items: center;
gap: 14px;
padding: 16px 14px;
background: linear-gradient(120deg, #fffbeb 0%, #ffffff 55%, #f0fdf4 100%);
border: 1px solid #fde68a;
}
.staff-avatar {
width: 56px;
height: 56px;
border-radius: 50%;
flex-shrink: 0;
border: 3px solid rgba(22, 163, 74, 0.35);
box-shadow: 0 4px 14px rgba(22, 163, 74, 0.15);
}
.staff-avatar--ph {
background: linear-gradient(145deg, #86efac, #22c55e);
display: flex;
align-items: center;
justify-content: center;
}
.staff-avatar-letter {
font-size: 22px;
font-weight: 800;
color: #fff;
}
.staff-copy {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.staff-sign {
font-family: "Ma Shan Zheng", "KaiTi", "STKaiti", "Songti SC", "PingFang SC", serif;
font-size: 23px;
color: #422006;
line-height: 1.35;
letter-spacing: 0.5px;
}
.staff-subline {
font-size: 12px;
color: #78716c;
line-height: 1.4;
}
.service-info { margin: 0 16px 12px; border-radius: 14px !important; }
.section-card {
margin: 0 16px 12px;
@ -304,22 +443,13 @@ onMounted(() => loadReport())
padding: 12px;
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.04);
}
.section-card--hero {
padding-bottom: 14px;
}
.section-card--during {
padding: 14px 12px 12px;
}
.section-label { font-size: 15px; font-weight: 700; color: #1f2937; margin-bottom: 10px; }
.photo-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.photo-image {
width: 100%;
aspect-ratio: 1 / 1;
border-radius: 12px;
border: 1px solid #e5e7eb;
}
.photo-empty {
width: 100%;
aspect-ratio: 1 / 1;
background: #f1f5f9;
border: 1px dashed #d1d9e6;
border-radius: 12px;
display: flex; align-items: center; justify-content: center; color: #94a3b8; font-size: 13px;
}
.remark-content { background: #f8fafc; border: 1px solid #e8edf4; border-radius: 12px; padding: 14px; font-size: 14px; color: #64748b; line-height: 1.6; min-height: 60px; }
.action-section { margin: 0 16px 24px; }
</style>