fix: H5海报Canvas按devicePixelRatio缩放
This commit is contained in:
parent
d93ed027c7
commit
582092f467
@ -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 | 个人中心,老板有额外菜单 |
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 的 code(loginCode)一并提交,服务端换手机号并沉淀 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 })
|
||||
// 报告页:宠主留资(下次提醒);小程序可传 loginCode(uni.login)沉淀 openid/unionid
|
||||
export const submitReportReminder = (token, phone, loginCode) =>
|
||||
post(`/report/${encodeURIComponent(token)}/reminder`, {
|
||||
phone,
|
||||
consent: true,
|
||||
loginCode: loginCode || ''
|
||||
})
|
||||
|
||||
// 报告页:一键退订
|
||||
export const unsubscribeReportReminder = (unsubscribeToken) =>
|
||||
|
||||
146
src/components/AppPageState.vue
Normal file
146
src/components/AppPageState.vue
Normal 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>
|
||||
243
src/components/report/BeforeAfterSlider.vue
Normal file
243
src/components/report/BeforeAfterSlider.vue
Normal 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>
|
||||
194
src/components/report/DuringMediaGrid.vue
Normal file
194
src/components/report/DuringMediaGrid.vue
Normal 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>
|
||||
@ -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
|
||||
|
||||
@ -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"},
|
||||
|
||||
@ -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">
|
||||
@ -41,9 +50,8 @@
|
||||
<text v-if="detail.id">预约编号 {{ detail.id }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="page-section empty"><text>未找到该预约</text></view>
|
||||
</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
|
||||
const res = await getAppointmentDetail(Number(id))
|
||||
loading.value = false
|
||||
if (res.code === 200 && res.data) {
|
||||
detail.value = res.data
|
||||
} else {
|
||||
try {
|
||||
const res = await getAppointmentDetail(Number(id))
|
||||
if (res && res.code === 200 && res.data) {
|
||||
detail.value = res.data
|
||||
} else {
|
||||
detail.value = null
|
||||
if (res && res.code === 404) {
|
||||
loadError.value = null
|
||||
} else {
|
||||
loadError.value = messageFromApi(res, '加载失败')
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
detail.value = null
|
||||
uni.showToast({ title: res.message || '加载失败', icon: 'none' })
|
||||
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>
|
||||
|
||||
|
||||
@ -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,27 +55,36 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="order-list">
|
||||
<view
|
||||
v-for="item in filteredOrders"
|
||||
: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>
|
||||
<text class="order-svc">{{ item.serviceType }}</text>
|
||||
<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 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>
|
||||
<text class="order-svc">{{ item.serviceType }}</text>
|
||||
</view>
|
||||
<view :class="['order-status', `status-${item.status}`]">{{ item.statusText }}</view>
|
||||
</view>
|
||||
<view class="order-time">{{ item.time }}</view>
|
||||
<view v-if="item.status === 'new'" class="order-actions">
|
||||
<button class="app-btn-sm app-btn-outline" @click.stop="cancelService(item)">取消预约</button>
|
||||
</view>
|
||||
<view :class="['order-status', `status-${item.status}`]">{{ item.statusText }}</view>
|
||||
</view>
|
||||
<view class="order-time">{{ item.time }}</view>
|
||||
<view v-if="item.status === 'new'" class="order-actions">
|
||||
<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,28 +143,37 @@
|
||||
</view>
|
||||
|
||||
<!-- 列表 -->
|
||||
<view class="order-list">
|
||||
<view v-for="item in filteredOrders" :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>
|
||||
<text class="order-svc">{{ item.serviceType }}</text>
|
||||
<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 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>
|
||||
<text class="order-svc">{{ item.serviceType }}</text>
|
||||
</view>
|
||||
<view :class="['order-status', `status-${item.status}`]">{{ item.statusText }}</view>
|
||||
</view>
|
||||
<view :class="['order-status', `status-${item.status}`]">{{ item.statusText }}</view>
|
||||
</view>
|
||||
<view class="order-time">{{ item.time }}</view>
|
||||
<view class="order-actions">
|
||||
<view v-if="item.status === 'new'" class="order-btns">
|
||||
<button class="app-btn-sm app-btn-primary" @click.stop="startService(item)">开始服务</button>
|
||||
<button class="app-btn-sm app-btn-outline" @click.stop="cancelService(item)">取消</button>
|
||||
</view>
|
||||
<view v-else-if="item.status === 'doing'" class="order-btns">
|
||||
<button class="app-btn-sm app-btn-primary" @click.stop="goReport(item)">填写报告</button>
|
||||
<view class="order-time">{{ item.time }}</view>
|
||||
<view class="order-actions">
|
||||
<view v-if="item.status === 'new'" class="order-btns">
|
||||
<button class="app-btn-sm app-btn-primary" @click.stop="startService(item)">开始服务</button>
|
||||
<button class="app-btn-sm app-btn-outline" @click.stop="cancelService(item)">取消</button>
|
||||
</view>
|
||||
<view v-else-if="item.status === 'doing'" class="order-btns">
|
||||
<button class="app-btn-sm app-btn-primary" @click.stop="goReport(item)">填写报告</button>
|
||||
</view>
|
||||
</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,47 @@ const onStaffApptTimeChange = (e) => {
|
||||
}
|
||||
|
||||
const fetchAppointments = async () => {
|
||||
appointmentsError.value = null
|
||||
if (userInfo.role === 'customer') {
|
||||
if (!userInfo.id) return
|
||||
const res = await getAppointmentList(userInfo.id)
|
||||
if (res.code === 200) {
|
||||
orders.value = res.data.map(appt => ({
|
||||
if (!userInfo.id) {
|
||||
appointmentsLoading.value = false
|
||||
return
|
||||
}
|
||||
appointmentsLoading.value = true
|
||||
try {
|
||||
const res = await getAppointmentList(userInfo.id)
|
||||
if (res && res.code === 200) {
|
||||
orders.value = res.data.map((appt) => ({
|
||||
id: appt.id,
|
||||
title: appt.serviceType || '洗澡美容预约',
|
||||
desc: `${appt.petType || ''} - ${appt.petName || ''}`,
|
||||
time: formatDateTimeCN(appt.appointmentTime),
|
||||
status: appt.status || 'new',
|
||||
statusText: getAppointmentStatusText(appt.status),
|
||||
petName: appt.petName,
|
||||
petType: appt.petType,
|
||||
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) {
|
||||
appointmentsLoading.value = false
|
||||
return
|
||||
}
|
||||
appointmentsLoading.value = true
|
||||
try {
|
||||
const res = await getAppointmentList(null, storeInfo.id, { page: 1, pageSize: 50 })
|
||||
if (res && res.code === 200) {
|
||||
orders.value = res.data.map((appt) => ({
|
||||
id: appt.id,
|
||||
title: appt.serviceType || '洗澡美容预约',
|
||||
desc: `${appt.petType || ''} - ${appt.petName || ''}`,
|
||||
@ -353,26 +569,13 @@ const fetchAppointments = async () => {
|
||||
serviceType: appt.serviceType,
|
||||
appointmentTime: appt.appointmentTime
|
||||
}))
|
||||
} else {
|
||||
appointmentsError.value = messageFromApi(res, '加载失败')
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!storeInfo.id) return
|
||||
const res = await getAppointmentList(null, storeInfo.id, { page: 1, pageSize: 50 })
|
||||
if (res.code === 200) {
|
||||
orders.value = res.data.map(appt => ({
|
||||
id: appt.id,
|
||||
title: appt.serviceType || '洗澡美容预约',
|
||||
desc: `${appt.petType || ''} - ${appt.petName || ''}`,
|
||||
time: formatDateTimeCN(appt.appointmentTime),
|
||||
status: appt.status || 'new',
|
||||
statusText: getAppointmentStatusText(appt.status),
|
||||
petName: appt.petName,
|
||||
petType: appt.petType,
|
||||
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' })
|
||||
} 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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
@ -90,7 +99,8 @@
|
||||
</button>
|
||||
</view>
|
||||
</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 {
|
||||
|
||||
@ -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,43 +25,53 @@
|
||||
|
||||
<!-- 订单列表 -->
|
||||
<view class="page-section section-gap">
|
||||
<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>
|
||||
<view :class="`van-tag van-tag--${statusTagBg(item.status)}`">{{ item.statusText }}</view>
|
||||
</div>
|
||||
<div class="order-desc">
|
||||
<span class="desc-icon"><AppIcon name="profile" :size="12" /></span>
|
||||
<span>{{ item.desc }}</span>
|
||||
</div>
|
||||
<div class="order-footer">
|
||||
<span class="order-time">
|
||||
<AppIcon name="orders" :size="12" color="#94a3b8" />
|
||||
<text>{{ item.time }}</text>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="userInfo.role !== 'customer'">
|
||||
<div v-if="item.status === 'new'" class="action-btns">
|
||||
<button class="van-button van-button--small van-button--primary" @click.stop="startService(item)">开始服务</button>
|
||||
<button class="van-button van-button--small" @click.stop="cancelService(item)">取消</button>
|
||||
<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>
|
||||
<view :class="`van-tag van-tag--${statusTagBg(item.status)}`">{{ item.statusText }}</view>
|
||||
</div>
|
||||
<button v-else-if="item.status === 'doing'" class="van-button van-button--small btn-mt" @click.stop="goReport(item)">填写报告</button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="item.status === 'new'" class="action-btns">
|
||||
<button class="van-button van-button--small" @click.stop="cancelService(item)">取消预约</button>
|
||||
<div class="order-desc">
|
||||
<span class="desc-icon"><AppIcon name="profile" :size="12" /></span>
|
||||
<span>{{ item.desc }}</span>
|
||||
</div>
|
||||
<div class="order-footer">
|
||||
<span class="order-time">
|
||||
<AppIcon name="orders" :size="12" color="#94a3b8" />
|
||||
<text>{{ item.time }}</text>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="userInfo.role !== 'customer'">
|
||||
<div v-if="item.status === 'new'" class="action-btns">
|
||||
<button class="van-button van-button--small van-button--primary" @click.stop="startService(item)">开始服务</button>
|
||||
<button class="van-button van-button--small" @click.stop="cancelService(item)">取消</button>
|
||||
</div>
|
||||
<button v-else-if="item.status === 'doing'" class="van-button van-button--small btn-mt" @click.stop="goReport(item)">填写报告</button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="item.status === 'new'" class="action-btns">
|
||||
<button class="van-button van-button--small" @click.stop="cancelService(item)">取消预约</button>
|
||||
</div>
|
||||
</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,21 +124,34 @@ const statusTagBg = (status) => {
|
||||
}
|
||||
|
||||
const fetchOrders = async () => {
|
||||
if (!currentUserId) return
|
||||
const res = await getAppointmentList(currentUserId, null, { page: 1, pageSize: 50 })
|
||||
if (res.code === 200) {
|
||||
orders.value = res.data.map(appt => ({
|
||||
id: appt.id,
|
||||
title: appt.serviceType || '洗澡美容预约',
|
||||
desc: `${appt.petType || ''} - ${appt.petName || ''}`,
|
||||
time: formatDateTimeCN(appt.appointmentTime),
|
||||
status: appt.status || 'new',
|
||||
statusText: getAppointmentStatusText(appt.status),
|
||||
petName: appt.petName,
|
||||
petType: appt.petType,
|
||||
serviceType: appt.serviceType,
|
||||
appointmentTime: appt.appointmentTime
|
||||
}))
|
||||
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 && res.code === 200) {
|
||||
orders.value = res.data.map((appt) => ({
|
||||
id: appt.id,
|
||||
title: appt.serviceType || '洗澡美容预约',
|
||||
desc: `${appt.petType || ''} - ${appt.petName || ''}`,
|
||||
time: formatDateTimeCN(appt.appointmentTime),
|
||||
status: appt.status || 'new',
|
||||
statusText: getAppointmentStatusText(appt.status),
|
||||
petName: appt.petName,
|
||||
petType: appt.petType,
|
||||
serviceType: appt.serviceType,
|
||||
appointmentTime: appt.appointmentTime
|
||||
}))
|
||||
} else {
|
||||
loadError.value = messageFromApi(res, '加载失败')
|
||||
}
|
||||
} catch (e) {
|
||||
loadError.value = NETWORK_ERROR_MSG
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
</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,20 +184,30 @@ const petTypes = [
|
||||
]
|
||||
|
||||
const loadPets = async () => {
|
||||
loadError.value = null
|
||||
loading.value = true
|
||||
let res
|
||||
if (isCustomer.value) {
|
||||
res = await getPetList({ ownerUserId: userInfo.id })
|
||||
} else {
|
||||
if (!storeInfo?.id) {
|
||||
petList.value = []
|
||||
loading.value = false
|
||||
return
|
||||
try {
|
||||
let res
|
||||
if (isCustomer.value) {
|
||||
res = await getPetList({ ownerUserId: userInfo.id })
|
||||
} else {
|
||||
if (!storeInfo?.id) {
|
||||
petList.value = []
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
res = await getPetList({ storeId: storeInfo.id })
|
||||
}
|
||||
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
|
||||
}
|
||||
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;
|
||||
|
||||
@ -9,41 +9,49 @@
|
||||
|
||||
<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">
|
||||
<div
|
||||
v-for="r in reportList"
|
||||
:key="r.id"
|
||||
class="gallery-item"
|
||||
@click="viewReport(r)"
|
||||
>
|
||||
<div class="gallery-cover">
|
||||
<img
|
||||
v-if="r.beforePhotos && r.beforePhotos.length > 0"
|
||||
:src="imgUrl(r.beforePhotos[0])"
|
||||
class="cover-img"
|
||||
/>
|
||||
<div v-else class="cover-placeholder">
|
||||
<span class="placeholder-icon"><AppIcon name="camera" :size="18" color="#94a3b8" /></span>
|
||||
<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"
|
||||
class="gallery-item"
|
||||
@click="viewReport(r)"
|
||||
>
|
||||
<div class="gallery-cover">
|
||||
<img
|
||||
v-if="r.beforePhotos && r.beforePhotos.length > 0"
|
||||
:src="imgUrl(r.beforePhotos[0])"
|
||||
class="cover-img"
|
||||
/>
|
||||
<div v-else class="cover-placeholder">
|
||||
<span class="placeholder-icon"><AppIcon name="camera" :size="18" color="#94a3b8" /></span>
|
||||
</div>
|
||||
<div class="gallery-overlay">
|
||||
<div class="overlay-name">{{ r.petName }}</div>
|
||||
<div class="overlay-service">{{ r.serviceType }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gallery-overlay">
|
||||
<div class="overlay-name">{{ r.petName }}</div>
|
||||
<div class="overlay-service">{{ r.serviceType }}</div>
|
||||
<div class="gallery-meta">
|
||||
<span class="meta-chip">
|
||||
<AppIcon name="report" :size="11" color="#64748b" />
|
||||
<text>查看报告</text>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gallery-meta">
|
||||
<span class="meta-chip">
|
||||
<AppIcon name="report" :size="11" color="#64748b" />
|
||||
<text>查看报告</text>
|
||||
</span>
|
||||
</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
|
||||
const params = userInfo.role === 'boss'
|
||||
? { storeId: storeInfo.id, page: 1, pageSize: 50 }
|
||||
: { userId: userInfo.id, page: 1, pageSize: 50 }
|
||||
const res = await getReportList(params)
|
||||
loading.value = false
|
||||
if (res.code === 200) reportList.value = res.data
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
const openFillReport = () => {
|
||||
|
||||
272
src/pages/mine/PetHistory.vue
Normal file
272
src/pages/mine/PetHistory.vue
Normal 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>
|
||||
@ -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">
|
||||
<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>
|
||||
<!-- 技师情感卡片(与 H5 对齐) -->
|
||||
<view v-if="reportData.staffName || staffAvatarDisplay" class="staff-card-mp">
|
||||
<image
|
||||
v-if="staffAvatarDisplay"
|
||||
class="staff-avatar-mp"
|
||||
:src="staffAvatarDisplay"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view v-else class="staff-avatar-mp staff-avatar-mp--ph">
|
||||
<text class="staff-avatar-letter-mp">{{ staffInitial }}</text>
|
||||
</view>
|
||||
<view class="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 class="compare-block">
|
||||
<text class="compare-h2">服务前后对比</text>
|
||||
<BeforeAfterSlider :before-src="beforeMainUrl" :after-src="afterMainUrl" />
|
||||
<view
|
||||
v-if="beforePhotos.length > 1 || afterPhotos.length > 1"
|
||||
class="compare-more-hint"
|
||||
>
|
||||
<text>滑动对比主图;更多张数见下方补充相册</text>
|
||||
</view>
|
||||
</view>
|
||||
<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>
|
||||
|
||||
<!-- 过程素材九宫格(与 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: '加载中' })
|
||||
const res = await getReportByToken(token)
|
||||
uni.hideLoading()
|
||||
loading.value = false
|
||||
if (res.code === 200) {
|
||||
reportData.value = res.data
|
||||
maybeStartHighlightPoll()
|
||||
} else {
|
||||
notFound.value = true
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getReportByToken(token)
|
||||
if (res && res.code === 200 && res.data) {
|
||||
reportData.value = res.data
|
||||
maybeStartHighlightPoll()
|
||||
} 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
11
src/utils/apiResult.js
Normal 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 = '网络异常,请稍后再试'
|
||||
@ -1,5 +1,5 @@
|
||||
export const APPOINTMENT_STATUS_TEXT = {
|
||||
new: '待确认',
|
||||
new: '待开始',
|
||||
doing: '服务中',
|
||||
done: '已完成',
|
||||
cancel: '已取消'
|
||||
|
||||
@ -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-logo">{{ reportData.store?.name || '宠伴生活馆' }}</div>
|
||||
<div class="brand-sub">宠物服务,让爱更专业</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>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user